1
This commit is contained in:
@@ -16,9 +16,9 @@ export function RpgEntryBrandLogo({
|
||||
className={`platform-brand-logo ${className}`.trim()}
|
||||
role={decorative ? undefined : 'img'}
|
||||
aria-hidden={decorative || undefined}
|
||||
aria-label={decorative ? undefined : '叙世 GENARRATIVE'}
|
||||
aria-label={decorative ? undefined : '陶泥 GENARRATIVE'}
|
||||
>
|
||||
<span className="platform-brand-logo__title">叙世</span>
|
||||
<span className="platform-brand-logo__title">陶泥</span>
|
||||
<span className="platform-brand-logo__subtitle">GENARRATIVE</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
|
||||
import type { CustomWorldAgentSessionSnapshot } from '../../../packages/shared/src/contracts/customWorldAgent';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
getPuzzleGalleryDetail,
|
||||
listPuzzleGallery,
|
||||
} from '../../services/puzzle-gallery';
|
||||
import { startPuzzleRun } from '../../services/puzzle-runtime';
|
||||
import { listPuzzleWorks } from '../../services/puzzle-works';
|
||||
import {
|
||||
createRpgCreationSession,
|
||||
@@ -81,12 +83,12 @@ async function clickFirstAsyncButtonByName(
|
||||
|
||||
async function openCreationHub(user: ReturnType<typeof userEvent.setup>) {
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
}
|
||||
|
||||
async function openNewRpgCreation(user: ReturnType<typeof userEvent.setup>) {
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }));
|
||||
}
|
||||
|
||||
function getPlatformTabPanel(tab: string) {
|
||||
@@ -134,9 +136,20 @@ vi.mock('../../services/puzzle-gallery', () => ({
|
||||
listPuzzleGallery: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/puzzle-runtime', () => ({
|
||||
advanceLocalPuzzleNextLevel: vi.fn(),
|
||||
dragPuzzlePieceOrGroup: vi.fn(),
|
||||
startPuzzleRun: vi.fn(),
|
||||
swapPuzzlePieces: vi.fn(),
|
||||
submitPuzzleLeaderboard: vi.fn(),
|
||||
updatePuzzleRunPause: vi.fn(),
|
||||
usePuzzleRuntimeProp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/rpg-entry/rpgEntryLibraryClient', () => ({
|
||||
deleteRpgEntryWorldProfile: vi.fn(),
|
||||
getRpgEntryWorldGalleryDetailByCode: vi.fn(),
|
||||
getRpgEntryWorldLibraryDetail: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/big-fish-creation', () => ({
|
||||
@@ -362,6 +375,7 @@ const mockAuthUser: AuthUser = {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
publicUserCode: 'user-tester',
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
@@ -369,6 +383,62 @@ const mockAuthUser: AuthUser = {
|
||||
wechatBound: false,
|
||||
};
|
||||
|
||||
function buildMockPuzzleRun(
|
||||
profileId: string,
|
||||
levelName: string,
|
||||
): PuzzleRunSnapshot {
|
||||
const gridSize = 3 as const;
|
||||
|
||||
return {
|
||||
runId: `run-${profileId}`,
|
||||
entryProfileId: profileId,
|
||||
clearedLevelCount: 0,
|
||||
currentLevelIndex: 1,
|
||||
currentGridSize: gridSize,
|
||||
playedProfileIds: [profileId],
|
||||
previousLevelTags: ['机关'],
|
||||
recommendedNextProfileId: null,
|
||||
leaderboardEntries: [],
|
||||
currentLevel: {
|
||||
runId: `run-${profileId}`,
|
||||
levelIndex: 1,
|
||||
gridSize,
|
||||
profileId,
|
||||
levelName,
|
||||
authorDisplayName: '拼图作者',
|
||||
themeTags: ['机关'],
|
||||
coverImageSrc: null,
|
||||
status: 'playing',
|
||||
startedAtMs: 1_000,
|
||||
clearedAtMs: null,
|
||||
elapsedMs: null,
|
||||
timeLimitMs: 300_000,
|
||||
remainingMs: 300_000,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
leaderboardEntries: [],
|
||||
board: {
|
||||
rows: 3,
|
||||
cols: 3,
|
||||
selectedPieceId: null,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [],
|
||||
pieces: Array.from({ length: 9 }, (_, index) => ({
|
||||
pieceId: `piece-${index}`,
|
||||
correctRow: Math.floor(index / 3),
|
||||
correctCol: index % 3,
|
||||
currentRow: Math.floor(index / 3),
|
||||
currentCol: index % 3,
|
||||
mergedGroupId: null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const compiledAgentDraftSession: CustomWorldAgentSessionSnapshot = {
|
||||
...mockSession,
|
||||
stage: 'object_refining',
|
||||
@@ -540,14 +610,14 @@ function buildResultViewForSession(
|
||||
session,
|
||||
profile,
|
||||
profileSource: profile ? 'result_preview' : 'none',
|
||||
targetStage: profile && isResultStage
|
||||
? 'custom-world-result'
|
||||
: session.stage === 'error'
|
||||
? 'custom-world-generating'
|
||||
: 'agent-workspace',
|
||||
generationViewSource: session.stage === 'error'
|
||||
? 'agent-draft-foundation'
|
||||
: null,
|
||||
targetStage:
|
||||
profile && isResultStage
|
||||
? 'custom-world-result'
|
||||
: session.stage === 'error'
|
||||
? 'custom-world-generating'
|
||||
: 'agent-workspace',
|
||||
generationViewSource:
|
||||
session.stage === 'error' ? 'agent-draft-foundation' : null,
|
||||
resultViewSource: profile && isResultStage ? 'agent-draft' : null,
|
||||
canAutosaveLibrary: Boolean(profile && isResultStage),
|
||||
canSyncResultProfile:
|
||||
@@ -558,11 +628,12 @@ function buildResultViewForSession(
|
||||
publishReady: Boolean(session.resultPreview?.publishReady),
|
||||
canEnterWorld: Boolean(session.resultPreview?.canEnterWorld),
|
||||
blockerCount: session.resultPreview?.blockers?.length ?? 0,
|
||||
recoveryAction: profile && isResultStage
|
||||
? 'open_result'
|
||||
: session.stage === 'error'
|
||||
? 'resume_generation'
|
||||
: 'continue_agent',
|
||||
recoveryAction:
|
||||
profile && isResultStage
|
||||
? 'open_result'
|
||||
: session.stage === 'error'
|
||||
? 'resume_generation'
|
||||
: 'continue_agent',
|
||||
recoveryReason: null,
|
||||
};
|
||||
}
|
||||
@@ -574,6 +645,7 @@ type TestAuthValue = {
|
||||
requireAuth: (action: () => void) => void;
|
||||
openSettingsModal: (section?: PlatformSettingsSection) => void;
|
||||
openAccountModal: () => void;
|
||||
setCurrentUser: (user: AuthUser) => void;
|
||||
logout: () => Promise<void>;
|
||||
musicVolume: number;
|
||||
setMusicVolume: (value: number) => void;
|
||||
@@ -594,6 +666,7 @@ function createAuthValue(
|
||||
requireAuth: (action) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
setCurrentUser: () => {},
|
||||
logout: async () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: () => {},
|
||||
@@ -1160,7 +1233,7 @@ test('create hub exposes direct template entry, keeps AIRP and visual novel lock
|
||||
expect((airpButton as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
|
||||
@@ -1183,7 +1256,7 @@ test('platform create hub does not prefetch hidden big fish platform data', asyn
|
||||
await openCreationHub(user);
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /角色扮演 RPG/u }),
|
||||
await screen.findByRole('button', { name: /角色扮演.*剧情演绎/u }),
|
||||
).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: /大鱼吃小鱼/u })).toBeNull();
|
||||
expect(listBigFishWorks).not.toHaveBeenCalled();
|
||||
@@ -1406,7 +1479,9 @@ test('opening a compiled draft with a missing agent session falls back to create
|
||||
code: 'NOT_FOUND',
|
||||
});
|
||||
vi.mocked(getRpgCreationSession).mockRejectedValueOnce(missingSessionError);
|
||||
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(missingSessionError);
|
||||
vi.mocked(getRpgCreationResultView).mockRejectedValueOnce(
|
||||
missingSessionError,
|
||||
);
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
@@ -1569,7 +1644,7 @@ test('creation hub clears all private work shelves immediately after logout stat
|
||||
expect(within(createPanel).getByText('还没有作品')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('published puzzle works appear on home and category public shelves', async () => {
|
||||
test('published puzzle works appear on home and mobile game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -1596,6 +1671,12 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper />);
|
||||
|
||||
@@ -1603,18 +1684,18 @@ test('published puzzle works appear on home and category public shelves', async
|
||||
expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).getAllByText('星桥机关').length).toBeGreaterThan(
|
||||
0,
|
||||
);
|
||||
const homePanel = getPlatformTabPanel('home');
|
||||
expect(within(homePanel).getAllByText('星桥机关').length).toBeGreaterThan(0);
|
||||
expect(
|
||||
within(categoryPanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
within(homePanel).getAllByRole('button', { name: /机关/u }).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
});
|
||||
|
||||
test('published big fish works stay hidden from platform home and category shelves', async () => {
|
||||
test('published big fish works stay hidden from platform home and game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedBigFishWork: BigFishWorkSummary = {
|
||||
workId: 'big-fish-work-public-1',
|
||||
@@ -1644,16 +1725,16 @@ test('published big fish works stay hidden from platform home and category shelv
|
||||
});
|
||||
expect(screen.queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
expect(within(categoryPanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
const homePanel = getPlatformTabPanel('home');
|
||||
expect(within(homePanel).queryByText('机械深海 大鱼吃小鱼')).toBeNull();
|
||||
expect(
|
||||
within(categoryPanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||||
within(homePanel).queryAllByRole('button', { name: /大鱼/u }).length,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('published puzzle detail returns to the source platform tab', async () => {
|
||||
test('published puzzle detail returns to the ranking platform tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
const publishedPuzzleWork = {
|
||||
workId: 'puzzle-work-public-1',
|
||||
@@ -1680,37 +1761,48 @@ test('published puzzle detail returns to the source platform tab', async () => {
|
||||
vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({
|
||||
item: publishedPuzzleWork,
|
||||
});
|
||||
vi.mocked(startPuzzleRun).mockResolvedValue({
|
||||
run: buildMockPuzzleRun(
|
||||
publishedPuzzleWork.profileId,
|
||||
publishedPuzzleWork.levelName,
|
||||
),
|
||||
});
|
||||
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '分类' }));
|
||||
await user.click(await screen.findByRole('button', { name: '排行' }));
|
||||
await waitFor(() => {
|
||||
expect(document.getElementById('platform-tab-panel-category')).toBeTruthy();
|
||||
});
|
||||
await waitFor(() => {
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
const rankingPanel = getPlatformTabPanel('category');
|
||||
expect(
|
||||
within(categoryPanel).getAllByText('星桥机关').length,
|
||||
within(rankingPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
const categoryPanel = getPlatformTabPanel('category');
|
||||
const rankingPanel = getPlatformTabPanel('category');
|
||||
|
||||
await user.click(
|
||||
within(categoryPanel).getByRole('button', {
|
||||
name: /拼图关卡.*星桥机关/u,
|
||||
within(rankingPanel).getByRole('button', {
|
||||
name: /星桥机关/u,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
await screen.findByRole('button', { name: '进入第 1 关' }),
|
||||
).toBeTruthy();
|
||||
await user.click(await screen.findByRole('button', { name: '启动' }));
|
||||
expect(await screen.findByTestId('puzzle-board')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回上一页' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: '启动' })).toBeTruthy();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '返回' }));
|
||||
|
||||
await waitFor(() => {
|
||||
const returnedCategoryPanel = getPlatformTabPanel('category');
|
||||
expect(returnedCategoryPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
const returnedRankingPanel = getPlatformTabPanel('category');
|
||||
expect(returnedRankingPanel.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(
|
||||
within(returnedCategoryPanel).getAllByText('星桥机关').length,
|
||||
within(returnedRankingPanel).getAllByText('星桥机关').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -1854,7 +1946,7 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await openCreationHub(user);
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演 RPG/u }));
|
||||
await user.click(screen.getByRole('button', { name: /角色扮演.*剧情演绎/u }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
@@ -1892,7 +1984,7 @@ test('puzzle creation timeout exits busy state and shows a readable error', asyn
|
||||
|
||||
await openCreationHub(user);
|
||||
|
||||
const button = screen.getByRole('button', { name: /拼图玩法/u });
|
||||
const button = screen.getByRole('button', { name: /拼图.*创意礼物/u });
|
||||
await user.click(button);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -2787,7 +2879,7 @@ 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.getByText('角色扮演 RPG')).toBeTruthy();
|
||||
expect(screen.getByText('角色扮演')).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(
|
||||
@@ -2905,23 +2997,25 @@ test('agent draft result auto-save syncs result profile before persisting backen
|
||||
vi.mocked(getRpgCreationResultView).mockResolvedValue(
|
||||
buildResultViewForSession(syncedSession),
|
||||
);
|
||||
vi.mocked(executeRpgCreationAction).mockImplementation(async (_, payload) => ({
|
||||
operation: {
|
||||
operationId:
|
||||
payload.action === 'sync_result_profile'
|
||||
? 'operation-sync-result-profile-1'
|
||||
: 'operation-draft-foundation-1',
|
||||
type: payload.action,
|
||||
status: 'queued',
|
||||
phaseLabel: '已接收请求',
|
||||
phaseDetail:
|
||||
payload.action === 'sync_result_profile'
|
||||
? '正在同步结果页档案。'
|
||||
: '正在准备生成世界底稿。',
|
||||
progress: 10,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
vi.mocked(executeRpgCreationAction).mockImplementation(
|
||||
async (_, payload) => ({
|
||||
operation: {
|
||||
operationId:
|
||||
payload.action === 'sync_result_profile'
|
||||
? 'operation-sync-result-profile-1'
|
||||
: 'operation-draft-foundation-1',
|
||||
type: payload.action,
|
||||
status: 'queued',
|
||||
phaseLabel: '已接收请求',
|
||||
phaseDetail:
|
||||
payload.action === 'sync_result_profile'
|
||||
? '正在同步结果页档案。'
|
||||
: '正在准备生成世界底稿。',
|
||||
progress: 10,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(getRpgCreationOperation).mockResolvedValue({
|
||||
operationId: 'operation-sync-result-profile-1',
|
||||
type: 'sync_result_profile',
|
||||
@@ -3070,13 +3164,13 @@ test('manual tab switch is preserved after platform bootstrap requests finish',
|
||||
render(<TestWrapper withAuth />);
|
||||
|
||||
await clickFirstButtonByName(user, '创作');
|
||||
expect(await screen.findByText('角色扮演 RPG')).toBeTruthy();
|
||||
expect(await screen.findByText('角色扮演')).toBeTruthy();
|
||||
|
||||
resolveGalleryRequest([]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(getPlatformTabPanel('create')).getByText('角色扮演 RPG'),
|
||||
within(getPlatformTabPanel('create')).getByText('角色扮演'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { AuthUiContext } from '../auth/AuthUiContext';
|
||||
@@ -46,14 +47,14 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
pointProducts: [
|
||||
{
|
||||
productId: 'points_60',
|
||||
title: '60叙世币',
|
||||
title: '60陶泥币',
|
||||
priceCents: 600,
|
||||
kind: 'points',
|
||||
pointsAmount: 60,
|
||||
bonusPoints: 60,
|
||||
durationDays: 0,
|
||||
badgeLabel: '首充双倍',
|
||||
description: '首充送60叙世币',
|
||||
description: '首充送60陶泥币',
|
||||
tier: 'normal',
|
||||
},
|
||||
],
|
||||
@@ -73,7 +74,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
],
|
||||
benefits: [
|
||||
{
|
||||
benefitName: '免叙世币回合数',
|
||||
benefitName: '免陶泥币回合数',
|
||||
normalValue: '30',
|
||||
monthValue: '100',
|
||||
seasonValue: '100',
|
||||
@@ -87,7 +88,7 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
|
||||
order: {
|
||||
orderId: 'order-1',
|
||||
productId: 'points_60',
|
||||
productTitle: '60叙世币',
|
||||
productTitle: '60陶泥币',
|
||||
kind: 'points',
|
||||
amountCents: 600,
|
||||
status: 'paid',
|
||||
@@ -138,6 +139,64 @@ const puzzlePublicEntry = {
|
||||
updatedAt: '2026-04-25T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const remixRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-remix-rank',
|
||||
profileId: 'puzzle-profile-remix-rank',
|
||||
publicWorkCode: 'PZ-REMIX1',
|
||||
worldName: '改造高分拼图',
|
||||
playCount: 2,
|
||||
remixCount: 18,
|
||||
likeCount: 1,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-25T11:00:00.000Z',
|
||||
updatedAt: '2026-04-25T11:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const hotRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-hot-rank',
|
||||
profileId: 'puzzle-profile-hot-rank',
|
||||
publicWorkCode: 'PZ-HOT001',
|
||||
worldName: '热门高分拼图',
|
||||
themeTags: ['奇幻', '机关'],
|
||||
playCount: 40,
|
||||
remixCount: 1,
|
||||
likeCount: 4,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-24T10:00:00.000Z',
|
||||
updatedAt: '2026-04-24T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const newRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-new-rank',
|
||||
profileId: 'puzzle-profile-new-rank',
|
||||
publicWorkCode: 'PZ-NEW001',
|
||||
worldName: '新品增长拼图',
|
||||
playCount: 1,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 9,
|
||||
publishedAt: '2026-04-20T10:00:00.000Z',
|
||||
updatedAt: '2026-04-20T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
const longTextRankEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-long-text-rank',
|
||||
profileId: 'puzzle-profile-long-text-rank',
|
||||
publicWorkCode: 'PZ-LONG01',
|
||||
worldName: '关键词逍遥游拼图关卡',
|
||||
themeTags: ['逍遥游拼图', '古风机关'],
|
||||
playCount: 88,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
recentPlayCount7d: 0,
|
||||
publishedAt: '2026-04-29T10:00:00.000Z',
|
||||
updatedAt: '2026-04-29T10:00:00.000Z',
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
function mockDesktopLayout() {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
configurable: true,
|
||||
@@ -155,7 +214,12 @@ function mockDesktopLayout() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
function renderProfileView(
|
||||
onRechargeSuccess = vi.fn(),
|
||||
profileDashboardOverrides: Partial<
|
||||
NonNullable<RpgEntryHomeViewProps['profileDashboard']>
|
||||
> = {},
|
||||
) {
|
||||
return render(
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
@@ -164,6 +228,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
publicUserCode: '100001',
|
||||
username: 'tester',
|
||||
displayName: '测试玩家',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
@@ -174,6 +239,7 @@ function renderProfileView(onRechargeSuccess = 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(),
|
||||
@@ -200,6 +266,7 @@ function renderProfileView(onRechargeSuccess = vi.fn()) {
|
||||
totalPlayTimeMs: 0,
|
||||
playedWorldCount: 0,
|
||||
updatedAt: null,
|
||||
...profileDashboardOverrides,
|
||||
}}
|
||||
isLoadingPlatform={false}
|
||||
isLoadingDashboard={false}
|
||||
@@ -240,6 +307,7 @@ function renderLoggedOutHomeView(
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
@@ -279,6 +347,67 @@ function renderLoggedOutHomeView(
|
||||
);
|
||||
}
|
||||
|
||||
function renderStatefulLoggedOutHomeView(
|
||||
overrides: Partial<
|
||||
Pick<RpgEntryHomeViewProps, 'featuredEntries' | 'latestEntries'>
|
||||
> = {},
|
||||
) {
|
||||
function StatefulLoggedOutHomeView() {
|
||||
const [activeTab, setActiveTab] =
|
||||
useState<RpgEntryHomeViewProps['activeTab']>('home');
|
||||
|
||||
return (
|
||||
<AuthUiContext.Provider
|
||||
value={{
|
||||
user: null,
|
||||
canAccessProtectedData: false,
|
||||
openLoginModal: vi.fn(),
|
||||
requireAuth: vi.fn(),
|
||||
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={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
hasSavedGame={false}
|
||||
savedSnapshot={null}
|
||||
saveEntries={[]}
|
||||
saveError={null}
|
||||
featuredEntries={overrides.featuredEntries ?? []}
|
||||
latestEntries={overrides.latestEntries ?? []}
|
||||
myEntries={[]}
|
||||
historyEntries={[]}
|
||||
profileDashboard={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()}
|
||||
/>
|
||||
</AuthUiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
return render(<StatefulLoggedOutHomeView />);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
@@ -296,9 +425,9 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
|
||||
expect(await screen.findByText('叙世币账单')).toBeTruthy();
|
||||
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
|
||||
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText('资产操作消耗')).toBeTruthy();
|
||||
expect(screen.getByText('-1')).toBeTruthy();
|
||||
@@ -306,17 +435,30 @@ test('opens wallet ledger modal from narrative coin card', async () => {
|
||||
expect(screen.getByText('+30')).toBeTruthy();
|
||||
});
|
||||
|
||||
test('profile total play time card always uses hours', () => {
|
||||
renderProfileView(vi.fn(), {
|
||||
totalPlayTimeMs: 90 * 60 * 1000,
|
||||
});
|
||||
|
||||
const playTimeCard = screen.getByRole('button', {
|
||||
name: /总游戏时长/u,
|
||||
});
|
||||
|
||||
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
|
||||
expect(within(playTimeCard).queryByText('90分')).toBeNull();
|
||||
});
|
||||
|
||||
test('wallet ledger modal shows empty and error states', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
|
||||
|
||||
renderProfileView();
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
|
||||
|
||||
await user.click(screen.getByLabelText('关闭叙世币账单'));
|
||||
await user.click(screen.getByLabelText('关闭陶泥币账单'));
|
||||
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
|
||||
await user.click(screen.getByText('剩余叙世币'));
|
||||
await user.click(screen.getByText('剩余陶泥币'));
|
||||
|
||||
expect(await screen.findByText('加载失败')).toBeTruthy();
|
||||
expect(screen.getByText('重新加载')).toBeTruthy();
|
||||
@@ -345,6 +487,7 @@ test('mobile home search submits public work code', async () => {
|
||||
requireAuth: vi.fn(),
|
||||
openSettingsModal: vi.fn(),
|
||||
openAccountModal: vi.fn(),
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: vi.fn(async () => undefined),
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
@@ -410,6 +553,82 @@ test('public gallery cards hide work code until detail is opened', async () => {
|
||||
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
|
||||
});
|
||||
|
||||
test('mobile public work cards render cover, content and like count', () => {
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
const card = screen.getByRole('button', {
|
||||
name: /奇幻拼图,12点赞/u,
|
||||
});
|
||||
expect(
|
||||
card.querySelector('.platform-public-work-card__cover.aspect-video'),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('奇幻拼图')).toBeTruthy();
|
||||
expect(screen.getByText('一张用于公开分享的拼图作品。')).toBeTruthy();
|
||||
expect(screen.getByText('奇幻')).toBeTruthy();
|
||||
expect(screen.getByText('12')).toBeTruthy();
|
||||
expect(screen.getByText('点赞')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-mobile-home-channel--active')
|
||||
?.textContent,
|
||||
).toBe('推荐');
|
||||
});
|
||||
|
||||
test('mobile today channel only shows newly published works from today', async () => {
|
||||
const user = userEvent.setup();
|
||||
const now = new Date();
|
||||
const todayPublishedAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
10,
|
||||
).toISOString();
|
||||
const yesterdayPublishedAt = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate() - 1,
|
||||
10,
|
||||
).toISOString();
|
||||
const todayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-today',
|
||||
profileId: 'puzzle-profile-today',
|
||||
publicWorkCode: 'PZ-TODAY1',
|
||||
worldName: '今日新游',
|
||||
publishedAt: todayPublishedAt,
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const yesterdayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-yesterday',
|
||||
profileId: 'puzzle-profile-yesterday',
|
||||
publicWorkCode: 'PZ-YDAY01',
|
||||
worldName: '昨日旧作',
|
||||
publishedAt: yesterdayPublishedAt,
|
||||
updatedAt: yesterdayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
const updatedTodayEntry = {
|
||||
...puzzlePublicEntry,
|
||||
workId: 'puzzle-work-updated-today',
|
||||
profileId: 'puzzle-profile-updated-today',
|
||||
publicWorkCode: 'PZ-UPDAY1',
|
||||
worldName: '今日更新旧作',
|
||||
publishedAt: yesterdayPublishedAt,
|
||||
updatedAt: todayPublishedAt,
|
||||
} satisfies PlatformPublicGalleryCard;
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [yesterdayEntry, updatedTodayEntry, todayEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '今日游戏' }));
|
||||
|
||||
expect(screen.getByRole('button', { name: /今日新游/u })).toBeTruthy();
|
||||
expect(screen.queryByText('昨日旧作')).toBeNull();
|
||||
expect(screen.queryByText('今日更新旧作')).toBeNull();
|
||||
});
|
||||
|
||||
test('desktop trending list shows kind instead of work code or timestamp text', () => {
|
||||
mockDesktopLayout();
|
||||
|
||||
@@ -421,3 +640,96 @@ test('desktop trending list shows kind instead of work code or timestamp text',
|
||||
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('1777110165.990127Z')).toBeNull();
|
||||
});
|
||||
|
||||
test('mobile home moves category shelf into game category channel', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'PC游戏' })).toBeNull();
|
||||
expect(screen.queryByRole('button', { name: '即点即玩' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
|
||||
expect(screen.getAllByText('游戏分类').length).toBeGreaterThan(0);
|
||||
expect(screen.getByRole('button', { name: /筛选/u })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '奇幻' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: /奇幻拼图,试玩/u })).toBeTruthy();
|
||||
expect(container.querySelector('.platform-category-game-list')).toBeTruthy();
|
||||
expect(container.querySelector('.platform-category-game-item')).toBeTruthy();
|
||||
expect(
|
||||
container.querySelector('.platform-category-game-item__action')
|
||||
?.textContent,
|
||||
).toBe('试玩');
|
||||
});
|
||||
|
||||
test('mobile game category list orders works by composite public metric', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderLoggedOutHomeView(vi.fn(), {
|
||||
latestEntries: [puzzlePublicEntry, hotRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '游戏分类' }));
|
||||
await user.click(screen.getByRole('button', { name: '奇幻' }));
|
||||
|
||||
const gameItems = Array.from(
|
||||
document.querySelectorAll('.platform-category-game-item__title'),
|
||||
).map((element) => element.textContent);
|
||||
expect(gameItems).toEqual(['热门高分拼图', '奇幻拼图']);
|
||||
});
|
||||
|
||||
test('bottom category tab becomes ranking and switches ranking metrics', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [remixRankEntry, hotRankEntry, newRankEntry],
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('button', { name: '分类' })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
expect(await screen.findByRole('tab', { name: '热门榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '改造榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '新品榜' })).toBeTruthy();
|
||||
expect(screen.getByRole('tab', { name: '点赞榜' })).toBeTruthy();
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
expect(rankingPanel?.getAttribute('aria-hidden')).toBe('false');
|
||||
expect(within(rankingPanel!).getByText('热门高分拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('40')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('游玩').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '改造榜' }));
|
||||
|
||||
expect(within(rankingPanel!).getByText('改造高分拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('18')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('改造').length).toBeGreaterThan(0);
|
||||
|
||||
await user.click(screen.getByRole('tab', { name: '新品榜' }));
|
||||
|
||||
expect(within(rankingPanel!).getByText('新品增长拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('9')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getAllByText('近7日').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('ranking rows limit displayed work name and show two short tags on the third line', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderStatefulLoggedOutHomeView({
|
||||
latestEntries: [longTextRankEntry],
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '排行' }));
|
||||
|
||||
const rankingPanel = document.getElementById('platform-tab-panel-category');
|
||||
expect(rankingPanel).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('关键词逍遥游拼图')).toBeTruthy();
|
||||
expect(within(rankingPanel!).queryByText('关键词逍遥游拼图关卡')).toBeNull();
|
||||
expect(within(rankingPanel!).getByText('逍遥游拼')).toBeTruthy();
|
||||
expect(within(rankingPanel!).getByText('古风机关')).toBeTruthy();
|
||||
expect(within(rankingPanel!).queryByText(/2026-04-29/u)).toBeNull();
|
||||
expect(within(rankingPanel!).queryByText('拼图玩家')).toBeNull();
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,8 +8,10 @@ import { copyTextToClipboard } from '../../services/clipboard';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
import {
|
||||
buildPlatformWorldTags,
|
||||
buildPlatformWorldDisplayTags,
|
||||
describePlatformThemeLabel,
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTag,
|
||||
formatPlatformWorldTime,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
@@ -83,13 +85,8 @@ export function RpgEntryWorldDetailView({
|
||||
entry.profile,
|
||||
).slice(0, 3);
|
||||
const previewLandmarks = entry.profile.landmarks.slice(0, 3);
|
||||
const tags = [
|
||||
...new Set(
|
||||
buildPlatformWorldTags(entry)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 3);
|
||||
const displayName = formatPlatformWorkDisplayName(entry.worldName);
|
||||
const tags = buildPlatformWorldDisplayTags(entry, 3);
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
@@ -152,7 +149,9 @@ export function RpgEntryWorldDetailView({
|
||||
<div className="relative z-10">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="platform-pill platform-pill--warm">
|
||||
{describePlatformThemeLabel(entry.themeMode)}
|
||||
{formatPlatformWorkDisplayTag(
|
||||
describePlatformThemeLabel(entry.themeMode),
|
||||
)}
|
||||
</span>
|
||||
<span className="platform-pill platform-pill--neutral px-3">
|
||||
{entry.authorDisplayName}
|
||||
@@ -198,7 +197,7 @@ export function RpgEntryWorldDetailView({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-4 text-3xl font-black text-white">
|
||||
{entry.worldName}
|
||||
{displayName}
|
||||
</div>
|
||||
{entry.subtitle ? (
|
||||
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-300/88">
|
||||
|
||||
38
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal file
38
src/components/rpg-entry/rpgEntryWorldPresentation.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
formatPlatformWorkDisplayName,
|
||||
formatPlatformWorkDisplayTags,
|
||||
formatPlatformWorldTime,
|
||||
} from './rpgEntryWorldPresentation';
|
||||
|
||||
test('formatPlatformWorldTime formats backend seconds timestamp text as date', () => {
|
||||
expect(formatPlatformWorldTime('1777110165.990127Z')).toBe('2026-04-25');
|
||||
});
|
||||
|
||||
test('formatPlatformWorldTime keeps full year for iso date strings', () => {
|
||||
expect(formatPlatformWorldTime('2026-04-25T12:00:00.000Z')).toBe(
|
||||
'2026-04-25',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatPlatformWorldTime uses utc calendar date for zulu time', () => {
|
||||
expect(formatPlatformWorldTime('2026-04-25T00:30:00.000Z')).toBe(
|
||||
'2026-04-25',
|
||||
);
|
||||
});
|
||||
|
||||
test('formatPlatformWorldTime keeps fallback text for invalid values', () => {
|
||||
expect(formatPlatformWorldTime(null)).toBe('未发布');
|
||||
expect(formatPlatformWorldTime('not-a-date')).toBe('not-a-date');
|
||||
});
|
||||
|
||||
test('platform work display text limits names and tags by character count', () => {
|
||||
expect(formatPlatformWorkDisplayName('热门高分拼图超长标题')).toBe(
|
||||
'热门高分拼图超长',
|
||||
);
|
||||
expect(formatPlatformWorkDisplayTags(['超长机关标签', '星桥', '超长机关标签'])).toEqual([
|
||||
'超长机关',
|
||||
'星桥',
|
||||
]);
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import {
|
||||
} from '../../services/publicWorkCode';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
|
||||
export const PLATFORM_WORK_NAME_DISPLAY_LIMIT = 8;
|
||||
export const PLATFORM_WORK_TAG_DISPLAY_LIMIT = 4;
|
||||
|
||||
export type PlatformWorldCardLike =
|
||||
| CustomWorldGalleryCard
|
||||
| CustomWorldLibraryEntry<CustomWorldProfile>
|
||||
@@ -33,6 +36,7 @@ export type PlatformPuzzleGalleryCard = {
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
@@ -53,6 +57,7 @@ export type PlatformBigFishGalleryCard = {
|
||||
playCount?: number;
|
||||
remixCount?: number;
|
||||
likeCount?: number;
|
||||
recentPlayCount7d?: number;
|
||||
visibility: 'published';
|
||||
publishedAt: string | null;
|
||||
updatedAt: string;
|
||||
@@ -99,6 +104,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: work.remixCount ?? 0,
|
||||
likeCount: work.likeCount ?? 0,
|
||||
recentPlayCount7d: work.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
@@ -114,7 +120,7 @@ export function mapBigFishWorkToPlatformGalleryCard(
|
||||
profileId: work.sourceSessionId,
|
||||
publicWorkCode: buildBigFishPublicWorkCode(work.sourceSessionId),
|
||||
ownerUserId: work.ownerUserId,
|
||||
authorDisplayName: '大鱼创作者',
|
||||
authorDisplayName: '大鱼陶泥主',
|
||||
worldName: work.title,
|
||||
subtitle: work.subtitle || '大鱼吃小鱼',
|
||||
summaryText: work.summary,
|
||||
@@ -123,6 +129,7 @@ export function mapBigFishWorkToPlatformGalleryCard(
|
||||
playCount: work.playCount ?? 0,
|
||||
remixCount: work.remixCount ?? 0,
|
||||
likeCount: work.likeCount ?? 0,
|
||||
recentPlayCount7d: work.recentPlayCount7d ?? 0,
|
||||
visibility: 'published',
|
||||
publishedAt: work.publishedAt ?? work.updatedAt,
|
||||
updatedAt: work.updatedAt,
|
||||
@@ -134,7 +141,10 @@ export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
|
||||
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
|
||||
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
|
||||
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
|
||||
recentPlayCount7d:
|
||||
'recentPlayCount7d' in entry ? (entry.recentPlayCount7d ?? 0) : 0,
|
||||
publishedAt: entry.publishedAt ?? null,
|
||||
updatedAt: entry.updatedAt ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,6 +168,44 @@ export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
|
||||
return buildCustomWorldPlayableCharacters(entry.profile)[0]?.portrait ?? '';
|
||||
}
|
||||
|
||||
function limitPlatformDisplayText(value: string, maxLength: number) {
|
||||
const normalized = value.trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return chars.slice(0, maxLength).join('');
|
||||
}
|
||||
|
||||
export function formatPlatformWorkDisplayName(value: string) {
|
||||
return limitPlatformDisplayText(value, PLATFORM_WORK_NAME_DISPLAY_LIMIT);
|
||||
}
|
||||
|
||||
export function formatPlatformWorkDisplayTag(value: string) {
|
||||
return limitPlatformDisplayText(value, PLATFORM_WORK_TAG_DISPLAY_LIMIT);
|
||||
}
|
||||
|
||||
export function formatPlatformWorkDisplayTags(
|
||||
tags: string[],
|
||||
limit = tags.length,
|
||||
) {
|
||||
return [
|
||||
...new Set(
|
||||
tags
|
||||
.map((tag) => formatPlatformWorkDisplayTag(tag))
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, limit);
|
||||
}
|
||||
|
||||
export function buildPlatformWorldDisplayTags(
|
||||
entry: PlatformWorldCardLike,
|
||||
limit = 3,
|
||||
) {
|
||||
return formatPlatformWorkDisplayTags(buildPlatformWorldTags(entry), limit);
|
||||
}
|
||||
|
||||
export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return entry.themeTags.length > 0 ? entry.themeTags.slice(0, 3) : ['大鱼'];
|
||||
@@ -184,20 +232,50 @@ export function buildPlatformWorldTags(entry: PlatformWorldCardLike) {
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
function parsePlatformWorldDate(value: string) {
|
||||
const normalized = value.trim();
|
||||
const numericTimestamp = normalized.match(/^(-?\d+(?:\.\d+)?)(?:Z)?$/u);
|
||||
if (numericTimestamp?.[1]) {
|
||||
const rawTimestamp = Number(numericTimestamp[1]);
|
||||
if (Number.isFinite(rawTimestamp)) {
|
||||
const absoluteTimestamp = Math.abs(rawTimestamp);
|
||||
const timestampMs =
|
||||
absoluteTimestamp >= 1_000_000_000_000_000
|
||||
? rawTimestamp / 1000
|
||||
: absoluteTimestamp >= 1_000_000_000_000
|
||||
? rawTimestamp
|
||||
: absoluteTimestamp >= 1_000_000_000
|
||||
? rawTimestamp * 1000
|
||||
: Number.NaN;
|
||||
const date = new Date(timestampMs);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatPlatformDateOnly(date: Date) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getUTCDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function formatPlatformWorldTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '未发布';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
const date = parsePlatformWorldDate(value);
|
||||
if (!date) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
return formatPlatformDateOnly(date);
|
||||
}
|
||||
|
||||
export function resolvePlatformPublicWorkCode(
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ApiClientError } from '../../services/apiClient';
|
||||
import {
|
||||
deleteRpgEntryWorldProfile,
|
||||
getRpgEntryWorldLibraryDetail,
|
||||
getRpgEntryWorldGalleryDetail,
|
||||
listRpgEntryWorldLibrary,
|
||||
publishRpgEntryWorldProfile,
|
||||
@@ -138,7 +139,7 @@ export function useRpgEntryLibraryDetail(
|
||||
}, [savedCustomWorldEntries, selectedDetailEntry, setSelectedDetailEntry]);
|
||||
|
||||
const openLibraryDetail = useCallback(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
if (entry.visibility === 'published') {
|
||||
void appendBrowseHistoryEntry({
|
||||
ownerUserId: entry.ownerUserId,
|
||||
@@ -157,8 +158,29 @@ export function useRpgEntryLibraryDetail(
|
||||
if (entry.publicWorkCode?.trim()) {
|
||||
pushAppHistoryPath(buildPublicWorkDetailPath(entry.publicWorkCode));
|
||||
}
|
||||
|
||||
if (!userId || entry.ownerUserId !== userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDetailLoading(true);
|
||||
try {
|
||||
const detailEntry = await getRpgEntryWorldLibraryDetail(entry.profileId);
|
||||
setSelectedDetailEntry(detailEntry);
|
||||
} catch (error) {
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsDetailLoading(false);
|
||||
}
|
||||
},
|
||||
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
|
||||
[
|
||||
appendBrowseHistoryEntry,
|
||||
setSelectedDetailEntry,
|
||||
setSelectionStage,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
const loadGalleryDetailEntry = useCallback(
|
||||
@@ -213,21 +235,31 @@ export function useRpgEntryLibraryDetail(
|
||||
);
|
||||
|
||||
const openSavedCustomWorldEditor = useCallback(
|
||||
(entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
setSelectedDetailEntry(entry);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setCustomWorldError(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setGeneratedCustomWorldProfile(entry.profile);
|
||||
markAutoSavedProfile(entry.profile);
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('saved-profile');
|
||||
setSelectionStage('custom-world-result');
|
||||
async (entry: CustomWorldLibraryEntry<CustomWorldProfile>) => {
|
||||
try {
|
||||
const detailEntry =
|
||||
userId && entry.ownerUserId === userId
|
||||
? await getRpgEntryWorldLibraryDetail(entry.profileId)
|
||||
: entry;
|
||||
setSelectedDetailEntry(detailEntry);
|
||||
resetAutoSaveTrackingToIdle();
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource(null);
|
||||
setCustomWorldError(null);
|
||||
setGeneratedCustomWorldProfile(null);
|
||||
setGeneratedCustomWorldProfile(detailEntry.profile);
|
||||
markAutoSavedProfile(detailEntry.profile);
|
||||
setCustomWorldAutoSaveState('saved');
|
||||
setCustomWorldAutoSaveError(null);
|
||||
setCustomWorldError(null);
|
||||
setCustomWorldGenerationViewSource(null);
|
||||
setCustomWorldResultViewSource('saved-profile');
|
||||
setSelectionStage('custom-world-result');
|
||||
} catch (error) {
|
||||
setDetailError(
|
||||
resolveRpgEntryErrorMessage(error, '读取作品详情失败。'),
|
||||
);
|
||||
}
|
||||
},
|
||||
[
|
||||
markAutoSavedProfile,
|
||||
@@ -239,6 +271,7 @@ export function useRpgEntryLibraryDetail(
|
||||
setGeneratedCustomWorldProfile,
|
||||
setSelectedDetailEntry,
|
||||
setSelectionStage,
|
||||
userId,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -346,7 +379,7 @@ export function useRpgEntryLibraryDetail(
|
||||
}
|
||||
|
||||
if (matchedEntry) {
|
||||
openLibraryDetail(matchedEntry);
|
||||
void openLibraryDetail(matchedEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user