Update spacetime-client bindings and frontend
Large update across server and web clients: regenerated/added many spacetime-client module bindings and input types (including new delete/work_delete input types and numerous procedure/reducer files), updates to server-rs API modules (bark_battle, jump_hop, wooden_fish, auth, module-runtime and shared contracts), and fixes in module-runtime behavior and domain logic. Frontend changes include new/updated components and tests (creative audio helpers, bark-battle/jump-hop/wooden-fish clients and views, unified generation pages, RPG entry views, and runtime shells), plus CSS and service updates. Documentation and operational notes updated (.hermes pitfalls and multiple PRD/docs) to cover daily-task refresh, banner asset fallback, recommend-key bug, and other platform behaviors. Tests and verification steps added/updated alongside these changes.
This commit is contained in:
@@ -20,6 +20,7 @@ import type {
|
||||
import type {
|
||||
JumpHopWorkDetailResponse,
|
||||
JumpHopWorkProfileResponse,
|
||||
JumpHopWorkSummaryResponse,
|
||||
} from '../../../packages/shared/src/contracts/jumpHop';
|
||||
import type { Match3DAgentSessionSnapshot } from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
@@ -40,6 +41,7 @@ import type {
|
||||
CustomWorldGalleryCard,
|
||||
CustomWorldLibraryEntry,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { normalizeCustomWorldProfileRecord } from '../../data/customWorldLibrary';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import {
|
||||
@@ -50,6 +52,7 @@ import { ApiClientError } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
createBarkBattleDraft,
|
||||
deleteBarkBattleWork,
|
||||
generateAllBarkBattleImageAssets,
|
||||
listBarkBattleGallery,
|
||||
listBarkBattleWorks,
|
||||
@@ -634,6 +637,7 @@ vi.mock('../../services/big-fish-runtime', () => ({
|
||||
|
||||
vi.mock('../../services/bark-battle-creation', () => ({
|
||||
createBarkBattleDraft: vi.fn(),
|
||||
deleteBarkBattleWork: vi.fn(),
|
||||
generateAllBarkBattleImageAssets: vi.fn(),
|
||||
listBarkBattleGallery: vi.fn(),
|
||||
listBarkBattleWorks: vi.fn(),
|
||||
@@ -656,6 +660,7 @@ vi.mock('../../services/edutainment-baby-object', () => ({
|
||||
vi.mock('../../services/jump-hop/jumpHopClient', () => ({
|
||||
jumpHopClient: {
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
@@ -673,6 +678,7 @@ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
checkpointRun: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
finishRun: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
@@ -802,6 +808,7 @@ vi.mock('../../services/wooden-fish/woodenFishClient', () => ({
|
||||
woodenFishClient: {
|
||||
checkpointRun: vi.fn(),
|
||||
createSession: vi.fn(),
|
||||
deleteWork: vi.fn(),
|
||||
executeAction: vi.fn(),
|
||||
finishRun: vi.fn(),
|
||||
getGalleryDetail: vi.fn(),
|
||||
@@ -2725,6 +2732,7 @@ beforeEach(() => {
|
||||
vi.mocked(upsertProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(clearProfileBrowseHistory).mockResolvedValue([]);
|
||||
vi.mocked(deleteRpgEntryWorldProfile).mockResolvedValue([]);
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listVisualNovelGallery).mockResolvedValue({ works: [] });
|
||||
vi.mocked(listVisualNovelWorks).mockResolvedValue({ works: [] });
|
||||
vi.mocked(woodenFishClient.listGallery).mockResolvedValue({
|
||||
@@ -2733,6 +2741,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(listLocalBabyObjectMatchDrafts).mockResolvedValue([]);
|
||||
vi.mocked(deleteLocalBabyObjectMatchDraft).mockResolvedValue([]);
|
||||
vi.mocked(jumpHopClient.listGallery).mockResolvedValue({
|
||||
@@ -2741,6 +2750,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(jumpHopClient.getSession).mockRejectedValue(
|
||||
new Error('未找到跳一跳会话'),
|
||||
);
|
||||
@@ -2753,6 +2763,7 @@ beforeEach(() => {
|
||||
nextCursor: null,
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValue({ items: [] });
|
||||
vi.mocked(woodenFishClient.getSession).mockRejectedValue(
|
||||
new Error('未找到敲木鱼会话'),
|
||||
);
|
||||
@@ -9135,10 +9146,10 @@ test('starting draft generation leaves the agent workspace and shows the generat
|
||||
}),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByText(/Agent工作区/u)).toBeNull();
|
||||
expect(screen.getByText('当前世界信息')).toBeTruthy();
|
||||
expect(screen.queryByText('当前世界信息')).toBeNull();
|
||||
expect(screen.queryByText('回到工作区')).toBeNull();
|
||||
expect(screen.getByText('世界承诺')).toBeTruthy();
|
||||
expect(screen.getByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeTruthy();
|
||||
expect(screen.queryByText('世界承诺')).toBeNull();
|
||||
expect(screen.queryByText(/灯塔与禁航令共同决定谁能穿过死潮/u)).toBeNull();
|
||||
expect(screen.queryByText('先告诉我你想做一个怎样的 RPG 世界。')).toBeNull();
|
||||
});
|
||||
|
||||
@@ -11364,3 +11375,145 @@ test('creation hub published work card reveals delete action after card action r
|
||||
expect(within(dialog).getByRole('button', { name: '确认删除' })).toBeTruthy();
|
||||
expect(deleteRpgEntryWorldProfile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('creation hub gives jump hop wooden fish and bark battle cards the shared delete interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
const jumpHopWork = {
|
||||
...buildMockJumpHopWork({
|
||||
summary: {
|
||||
runtimeKind: 'jump-hop',
|
||||
workId: 'jump-hop-work-delete',
|
||||
profileId: 'jump-hop-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'jump-hop-session-delete',
|
||||
workTitle: '跳台删除草稿',
|
||||
workDescription: '跳一跳草稿也应接入统一删除。',
|
||||
themeTags: ['跳台'],
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'paper-toy',
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:20:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
},
|
||||
}).summary,
|
||||
} satisfies JumpHopWorkSummaryResponse;
|
||||
const woodenFishWork = {
|
||||
runtimeKind: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-delete',
|
||||
profileId: 'wooden-fish-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
sourceSessionId: 'wooden-fish-session-delete',
|
||||
workTitle: '木鱼删除草稿',
|
||||
workDescription: '敲木鱼草稿也应接入统一删除。',
|
||||
themeTags: ['木鱼'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'draft',
|
||||
playCount: 0,
|
||||
updatedAt: '2026-05-21T10:10:00.000Z',
|
||||
publishedAt: null,
|
||||
publishReady: true,
|
||||
generationStatus: 'ready',
|
||||
} satisfies WoodenFishWorkSummaryResponse;
|
||||
const barkBattleWork = buildMockBarkBattleWork({
|
||||
workId: 'bark-battle-work-delete',
|
||||
draftId: 'bark-battle-draft-delete',
|
||||
title: '声浪删除已发布',
|
||||
summary: '汪汪声浪已发布作品也应接入统一删除。',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
publishedAt: '2026-05-21T10:00:00.000Z',
|
||||
});
|
||||
|
||||
vi.mocked(fetchCreationEntryConfig).mockResolvedValueOnce({
|
||||
...testCreationEntryConfig,
|
||||
creationTypes: [
|
||||
...testCreationEntryConfig.creationTypes,
|
||||
{
|
||||
id: 'jump-hop',
|
||||
title: '跳一跳',
|
||||
subtitle: '俯视角跳台挑战',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/jump-hop.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 46,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
{
|
||||
id: 'wooden-fish',
|
||||
title: '敲木鱼',
|
||||
subtitle: '功德敲击小游戏',
|
||||
badge: '可创建',
|
||||
imageSrc: '/creation-type-references/wooden-fish.webp',
|
||||
visible: true,
|
||||
open: true,
|
||||
sortOrder: 47,
|
||||
categoryId: 'recommended',
|
||||
categoryLabel: '热门推荐',
|
||||
categorySortOrder: 20,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
vi.mocked(jumpHopClient.listWorks).mockResolvedValue({
|
||||
items: [jumpHopWork],
|
||||
});
|
||||
vi.mocked(woodenFishClient.listWorks).mockResolvedValue({
|
||||
items: [woodenFishWork],
|
||||
});
|
||||
vi.mocked(listBarkBattleWorks).mockResolvedValue({
|
||||
items: [barkBattleWork],
|
||||
});
|
||||
vi.mocked(jumpHopClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(woodenFishClient.deleteWork).mockResolvedValueOnce({ items: [] });
|
||||
vi.mocked(deleteBarkBattleWork).mockResolvedValueOnce({ items: [] });
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openDraftHub(user);
|
||||
|
||||
async function revealAndConfirmDelete(
|
||||
cardName: RegExp,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const card = await screen.findByRole('button', { name: cardName });
|
||||
card.focus();
|
||||
await user.keyboard('{ArrowLeft}');
|
||||
const shell = card.closest('.creation-work-card-shell');
|
||||
if (!shell) {
|
||||
throw new Error('作品卡应位于统一操作壳内');
|
||||
}
|
||||
await user.click(within(shell as HTMLElement).getByRole('button', { name: '删除' }));
|
||||
|
||||
const dialog = await screen.findByRole('dialog', { name: '删除作品' });
|
||||
expect(within(dialog).getByText(`确认删除《${title}》吗?`)).toBeTruthy();
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认删除' }));
|
||||
}
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《跳台删除草稿》/u, '跳台删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(jumpHopClient.deleteWork).toHaveBeenCalledWith(
|
||||
'jump-hop-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/继续创作《木鱼删除草稿》/u, '木鱼删除草稿');
|
||||
await waitFor(() => {
|
||||
expect(woodenFishClient.deleteWork).toHaveBeenCalledWith(
|
||||
'wooden-fish-profile-delete',
|
||||
);
|
||||
});
|
||||
|
||||
await revealAndConfirmDelete(/查看详情《声浪删除已发布》/u, '声浪删除已发布');
|
||||
await waitFor(() => {
|
||||
expect(deleteBarkBattleWork).toHaveBeenCalledWith(
|
||||
'bark-battle-work-delete',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,8 +32,10 @@ import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformEdutainmentGalleryCard,
|
||||
type PlatformJumpHopGalleryCard,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformPuzzleGalleryCard,
|
||||
type PlatformWoodenFishGalleryCard,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
const {
|
||||
@@ -321,6 +323,7 @@ const {
|
||||
const {
|
||||
mockGetPublicAuthUserByCode,
|
||||
mockGetPublicAuthUserById,
|
||||
mockRefreshStoredAccessToken,
|
||||
mockUpdateAuthProfile,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetPublicAuthUserByCode: vi.fn(
|
||||
@@ -341,9 +344,14 @@ const {
|
||||
avatarUrl: null,
|
||||
}),
|
||||
),
|
||||
mockRefreshStoredAccessToken: vi.fn(async () => 'jwt-refreshed-token'),
|
||||
mockUpdateAuthProfile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/apiClient', () => ({
|
||||
refreshStoredAccessToken: mockRefreshStoredAccessToken,
|
||||
}));
|
||||
|
||||
vi.mock('../../services/authService', () => ({
|
||||
getPublicAuthUserByCode: mockGetPublicAuthUserByCode,
|
||||
getPublicAuthUserById: mockGetPublicAuthUserById,
|
||||
@@ -413,11 +421,6 @@ 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,
|
||||
@@ -481,6 +484,53 @@ const puzzlePublicEntry = {
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const jumpHopPublicEntry = {
|
||||
sourceType: 'jump-hop',
|
||||
workId: 'jump-hop-work-public-1',
|
||||
profileId: 'jump-hop-profile-public-1',
|
||||
sourceSessionId: 'jump-hop-session-public-1',
|
||||
publicWorkCode: 'JH-EPUBLIC1',
|
||||
ownerUserId: 'jump-hop-user-1',
|
||||
authorDisplayName: '跳台作者',
|
||||
worldName: '星桥跳台',
|
||||
subtitle: '标准路线',
|
||||
summaryText: '一条用于公开分享的跳一跳路线。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['跳一跳'],
|
||||
playCount: 8,
|
||||
remixCount: 1,
|
||||
likeCount: 3,
|
||||
recentPlayCount7d: 2,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-20T10:00:00.000Z',
|
||||
updatedAt: '2026-05-20T10:00:00.000Z',
|
||||
difficulty: 'standard',
|
||||
stylePreset: 'storybook',
|
||||
} satisfies PlatformJumpHopGalleryCard;
|
||||
|
||||
const woodenFishPublicEntry = {
|
||||
sourceType: 'wooden-fish',
|
||||
workId: 'wooden-fish-work-public-1',
|
||||
profileId: 'wooden-fish-profile-public-1',
|
||||
sourceSessionId: 'wooden-fish-session-public-1',
|
||||
publicWorkCode: 'WF-EPUBLIC1',
|
||||
ownerUserId: 'wooden-fish-user-1',
|
||||
authorUsername: null,
|
||||
authorDisplayName: '木鱼作者',
|
||||
worldName: '莲台木鱼',
|
||||
subtitle: '敲木鱼',
|
||||
summaryText: '一件用于公开分享的敲木鱼作品。',
|
||||
coverImageSrc: null,
|
||||
themeTags: ['敲木鱼'],
|
||||
playCount: 9,
|
||||
remixCount: 2,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 3,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-05-21T10:00:00.000Z',
|
||||
updatedAt: '2026-05-21T10:00:00.000Z',
|
||||
} satisfies PlatformWoodenFishGalleryCard;
|
||||
|
||||
const remixRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-remix-rank',
|
||||
@@ -1081,6 +1131,7 @@ afterEach(() => {
|
||||
mockBuildReferralCenter(),
|
||||
);
|
||||
mockGetRpgProfileTasks.mockResolvedValue(mockBuildTaskCenter());
|
||||
mockRefreshStoredAccessToken.mockResolvedValue('jwt-refreshed-token');
|
||||
mockClaimRpgProfileTaskReward.mockResolvedValue({
|
||||
taskId: 'daily_login',
|
||||
dayKey: 20260503,
|
||||
@@ -2447,7 +2498,7 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
|
||||
await waitFor(() => {
|
||||
expect(within(dailyTask).getByText('1 / 1')).toBeTruthy();
|
||||
});
|
||||
expect(within(dailyTask).getByText('领取')).toBeTruthy();
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
@@ -2465,7 +2516,80 @@ test('profile daily task shortcut reflects task progress and claim updates', asy
|
||||
expect(await screen.findByText('已领取 10 泥点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '已领取' })).toBeNull();
|
||||
expect(screen.getByText('暂无任务')).toBeTruthy();
|
||||
expect(within(dailyTask).getByText('已完成')).toBeTruthy();
|
||||
expect(within(dailyTask).queryByText('已完成')).toBeNull();
|
||||
});
|
||||
|
||||
test('profile daily task refreshes at Beijing midnight reset', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-03T15:59:58.000Z'));
|
||||
|
||||
mockGetRpgProfileTasks
|
||||
.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
walletBalance: 10,
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'daily_login',
|
||||
title: '每日登录',
|
||||
description: '',
|
||||
eventKey: 'profile.login.daily',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 10,
|
||||
status: 'claimed',
|
||||
dayKey: 20260503,
|
||||
claimedAt: '2026-05-03T15:59:00Z',
|
||||
updatedAt: '2026-05-03T15:59:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-05-03T15:59:00Z',
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
mockBuildTaskCenter({
|
||||
walletBalance: 10,
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'daily_login',
|
||||
title: '每日登录',
|
||||
description: '',
|
||||
eventKey: 'profile.login.daily',
|
||||
cycle: 'daily',
|
||||
threshold: 1,
|
||||
progressCount: 1,
|
||||
rewardPoints: 10,
|
||||
status: 'claimable',
|
||||
dayKey: 20260504,
|
||||
claimedAt: null,
|
||||
updatedAt: '2026-05-03T16:00:00Z',
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-05-03T16:00:00Z',
|
||||
}),
|
||||
);
|
||||
|
||||
renderProfileView();
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockRefreshStoredAccessToken).toHaveBeenCalledWith({
|
||||
clearOnFailure: false,
|
||||
});
|
||||
expect(mockGetRpgProfileTasks).toHaveBeenCalledTimes(2);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /每日任务/u }));
|
||||
|
||||
expect(screen.getByRole('button', { name: '领取' })).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile task center keeps only the highest priority actionable task', async () => {
|
||||
@@ -2534,7 +2658,7 @@ test('profile total play time card always uses hours', async () => {
|
||||
});
|
||||
|
||||
const playTimeCard = screen.getByRole('button', {
|
||||
name: /游戏时长/u,
|
||||
name: /累计游玩/u,
|
||||
});
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
@@ -2548,10 +2672,11 @@ test('profile played works card shows count unit', async () => {
|
||||
});
|
||||
|
||||
const playedCard = screen.getByRole('button', {
|
||||
name: /已玩游戏数量\s*1个/u,
|
||||
name: /已玩游戏\s*1个/u,
|
||||
});
|
||||
|
||||
expect(within(playedCard).getByText('1个')).toBeTruthy();
|
||||
expect(within(playedCard).queryByText('已玩游戏数量')).toBeNull();
|
||||
await screen.findByText('1 / 1');
|
||||
});
|
||||
|
||||
@@ -2563,8 +2688,8 @@ test('profile stats cards are centered without update timestamp', async () => {
|
||||
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 playTimeCard = screen.getByRole('button', { name: /累计游玩\s*0小时/u });
|
||||
const playedCard = screen.getByRole('button', { name: /已玩游戏\s*0个/u });
|
||||
|
||||
for (const card of [walletCard, playTimeCard, playedCard]) {
|
||||
expect(card.className).toContain('platform-profile-stat-card');
|
||||
@@ -2616,8 +2741,8 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
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*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');
|
||||
@@ -2628,6 +2753,8 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
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.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
expect(dailyTask.textContent).not.toContain('去完成');
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
expect(await within(dailyTask).findByText('1 / 1')).toBeTruthy();
|
||||
|
||||
@@ -2668,13 +2795,22 @@ test('mobile profile page matches the reference layout sections', async () => {
|
||||
within(shortcutRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
).getByText('帮我们优化产品'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
within(
|
||||
within(shortcutRegion).getByRole('button', { name: /反馈与建议/u }),
|
||||
).queryByText('帮助我们做得更好'),
|
||||
).toBeNull();
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
for (const label of ['主题设置', '账号与安全', '通用设置']) {
|
||||
expect(
|
||||
within(settingsRegion).getByRole('button', { name: new RegExp(label, 'u') }),
|
||||
).toBeTruthy();
|
||||
}
|
||||
expect(within(settingsRegion).getByRole('button', { name: /通用设置/u })).toBeTruthy();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /主题设置/u })).toBeNull();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /账号与安全/u })).toBeNull();
|
||||
expect(settingsRegion.querySelectorAll('.platform-profile-settings-row')).toHaveLength(1);
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
@@ -2805,7 +2941,8 @@ test('profile community shortcut shows reward subtitle and invited users', async
|
||||
expect(screen.queryByRole('button', { name: /邀请好友/u })).toBeNull();
|
||||
|
||||
const communityButton = screen.getByRole('button', { name: /玩家社区/u });
|
||||
expect(within(communityButton).getByText('交流心得 领取福利')).toBeTruthy();
|
||||
expect(within(communityButton).getByText('交流心得')).toBeTruthy();
|
||||
expect(within(communityButton).queryByText('交流心得 领取福利')).toBeNull();
|
||||
|
||||
await user.click(communityButton);
|
||||
|
||||
@@ -2982,8 +3119,12 @@ test('profile page shows legal entries and hides archive shortcuts', async () =>
|
||||
const dailyTask = screen.getByRole('button', { name: /每日任务/u });
|
||||
expect(dailyTask).toBeTruthy();
|
||||
expect(dailyTask.textContent).toContain('完成任务可领取 10 泥点');
|
||||
expect(dailyTask.querySelector('.platform-profile-daily-task-card__action')).toBeNull();
|
||||
|
||||
const settingsRegion = screen.getByRole('region', { name: '设置入口' });
|
||||
expect(within(settingsRegion).getByRole('button', { name: /通用设置/u })).toBeTruthy();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /主题设置/u })).toBeNull();
|
||||
expect(within(settingsRegion).queryByRole('button', { name: /账号与安全/u })).toBeNull();
|
||||
expect(
|
||||
within(settingsRegion).queryByRole('button', { name: /存档/u }),
|
||||
).toBeNull();
|
||||
@@ -3590,6 +3731,53 @@ test('logged out recommend page can enter runtime without login gate', () => {
|
||||
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('mobile recommend meta matches active jump hop runtime entry', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, jumpHopPublicEntry],
|
||||
activeRecommendEntryKey: 'jump-hop:jump-hop-user-1:jump-hop-profile-public-1',
|
||||
recommendRuntimeContent: (
|
||||
<div data-testid="recommend-runtime">跳一跳运行内容</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
|
||||
'跳一跳运行内容',
|
||||
);
|
||||
const meta = document.querySelector(
|
||||
'.platform-recommend-work-meta[data-active="true"]',
|
||||
) as HTMLElement | null;
|
||||
expect(meta?.getAttribute('aria-label')).toBe('星桥跳台 作品信息');
|
||||
if (!meta) {
|
||||
throw new Error('缺少当前推荐作品信息');
|
||||
}
|
||||
expect(within(meta).getByText('跳台作者')).toBeTruthy();
|
||||
expect(within(meta).getByText('星桥跳台')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('mobile recommend meta matches active wooden fish runtime entry', () => {
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, woodenFishPublicEntry],
|
||||
activeRecommendEntryKey:
|
||||
'wooden-fish:wooden-fish-user-1:wooden-fish-profile-public-1',
|
||||
recommendRuntimeContent: (
|
||||
<div data-testid="recommend-runtime">敲木鱼运行内容</div>
|
||||
),
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('recommend-runtime').textContent).toContain(
|
||||
'敲木鱼运行内容',
|
||||
);
|
||||
const meta = document.querySelector(
|
||||
'.platform-recommend-work-meta[data-active="true"]',
|
||||
) as HTMLElement | null;
|
||||
expect(meta?.getAttribute('aria-label')).toBe('莲台木鱼 作品信息');
|
||||
if (!meta) {
|
||||
throw new Error('缺少当前推荐作品信息');
|
||||
}
|
||||
expect(within(meta).getByText('木鱼作者')).toBeTruthy();
|
||||
expect(within(meta).getByText('莲台木鱼')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('logged out desktop recommend rail enters runtime without login modal', async () => {
|
||||
mockDesktopLayout();
|
||||
const user = userEvent.setup();
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
Gamepad2,
|
||||
GitFork,
|
||||
Heart,
|
||||
Loader2,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Loader2,
|
||||
Palette,
|
||||
Pencil,
|
||||
Plus,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Sparkles,
|
||||
Star,
|
||||
@@ -80,6 +79,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { refreshStoredAccessToken } from '../../services/apiClient';
|
||||
import type { AuthUser } from '../../services/authService';
|
||||
import {
|
||||
getPublicAuthUserByCode,
|
||||
@@ -136,6 +136,7 @@ import { getInitialPlatformDesktopLayout } from '../platform-entry/platformEntry
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import { RpgEntryBrandLogo } from './RpgEntryBrandLogo';
|
||||
import {
|
||||
buildPlatformPublicGalleryCardKey,
|
||||
buildPlatformWorldDisplayTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
@@ -152,8 +153,8 @@ import {
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
type PlatformWorldCardLike,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorkAuthorDisplayName,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldFallbackCoverImage,
|
||||
@@ -246,6 +247,9 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
|
||||
'saves',
|
||||
'profile',
|
||||
];
|
||||
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 AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
|
||||
const AVATAR_OUTPUT_SIZE = 256;
|
||||
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
@@ -301,15 +305,8 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
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,
|
||||
@@ -317,6 +314,15 @@ function buildProfileTaskCardSummary(center: ProfileTaskCenterResponse | null) {
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
type ProfileReferralPanel = 'invite' | 'redeem' | 'community';
|
||||
type ProfilePopupPanel = ProfileReferralPanel | 'saveArchives';
|
||||
type BarcodeDetectorLike = {
|
||||
@@ -1801,22 +1807,7 @@ function isExactPublicWorkCodeSearch(
|
||||
}
|
||||
|
||||
function buildPublicGalleryCardKey(entry: PlatformPublicGalleryCard) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
return buildPlatformPublicGalleryCardKey(entry);
|
||||
}
|
||||
|
||||
function PlatformWorkSearchResults({
|
||||
@@ -2396,7 +2387,7 @@ function ProfileStatCard({
|
||||
type="button"
|
||||
onClick={onClick ? () => onClick(cardKey) : undefined}
|
||||
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"
|
||||
className="platform-profile-stat-card flex min-h-[5.25rem] items-center justify-center gap-2 px-2.5 py-2.5 text-center transition"
|
||||
>
|
||||
<div className="platform-profile-stat-card__icon">
|
||||
{imageSrc ? (
|
||||
@@ -2406,10 +2397,10 @@ function ProfileStatCard({
|
||||
)}
|
||||
</div>
|
||||
<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)]">
|
||||
<div className="platform-profile-stat-card__value whitespace-nowrap text-[16px] 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)]">
|
||||
<div className="platform-profile-stat-card__label mt-1 whitespace-nowrap text-[11px] font-medium text-[var(--platform-text-soft)]">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2445,7 +2436,7 @@ function ProfileShortcutButton({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick ?? undefined}
|
||||
className="platform-profile-shortcut-button flex min-h-[5.25rem] w-full flex-col items-center justify-center gap-2 px-2.5 py-3 text-center transition"
|
||||
className="platform-profile-shortcut-button flex min-h-[4.75rem] w-full flex-col items-center justify-center gap-1.5 px-2 py-2.5 text-center transition"
|
||||
>
|
||||
<div className="platform-profile-shortcut-button__icon">
|
||||
{imageSrc ? (
|
||||
@@ -2454,11 +2445,11 @@ function ProfileShortcutButton({
|
||||
<Icon className="h-[1.125rem] w-[1.125rem]" />
|
||||
)}
|
||||
</div>
|
||||
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[13px] font-semibold text-[var(--platform-text-strong)]">
|
||||
<div className="platform-profile-shortcut-button__label whitespace-nowrap text-[12px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</div>
|
||||
{subLabel ? (
|
||||
<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)]">
|
||||
<div className="platform-profile-shortcut-button__sub-label flex min-h-4 items-center justify-center gap-1 whitespace-nowrap text-[10px] font-medium text-[var(--platform-text-soft)]">
|
||||
{subLabel}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -2481,13 +2472,13 @@ function ProfileSettingsRow({
|
||||
<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"
|
||||
className="platform-profile-settings-row flex w-full items-center justify-between gap-3 px-4 py-3 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" />
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-semibold text-[var(--platform-text-strong)]">
|
||||
<span className="truncate text-[14px] font-semibold text-[var(--platform-text-strong)]">
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
@@ -5018,6 +5009,40 @@ export function RpgEntryHomeView({
|
||||
loadTaskCenter();
|
||||
}, [activeTab, isAuthenticated, loadTaskCenter, profileTaskRefreshKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'profile' || !isAuthenticated) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
setIsTaskCenterOpen(true);
|
||||
setTaskClaimSuccess(null);
|
||||
@@ -6320,7 +6345,7 @@ export function RpgEntryHomeView({
|
||||
|
||||
<div className="platform-profile-header__text min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="platform-profile-header__name truncate text-[20px] font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
<div className="platform-profile-header__name truncate text-[18px] font-black leading-tight text-[var(--platform-text-strong)]">
|
||||
{authUi.user.displayName}
|
||||
</div>
|
||||
<button
|
||||
@@ -6329,10 +6354,10 @@ export function RpgEntryHomeView({
|
||||
className="platform-profile-edit-button"
|
||||
aria-label="修改昵称"
|
||||
>
|
||||
<Pencil className="h-5 w-5" />
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="platform-profile-header__code mt-3 flex flex-wrap items-center gap-2 text-[13px] text-[var(--platform-text-base)]">
|
||||
<div className="platform-profile-header__code mt-2 flex flex-wrap items-center gap-2 text-[12px] text-[var(--platform-text-base)]">
|
||||
<span>陶泥号: {publicUserCode}</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -6361,10 +6386,10 @@ export function RpgEntryHomeView({
|
||||
<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 className="platform-profile-membership-card__title block text-[16px] 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 className="platform-profile-membership-card__subtitle mt-1.5 block text-[12px] font-medium text-white/92">
|
||||
升级会员,享专属特权与福利
|
||||
</span>
|
||||
</span>
|
||||
@@ -6393,7 +6418,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value="暂不可用"
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6401,7 +6426,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value="暂不可用"
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6420,7 +6445,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playTime"
|
||||
label="累计游戏时长"
|
||||
label="累计游玩"
|
||||
value={totalPlayTime}
|
||||
icon={Clock3}
|
||||
imageSrc={profileClockImage}
|
||||
@@ -6428,7 +6453,7 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileStatCard
|
||||
cardKey="playedWorks"
|
||||
label="已玩游戏数量"
|
||||
label="已玩游戏"
|
||||
value={`${formatDashboardCount(playedWorkCount)}个`}
|
||||
icon={BookOpen}
|
||||
imageSrc={profileGamepadImage}
|
||||
@@ -6448,15 +6473,15 @@ export function RpgEntryHomeView({
|
||||
<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="platform-profile-daily-task-card__desc mt-2 block text-[12px] font-medium text-[var(--platform-text-base)]">
|
||||
完成任务可领取{' '}
|
||||
<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]">
|
||||
<span className="platform-profile-daily-task-card__progress mt-3 flex items-center gap-3">
|
||||
<span className="platform-profile-daily-task-card__progress-value text-[13px] font-semibold text-[#dc3f0e]">
|
||||
{profileTaskCardSummary.progressCount} /{' '}
|
||||
{profileTaskCardSummary.threshold}
|
||||
</span>
|
||||
@@ -6475,9 +6500,6 @@ export function RpgEntryHomeView({
|
||||
alt=""
|
||||
className="platform-profile-daily-task-card__mascot"
|
||||
/>
|
||||
<span className="platform-profile-daily-task-card__action">
|
||||
{profileTaskCardSummary.actionLabel}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<section
|
||||
@@ -6501,14 +6523,14 @@ export function RpgEntryHomeView({
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="玩家社区"
|
||||
subLabel="交流心得 领取福利"
|
||||
subLabel="交流心得"
|
||||
icon={MessageCircle}
|
||||
imageSrc={profileCommunityImage}
|
||||
onClick={() => openProfilePopupPanel('community')}
|
||||
/>
|
||||
<ProfileShortcutButton
|
||||
label="反馈与建议"
|
||||
subLabel="帮助我们做得更好"
|
||||
subLabel="帮我们优化产品"
|
||||
icon={MessageCircle}
|
||||
imageSrc={profileFeedbackImage}
|
||||
onClick={onOpenFeedback}
|
||||
@@ -6517,16 +6539,6 @@ export function RpgEntryHomeView({
|
||||
</section>
|
||||
|
||||
<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}
|
||||
|
||||
@@ -360,6 +360,31 @@ export function isBarkBattleGalleryEntry(
|
||||
return 'sourceType' in entry && entry.sourceType === 'bark-battle';
|
||||
}
|
||||
|
||||
export function buildPlatformPublicGalleryCardKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
work: PuzzleWorkSummary,
|
||||
): PlatformPuzzleGalleryCard {
|
||||
|
||||
Reference in New Issue
Block a user