1
This commit is contained in:
@@ -31,6 +31,7 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type {
|
||||
PuzzleRunSnapshot,
|
||||
PuzzleRuntimePropKind,
|
||||
SubmitPuzzleLeaderboardRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
@@ -39,6 +40,7 @@ import type {
|
||||
CustomWorldLibraryEntry,
|
||||
ProfilePlayedWorkSummary,
|
||||
ProfilePlayStatsResponse,
|
||||
ProfileSaveArchiveSummary,
|
||||
} from '../../../packages/shared/src/contracts/runtime';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
@@ -111,8 +113,10 @@ import {
|
||||
import {
|
||||
applyLocalPuzzleFreezeTime,
|
||||
dragLocalPuzzlePiece,
|
||||
extendLocalPuzzleTime,
|
||||
isLocalPuzzleRun,
|
||||
refreshLocalPuzzleTimer,
|
||||
restartLocalPuzzleLevel,
|
||||
setLocalPuzzlePaused,
|
||||
startLocalPuzzleRun,
|
||||
submitLocalPuzzleLeaderboard,
|
||||
@@ -128,7 +132,10 @@ import {
|
||||
recordRpgEntryWorldGalleryPlay,
|
||||
remixRpgEntryWorldGallery,
|
||||
} from '../../services/rpg-entry/rpgEntryLibraryClient';
|
||||
import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClient';
|
||||
import {
|
||||
getRpgProfilePlayStats,
|
||||
resumeRpgProfileSaveArchive,
|
||||
} from '../../services/rpg-entry/rpgProfileClient';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import {
|
||||
@@ -178,6 +185,13 @@ type PuzzleRuntimeReturnStage =
|
||||
|
||||
type BigFishRuntimeReturnStage = 'big-fish-result' | 'work-detail' | 'platform';
|
||||
|
||||
type PuzzleSaveArchiveState = {
|
||||
runtimeKind?: unknown;
|
||||
entryProfileId?: unknown;
|
||||
currentProfileId?: unknown;
|
||||
currentLevelId?: unknown;
|
||||
};
|
||||
|
||||
type AgentResultBlockerView = {
|
||||
code?: string;
|
||||
message: string;
|
||||
@@ -210,7 +224,10 @@ function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return getPlatformPublicGalleryEntryKey(left) === getPlatformPublicGalleryEntryKey(right);
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
function mergePlatformPublicGalleryEntries(
|
||||
@@ -272,6 +289,17 @@ function mapPublicWorkDetailToPuzzleWork(
|
||||
remixCount: entry.remixCount ?? 0,
|
||||
likeCount: entry.likeCount ?? 0,
|
||||
publishReady: true,
|
||||
levels:
|
||||
entry.coverSlides?.map((slide, index) => ({
|
||||
levelId: slide.id || `puzzle-level-${index + 1}`,
|
||||
levelName: slide.label,
|
||||
pictureDescription: entry.summaryText,
|
||||
candidates: [],
|
||||
selectedCandidateId: null,
|
||||
coverImageSrc: slide.imageSrc,
|
||||
coverAssetId: null,
|
||||
generationStatus: 'ready' as const,
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -322,7 +350,9 @@ function mergeBigFishWorkSummary(
|
||||
current: BigFishWorkSummary,
|
||||
updated: BigFishWorkSummary,
|
||||
): BigFishWorkSummary {
|
||||
return current.sourceSessionId === updated.sourceSessionId ? updated : current;
|
||||
return current.sourceSessionId === updated.sourceSessionId
|
||||
? updated
|
||||
: current;
|
||||
}
|
||||
|
||||
async function resolvePublicWorkAuthorSummary(
|
||||
@@ -562,6 +592,22 @@ function isPuzzleFormOnlyDraft(session: PuzzleAgentSessionSnapshot | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyPuzzleFormOnlyDraft(
|
||||
session: PuzzleAgentSessionSnapshot | null,
|
||||
) {
|
||||
if (!isPuzzleFormOnlyDraft(session)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const formDraft = session?.draft?.formDraft;
|
||||
return !(
|
||||
session?.seedText?.trim() ||
|
||||
formDraft?.workTitle?.trim() ||
|
||||
formDraft?.workDescription?.trim() ||
|
||||
formDraft?.pictureDescription?.trim()
|
||||
);
|
||||
}
|
||||
|
||||
const CustomWorldGenerationView = lazy(async () => {
|
||||
const module = await import('../CustomWorldGenerationView');
|
||||
return {
|
||||
@@ -665,11 +711,20 @@ function mergePuzzleServiceRuntimeState(
|
||||
? serviceLevel.leaderboardEntries
|
||||
: serviceRun.leaderboardEntries;
|
||||
|
||||
return {
|
||||
...currentRun,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
return {
|
||||
...currentRun,
|
||||
recommendedNextProfileId: serviceRun.recommendedNextProfileId,
|
||||
nextLevelMode: serviceRun.nextLevelMode,
|
||||
nextLevelProfileId: serviceRun.nextLevelProfileId,
|
||||
nextLevelId: serviceRun.nextLevelId,
|
||||
recommendedNextWorks: serviceRun.recommendedNextWorks,
|
||||
leaderboardEntries,
|
||||
currentLevel: {
|
||||
...currentRun.currentLevel,
|
||||
status: serviceLevel.status,
|
||||
startedAtMs: serviceLevel.startedAtMs,
|
||||
clearedAtMs: serviceLevel.clearedAtMs,
|
||||
elapsedMs: serviceLevel.elapsedMs,
|
||||
timeLimitMs: serviceLevel.timeLimitMs,
|
||||
remainingMs: serviceLevel.remainingMs,
|
||||
pausedAccumulatedMs: serviceLevel.pausedAccumulatedMs,
|
||||
@@ -1338,7 +1393,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
const createPuzzleDraftFromForm = useCallback(
|
||||
async (payload: CreatePuzzleAgentSessionRequest) => {
|
||||
setPuzzleFormDraftPayload(payload);
|
||||
const nextSession = puzzleFlow.session ?? (await puzzleFlow.openWorkspace(payload));
|
||||
const nextSession =
|
||||
puzzleFlow.session && !isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
|
||||
? puzzleFlow.session
|
||||
: await puzzleFlow.openWorkspace(payload);
|
||||
if (!nextSession) {
|
||||
return;
|
||||
}
|
||||
@@ -1504,6 +1562,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
|
||||
const executePuzzleAction = puzzleFlow.executeAction;
|
||||
|
||||
const retryPuzzleDraftGeneration = useCallback(() => {
|
||||
if (puzzleFormDraftPayload) {
|
||||
void createPuzzleDraftFromForm(puzzleFormDraftPayload);
|
||||
return;
|
||||
}
|
||||
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(puzzleFormDraftPayload),
|
||||
);
|
||||
}, [createPuzzleDraftFromForm, executePuzzleAction, puzzleFormDraftPayload]);
|
||||
|
||||
const executePuzzleWorkspaceAction = useCallback(
|
||||
(payload: PuzzleAgentActionRequest) => {
|
||||
if (
|
||||
payload.action === 'compile_puzzle_draft' &&
|
||||
isEmptyPuzzleFormOnlyDraft(puzzleFlow.session)
|
||||
) {
|
||||
const formPayload = buildPuzzleFormPayloadFromAction(payload);
|
||||
if (formPayload) {
|
||||
void createPuzzleDraftFromForm(formPayload);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void executePuzzleAction(payload);
|
||||
},
|
||||
[createPuzzleDraftFromForm, executePuzzleAction, puzzleFlow.session],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectionStage === 'big-fish-result' && !bigFishSession?.draft) {
|
||||
setSelectionStage(
|
||||
@@ -1655,6 +1742,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: Boolean(puzzleSession?.resultPreview?.publishReady),
|
||||
levels: draft.levels,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
},
|
||||
[
|
||||
@@ -1782,7 +1870,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
paused,
|
||||
});
|
||||
setPuzzleRun((currentRun) =>
|
||||
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
|
||||
currentRun
|
||||
? mergePuzzleServiceRuntimeState(currentRun, run)
|
||||
: currentRun,
|
||||
);
|
||||
void platformBootstrap.refreshProfileDashboard();
|
||||
} catch (error) {
|
||||
@@ -1812,7 +1902,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
try {
|
||||
const { run } = await getPuzzleRun(puzzleRun.runId);
|
||||
setPuzzleRun((currentRun) =>
|
||||
currentRun ? mergePuzzleServiceRuntimeState(currentRun, run) : currentRun,
|
||||
currentRun
|
||||
? mergePuzzleServiceRuntimeState(currentRun, run)
|
||||
: currentRun,
|
||||
);
|
||||
} catch (error) {
|
||||
setPuzzleError(
|
||||
@@ -1822,11 +1914,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
}, [puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]);
|
||||
|
||||
const usePuzzleProp = useCallback(
|
||||
async (propKind: 'hint' | 'reference' | 'freezeTime') => {
|
||||
if (
|
||||
!puzzleRun?.currentLevel ||
|
||||
puzzleRun.currentLevel.status !== 'playing'
|
||||
) {
|
||||
async (propKind: PuzzleRuntimePropKind) => {
|
||||
if (!puzzleRun?.currentLevel) {
|
||||
return null;
|
||||
}
|
||||
const expectedStatus =
|
||||
propKind === 'extendTime' ? 'failed' : 'playing';
|
||||
if (puzzleRun.currentLevel.status !== expectedStatus) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1836,7 +1930,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
return null;
|
||||
}
|
||||
const nextRun =
|
||||
propKind === 'freezeTime'
|
||||
propKind === 'extendTime'
|
||||
? extendLocalPuzzleTime(currentRun)
|
||||
: propKind === 'freezeTime'
|
||||
? applyLocalPuzzleFreezeTime(currentRun)
|
||||
: setLocalPuzzlePaused(currentRun, propKind === 'reference');
|
||||
puzzleRunRef.current = nextRun;
|
||||
@@ -1859,6 +1955,92 @@ export function PlatformEntryFlowShellImpl({
|
||||
[platformBootstrap, puzzleRun],
|
||||
);
|
||||
|
||||
const restartPuzzleCurrentLevel = useCallback(async () => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError(null);
|
||||
if (isLocalPuzzleRun(puzzleRun)) {
|
||||
const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun);
|
||||
puzzleRunRef.current = nextRun;
|
||||
setPuzzleRun(nextRun);
|
||||
return;
|
||||
}
|
||||
|
||||
await startPuzzleRunFromProfile(
|
||||
currentLevel.profileId,
|
||||
puzzleRuntimeReturnStage,
|
||||
selectedPuzzleDetail?.profileId === currentLevel.profileId
|
||||
? selectedPuzzleDetail
|
||||
: undefined,
|
||||
false,
|
||||
currentLevel.levelId ?? null,
|
||||
);
|
||||
}, [
|
||||
isPuzzleBusy,
|
||||
puzzleRun,
|
||||
puzzleRuntimeReturnStage,
|
||||
selectedPuzzleDetail,
|
||||
setPuzzleError,
|
||||
startPuzzleRunFromProfile,
|
||||
]);
|
||||
|
||||
const resumePuzzleSaveArchive = useCallback(
|
||||
async (entry: ProfileSaveArchiveSummary) => {
|
||||
if (isPuzzleBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsPuzzleBusy(true);
|
||||
setPuzzleError(null);
|
||||
platformBootstrap.setSaveError(null);
|
||||
|
||||
try {
|
||||
const resumedArchive = await resumeRpgProfileSaveArchive(
|
||||
entry.worldKey,
|
||||
);
|
||||
platformBootstrap.setSaveEntries((currentEntries) =>
|
||||
currentEntries.map((currentEntry) =>
|
||||
currentEntry.worldKey === resumedArchive.entry.worldKey
|
||||
? resumedArchive.entry
|
||||
: currentEntry,
|
||||
),
|
||||
);
|
||||
const gameState = resumedArchive.snapshot
|
||||
.gameState as PuzzleSaveArchiveState;
|
||||
const profileId =
|
||||
typeof gameState.currentProfileId === 'string' &&
|
||||
gameState.currentProfileId.trim()
|
||||
? gameState.currentProfileId
|
||||
: typeof gameState.entryProfileId === 'string' &&
|
||||
gameState.entryProfileId.trim()
|
||||
? gameState.entryProfileId
|
||||
: (entry.profileId ?? entry.worldKey.replace(/^puzzle:/u, ''));
|
||||
const levelId =
|
||||
typeof gameState.currentLevelId === 'string' &&
|
||||
gameState.currentLevelId.trim()
|
||||
? gameState.currentLevelId
|
||||
: null;
|
||||
await startPuzzleRunFromProfile(profileId, 'platform', undefined, false, levelId);
|
||||
} catch (error) {
|
||||
platformBootstrap.setSaveError(
|
||||
resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'),
|
||||
);
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
isPuzzleBusy,
|
||||
platformBootstrap,
|
||||
resolvePuzzleErrorMessage,
|
||||
setPuzzleError,
|
||||
startPuzzleRunFromProfile,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const currentLevel = puzzleRun?.currentLevel ?? null;
|
||||
if (!puzzleRun || !currentLevel || currentLevel.status !== 'cleared') {
|
||||
@@ -1916,7 +2098,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError,
|
||||
]);
|
||||
|
||||
const advancePuzzleLevel = useCallback(async () => {
|
||||
const advancePuzzleLevel = useCallback(async (target?: {
|
||||
profileId?: string;
|
||||
levelId?: string | null;
|
||||
}) => {
|
||||
if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) {
|
||||
return;
|
||||
}
|
||||
@@ -1931,6 +2116,21 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleError(null);
|
||||
|
||||
try {
|
||||
const targetProfileId = target?.profileId?.trim();
|
||||
if (
|
||||
targetProfileId &&
|
||||
targetProfileId !== currentLevel.profileId &&
|
||||
puzzleRun.nextLevelMode === 'similarWorks'
|
||||
) {
|
||||
await startPuzzleRunFromProfile(
|
||||
targetProfileId,
|
||||
'puzzle-gallery-detail',
|
||||
undefined,
|
||||
false,
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { run } = isLocalPuzzleRun(puzzleRun)
|
||||
? await advanceLocalPuzzleNextLevel({
|
||||
run: puzzleRun,
|
||||
@@ -1954,6 +2154,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
puzzleSession,
|
||||
resolvePuzzleErrorMessage,
|
||||
selectedPuzzleDetail,
|
||||
startPuzzleRunFromProfile,
|
||||
]);
|
||||
|
||||
const leaveAgentWorkspace = useCallback(() => {
|
||||
@@ -2262,24 +2463,27 @@ export function PlatformEntryFlowShellImpl({
|
||||
return;
|
||||
}
|
||||
setBigFishGalleryEntries((current) =>
|
||||
current.map((item) => mergeBigFishWorkSummary(item, updatedWork)),
|
||||
current.map((item) =>
|
||||
mergeBigFishWorkSummary(item, updatedWork),
|
||||
),
|
||||
);
|
||||
setBigFishWorks((current) =>
|
||||
current.map((item) => mergeBigFishWorkSummary(item, updatedWork)),
|
||||
current.map((item) =>
|
||||
mergeBigFishWorkSummary(item, updatedWork),
|
||||
),
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
mapBigFishWorkToPublicWorkDetail(updatedWork),
|
||||
);
|
||||
setBigFishRuntimeWork((current) =>
|
||||
current ? mergeBigFishWorkSummary(current, updatedWork) : current,
|
||||
current
|
||||
? mergeBigFishWorkSummary(current, updatedWork)
|
||||
: current,
|
||||
);
|
||||
})
|
||||
.catch((error) => {
|
||||
setPublicWorkDetailError(
|
||||
resolveBigFishErrorMessage(
|
||||
error,
|
||||
'点赞大鱼吃小鱼作品失败。',
|
||||
),
|
||||
resolveBigFishErrorMessage(error, '点赞大鱼吃小鱼作品失败。'),
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -2293,13 +2497,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
.then((response) => {
|
||||
const updatedWork = response.item;
|
||||
setPuzzleGalleryEntries((current) =>
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
current.map((item) =>
|
||||
mergePuzzleWorkSummary(item, updatedWork),
|
||||
),
|
||||
);
|
||||
setPuzzleWorks((current) =>
|
||||
current.map((item) => mergePuzzleWorkSummary(item, updatedWork)),
|
||||
current.map((item) =>
|
||||
mergePuzzleWorkSummary(item, updatedWork),
|
||||
),
|
||||
);
|
||||
setSelectedPuzzleDetail((current) =>
|
||||
current ? mergePuzzleWorkSummary(current, updatedWork) : current,
|
||||
current
|
||||
? mergePuzzleWorkSummary(current, updatedWork)
|
||||
: current,
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
mapPuzzleWorkToPublicWorkDetail(updatedWork),
|
||||
@@ -2319,13 +2529,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
void likeRpgEntryWorldGallery(entry.ownerUserId, entry.profileId)
|
||||
.then((updatedEntry) => {
|
||||
setSelectedDetailEntry((current) =>
|
||||
current?.profileId === updatedEntry.profileId ? updatedEntry : current,
|
||||
current?.profileId === updatedEntry.profileId
|
||||
? updatedEntry
|
||||
: current,
|
||||
);
|
||||
platformBootstrap.setPublishedGalleryEntries((current) =>
|
||||
current.map((item) =>
|
||||
item.profileId === updatedEntry.profileId
|
||||
? mapRpgGalleryCardToPublicWorkDetail(updatedEntry)
|
||||
: item,
|
||||
item.profileId === updatedEntry.profileId ? updatedEntry : item,
|
||||
),
|
||||
);
|
||||
syncUpdatedPublicWorkDetail(
|
||||
@@ -3186,6 +3396,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
createTabContent={creationHubContent}
|
||||
onContinueGame={handleContinueGame}
|
||||
onResumeSave={(entry) => {
|
||||
if (
|
||||
(entry.worldType ?? '').toLowerCase() === 'puzzle' ||
|
||||
entry.worldKey.startsWith('puzzle:')
|
||||
) {
|
||||
void resumePuzzleSaveArchive(entry);
|
||||
return;
|
||||
}
|
||||
void platformBootstrap.handleResumeSaveEntry(entry);
|
||||
}}
|
||||
onOpenCreateWorld={openCreationTypePicker}
|
||||
@@ -3286,7 +3503,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
<PlatformWorkDetailView
|
||||
entry={mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry)}
|
||||
authorAvatarUrl={selectedPublicWorkAuthor?.avatarUrl ?? null}
|
||||
authorDisplayName={selectedPublicWorkAuthor?.displayName ?? null}
|
||||
authorDisplayName={
|
||||
selectedPublicWorkAuthor?.displayName ?? null
|
||||
}
|
||||
isBusy={detailNavigation.isMutatingDetail}
|
||||
error={detailNavigation.detailError}
|
||||
onBack={() => {
|
||||
@@ -3568,7 +3787,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
void submitPuzzleMessage(payload);
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
executePuzzleWorkspaceAction(payload);
|
||||
}}
|
||||
initialFormPayload={puzzleFormDraftPayload}
|
||||
onCreateFromForm={(payload) => {
|
||||
@@ -3610,13 +3829,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
onEditSetting={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onRetry={() => {
|
||||
void executePuzzleAction(
|
||||
buildPuzzleCompileActionFromFormPayload(
|
||||
puzzleFormDraftPayload,
|
||||
),
|
||||
);
|
||||
}}
|
||||
onRetry={retryPuzzleDraftGeneration}
|
||||
onInterrupt={undefined}
|
||||
backLabel="返回创作中心"
|
||||
settingActionLabel={null}
|
||||
@@ -3635,35 +3848,35 @@ export function PlatformEntryFlowShellImpl({
|
||||
{selectionStage === 'puzzle-result' &&
|
||||
puzzleSession?.draft &&
|
||||
!isPuzzleFormOnlyDraft(puzzleSession) && (
|
||||
<motion.div
|
||||
key="puzzle-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
|
||||
<motion.div
|
||||
key="puzzle-result"
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -12 }}
|
||||
className="flex h-full min-h-0 flex-col"
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
profileId={
|
||||
puzzleSession.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession.sessionId)
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
onStartTestRun={startPuzzleTestRunFromDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
<Suspense
|
||||
fallback={<LazyPanelFallback label="正在加载拼图结果..." />}
|
||||
>
|
||||
<PuzzleResultView
|
||||
session={puzzleSession}
|
||||
profileId={
|
||||
puzzleSession.publishedProfileId ??
|
||||
buildPuzzleResultProfileId(puzzleSession.sessionId)
|
||||
}
|
||||
isBusy={isPuzzleBusy}
|
||||
error={puzzleError}
|
||||
onBack={() => {
|
||||
setSelectionStage('puzzle-agent-workspace');
|
||||
}}
|
||||
onExecuteAction={(payload) => {
|
||||
void executePuzzleAction(payload);
|
||||
}}
|
||||
onStartTestRun={startPuzzleTestRunFromDraft}
|
||||
/>
|
||||
</Suspense>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{selectionStage === 'puzzle-gallery-detail' && selectedPuzzleDetail && (
|
||||
<motion.div
|
||||
@@ -3737,8 +3950,11 @@ export function PlatformEntryFlowShellImpl({
|
||||
onDragPiece={(payload) => {
|
||||
void dragPuzzlePiece(payload);
|
||||
}}
|
||||
onAdvanceNextLevel={() => {
|
||||
void advancePuzzleLevel();
|
||||
onAdvanceNextLevel={(target) => {
|
||||
void advancePuzzleLevel(target);
|
||||
}}
|
||||
onRestartLevel={() => {
|
||||
void restartPuzzleCurrentLevel();
|
||||
}}
|
||||
onPauseChange={setPuzzleRuntimePaused}
|
||||
onUseProp={usePuzzleProp}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { afterEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PlatformPublicGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import type { PlatformPuzzleGalleryCard } from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
|
||||
function createPuzzleEntry(): PlatformPublicGalleryCard {
|
||||
vi.mock('../ResolvedAssetImage', () => ({
|
||||
ResolvedAssetImage: ({
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
'aria-hidden': ariaHidden,
|
||||
}: {
|
||||
src?: string | null;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
'aria-hidden'?: boolean | 'true' | 'false';
|
||||
}) => (
|
||||
<img
|
||||
src={src ?? ''}
|
||||
alt={alt ?? ''}
|
||||
className={className}
|
||||
aria-hidden={ariaHidden}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
function createPuzzleEntry(): PlatformPuzzleGalleryCard {
|
||||
return {
|
||||
sourceType: 'puzzle',
|
||||
workId: 'work-1',
|
||||
@@ -18,6 +40,7 @@ function createPuzzleEntry(): PlatformPublicGalleryCard {
|
||||
subtitle: '拼图关卡',
|
||||
summaryText: '适合公开游玩的拼图作品。',
|
||||
coverImageSrc: null,
|
||||
coverSlides: [],
|
||||
themeTags: ['拼图'],
|
||||
playCount: 12,
|
||||
remixCount: 3,
|
||||
@@ -29,6 +52,10 @@ function createPuzzleEntry(): PlatformPublicGalleryCard {
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView renders compact stats and date time', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
@@ -94,3 +121,51 @@ test('PlatformWorkDetailView calls like handler', () => {
|
||||
|
||||
expect(onLike).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={{
|
||||
...createPuzzleEntry(),
|
||||
coverImageSrc: '/fallback-cover.png',
|
||||
coverSlides: [
|
||||
{
|
||||
id: 'level-1',
|
||||
imageSrc: '/level-1.png',
|
||||
label: '第一关',
|
||||
},
|
||||
{
|
||||
id: 'level-2',
|
||||
imageSrc: '/level-2.png',
|
||||
label: '第二关',
|
||||
},
|
||||
],
|
||||
}}
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-1.png',
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '下一张关卡图' }));
|
||||
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-2.png',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(4200);
|
||||
});
|
||||
|
||||
expect(screen.getAllByAltText('关键词:逍遥游拼图')[0]?.getAttribute('src')).toBe(
|
||||
'/level-1.png',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock3,
|
||||
Copy,
|
||||
Gamepad2,
|
||||
@@ -8,7 +10,7 @@ import {
|
||||
Play,
|
||||
Share2,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
|
||||
import { copyTextToClipboard } from '../../services/clipboard';
|
||||
@@ -20,7 +22,7 @@ import {
|
||||
formatPlatformWorldTime,
|
||||
type PlatformPublicGalleryCard,
|
||||
resolvePlatformPublicWorkCode,
|
||||
resolvePlatformWorldCoverImage,
|
||||
resolvePlatformWorldCoverSlides,
|
||||
resolvePlatformWorldStats,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
@@ -58,6 +60,8 @@ function getAuthorAvatarLabel(authorDisplayName: string) {
|
||||
return Array.from(authorDisplayName.trim() || '作')[0] ?? '作';
|
||||
}
|
||||
|
||||
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
|
||||
|
||||
export function PlatformWorkDetailView({
|
||||
entry,
|
||||
authorAvatarUrl,
|
||||
@@ -69,7 +73,15 @@ export function PlatformWorkDetailView({
|
||||
onStart,
|
||||
onRemix,
|
||||
}: PlatformWorkDetailViewProps) {
|
||||
const coverImage = resolvePlatformWorldCoverImage(entry);
|
||||
const coverSlides = useMemo(
|
||||
() => resolvePlatformWorldCoverSlides(entry),
|
||||
[entry],
|
||||
);
|
||||
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
|
||||
const activeCoverSlide =
|
||||
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
|
||||
const coverImage = activeCoverSlide?.imageSrc ?? '';
|
||||
const hasCoverCarousel = coverSlides.length > 1;
|
||||
const publicWorkCode = resolvePlatformPublicWorkCode(entry);
|
||||
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
|
||||
const resolvedAuthorDisplayName =
|
||||
@@ -121,6 +133,46 @@ export function PlatformWorkDetailView({
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCoverIndex(0);
|
||||
}, [entry.profileId, coverSlides.length]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveCoverIndex((current) =>
|
||||
coverSlides.length > 0 ? Math.min(current, coverSlides.length - 1) : 0,
|
||||
);
|
||||
}, [coverSlides.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasCoverCarousel) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
||||
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timerId);
|
||||
};
|
||||
}, [coverSlides.length, hasCoverCarousel]);
|
||||
|
||||
const showPreviousCover = () => {
|
||||
if (!hasCoverCarousel) {
|
||||
return;
|
||||
}
|
||||
setActiveCoverIndex(
|
||||
(current) => (current - 1 + coverSlides.length) % coverSlides.length,
|
||||
);
|
||||
};
|
||||
|
||||
const showNextCover = () => {
|
||||
if (!hasCoverCarousel) {
|
||||
return;
|
||||
}
|
||||
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
|
||||
};
|
||||
|
||||
const copyPublicWorkCode = () => {
|
||||
if (!publicWorkCode) {
|
||||
return;
|
||||
@@ -184,6 +236,46 @@ export function PlatformWorkDetailView({
|
||||
alt={entry.worldName}
|
||||
className="platform-work-detail__cover-image"
|
||||
/>
|
||||
{hasCoverCarousel ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--prev"
|
||||
onClick={showPreviousCover}
|
||||
aria-label="上一张关卡图"
|
||||
title="上一张关卡图"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="platform-work-detail__cover-nav platform-work-detail__cover-nav--next"
|
||||
onClick={showNextCover}
|
||||
aria-label="下一张关卡图"
|
||||
title="下一张关卡图"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="platform-work-detail__cover-dots">
|
||||
{coverSlides.map((slide, index) => (
|
||||
<button
|
||||
key={slide.id}
|
||||
type="button"
|
||||
className={`platform-work-detail__cover-dot${
|
||||
index === activeCoverIndex
|
||||
? ' platform-work-detail__cover-dot--active'
|
||||
: ''
|
||||
}`}
|
||||
onClick={() => setActiveCoverIndex(index)}
|
||||
aria-label={`查看${slide.label || `第 ${index + 1} 关`}`}
|
||||
aria-current={
|
||||
index === activeCoverIndex ? 'true' : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="platform-work-detail__cover-fallback" />
|
||||
|
||||
@@ -19,7 +19,15 @@ export type PlatformCreationTypeCard = {
|
||||
* 平台层的入口、首屏卡带与初始化请求都应基于这份结果统一判断。
|
||||
*/
|
||||
export function getVisiblePlatformCreationTypes() {
|
||||
return PLATFORM_CREATION_TYPES.filter((item) => !item.hidden);
|
||||
const visibleCreationTypes = PLATFORM_CREATION_TYPES.filter(
|
||||
(item) => !item.hidden,
|
||||
);
|
||||
|
||||
// 中文注释:可创建模板优先露出,敬请期待模板后置;两组内部沿用配置顺序。
|
||||
return [
|
||||
...visibleCreationTypes.filter((item) => !item.locked),
|
||||
...visibleCreationTypes.filter((item) => item.locked),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user