This commit is contained in:
2026-04-29 11:51:04 +08:00
parent e191619ab3
commit 412279ae11
89 changed files with 3966 additions and 491 deletions

View File

@@ -105,6 +105,7 @@ test('creation hub mixes puzzle works into the same grid and uses puzzle tag to
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
publishReady: true,
},
]}
@@ -158,6 +159,7 @@ test('creation hub shows RPG public work code from published library entry', ()
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]}
loading={false}
@@ -249,6 +251,7 @@ test('creation hub work code copy button copies without opening the card', async
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
likeCount: 0,
publishReady: true,
},
]}

View File

@@ -65,6 +65,7 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
likeCount: 0,
publishReady: true,
},
]}

View File

@@ -54,7 +54,10 @@ import {
getBigFishCreationSession,
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import { listBigFishGallery } from '../../services/big-fish-gallery';
import {
listBigFishGallery,
remixBigFishGalleryWork,
} from '../../services/big-fish-gallery';
import {
advanceLocalBigFishRuntimeRun,
recordBigFishPlay,
@@ -91,6 +94,7 @@ import {
import {
getPuzzleGalleryDetail,
listPuzzleGallery,
remixPuzzleGalleryWork,
} from '../../services/puzzle-gallery';
import {
advanceLocalPuzzleNextLevel,
@@ -110,6 +114,8 @@ import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreati
import {
deleteRpgEntryWorldProfile,
getRpgEntryWorldGalleryDetailByCode,
remixRpgEntryWorldGallery,
recordRpgEntryWorldGalleryPlay,
} from '../../services/rpg-entry/rpgEntryLibraryClient';
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
import type { CustomWorldProfile } from '../../types';
@@ -138,6 +144,7 @@ import {
} from './platformEntryShared';
import type { PlatformEntryFlowShellProps } from './platformEntryTypes';
import { PlatformEntryWorldDetailView } from './PlatformEntryWorldDetailView';
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
import { usePlatformEntryLibraryDetail } from './usePlatformEntryLibraryDetail';
@@ -152,15 +159,19 @@ type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab;
};
type PuzzleRuntimeReturnStage = 'puzzle-result' | 'puzzle-gallery-detail';
type PuzzleRuntimeReturnStage =
| 'puzzle-result'
| 'puzzle-gallery-detail'
| 'work-detail'
| 'platform';
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
type AgentResultBlockerView = {
code?: string;
message: string;
};
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
const AGENT_RESULT_STRUCTURAL_BLOCKER_CODES = new Set([
'publish_missing_world_hook',
'publish_missing_player_premise',
@@ -201,6 +212,59 @@ function mergePlatformPublicGalleryEntries(
);
}
function mapRpgGalleryCardToPublicWorkDetail(
entry: CustomWorldGalleryCard,
): PlatformPublicGalleryCard {
return entry;
}
function mapPuzzleWorkToPublicWorkDetail(
item: PuzzleWorkSummary,
): PlatformPublicGalleryCard {
return mapPuzzleWorkToPlatformGalleryCard(item);
}
function mapBigFishWorkToPublicWorkDetail(
item: BigFishWorkSummary,
): PlatformPublicGalleryCard {
return mapBigFishWorkToPlatformGalleryCard(item);
}
function mapPublicWorkDetailToBigFishWork(
entry: PlatformPublicGalleryCard,
): BigFishWorkSummary | null {
if (!isBigFishGalleryEntry(entry)) {
return null;
}
const levelCount = Number.parseInt(
entry.themeTags.find((tag) => /^\d+$/u.test(tag))?.replace('', '') ??
'0',
10,
);
return {
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishedAt: entry.publishedAt,
publishReady: true,
levelCount: Number.isNaN(levelCount) ? 0 : levelCount,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
};
}
function readProfileTextField(
profile: CustomWorldProfile | null,
paths: string[],
@@ -439,6 +503,12 @@ export function PlatformEntryFlowShellImpl({
const [showCreationTypeModal, setShowCreationTypeModal] = useState(false);
const [selectedDetailEntry, setSelectedDetailEntry] =
useState<CustomWorldLibraryEntry<CustomWorldProfile> | null>(null);
const [selectedPublicWorkDetail, setSelectedPublicWorkDetail] =
useState<PlatformPublicGalleryCard | null>(null);
const [publicWorkDetailError, setPublicWorkDetailError] = useState<
string | null
>(null);
const [isPublicWorkDetailBusy, setIsPublicWorkDetailBusy] = useState(false);
const [bigFishWorks, setBigFishWorks] = useState<BigFishWorkSummary[]>([]);
const [bigFishGalleryEntries, setBigFishGalleryEntries] = useState<
BigFishWorkSummary[]
@@ -454,8 +524,8 @@ export function PlatformEntryFlowShellImpl({
const [bigFishRuntimeStartedAt, setBigFishRuntimeStartedAt] = useState<
number | null
>(null);
const [bigFishRuntimeSessionSource, setBigFishRuntimeSessionSource] =
useState<BigFishRuntimeSessionSource>(null);
const [bigFishRuntimeReturnStage, setBigFishRuntimeReturnStage] =
useState<BigFishRuntimeReturnStage>('platform');
const [isBigFishLoadingLibrary, setIsBigFishLoadingLibrary] = useState(false);
const [bigFishGenerationState, setBigFishGenerationState] =
useState<MiniGameDraftGenerationState | null>(null);
@@ -980,10 +1050,13 @@ export function PlatformEntryFlowShellImpl({
response.session.publishedProfileId,
);
setSelectedPuzzleDetail(galleryDetail.item);
setSelectionStage('puzzle-gallery-detail');
const detailEntry = mapPuzzleWorkToPublicWorkDetail(galleryDetail.item);
setSelectedPublicWorkDetail(detailEntry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
pushAppHistoryPath(
buildPublicWorkStagePath(
'puzzle-gallery-detail',
'work-detail',
buildPuzzlePublicWorkCode(galleryDetail.item.profileId),
),
);
@@ -1060,12 +1133,15 @@ export function PlatformEntryFlowShellImpl({
// 一旦退出登录或鉴权上下文被收回,三类作品缓存必须同步清空,不能等刷新页面。
setShowCreationTypeModal(false);
setSelectedDetailEntry(null);
setSelectedPublicWorkDetail(null);
setPublicWorkDetailError(null);
setIsPublicWorkDetailBusy(false);
setBigFishWorks([]);
setBigFishRun(null);
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishRuntimeReturnStage('platform');
setBigFishGenerationState(null);
setBigFishError(null);
setPuzzleOperation(null);
@@ -1088,6 +1164,7 @@ export function PlatformEntryFlowShellImpl({
if (
selectionStage !== 'platform' &&
selectionStage !== 'work-detail' &&
selectionStage !== 'detail' &&
selectionStage !== 'puzzle-gallery-detail'
) {
@@ -1150,7 +1227,7 @@ export function PlatformEntryFlowShellImpl({
setBigFishRun(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(null);
setBigFishRuntimeSessionSource(null);
setBigFishRuntimeReturnStage('platform');
setBigFishGenerationState(null);
bigFishFlow.leaveFlow();
}, [bigFishFlow]);
@@ -1192,7 +1269,7 @@ export function PlatformEntryFlowShellImpl({
setBigFishRuntimeShare(null);
setBigFishRuntimeWork(null);
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('draft');
setBigFishRuntimeReturnStage('big-fish-result');
setBigFishRun(startLocalBigFishRuntimeRun({ session: bigFishSession }));
setSelectionStage('big-fish-runtime');
void recordBigFishPlay(sessionId, { elapsedMs: 0 }).catch((error) => {
@@ -1221,9 +1298,9 @@ export function PlatformEntryFlowShellImpl({
setBigFishError(null);
if (bigFishSession) {
setBigFishRuntimeShare(null);
setBigFishRuntimeReturnStage('big-fish-result');
}
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource(bigFishSession ? 'draft' : 'work');
setBigFishRun(
startLocalBigFishRuntimeRun({
session: bigFishSession,
@@ -1245,7 +1322,10 @@ export function PlatformEntryFlowShellImpl({
]);
const startPuzzleRunFromProfile = useCallback(
async (profileId: string) => {
async (
profileId: string,
returnStage: PuzzleRuntimeReturnStage = 'work-detail',
) => {
if (isPuzzleBusy) {
return;
}
@@ -1258,7 +1338,7 @@ export function PlatformEntryFlowShellImpl({
const { run } = await startPuzzleRun({ profileId: item.profileId });
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setPuzzleRuntimeReturnStage(returnStage);
setSelectionStage('puzzle-runtime');
pushAppHistoryPath(
buildPublicWorkStagePath(
@@ -1297,6 +1377,8 @@ export function PlatformEntryFlowShellImpl({
updatedAt: now,
publishedAt: null,
playCount: 0,
remixCount: 0,
likeCount: 0,
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
} satisfies PuzzleWorkSummary;
},
@@ -1733,6 +1815,85 @@ export function PlatformEntryFlowShellImpl({
],
);
const openPublicWorkDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
if (entry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', entry.publicWorkCode),
);
}
},
[setSelectionStage],
);
const openRpgPublicWorkDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
try {
const detailEntry =
await detailNavigation.loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
setSelectedPublicWorkDetail(
mapRpgGalleryCardToPublicWorkDetail(detailEntry),
);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkStagePath('work-detail', detailEntry.publicWorkCode),
);
}
} catch (error) {
setSelectedPublicWorkDetail(entry);
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '读取作品详情失败。'),
);
} finally {
setIsPublicWorkDetailBusy(false);
}
},
[detailNavigation, setSelectedDetailEntry, setSelectionStage],
);
const openPuzzlePublicWorkDetail = useCallback(
async (
profileId: string,
returnTarget: PuzzleDetailReturnTarget = {
tab: platformBootstrap.platformTab,
},
) => {
setIsPuzzleBusy(true);
setIsPublicWorkDetailBusy(true);
setPuzzleError(null);
setPublicWorkDetailError(null);
setSelectionStage('work-detail');
try {
const { item } = await getPuzzleGalleryDetail(profileId);
setSelectedPuzzleDetail(item);
setPuzzleDetailReturnTarget(returnTarget);
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
} catch (error) {
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
);
} finally {
setIsPuzzleBusy(false);
setIsPublicWorkDetailBusy(false);
}
},
[
openPublicWorkDetail,
platformBootstrap.platformTab,
resolvePuzzleErrorMessage,
setPuzzleError,
],
);
const openPuzzleDetail = useCallback(
async (
profileId: string,
@@ -1793,7 +1954,10 @@ export function PlatformEntryFlowShellImpl({
);
const startBigFishRunFromWork = useCallback(
(item: BigFishWorkSummary) => {
(
item: BigFishWorkSummary,
returnStage: BigFishRuntimeReturnStage = 'work-detail',
) => {
const sessionId = item.sourceSessionId?.trim();
if (!sessionId) {
setBigFishError('当前作品缺少会话信息,暂时无法进入玩法。');
@@ -1809,7 +1973,7 @@ export function PlatformEntryFlowShellImpl({
publicWorkCode,
});
setBigFishRuntimeStartedAt(Date.now());
setBigFishRuntimeSessionSource('work');
setBigFishRuntimeReturnStage(returnStage);
setBigFishRun(startLocalBigFishRuntimeRun({ work: item }));
setSelectionStage('big-fish-runtime');
pushAppHistoryPath(
@@ -1824,6 +1988,157 @@ export function PlatformEntryFlowShellImpl({
[bigFishFlow, resolveBigFishErrorMessage, setSelectionStage],
);
const startSelectedPublicWork = useCallback(() => {
if (!selectedPublicWorkDetail || isPublicWorkDetailBusy) {
return;
}
if (isBigFishGalleryEntry(selectedPublicWorkDetail)) {
const work = mapPublicWorkDetailToBigFishWork(selectedPublicWorkDetail);
if (!work) {
setPublicWorkDetailError('当前作品缺少会话信息,暂时无法进入玩法。');
return;
}
startBigFishRunFromWork(work);
return;
}
if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) {
void startPuzzleRunFromProfile(selectedPublicWorkDetail.profileId);
return;
}
const launchEntry =
selectedDetailEntry?.profileId === selectedPublicWorkDetail.profileId
? selectedDetailEntry
: null;
if (!launchEntry) {
setPublicWorkDetailError('作品详情尚未读取完成。');
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
void recordRpgEntryWorldGalleryPlay(
launchEntry.ownerUserId,
launchEntry.profileId,
)
.then((updatedEntry) => {
setSelectedDetailEntry(updatedEntry);
setSelectedPublicWorkDetail(
mapRpgGalleryCardToPublicWorkDetail(updatedEntry),
);
handleCustomWorldSelect(updatedEntry.profile);
})
.catch((error) => {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, '记录作品游玩失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
});
}, [
handleCustomWorldSelect,
isPublicWorkDetailBusy,
runProtectedAction,
selectedDetailEntry,
selectedPublicWorkDetail,
startBigFishRunFromWork,
startPuzzleRunFromProfile,
]);
const remixPublicWork = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isPublicWorkDetailBusy) {
return;
}
runProtectedAction(() => {
setIsPublicWorkDetailBusy(true);
setPublicWorkDetailError(null);
if (isBigFishGalleryEntry(entry)) {
void remixBigFishGalleryWork(entry.profileId)
.then((response) => {
bigFishFlow.setSession(response.session);
enterCreateTab();
setSelectionStage('big-fish-result');
})
.catch((error) => {
setPublicWorkDetailError(
resolveBigFishErrorMessage(error, 'Remix 大鱼吃小鱼作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
return;
}
if (isPuzzleGalleryEntry(entry)) {
void remixPuzzleGalleryWork(entry.profileId)
.then((response) => {
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
enterCreateTab();
setSelectionStage('puzzle-result');
})
.catch((error) => {
setPublicWorkDetailError(
resolvePuzzleErrorMessage(error, 'Remix 拼图作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
return;
}
void remixRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
.then((response) => {
const nextEntry = response.entry;
setSelectedDetailEntry(nextEntry);
platformBootstrap.setSavedCustomWorldEntries([
nextEntry,
...platformBootstrap.savedCustomWorldEntries.filter(
(entry) => entry.profileId !== nextEntry.profileId,
),
]);
detailNavigation.openSavedCustomWorldEditor(nextEntry);
})
.catch((error) => {
setPublicWorkDetailError(
resolveRpgCreationErrorMessage(error, 'Remix RPG 作品失败。'),
);
})
.finally(() => {
setIsPublicWorkDetailBusy(false);
});
});
},
[
bigFishFlow,
detailNavigation,
enterCreateTab,
isPublicWorkDetailBusy,
platformBootstrap,
puzzleFlow,
resolveBigFishErrorMessage,
resolvePuzzleErrorMessage,
runProtectedAction,
setSelectionStage,
],
);
const remixSelectedPublicWork = useCallback(() => {
if (!selectedPublicWorkDetail) {
return;
}
remixPublicWork(selectedPublicWorkDetail);
}, [remixPublicWork, selectedPublicWorkDetail]);
const handlePublicCodeSearch = useCallback(
async (keyword: string) => {
const normalizedKeyword = keyword.trim();
@@ -1856,7 +2171,7 @@ export function PlatformEntryFlowShellImpl({
const tryOpenGalleryEntry = async () => {
const entry =
await getRpgEntryWorldGalleryDetailByCode(normalizedKeyword);
await detailNavigation.openGalleryDetail({
const card = {
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: entry.publicWorkCode,
@@ -1872,7 +2187,12 @@ export function PlatformEntryFlowShellImpl({
themeMode: entry.themeMode,
playableNpcCount: entry.playableNpcCount,
landmarkCount: entry.landmarkCount,
} satisfies CustomWorldGalleryCard);
playCount: entry.playCount ?? 0,
remixCount: entry.remixCount ?? 0,
likeCount: entry.likeCount ?? 0,
} satisfies CustomWorldGalleryCard;
setSelectedDetailEntry(entry);
openPublicWorkDetail(card);
};
const tryOpenPuzzleGalleryEntry = async () => {
const entries =
@@ -1887,7 +2207,7 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到拼图作品。');
}
await openPuzzleDetail(matchedEntry.profileId, {
await openPuzzlePublicWorkDetail(matchedEntry.profileId, {
tab: platformBootstrap.platformTab,
});
};
@@ -1904,7 +2224,7 @@ export function PlatformEntryFlowShellImpl({
throw new Error('未找到大鱼吃小鱼作品。');
}
await startBigFishRunFromWork(matchedEntry);
openPublicWorkDetail(mapBigFishWorkToPublicWorkDetail(matchedEntry));
};
try {
@@ -1959,14 +2279,13 @@ export function PlatformEntryFlowShellImpl({
}
},
[
detailNavigation,
bigFishGalleryEntries,
openPuzzleDetail,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
platformBootstrap.platformTab,
puzzleGalleryEntries,
refreshBigFishGallery,
refreshPuzzleGallery,
startBigFishRunFromWork,
],
);
@@ -1997,7 +2316,7 @@ export function PlatformEntryFlowShellImpl({
const profileId =
work.profileId ?? work.worldKey.replace(/^puzzle:/u, '');
if (profileId) {
void openPuzzleDetail(profileId, { tab: 'profile' });
void openPuzzlePublicWorkDetail(profileId, { tab: 'profile' });
}
return;
}
@@ -2018,25 +2337,29 @@ export function PlatformEntryFlowShellImpl({
(entry) => entry.sourceSessionId === sessionId,
);
if (matchedEntry) {
startBigFishRunFromWork(matchedEntry);
openPublicWorkDetail(
mapBigFishWorkToPublicWorkDetail(matchedEntry),
);
return;
}
startBigFishRunFromWork({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
});
openPublicWorkDetail(
mapBigFishWorkToPublicWorkDetail({
workId: `big-fish:${sessionId}`,
sourceSessionId: sessionId,
ownerUserId: work.ownerUserId ?? '',
title: work.worldTitle,
subtitle: work.worldSubtitle,
summary: work.worldSubtitle,
coverImageSrc: null,
status: 'published',
updatedAt: work.lastPlayedAt,
publishReady: true,
levelCount: 0,
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: false,
}),
);
})
.catch((error) => {
setBigFishError(
@@ -2052,33 +2375,33 @@ export function PlatformEntryFlowShellImpl({
return;
}
runProtectedAction(() => {
void detailNavigation.openGalleryDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
});
void openRpgPublicWorkDetail({
ownerUserId,
profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: work.firstPlayedAt,
updatedAt: work.lastPlayedAt,
authorDisplayName: work.worldSubtitle,
worldName: work.worldTitle,
subtitle: work.worldSubtitle,
summaryText: '',
coverImageSrc: null,
themeMode: 'martial',
playableNpcCount: 0,
landmarkCount: 0,
playCount: 0,
remixCount: 0,
likeCount: 0,
});
},
[
detailNavigation,
openPuzzleDetail,
openPuzzlePublicWorkDetail,
openPublicWorkDetail,
openRpgPublicWorkDetail,
refreshBigFishGallery,
resolveBigFishErrorMessage,
runProtectedAction,
startBigFishRunFromWork,
],
);
@@ -2234,7 +2557,7 @@ export function PlatformEntryFlowShellImpl({
isBigFishCreationVisible
? (item) => {
runProtectedAction(() => {
void startBigFishRunFromWork(item);
void startBigFishRunFromWork(item, 'platform');
});
}
: null
@@ -2254,7 +2577,7 @@ export function PlatformEntryFlowShellImpl({
}}
onExperiencePuzzle={(profileId) => {
runProtectedAction(() => {
void startPuzzleRunFromProfile(profileId);
void startPuzzleRunFromProfile(profileId, 'platform');
});
}}
onDeletePuzzle={(item) => {
@@ -2310,42 +2633,18 @@ export function PlatformEntryFlowShellImpl({
onOpenCreateTypePicker={openCreationTypePicker}
onOpenGalleryDetail={(entry) => {
if (isBigFishGalleryEntry(entry)) {
runProtectedAction(() => {
void startBigFishRunFromWork({
workId: entry.workId,
sourceSessionId: entry.profileId,
ownerUserId: entry.ownerUserId,
title: entry.worldName,
subtitle: entry.subtitle,
summary: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
status: 'published',
updatedAt: entry.updatedAt,
publishReady: true,
levelCount: Number.parseInt(
entry.themeTags
.find((tag) => /^\d+$/u.test(tag))
?.replace('级', '') ?? '0',
10,
),
levelMainImageReadyCount: 0,
levelMotionReadyCount: 0,
backgroundReady: Boolean(entry.coverImageSrc),
});
});
openPublicWorkDetail(entry);
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzleDetail(entry.profileId, {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
runProtectedAction(() => {
void detailNavigation.openGalleryDetail(entry);
});
void openRpgPublicWorkDetail(entry);
}}
onOpenLibraryDetail={(entry) => {
runProtectedAction(() => {
@@ -2382,6 +2681,28 @@ export function PlatformEntryFlowShellImpl({
</motion.div>
)}
{selectionStage === 'work-detail' && selectedPublicWorkDetail && (
<motion.div
key="platform-work-detail"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
className="flex h-full min-h-0 flex-col"
>
<PlatformWorkDetailView
entry={selectedPublicWorkDetail}
isBusy={isPublicWorkDetailBusy || isPuzzleBusy || isBigFishBusy}
error={publicWorkDetailError}
onBack={() => {
setPublicWorkDetailError(null);
setSelectionStage('platform');
}}
onStart={startSelectedPublicWork}
onRemix={remixSelectedPublicWork}
/>
</motion.div>
)}
{selectionStage === 'detail' && (
<motion.div
key="platform-detail"
@@ -2396,6 +2717,22 @@ export function PlatformEntryFlowShellImpl({
{detailNavigation.detailError || '正在读取作品详情...'}
</div>
</div>
) : selectedDetailEntry.visibility !== 'draft' ? (
<PlatformWorkDetailView
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
isBusy={detailNavigation.isMutatingDetail}
error={detailNavigation.detailError}
onBack={() => {
detailNavigation.setDetailError(null);
entryNavigation.backToPlatformHome();
}}
onStart={handleStartSelectedWorld}
onRemix={() => {
remixPublicWork(
mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry),
);
}}
/>
) : (
<PlatformEntryWorldDetailView
entry={selectedDetailEntry}
@@ -2428,7 +2765,7 @@ export function PlatformEntryFlowShellImpl({
: null
}
onUnpublish={
selectedDetailEntry.visibility === 'published' &&
selectedDetailEntry.visibility !== 'draft' &&
detailNavigation.isSelectedWorldOwned
? () => {
runProtectedAction(() => {
@@ -2626,11 +2963,7 @@ export function PlatformEntryFlowShellImpl({
error={bigFishError}
onBack={() => {
reportBigFishObservedPlayTime();
setSelectionStage(
bigFishRuntimeSessionSource === 'draft'
? 'big-fish-result'
: 'platform',
);
setSelectionStage(bigFishRuntimeReturnStage);
}}
onRestart={() => {
reportBigFishObservedPlayTime();
@@ -2783,6 +3116,7 @@ export function PlatformEntryFlowShellImpl({
onStartGame={() => {
void startPuzzleRunFromProfile(
selectedPuzzleDetail.profileId,
'puzzle-gallery-detail',
);
}}
/>

View File

@@ -0,0 +1,242 @@
import { ArrowLeft, Copy, GitFork, Play, Share2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import { copyTextToClipboard } from '../../services/clipboard';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPlatformWorldTags,
formatPlatformWorldTime,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldStats,
type PlatformPublicGalleryCard,
} from '../rpg-entry/rpgEntryWorldPresentation';
export interface PlatformWorkDetailViewProps {
entry: PlatformPublicGalleryCard;
isBusy: boolean;
error: string | null;
onBack: () => void;
onStart: () => void;
onRemix: () => void;
}
function formatCompactCount(value: number) {
if (value >= 10000) {
const normalized = value / 10000;
return `${Number.isInteger(normalized) ? normalized.toFixed(0) : normalized.toFixed(1)}`;
}
return `${value}`;
}
function getSourceLabel(entry: PlatformPublicGalleryCard) {
if ('sourceType' in entry && entry.sourceType === 'puzzle') {
return '拼图';
}
if ('sourceType' in entry && entry.sourceType === 'big-fish') {
return '大鱼吃小鱼';
}
return 'RPG';
}
export function PlatformWorkDetailView({
entry,
isBusy,
error,
onBack,
onStart,
onRemix,
}: PlatformWorkDetailViewProps) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const [shareState, setShareState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const tags = useMemo(
() =>
[
getSourceLabel(entry),
...buildPlatformWorldTags(entry).map((tag) => tag.trim()),
]
.filter(Boolean)
.slice(0, 4),
[entry],
);
const stats = resolvePlatformWorldStats(entry);
const statItems = [
{ label: '改造次数', value: formatCompactCount(stats.remixCount) },
{ label: '游玩次数', value: formatCompactCount(stats.playCount) },
{ label: '点赞次数', value: formatCompactCount(stats.likeCount) },
{
label: '上线日期',
value: formatPlatformWorldTime(stats.publishedAt),
},
];
const copyPublicWorkCode = () => {
if (!publicWorkCode) {
return;
}
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
const sharePublicWork = () => {
if (!publicWorkCode) {
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setShareState(copied ? 'copied' : 'failed');
window.setTimeout(() => setShareState('idle'), 1400);
});
};
return (
<div className="platform-work-detail">
<div className="platform-work-detail__topbar">
<button
type="button"
className="platform-work-detail__icon-button"
onClick={onBack}
aria-label="返回"
title="返回"
>
<ArrowLeft className="h-6 w-6" />
</button>
<div className="platform-work-detail__title"></div>
<button
type="button"
className="platform-work-detail__icon-button"
onClick={sharePublicWork}
disabled={!publicWorkCode}
aria-label="分享"
title="分享"
>
<Share2 className="h-5 w-5" />
</button>
</div>
<div className="platform-work-detail__scroll">
<section className="platform-work-detail__cover">
{coverImage ? (
<>
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="platform-work-detail__cover-blur"
/>
<ResolvedAssetImage
src={coverImage}
alt={entry.worldName}
className="platform-work-detail__cover-image"
/>
</>
) : (
<div className="platform-work-detail__cover-fallback" />
)}
</section>
<section className="platform-work-detail__summary">
<div className="platform-work-detail__meta-row">
<div className="platform-work-detail__app-icon">
{coverImage ? (
<ResolvedAssetImage
src={coverImage}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
entry.worldName.slice(0, 1)
)}
</div>
<div className="min-w-0 flex-1">
<div className="platform-work-detail__name">
{entry.worldName}
</div>
<div className="platform-work-detail__author">
{entry.authorDisplayName}
</div>
</div>
<button
type="button"
className="platform-work-detail__remix"
onClick={onRemix}
disabled={isBusy}
>
<GitFork className="h-5 w-5" />
Remix
</button>
</div>
<div className="platform-work-detail__stats">
{statItems.map((item) => (
<div key={item.label} className="platform-work-detail__stat">
<div className="platform-work-detail__stat-label">
{item.label}
</div>
<div className="platform-work-detail__stat-value">
{item.value}
</div>
</div>
))}
</div>
</section>
<section className="platform-work-detail__body">
<div className="platform-work-detail__chips">
{tags.map((tag) => (
<span key={tag} className="platform-work-detail__chip">
{tag}
</span>
))}
</div>
<p className="platform-work-detail__copy">{entry.summaryText}</p>
{publicWorkCode ? (
<button
type="button"
className="platform-work-detail__code"
onClick={copyPublicWorkCode}
>
<Copy className="h-4 w-4" />
<span>{publicWorkCode}</span>
{copyState !== 'idle' ? (
<span>{copyState === 'copied' ? '已复制' : '复制失败'}</span>
) : null}
</button>
) : null}
{shareState !== 'idle' ? (
<div className="platform-work-detail__toast">
{shareState === 'copied' ? '分享内容已复制' : '分享失败'}
</div>
) : null}
{error ? (
<div className="platform-work-detail__error">{error}</div>
) : null}
</section>
</div>
<div className="platform-work-detail__bottom">
<button
type="button"
className="platform-work-detail__start"
onClick={onStart}
disabled={isBusy}
>
<Play className="h-5 w-5 fill-current" />
</button>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ export type CustomWorldRuntimeLaunchOptions = {
export type SelectionStage =
| 'platform'
| 'work-detail'
| 'detail'
| 'agent-workspace'
| 'big-fish-agent-workspace'

View File

@@ -28,6 +28,7 @@ const detailItem = {
updatedAt: '2026-04-25T10:00:00.000Z',
publishedAt: '2026-04-25T10:00:00.000Z',
playCount: 7,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;

View File

@@ -708,6 +708,7 @@ beforeEach(() => {
themeMode: 'tide',
playableNpcCount: 1,
landmarkCount: 1,
likeCount: 0,
},
entries: [],
});
@@ -1449,6 +1450,7 @@ test('clicking a public work while logged out routes through requireAuth', async
authorDisplayName: '潮汐作者',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -1543,6 +1545,7 @@ test('creation hub clears all private work shelves immediately after logout stat
updatedAt: '2026-04-25T10:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1583,6 +1586,7 @@ test('published puzzle works appear on home and category public shelves', async
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1666,6 +1670,7 @@ test('published puzzle detail returns to the source platform tab', async () => {
updatedAt: '2026-04-25T09:00:00.000Z',
publishedAt: '2026-04-25T09:00:00.000Z',
playCount: 3,
likeCount: 0,
publishReady: true,
} satisfies PuzzleWorkSummary;
@@ -1921,6 +1926,7 @@ test('puzzle draft card restores the bound agent session and opens the result vi
updatedAt: '2026-04-22T12:10:00.000Z',
publishedAt: null,
playCount: 0,
likeCount: 0,
publishReady: false,
},
],
@@ -1967,6 +1973,7 @@ test('published puzzle work card restores its source session for editing', async
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
},
],
@@ -2007,6 +2014,7 @@ test('public code search opens a published puzzle by PZ code', async () => {
updatedAt: '2026-04-25T12:10:00.000Z',
publishedAt: '2026-04-25T12:10:00.000Z',
playCount: 8,
likeCount: 0,
publishReady: true,
};
@@ -3185,6 +3193,7 @@ test('creation hub published work can open detail view before deleting from deta
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)
@@ -3272,6 +3281,7 @@ test('creation hub published work enters existing detail view', async () => {
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3345,6 +3355,7 @@ test('creation hub published work experience button enters world directly', asyn
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
likeCount: 0,
},
]);
@@ -3421,6 +3432,7 @@ test('creation hub published work delete button removes the work directly from c
themeMode: 'tide' as const,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
};
vi.mocked(listRpgCreationWorks)

View File

@@ -132,6 +132,7 @@ const puzzlePublicEntry = {
summaryText: '一张用于公开分享的拼图作品。',
coverImageSrc: null,
themeTags: ['奇幻'],
likeCount: 12,
visibility: 'published',
publishedAt: '1777110165.990127Z',
updatedAt: '2026-04-25T10:00:00.000Z',
@@ -404,7 +405,7 @@ test('public gallery cards hide work code until detail is opened', async () => {
screen.queryByRole('button', { name: '复制作品号 PZ-EPUBLIC1' }),
).toBeNull();
await user.click(screen.getByRole('button', { name: //u }));
await user.click(screen.getByRole('button', { name: //u }));
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});

View File

@@ -8,6 +8,7 @@ import {
Clock3,
Coins,
Copy,
Heart,
House,
LogIn,
MessageCircle,
@@ -129,6 +130,18 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
'profile',
];
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category' | 'pc' | 'instant';
const MOBILE_HOME_CHANNELS: Array<{
id: MobileHomeChannel;
label: string;
}> = [
{ id: 'recommend', label: '推荐' },
{ id: 'today', label: '今日游戏' },
{ id: 'category', label: '游戏分类' },
{ id: 'pc', label: 'PC游戏' },
{ id: 'instant', label: '即点即玩' },
];
function usePlatformDesktopLayout() {
const [isDesktopLayout, setIsDesktopLayout] = useState(() => {
@@ -303,7 +316,6 @@ function WorldCard({
className?: string;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const leadPortrait = resolvePlatformWorldLeadPortrait(entry);
const tags = [
...new Set(
buildPlatformWorldTags(entry)
@@ -311,66 +323,79 @@ function WorldCard({
.filter(Boolean),
),
].slice(0, 3);
const likeCount = getPlatformWorldLikeCount(entry);
const cardLabel = `${entry.worldName}${formatCompactCount(likeCount)}点赞`;
return (
<button
type="button"
onClick={onClick}
className={`platform-surface platform-interactive-card relative flex h-[15rem] w-[min(15.25rem,78vw)] shrink-0 flex-col overflow-hidden px-3.5 py-3.5 text-left ${className ?? ''}`}
aria-label={cardLabel}
className={`platform-public-work-card platform-surface platform-interactive-card relative flex w-[min(21rem,88vw)] shrink-0 flex-col overflow-hidden p-0 text-left ${className ?? ''}`}
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover opacity-40"
/>
) : null}
{leadPortrait ? (
<ResolvedAssetImage
src={leadPortrait}
alt=""
aria-hidden="true"
className="absolute bottom-2 right-2 h-24 w-24 object-contain opacity-25"
/>
) : null}
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full flex-col">
<div className="flex items-start justify-between gap-3">
<span className="platform-pill platform-pill--warm max-w-[8.5rem] truncate">
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.02),rgba(0,0,0,0.18))]" />
<div className="absolute left-3 top-3 flex min-w-0 max-w-[calc(100%-1.5rem)] flex-wrap gap-1.5">
<span className="platform-pill platform-pill--warm max-w-[9rem] truncate px-2.5">
{badge}
</span>
<span className="platform-pill platform-pill--neutral px-2.5">
<span className="platform-pill platform-pill--neutral max-w-[9rem] truncate px-2.5">
{metaLabel}
</span>
</div>
<div className="mt-auto">
<div className="line-clamp-1 text-xl font-black text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
{entry.subtitle ? (
<div className="mt-1 line-clamp-1 text-[11px] tracking-[0.16em] text-[color:color-mix(in_srgb,var(--platform-text-base)_85%,transparent)]">
{entry.subtitle}
</div>
<div className="platform-public-work-card__body flex min-h-[7.25rem] flex-col gap-2 px-3.5 py-3">
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="line-clamp-1 break-words text-base font-black leading-tight text-[var(--platform-text-strong)]">
{entry.worldName}
</div>
) : null}
<div className="mt-2 line-clamp-2 text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_90%,transparent)]">
{entry.summaryText || '等待补充世界摘要。'}
{entry.subtitle ? (
<div className="mt-0.5 line-clamp-1 break-words text-[11px] font-medium text-[var(--platform-text-soft)]">
{entry.subtitle}
</div>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral px-2.5"
>
{tag}
</span>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
<div className="platform-public-work-card__likes shrink-0 text-right">
<div className="flex items-center justify-end gap-1 text-xs font-black text-[var(--platform-warm-text)]">
<Heart className="h-3.5 w-3.5 fill-current" />
<span>{formatCompactCount(likeCount)}</span>
</div>
<div className="mt-0.5 text-[10px] font-semibold text-[var(--platform-text-soft)]">
</div>
</div>
</div>
<div className="line-clamp-2 break-words text-xs leading-5 text-[color:color-mix(in_srgb,var(--platform-text-base)_88%,transparent)]">
{entry.summaryText || entry.subtitle || '等待补充世界摘要。'}
</div>
<div className="mt-auto flex min-w-0 flex-wrap gap-1.5">
{tags.length > 0 ? (
tags.map((tag, index) => (
<span
key={`world-tag-${index}-${tag || 'empty'}`}
className="platform-pill platform-pill--neutral max-w-full px-2.5"
>
<span className="truncate">{tag}</span>
</span>
)}
</div>
))
) : (
<span className="platform-pill platform-pill--neutral px-2.5">
{describePublicGalleryCardKind(entry)}
</span>
)}
</div>
</div>
</button>
@@ -740,6 +765,21 @@ function describePublicGalleryCardKind(entry: PlatformPublicGalleryCard) {
: describePlatformThemeLabel(entry.themeMode);
}
function getPlatformWorldLikeCount(entry: PlatformWorldCardLike) {
return Math.max(0, Math.round(entry.likeCount ?? 0));
}
function formatCompactCount(value: number) {
const normalizedValue = Math.max(0, Math.round(value));
if (normalizedValue >= 100000000) {
return `${(normalizedValue / 100000000).toFixed(1)}亿`;
}
if (normalizedValue >= 10000) {
return `${(normalizedValue / 10000).toFixed(1)}`;
}
return `${normalizedValue}`;
}
function formatSnapshotTime(value: string | null | undefined) {
if (!value) {
return '刚刚保存';
@@ -1435,6 +1475,8 @@ export function RpgEntryHomeView({
const [selectedCategoryTag, setSelectedCategoryTag] = useState<string | null>(
null,
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
() => new Set([activeTab]),
);
@@ -1644,6 +1686,19 @@ export function RpgEntryHomeView({
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
const sourceEntries =
mobileHomeChannel === 'recommend'
? [...featuredShelf, ...latestEntries]
: latestEntries;
sourceEntries.forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
const categoryPageClass = isDesktopLayout
? DESKTOP_PAGE_STAGE_CLASS
: MOBILE_PAGE_STAGE_CLASS;
@@ -1666,39 +1721,21 @@ export function RpgEntryHomeView({
isSearching={isSearchingPublicCode}
/>
<button
type="button"
onClick={openLeadPublicEntry}
className={`${HERO_SURFACE_CLASS} relative block w-full overflow-hidden px-4 py-4 text-left`}
>
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 flex min-h-[10rem] flex-col justify-between">
<div className="flex min-w-0 flex-wrap items-start justify-between gap-2">
<span className="platform-pill platform-pill--warm shrink-0">
</span>
<div className="platform-mobile-hero-secondary platform-pill platform-pill--neutral max-w-full px-3 text-[11px] tracking-[0.08em]">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
</div>
</div>
<div className="min-w-0">
<div className="break-all text-[clamp(1.6rem,7.4vw,1.92rem)] font-black leading-tight text-white">
{leadPublicEntry?.worldName ?? '浏览玩家作品'}
</div>
<div className="mt-2 max-w-[28rem] break-all text-sm leading-6 text-zinc-200/88">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
</div>
<div className="mt-4 flex min-w-0 items-center gap-2 text-sm font-semibold text-white/90">
<span className="min-w-0 break-all"></span>
<ArrowRight className="h-4 w-4" />
</div>
</div>
</div>
</button>
<div className="platform-mobile-home-channelbar flex min-w-0 gap-4 overflow-x-auto pb-1 scrollbar-hide">
{MOBILE_HOME_CHANNELS.map((channel) => {
const active = mobileHomeChannel === channel.id;
return (
<button
key={channel.id}
type="button"
onClick={() => setMobileHomeChannel(channel.id)}
className={`platform-mobile-home-channel shrink-0 ${active ? 'platform-mobile-home-channel--active' : ''}`}
>
{channel.label}
</button>
);
})}
</div>
{platformError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-700">
@@ -1706,45 +1743,28 @@ export function RpgEntryHomeView({
</div>
) : null}
<section>
<SectionHeader title="精选推荐" detail="为你挑选" />
<section className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
) : featuredShelf.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{featuredShelf.map((entry: PlatformPublicGalleryCard) => (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:featured`}
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
)}
</section>
<section>
<SectionHeader title="最新发布" detail="玩家广场" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : latestEntries.length > 0 ? (
<div className="flex min-w-0 gap-3 overflow-x-auto pb-1 scrollbar-hide">
{latestEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:latest`}
entry={entry}
badge={describePublicGalleryCardKind(entry)}
badge={
mobileHomeChannel === 'recommend'
? '推荐'
: describePublicGalleryCardKind(entry)
}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有作品。" />
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
)}
</section>
</div>
@@ -1783,7 +1803,7 @@ export function RpgEntryHomeView({
badge={activeCategoryGroup.tag}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[15rem] w-full min-w-0 sm:h-[16rem]"
className="w-full min-w-0"
/>
))}
</div>
@@ -2226,7 +2246,7 @@ export function RpgEntryHomeView({
badge="推荐"
metaLabel={describePublicGalleryCardKind(entry)}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[16rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>
@@ -2304,6 +2324,7 @@ export function RpgEntryHomeView({
authorDisplayName: entry.authorDisplayName,
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
})
}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
@@ -2344,7 +2365,7 @@ export function RpgEntryHomeView({
badge={describePublicGalleryCardKind(entry)}
metaLabel={entry.authorDisplayName}
onClick={() => onOpenGalleryDetail(entry)}
className="h-[17rem] w-full min-w-0"
className="w-full min-w-0"
/>
))}
</div>

View File

@@ -1,9 +1,9 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
CustomWorldLibraryEntry,
} from '../../../packages/shared/src/contracts/runtime';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
import { resolveCustomWorldCampSceneImage } from '../../data/customWorldVisuals';
import {
@@ -30,6 +30,9 @@ export type PlatformPuzzleGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -47,6 +50,9 @@ export type PlatformBigFishGalleryCard = {
summaryText: string;
coverImageSrc: string | null;
themeTags: string[];
playCount?: number;
remixCount?: number;
likeCount?: number;
visibility: 'published';
publishedAt: string | null;
updatedAt: string;
@@ -90,6 +96,9 @@ export function mapPuzzleWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.publishedAt,
updatedAt: work.updatedAt,
@@ -111,12 +120,24 @@ export function mapBigFishWorkToPlatformGalleryCard(
summaryText: work.summary,
coverImageSrc: work.coverImageSrc,
themeTags: ['大鱼', `${work.levelCount}`],
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
likeCount: work.likeCount ?? 0,
visibility: 'published',
publishedAt: work.updatedAt,
publishedAt: work.publishedAt ?? work.updatedAt,
updatedAt: work.updatedAt,
};
}
export function resolvePlatformWorldStats(entry: PlatformWorldCardLike) {
return {
playCount: 'playCount' in entry ? (entry.playCount ?? 0) : 0,
remixCount: 'remixCount' in entry ? (entry.remixCount ?? 0) : 0,
likeCount: 'likeCount' in entry ? (entry.likeCount ?? 0) : 0,
publishedAt: entry.publishedAt ?? null,
};
}
export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
if (entry.coverImageSrc) {
return entry.coverImageSrc;

View File

@@ -280,6 +280,7 @@ describe('RPG Agent 草稿恢复', () => {
themeMode: 'tide',
playableNpcCount: 0,
landmarkCount: 0,
likeCount: 0,
},
entries: [],
});

View File

@@ -161,6 +161,27 @@ export function useRpgEntryLibraryDetail(
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
);
const loadGalleryDetailEntry = useCallback(
async (entry: CustomWorldGalleryCard) => {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
return detailEntry;
},
[appendBrowseHistoryEntry],
);
const openGalleryDetail = useCallback(
async (entry: CustomWorldGalleryCard) => {
setSelectionStage('detail');
@@ -168,26 +189,13 @@ export function useRpgEntryLibraryDetail(
setDetailError(null);
try {
const detailEntry = await getRpgEntryWorldGalleryDetail(
entry.ownerUserId,
entry.profileId,
);
const detailEntry = await loadGalleryDetailEntry(entry);
setSelectedDetailEntry(detailEntry);
if (detailEntry.publicWorkCode?.trim()) {
pushAppHistoryPath(
buildPublicWorkDetailPath(detailEntry.publicWorkCode),
);
}
void appendBrowseHistoryEntry({
ownerUserId: detailEntry.ownerUserId,
profileId: detailEntry.profileId,
worldName: detailEntry.worldName,
subtitle: detailEntry.subtitle,
summaryText: detailEntry.summaryText,
coverImageSrc: detailEntry.coverImageSrc,
themeMode: detailEntry.themeMode,
authorDisplayName: detailEntry.authorDisplayName,
});
} catch (error) {
setSelectedDetailEntry(null);
setDetailError(
@@ -197,7 +205,11 @@ export function useRpgEntryLibraryDetail(
setIsDetailLoading(false);
}
},
[appendBrowseHistoryEntry, setSelectedDetailEntry, setSelectionStage],
[
loadGalleryDetailEntry,
setSelectedDetailEntry,
setSelectionStage,
],
);
const openSavedCustomWorldEditor = useCallback(
@@ -489,6 +501,7 @@ export function useRpgEntryLibraryDetail(
isSelectedWorldOwned,
openLibraryDetail,
openGalleryDetail,
loadGalleryDetailEntry,
openSavedCustomWorldEditor,
handleOpenCreationWork,
handlePublishSelectedWorld,