Merge remote-tracking branch 'origin/master' into codex/wechat-mini-program-virtual-payment

# Conflicts:
#	.hermes/shared-memory/decision-log.md
This commit is contained in:
kdletters
2026-05-27 20:35:32 +08:00
256 changed files with 10164 additions and 6985 deletions

View File

@@ -84,7 +84,6 @@ import {
} from '../../services/edutainment-baby-object';
import { match3dCreationClient } from '../../services/match3d-creation';
import {
createLocalMatch3DRuntimeAdapter,
createServerMatch3DRuntimeAdapter,
} from '../../services/match3d-runtime';
import {
@@ -674,7 +673,6 @@ vi.mock('../../services/match3dGeneratedModelCache', () => ({
}));
const match3dRuntimeServiceMocks = vi.hoisted(() => ({
createLocalMatch3DRuntimeAdapter: vi.fn(),
createServerMatch3DRuntimeAdapter: vi.fn(),
}));
@@ -687,15 +685,6 @@ const match3dServerRuntimeAdapterMock = vi.hoisted(() => ({
stopRun: vi.fn(),
}));
const match3dLocalRuntimeAdapterMock = vi.hoisted(() => ({
clickItem: vi.fn(),
finishTimeUp: vi.fn(),
getRun: vi.fn(),
restartRun: vi.fn(),
startRun: vi.fn(),
stopRun: vi.fn(),
}));
vi.mock('../../services/match3d-runtime', async () => {
const actual = await vi.importActual<
typeof import('../../services/match3d-runtime')
@@ -2405,9 +2394,6 @@ beforeEach(() => {
vi.mocked(createServerMatch3DRuntimeAdapter).mockReturnValue(
match3dServerRuntimeAdapterMock,
);
vi.mocked(createLocalMatch3DRuntimeAdapter).mockReturnValue(
match3dLocalRuntimeAdapterMock,
);
match3dServerRuntimeAdapterMock.startRun.mockRejectedValue(
new Error('未启动抓大鹅运行态'),
);
@@ -2423,21 +2409,6 @@ beforeEach(() => {
match3dServerRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-profile-stopped'),
});
match3dLocalRuntimeAdapterMock.startRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.clickItem.mockRejectedValue(
new Error('未执行本地抓大鹅点击'),
);
match3dLocalRuntimeAdapterMock.restartRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.finishTimeUp.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
match3dLocalRuntimeAdapterMock.stopRun.mockResolvedValue({
run: buildMockMatch3DRun('match3d-demo-20260525'),
});
window.history.replaceState(null, '', '/');
window.sessionStorage.clear();
window.localStorage.clear();
@@ -5080,6 +5051,22 @@ test('completed match3d draft notice first opens trial then reopens result', asy
resolveCompile({ session: generatedSession });
});
const completionDialog = await screen.findByRole('dialog', {
name: '生成完成',
});
expect(
within(completionDialog).getByText(
/抓大鹅草稿 match3d-notice-session-1/u,
),
).toBeTruthy();
expect(
within(completionDialog).getByText(/生成任务已完成/u),
).toBeTruthy();
expect(
within(completionDialog).getByRole('button', { name: '复制内容' }),
).toBeTruthy();
await user.click(within(completionDialog).getByLabelText('关闭'));
expect(await screen.findByLabelText('新生成完成')).toBeTruthy();
await user.click(
await screen.findByRole('button', {
@@ -7503,6 +7490,48 @@ test('persisted generating puzzle draft keeps session polling on the same sessio
expect(getPuzzleAgentSession).toHaveBeenCalledTimes(2);
});
test('puzzle compile timeout shows failure dialog when reread session is still generating', async () => {
const user = userEvent.setup();
const runningSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-timeout',
draft: null,
stage: 'collecting_anchors',
progressPercent: 88,
lastAssistantReply: '正在生成拼图草稿。',
});
vi.mocked(createPuzzleAgentSession).mockResolvedValueOnce({
session: runningSession,
});
vi.mocked(executePuzzleAgentAction).mockRejectedValueOnce(
Object.assign(new Error('请求超时1800000ms'), {
name: 'TimeoutError',
}),
);
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: runningSession,
});
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('拼图'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
const dialog = await screen.findByRole('dialog', { name: '发生错误' });
expect(within(dialog).getByText('拼图草稿 puzzle-session-timeout')).toBeTruthy();
expect(
within(dialog).getByText(
'拼图共创操作超时,请确认运行时后端已启动后重试。',
),
).toBeTruthy();
expect(within(dialog).getByRole('button', { name: '复制报错' })).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
});
test('published puzzle work card restores its source session for editing', async () => {
const user = userEvent.setup();
@@ -8332,38 +8361,6 @@ test('public code search opens a published Match3D work by M3 code and starts ru
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
});
test('public code search opens the local Match3D demo and starts local runtime', async () => {
const user = userEvent.setup();
vi.mocked(listMatch3DGallery).mockResolvedValue({ items: [] });
render(<TestWrapper withAuth />);
await openDiscoverHub(user);
const searchInput =
await screen.findByPlaceholderText('搜索作品号、名称、作者、描述');
await user.type(searchInput, 'M3-20260525');
await user.click(screen.getByRole('button', { name: '搜索' }));
expect(await screen.findByText('详情')).toBeTruthy();
expect(screen.getByText('海底糖果集市')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '启动' }));
await waitFor(() => {
expect(match3dLocalRuntimeAdapterMock.startRun).toHaveBeenCalledWith(
'match3d-demo-20260525',
{},
);
});
expect(match3dServerRuntimeAdapterMock.startRun).not.toHaveBeenCalled();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-demo-20260525',
);
expect(
await screen.findByText('抓大鹅运行态match3d-run-match3d-demo-20260525'),
).toBeTruthy();
});
test('published Match3D runtime receives persisted generated models', async () => {
const user = userEvent.setup();
const match3dWork: Match3DWorkSummary = {

View File

@@ -702,14 +702,22 @@ function mockNarrowMobileLayout() {
});
}
function renderProfileView(
function ProfileHomeViewHarness({
onRechargeSuccess = vi.fn(),
profileDashboardOverrides: Partial<
profileDashboardOverrides = {},
userOverrides = {},
activeTab = 'profile',
profileTaskRefreshKey = 0,
}: {
onRechargeSuccess?: () => void | Promise<void>;
profileDashboardOverrides?: Partial<
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
> = {},
userOverrides: Partial<AuthUser> = {},
) {
return render(
>;
userOverrides?: Partial<AuthUser>;
activeTab?: RpgEntryHomeViewProps['activeTab'];
profileTaskRefreshKey?: number;
}) {
return (
<AuthUiContext.Provider
value={{
user: {
@@ -742,7 +750,7 @@ function renderProfileView(
}}
>
<RpgEntryHomeView
activeTab="profile"
activeTab={activeTab}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -772,8 +780,27 @@ function renderProfileView(
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
onRechargeSuccess={onRechargeSuccess}
profileTaskRefreshKey={profileTaskRefreshKey}
/>
</AuthUiContext.Provider>,
</AuthUiContext.Provider>
);
}
function renderProfileView(
onRechargeSuccess = vi.fn(),
profileDashboardOverrides: Partial<
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
> = {},
userOverrides: Partial<AuthUser> = {},
profileTaskRefreshKey = 0,
) {
return render(
<ProfileHomeViewHarness
onRechargeSuccess={onRechargeSuccess}
profileDashboardOverrides={profileDashboardOverrides}
userOverrides={userOverrides}
profileTaskRefreshKey={profileTaskRefreshKey}
/>,
);
}
@@ -1902,11 +1929,18 @@ test('non-wechat profile opens reward code from recharge-shaped entry', async ()
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('profile daily task shortcut opens task center and claims reward', async () => {
test('profile daily task shortcut reflects task progress and claim updates', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
const dailyTask = screen.getByRole('button', { name: //u });
await waitFor(() => {
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
});
expect(within(dailyTask).getByText('领取')).toBeTruthy();
await user.click(screen.getByRole('button', { name: //u }));
expect(await screen.findByText('每日登录')).toBeTruthy();
@@ -1923,6 +1957,7 @@ test('profile daily task shortcut opens task center and claims reward', async ()
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
expect(screen.getByText('暂无任务')).toBeTruthy();
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
});
test('profile task center keeps only the highest priority actionable task', async () => {
@@ -1985,7 +2020,7 @@ test('profile task center keeps only the highest priority actionable task', asyn
expect(screen.queryByText('低优先级已完成')).toBeNull();
});
test('profile total play time card always uses hours', () => {
test('profile total play time card always uses hours', async () => {
renderProfileView(vi.fn(), {
totalPlayTimeMs: 90 * 60 * 1000,
});
@@ -1996,9 +2031,10 @@ test('profile total play time card always uses hours', () => {
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
expect(within(playTimeCard).queryByText('90分')).toBeNull();
await screen.findByText('1 / 1');
});
test('profile played works card shows count unit', () => {
test('profile played works card shows count unit', async () => {
renderProfileView(vi.fn(), {
playedWorldCount: 1,
});
@@ -2008,9 +2044,10 @@ test('profile played works card shows count unit', () => {
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
await screen.findByText('1 / 1');
});
test('profile stats cards are centered without update timestamp', () => {
test('profile stats cards are centered without update timestamp', async () => {
renderProfileView(vi.fn(), {
updatedAt: '2026-05-03T08:01:00Z',
});
@@ -2026,6 +2063,7 @@ test('profile stats cards are centered without update timestamp', () => {
expect(card.className).toContain('text-center');
}
expect(screen.queryByText(//u)).toBeNull();
await screen.findByText('1 / 1');
});
test('mobile profile page matches the reference layout sections', async () => {
@@ -2083,7 +2121,7 @@ test('mobile profile page matches the reference layout sections', async () => {
expect(dailyTask.querySelector('.platform-profile-daily-task-card__desc')).toBeTruthy();
expect(dailyTask.querySelector('.platform-profile-daily-task-card__progress')).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
expect(within(dailyTask).getByText('0 / 1')).toBeTruthy();
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
@@ -2091,7 +2129,7 @@ test('mobile profile page matches the reference layout sections', async () => {
).toBeTruthy();
expect(
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
).toHaveLength(5);
).toHaveLength(4);
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
@@ -2099,7 +2137,6 @@ test('mobile profile page matches the reference layout sections', async () => {
).toBe(true);
for (const label of [
'泥点充值',
'邀请好友',
'兑换码',
'玩家社区',
'反馈与建议',
@@ -2177,7 +2214,7 @@ test('profile scan action opens camera scanner instead of recharge panel', async
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
test('desktop account entry uses saved avatar image when available', () => {
test('desktop account entry uses saved avatar image when available', async () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -2187,6 +2224,7 @@ test('desktop account entry uses saved avatar image when available', () => {
const avatarImage = accountEntry.querySelector('img');
expect(avatarImage?.getAttribute('src')).toBe(avatarUrl);
expect(within(accountEntry).queryByText('测')).toBeNull();
await screen.findByText('1 / 1');
});
test('profile avatar upload uses the shared square crop tool', async () => {
@@ -2236,83 +2274,83 @@ test('wallet ledger modal shows empty and error states', async () => {
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('profile invite shortcut shows reward subtitle and invited users', async () => {
test('profile community shortcut shows reward subtitle and invited users', async () => {
const user = userEvent.setup();
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
const inviteButton = screen.getByRole('button', { name: //u });
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
await user.click(inviteButton);
await user.click(communityButton);
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('邀请一个用户注册双方都可以获得30泥点。'),
).toBeTruthy();
expect(screen.getByText('每日最多获得十次邀请奖励。')).toBeTruthy();
expect(screen.getByText('成功邀请')).toBeTruthy();
expect(screen.getByText('被邀请玩家')).toBeTruthy();
expect(screen.queryByText('已奖')).toBeNull();
expect(screen.queryByText('今日')).toBeNull();
expect(screen.getByAltText('玩家社区微信群二维码')).toBeTruthy();
expect(screen.getByAltText('玩家社区 QQ 群二维码')).toBeTruthy();
expect(screen.getByText('微信群')).toBeTruthy();
expect(screen.getByText('QQ群')).toBeTruthy();
expect(screen.queryByText('成功邀请')).toBeNull();
expect(screen.queryByText('被邀请玩家')).toBeNull();
});
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
test('profile page hides legacy redeem invite secondary shortcut for fresh accounts', async () => {
renderProfileView(
vi.fn(),
{},
{ createdAt: buildFreshProfileCreatedAt() },
);
const inviteButton = screen.getByRole('button', { name: //u });
const redeemButton = await screen.findByRole('button', {
name: //u,
});
const communityButton = screen.getByRole('button', { name: //u });
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
await waitFor(() => {
expect(mockGetRpgProfileReferralInviteCenter).toHaveBeenCalledTimes(1);
});
expect(inviteButton).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(communityButton).toBeTruthy();
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
});
test('profile redeem invite shortcut hides after redeemed or one day old', async () => {
const user = userEvent.setup();
mockGetRpgProfileReferralInviteCenter.mockResolvedValueOnce(
mockBuildReferralCenter({
invitedUsers: [],
hasRedeemedCode: true,
boundInviterUserId: 'user-2',
boundAt: '2026-05-01T08:00:00Z',
}),
);
const { unmount } = renderProfileView();
await user.click(screen.getByRole('button', { name: //u }));
await screen.findByText('成功邀请');
const firstShortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(firstShortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(firstShortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
await waitFor(() => {
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
});
await act(async () => {
await Promise.resolve();
});
unmount();
renderProfileView(vi.fn(), {}, { createdAt: '2026-04-01T00:00:00.000Z' });
const expiredShortcutRegion = screen.getByRole('region', {
name: '常用功能',
});
expect(
within(expiredShortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
expect(
within(expiredShortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
await waitFor(() => {
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
});
await act(async () => {
await Promise.resolve();
});
});
test('invite query opens login modal for logged out users', async () => {
@@ -2345,9 +2383,10 @@ test('profile redeem invite modal reads query invite code after login', async ()
expect((input as HTMLInputElement).value).toBe('SPRING2026');
});
test('profile redeem invite modal submits code and hides shortcut after success', async () => {
test('profile redeem invite query modal submits code after login', async () => {
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
window.history.replaceState(null, '', '/?inviteCode=spring-2026');
renderProfileView(
onRechargeSuccess,
@@ -2355,9 +2394,7 @@ test('profile redeem invite modal submits code and hides shortcut after success'
{ createdAt: buildFreshProfileCreatedAt() },
);
await user.click(await screen.findByRole('button', { name: //u }));
const input = await screen.findByLabelText('邀请码');
await user.type(input, 'spring-2026');
expect(await screen.findByLabelText('邀请码')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '提交' }));
await waitFor(() => {
@@ -2367,12 +2404,23 @@ test('profile redeem invite modal submits code and hides shortcut after success'
});
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
expect(await screen.findByText('已填写')).toBeTruthy();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', {
name: //u,
}),
).toBeNull();
expect(screen.queryByRole('region', { name: '次级入口' })).toBeNull();
});
test('profile task center reloads when refresh key changes', async () => {
const { rerender } = renderProfileView();
await waitFor(() => {
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
});
rerender(
<ProfileHomeViewHarness profileTaskRefreshKey={1} />,
);
await waitFor(() => {
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
});
});
test('opens reward code modal from profile action on mobile', async () => {
@@ -2402,8 +2450,8 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
@@ -3274,6 +3322,41 @@ test('logged out active recommend bottom tab selects next work without login', a
expect(openLoginModal).not.toHaveBeenCalled();
});
test('logged out recommend card supports vertical swipe without login', () => {
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(openLoginModal, {
latestEntries: [
puzzlePublicEntry,
{
...puzzlePublicEntry,
workId: 'puzzle-work-guest-next',
profileId: 'puzzle-profile-guest-next',
ownerUserId: 'user-guest-next',
publicWorkCode: 'PZ-GUEST-NEXT',
worldName: '访客下一张',
},
],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectNextRecommendEntry,
recommendRuntimeContent: <div data-testid="guest-recommend-runtime" />,
});
const meta = screen.getByLabelText('奇幻拼图 作品信息') as HTMLElement;
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 320 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 220 });
dispatchPointerEvent(meta, 'pointerup', { pointerId: 1, clientY: 220 });
vi.advanceTimersByTime(180);
});
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
vi.useRealTimers();
});
test('mobile recommend meta loads real author avatar from public user summary', async () => {
mockGetPublicAuthUserById.mockResolvedValueOnce({
id: 'user-2',

View File

@@ -29,7 +29,6 @@ import {
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
XCircle,
} from 'lucide-react';
@@ -50,7 +49,6 @@ import profileClockImage from '../../../media/profile/_Image (1).png';
import profileGamepadImage from '../../../media/profile/_Image (2).png';
import profileStillLifeImage from '../../../media/profile/_Image (3).png';
import profileCoinsImage from '../../../media/profile/_Image (4).png';
import profileInviteImage from '../../../media/profile/_Image (5).png';
import profileGiftImage from '../../../media/profile/_Image (6).png';
import profileCommunityImage from '../../../media/profile/_Image (7).png';
import profileFeedbackImage from '../../../media/profile/_Image (8).png';
@@ -79,7 +77,6 @@ import type {
WechatMiniProgramVirtualPayParams,
WechatNativePayment,
} from '../../../packages/shared/src/contracts/runtime';
import { isMatch3DDemoProfileId } from '../../data/match3dDemoGalleryCard';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
@@ -218,6 +215,7 @@ export interface RpgEntryHomeViewProps {
onOpenPlayedWork?: (work: ProfilePlayedWorkSummary) => void;
onOpenFeedback?: () => void;
onRechargeSuccess?: () => void | Promise<void>;
profileTaskRefreshKey?: number;
createTabContent?: ReactNode;
draftTabContent?: ReactNode;
hasUnreadDraftUpdate?: boolean;
@@ -257,18 +255,25 @@ const RECOMMEND_ENTRY_DRAG_LIMIT_PX = 160;
const WECHAT_JS_SDK_URL = 'https://res.wx.qq.com/open/js/jweixin-1.6.0.js';
const WECHAT_PAY_CONFIRM_RETRY_DELAYS_MS = [800, 1600, 3000] as const;
const WECHAT_NATIVE_PAY_QR_IMAGE_SIZE = 180;
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<ProfileTaskItem['status'], number> = {
const PROFILE_TASK_STATUS_PRIORITY_RANK: Record<
ProfileTaskItem['status'],
number
> = {
claimable: 2,
incomplete: 1,
disabled: 0,
claimed: -1,
};
const PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS = 10;
const PROFILE_QR_SCAN_INTERVAL_MS = 360;
function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
return tasks
.map((task, index) => ({ task, index }))
.filter(({ task }) => task.status === 'claimable' || task.status === 'incomplete')
.filter(
({ task }) =>
task.status === 'claimable' || task.status === 'incomplete',
)
.sort(
(left, right) =>
PROFILE_TASK_STATUS_PRIORITY_RANK[right.task.status] -
@@ -279,6 +284,37 @@ function selectProfileTaskCenterTasks(tasks: ProfileTaskItem[]) {
.map(({ task }) => task);
}
function selectProfileTaskCardTask(tasks: ProfileTaskItem[]) {
return (
selectProfileTaskCenterTasks(tasks)[0] ??
tasks.find((task) => task.status === 'claimed') ??
tasks.find((task) => task.status !== 'disabled') ??
null
);
}
function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
const task = selectProfileTaskCardTask(center?.tasks ?? []);
const threshold = Math.max(1, task?.threshold ?? 1);
const progressCount = Math.min(task?.progressCount ?? 0, threshold);
const rewardPoints =
task?.rewardPoints ?? PROFILE_TASK_CARD_FALLBACK_REWARD_POINTS;
const actionLabel =
task?.status === 'claimable'
? '领取'
: task?.status === 'claimed'
? '已完成'
: '去完成';
return {
actionLabel,
progressCount,
progressPercent: Math.round((progressCount / threshold) * 100),
rewardPoints,
threshold,
};
}
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
type BarcodeDetectorLike = {
@@ -2451,42 +2487,6 @@ function ProfileSettingsRow({
);
}
function ProfileSecondaryShortcutButton({
label,
subLabel,
icon,
onClick,
}: {
label: string;
subLabel?: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-secondary-shortcut inline-flex items-center gap-2 rounded-full px-3 py-2 text-left"
>
<span className="platform-profile-secondary-shortcut__icon">
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0">
<span className="block truncate text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
{subLabel ? (
<span className="mt-0.5 block truncate text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</span>
) : null}
</span>
</button>
);
}
function ProfileLegalSection({
onOpenDocument,
}: {
@@ -3987,6 +3987,7 @@ export function RpgEntryHomeView({
onOpenPlayedWork,
onOpenFeedback,
onRechargeSuccess,
profileTaskRefreshKey = 0,
createTabContent,
draftTabContent,
hasUnreadDraftUpdate = false,
@@ -4028,6 +4029,7 @@ export function RpgEntryHomeView({
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 [isQrScannerOpen, setIsQrScannerOpen] = useState(false);
@@ -4047,6 +4049,7 @@ export function RpgEntryHomeView({
: readProfileInviteCodeFromLocationSearch(window.location.search),
[],
);
const promptedLoginForInviteQueryRef = useRef(false);
const autoOpenedInviteQueryRef = useRef(false);
const [referralRedeemCode, setReferralRedeemCode] = useState(
pendingProfileInviteCode,
@@ -4225,12 +4228,10 @@ export function RpgEntryHomeView({
profileDashboard?.totalPlayTimeMs ?? 0,
);
const playedWorkCount = profileDashboard?.playedWorldCount ?? 0;
const canShowReferralRedeemShortcut =
isAuthenticated &&
isWithinProfileInviteRedeemWindow(authUi?.user?.createdAt) &&
isReferralCenterInitialized &&
Boolean(referralCenter) &&
referralCenter?.hasRedeemedCode !== true;
const profileTaskCardSummary = useMemo(
() => buildProfileTaskCardSummary(taskCenter),
[taskCenter],
);
const tabIcons: Record<
PlatformHomeTab,
ComponentType<{ className?: string }>
@@ -4299,19 +4300,13 @@ export function RpgEntryHomeView({
return;
}
const firstCategoryGroup =
categoryGroups.find((group) =>
group.entries.some((entry) => !isMatch3DDemoProfileId(entry.profileId)),
) ?? categoryGroups[0];
const firstCategoryGroup = categoryGroups[0];
const selectedCategoryGroup =
categoryGroups.find((group) => group.tag === selectedCategoryTag) ?? null;
if (
firstCategoryGroup &&
(!selectedCategoryGroup ||
(!hasManualCategoryTagSelectionRef.current &&
selectedCategoryGroup.entries.every((entry) =>
isMatch3DDemoProfileId(entry.profileId),
) &&
firstCategoryGroup.tag !== selectedCategoryGroup.tag))
) {
setSelectedCategoryTag(firstCategoryGroup.tag);
@@ -4397,12 +4392,15 @@ export function RpgEntryHomeView({
return;
}
autoOpenedInviteQueryRef.current = true;
if (!authUi?.user) {
authUi?.openLoginModal();
if (!promptedLoginForInviteQueryRef.current) {
promptedLoginForInviteQueryRef.current = true;
authUi?.openLoginModal();
}
return;
}
autoOpenedInviteQueryRef.current = true;
setReferralRedeemCode(pendingProfileInviteCode);
setReferralError(null);
setReferralSuccess(null);
@@ -4803,23 +4801,49 @@ export function RpgEntryHomeView({
document.removeEventListener('visibilitychange', handleResume);
};
}, [handleWechatPayResult]);
const loadTaskCenter = () => {
const loadTaskCenter = useCallback(() => {
const requestId = ++taskCenterRequestIdRef.current;
setTaskCenterError(null);
setIsLoadingTaskCenter(true);
void getRpgProfileTasks()
.then(setTaskCenter)
.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(() => setIsLoadingTaskCenter(false));
};
.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]);
const openTaskCenterPanel = () => {
setIsTaskCenterOpen(true);
setTaskClaimSuccess(null);
loadTaskCenter();
if (!taskCenter) {
loadTaskCenter();
}
};
const openQrScannerPanel = () => {
if (!authUi?.user) {
@@ -5266,7 +5290,6 @@ export function RpgEntryHomeView({
(event: PointerEvent<HTMLElement>) => {
if (
recommendDragCommitDirection ||
!isAuthenticated ||
!activeRecommendEntry ||
recommendedFeedEntries.length <= 1
) {
@@ -5282,7 +5305,6 @@ export function RpgEntryHomeView({
},
[
activeRecommendEntry,
isAuthenticated,
recommendDragCommitDirection,
recommendedFeedEntries.length,
],
@@ -6223,14 +6245,24 @@ export function RpgEntryHomeView({
</span>
<span className="platform-profile-daily-task-card__desc mt-4 block text-[13px] font-medium text-[var(--platform-text-base)]">
<span className="text-[#c45b2a]">10</span>
{' '}
<span className="text-[#c45b2a]">
{profileTaskCardSummary.rewardPoints}
</span>{' '}
</span>
<span className="platform-profile-daily-task-card__progress mt-4 flex items-center gap-3">
<span className="platform-profile-daily-task-card__progress-value text-[14px] font-semibold text-[#dc3f0e]">
0 / 1
{profileTaskCardSummary.progressCount} /{' '}
{profileTaskCardSummary.threshold}
</span>
<span className="platform-profile-daily-task-card__track">
<span className="platform-profile-daily-task-card__bar" />
<span
className="platform-profile-daily-task-card__bar"
style={{
width: `${profileTaskCardSummary.progressPercent}%`,
}}
/>
</span>
</span>
</span>
@@ -6240,7 +6272,7 @@ export function RpgEntryHomeView({
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
{profileTaskCardSummary.actionLabel}
</span>
</button>
@@ -6256,13 +6288,6 @@ export function RpgEntryHomeView({
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="邀请好友"
subLabel="双方得 30 泥点"
icon={UserPlus}
imageSrc={profileInviteImage}
onClick={() => openProfilePopupPanel('invite')}
/>
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
@@ -6305,20 +6330,6 @@ export function RpgEntryHomeView({
/>
</section>
{canShowReferralRedeemShortcut ? (
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
</section>
) : null}
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
</>
) : (

View File

@@ -9,6 +9,9 @@ import type { CustomWorldProfile } from '../../types';
export function resolveRpgEntryErrorMessage(error: unknown, fallback: string) {
if (isTimeoutError(error)) {
if (//u.test(fallback) && /|||稿/u.test(fallback)) {
return '拼图共创操作超时,请确认运行时后端已启动后重试。';
}
if (//u.test(fallback)) {
return '开启智能创作工作区超时,请确认运行时后端已启动后重试。';
}

View File

@@ -10,7 +10,6 @@ import {
formatPlatformWorldTime,
isBarkBattleGalleryEntry,
isEdutainmentGalleryEntry,
isMatch3DGalleryEntry,
isVisualNovelGalleryEntry,
isWoodenFishGalleryEntry,
mapBabyObjectMatchDraftToPlatformGalleryCard,
@@ -22,7 +21,6 @@ import {
resolvePlatformPublicWorkCode,
resolvePlatformWorldFallbackCoverImage,
} from './rpgEntryWorldPresentation';
import { buildMatch3DDemoGalleryCard } from '../../data/match3dDemoGalleryCard';
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
@@ -80,24 +78,6 @@ test('platform public cards use play type reference images as cover fallback', (
);
});
test('builds local Match3D demo gallery card with generated runtime assets intact', () => {
const card = buildMatch3DDemoGalleryCard();
expect(isMatch3DGalleryEntry(card)).toBe(true);
expect(card.publicWorkCode).toBe('M3-20260525');
expect(resolvePlatformPublicWorkCode(card)).toBe('M3-20260525');
expect(card.coverImageSrc).toBe(
'/match3d-demo/undersea-candy-market/level-scene.png',
);
expect(card.generatedBackgroundAsset?.uiSpritesheetImageSrc).toBe(
'/match3d-demo/undersea-candy-market/ui-spritesheet.png',
);
expect(card.generatedBackgroundAsset?.containerImageSrc).toBeNull();
expect(card.generatedItemAssets?.[0]?.imageViews?.[0]?.imageSrc).toBe(
'/match3d-demo/undersea-candy-market/item-slices/item-01/view-01.png',
);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',