Merge branch 'master' into extract-wallet-entry-to-shared

This commit is contained in:
2026-07-14 16:52:47 +08:00
124 changed files with 20247 additions and 362 deletions
@@ -3,7 +3,7 @@
import {
act,
fireEvent,
render,
render as testingLibraryRender,
screen,
waitFor,
within,
@@ -22,6 +22,12 @@ import {
setupImageCanvasEditorViewTestLifecycle,
} from './ImageCanvasEditorView.test-utils';
function render(ui: Parameters<typeof testingLibraryRender>[0]) {
const result = testingLibraryRender(ui);
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
return result;
}
const generateEditorImageMock = vi.hoisted(() => vi.fn());
const generateEditorIconSpritesheetMock = vi.hoisted(() => vi.fn());
const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn());
@@ -39,6 +39,7 @@ const loadEditorProjectMock = vi.hoisted(() => vi.fn());
const loadOrCreateRecentEditorProjectMock = vi.hoisted(() => vi.fn());
const renameEditorProjectMock = vi.hoisted(() => vi.fn());
const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn());
const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/image-editor/editorProjectClient', async () => {
const actual = await vi.importActual<
@@ -71,6 +72,10 @@ vi.mock('./ImageCanvasUiAssetExtractionRasterModel', () => ({
renderUiDesignAssetExtractionMarkedImageMock,
}));
vi.mock('../../services/image-editor/editorMediaAssetUploadClient', () => ({
uploadEditorMediaAssetFile: uploadEditorMediaAssetFileMock,
}));
vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => {
const actual = await vi.importActual<
typeof import('./ImageCanvasProjectCoverSnapshotModel')
@@ -106,8 +111,14 @@ describe('ImageCanvasEditorView generation integration', () => {
window.localStorage.clear();
renderUiDesignAssetExtractionMarkedImageMock.mockReset();
renderUiDesignAssetExtractionMarkedImageMock.mockResolvedValue(
'data:image/png;base64,marked-ui-design',
'data:image/png;base64,bWFya2VkLXVpLWRlc2lnbg==',
);
uploadEditorMediaAssetFileMock.mockReset();
uploadEditorMediaAssetFileMock.mockResolvedValue({
objectKey:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
assetObjectId: 'asset-object-marked-ui-design',
});
});
const openGenerationDimensionsPanel = (
@@ -1344,6 +1355,13 @@ describe('ImageCanvasEditorView generation integration', () => {
clientY: 120,
},
);
fireEvent.change(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'textbox',
{ name: '素材描述' },
),
{ target: { value: '返回按钮' } },
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
@@ -1351,7 +1369,11 @@ describe('ImageCanvasEditorView generation integration', () => {
),
);
expect(screen.queryByRole('dialog', { name: '生成图标素材' })).toBeNull();
await waitFor(() => {
expect(
screen.queryByRole('dialog', { name: '生成图标素材' }),
).toBeNull();
});
const frame = screen.getByLabelText('图标素材生成占位图');
expect(frame.className).toContain(
'image-canvas-editor__generation-frame--generating',
@@ -1743,6 +1765,13 @@ describe('ImageCanvasEditorView generation integration', () => {
screen.getByRole('dialog', { name: '生成图标素材' }),
'图标规范 2',
);
fireEvent.change(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'textbox',
{ name: '素材描述' },
),
{ target: { value: '返回按钮' } },
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '生成图标素材' })).getByRole(
'button',
@@ -1990,7 +2019,12 @@ describe('ImageCanvasEditorView generation integration', () => {
{ target: { value: '返回按钮\n设置按钮\n提示按钮' } },
);
expect(within(iconPanel).getAllByRole('textbox')).toHaveLength(1);
expect(
within(iconPanel).getByRole('textbox', { name: '素材描述' }).tagName,
).toBe('TEXTAREA');
expect(
within(iconPanel).getByRole('textbox', { name: '资源名称' }),
).toBeTruthy();
expect(
within(iconPanel).queryByRole('button', { name: '添加素材描述' }),
).toBeNull();
@@ -2389,8 +2423,8 @@ describe('ImageCanvasEditorView generation integration', () => {
aspectRatio: '1:1',
imageSize: '1K',
referenceImageSrcs: [
'data:image/png;base64,cm9sZS1zcGVj',
'data:image/png;base64,YmlnLWZpc2gtcmVm',
'resource-character-spec',
'resource-big-fish',
],
projectId: 'editor-project-default',
assetFolderId: 'project',
@@ -2759,6 +2793,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(loadEditorAssetLibraryMock).toHaveBeenCalledTimes(2);
});
fireEvent.click(screen.getByRole('button', { name: '打开素材' }));
expect(
await screen.findByRole('button', { name: '添加清爽按钮素材图集' }),
).toBeTruthy();
@@ -3038,7 +3073,7 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(generateEditorIconSpritesheetMock).toHaveBeenCalledWith(
expect.objectContaining({
referenceImageSrc: 'data:image/png;base64,cmVuYW1lZC1pY29uLXNwZWM=',
referenceImageSrc: 'resource-editor-icon-spec-renamed-1',
iconDescriptions: ['背包按钮'],
model: 'gemini-3.1-flash-image-preview',
aspectRatio: '1:1',
@@ -3258,20 +3293,22 @@ describe('ImageCanvasEditorView generation integration', () => {
).toBeTruthy();
fireEvent.click(within(panel).getByRole('button', { name: '生成120泥点' }));
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'layer-character',
sourceImageSrc:
'generated-character-drafts/editor/character-images/source/image.png',
sourceWidth: 1024,
sourceHeight: 1024,
resolution: '720p',
ratio: '16:9',
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
}),
);
await waitFor(() => {
expect(generateEditorCharacterAnimationMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceLayerId: 'layer-character',
sourceImageSrc:
'generated-character-drafts/editor/character-images/source/image.png',
sourceWidth: 1024,
sourceHeight: 1024,
resolution: '720p',
ratio: '16:9',
frameCount: 48,
durationSeconds: 6,
model: 'seedance2.0-fast',
}),
);
});
expect(
generateEditorCharacterAnimationMock.mock.calls[0]?.[0]?.promptText,
).toBe(precisePrompt);
@@ -3429,7 +3466,8 @@ describe('ImageCanvasEditorView generation integration', () => {
await waitFor(() => {
expect(extractEditorUiDesignAssetsMock).toHaveBeenCalledWith(
expect.objectContaining({
sourceImageSrc: 'data:image/png;base64,marked-ui-design',
sourceImageSrc:
'generated-character-drafts/editor/generation-references/marked-ui-design.png',
screenColor: 'auto',
segModel: 'birefnet',
aspectRatio: '1:1',
@@ -15,7 +15,7 @@ import {
clearStoredAccessToken,
refreshStoredAccessToken,
} from '../../services/apiClient';
import { type AuthUser,startWechatBind } from '../../services/authService';
import { type AuthUser, startWechatBind } from '../../services/authService';
import {
getHostRuntime,
requestHostLogin,
@@ -52,6 +52,7 @@ const PROFILE_TASK_BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
const PROFILE_TASK_MIN_RESET_DELAY_MS = 1000;
const PROFILE_INVITE_QUERY_KEYS = ['inviteCode', 'invite_code'] as const;
const WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS = [800, 1600] as const;
const WECHAT_NATIVE_WATCH_RETRY_DELAY_MS = 1000;
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_PAY_RESULT_RECHECK_INTERVAL_MS = 250;
const WECHAT_PAY_RESULT_RECHECK_TIMEOUT_MS = 10000;
@@ -68,7 +69,11 @@ type WechatPayResult = {
};
type RechargePaymentResultKind =
'success' | 'pending' | 'cancel' | 'failed' | 'expired';
| 'success'
| 'pending'
| 'cancel'
| 'failed'
| 'expired';
export type RechargePaymentResult = {
kind: RechargePaymentResultKind;
@@ -774,6 +779,12 @@ export function usePlatformProfileCenterController({
);
void confirmWechatRechargeOrderQuickly(nativeWechatPayment.orderId)
.then((response) => {
if (
pendingWechatRechargeOrderIdRef.current !==
nativeWechatPayment.orderId
) {
return;
}
const result = buildRechargePaymentResultForOrder(response.order);
const isPaid = result.kind === 'success';
setRechargeCenter(response.center);
@@ -810,6 +821,70 @@ export function usePlatformProfileCenterController({
.finally(() => setSubmittingRechargeProductId(null));
}, [nativeWechatPayment, onRechargeSuccess]);
useEffect(() => {
const orderId = nativeWechatPayment?.orderId;
const expiresAtMs = Date.parse(nativeWechatPayment?.expiresAt ?? '');
if (!orderId || !Number.isFinite(expiresAtMs)) {
return undefined;
}
let cancelled = false;
const abortController = new AbortController();
const watchUntilSettled = async () => {
while (!cancelled && Date.now() < expiresAtMs) {
try {
const response = await watchWechatRpgProfileRechargeOrder(orderId, {
signal: abortController.signal,
});
if (
cancelled ||
!response ||
pendingWechatRechargeOrderIdRef.current !== orderId
) {
return;
}
const result = buildRechargePaymentResultForOrder(response.order);
setRechargeCenter(response.center);
if (result.kind === 'pending') {
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
continue;
}
pendingWechatRechargeOrderIdRef.current = null;
if (confirmingWechatRechargeOrderIdRef.current === orderId) {
confirmingWechatRechargeOrderIdRef.current = null;
}
setNativeWechatPayment((current) =>
current?.orderId === orderId ? null : current,
);
setSubmittingRechargeProductId(null);
setRechargePaymentResult(result);
if (result.kind === 'success') {
void onRechargeSuccess?.();
}
return;
} catch {
if (cancelled || abortController.signal.aborted) {
return;
}
}
await waitWechatPayConfirmDelay(WECHAT_NATIVE_WATCH_RETRY_DELAY_MS);
}
};
void watchUntilSettled();
return () => {
cancelled = true;
abortController.abort();
};
}, [
nativeWechatPayment?.expiresAt,
nativeWechatPayment?.orderId,
onRechargeSuccess,
]);
// 中文注释:H5 / 小程序支付返回页、页面恢复和 hash 轮询都统一走同一套到账确认逻辑,
// 避免页面组件自己感知微信支付细节。
useEffect(() => {
@@ -386,7 +386,10 @@ const {
updatedAt: '2026-06-19T10:00:00Z',
},
})),
mockWatchWechatRpgProfileRechargeOrder: vi.fn(async () => null),
mockWatchWechatRpgProfileRechargeOrder: vi.fn(
() =>
new Promise<ConfirmWechatProfileRechargeOrderResponse>(() => undefined),
),
};
});
@@ -2649,6 +2652,99 @@ test('profile native qr confirmation refreshes only after server reports paid',
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile native qr closes automatically when the order stream reports paid', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
let resolveOrderWatch!: (
response: ConfirmWechatProfileRechargeOrderResponse,
) => void;
mockDesktopLayout();
mockCreateRpgProfileRechargeOrder.mockResolvedValueOnce({
order: {
orderId: 'order-native-auto-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'pending' as const,
paymentChannel: 'wechat_native',
createdAt: '2026-04-25T10:00:00Z',
paidAt: null,
providerTransactionId: null,
pointsDelta: 0,
membershipExpiresAt: null,
},
center: {
walletBalance: 0,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: false,
},
wechatNativePayment: {
codeUrl: 'weixin://pay.weixin.qq.com/bizpayurl/up?pr=native-auto-paid',
expiresAt: '2099-01-01T00:05:00Z',
},
});
mockWatchWechatRpgProfileRechargeOrder.mockReturnValueOnce(
new Promise((resolve) => {
resolveOrderWatch = resolve;
}),
);
renderProfileView(onRechargeSuccess);
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
await user.click(
within(shortcutRegion).getByRole('button', { name: /充值/u }),
);
await user.click(await screen.findByRole('button', { name: /60泥点/u }));
expect(
await screen.findByRole('dialog', { name: '微信扫码支付' }),
).toBeTruthy();
await waitFor(() => {
expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-native-auto-paid',
{ signal: expect.any(AbortSignal) },
);
});
act(() => {
resolveOrderWatch({
order: {
orderId: 'order-native-auto-paid',
productId: 'points_60',
productTitle: '60泥点',
kind: 'points',
amountCents: 600,
status: 'paid',
paymentChannel: 'wechat_native',
createdAt: '2026-04-25T10:00:00Z',
paidAt: '2026-04-25T10:01:00Z',
providerTransactionId: 'wx-native-auto-1',
pointsDelta: 60,
membershipExpiresAt: null,
},
center: {
walletBalance: 60,
membership: buildNormalMembership(),
pointProducts: [],
membershipProducts: [],
benefits: [],
latestOrder: null,
hasPointsRecharged: true,
},
});
});
expect(await screen.findByRole('dialog', { name: '支付成功' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '微信扫码支付' })).toBeNull();
expect(mockConfirmWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('profile native qr confirmation closes qr dialog when order is expired', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
@@ -2907,11 +3003,12 @@ test('profile native qr confirmation keeps qr dialog when payment is still pendi
},
),
).toBeTruthy();
expect(
screen.getByRole('dialog', { name: '微信扫码支付' }),
).toBeTruthy();
expect(screen.getByRole('dialog', { name: '微信扫码支付' })).toBeTruthy();
expect(screen.queryByRole('dialog', { name: '支付处理中' })).toBeNull();
expect(mockWatchWechatRpgProfileRechargeOrder).not.toHaveBeenCalled();
expect(mockWatchWechatRpgProfileRechargeOrder).toHaveBeenCalledWith(
'order-native-pending',
{ signal: expect.any(AbortSignal) },
);
expect(onRechargeSuccess).not.toHaveBeenCalled();
});
@@ -81,6 +81,9 @@ test('profile funds ViewModel resolves ledger source labels with raw fallback',
expect(getWalletLedgerSourceLabel('puzzle_author_incentive_claim')).toBe(
'拼图作者奖励',
);
expect(getWalletLedgerSourceLabel('recharge_refund_recovery')).toBe(
'充值退款追回',
);
expect(getWalletLedgerSourceLabel('future_source')).toBe('future_source');
expect(getWalletLedgerSourceLabel('')).toBe('未知来源');
});
@@ -98,9 +101,18 @@ test('profile funds ViewModel builds wallet ledger presentation', () => {
balanceAfter: 79,
sourceType: 'asset_operation_consume',
});
const recoveryEntry = buildLedgerEntry({
id: 'ledger-recovery',
amountDelta: -30,
balanceAfter: 49,
sourceType: 'recharge_refund_recovery',
});
expect(
buildWalletLedgerPresentation({ entries: [incomeEntry, outcomeEntry] }, 12),
buildWalletLedgerPresentation(
{ entries: [incomeEntry, outcomeEntry, recoveryEntry] },
12,
),
).toEqual({
balance: 80,
balanceLabel: '80泥点',
@@ -121,6 +133,14 @@ test('profile funds ViewModel builds wallet ledger presentation', () => {
isIncome: false,
sourceLabel: '资产操作消耗',
},
{
amountLabel: '-30',
balanceLabel: '余额 49',
createdAt: '2026-06-03T00:00:00.000Z',
id: 'ledger-recovery',
isIncome: false,
sourceLabel: '充值退款追回',
},
],
});
@@ -17,6 +17,7 @@ const PROFILE_WALLET_LEDGER_SOURCE_LABELS = {
daily_free_reset: '每日免费重置',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
recharge_refund_recovery: '充值退款追回',
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',