Refine creation tab UX, generation flow, and bindings

Large changes across frontend, backend and docs to align creation-tab and generation-page behavior with new product UI/UX and Spacetime bindings. Updated hermes decision-log and pitfalls with concrete rules (banner carousel, font sizing, unread-dot tokens, template-card layout, direct card->entry routing, separation of account balance vs prize pools, removal of global page card shell, generation progress milestones and unified circular progress, and background video handling). Added GenerationProgressHero component and media assets, plus generation-related UI/tests updates (CustomWorldGenerationView, BarkBattleGeneratingView, creation hub/cards, platform entry routing, index tests). Backend and contract updates include new category fields in admin API types and admin UI form/list, spacetime-client/module/migration changes and generated bindings script. Misc: many tests adjusted, new docs and plan files added, and several server-rs crate changes to support the updated creation/ generation workflows.
This commit is contained in:
2026-05-25 00:41:30 +08:00
parent 2ba4691bc0
commit 50a0d6f982
75 changed files with 5533 additions and 1101 deletions

View File

@@ -35,8 +35,8 @@ import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import {
readPublicWorkCodeFromLocationSearch,
resolveSelectionStageFromPath,
@@ -196,9 +196,30 @@ async function clickFirstAsyncButtonByName(
async function openCreateTemplateHub(user: ReturnType<typeof userEvent.setup>) {
await clickFirstButtonByName(user, '创作');
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tab', { name: '拼图' })).toBeTruthy();
expect(screen.getByText('拼图工作区missing-session')).toBeTruthy();
const panel = getPlatformTabPanel('create');
await waitFor(() => {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(
await within(panel).findByRole('button', { name: //u }),
).toBeTruthy();
expect(within(panel).queryByText('拼图工作区missing-session')).toBeNull();
return panel;
}
async function findCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).findByRole('button', { name: matcher });
}
function queryCreationTypeButton(name: string | RegExp) {
const matcher =
typeof name === 'string' ? new RegExp(name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'u') : name;
return within(getPlatformTabPanel('create')).queryByRole('button', { name: matcher });
}
async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
@@ -208,7 +229,7 @@ async function openDraftHub(user: ReturnType<typeof userEvent.setup>) {
expect(panel.getAttribute('aria-hidden')).toBe('false');
});
expect(
await within(panel).findByRole('button', { name: //u }),
await within(panel).findByRole('tab', { name: //u }),
).toBeTruthy();
}
@@ -276,6 +297,14 @@ const testCreationEntryConfig = {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
eventBanner: {
title: '泥点挑战',
description: '创作活动测试横幅。',
coverImageSrc: '/creation-type-references/puzzle.webp',
prizePoolMudPoints: 1000,
startsAtText: '2026-05-01',
endsAtText: '2026-05-31',
},
creationTypes: [
{
id: 'rpg',
@@ -286,6 +315,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 10,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -297,6 +329,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 30,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -308,6 +343,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 40,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -319,6 +357,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 45,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -330,6 +371,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 50,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -341,6 +385,9 @@ const testCreationEntryConfig = {
visible: false,
open: false,
sortOrder: 60,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -352,6 +399,9 @@ const testCreationEntryConfig = {
visible: true,
open: false,
sortOrder: 70,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -363,6 +413,9 @@ const testCreationEntryConfig = {
visible: false,
open: true,
sortOrder: 80,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
{
@@ -374,6 +427,9 @@ const testCreationEntryConfig = {
visible: true,
open: true,
sortOrder: 90,
categoryId: 'recent',
categoryLabel: '最近创作',
categorySortOrder: 10,
updatedAtMicros: 1,
},
],
@@ -3345,81 +3401,80 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
await openCreateTemplateHub(user);
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(screen.getByRole('tablist', { name: '选择模板' }).className).toContain(
expect(screen.getByRole('tablist', { name: '玩法模板分类' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }).className,
).toContain(
'scroll-px-3',
);
expect(
screen.getByRole('tab', { name: '拼图' }).getAttribute('aria-selected'),
screen.getByRole('tab', { name: '最近创作' }).getAttribute('aria-selected'),
).toBe('true');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('img')?.src,
).toContain('/creation-type-references/puzzle.webp');
expect(
screen.getByRole('tab', { name: '文字冒险' }).querySelector('img')?.src,
).toContain('/creation-type-references/rpg.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).querySelector('img')?.src,
).toContain('/creation-type-references/bark-battle.webp');
expect(
screen.getByRole('tab', { name: '宝贝识物' }).querySelector('img')?.src,
).toContain('/child-motion-demo/picture-book-grass-stage.png');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
await findCreationTypeButton('拼图'),
).toBeTruthy();
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-inherit'),
await findCreationTypeButton('文字冒险'),
).toBeTruthy();
expect(
await findCreationTypeButton('抓大鹅'),
).toBeTruthy();
expect(
await findCreationTypeButton('汪汪声浪'),
).toBeTruthy();
expect(
await findCreationTypeButton('宝贝识物'),
).toBeTruthy();
expect(
queryCreationTypeButton('智能创作'),
).toBeNull();
expect(
screen
.getByRole('tab', { name: '最近创作' })
.querySelector('[class*="bg-[#d9793f]"]'),
).toBeTruthy();
expect(screen.queryByRole('button', { name: /智能创作/u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问陶泥儿')).toBeNull();
expect(screen.queryByRole('button', { name: /角色扮演/u })).toBeNull();
expect(screen.queryByRole('tab', { name: /方洞挑战/u })).toBeNull();
expect(screen.queryByRole('tab', { name: '视觉小说' })).toBeNull();
expect(screen.getByRole('tab', { name: /抓大鹅/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /汪汪声浪/u })).toBeTruthy();
expect(screen.getByRole('tab', { name: /宝贝识物/u })).toBeTruthy();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab switches match3d into the embedded entry form', async () => {
test('create tab opens match3d entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
await user.click(await findCreationTypeButton('抓大鹅'));
expect(
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
).toBe('true');
expect(await screen.findByText('抓大鹅工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('create tab switches bark battle into the embedded config form', async () => {
test('create tab opens puzzle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('拼图'));
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab opens bark battle entry form from the template card', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(await findCreationTypeButton('汪汪声浪'));
expect(
screen.getByRole('tab', { name: '汪汪声浪' }).getAttribute('aria-selected'),
).toBe('true');
expect(await screen.findByText('汪汪声浪配置表单')).toBeTruthy();
expect(screen.getByTestId('bark-battle-editor-back-state').textContent).toBe(
'back-hidden',
);
expect(screen.getByTestId('bark-battle-editor-title-state').textContent).toBe(
'title-hidden',
);
expect(screen.queryByText('汪汪声浪运行态')).toBeNull();
expect(createBarkBattleDraft).not.toHaveBeenCalled();
expect(publishBarkBattleWork).not.toHaveBeenCalled();
@@ -3431,7 +3486,7 @@ test('bark battle draft result can test before publish and publish to work detai
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(createBarkBattleDraft).toHaveBeenCalledWith({
@@ -3525,7 +3580,7 @@ test('bark battle form checks mud points before creating image assets', async ()
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(
@@ -3547,7 +3602,7 @@ test('bark battle draft is visible in draft shelf while image assets are generat
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
expect(await screen.findByText('自动生成素材')).toBeTruthy();
@@ -3596,7 +3651,7 @@ test('published bark battle stays visible when refresh temporarily returns only
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '汪汪声浪' }));
await user.click(await findCreationTypeButton('汪汪声浪'));
await user.click(await screen.findByRole('button', { name: '生成草稿' }));
await waitFor(() => {
expect(updateBarkBattleDraftConfig).toHaveBeenCalledWith(
@@ -3811,7 +3866,17 @@ test('persisted generating match3d draft opens generation progress after refresh
'match3d-session-generating',
);
});
expect(await screen.findByText('抓大鹅草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '抓大鹅草稿生成进度',
}),
).toBeTruthy();
expect(
screen
.getByRole('progressbar', { name: '抓大鹅草稿生成进度' })
.getAttribute('aria-valuenow'),
).toBe('0');
expect(screen.getByText('0%')).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
expect(getMatch3DWorkDetail).not.toHaveBeenCalledWith(
'match3d-profile-generating',
@@ -4514,7 +4579,9 @@ test('match3d result back returns to platform creation page', async () => {
expect(await screen.findByText('抓大鹅结果页')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(screen.queryByText('抓大鹅结果页')).toBeNull();
});
@@ -6788,7 +6855,9 @@ test('puzzle draft result back button returns to creation hub', async () => {
await user.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
expect(await screen.findByText('拼图工作区missing-session')).toBeTruthy();
expect(
screen.queryByText('雨夜里有一只会发光的猫站在遗迹台阶上。'),
@@ -6825,14 +6894,15 @@ test('persisted generating puzzle draft opens generation progress after refresh'
},
],
});
vi.mocked(getPuzzleAgentSession).mockResolvedValueOnce({
session: buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 42,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
}),
const persistedGeneratingPuzzleSession = buildMockPuzzleAgentSession({
sessionId: 'puzzle-session-generating',
stage: 'collecting_anchors',
progressPercent: 88,
lastAssistantReply: '正在生成拼图草稿。',
updatedAt: '2026-05-18T12:00:00.000Z',
});
vi.mocked(getPuzzleAgentSession).mockResolvedValue({
session: persistedGeneratingPuzzleSession,
});
render(<TestWrapper withAuth />);
@@ -6845,7 +6915,19 @@ test('persisted generating puzzle draft opens generation progress after refresh'
'puzzle-session-generating',
);
});
expect(await screen.findByText('拼图草稿生成进度')).toBeTruthy();
expect(
await screen.findByRole('progressbar', {
name: '拼图草稿生成进度',
}),
).toBeTruthy();
expect(
Number(
screen
.getByRole('progressbar', { name: '拼图草稿生成进度' })
.getAttribute('aria-valuenow'),
),
).toBe(0);
expect(screen.getByText('0%')).toBeTruthy();
expect(screen.queryByText('拼图结果页')).toBeNull();
});
@@ -7844,7 +7926,9 @@ test('running custom world draft generation can return to creation center with s
expect(await screen.findByText('世界草稿生成进度')).toBeTruthy();
await user.click(screen.getByRole('button', { name: '返回创作中心' }));
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
await openDraftHub(user);
expect(await screen.findByText('潮雾列岛')).toBeTruthy();
@@ -8892,7 +8976,9 @@ test('agent draft result back button returns to creation hub without syncing res
await user.click(screen.getByRole('button', { name: /返回创作/u }));
await waitFor(() => {
expect(screen.getByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
screen.getByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
});
expect(
@@ -9215,14 +9301,16 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
render(<TestWrapper withAuth />);
await clickFirstButtonByName(user, '创作');
expect(await screen.findByRole('tablist', { name: '选择模板' })).toBeTruthy();
expect(
await screen.findByRole('tablist', { name: '玩法模板分类' }),
).toBeTruthy();
resolveGalleryRequest([]);
await waitFor(() => {
expect(
within(getPlatformTabPanel('create')).getByRole('tablist', {
name: '选择模板',
name: '玩法模板分类',
}),
).toBeTruthy();
});

View File

@@ -413,6 +413,11 @@ const originalUserAgent = navigator.userAgent;
const originalMaxTouchPoints = navigator.maxTouchPoints;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const DEFAULT_PROFILE_CREATED_AT = '2026-04-01T00:00:00.000Z';
function buildFreshProfileCreatedAt() {
return new Date().toISOString();
}
function dispatchPointerEvent(
target: HTMLElement,
@@ -670,6 +675,33 @@ function mockWechatMobileLayout() {
});
}
function mockNarrowMobileLayout() {
Object.defineProperty(navigator, 'userAgent', {
configurable: true,
value:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit Mobile',
});
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: vi.fn().mockImplementation((query: string) => {
const normalizedQuery = query.replace(/\s/g, '');
return {
matches:
normalizedQuery.includes('max-width:767px') ||
normalizedQuery.includes('max-width:768px'),
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
};
}),
});
}
function renderProfileView(
onRechargeSuccess = vi.fn(),
profileDashboardOverrides: Partial<
@@ -690,7 +722,7 @@ function renderProfileView(
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
createdAt: DEFAULT_PROFILE_CREATED_AT,
...userOverrides,
},
canAccessProtectedData: true,
@@ -1056,7 +1088,7 @@ afterEach(() => {
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: new Date().toISOString(),
createdAt: DEFAULT_PROFILE_CREATED_AT,
});
mockQrCodeToDataUrl.mockResolvedValue('data:image/png;base64,QR');
mockRedirectToPaymentUrl.mockReset();
@@ -1094,7 +1126,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(
screen.getByRole('button', { name: /\s*0/u }),
);
expect(await screen.findByText('泥点账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
@@ -1760,19 +1794,21 @@ test('profile native qr confirmation refreshes only after server reports paid',
expect(onRechargeSuccess).toHaveBeenCalledTimes(1);
});
test('non-wechat profile shows reward code instead of recharge entry', async () => {
test('non-wechat profile opens reward code from recharge-shaped entry', async () => {
const user = userEvent.setup();
renderProfileView();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
within(shortcutRegion).queryByRole('button', { name: //u }),
).toBeNull();
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
await user.click(within(shortcutRegion).getByRole('button', { name: //u }));
await user.click(
within(shortcutRegion).getByRole('button', { name: //u }),
);
expect(await screen.findByPlaceholderText('输入兑换码')).toBeTruthy();
expect(mockGetRpgProfileRechargeCenter).not.toHaveBeenCalled();
});
@@ -1821,7 +1857,7 @@ test('profile played works card shows count unit', () => {
});
const playedCard = screen.getByRole('button', {
name: /\s*1/u,
name: /\s*1/u,
});
expect(within(playedCard).getByText('1个')).toBeTruthy();
@@ -1832,18 +1868,120 @@ test('profile stats cards are centered without update timestamp', () => {
updatedAt: '2026-05-03T08:01:00Z',
});
const walletCard = screen.getByRole('button', { name: /\s*0/u });
const playTimeCard = screen.getByRole('button', { name: //u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
const walletCard = screen.getByRole('button', {
name: /\s*0/u,
});
const playTimeCard = screen.getByRole('button', { name: /|/u });
const playedCard = screen.getByRole('button', { name: /\s*0/u });
for (const card of [walletCard, playTimeCard, playedCard]) {
expect(card.className).toContain('items-center');
expect(card.className).toContain('justify-center');
expect(card.className).toContain('platform-profile-stat-card');
expect(card.className).toContain('text-center');
}
expect(screen.queryByText(//u)).toBeNull();
});
test('mobile profile page matches the reference layout sections', async () => {
mockWechatMobileLayout();
const { container } = renderProfileView(vi.fn(), {
walletBalance: 70,
totalPlayTimeMs: 0,
playedWorldCount: 0,
}, { createdAt: buildFreshProfileCreatedAt() });
const profilePage = container.querySelector('.platform-profile-page');
expect(profilePage).toBeTruthy();
expect(profilePage?.classList.contains('platform-page-stage')).toBe(true);
expect(profilePage?.querySelector('.platform-profile-scene-decor')).toBeTruthy();
expect(profilePage?.classList.contains('platform-profile-page')).toBe(true);
expect(profilePage?.getAttribute('style') ?? '').not.toContain('overflow: hidden');
const membershipCard = screen.getByRole('button', { name: '查看权益' });
expect(membershipCard.className).toContain('platform-profile-membership-card');
expect(
within(membershipCard).getByText('普通用户').className,
).toContain('platform-profile-membership-card__title');
expect(within(membershipCard).getByText('普通用户')).toBeTruthy();
expect(within(membershipCard).getByText('升级会员,享专属特权与福利')).toBeTruthy();
const statPanel = screen.getByRole('region', { name: '我的数据' });
expect(statPanel.className).toContain('platform-profile-stats-panel');
expect(statPanel.querySelector('.platform-profile-stats-grid')).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*70/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(within(statPanel).getByRole('button', { name: /\s*0/u })).toBeTruthy();
expect(
within(statPanel).getByRole('button', { name: /\s*70/u }).className,
).toContain('platform-profile-stat-card');
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask.className).toContain('platform-profile-daily-task-card');
expect(dailyTask.querySelector('.platform-profile-daily-task-card__title')).toBeTruthy();
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();
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
shortcutRegion.querySelector('.platform-profile-shortcut-grid'),
).toBeTruthy();
expect(
shortcutRegion.querySelectorAll('.platform-profile-shortcut-button'),
).toHaveLength(5);
expect(
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
for (const label of [
'泥点充值',
'邀请好友',
'兑换码',
'玩家社区',
'反馈与建议',
]) {
expect(
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
for (const label of ['主题设置', '账号与安全', '通用设置']) {
expect(
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
).toBeTruthy();
}
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
await within(secondaryShortcuts).findByRole('button', {
name: //u,
}),
).toBeTruthy();
const profileHeader = profilePage?.querySelector('.platform-profile-header');
expect(profileHeader).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__identity-row')).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__name')).toBeTruthy();
expect(profileHeader?.querySelector('.platform-profile-header__code')).toBeTruthy();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(legalRegion.className).toContain('platform-profile-legal-strip');
expect(legalRegion.textContent).toContain('用户协议');
expect(legalRegion.textContent).toContain('隐私政策');
expect(legalRegion.textContent).toContain('免责声明');
expect(legalRegion.textContent).toContain(ICP_RECORD_NUMBER);
expect(legalRegion.textContent).toContain('2026025677');
expect(legalRegion.querySelector('.platform-profile-legal-strip__link')).toBeTruthy();
});
test('desktop account entry uses saved avatar image when available', () => {
mockDesktopLayout();
const avatarUrl = 'data:image/png;base64,AAAA';
@@ -1886,27 +2024,33 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('泥点账单')).toBeTruthy();
await waitFor(() => {
expect(screen.getByText('暂无账单记录')).toBeTruthy();
});
await user.click(screen.getByLabelText('关闭泥点账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(await screen.findByText('泥点账单')).toBeTruthy();
await waitFor(() => {
expect(screen.getByText('加载失败')).toBeTruthy();
});
expect(screen.getByText('重新加载')).toBeTruthy();
});
test('profile invite shortcut shows reward subtitle and invited users', async () => {
const user = userEvent.setup();
renderProfileView();
renderProfileView(vi.fn(), {}, { createdAt: buildFreshProfileCreatedAt() });
const inviteButton = screen.getByRole('button', { name: //u });
expect(within(inviteButton).getByText('双方得30')).toBeTruthy();
expect(within(inviteButton).getByText('双方得 30 泥点')).toBeTruthy();
const communityButton = screen.getByRole('button', { name: //u });
expect(within(communityButton).getByText('每日领福利')).toBeTruthy();
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
await user.click(inviteButton);
@@ -1922,21 +2066,25 @@ test('profile invite shortcut shows reward subtitle and invited users', async ()
});
test('profile redeem invite shortcut sits between invite and community for fresh accounts', async () => {
renderProfileView();
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: '次级入口',
});
expect(inviteButton).toBeTruthy();
expect(communityButton).toBeTruthy();
expect(
inviteButton.compareDocumentPosition(redeemButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
redeemButton.compareDocumentPosition(communityButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(redeemButton).getByText('新用户奖励')).toBeTruthy();
});
@@ -2006,7 +2154,11 @@ test('profile redeem invite modal submits code and hides shortcut after success'
const user = userEvent.setup();
const onRechargeSuccess = vi.fn();
renderProfileView(onRechargeSuccess);
renderProfileView(
onRechargeSuccess,
{},
{ createdAt: buildFreshProfileCreatedAt() },
);
await user.click(await screen.findByRole('button', { name: //u }));
const input = await screen.findByLabelText('邀请码');
@@ -2050,11 +2202,10 @@ test('profile page shows legal entries and ICP record link', async () => {
const shortcutRegion = screen.getByRole('region', { name: '常用功能' });
expect(
shortcutRegion.querySelector('.grid')?.className.includes('grid-cols-3'),
shortcutRegion
.querySelector('.platform-profile-shortcut-grid')
?.classList.contains('platform-profile-shortcut-grid'),
).toBe(true);
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
@@ -2064,6 +2215,24 @@ test('profile page shows legal entries and ICP record link', async () => {
expect(
within(shortcutRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const dailyTask = screen.getByRole('button', { name: //u });
expect(dailyTask).toBeTruthy();
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
expect(
within(settingsRegion).getByRole('button', { name: //u }),
).toBeTruthy();
const secondaryShortcuts = screen.getByRole('region', {
name: '次级入口',
});
expect(
within(secondaryShortcuts).getByRole('button', { name: //u }),
).toBeTruthy();
expect(
within(secondaryShortcuts).queryByRole('button', { name: //u }),
).toBeNull();
const legalRegion = screen.getByRole('region', { name: '法律信息' });
expect(
@@ -2138,6 +2307,83 @@ test('logged in draft bottom tab shows unread marker', () => {
expect(draftButton.querySelector('.platform-nav-unread-dot')).toBeTruthy();
});
test('logged in create tab shows real wallet balance beside the brand', () => {
mockNarrowMobileLayout();
const { container } = render(
<AuthUiContext.Provider
value={{
user: {
id: 'user-1',
publicUserCode: '100001',
username: 'tester',
displayName: '测试玩家',
avatarUrl: null,
phoneNumberMasked: null,
loginMethod: 'password',
bindingStatus: 'active',
wechatBound: false,
createdAt: DEFAULT_PROFILE_CREATED_AT,
},
canAccessProtectedData: true,
openLoginModal: vi.fn(),
requireAuth: (action) => action(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
setCurrentUser: vi.fn(),
logout: vi.fn(async () => undefined),
musicVolume: 0.42,
setMusicVolume: vi.fn(),
platformTheme: 'light',
setPlatformTheme: vi.fn(),
isHydratingSettings: false,
isPersistingSettings: false,
settingsError: null,
}}
>
<RpgEntryHomeView
activeTab="create"
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
saveEntries={[]}
saveError={null}
featuredEntries={[]}
latestEntries={[]}
myEntries={[]}
historyEntries={[]}
profileDashboard={{
walletBalance: 1234,
totalPlayTimeMs: 0,
playedWorldCount: 0,
updatedAt: null,
}}
isLoadingPlatform={false}
isLoadingDashboard={false}
isResumingSaveWorldKey={null}
platformError={null}
dashboardError={null}
onContinueGame={vi.fn()}
onResumeSave={vi.fn()}
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={vi.fn()}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
createTabContent={<div></div>}
/>
</AuthUiContext.Provider>,
);
const topbar = container.querySelector('.platform-mobile-topbar');
expect(topbar).toBeTruthy();
expect(
topbar?.querySelector('.platform-mobile-create-wallet-chip'),
).toBeTruthy();
expect(topbar?.textContent).toContain('陶泥儿');
expect(topbar?.textContent).toContain('1,234泥点');
});
test('mobile discover search submits public work code', async () => {
const user = userEvent.setup();
const onSearchPublicCode = vi.fn();
@@ -2248,6 +2494,15 @@ test('mobile discover keeps edutainment works in the last dedicated channel only
throw new Error('缺少发现面板');
}
const discoverStage = discoverPanel.querySelector(
'.platform-mobile-home-stage',
);
expect(discoverStage).toBeTruthy();
expect(discoverStage?.classList.contains('platform-remap-surface')).toBe(
true,
);
expect(discoverStage?.classList.contains('platform-page-stage')).toBe(false);
const channels = Array.from(
discoverPanel.querySelectorAll('.platform-mobile-home-channel'),
).map((button) => button.textContent);
@@ -3117,7 +3372,6 @@ test('desktop logged in home syncs mobile home modules without square or latest
expect(screen.queryByText('作品广场')).toBeNull();
expect(screen.queryByText('公开作品')).toBeNull();
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
});

View File

@@ -11,7 +11,7 @@ import {
Coins,
Compass,
Copy,
FileText,
Crown,
Gamepad2,
GitFork,
Heart,
@@ -20,9 +20,11 @@ import {
Palette,
Pencil,
Plus,
ScanLine,
Search,
Settings,
Share2,
ShieldCheck,
SlidersHorizontal,
Sparkles,
Star,
@@ -45,6 +47,16 @@ import {
useState,
} from 'react';
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';
import profileMascotImage from '../../../media/profile/_Image (9).png';
import profilePointImage from '../../../media/profile/_Image.png';
import communityQqQrImage from '../../../media/social-media-group/qq.png';
import communityWechatQrImage from '../../../media/social-media-group/wechat.png';
import type { PublicUserSummary } from '../../../packages/shared/src/contracts/auth';
@@ -215,8 +227,12 @@ const MOBILE_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_RECOMMEND_PAGE_STAGE_CLASS =
'platform-page-stage min-w-0 space-y-4 overflow-hidden pb-2';
const MOBILE_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-4 overflow-hidden pb-2';
const DESKTOP_PAGE_STAGE_CLASS =
'platform-page-stage platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_DISCOVER_PAGE_STAGE_CLASS =
'platform-remap-surface min-w-0 space-y-5 pb-4';
const DESKTOP_LAYOUT_QUERY = '(min-width: 1024px)';
const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'home',
@@ -2384,12 +2400,14 @@ function ProfileStatCard({
value,
onClick,
icon,
imageSrc,
}: {
cardKey: ProfileDashboardCardKey;
label: string;
value: string;
onClick?: ((cardKey: ProfileDashboardCardKey) => void) | null;
icon: ComponentType<{ className?: string }>;
imageSrc?: string;
}) {
const Icon = icon;
@@ -2397,16 +2415,23 @@ function ProfileStatCard({
<button
type="button"
onClick={onClick ? () => onClick(cardKey) : undefined}
className="platform-subpanel flex min-h-[5.75rem] flex-col items-center justify-center rounded-[1.35rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
aria-label={`${label} ${value}`}
className="platform-profile-stat-card flex min-h-[5.75rem] items-center justify-center gap-2 px-3 py-3 text-center transition"
>
<div className="flex w-full items-center justify-center gap-2 text-[var(--platform-text-soft)]">
<Icon className="h-4 w-4" />
<span className="whitespace-nowrap text-[11px] tracking-[0.16em]">
{label}
</span>
<div className="platform-profile-stat-card__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-5 w-5" />
)}
</div>
<div className="mt-2 whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
<div className="min-w-0 text-left">
<div className="platform-profile-stat-card__value whitespace-nowrap text-lg font-black leading-none text-[var(--platform-text-strong)]">
{value}
</div>
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[12px] font-medium text-[var(--platform-text-soft)]">
{label}
</div>
</div>
</button>
);
@@ -2426,11 +2451,13 @@ function ProfileShortcutButton({
subLabel,
icon,
onClick,
imageSrc,
}: {
label: string;
subLabel?: ReactNode;
icon: ComponentType<{ className?: string }>;
onClick?: (() => void) | null;
imageSrc?: string;
}) {
const Icon = icon;
@@ -2438,16 +2465,20 @@ function ProfileShortcutButton({
<button
type="button"
onClick={onClick ?? undefined}
className="platform-subpanel flex min-h-[5.25rem] flex-col items-center justify-center gap-2 rounded-[1.2rem] px-3 py-3 text-center transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
className="platform-profile-shortcut-button flex min-h-[5.25rem] flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
>
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Icon className="h-[1.125rem] w-[1.125rem]" />
<div className="platform-profile-shortcut-button__icon">
{imageSrc ? (
<img src={imageSrc} alt="" className="h-full w-full object-contain" />
) : (
<Icon className="h-[1.125rem] w-[1.125rem]" />
)}
</div>
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[13px] font-semibold text-[var(--platform-text-strong)]">
{label}
</div>
{subLabel ? (
<div className="flex min-h-4 items-center justify-center gap-1 text-[11px] font-semibold text-[var(--platform-text-soft)]">
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
{subLabel}
</div>
) : null}
@@ -2455,6 +2486,72 @@ function ProfileShortcutButton({
);
}
function ProfileSettingsRow({
label,
icon,
onClick,
}: {
label: string;
icon: ComponentType<{ className?: string }>;
onClick: () => void;
}) {
const Icon = icon;
return (
<button
type="button"
onClick={onClick}
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-4 text-left transition"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-settings-row__icon">
<Icon className="h-5 w-5" />
</span>
<span className="truncate text-[15px] font-semibold text-[var(--platform-text-strong)]">
{label}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
</button>
);
}
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,
}: {
@@ -2462,33 +2559,21 @@ function ProfileLegalSection({
}) {
return (
<section
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
className="platform-profile-legal-strip"
aria-label="法律信息"
>
<div className="mb-3 text-sm font-black text-[var(--platform-text-strong)]">
</div>
<div className="platform-subpanel overflow-hidden rounded-[1.25rem]">
<div className="platform-profile-legal-strip__links">
{LEGAL_DOCUMENTS.map((document, index) => (
<button
key={document.id}
type="button"
onClick={() => onOpenDocument(document.id)}
className={`flex w-full items-center justify-between gap-3 px-4 py-3 text-left transition hover:bg-[var(--platform-button-secondary-fill)] ${
index > 0
? 'border-t border-[var(--platform-subpanel-border)]'
: ''
}`}
className="platform-profile-legal-strip__link"
>
<span className="flex min-w-0 items-center gap-3">
<span className="platform-profile-chip flex h-8 w-8 shrink-0 items-center justify-center rounded-full">
<FileText className="h-4 w-4" />
</span>
<span className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{document.title}
</span>
</span>
<ChevronRight className="h-4 w-4 shrink-0 text-[var(--platform-text-soft)]" />
{document.title}
{index < LEGAL_DOCUMENTS.length - 1 ? (
<span aria-hidden="true" className="platform-profile-legal-strip__divider" />
) : null}
</button>
))}
</div>
@@ -2496,7 +2581,7 @@ function ProfileLegalSection({
href={ICP_RECORD_URL}
target="_blank"
rel="noreferrer"
className="mt-3 block text-center text-xs font-semibold text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)]"
className="platform-profile-legal-strip__record"
>
{ICP_RECORD_NUMBER}
</a>
@@ -5379,7 +5464,7 @@ export function RpgEntryHomeView({
);
const mobileDiscoverContent: ReactNode = (
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<div className={`${MOBILE_DISCOVER_PAGE_STAGE_CLASS} platform-mobile-home-stage`}>
<PublicCodeSearchBar
value={mobileSearchKeyword}
onChange={updateMobileSearchKeyword}
@@ -5594,7 +5679,7 @@ export function RpgEntryHomeView({
);
const desktopDiscoverContent: ReactNode = (
<div className={DESKTOP_PAGE_STAGE_CLASS}>
<div className={DESKTOP_DISCOVER_PAGE_STAGE_CLASS}>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{visibleDiscoverChannels.map((channel) => {
const active = discoverChannel === channel.id;
@@ -5849,30 +5934,55 @@ export function RpgEntryHomeView({
const savesContent: ReactNode = draftTabContent ?? fallbackDraftContent;
const profileContent: ReactNode = (
<div className={MOBILE_PAGE_STAGE_CLASS}>
<div className={`${MOBILE_PAGE_STAGE_CLASS} platform-profile-page`}>
{authUi?.user ? (
<>
<section className="platform-profile-hero rounded-[1.8rem] p-4">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<section className="platform-profile-header">
<div className="platform-profile-header__actions">
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-header__icon-button"
aria-label="打开充值入口"
>
<ScanLine className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => authUi.openSettingsModal()}
className="platform-profile-header__icon-button"
aria-label="打开设置"
>
<Settings className="h-5 w-5" />
</button>
</div>
<img
src={profileStillLifeImage}
alt=""
className="platform-profile-scene-decor"
/>
<div className="platform-profile-header__identity">
<div className="platform-profile-header__identity-row flex min-w-0 items-center gap-4">
<button
type="button"
onClick={openAvatarPicker}
className="platform-profile-avatar relative h-16 w-16 shrink-0 rounded-[1.4rem]"
className="platform-profile-avatar relative h-[5.15rem] w-[5.15rem] shrink-0 rounded-full"
aria-label="上传头像"
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
className="h-full w-full rounded-[1.4rem] object-cover"
className="h-full w-full rounded-full object-cover"
/>
) : (
<span className="flex h-full w-full items-center justify-center text-2xl font-black">
{avatarLabel}
</span>
<img
src={profileMascotImage}
alt=""
className="h-full w-full rounded-full object-cover"
/>
)}
<span className="platform-profile-camera absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full">
<span className="platform-profile-camera absolute bottom-0 right-0 flex h-7 w-7 items-center justify-center rounded-full">
<Camera className="h-3.5 w-3.5" />
</span>
</button>
@@ -5887,28 +5997,27 @@ export function RpgEntryHomeView({
}
/>
<div className="min-w-0">
<div className="platform-profile-header__text min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-xl font-black text-[var(--platform-text-strong)]">
<div className="platform-profile-header__name truncate text-[20px] font-black leading-tight text-[var(--platform-text-strong)]">
{authUi.user.displayName}
</div>
<button
type="button"
onClick={openNicknameModal}
className="platform-profile-icon-button flex h-7 w-7 items-center justify-center rounded-full"
className="platform-profile-edit-button"
aria-label="修改昵称"
>
<Pencil className="h-3.5 w-3.5" />
<Pencil className="h-5 w-5" />
</button>
</div>
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-[var(--platform-text-soft)]">
<span> {publicUserCode}</span>
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
<span> {publicUserCode}</span>
<button
type="button"
onClick={copyProfilePublicUserCode}
className="platform-profile-chip flex items-center gap-1 rounded-full px-2 py-1"
className="platform-profile-copy-button"
>
<Copy className="h-3 w-3" />
{profileCopyState === 'copied'
? '已复制'
: profileCopyState === 'failed'
@@ -5918,32 +6027,33 @@ export function RpgEntryHomeView({
</div>
</div>
</div>
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-action flex shrink-0 items-center gap-2 rounded-[1.1rem] px-3 py-2 text-left"
>
{showRechargeEntry ? (
<Coins className="h-4 w-4" />
) : (
<Ticket className="h-4 w-4" />
)}
<div>
<div className="text-xs font-bold">
{showRechargeEntry ? '充值' : '兑换码'}
</div>
<div className="text-[10px] opacity-80">
{showRechargeEntry ? '泥点/会员' : '福利奖励'}
</div>
</div>
<ChevronRight className="h-4 w-4 opacity-80" />
</button>
</div>
</section>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<div className="grid grid-cols-3 gap-3">
<button
type="button"
onClick={openRechargeOrRewardCodeModal}
className="platform-profile-membership-card"
aria-label="查看权益"
>
<span className="platform-profile-membership-card__badge">
<Crown className="platform-profile-membership-card__crown" />
</span>
<span className="min-w-0 flex-1">
<span className="platform-profile-membership-card__title block text-[18px] font-black leading-tight text-white">
</span>
<span className="platform-profile-membership-card__subtitle mt-2 block text-[13px] font-medium text-white/92">
</span>
</span>
<span className="platform-profile-membership-card__action">
</span>
</button>
<section className="platform-profile-stats-panel" aria-label="我的数据">
<div className="platform-profile-stats-grid grid grid-cols-3 gap-3">
{isLoadingDashboard ? (
<>
<ProfileStatCardSkeleton />
@@ -5954,23 +6064,26 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="泥点"
label="泥点余额"
value="暂不可用"
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="累计游戏时长"
value="暂不可用"
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
label="已玩游戏数量"
value="暂不可用"
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
@@ -5978,23 +6091,26 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="泥点"
label="泥点余额"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
imageSrc={profilePointImage}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="累计游戏时长"
value={totalPlayTime}
icon={Clock3}
imageSrc={profileClockImage}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过"
label="已玩游戏数量"
value={`${formatDashboardCount(playedWorkCount)}`}
icon={BookOpen}
imageSrc={profileGamepadImage}
onClick={onOpenProfileDashboardCard}
/>
</>
@@ -6002,101 +6118,125 @@ export function RpgEntryHomeView({
</div>
</section>
<button
type="button"
onClick={openTaskCenterPanel}
className="platform-profile-daily-task-card"
>
<span className="min-w-0 flex-1">
<span className="platform-profile-daily-task-card__title block text-[15px] font-black text-[var(--platform-text-strong)]">
</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>
<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
</span>
<span className="platform-profile-daily-task-card__track">
<span className="platform-profile-daily-task-card__bar" />
</span>
</span>
</span>
<img
src={profileMascotImage}
alt=""
className="platform-profile-daily-task-card__mascot"
/>
<span className="platform-profile-daily-task-card__action">
</span>
</button>
<section
className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}
className="platform-profile-shortcut-panel"
aria-label="常用功能"
>
<div className="grid grid-cols-3 gap-3">
<div className="platform-profile-shortcut-grid">
<ProfileShortcutButton
label="每日任务"
subLabel={
<>
<span>10</span>
<Coins className="h-3 w-3" />
</>
}
icon={Star}
onClick={openTaskCenterPanel}
/>
<ProfileShortcutButton
label={showRechargeEntry ? '充值' : '兑换码'}
subLabel={showRechargeEntry ? '泥点/会员' : '福利奖励'}
icon={showRechargeEntry ? Coins : Ticket}
label="泥点充值"
subLabel="充值泥点"
icon={Coins}
imageSrc={profileCoinsImage}
onClick={openRechargeOrRewardCodeModal}
/>
<ProfileShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{showRechargeEntry ? (
<ProfileShortcutButton
label="兑换码"
subLabel="福利奖励"
icon={Ticket}
onClick={openRewardCodeModal}
/>
) : null}
<ProfileShortcutButton
label="邀请好友"
subLabel={
<>
<span>30</span>
<Coins className="h-3 w-3" />
</>
}
subLabel="双方得 30 泥点"
icon={UserPlus}
imageSrc={profileInviteImage}
onClick={() => openProfilePopupPanel('invite')}
/>
{canShowReferralRedeemShortcut ? (
<ProfileShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
) : null}
<ProfileShortcutButton
label="兑换码"
subLabel="领取福利"
icon={Ticket}
imageSrc={profileGiftImage}
onClick={openRewardCodeModal}
/>
<ProfileShortcutButton
label="玩家社区"
subLabel="每日领福利"
subLabel="交流心得 领取福利"
icon={MessageCircle}
imageSrc={profileCommunityImage}
onClick={() => openProfilePopupPanel('community')}
/>
<ProfileShortcutButton
label="反馈"
subLabel="问题与建议"
label="反馈与建议"
subLabel="帮助我们做得更好"
icon={MessageCircle}
imageSrc={profileFeedbackImage}
onClick={onOpenFeedback}
/>
</div>
</section>
<section className={`${PANEL_SURFACE_CLASS} px-4 py-3.5`}>
<button
type="button"
<section className="platform-profile-settings-panel" aria-label="设置入口">
<ProfileSettingsRow
label="主题设置"
icon={Palette}
onClick={() => authUi.openSettingsModal('appearance')}
/>
<ProfileSettingsRow
label="账号与安全"
icon={ShieldCheck}
onClick={() => authUi.openSettingsModal('account')}
/>
<ProfileSettingsRow
label="通用设置"
icon={Settings}
onClick={() => authUi.openSettingsModal()}
className="platform-subpanel platform-interactive-card flex w-full items-center justify-between gap-3 rounded-[1.25rem] px-4 py-4 text-left transition hover:border-[var(--platform-surface-hover-border)] hover:bg-[var(--platform-button-secondary-fill)]"
>
<div className="flex items-center gap-3">
<div className="platform-profile-chip flex h-10 w-10 items-center justify-center rounded-full">
<Settings className="h-[1.125rem] w-[1.125rem]" />
</div>
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="text-xs text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<ChevronRight className="h-4 w-4 text-[var(--platform-text-soft)]" />
</button>
/>
<ProfileSettingsRow
label="存档"
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
</section>
<section
className="platform-profile-secondary-shortcuts"
aria-label="次级入口"
>
<ProfileSecondaryShortcutButton
label="存档"
subLabel={
saveEntries.length > 0
? `${saveEntries.length}个可继续`
: '继续游玩'
}
icon={Archive}
onClick={() => setProfilePopupPanel('saveArchives')}
/>
{canShowReferralRedeemShortcut ? (
<ProfileSecondaryShortcutButton
label="填邀请码"
subLabel="新用户奖励"
icon={Ticket}
onClick={() => openProfilePopupPanel('redeem')}
/>
) : null}
</section>
<ProfileLegalSection onOpenDocument={setActiveLegalDocumentId} />
@@ -6576,7 +6716,19 @@ export function RpgEntryHomeView({
{!isMobileRecommendTab ? (
<div className="platform-mobile-topbar mb-3 flex shrink-0 items-center justify-between gap-3 px-0.5">
<RpgEntryBrandLogo />
{!isAuthenticated ? (
{isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
className="platform-mobile-create-wallet-chip inline-flex shrink-0 items-center gap-1.5 rounded-full border border-[#f0cfae] bg-[#fff5eb] px-2.5 py-1.5 text-xs font-black text-[#b65f2c] shadow-[0_10px_22px_rgba(174,111,73,0.12)]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-6 w-6 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : !isAuthenticated ? (
<button
type="button"
onClick={openUserSurface}
@@ -6724,6 +6876,19 @@ export function RpgEntryHomeView({
</div>
<div className="flex items-center gap-3">
{isAuthenticated && activeTab === 'create' ? (
<button
type="button"
onClick={openUserSurface}
className="platform-desktop-create-wallet-chip platform-desktop-search inline-flex items-center gap-2 px-3 py-2.5 text-xs font-black text-[#b65f2c]"
aria-label={`${formatDashboardCount(remainingNarrativeCoins)}泥点`}
>
<span className="grid h-7 w-7 place-items-center rounded-full bg-[#ffe0ab] text-[#cf7b34]">
<Coins className="h-3.5 w-3.5" />
</span>
<span>{formatDashboardCount(remainingNarrativeCoins)}</span>
</button>
) : null}
<button
type="button"
onClick={openUserSurface}