统一主站与AI游戏创作钱包状态
新增共享泥点钱包 Store 并合并并发刷新 按账号 owner 隔离快照并屏蔽切换期间的旧余额 中止并脱离旧账号请求以保障新账号立即刷新 统一主站、图片编辑器和 AI Game Creator 钱包数据源 补充账号切换、悬挂请求和焦点刷新回归测试 同步更新项目基线与共享决策记录
This commit is contained in:
@@ -17,9 +17,11 @@ import { useWalletStore } from '../../stores/useWalletStore';
|
||||
|
||||
export function useAccountWallet(currentUserId: string) {
|
||||
const {
|
||||
ownerUserId,
|
||||
mudPointBalance,
|
||||
mudPointBalanceStatus,
|
||||
mudPointBalanceError,
|
||||
setWalletOwner,
|
||||
applyWalletBalanceSnapshot,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
resetWalletBalance,
|
||||
@@ -41,32 +43,70 @@ export function useAccountWallet(currentUserId: string) {
|
||||
const [nativeRechargePayment, setNativeRechargePayment] =
|
||||
useState<PlatformProfileRechargeNativePaymentState | null>(null);
|
||||
const rechargeLifecycleRef = useRef(0);
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentUserId) && ownerUserId === currentUserId;
|
||||
const visibleMudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalance
|
||||
: null;
|
||||
const visibleMudPointBalanceStatus = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalanceStatus
|
||||
: 'idle';
|
||||
const visibleMudPointBalanceError = walletOwnerMatchesCurrentUser
|
||||
? mudPointBalanceError
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
resetWalletBalance();
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
setWalletOwner(currentUserId || null);
|
||||
if (currentUserId) {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}
|
||||
|
||||
const refreshWalletBalance = () => {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
if (currentUserId) {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', refreshWalletBalance);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshWalletBalance);
|
||||
};
|
||||
}, [currentUserId, onWalletBalanceMayHaveChanged, resetWalletBalance]);
|
||||
}, [currentUserId, onWalletBalanceMayHaveChanged, setWalletOwner]);
|
||||
|
||||
useEffect(() => {
|
||||
rechargeLifecycleRef.current += 1;
|
||||
setWalletLedgerOpen(false);
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerLoading(false);
|
||||
setWalletLedgerError(null);
|
||||
setRechargeOpen(false);
|
||||
setRechargeContent(null);
|
||||
setRechargeLoading(false);
|
||||
setRechargeError(null);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setNativeRechargePayment(null);
|
||||
}, [currentUserId]);
|
||||
|
||||
async function loadWalletLedger() {
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
setWalletLedgerLoading(true);
|
||||
setWalletLedgerError(null);
|
||||
try {
|
||||
setWalletLedger(await getClientProfileWalletLedger());
|
||||
const ledger = await getClientProfileWalletLedger();
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
setWalletLedger(ledger);
|
||||
}
|
||||
} catch (error) {
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
return;
|
||||
}
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerError(
|
||||
error instanceof Error ? error.message : '读取泥点账单失败',
|
||||
);
|
||||
} finally {
|
||||
setWalletLedgerLoading(false);
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
setWalletLedgerLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +115,10 @@ export function useAccountWallet(currentUserId: string) {
|
||||
void loadWalletLedger();
|
||||
}
|
||||
|
||||
function applyRechargeContent(center: ProfileRechargeCenterResponse) {
|
||||
function applyRechargeContent(
|
||||
snapshotOwnerUserId: string,
|
||||
center: ProfileRechargeCenterResponse,
|
||||
) {
|
||||
const {
|
||||
walletBalance,
|
||||
mudPointBalance: nextMudPointBalance,
|
||||
@@ -83,12 +126,15 @@ export function useAccountWallet(currentUserId: string) {
|
||||
} = center;
|
||||
void walletBalance;
|
||||
if (nextMudPointBalance) {
|
||||
applyWalletBalanceSnapshot(nextMudPointBalance);
|
||||
applyWalletBalanceSnapshot(snapshotOwnerUserId, nextMudPointBalance);
|
||||
} else {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}
|
||||
setRechargeContent(content);
|
||||
}
|
||||
|
||||
async function loadRechargeCenter() {
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
setRechargeLoading(true);
|
||||
setRechargeError(null);
|
||||
@@ -97,7 +143,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(center);
|
||||
applyRechargeContent(snapshotOwnerUserId, center);
|
||||
} catch (error) {
|
||||
if (rechargeLifecycleRef.current === rechargeLifecycle) {
|
||||
setRechargeError(
|
||||
@@ -130,6 +176,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
return;
|
||||
}
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
setSubmittingRechargeProductId(product.productId);
|
||||
setRechargeError(null);
|
||||
try {
|
||||
@@ -139,7 +186,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(response.center);
|
||||
applyRechargeContent(snapshotOwnerUserId, response.center);
|
||||
const nativePayment = response.wechatNativePayment;
|
||||
const codeUrl = nativePayment?.codeUrl?.trim();
|
||||
const expiresAt = nativePayment?.expiresAt?.trim();
|
||||
@@ -170,6 +217,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
return;
|
||||
}
|
||||
const rechargeLifecycle = rechargeLifecycleRef.current;
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
const orderId = nativeRechargePayment.orderId;
|
||||
setNativeRechargePayment((current) =>
|
||||
current?.orderId === orderId
|
||||
@@ -181,7 +229,7 @@ export function useAccountWallet(currentUserId: string) {
|
||||
if (rechargeLifecycleRef.current !== rechargeLifecycle) {
|
||||
return;
|
||||
}
|
||||
applyRechargeContent(response.center);
|
||||
applyRechargeContent(snapshotOwnerUserId, response.center);
|
||||
if (response.order.status === 'paid') {
|
||||
setNativeRechargePayment(null);
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
@@ -214,9 +262,10 @@ export function useAccountWallet(currentUserId: string) {
|
||||
}
|
||||
|
||||
return {
|
||||
mudPointBalance,
|
||||
mudPointBalanceStatus,
|
||||
mudPointBalanceError,
|
||||
ownerUserId,
|
||||
mudPointBalance: visibleMudPointBalance,
|
||||
mudPointBalanceStatus: visibleMudPointBalanceStatus,
|
||||
mudPointBalanceError: visibleMudPointBalanceError,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
resetWalletBalance,
|
||||
walletLedgerOpen,
|
||||
@@ -226,11 +275,11 @@ export function useAccountWallet(currentUserId: string) {
|
||||
walletLedgerError,
|
||||
rechargeOpen,
|
||||
rechargeModalCenter:
|
||||
rechargeContent && mudPointBalance
|
||||
rechargeContent && visibleMudPointBalance
|
||||
? {
|
||||
...rechargeContent,
|
||||
walletBalance: mudPointBalance.totalPoints,
|
||||
mudPointBalance,
|
||||
walletBalance: visibleMudPointBalance.totalPoints,
|
||||
mudPointBalance: visibleMudPointBalance,
|
||||
}
|
||||
: null,
|
||||
rechargeLoading,
|
||||
|
||||
@@ -137,10 +137,10 @@ export function getClientProfileDashboard() {
|
||||
);
|
||||
}
|
||||
|
||||
export function getClientProfileRechargeCenter() {
|
||||
export function getClientProfileRechargeCenter(signal?: AbortSignal) {
|
||||
return requestClientApi<ProfileRechargeCenterResponse>(
|
||||
'/api/profile/recharge-center',
|
||||
{ method: 'GET' },
|
||||
{ method: 'GET', signal },
|
||||
'读取泥点明细失败',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,100 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import type { ProfileMudPointBalance } from '../../../../packages/shared/src';
|
||||
import { createProfileWalletStore } from '../../../../packages/shared/src';
|
||||
import { getClientProfileRechargeCenter } from '../services/clientApi';
|
||||
|
||||
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
type WalletStore = {
|
||||
mudPointBalance: ProfileMudPointBalance | null;
|
||||
mudPointBalanceStatus: MudPointBalanceStatus;
|
||||
mudPointBalanceError: string;
|
||||
applyWalletBalanceSnapshot: (balance: ProfileMudPointBalance) => void;
|
||||
onWalletBalanceMayHaveChanged: () => Promise<void>;
|
||||
resetWalletBalance: () => void;
|
||||
};
|
||||
|
||||
const initialWalletState: Pick<
|
||||
WalletStore,
|
||||
'mudPointBalance' | 'mudPointBalanceStatus' | 'mudPointBalanceError'
|
||||
> = {
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'idle',
|
||||
mudPointBalanceError: '',
|
||||
};
|
||||
|
||||
let refreshVersion = 0;
|
||||
let requestGeneration = 0;
|
||||
let activeRefresh: Promise<void> | null = null;
|
||||
|
||||
export const useWalletStore = create<WalletStore>((set) => ({
|
||||
...initialWalletState,
|
||||
applyWalletBalanceSnapshot: (balance) => {
|
||||
requestGeneration += 1;
|
||||
refreshVersion = 0;
|
||||
activeRefresh = null;
|
||||
set({
|
||||
mudPointBalance: balance,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
},
|
||||
onWalletBalanceMayHaveChanged: () => {
|
||||
refreshVersion += 1;
|
||||
if (activeRefresh) {
|
||||
return activeRefresh;
|
||||
}
|
||||
|
||||
const refreshRequestGeneration = requestGeneration;
|
||||
const refresh = (async () => {
|
||||
while (refreshRequestGeneration === requestGeneration) {
|
||||
const requestedVersion = refreshVersion;
|
||||
set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' });
|
||||
try {
|
||||
const center = await getClientProfileRechargeCenter();
|
||||
if (!center.mudPointBalance) {
|
||||
throw new Error('充值中心响应缺少泥点余额');
|
||||
}
|
||||
if (refreshRequestGeneration !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
if (requestedVersion !== refreshVersion) {
|
||||
continue;
|
||||
}
|
||||
set({
|
||||
mudPointBalance: center.mudPointBalance,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
return;
|
||||
} catch (error) {
|
||||
if (refreshRequestGeneration !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
if (requestedVersion !== refreshVersion) {
|
||||
continue;
|
||||
}
|
||||
set({
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError:
|
||||
error instanceof Error ? error.message : '泥点明细读取失败',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
})();
|
||||
activeRefresh = refresh;
|
||||
void refresh.finally(() => {
|
||||
if (activeRefresh === refresh) {
|
||||
activeRefresh = null;
|
||||
}
|
||||
});
|
||||
return refresh;
|
||||
},
|
||||
resetWalletBalance: () => {
|
||||
requestGeneration += 1;
|
||||
refreshVersion = 0;
|
||||
activeRefresh = null;
|
||||
set(initialWalletState);
|
||||
},
|
||||
}));
|
||||
export const useWalletStore = createProfileWalletStore({
|
||||
getRechargeCenter: getClientProfileRechargeCenter,
|
||||
});
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { createElement } from 'react';
|
||||
import { renderToString } from 'react-dom/server';
|
||||
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const clientApi = vi.hoisted(() => ({
|
||||
confirmClientWechatProfileRechargeOrder: vi.fn(),
|
||||
createClientProfileRechargeOrder: vi.fn(),
|
||||
getClientProfileRechargeCenter: vi.fn(),
|
||||
getClientProfileWalletLedger: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../src/services/clientApi', () => clientApi);
|
||||
|
||||
import { useWalletStore } from '../src/stores/useWalletStore';
|
||||
import { useAccountWallet } from '../src/features/app-shell/useAccountWallet';
|
||||
|
||||
function detailedBalance(totalPoints: number) {
|
||||
return {
|
||||
@@ -26,6 +32,7 @@ function detailedBalance(totalPoints: number) {
|
||||
describe('useWalletStore', () => {
|
||||
beforeEach(() => {
|
||||
useWalletStore.getState().resetWalletBalance();
|
||||
useWalletStore.getState().setWalletOwner('user-a');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -50,7 +57,9 @@ describe('useWalletStore', () => {
|
||||
'mudPointBalanceError',
|
||||
'mudPointBalanceStatus',
|
||||
'onWalletBalanceMayHaveChanged',
|
||||
'ownerUserId',
|
||||
'resetWalletBalance',
|
||||
'setWalletOwner',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -61,7 +70,9 @@ describe('useWalletStore', () => {
|
||||
await useWalletStore.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
const mudPointBalance = detailedBalance(120);
|
||||
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
|
||||
useWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
|
||||
|
||||
expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance);
|
||||
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready');
|
||||
@@ -79,7 +90,9 @@ describe('useWalletStore', () => {
|
||||
|
||||
const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged();
|
||||
const mudPointBalance = detailedBalance(160);
|
||||
useWalletStore.getState().applyWalletBalanceSnapshot(mudPointBalance);
|
||||
useWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot('user-a', mudPointBalance);
|
||||
rejectRequest?.(new Error('过期请求失败'));
|
||||
await refresh;
|
||||
|
||||
@@ -179,4 +192,57 @@ describe('useWalletStore', () => {
|
||||
expect(useWalletStore.getState().mudPointBalance).toBeNull();
|
||||
expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle');
|
||||
});
|
||||
|
||||
test('clears immediately when the adapter switches wallet owners', () => {
|
||||
useWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
|
||||
|
||||
useWalletStore.getState().setWalletOwner('user-b');
|
||||
|
||||
expect(useWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-b',
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'idle',
|
||||
});
|
||||
});
|
||||
|
||||
test('masks another owner snapshot before the owner-binding effect runs', () => {
|
||||
useWalletStore
|
||||
.getState()
|
||||
.applyWalletBalanceSnapshot('user-a', detailedBalance(88));
|
||||
|
||||
function WalletProbe() {
|
||||
const wallet = useAccountWallet('user-b');
|
||||
return createElement(
|
||||
'span',
|
||||
null,
|
||||
wallet.mudPointBalance?.totalPoints ?? 'empty',
|
||||
);
|
||||
}
|
||||
|
||||
expect(renderToString(createElement(WalletProbe))).toContain('empty');
|
||||
expect(useWalletStore.getState().ownerUserId).toBe('user-a');
|
||||
});
|
||||
|
||||
test('binds the account owner and refreshes again when the window regains focus', async () => {
|
||||
clientApi.getClientProfileRechargeCenter.mockResolvedValue({
|
||||
walletBalance: 18,
|
||||
mudPointBalance: detailedBalance(18),
|
||||
});
|
||||
|
||||
renderHook(() => useAccountWallet('user-a'));
|
||||
await act(async () => undefined);
|
||||
expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
|
||||
expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2);
|
||||
expect(useWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-a',
|
||||
mudPointBalance: detailedBalance(18),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5931,3 +5931,12 @@
|
||||
- Agent 发现:新增公开 `agent-integration.json`、`skill/SKILL.md` 和 `skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
|
||||
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`。
|
||||
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`.codex/skills/genarrative-external-editor-api/SKILL.md`。
|
||||
|
||||
## 2026-08-03 主站与 AI Game Creator 复用单一泥点钱包 Store
|
||||
|
||||
- 背景:主站顶部优先读取 dashboard 总额,图片画板独立轮询 dashboard,充值 controller 和 AI Game Creator 又分别保存充值中心明细;不同请求返回时序不一致会让总额与分桶同时显示不同快照,快速生成或切换账号时旧响应还可能回滚余额。
|
||||
- 决策:在 `packages/shared` 提供依赖注入式 `createProfileWalletStore`,统一保存 `ownerUserId`、完整 `ProfileMudPointBalance`、读取状态与错误,并在每个实例的独立闭包内完成请求合并、尾随补读、generation 失效和 owner 校验。主站注入 `getPlatformProfileRechargeCenter`,AI Game Creator 注入 `getClientProfileRechargeCenter`;两端不共享 transport、认证或重试实现。主站顶部、图片画板顶部和“我的”统计只消费同一 Store 快照,总额固定取 `totalPoints`;dashboard 的 `walletBalance` 只保留后端兼容,不再作为钱包 UI 数据源。
|
||||
- 并发与账号边界:同一 owner generation 同时只执行一个余额读取;读取期间的新变化通知在当前请求结束后补读,直到覆盖最后一次通知。切换或退出账号立即清空快照、提升 generation、中止并脱离旧 generation 的 active 请求,新 owner 不得等待旧 transport 收束;不响应 abort 的旧 transport 可以在后台结束,但其结果必须忽略。消费端在 owner 绑定 effect 提交前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,不能让旧余额与新账号身份同屏。充值中心读取或 mutation 响应必须携带请求开始时捕获的 owner,owner 不符时忽略。刷新失败保留已有快照;响应缺少 `mudPointBalance` 时进入错误状态,不用总额反推分桶。
|
||||
- UI 与 mutation:充值 controller 继续保存商品、订单和支付状态,但充值中心响应必须同步写入共享快照,余额 mutation 应在应用响应后再通知一次合并刷新。充值弹窗的余额和分桶由当前 Store 快照覆盖。生成完成、失败退款、兑换码和邀请奖励等事件只发送余额可能变化通知;账号变化同时清理旧充值中心、账单与支付临时状态。`limitedPoints` 只按既有后端快照原样保存,本决策不新增或调整会员限时泥点展示与结算。
|
||||
- 验证:共享 Store 覆盖首次读取、尾随补读、旧响应、账号切换、错误保留和错误 owner;主站覆盖 dashboard 与充值中心不一致时三处 UI 仍一致、切换账号清空及 focus 刷新;AI Game Creator 覆盖 adapter、账号 owner 和 focus 刷新。运行定向 Vitest、两端类型检查、编码检查与 `git diff --check`。
|
||||
- 关联文档:`docs/【项目基线】当前产品与工程约束-2026-05-15.md`。
|
||||
|
||||
@@ -59,7 +59,7 @@ Genarrative / 陶泥儿是一个 AI 原生互动内容与小游戏平台。当
|
||||
|
||||
## 账户与充值
|
||||
|
||||
1. 主站和图片画板统一使用公共泥点资产入口。收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及“每天重置为 20 泥点”,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
|
||||
1. 主站、图片画板和 AI Game Creator 统一使用 `packages/shared` 的依赖注入式钱包 Zustand Store;主站与 Tauri 客户端只保留各自的 URL、认证和重试 transport adapter。主站顶部、图片画板顶部和“我的”统计必须消费同一份 `ProfileMudPointBalance` 快照,泥点总额固定取 `totalPoints`,不得混用 dashboard 的 `walletBalance`。切换或退出账号必须立即清空快照并拒绝旧账号在途响应;消费端在 owner 绑定 effect 生效前也必须按当前用户 ID 同步屏蔽 owner 不匹配的快照,旧账号请求不得阻塞新账号首次读取。生成、退款、充值、兑换码等余额可能变化事件只通知 Store 合并刷新。公共泥点资产入口收起态展示“泥点图标 + 泥点总额 | 充值”;桌面端通过 hover / focus 展开,移动端通过点击展开。展开态只展示不限时泥点、每日免费泥点及后端返回的每日重置额度,并提供“使用详情”入口;余额都以后端充值中心 read model 为准,前端不得自行相减推算。
|
||||
2. 账户充值弹窗标题统一为“购买更多泥点”,当前版本只展示泥点商品,不展示会员页签、会员商品、购买会员或升级会员入口。底层会员数据与周期刷新能力继续保留用于存量兼容和结算,会员周期限时泥点不在当前版本前台展示。
|
||||
3. 泥点默认商品固定为四档:`60 泥点 / ¥6`、`180 + 90 泥点 / ¥18`、`300 + 150 泥点 / ¥30`、`680 + 340 泥点 / ¥68`。`60` 档不加赠,后三档首次购买各加赠基础泥点的 `50%`;实际展示、下单校验和支付确认仍以后端返回的充值商品配置为准。
|
||||
4. 首充加赠资格按泥点商品档位独立计算。用户买过 `points_180` 后,只影响 `points_180` 的首充展示和结算,其它未购买档位仍保留各自首充加赠资格。
|
||||
|
||||
@@ -50,5 +50,6 @@ export type * from './contracts/visualNovel';
|
||||
export * from './http';
|
||||
export * from './llm/narrativeLanguage';
|
||||
export * from './llm/parsers';
|
||||
export * from './stores/createProfileWalletStore';
|
||||
|
||||
// should not export component, instead, they should be ref by relative path
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type {
|
||||
ProfileMudPointBalance,
|
||||
ProfileRechargeCenterResponse,
|
||||
} from '../contracts/runtime';
|
||||
import { createProfileWalletStore } from './createProfileWalletStore';
|
||||
|
||||
function balance(totalPoints: number): ProfileMudPointBalance {
|
||||
return {
|
||||
totalPoints,
|
||||
permanentPoints: totalPoints,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
};
|
||||
}
|
||||
|
||||
function center(totalPoints: number): ProfileRechargeCenterResponse {
|
||||
return { mudPointBalance: balance(totalPoints) } as ProfileRechargeCenterResponse;
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('createProfileWalletStore', () => {
|
||||
test('reads the first complete wallet snapshot for its owner', async () => {
|
||||
const getRechargeCenter = vi.fn().mockResolvedValue(center(42));
|
||||
const store = createProfileWalletStore({ getRechargeCenter });
|
||||
|
||||
store.getState().setWalletOwner('user-a');
|
||||
await store.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
expect(store.getState()).toMatchObject({
|
||||
ownerUserId: 'user-a',
|
||||
mudPointBalance: balance(42),
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('merges concurrent notifications into one request', async () => {
|
||||
const pending = deferred<ProfileRechargeCenterResponse>();
|
||||
const getRechargeCenter = vi.fn(() => pending.promise);
|
||||
const store = createProfileWalletStore({ getRechargeCenter });
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
const first = store.getState().onWalletBalanceMayHaveChanged();
|
||||
const second = store.getState().onWalletBalanceMayHaveChanged();
|
||||
expect(first).toBe(second);
|
||||
expect(getRechargeCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
pending.resolve(center(12));
|
||||
await first;
|
||||
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('reads again when balance changes during an active request', async () => {
|
||||
const first = deferred<ProfileRechargeCenterResponse>();
|
||||
const getRechargeCenter = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => first.promise)
|
||||
.mockResolvedValueOnce(center(30));
|
||||
const store = createProfileWalletStore({ getRechargeCenter });
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
const refresh = store.getState().onWalletBalanceMayHaveChanged();
|
||||
void store.getState().onWalletBalanceMayHaveChanged();
|
||||
first.resolve(center(99));
|
||||
await refresh;
|
||||
|
||||
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(30);
|
||||
});
|
||||
|
||||
test('does not let an older read overwrite an applied mutation snapshot', async () => {
|
||||
const pending = deferred<ProfileRechargeCenterResponse>();
|
||||
const store = createProfileWalletStore({
|
||||
getRechargeCenter: vi.fn(() => pending.promise),
|
||||
});
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
const refresh = store.getState().onWalletBalanceMayHaveChanged();
|
||||
store.getState().applyWalletBalanceSnapshot('user-a', balance(80));
|
||||
pending.resolve(center(20));
|
||||
await refresh;
|
||||
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(80);
|
||||
});
|
||||
|
||||
test('refreshes the new owner without waiting for a stalled old-owner request', async () => {
|
||||
const oldOwner = deferred<ProfileRechargeCenterResponse>();
|
||||
const newOwner = deferred<ProfileRechargeCenterResponse>();
|
||||
const requestSignals: Array<AbortSignal | undefined> = [];
|
||||
const getRechargeCenter = vi
|
||||
.fn()
|
||||
.mockImplementationOnce((signal?: AbortSignal) => {
|
||||
requestSignals.push(signal);
|
||||
return oldOwner.promise;
|
||||
})
|
||||
.mockImplementationOnce((signal?: AbortSignal) => {
|
||||
requestSignals.push(signal);
|
||||
return newOwner.promise;
|
||||
});
|
||||
const store = createProfileWalletStore({ getRechargeCenter });
|
||||
store.getState().setWalletOwner('user-a');
|
||||
store.getState().applyWalletBalanceSnapshot('user-a', balance(50));
|
||||
|
||||
const oldRefresh = store.getState().onWalletBalanceMayHaveChanged();
|
||||
store.getState().setWalletOwner('user-b');
|
||||
expect(store.getState()).toMatchObject({
|
||||
ownerUserId: 'user-b',
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'idle',
|
||||
});
|
||||
const newRefresh = store.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(getRechargeCenter).toHaveBeenCalledTimes(2);
|
||||
expect(newRefresh).not.toBe(oldRefresh);
|
||||
expect(requestSignals[0]?.aborted).toBe(true);
|
||||
expect(requestSignals[1]?.aborted).toBe(false);
|
||||
newOwner.resolve(center(7));
|
||||
await newRefresh;
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
|
||||
|
||||
oldOwner.resolve(center(60));
|
||||
await oldRefresh;
|
||||
expect(store.getState().mudPointBalance?.totalPoints).toBe(7);
|
||||
});
|
||||
|
||||
test('keeps an existing snapshot when refresh fails', async () => {
|
||||
const store = createProfileWalletStore({
|
||||
getRechargeCenter: vi.fn().mockRejectedValue(new Error('network down')),
|
||||
});
|
||||
store.getState().setWalletOwner('user-a');
|
||||
store.getState().applyWalletBalanceSnapshot('user-a', balance(55));
|
||||
|
||||
await store.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
mudPointBalance: balance(55),
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError: 'network down',
|
||||
});
|
||||
});
|
||||
|
||||
test('treats a recharge response without the balance breakdown as an error', async () => {
|
||||
const store = createProfileWalletStore({
|
||||
getRechargeCenter: vi
|
||||
.fn()
|
||||
.mockResolvedValue({} as ProfileRechargeCenterResponse),
|
||||
});
|
||||
store.getState().setWalletOwner('user-a');
|
||||
|
||||
await store.getState().onWalletBalanceMayHaveChanged();
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError: '充值中心响应缺少泥点余额',
|
||||
});
|
||||
});
|
||||
|
||||
test('ignores a snapshot captured for a different owner', () => {
|
||||
const store = createProfileWalletStore({
|
||||
getRechargeCenter: vi.fn(),
|
||||
});
|
||||
store.getState().setWalletOwner('user-b');
|
||||
|
||||
store.getState().applyWalletBalanceSnapshot('user-a', balance(100));
|
||||
|
||||
expect(store.getState().mudPointBalance).toBeNull();
|
||||
expect(store.getState().mudPointBalanceStatus).toBe('idle');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand';
|
||||
|
||||
import type {
|
||||
ProfileMudPointBalance,
|
||||
ProfileRechargeCenterResponse,
|
||||
} from '../contracts/runtime';
|
||||
|
||||
export type MudPointBalanceStatus = 'idle' | 'loading' | 'ready' | 'error';
|
||||
|
||||
export type ProfileWalletApi = {
|
||||
getRechargeCenter(signal?: AbortSignal): Promise<ProfileRechargeCenterResponse>;
|
||||
};
|
||||
|
||||
export type ProfileWalletStore = {
|
||||
ownerUserId: string | null;
|
||||
mudPointBalance: ProfileMudPointBalance | null;
|
||||
mudPointBalanceStatus: MudPointBalanceStatus;
|
||||
mudPointBalanceError: string;
|
||||
setWalletOwner: (userId: string | null) => void;
|
||||
applyWalletBalanceSnapshot: (
|
||||
ownerUserId: string,
|
||||
balance: ProfileMudPointBalance,
|
||||
) => void;
|
||||
onWalletBalanceMayHaveChanged: () => Promise<void>;
|
||||
resetWalletBalance: () => void;
|
||||
};
|
||||
|
||||
const EMPTY_WALLET_STATE = {
|
||||
mudPointBalance: null,
|
||||
mudPointBalanceStatus: 'idle',
|
||||
mudPointBalanceError: '',
|
||||
} as const;
|
||||
|
||||
export function createProfileWalletStore(
|
||||
api: ProfileWalletApi,
|
||||
): UseBoundStore<StoreApi<ProfileWalletStore>> {
|
||||
let refreshVersion = 0;
|
||||
let settledRefreshVersion = 0;
|
||||
let requestGeneration = 0;
|
||||
let activeRefresh: Promise<void> | null = null;
|
||||
let activeAbortController: AbortController | null = null;
|
||||
|
||||
const invalidateActiveRefresh = () => {
|
||||
requestGeneration += 1;
|
||||
activeAbortController?.abort();
|
||||
activeAbortController = null;
|
||||
activeRefresh = null;
|
||||
};
|
||||
|
||||
return create<ProfileWalletStore>((set, get) => ({
|
||||
ownerUserId: null,
|
||||
...EMPTY_WALLET_STATE,
|
||||
setWalletOwner: (userId) => {
|
||||
const normalizedUserId = userId?.trim() || null;
|
||||
if (get().ownerUserId === normalizedUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateActiveRefresh();
|
||||
settledRefreshVersion = refreshVersion;
|
||||
set({
|
||||
ownerUserId: normalizedUserId,
|
||||
...EMPTY_WALLET_STATE,
|
||||
});
|
||||
},
|
||||
applyWalletBalanceSnapshot: (ownerUserId, balance) => {
|
||||
if (!ownerUserId || get().ownerUserId !== ownerUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
invalidateActiveRefresh();
|
||||
settledRefreshVersion = refreshVersion;
|
||||
set({
|
||||
mudPointBalance: balance,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
},
|
||||
onWalletBalanceMayHaveChanged: () => {
|
||||
refreshVersion += 1;
|
||||
if (activeRefresh) {
|
||||
return activeRefresh;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
const refresh = (async () => {
|
||||
while (settledRefreshVersion < refreshVersion) {
|
||||
const ownerUserId = get().ownerUserId;
|
||||
const requestedVersion = refreshVersion;
|
||||
const generation = requestGeneration;
|
||||
if (!ownerUserId) {
|
||||
settledRefreshVersion = requestedVersion;
|
||||
return;
|
||||
}
|
||||
|
||||
set({ mudPointBalanceStatus: 'loading', mudPointBalanceError: '' });
|
||||
try {
|
||||
const center = await api.getRechargeCenter(abortController.signal);
|
||||
if (generation !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
if (requestedVersion !== refreshVersion) {
|
||||
settledRefreshVersion = requestedVersion;
|
||||
continue;
|
||||
}
|
||||
if (!center.mudPointBalance) {
|
||||
throw new Error('充值中心响应缺少泥点余额');
|
||||
}
|
||||
|
||||
settledRefreshVersion = requestedVersion;
|
||||
set({
|
||||
mudPointBalance: center.mudPointBalance,
|
||||
mudPointBalanceStatus: 'ready',
|
||||
mudPointBalanceError: '',
|
||||
});
|
||||
} catch (error) {
|
||||
if (generation !== requestGeneration) {
|
||||
return;
|
||||
}
|
||||
if (requestedVersion !== refreshVersion) {
|
||||
settledRefreshVersion = requestedVersion;
|
||||
continue;
|
||||
}
|
||||
|
||||
settledRefreshVersion = requestedVersion;
|
||||
set({
|
||||
mudPointBalanceStatus: 'error',
|
||||
mudPointBalanceError:
|
||||
error instanceof Error ? error.message : '泥点明细读取失败',
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
activeRefresh = refresh;
|
||||
activeAbortController = abortController;
|
||||
void refresh.finally(() => {
|
||||
if (activeRefresh === refresh) {
|
||||
activeRefresh = null;
|
||||
}
|
||||
if (activeAbortController === abortController) {
|
||||
activeAbortController = null;
|
||||
}
|
||||
});
|
||||
return refresh;
|
||||
},
|
||||
resetWalletBalance: () => {
|
||||
invalidateActiveRefresh();
|
||||
settledRefreshVersion = refreshVersion;
|
||||
set({ ownerUserId: null, ...EMPTY_WALLET_STATE });
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import JSZip from 'jszip';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { EditorProjectResourceSnapshot } from '../../services/image-editor/editorProjectClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import type { EditorAgentConversationClient } from './EditorAgentConversation/useEditorAgentConversation';
|
||||
import {
|
||||
ApiClientError,
|
||||
@@ -295,6 +296,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
loadFrontendRuntimeConfigMock.mockImplementation(() =>
|
||||
immediateAsync({
|
||||
imageEditorAgentSidebarEnabled: false,
|
||||
@@ -594,6 +596,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
});
|
||||
|
||||
it('shows the live mud point balance in the canvas topbar when logged in', async () => {
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-1');
|
||||
usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', {
|
||||
totalPoints: 1234,
|
||||
permanentPoints: 1000,
|
||||
limitedPoints: 214,
|
||||
limitedExpiresAt: '2026-07-31T16:00:00Z',
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
@@ -615,12 +627,7 @@ describe('ImageCanvasEditorView', () => {
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点 1,234')).toBeTruthy();
|
||||
expect(getPlatformProfileDashboardMock).toHaveBeenCalledWith({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
});
|
||||
expect(getPlatformProfileDashboardMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the account modal from the canvas topbar avatar entry', async () => {
|
||||
@@ -661,6 +668,16 @@ describe('ImageCanvasEditorView', () => {
|
||||
|
||||
it('opens the shared wallet breakdown and ledger from the canvas topbar', async () => {
|
||||
const user = userEvent.setup();
|
||||
usePlatformWalletStore.getState().setWalletOwner('user-1');
|
||||
usePlatformWalletStore.getState().applyWalletBalanceSnapshot('user-1', {
|
||||
totalPoints: 1234,
|
||||
permanentPoints: 1000,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 234,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-07-12T16:00:00Z',
|
||||
});
|
||||
render(
|
||||
<AuthUiContext.Provider
|
||||
value={createAuthValue({
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
loadEditorProject,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
|
||||
import { PlatformProfileRewardCodeRedeemModal } from '../platform-entry/PlatformProfileRewardCodeRedeemModal';
|
||||
@@ -300,8 +300,36 @@ export function ImageCanvasEditorView({
|
||||
}: ImageCanvasEditorViewProps = {}) {
|
||||
const authUi = useAuthUi();
|
||||
const [, setGenerationPricingVersion] = useState(0);
|
||||
const [walletBalance, setWalletBalance] = useState<number | null>(null);
|
||||
const [isWalletBalanceLoading, setIsWalletBalanceLoading] = useState(false);
|
||||
const walletOwnerUserId = usePlatformWalletStore(
|
||||
(state) => state.ownerUserId,
|
||||
);
|
||||
const storedMudPointBalance = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalance,
|
||||
);
|
||||
const storedMudPointBalanceStatus = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceStatus,
|
||||
);
|
||||
const storedMudPointBalanceError = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceError,
|
||||
);
|
||||
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
|
||||
(state) => state.onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
const currentWalletOwnerUserId =
|
||||
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentWalletOwnerUserId) &&
|
||||
walletOwnerUserId === currentWalletOwnerUserId;
|
||||
const mudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalance
|
||||
: null;
|
||||
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalanceStatus
|
||||
: 'idle';
|
||||
const mudPointBalanceError = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalanceError
|
||||
: '';
|
||||
const walletBalance = mudPointBalance?.totalPoints ?? null;
|
||||
const editorRootRef = useRef<HTMLElement | null>(null);
|
||||
const canvasViewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const assetListRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -489,21 +517,6 @@ export function ImageCanvasEditorView({
|
||||
},
|
||||
[],
|
||||
);
|
||||
const refreshEditorWalletBalance = useCallback(() => {
|
||||
if (!authUiRef.current?.canAccessProtectedData || !authUiRef.current.user) {
|
||||
return;
|
||||
}
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const {
|
||||
buyRechargeProduct,
|
||||
closeNativeWechatPayment,
|
||||
@@ -539,14 +552,12 @@ export function ImageCanvasEditorView({
|
||||
activeTab: 'editor-canvas',
|
||||
isAuthenticated: Boolean(authUi?.user),
|
||||
showRechargeEntry,
|
||||
onRechargeSuccess: refreshEditorWalletBalance,
|
||||
requestLogin: () => authUiRef.current?.openLoginModal(),
|
||||
currentUser: authUi?.user ?? null,
|
||||
});
|
||||
const refreshEditorWalletState = useCallback(() => {
|
||||
refreshEditorWalletBalance();
|
||||
loadRechargeCenter();
|
||||
}, [loadRechargeCenter, refreshEditorWalletBalance]);
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}, [onWalletBalanceMayHaveChanged]);
|
||||
const isAccountPaymentModalOpen =
|
||||
isRewardCodeOpen ||
|
||||
isRechargeOpen ||
|
||||
@@ -568,66 +579,6 @@ export function ImageCanvasEditorView({
|
||||
window.location.reload();
|
||||
});
|
||||
}, [authUi]);
|
||||
useEffect(() => {
|
||||
if (!authUi?.canAccessProtectedData || !authUi.user?.id) {
|
||||
setWalletBalance(null);
|
||||
setIsWalletBalanceLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let isMounted = true;
|
||||
let requestId = 0;
|
||||
|
||||
const refreshWalletBalance = () => {
|
||||
const currentRequestId = requestId + 1;
|
||||
requestId = currentRequestId;
|
||||
setIsWalletBalanceLoading(true);
|
||||
void getPlatformProfileDashboard({
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
notifyAuthStateChange: false,
|
||||
clearAuthOnUnauthorized: false,
|
||||
})
|
||||
.then((dashboard) => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalance(dashboard.walletBalance);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setWalletBalance(null);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!isMounted || currentRequestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
setIsWalletBalanceLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
refreshWalletBalance();
|
||||
|
||||
const handleWindowFocus = () => {
|
||||
refreshWalletBalance();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshWalletBalance();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', handleWindowFocus);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.removeEventListener('focus', handleWindowFocus);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [authUi?.canAccessProtectedData, authUi?.user?.id]);
|
||||
const {
|
||||
projectTitle,
|
||||
setProjectTitle,
|
||||
@@ -2317,10 +2268,10 @@ export function ImageCanvasEditorView({
|
||||
projectRenameError,
|
||||
layers,
|
||||
walletBalance,
|
||||
walletBreakdown: rechargeCenter?.mudPointBalance ?? null,
|
||||
isWalletBalanceLoading,
|
||||
walletBreakdown: mudPointBalance,
|
||||
isWalletBalanceLoading: mudPointBalanceStatus === 'loading',
|
||||
isWalletDetailsLoading: isLoadingRechargeCenter,
|
||||
walletDetailsError: rechargeError,
|
||||
walletDetailsError: rechargeError || mudPointBalanceError || null,
|
||||
currentUser: authUi?.user,
|
||||
assetExportStatus,
|
||||
isExportingAssets,
|
||||
|
||||
@@ -48,6 +48,8 @@ describe('PlatformActiveProfileView', () => {
|
||||
{...callbacks}
|
||||
dashboard={null}
|
||||
isLoadingDashboard={false}
|
||||
isLoadingWalletBalance={false}
|
||||
mudPointBalance={null}
|
||||
user={null}
|
||||
/>,
|
||||
);
|
||||
@@ -68,6 +70,16 @@ describe('PlatformActiveProfileView', () => {
|
||||
updatedAt: '2026-07-18T00:00:00.000Z',
|
||||
}}
|
||||
isLoadingDashboard={false}
|
||||
isLoadingWalletBalance={false}
|
||||
mudPointBalance={{
|
||||
totalPoints: 108,
|
||||
permanentPoints: 88,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
}}
|
||||
user={authenticatedUser}
|
||||
/>,
|
||||
);
|
||||
@@ -95,6 +107,8 @@ describe('PlatformActiveProfileView', () => {
|
||||
{...callbacks}
|
||||
dashboard={null}
|
||||
isLoadingDashboard={false}
|
||||
isLoadingWalletBalance={false}
|
||||
mudPointBalance={null}
|
||||
user={authenticatedUser}
|
||||
/>,
|
||||
);
|
||||
@@ -127,6 +141,8 @@ describe('PlatformActiveProfileView', () => {
|
||||
{...callbacks}
|
||||
dashboard={null}
|
||||
isLoadingDashboard={false}
|
||||
isLoadingWalletBalance={false}
|
||||
mudPointBalance={null}
|
||||
user={authenticatedUser}
|
||||
/>,
|
||||
);
|
||||
@@ -189,6 +205,8 @@ describe('PlatformActiveProfileView', () => {
|
||||
{...callbacks}
|
||||
dashboard={null}
|
||||
isLoadingDashboard={false}
|
||||
isLoadingWalletBalance={false}
|
||||
mudPointBalance={null}
|
||||
user={authenticatedUser}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
import type { AuthUser } from '@/packages/shared/src';
|
||||
import type {
|
||||
ProfileDashboardSummary,
|
||||
ProfileMudPointBalance,
|
||||
} from '@/packages/shared/src';
|
||||
|
||||
import profileClockImage from '../../../media/profile/_Image (1).png';
|
||||
import profileGamepadImage from '../../../media/profile/_Image (2).png';
|
||||
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
|
||||
@@ -20,8 +26,6 @@ import profileCommunityImage from '../../../media/profile/_Image (7).png';
|
||||
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
|
||||
import profileMascotImage from '../../../media/profile/_Image (9).png';
|
||||
import profilePointImage from '../../../media/profile/_Image.png';
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import type { ProfileDashboardSummary } from '../../../packages/shared/src/contracts/runtime';
|
||||
import { updateAuthProfile } from '../../services/authService';
|
||||
import {
|
||||
canUseNativeHostCapability,
|
||||
@@ -58,6 +62,8 @@ import {
|
||||
type PlatformActiveProfileViewProps = {
|
||||
dashboard: ProfileDashboardSummary | null;
|
||||
isLoadingDashboard: boolean;
|
||||
isLoadingWalletBalance: boolean;
|
||||
mudPointBalance: ProfileMudPointBalance | null;
|
||||
onLogin: () => void;
|
||||
onOpenApiKeys: () => void;
|
||||
onOpenCommunity: () => void;
|
||||
@@ -247,6 +253,8 @@ function formatTotalPlayTime(value: number) {
|
||||
export function PlatformActiveProfileView({
|
||||
dashboard,
|
||||
isLoadingDashboard,
|
||||
isLoadingWalletBalance,
|
||||
mudPointBalance,
|
||||
onLogin,
|
||||
onOpenApiKeys,
|
||||
onOpenCommunity,
|
||||
@@ -540,9 +548,11 @@ export function PlatformActiveProfileView({
|
||||
cardKey="wallet"
|
||||
label="泥点余额"
|
||||
value={
|
||||
dashboard
|
||||
? formatDashboardCount(dashboard.walletBalance)
|
||||
: '暂不可用'
|
||||
mudPointBalance
|
||||
? formatDashboardCount(mudPointBalance.totalPoints)
|
||||
: isLoadingWalletBalance
|
||||
? '读取中'
|
||||
: '暂不可用'
|
||||
}
|
||||
icon={Coins}
|
||||
imageSrc={profilePointImage}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
|
||||
import type { SelectionStage } from './platformEntryActiveTypes';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
|
||||
const authUiMock = vi.hoisted(() => ({
|
||||
value: {
|
||||
user: null,
|
||||
user: null as AuthUser | null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
@@ -21,6 +23,11 @@ const responsiveMock = vi.hoisted(() => ({
|
||||
isDesktopLayout: true,
|
||||
}));
|
||||
|
||||
const profileClientMock = vi.hoisted(() => ({
|
||||
getPlatformProfileDashboard: vi.fn(),
|
||||
getPlatformProfileRechargeCenter: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../auth/AuthUiContext', () => ({
|
||||
useAuthUi: () => authUiMock.value,
|
||||
}));
|
||||
@@ -83,9 +90,9 @@ vi.mock('../image-editor/ImageCanvasEditorView', () => ({
|
||||
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
|
||||
getPlatformProfileDashboard: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/platform-entry/platformProfileClient', () =>
|
||||
profileClientMock,
|
||||
);
|
||||
|
||||
vi.mock('./usePlatformProfileCenterController', () => ({
|
||||
usePlatformProfileCenterController: () => ({
|
||||
@@ -130,10 +137,179 @@ function StatefulPlatformEntryFlowShell({
|
||||
describe('PlatformEntryActiveFlowShell', () => {
|
||||
beforeEach(() => {
|
||||
window.history.replaceState(null, '', '/creation');
|
||||
usePlatformWalletStore.getState().resetWalletBalance();
|
||||
profileClientMock.getPlatformProfileDashboard.mockReset();
|
||||
profileClientMock.getPlatformProfileRechargeCenter.mockReset();
|
||||
authUiMock.value.user = null;
|
||||
authUiMock.value.canAccessProtectedData = false;
|
||||
authUiMock.value.openLoginModal.mockReset();
|
||||
responsiveMock.isDesktopLayout = true;
|
||||
});
|
||||
|
||||
it('uses one recharge-center snapshot for the topbar and profile wallet when dashboard differs', async () => {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
profileClientMock.getPlatformProfileDashboard.mockResolvedValue({
|
||||
walletBalance: 999,
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
});
|
||||
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
||||
walletBalance: 888,
|
||||
mudPointBalance: {
|
||||
totalPoints: 207,
|
||||
permanentPoints: 180,
|
||||
limitedPoints: 7,
|
||||
limitedExpiresAt: '2026-08-31T16:00:00Z',
|
||||
dailyFreePoints: 20,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('泥点 207')).toBeTruthy();
|
||||
|
||||
rerender(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="profile"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '泥点余额 207' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText('999')).toBeNull();
|
||||
});
|
||||
|
||||
it('clears the previous wallet synchronously when the authenticated account changes', async () => {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '用户一',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
|
||||
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
||||
mudPointBalance: {
|
||||
totalPoints: 66,
|
||||
permanentPoints: 66,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
},
|
||||
});
|
||||
const { rerender } = render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText('泥点 66');
|
||||
|
||||
let resolveNextOwner!: (value: unknown) => void;
|
||||
profileClientMock.getPlatformProfileRechargeCenter.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveNextOwner = resolve;
|
||||
}),
|
||||
);
|
||||
authUiMock.value.user = { ...authUiMock.value.user, id: 'user-2' };
|
||||
rerender(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText('泥点 66')).toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(usePlatformWalletStore.getState()).toMatchObject({
|
||||
ownerUserId: 'user-2',
|
||||
mudPointBalance: null,
|
||||
});
|
||||
});
|
||||
resolveNextOwner({
|
||||
mudPointBalance: {
|
||||
totalPoints: 67,
|
||||
permanentPoints: 67,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
},
|
||||
});
|
||||
await screen.findByLabelText('泥点 67');
|
||||
});
|
||||
|
||||
it('refreshes the shared wallet when the authenticated page regains focus', async () => {
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
profileClientMock.getPlatformProfileDashboard.mockResolvedValue(null);
|
||||
profileClientMock.getPlatformProfileRechargeCenter.mockResolvedValue({
|
||||
mudPointBalance: {
|
||||
totalPoints: 10,
|
||||
permanentPoints: 10,
|
||||
limitedPoints: 0,
|
||||
limitedExpiresAt: null,
|
||||
dailyFreePoints: 0,
|
||||
dailyFreeResetPoints: 20,
|
||||
dailyFreeResetsAt: '2026-08-04T00:00:00+08:00',
|
||||
},
|
||||
});
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creation-home"
|
||||
setSelectionStage={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await screen.findByLabelText('泥点 10');
|
||||
|
||||
await act(async () => {
|
||||
window.dispatchEvent(new Event('focus'));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
profileClientMock.getPlatformProfileRechargeCenter,
|
||||
).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the active desktop rail and the shared account capsule', async () => {
|
||||
const setSelectionStage = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
|
||||
@@ -25,6 +25,10 @@ import {
|
||||
replaceAppHistoryPath,
|
||||
} from '../../routing/activeAppPageRoutes';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import {
|
||||
usePlatformWalletLifecycle,
|
||||
usePlatformWalletStore,
|
||||
} from '../../stores/usePlatformWalletStore';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel';
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
@@ -224,6 +228,36 @@ export function PlatformEntryFlowShellImpl({
|
||||
const [isMobileDesktopGuideOpen, setIsMobileDesktopGuideOpen] =
|
||||
useState(false);
|
||||
const isDesktopLayout = usePlatformDesktopLayout();
|
||||
const currentWalletOwnerUserId =
|
||||
authUi?.canAccessProtectedData && authUi.user?.id ? authUi.user.id : null;
|
||||
usePlatformWalletLifecycle(
|
||||
currentWalletOwnerUserId,
|
||||
Boolean(authUi?.canAccessProtectedData),
|
||||
);
|
||||
const walletOwnerUserId = usePlatformWalletStore(
|
||||
(state) => state.ownerUserId,
|
||||
);
|
||||
const storedMudPointBalance = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalance,
|
||||
);
|
||||
const storedMudPointBalanceStatus = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceStatus,
|
||||
);
|
||||
const storedMudPointBalanceError = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalanceError,
|
||||
);
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentWalletOwnerUserId) &&
|
||||
walletOwnerUserId === currentWalletOwnerUserId;
|
||||
const mudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalance
|
||||
: null;
|
||||
const mudPointBalanceStatus = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalanceStatus
|
||||
: 'idle';
|
||||
const mudPointBalanceError = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalanceError
|
||||
: '';
|
||||
|
||||
const refreshDashboard = useCallback(async () => {
|
||||
if (!authUi?.user || !authUi.canAccessProtectedData) {
|
||||
@@ -250,7 +284,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
activeTab: isProfileStage ? 'profile' : 'project',
|
||||
isAuthenticated: Boolean(authUi?.user),
|
||||
showRechargeEntry: true,
|
||||
onRechargeSuccess: refreshDashboard,
|
||||
requestLogin: () => authUi?.openLoginModal(),
|
||||
currentUser: authUi?.user,
|
||||
});
|
||||
@@ -337,11 +370,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
|
||||
const isAuthenticated = Boolean(authUi?.user);
|
||||
const balance =
|
||||
dashboard?.walletBalance ??
|
||||
profileCenter.rechargeCenter?.mudPointBalance?.totalPoints ??
|
||||
profileCenter.rechargeCenter?.walletBalance ??
|
||||
null;
|
||||
const balance = mudPointBalance?.totalPoints ?? null;
|
||||
const isCreationStage =
|
||||
!isProfileStage &&
|
||||
(selectionStage === 'platform' || selectionStage === 'creation-home');
|
||||
@@ -455,14 +484,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformMudPointWalletEntry
|
||||
variant={isDesktopLayout ? 'desktop' : 'mobile'}
|
||||
balance={balance}
|
||||
breakdown={
|
||||
profileCenter.rechargeCenter?.mudPointBalance ?? null
|
||||
}
|
||||
isLoading={
|
||||
isLoadingDashboard ||
|
||||
profileCenter.isLoadingRechargeCenter
|
||||
}
|
||||
error={profileCenter.rechargeError}
|
||||
breakdown={mudPointBalance}
|
||||
isLoading={mudPointBalanceStatus === 'loading'}
|
||||
error={mudPointBalanceError || null}
|
||||
className={
|
||||
isDesktopLayout
|
||||
? 'platform-desktop-create-wallet-chip'
|
||||
@@ -515,6 +539,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformActiveProfileView
|
||||
dashboard={dashboard}
|
||||
isLoadingDashboard={isLoadingDashboard}
|
||||
isLoadingWalletBalance={
|
||||
mudPointBalanceStatus === 'loading'
|
||||
}
|
||||
mudPointBalance={mudPointBalance}
|
||||
user={authUi?.user}
|
||||
onLogin={() => authUi?.openLoginModal()}
|
||||
onOpenApiKeys={() => setIsApiKeysOpen(true)}
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
redeemPlatformProfileRewardCode,
|
||||
watchWechatPlatformProfileRechargeOrder,
|
||||
} from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
import {
|
||||
type CopyFeedbackState,
|
||||
useCopyFeedback,
|
||||
@@ -354,6 +355,45 @@ export function usePlatformProfileCenterController({
|
||||
const pendingWechatRechargeOrderIdRef = useRef<string | null>(null);
|
||||
const confirmingWechatRechargeOrderIdRef = useRef<string | null>(null);
|
||||
const rechargeCenterReadRevisionRef = useRef(0);
|
||||
const currentUserId = currentUser?.id ?? '';
|
||||
const walletOwnerUserId = usePlatformWalletStore(
|
||||
(state) => state.ownerUserId,
|
||||
);
|
||||
const storedMudPointBalance = usePlatformWalletStore(
|
||||
(state) => state.mudPointBalance,
|
||||
);
|
||||
const applyWalletBalanceSnapshot = usePlatformWalletStore(
|
||||
(state) => state.applyWalletBalanceSnapshot,
|
||||
);
|
||||
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
|
||||
(state) => state.onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
const walletOwnerMatchesCurrentUser =
|
||||
Boolean(currentUserId) && walletOwnerUserId === currentUserId;
|
||||
const mudPointBalance = walletOwnerMatchesCurrentUser
|
||||
? storedMudPointBalance
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
rechargeCenterReadRevisionRef.current += 1;
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setRechargeCenter(null);
|
||||
setIsLoadingRechargeCenter(false);
|
||||
setRechargeError(null);
|
||||
setRechargePaymentResult(null);
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
setNativeWechatPayment(null);
|
||||
setSubmittingRechargeProductId(null);
|
||||
setIsWalletLedgerOpen(false);
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerError(null);
|
||||
setIsLoadingWalletLedger(false);
|
||||
setIsRewardCodeOpen(false);
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeError(null);
|
||||
setRewardCodeSuccess(null);
|
||||
}, [currentUserId]);
|
||||
|
||||
// 中文注释:支持带邀请码 query 的直达场景,登录成功后自动打开兑换面板并复用同一套输入状态。
|
||||
useEffect(() => {
|
||||
@@ -377,18 +417,36 @@ export function usePlatformProfileCenterController({
|
||||
}, [currentUser, pendingProfileInviteCode, requestLogin]);
|
||||
|
||||
const loadWalletLedger = useCallback(() => {
|
||||
const snapshotOwnerUserId = currentUserId;
|
||||
setWalletLedgerError(null);
|
||||
setIsLoadingWalletLedger(true);
|
||||
void getPlatformProfileWalletLedger()
|
||||
.then(setWalletLedger)
|
||||
.then((ledger) => {
|
||||
if (
|
||||
usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId
|
||||
) {
|
||||
setWalletLedger(ledger);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
usePlatformWalletStore.getState().ownerUserId !== snapshotOwnerUserId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setWalletLedger(null);
|
||||
setWalletLedgerError(
|
||||
error instanceof Error ? error.message : '读取泥点账单失败',
|
||||
);
|
||||
})
|
||||
.finally(() => setIsLoadingWalletLedger(false));
|
||||
}, []);
|
||||
.finally(() => {
|
||||
if (
|
||||
usePlatformWalletStore.getState().ownerUserId === snapshotOwnerUserId
|
||||
) {
|
||||
setIsLoadingWalletLedger(false);
|
||||
}
|
||||
});
|
||||
}, [currentUserId]);
|
||||
|
||||
const openWalletLedgerPanel = useCallback(() => {
|
||||
setIsWalletLedgerOpen(true);
|
||||
@@ -396,13 +454,30 @@ export function usePlatformProfileCenterController({
|
||||
}, [loadWalletLedger]);
|
||||
|
||||
const applyRechargeCenter = useCallback(
|
||||
(center: ProfileRechargeCenterResponse) => {
|
||||
(center: ProfileRechargeCenterResponse, refreshWallet = false) => {
|
||||
if (
|
||||
!currentUserId ||
|
||||
usePlatformWalletStore.getState().ownerUserId !== currentUserId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
rechargeCenterReadRevisionRef.current += 1;
|
||||
setIsLoadingRechargeCenter(false);
|
||||
setRechargeError(null);
|
||||
setRechargeCenter(center);
|
||||
if (center.mudPointBalance) {
|
||||
applyWalletBalanceSnapshot(currentUserId, center.mudPointBalance);
|
||||
}
|
||||
if (refreshWallet || !center.mudPointBalance) {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
[
|
||||
applyWalletBalanceSnapshot,
|
||||
currentUserId,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
],
|
||||
);
|
||||
|
||||
const loadRechargeCenter = useCallback(() => {
|
||||
@@ -412,7 +487,7 @@ export function usePlatformProfileCenterController({
|
||||
void getPlatformProfileRechargeCenter()
|
||||
.then((center) => {
|
||||
if (revision === rechargeCenterReadRevisionRef.current) {
|
||||
setRechargeCenter(center);
|
||||
applyRechargeCenter(center);
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -429,7 +504,7 @@ export function usePlatformProfileCenterController({
|
||||
setIsLoadingRechargeCenter(false);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
}, [applyRechargeCenter]);
|
||||
|
||||
const refreshRechargeState = useCallback(() => {
|
||||
loadRechargeCenter();
|
||||
@@ -473,7 +548,9 @@ export function usePlatformProfileCenterController({
|
||||
.then((response) => {
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
@@ -540,7 +617,9 @@ export function usePlatformProfileCenterController({
|
||||
.then((response) => {
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
confirmingWechatRechargeOrderIdRef.current = null;
|
||||
setWechatRechargeOrderConfirmationState(null);
|
||||
@@ -616,7 +695,9 @@ export function usePlatformProfileCenterController({
|
||||
.then(async (response) => {
|
||||
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
const paymentHandled = await requestHostPayment({
|
||||
payload: response.wechatMiniProgramPayParams,
|
||||
orderId: response.order.orderId,
|
||||
@@ -628,7 +709,9 @@ export function usePlatformProfileCenterController({
|
||||
}
|
||||
if (paymentChannel === WECHAT_JSAPI_PAYMENT_CHANNEL) {
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
setRechargePaymentResult({
|
||||
kind: 'pending',
|
||||
title: '正在打开微信支付',
|
||||
@@ -648,7 +731,9 @@ export function usePlatformProfileCenterController({
|
||||
confirmResponse.order,
|
||||
);
|
||||
const isPaid = result.kind === 'success';
|
||||
applyRechargeCenter(confirmResponse.center);
|
||||
if (!applyRechargeCenter(confirmResponse.center, true)) {
|
||||
return;
|
||||
}
|
||||
setRechargePaymentResult(result);
|
||||
if (result.kind !== 'pending') {
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
@@ -672,7 +757,9 @@ export function usePlatformProfileCenterController({
|
||||
throw new Error('微信 H5 支付链接生成失败');
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
setRechargePaymentResult({
|
||||
kind: 'pending',
|
||||
title: '正在打开微信支付',
|
||||
@@ -689,7 +776,9 @@ export function usePlatformProfileCenterController({
|
||||
throw new Error('微信 Native 支付链接生成失败');
|
||||
}
|
||||
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
setNativeWechatPayment({
|
||||
...wechatNativePayment,
|
||||
codeUrl,
|
||||
@@ -778,7 +867,9 @@ export function usePlatformProfileCenterController({
|
||||
}
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
const isPaid = result.kind === 'success';
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
if (result.kind !== 'pending') {
|
||||
setNativeWechatPayment(null);
|
||||
pendingWechatRechargeOrderIdRef.current = null;
|
||||
@@ -839,7 +930,9 @@ export function usePlatformProfileCenterController({
|
||||
}
|
||||
|
||||
const result = buildRechargePaymentResultForOrder(response.order);
|
||||
applyRechargeCenter(response.center);
|
||||
if (!applyRechargeCenter(response.center, true)) {
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'pending') {
|
||||
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
|
||||
continue;
|
||||
@@ -1034,6 +1127,7 @@ export function usePlatformProfileCenterController({
|
||||
setReferralCenter(response.center);
|
||||
setReferralRedeemCode('');
|
||||
setReferralSuccess('已填写');
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -1042,7 +1136,12 @@ export function usePlatformProfileCenterController({
|
||||
);
|
||||
})
|
||||
.finally(() => setIsSubmittingReferralRedeem(false));
|
||||
}, [isSubmittingReferralRedeem, onRechargeSuccess, referralRedeemCode]);
|
||||
}, [
|
||||
isSubmittingReferralRedeem,
|
||||
onRechargeSuccess,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
referralRedeemCode,
|
||||
]);
|
||||
|
||||
const submitRewardCode = useCallback(() => {
|
||||
if (isSubmittingRewardCode || !rewardCodeInput.trim()) {
|
||||
@@ -1056,13 +1155,30 @@ export function usePlatformProfileCenterController({
|
||||
.then((response: RedeemProfileRewardCodeResponse) => {
|
||||
setRewardCodeInput('');
|
||||
setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`);
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
void onRechargeSuccess?.();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
setRewardCodeError(error instanceof Error ? error.message : '兑换失败');
|
||||
})
|
||||
.finally(() => setIsSubmittingRewardCode(false));
|
||||
}, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]);
|
||||
}, [
|
||||
isSubmittingRewardCode,
|
||||
onRechargeSuccess,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
rewardCodeInput,
|
||||
]);
|
||||
|
||||
const rechargeModalCenter = useMemo(() => {
|
||||
if (!walletOwnerMatchesCurrentUser || !rechargeCenter) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...rechargeCenter,
|
||||
walletBalance: mudPointBalance?.totalPoints ?? 0,
|
||||
mudPointBalance: mudPointBalance ?? undefined,
|
||||
};
|
||||
}, [mudPointBalance, rechargeCenter, walletOwnerMatchesCurrentUser]);
|
||||
|
||||
return {
|
||||
closeNativeWechatPayment,
|
||||
@@ -1086,7 +1202,7 @@ export function usePlatformProfileCenterController({
|
||||
openRewardCodeModal,
|
||||
openWalletLedgerPanel,
|
||||
profilePopupPanel,
|
||||
rechargeCenter,
|
||||
rechargeCenter: rechargeModalCenter,
|
||||
rechargeError,
|
||||
rechargePaymentResult,
|
||||
referralCenter,
|
||||
|
||||
@@ -142,10 +142,11 @@ export function revokePlatformProfileExternalApiKey(
|
||||
|
||||
export function getPlatformProfileRechargeCenter(
|
||||
options: PlatformProfileRequestOptions = {},
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return requestPlatformProfileJson<ProfileRechargeCenterResponse>(
|
||||
'/recharge-center',
|
||||
{ method: 'GET' },
|
||||
{ method: 'GET', signal },
|
||||
'读取泥点购买信息失败',
|
||||
options,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { createProfileWalletStore } from '@/packages/shared/src';
|
||||
import { getPlatformProfileRechargeCenter } from '@/src/services/platform-entry/platformProfileClient.ts';
|
||||
|
||||
export const usePlatformWalletStore = createProfileWalletStore({
|
||||
getRechargeCenter: (signal) =>
|
||||
getPlatformProfileRechargeCenter({}, signal),
|
||||
});
|
||||
|
||||
export function usePlatformWalletLifecycle(
|
||||
currentUserId: string | null,
|
||||
canAccessProtectedData: boolean,
|
||||
) {
|
||||
const setWalletOwner = usePlatformWalletStore(
|
||||
(state) => state.setWalletOwner,
|
||||
);
|
||||
const onWalletBalanceMayHaveChanged = usePlatformWalletStore(
|
||||
(state) => state.onWalletBalanceMayHaveChanged,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const ownerUserId =
|
||||
canAccessProtectedData && currentUserId ? currentUserId : null;
|
||||
setWalletOwner(ownerUserId);
|
||||
if (!ownerUserId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
const refreshWallet = () => {
|
||||
void onWalletBalanceMayHaveChanged();
|
||||
};
|
||||
const refreshVisibleWallet = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
refreshWallet();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('focus', refreshWallet);
|
||||
document.addEventListener('visibilitychange', refreshVisibleWallet);
|
||||
return () => {
|
||||
window.removeEventListener('focus', refreshWallet);
|
||||
document.removeEventListener('visibilitychange', refreshVisibleWallet);
|
||||
};
|
||||
}, [
|
||||
canAccessProtectedData,
|
||||
currentUserId,
|
||||
onWalletBalanceMayHaveChanged,
|
||||
setWalletOwner,
|
||||
]);
|
||||
}
|
||||
@@ -74,6 +74,7 @@ export default defineConfig({
|
||||
'packages/shared/src/contracts/hostBridge.test.ts',
|
||||
'packages/shared/src/components/**/*.test.ts',
|
||||
'packages/shared/src/components/**/*.test.tsx',
|
||||
'packages/shared/src/stores/**/*.test.ts',
|
||||
'packages/shared/src/utils/**/*.test.ts',
|
||||
],
|
||||
exclude: [
|
||||
|
||||
Reference in New Issue
Block a user