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

@@ -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'