合并 master 最新更新
Project CI / Frontend tests (push) Failing after 24s
Project CI / Repository checks (push) Failing after 43s
Project CI / Backend tests (push) Successful in 3m5s
Project CI / Native shell tests (push) Failing after 6m49s

合入 master 的 BgFilter、CI、运维与现役平台改造。

保留 AI 游戏创作 Runtime、独立锁文件与原生壳验证链路。

修复共享充值账单组件、LLM 网关与退役 Agent 兼容边界。

同步冲突文档、锁文件和开发脚本。
This commit is contained in:
AIGameCreator App
2026-07-27 18:28:26 +08:00
1304 changed files with 170414 additions and 36096 deletions
@@ -0,0 +1,209 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
import * as hostBridgeServices from '../../services/host-bridge/hostBridge';
import { PlatformActiveProfileView } from './PlatformActiveProfileView';
const updateAuthProfileMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/authService', () => ({
updateAuthProfile: updateAuthProfileMock,
}));
const callbacks = {
onLogin: vi.fn(),
onOpenApiKeys: vi.fn(),
onOpenCommunity: vi.fn(),
onOpenFeedback: vi.fn(),
onOpenRecharge: vi.fn(),
onOpenRewardCode: vi.fn(),
onOpenSettings: vi.fn(),
onOpenWalletLedger: vi.fn(),
onUserUpdated: vi.fn(),
};
const authenticatedUser: AuthUser = {
id: 'user-1',
publicUserCode: '100001',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password' as const,
bindingStatus: 'active',
wechatBound: false,
};
describe('PlatformActiveProfileView', () => {
beforeEach(() => {
Object.values(callbacks).forEach((callback) => callback.mockReset());
updateAuthProfileMock.mockReset();
});
it('shows the login entry for guests', () => {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={null}
/>,
);
expect(screen.getByRole('main', { name: '我的' })).toBeTruthy();
expect(screen.getByText('尚未登录')).toBeTruthy();
expect(screen.getByRole('button', { name: '登录' })).toBeTruthy();
});
it('renders active account, wallet, and settings capabilities', () => {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={{
walletBalance: 108,
totalPlayTimeMs: 5_400_000,
playedWorldCount: 7,
updatedAt: '2026-07-18T00:00:00.000Z',
}}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
expect(screen.getByText('测试玩家')).toBeTruthy();
expect(screen.getByText(/陶泥号:\s*100001/u)).toBeTruthy();
expect(screen.getByRole('button', { name: '泥点余额 108' })).toBeTruthy();
expect(screen.getByRole('button', { name: /泥点充值/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /兑换码/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /玩家社区/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /反馈与建议/u })).toBeTruthy();
expect(screen.getByRole('button', { name: /通用设置/u })).toBeTruthy();
expect(
screen.getByRole('button', { name: /开发者 API Key/u }),
).toBeTruthy();
});
it('edits the nickname through the profile identity action', async () => {
updateAuthProfileMock.mockResolvedValueOnce({
...authenticatedUser,
displayName: '新昵称',
});
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '修改昵称' }));
const input = screen.getByRole('textbox', { name: '新昵称' });
fireEvent.change(input, { target: { value: 'a' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
expect(screen.getByText('昵称需要 2 到 20 位')).toBeTruthy();
expect(updateAuthProfileMock).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: '新昵称' } });
fireEvent.click(screen.getByRole('button', { name: '保存' }));
await waitFor(() => {
expect(updateAuthProfileMock).toHaveBeenCalledWith({
displayName: '新昵称',
});
expect(callbacks.onUserUpdated).toHaveBeenCalledWith({
...authenticatedUser,
displayName: '新昵称',
});
});
});
it('keeps avatar validation on the direct camera action', () => {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
const avatarInput = screen
.getAllByLabelText('上传头像')
.find((element) => element instanceof HTMLInputElement);
expect(avatarInput).toBeTruthy();
fireEvent.change(avatarInput as HTMLInputElement, {
target: {
files: [new File(['avatar'], 'avatar.txt', { type: 'text/plain' })],
},
});
expect(screen.getByText('头像仅支持 jpg、png、webp')).toBeTruthy();
expect(updateAuthProfileMock).not.toHaveBeenCalled();
});
it('imports avatar images through the native HostBridge', async () => {
class MockFileReader {
result: string | null = null;
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
readAsDataURL() {
this.result = 'data:image/webp;base64,YXZhdGFy';
this.onload?.();
}
}
class MockImage {
onload: null | (() => void) = null;
onerror: null | (() => void) = null;
naturalWidth = 720;
naturalHeight = 720;
set src(_value: string) {
this.onload?.();
}
}
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
vi.stubGlobal('Image', MockImage as unknown as typeof Image);
const inputClickSpy = vi
.spyOn(HTMLInputElement.prototype, 'click')
.mockImplementation(() => undefined);
vi.spyOn(
hostBridgeServices,
'canUseNativeHostCapability',
).mockImplementation((capability) => capability === 'file.importImage');
vi.spyOn(hostBridgeServices, 'importHostImageFile').mockResolvedValue({
action: 'selected',
fileName: 'native-avatar.webp',
base64Data: 'YXZhdGFy',
mimeType: 'image/webp',
bytes: 6,
});
try {
render(
<PlatformActiveProfileView
{...callbacks}
dashboard={null}
isLoadingDashboard={false}
user={authenticatedUser}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '上传头像' }));
await waitFor(() => {
expect(screen.getByRole('dialog', { name: '裁剪头像' })).toBeTruthy();
});
expect(hostBridgeServices.importHostImageFile).toHaveBeenCalledTimes(1);
expect(inputClickSpy).not.toHaveBeenCalled();
expect(screen.getByLabelText('头像裁剪操作区')).toBeTruthy();
} finally {
vi.restoreAllMocks();
vi.unstubAllGlobals();
}
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
const authUiMock = vi.hoisted(() => ({
value: {
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openAccountModal: vi.fn(),
openSettingsModal: vi.fn(),
},
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => authUiMock.value,
}));
vi.mock('../creation-home/CreationLandingView', () => ({
CreationLandingView: ({
onOpenProject,
onOpenProjects,
searchKeyword,
}: {
onOpenProject: (
projectId: string,
options?: { guide?: boolean; tool?: string },
) => void;
onOpenProjects: () => void;
searchKeyword?: string;
}) => (
<main aria-label="陶泥儿创作主页" data-search={searchKeyword}>
创作主页
<button type="button" onClick={onOpenProjects}>
查看项目
</button>
<button
type="button"
onClick={() =>
onOpenProject('tool-project', { tool: 'background-music' })
}
>
打开音乐工具
</button>
</main>
),
}));
vi.mock('../project/ProjectGalleryView', () => ({
ProjectGalleryView: ({
onOpenProject,
searchKeyword,
}: {
onOpenProject: (projectId: string, options?: { guide?: boolean }) => void;
searchKeyword?: string;
}) => (
<main aria-label="项目" data-search={searchKeyword}>
项目
<button
type="button"
onClick={() => onOpenProject('guide-project', { guide: true })}
>
打开引导项目
</button>
</main>
),
}));
vi.mock('../image-editor/ImageCanvasEditorView', () => ({
ImageCanvasEditorView: () => <main aria-label="图片画布编辑器" />,
}));
vi.mock('../../services/platform-entry/platformProfileClient', () => ({
getPlatformProfileDashboard: vi.fn(),
}));
vi.mock('./usePlatformProfileCenterController', () => ({
usePlatformProfileCenterController: () => ({
buyRechargeProduct: vi.fn(),
closeNativeWechatPayment: vi.fn(),
confirmNativeWechatPayment: vi.fn(),
isLoadingRechargeCenter: false,
isLoadingWalletLedger: false,
isRechargeOpen: false,
isWalletLedgerOpen: false,
loadRechargeCenter: vi.fn(),
nativeWechatPayment: null,
openWalletLedgerPanel: vi.fn(),
rechargeCenter: null,
rechargeError: null,
rechargePaymentResult: null,
setIsRechargeOpen: vi.fn(),
setIsWalletLedgerOpen: vi.fn(),
setRechargePaymentResult: vi.fn(),
submittingRechargeProductId: null,
walletLedger: null,
walletLedgerError: null,
wechatRechargeOrderConfirmationState: null,
}),
}));
describe('PlatformEntryActiveFlowShell', () => {
beforeEach(() => {
window.history.replaceState(null, '', '/creation');
authUiMock.value.openLoginModal.mockReset();
});
it('keeps the active desktop rail and the shared account capsule', async () => {
const setSelectionStage = vi.fn();
const { container, rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
expect(
await screen.findByRole('main', { name: '陶泥儿创作主页' }),
).toBeTruthy();
const navigation = screen.getByRole('navigation', { name: '平台导航' });
expect(
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '创作' })
.getAttribute('aria-current'),
).toBe('page');
const topbar = container.querySelector('.platform-desktop-topbar');
expect(topbar).toBeTruthy();
expect(
topbar?.querySelectorAll('button.platform-desktop-search'),
).toHaveLength(1);
expect(
within(topbar as HTMLElement).queryByRole('button', { name: '设置' }),
).toBeNull();
fireEvent.change(
screen.getByRole('searchbox', { name: '搜索项目和素材' }),
{ target: { value: '角色' } },
);
fireEvent.click(screen.getByRole('button', { name: '搜索' }));
expect(
screen
.getByRole('main', { name: '陶泥儿创作主页' })
.getAttribute('data-search'),
).toBe('角色');
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
expect(window.location.pathname).toBe('/profile');
expect(setSelectionStage).toHaveBeenCalledWith('profile', {
path: '/profile',
});
rerender(
<PlatformEntryFlowShellImpl
selectionStage="profile"
setSelectionStage={setSelectionStage}
/>,
);
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
expect(
within(navigation)
.getByRole('button', { name: '我的' })
.getAttribute('aria-current'),
).toBe('page');
});
it('keeps creation, project, and profile reachable from the mobile dock', async () => {
const setSelectionStage = vi.fn();
render(
<PlatformEntryFlowShellImpl
selectionStage="project"
setSelectionStage={setSelectionStage}
/>,
);
const navigation = await screen.findByRole('navigation', {
name: '移动平台导航',
});
expect(
within(navigation)
.getAllByRole('button')
.map((button) => button.getAttribute('aria-label')),
).toEqual(['创作', '项目', '我的']);
expect(
within(navigation)
.getByRole('button', { name: '项目' })
.getAttribute('aria-current'),
).toBe('page');
fireEvent.click(within(navigation).getByRole('button', { name: '我的' }));
expect(window.location.pathname).toBe('/profile');
expect(setSelectionStage).toHaveBeenCalledWith('profile', {
path: '/profile',
});
});
it('switches between creation and projects and passes search to the active page', async () => {
const setSelectionStage = vi.fn();
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
const navigation = await screen.findByRole('navigation', {
name: '平台导航',
});
fireEvent.click(within(navigation).getByRole('button', { name: '项目' }));
expect(window.location.pathname).toBe('/project');
expect(setSelectionStage).toHaveBeenCalledWith('project', {
path: '/project',
});
rerender(
<PlatformEntryFlowShellImpl
selectionStage="project"
setSelectionStage={setSelectionStage}
/>,
);
const projectPage = await screen.findByRole('main', { name: '项目' });
expect(
within(navigation)
.getByRole('button', { name: '项目' })
.getAttribute('aria-current'),
).toBe('page');
expect(
within(navigation)
.getByRole('button', { name: '创作' })
.getAttribute('aria-current'),
).toBeNull();
fireEvent.change(
screen.getByRole('searchbox', { name: '搜索项目和素材' }),
{ target: { value: '场景' } },
);
fireEvent.click(screen.getByRole('button', { name: '搜索' }));
expect(projectPage.getAttribute('data-search')).toBe('场景');
fireEvent.click(within(navigation).getByRole('button', { name: '创作' }));
expect(window.location.pathname).toBe('/creation');
expect(setSelectionStage).toHaveBeenCalledWith('creation-home', {
path: '/creation',
});
});
it('keeps guide and tool intent in editor navigation URLs', async () => {
const setSelectionStage = vi.fn();
const { rerender } = render(
<PlatformEntryFlowShellImpl
selectionStage="project"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '打开引导项目' }),
);
expect(`${window.location.pathname}${window.location.search}`).toBe(
'/editor/canvas?projectid=guide-project&guide=toolbar',
);
expect(setSelectionStage).toHaveBeenCalledWith('image-editor', {
path: '/editor/canvas?projectid=guide-project&guide=toolbar',
});
window.history.replaceState(null, '', '/creation');
setSelectionStage.mockClear();
rerender(
<PlatformEntryFlowShellImpl
selectionStage="creation-home"
setSelectionStage={setSelectionStage}
/>,
);
fireEvent.click(
await screen.findByRole('button', { name: '打开音乐工具' }),
);
expect(`${window.location.pathname}${window.location.search}`).toBe(
'/editor/canvas?projectid=tool-project&tool=background-music',
);
expect(setSelectionStage).toHaveBeenCalledWith('image-editor', {
path: '/editor/canvas?projectid=tool-project&tool=background-music',
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,14 +1,14 @@
import { PlatformEntryFlowShellImpl } from './PlatformEntryFlowShellImpl';
import { PlatformEntryFlowShellImpl } from './PlatformEntryActiveFlowShell';
import type {
PlatformEntryFlowShellProps,
SelectionStage,
} from './platformEntryTypes';
} from './platformEntryActiveTypes';
export type { PlatformEntryFlowShellProps, SelectionStage };
/**
* 平台入口通用壳层。
* RPG、Big Fish 等玩法创作入口在这里并列分流。
* 平台运行入口承载新创作主页、项目、编辑器与账户公共能力。
*/
export function PlatformEntryFlowShell(props: PlatformEntryFlowShellProps) {
return <PlatformEntryFlowShellImpl {...props} />;
@@ -6,10 +6,10 @@ import type {
ExternalApiKeyProfile,
} from '../../../packages/shared/src/contracts/runtime';
import {
createRpgProfileExternalApiKey,
listRpgProfileExternalApiKeys,
revokeRpgProfileExternalApiKey,
} from '../../services/rpg-entry/rpgProfileClient';
createPlatformProfileExternalApiKey,
listPlatformProfileExternalApiKeys,
revokePlatformProfileExternalApiKey,
} from '../../services/platform-entry/platformProfileClient';
import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformAsyncStatePanel } from '../common/PlatformAsyncStatePanel';
@@ -20,7 +20,6 @@ import { PlatformProfileSummaryHeader } from '../common/PlatformProfileSummaryHe
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { PlatformTextField } from '../common/PlatformTextField';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { formatPlatformWorldTime } from '../rpg-entry/rpgEntryWorldPresentation';
import { PlatformProfileSecondaryModalShell } from './PlatformProfileModalShell';
type PlatformProfileApiKeysModalProps = {
@@ -32,7 +31,17 @@ function isActiveExternalApiKey(key: ExternalApiKeyProfile) {
}
function buildApiKeyTimeLabel(value: string | null) {
return value ? formatPlatformWorldTime(value) : '尚未使用';
if (!value) {
return '尚未使用';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function buildApiKeyScopeLabel(scopes: string[]) {
@@ -56,21 +65,22 @@ export function PlatformProfileApiKeysModal({
const [revokingKeyId, setRevokingKeyId] = useState<string | null>(null);
const { copyState, copyText } = useCopyFeedback();
const activeKeys = useMemo(
() => keys.filter(isActiveExternalApiKey),
[keys],
const activeKeys = useMemo(() => keys.filter(isActiveExternalApiKey), [keys]);
const shouldShowBlockingError = Boolean(
error && !isLoading && keys.length === 0,
);
const shouldShowBlockingError = Boolean(error && !isLoading && keys.length === 0);
const loadKeys = useCallback(() => {
setIsLoading(true);
setError(null);
void listRpgProfileExternalApiKeys()
void listPlatformProfileExternalApiKeys()
.then((response) => {
setKeys(response.keys);
})
.catch((loadError: unknown) => {
setError(loadError instanceof Error ? loadError.message : '读取 API Key 失败');
setError(
loadError instanceof Error ? loadError.message : '读取 API Key 失败',
);
})
.finally(() => setIsLoading(false));
}, []);
@@ -86,7 +96,7 @@ export function PlatformProfileApiKeysModal({
setIsCreating(true);
setError(null);
setCreatedKey(null);
void createRpgProfileExternalApiKey(nameInput)
void createPlatformProfileExternalApiKey(nameInput)
.then((response) => {
setCreatedKey(response);
setKeys((current) => [
@@ -97,7 +107,9 @@ export function PlatformProfileApiKeysModal({
})
.catch((createError: unknown) => {
setError(
createError instanceof Error ? createError.message : '创建 API Key 失败',
createError instanceof Error
? createError.message
: '创建 API Key 失败',
);
})
.finally(() => setIsCreating(false));
@@ -106,7 +118,7 @@ export function PlatformProfileApiKeysModal({
const revokeKey = useCallback((keyId: string) => {
setRevokingKeyId(keyId);
setError(null);
void revokeRpgProfileExternalApiKey(keyId)
void revokePlatformProfileExternalApiKey(keyId)
.then((response) => {
setKeys((current) =>
current.map((key) =>
@@ -119,7 +131,9 @@ export function PlatformProfileApiKeysModal({
})
.catch((revokeError: unknown) => {
setError(
revokeError instanceof Error ? revokeError.message : '撤销 API Key 失败',
revokeError instanceof Error
? revokeError.message
: '撤销 API Key 失败',
);
})
.finally(() => setRevokingKeyId(null));
@@ -0,0 +1,19 @@
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
export function resolveActivePublicUserCode(
user: AuthUser | null | undefined,
) {
if (user?.publicUserCode?.trim()) {
return user.publicUserCode.trim();
}
const raw =
user?.id.replace(/[^a-zA-Z0-9]/gu, '').toUpperCase() || '00000000';
return `SY-${raw.slice(-8).padStart(8, '0')}`;
}
export function resolveActiveUserAvatarLabel(
user: AuthUser | null | undefined,
) {
return (user?.displayName || '叙').slice(0, 1).toUpperCase();
}
@@ -0,0 +1,14 @@
export type SelectionStage =
| 'platform'
| 'creation-home'
| 'project'
| 'profile'
| 'image-editor';
export type PlatformEntryFlowShellProps = {
selectionStage: SelectionStage;
setSelectionStage: (
stage: SelectionStage,
options?: { path?: string },
) => void;
};
@@ -0,0 +1,127 @@
import type {
ProfileWalletLedgerEntry,
ProfileWalletLedgerResponse,
} from '../../../packages/shared/src/contracts/runtime';
const PLATFORM_WALLET_LEDGER_SOURCE_LABELS = {
new_user_registration_reward: '注册赠送',
points_recharge: '泥点充值',
invite_inviter_reward: '邀请奖励',
invite_invitee_reward: '填写邀请码奖励',
snapshot_sync: '账户同步',
membership_period_grant: '会员周期发放',
membership_period_reset: '会员周期重置',
daily_free_grant: '每日免费发放',
daily_free_reset: '每日免费重置',
asset_operation_consume: '资产操作消耗',
asset_operation_refund: '资产操作退回',
recharge_refund_recovery: '充值退款追回',
redeem_code_reward: '兑换码奖励',
puzzle_author_incentive_claim: '拼图作者奖励',
daily_task_reward: '每日任务奖励',
} satisfies Record<ProfileWalletLedgerEntry['sourceType'], string>;
export type PlatformWalletLedgerEntryPresentation = {
amountLabel: string;
balanceLabel: string;
createdAt: string;
id: string;
isIncome: boolean;
sourceLabel: string;
};
export type PlatformWalletLedgerPresentation = {
balance: number;
balanceLabel: string;
entries: PlatformWalletLedgerEntryPresentation[];
};
function getPlatformWalletLedgerSourceLabel(
sourceType: string | null | undefined,
) {
const normalizedSourceType = sourceType?.trim() ?? '';
if (!normalizedSourceType) {
return '未知来源';
}
return (
PLATFORM_WALLET_LEDGER_SOURCE_LABELS[
normalizedSourceType as ProfileWalletLedgerEntry['sourceType']
] ?? normalizedSourceType
);
}
function buildPlatformWalletLedgerEntryPresentation(
entry: ProfileWalletLedgerEntry,
): PlatformWalletLedgerEntryPresentation {
return {
amountLabel:
entry.amountDelta > 0 ? `+${entry.amountDelta}` : `${entry.amountDelta}`,
balanceLabel: `余额 ${entry.balanceAfter}`,
createdAt: entry.createdAt,
id: entry.id,
isIncome: entry.amountDelta > 0,
sourceLabel: getPlatformWalletLedgerSourceLabel(entry.sourceType),
};
}
export function buildPlatformWalletLedgerPresentation(
ledger: ProfileWalletLedgerResponse | null,
fallbackBalance: number,
): PlatformWalletLedgerPresentation {
const entries = ledger?.entries ?? [];
const balance = entries[0]?.balanceAfter ?? fallbackBalance;
return {
balance,
balanceLabel: `${balance}泥点`,
entries: entries.map(buildPlatformWalletLedgerEntryPresentation),
};
}
export function formatPlatformRechargePrice(priceCents: number) {
const yuan = priceCents / 100;
return `¥${Number.isInteger(yuan) ? yuan.toFixed(0) : yuan.toFixed(2)}`;
}
function parsePlatformProfileDate(value: string) {
const normalized = value.trim();
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
if (numericTimestamp?.[1]) {
const rawTimestamp = Number(numericTimestamp[1]);
if (Number.isFinite(rawTimestamp)) {
const absoluteTimestamp = Math.abs(rawTimestamp);
const timestampMs =
absoluteTimestamp >= 1_000_000_000_000_000
? rawTimestamp / 1000
: absoluteTimestamp >= 1_000_000_000_000
? rawTimestamp
: absoluteTimestamp >= 1_000_000_000
? rawTimestamp * 1000
: Number.NaN;
const date = new Date(timestampMs);
if (!Number.isNaN(date.getTime())) {
return date;
}
}
}
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatPlatformProfileTime(value: string | null) {
if (!value) {
return '未记录';
}
const date = parsePlatformProfileDate(value);
if (!date) {
return value;
}
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
@@ -7,14 +7,10 @@ import {
type ProfileRechargeOrder,
type ProfileRechargeProduct,
type ProfileReferralInviteCenterResponse,
type ProfileTaskCenterResponse,
type ProfileWalletLedgerResponse,
type RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import {
clearStoredAccessToken,
refreshStoredAccessToken,
} from '../../services/apiClient';
import { clearStoredAccessToken } from '../../services/apiClient';
import { type AuthUser, startWechatBind } from '../../services/authService';
import {
getHostRuntime,
@@ -31,25 +27,20 @@ import {
import { redirectToPaymentUrl } from '../../services/payment/paymentRedirect';
import { requestWechatJsapiPayment } from '../../services/payment/wechatJsapiPayment';
import {
claimRpgProfileTaskReward,
confirmWechatRpgProfileRechargeOrder,
createRpgProfileRechargeOrder,
getRpgProfileRechargeCenter,
getRpgProfileReferralInviteCenter,
getRpgProfileTasks,
getRpgProfileWalletLedger,
redeemRpgProfileReferralInviteCode,
redeemRpgProfileRewardCode,
watchWechatRpgProfileRechargeOrder,
} from '../../services/rpg-entry/rpgProfileClient';
confirmWechatPlatformProfileRechargeOrder,
createPlatformProfileRechargeOrder,
getPlatformProfileRechargeCenter,
getPlatformProfileReferralInviteCenter,
getPlatformProfileWalletLedger,
redeemPlatformProfileReferralInviteCode,
redeemPlatformProfileRewardCode,
watchWechatPlatformProfileRechargeOrder,
} from '../../services/platform-entry/platformProfileClient';
import {
type CopyFeedbackState,
useCopyFeedback,
} from '../common/useCopyFeedback';
const PROFILE_TASK_DAY_MS = 24 * 60 * 60 * 1000;
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;
@@ -106,21 +97,11 @@ type UsePlatformProfileCenterControllerArgs = {
activeTab: string;
isAuthenticated: boolean;
showRechargeEntry: boolean;
profileTaskRefreshKey?: number;
onRechargeSuccess?: () => void | Promise<void>;
requestLogin: () => void;
currentUser: AuthUser | null | undefined;
};
function getDelayUntilNextProfileTaskReset(nowMs = Date.now()) {
const shiftedNow = nowMs + PROFILE_TASK_BEIJING_OFFSET_MS;
const nextDayStart =
Math.floor(shiftedNow / PROFILE_TASK_DAY_MS) * PROFILE_TASK_DAY_MS +
PROFILE_TASK_DAY_MS;
const nextResetAt = nextDayStart - PROFILE_TASK_BEIJING_OFFSET_MS;
return Math.max(PROFILE_TASK_MIN_RESET_DELAY_MS, nextResetAt - nowMs);
}
function readProfileInviteCodeFromLocationSearch(search: string) {
const params = new URLSearchParams(search);
for (const key of PROFILE_INVITE_QUERY_KEYS) {
@@ -217,7 +198,7 @@ function isWechatRechargeOrderTerminalForConfirmation(
async function confirmWechatRechargeOrderUntilSettled(
orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId);
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
return latestResponse;
}
@@ -225,14 +206,15 @@ async function confirmWechatRechargeOrderUntilSettled(
for (const delayMs of WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS) {
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId);
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
return latestResponse;
}
}
try {
const streamedResponse = await watchWechatRpgProfileRechargeOrder(orderId);
const streamedResponse =
await watchWechatPlatformProfileRechargeOrder(orderId);
return streamedResponse;
} catch {
return latestResponse;
@@ -242,7 +224,7 @@ async function confirmWechatRechargeOrderUntilSettled(
async function confirmWechatRechargeOrderQuickly(
orderId: string,
): Promise<ConfirmWechatProfileRechargeOrderResponse> {
let latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
let latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId);
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
return latestResponse;
}
@@ -250,7 +232,7 @@ async function confirmWechatRechargeOrderQuickly(
for (const delayMs of WECHAT_NATIVE_CONFIRM_RETRY_DELAYS_MS) {
await waitWechatPayConfirmDelay(delayMs);
latestResponse = await confirmWechatRpgProfileRechargeOrder(orderId);
latestResponse = await confirmWechatPlatformProfileRechargeOrder(orderId);
if (isWechatRechargeOrderTerminalForConfirmation(latestResponse.order)) {
return latestResponse;
}
@@ -309,7 +291,6 @@ export function usePlatformProfileCenterController({
activeTab,
isAuthenticated,
showRechargeEntry,
profileTaskRefreshKey = 0,
onRechargeSuccess,
requestLogin,
currentUser,
@@ -345,14 +326,6 @@ export function usePlatformProfileCenterController({
null,
);
const [isLoadingWalletLedger, setIsLoadingWalletLedger] = useState(false);
const [isTaskCenterOpen, setIsTaskCenterOpen] = useState(false);
const [taskCenter, setTaskCenter] =
useState<ProfileTaskCenterResponse | null>(null);
const [taskCenterError, setTaskCenterError] = useState<string | null>(null);
const [isLoadingTaskCenter, setIsLoadingTaskCenter] = useState(false);
const taskCenterRequestIdRef = useRef(0);
const [claimingTaskId, setClaimingTaskId] = useState<string | null>(null);
const [taskClaimSuccess, setTaskClaimSuccess] = useState<string | null>(null);
const [profilePopupPanel, setProfilePopupPanel] =
useState<ProfilePopupPanel | null>(null);
const [referralCenter, setReferralCenter] =
@@ -406,7 +379,7 @@ export function usePlatformProfileCenterController({
const loadWalletLedger = useCallback(() => {
setWalletLedgerError(null);
setIsLoadingWalletLedger(true);
void getRpgProfileWalletLedger()
void getPlatformProfileWalletLedger()
.then(setWalletLedger)
.catch((error: unknown) => {
setWalletLedger(null);
@@ -436,7 +409,7 @@ export function usePlatformProfileCenterController({
const revision = ++rechargeCenterReadRevisionRef.current;
setRechargeError(null);
setIsLoadingRechargeCenter(true);
void getRpgProfileRechargeCenter()
void getPlatformProfileRechargeCenter()
.then((center) => {
if (revision === rechargeCenterReadRevisionRef.current) {
setRechargeCenter(center);
@@ -639,7 +612,7 @@ export function usePlatformProfileCenterController({
setRechargePaymentResult(null);
setWechatRechargeOrderConfirmationState(null);
setNativeWechatPayment(null);
void createRpgProfileRechargeOrder(product.productId, paymentChannel)
void createPlatformProfileRechargeOrder(product.productId, paymentChannel)
.then(async (response) => {
if (paymentChannel === WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_CHANNEL) {
pendingWechatRechargeOrderIdRef.current = response.order.orderId;
@@ -851,9 +824,12 @@ export function usePlatformProfileCenterController({
const watchUntilSettled = async () => {
while (!cancelled && Date.now() < expiresAtMs) {
try {
const response = await watchWechatRpgProfileRechargeOrder(orderId, {
signal: abortController.signal,
});
const response = await watchWechatPlatformProfileRechargeOrder(
orderId,
{
signal: abortController.signal,
},
);
if (
cancelled ||
!response ||
@@ -970,90 +946,10 @@ export function usePlatformProfileCenterController({
wechatRechargeOrderConfirmationState,
]);
const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then((center) => {
if (requestId === taskCenterRequestIdRef.current) {
setTaskCenter(center);
}
})
.catch((error: unknown) => {
if (requestId !== taskCenterRequestIdRef.current) {
return;
}
setTaskCenter(null);
setTaskCenterError(
error instanceof Error ? error.message : '读取每日任务失败',
);
})
.finally(() => {
if (requestId === taskCenterRequestIdRef.current) {
setIsLoadingTaskCenter(false);
}
});
}, []);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
taskCenterRequestIdRef.current += 1;
setTaskCenter(null);
setTaskCenterError(null);
return;
}
loadTaskCenter();
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
useEffect(() => {
if (activeTab !== 'profile' || !isAuthenticated) {
return undefined;
}
// 中文注释:每日任务重置依赖北京时间跨天与 access token 刷新,继续留在 controller 里集中托管。
let cancelled = false;
let timer: number | null = null;
const scheduleNextReset = () => {
if (cancelled) {
return;
}
timer = window.setTimeout(() => {
void refreshStoredAccessToken({ clearOnFailure: false })
.catch(() => undefined)
.finally(() => {
if (cancelled) {
return;
}
loadTaskCenter();
scheduleNextReset();
});
}, getDelayUntilNextProfileTaskReset());
};
scheduleNextReset();
return () => {
cancelled = true;
if (timer !== null) {
window.clearTimeout(timer);
}
};
}, [activeTab, isAuthenticated, loadTaskCenter]);
const openTaskCenterPanel = useCallback(() => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
if (!taskCenter) {
loadTaskCenter();
}
}, [loadTaskCenter, taskCenter]);
const loadReferralCenter = useCallback(() => {
setIsLoadingReferral(true);
setIsReferralCenterInitialized(false);
void getRpgProfileReferralInviteCenter()
void getPlatformProfileReferralInviteCenter()
.then(setReferralCenter)
.catch((error: unknown) => {
setReferralCenter(null);
@@ -1133,7 +1029,7 @@ export function usePlatformProfileCenterController({
setIsSubmittingReferralRedeem(true);
setReferralError(null);
setReferralSuccess(null);
void redeemRpgProfileReferralInviteCode(inviteCode)
void redeemPlatformProfileReferralInviteCode(inviteCode)
.then((response) => {
setReferralCenter(response.center);
setReferralRedeemCode('');
@@ -1156,7 +1052,7 @@ export function usePlatformProfileCenterController({
setIsSubmittingRewardCode(true);
setRewardCodeError(null);
setRewardCodeSuccess(null);
void redeemRpgProfileRewardCode(rewardCodeInput)
void redeemPlatformProfileRewardCode(rewardCodeInput)
.then((response: RedeemProfileRewardCodeResponse) => {
setRewardCodeInput('');
setRewardCodeSuccess(`已到账 ${response.amountGranted} 泥点`);
@@ -1168,57 +1064,26 @@ export function usePlatformProfileCenterController({
.finally(() => setIsSubmittingRewardCode(false));
}, [isSubmittingRewardCode, onRechargeSuccess, rewardCodeInput]);
const claimTaskReward = useCallback(
(taskId: string) => {
if (claimingTaskId) {
return;
}
setClaimingTaskId(taskId);
setTaskCenterError(null);
setTaskClaimSuccess(null);
void claimRpgProfileTaskReward(taskId)
.then((response) => {
setTaskCenter(response.center);
setTaskClaimSuccess(`已领取 ${response.rewardPoints} 泥点`);
void onRechargeSuccess?.();
})
.catch((error: unknown) => {
setTaskCenterError(
error instanceof Error ? error.message : '领取任务奖励失败',
);
})
.finally(() => setClaimingTaskId(null));
},
[claimingTaskId, onRechargeSuccess],
);
return {
claimTaskReward,
claimingTaskId,
closeNativeWechatPayment,
closeProfilePopupPanel,
confirmNativeWechatPayment,
inviteCopyState: inviteCopyState as CopyFeedbackState,
isLoadingRechargeCenter,
isLoadingReferral,
isLoadingTaskCenter,
isLoadingWalletLedger,
isRechargeOpen,
isRewardCodeOpen,
isSubmittingReferralRedeem,
isSubmittingRewardCode,
isTaskCenterOpen,
isWalletLedgerOpen,
loadRechargeCenter,
loadReferralCenter,
loadTaskCenter,
loadWalletLedger,
nativeWechatPayment,
openProfilePopupPanel,
openRechargeOrRewardCodeModal,
openRewardCodeModal,
openTaskCenterPanel,
openWalletLedgerPanel,
profilePopupPanel,
rechargeCenter,
@@ -1233,7 +1098,6 @@ export function usePlatformProfileCenterController({
rewardCodeSuccess,
setIsRechargeOpen,
setIsRewardCodeOpen,
setIsTaskCenterOpen,
setIsWalletLedgerOpen,
setRechargePaymentResult,
setReferralRedeemCode,
@@ -1242,9 +1106,6 @@ export function usePlatformProfileCenterController({
submittingRechargeProductId,
submitReferralRedeemCode,
submitRewardCode,
taskCenter,
taskCenterError,
taskClaimSuccess,
walletLedger,
walletLedgerError,
wechatRechargeOrderConfirmationState,