This commit is contained in:
2026-05-01 00:33:39 +08:00
parent 61969c5116
commit fe02603ba1
68 changed files with 4586 additions and 748 deletions

View File

@@ -105,9 +105,14 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const rpgButton = screen.getByRole('button', { name: //u });
const puzzleButton = screen.getByRole('button', { name: /.*/u });
expect(
puzzleButton.compareDocumentPosition(rpgButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect(screen.getByRole('button', { name: /.*/u })).toBeTruthy();
expect(puzzleButton).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
rerender(

View File

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

View File

@@ -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',
);
});

View File

@@ -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" />

View File

@@ -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),
];
}
/**

View File

@@ -110,7 +110,9 @@ export function PuzzleAgentWorkspace({
const [referenceImageError, setReferenceImageError] = useState<string | null>(
null,
);
const previousSessionIdRef = useRef<string | null>(session?.sessionId ?? null);
const previousSessionIdRef = useRef<string | null>(
session?.sessionId ?? null,
);
const appliedInitialFormKeyRef = useRef<string | null>(null);
useEffect(() => {
@@ -118,7 +120,8 @@ export function PuzzleAgentWorkspace({
if (
currentSessionId &&
previousSessionIdRef.current === null &&
appliedInitialFormKeyRef.current === JSON.stringify(initialFormPayload ?? null)
appliedInitialFormKeyRef.current ===
JSON.stringify(initialFormPayload ?? null)
) {
previousSessionIdRef.current = currentSessionId;
return;

View File

@@ -2,13 +2,22 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { PuzzleGalleryDetailView } from './PuzzleGalleryDetailView';
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
}: {
src?: string | null;
alt?: string;
className?: string;
}) => <img src={src ?? ''} alt={alt ?? ''} className={className} />,
}));
const originalClipboard = navigator.clipboard;
@@ -33,6 +42,7 @@ const detailItem = {
} satisfies PuzzleWorkSummary;
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
@@ -40,6 +50,72 @@ afterEach(() => {
});
});
test('cycles every level image on puzzle detail cover', async () => {
vi.useFakeTimers();
render(
<PuzzleGalleryDetailView
item={{
...detailItem,
coverImageSrc: '/fallback-cover.png',
levels: [
{
levelId: 'level-1',
levelName: '第一关',
pictureDescription: '第一关画面',
selectedCandidateId: 'candidate-1',
coverImageSrc: '/level-1-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/level-1.png',
assetId: 'asset-1',
prompt: '',
sourceType: 'generated',
selected: true,
},
],
},
{
levelId: 'level-2',
levelName: '第二关',
pictureDescription: '第二关画面',
selectedCandidateId: null,
coverImageSrc: '/level-2.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [],
},
],
}}
onBack={vi.fn()}
onStartGame={vi.fn()}
/>,
);
expect(screen.getByAltText('第一关').getAttribute('src')).toBe(
'/level-1.png',
);
act(() => {
screen.getByRole('button', { name: '下一张关卡图' }).click();
});
expect(screen.getByAltText('第二关').getAttribute('src')).toBe(
'/level-2.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(screen.getByAltText('第一关').getAttribute('src')).toBe(
'/level-1.png',
);
});
test('shows and copies puzzle public work code in detail view', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);

View File

@@ -1,5 +1,14 @@
import { ArrowLeft, Copy, Pencil, Play, Share2, UserRound } from 'lucide-react';
import { useState } from 'react';
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
Copy,
Pencil,
Play,
Share2,
UserRound,
} from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { buildPublicWorkStagePath } from '../../routing/appPageRoutes';
@@ -7,6 +16,7 @@ import { copyTextToClipboard } from '../../services/clipboard';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildPuzzleWorkCoverSlides,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
} from '../rpg-entry/rpgEntryWorldPresentation';
@@ -20,6 +30,8 @@ type PuzzleGalleryDetailViewProps = {
onStartGame: () => void;
};
const PUZZLE_DETAIL_COVER_CAROUSEL_INTERVAL_MS = 4200;
/**
* 拼图广场详情页。
* 展示最小信息并提供进入游戏动作,不扩展评论、收藏等非本轮需求。
@@ -41,6 +53,53 @@ export function PuzzleGalleryDetailView({
);
const displayName = formatPlatformWorkDisplayName(item.levelName);
const displayTags = formatPlatformWorkDisplayTags(item.themeTags);
const coverSlides = useMemo(() => buildPuzzleWorkCoverSlides(item), [item]);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const activeCoverSlide =
coverSlides[activeCoverIndex] ?? coverSlides[0] ?? null;
const coverImageSrc = activeCoverSlide?.imageSrc ?? '';
const hasCoverCarousel = coverSlides.length > 1;
useEffect(() => {
setActiveCoverIndex(0);
}, [item.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);
}, PUZZLE_DETAIL_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 = () => {
void copyTextToClipboard(publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
@@ -151,13 +210,55 @@ export function PuzzleGalleryDetailView({
<div className="grid min-h-0 flex-1 gap-3 overflow-hidden lg:grid-cols-[minmax(0,1.05fr)_minmax(18rem,0.95fr)]">
<section className="min-h-0 overflow-hidden rounded-[1.5rem] border border-[var(--platform-subpanel-border)] bg-[var(--platform-subpanel-fill)]">
<div className="aspect-square overflow-hidden">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
<div className="relative aspect-square overflow-hidden">
{coverImageSrc ? (
<>
<ResolvedAssetImage
src={coverImageSrc}
alt={activeCoverSlide?.label || item.levelName}
className="h-full w-full object-cover"
/>
{hasCoverCarousel ? (
<>
<button
type="button"
onClick={showPreviousCover}
className="absolute left-3 top-1/2 z-10 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/30 bg-slate-950/36 text-white backdrop-blur"
aria-label="上一张关卡图"
title="上一张关卡图"
>
<ChevronLeft className="h-5 w-5" />
</button>
<button
type="button"
onClick={showNextCover}
className="absolute right-3 top-1/2 z-10 inline-flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full border border-white/30 bg-slate-950/36 text-white backdrop-blur"
aria-label="下一张关卡图"
title="下一张关卡图"
>
<ChevronRight className="h-5 w-5" />
</button>
<div className="absolute inset-x-4 bottom-3 z-10 flex justify-center gap-1.5">
{coverSlides.map((slide, index) => (
<button
key={slide.id}
type="button"
onClick={() => setActiveCoverIndex(index)}
className={`h-1.5 rounded-full bg-white transition-all ${
index === activeCoverIndex
? 'w-5 opacity-95'
: 'w-1.5 opacity-48'
}`}
aria-label={`查看${slide.label || `${index + 1}`}`}
aria-current={
index === activeCoverIndex ? 'true' : undefined
}
/>
))}
</div>
</>
) : null}
</>
) : (
<div className="flex h-full items-center justify-center bg-[radial-gradient(circle_at_top_left,rgba(251,191,36,0.14),transparent_34%),linear-gradient(145deg,rgba(76,29,19,0.86),rgba(30,41,59,0.94))] text-sm text-white/66">

View File

@@ -239,9 +239,30 @@ describe('PuzzleResultView', () => {
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
levelId: 'puzzle-level-1',
levelName: '暖灯猫街',
pictureDescription: '一只猫在雨夜灯牌下回头。',
}),
]);
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
const levelNameInput = within(dialog).getByLabelText('关卡名称');
const formalImageTitle = within(dialog).getByText('画面图');
const pictureDescriptionInput = within(dialog).getByLabelText('画面描述');
expect(
levelNameInput.compareDocumentPosition(formalImageTitle) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
formalImageTitle.compareDocumentPosition(pictureDescriptionInput) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onStartTestRun).toHaveBeenCalledWith(
expect.objectContaining({
levelName: '暖灯猫街',
@@ -272,7 +293,10 @@ describe('PuzzleResultView', () => {
);
fireEvent.click(screen.getByRole('button', { name: //u }));
expect(screen.getByRole('dialog', { name: '关卡详情' })).toBeTruthy();
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByRole('button', { name: //u })).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(within(dialog).queryByRole('button', { name: //u })).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
@@ -285,7 +309,7 @@ describe('PuzzleResultView', () => {
expect.objectContaining({
levels: expect.arrayContaining([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({ levelName: '第2关' }),
expect.objectContaining({ levelName: '' }),
]),
}),
);
@@ -309,6 +333,45 @@ describe('PuzzleResultView', () => {
);
});
test('generates image for a newly added level with the current levels snapshot', () => {
vi.spyOn(Date, 'now').mockReturnValue(1_775_000_000_000);
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
candidateCount: 1,
levelsJson: expect.any(String),
});
const payload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(payload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({ levelId: 'puzzle-level-1' }),
expect.objectContaining({
levelId: 'puzzle-level-1775000000000-2',
levelName: '',
pictureDescription: '新关卡里有一座发光钟楼。',
}),
]);
});
test('publishes with work info and serialized levels', () => {
const onExecuteAction = vi.fn();
@@ -394,6 +457,7 @@ describe('PuzzleResultView', () => {
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
candidateCount: 1,
levelsJson: expect.any(String),
});
});
});

View File

@@ -16,7 +16,6 @@ import { createPortal } from 'react-dom';
import type { PuzzleAgentActionRequest } from '../../../packages/shared/src/contracts/puzzleAgentActions';
import type {
PuzzleDraftLevel,
PuzzleGeneratedImageCandidate,
PuzzleResultDraft,
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession';
@@ -84,7 +83,7 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || draft.workTitle || '第一关',
levelName: draft.levelName || '',
pictureDescription: draft.summary,
candidates: draft.candidates,
selectedCandidateId: draft.selectedCandidateId,
@@ -103,7 +102,7 @@ function normalizeDraftLevels(draft: PuzzleResultDraft) {
return sourceLevels.map((level, index) => ({
...level,
levelId: level.levelId?.trim() || `puzzle-level-${index + 1}`,
levelName: level.levelName?.trim() || `${index + 1}`,
levelName: level.levelName?.trim() || '',
pictureDescription: level.pictureDescription?.trim() || draft.summary,
candidates: level.candidates ?? [],
selectedCandidateId: level.selectedCandidateId ?? null,
@@ -148,7 +147,7 @@ function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraft
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
levelName: `${nextIndex}`,
levelName: '',
pictureDescription: '',
candidates: [],
selectedCandidateId: null,
@@ -585,6 +584,7 @@ function PuzzleLevelDetailDialog({
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false);
const formalImageSrc = resolveLevelFormalImageSrc(level);
const hasFormalImage = Boolean(formalImageSrc);
const handleReferenceImageChange = async (
event: ChangeEvent<HTMLInputElement>,
@@ -626,7 +626,7 @@ function PuzzleLevelDetailDialog({
role="dialog"
aria-modal="true"
aria-label="关卡详情"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-5xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
className="platform-modal-shell platform-remap-surface flex max-h-[min(94vh,50rem)] w-full max-w-2xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
@@ -644,167 +644,159 @@ function PuzzleLevelDetailDialog({
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_20rem]">
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="关卡名称"
/>
</section>
<div className="space-y-4">
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<input
value={level.levelName}
disabled={isBusy}
onChange={(event) =>
onLevelChange({ ...level, levelName: event.target.value })
}
className="mt-2 w-full rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base font-semibold text-[var(--platform-text-strong)] outline-none"
aria-label="关卡名称"
/>
</section>
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<label
className={`inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
</div>
<div className="space-y-4">
{hasFormalImage ? (
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
{formalImageSrc ? (
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-[var(--platform-text-soft)]">
</div>
)}
<div className="relative mt-3 aspect-square overflow-hidden rounded-[1.15rem] bg-[var(--platform-subpanel-fill)]">
<ResolvedAssetImage
src={formalImageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName || draft.workTitle || '拼图关卡'}
className="h-full w-full object-cover"
/>
<button
type="button"
disabled={isBusy}
onClick={() => setIsHistoryPickerOpen(true)}
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 items-center justify-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
aria-label="从历史拼图素材库选择"
title="从历史拼图素材库选择"
>
<Images className="h-4 w-4" />
</button>
</div>
</section>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
</button>
{onStartTestRun ? (
<button
type="button"
disabled={isBusy || !formalImageSrc}
onClick={() => onStartTestRun(level)}
className={`platform-button platform-button--secondary w-full ${isBusy || !formalImageSrc ? 'opacity-55' : ''}`}
<section className="platform-subpanel rounded-[1.35rem] p-4">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<div className="relative mt-3">
<textarea
value={level.pictureDescription}
disabled={isBusy}
rows={9}
onChange={(event) =>
onLevelChange({
...level,
pictureDescription: event.target.value,
})
}
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
<ImagePlus className="h-4 w-4" />
<span className="sr-only">
{referenceImageSrc ? '更换参考图' : '添加参考图'}
</span>
</button>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={isBusy}
onChange={(event) => {
void handleReferenceImageChange(event);
}}
className="hidden"
/>
</label>
</div>
{referenceImageSrc ? (
<div className="mt-3 flex items-center gap-3 rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/72 px-3 py-3">
<div className="h-14 w-14 overflow-hidden rounded-[0.9rem] bg-[var(--platform-subpanel-fill)]">
<img
src={referenceImageSrc}
alt="拼图参考图"
className="h-full w-full object-cover"
/>
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-[var(--platform-text-strong)]">
{referenceImageLabel || '已选择参考图'}
</div>
</div>
<button
type="button"
disabled={isBusy}
onClick={() => {
setReferenceImageSrc('');
setReferenceImageLabel('');
setReferenceImageError(null);
}}
className="platform-icon-button h-9 w-9"
aria-label="移除参考图"
title="移除参考图"
>
<X className="h-4 w-4" />
</button>
</div>
) : null}
</div>
{referenceImageError ? (
<div className="mt-2 text-xs leading-5 text-red-600">
{referenceImageError}
</div>
) : null}
</section>
</div>
</div>
<div className="shrink-0 space-y-3 border-t border-[var(--platform-subpanel-border)] bg-[var(--platform-page-fill)] px-5 py-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
{onStartTestRun && hasFormalImage ? (
<button
type="button"
disabled={isBusy}
onClick={() => onStartTestRun(level)}
className={`platform-button platform-button--secondary w-full ${isBusy ? 'opacity-55' : ''}`}
>
<span className="inline-flex items-center gap-2">
<Play className="h-4 w-4" />
</span>
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
>
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
<Sparkles className="h-4 w-4" />
{hasFormalImage ? '重新生成画面' : '生成画面'}
</button>
</div>
{isHistoryPickerOpen ? (
<PuzzleHistoryAssetPickerDialog
isBusy={isBusy}
@@ -968,6 +960,7 @@ function PuzzleLevelListTab({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
{editState.levels.map((level, index) => {
const imageSrc = resolveLevelFormalImageSrc(level);
const displayLevelName = level.levelName || `${index + 1}`;
return (
<div
key={level.levelId}
@@ -983,7 +976,7 @@ function PuzzleLevelListTab({
<ResolvedAssetImage
src={imageSrc}
refreshKey={`${imageRefreshKey}:${level.levelId}`}
alt={level.levelName}
alt={displayLevelName}
className="h-full w-full object-cover"
/>
) : (
@@ -996,18 +989,22 @@ function PuzzleLevelListTab({
<div className="text-[11px] font-bold tracking-[0.16em] text-[var(--platform-text-soft)]">
{index + 1}
</div>
<div className="truncate text-base font-black text-[var(--platform-text-strong)]">
{level.levelName || `${index + 1}`}
</div>
</div>
</button>
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-3 py-2">
<div className="flex items-end gap-2 px-4 pb-4">
<button
type="button"
onClick={() => onOpenLevel(level.levelId)}
className="min-w-0 flex-1 truncate text-left text-base font-black text-[var(--platform-text-strong)]"
>
{displayLevelName}
</button>
<button
type="button"
disabled={isBusy || editState.levels.length <= 1}
onClick={() => onDeleteLevel(level.levelId)}
className="platform-icon-button h-9 w-9"
aria-label={`删除关卡 ${level.levelName || index + 1}`}
className="platform-icon-button h-9 w-9 shrink-0"
aria-label={`删除关卡 ${displayLevelName}`}
title="删除关卡"
>
<Trash2 className="h-4 w-4" />
@@ -1393,6 +1390,7 @@ export function PuzzleResultView({
promptText,
referenceImageSrc,
candidateCount: 1,
levelsJson: JSON.stringify(editState.levels),
});
}}
onLevelChange={updateLevel}

View File

@@ -53,6 +53,16 @@ function renderPuzzleRuntime(
);
}
function dispatchPointerEvent(
target: HTMLElement,
type: string,
options: { pointerId: number; clientX: number; clientY: number },
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, options);
target.dispatchEvent(event);
}
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
@@ -159,12 +169,15 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
vi.useRealTimers();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => {
const runWithoutNext: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={clearedRun}
run={runWithoutNext}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
@@ -172,13 +185,125 @@ test('关闭通关弹窗后保留底部下一关入口', () => {
/>,
);
const avatar = screen.getByText('测');
const timer = screen.getByText('4:48');
const hintButton = screen.getByRole('button', { name: '提示' });
const referenceButton = screen.getByRole('button', { name: '原图' });
const freezeButton = screen.getByRole('button', { name: '冻结' });
expect(avatar.className).toContain('rounded-full');
expect(screen.getByText('测试作者')).toBeTruthy();
expect(timer.className).toContain('text-2xl');
expect(hintButton.className).toContain('h-16');
expect(referenceButton.className).toContain('h-16');
expect(freezeButton.className).toContain('h-16');
expect(screen.queryByText('等待下一关候选')).toBeNull();
});
test('关闭通关弹窗后保留底部下一关入口', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const runWithoutRecommendedNextProfile: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: null,
nextLevelMode: 'sameWork',
nextLevelProfileId: 'profile-1',
nextLevelId: 'puzzle-level-2',
recommendedNextWorks: [],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={runWithoutRecommendedNextProfile}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
const nextButton = screen.getByRole('button', { name: //u });
expect(nextButton).toBeTruthy();
fireEvent.click(nextButton);
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-1',
levelId: 'puzzle-level-2',
});
vi.useRealTimers();
});
test('当前作品没有下一关时展示三个相似作品并可选择进入', () => {
vi.useFakeTimers();
const onAdvanceNextLevel = vi.fn();
const similarWorksRun: PuzzleRunSnapshot = {
...clearedRun,
recommendedNextProfileId: 'profile-similar-1',
nextLevelMode: 'similarWorks',
nextLevelProfileId: 'profile-similar-1',
nextLevelId: null,
recommendedNextWorks: [
{
profileId: 'profile-similar-1',
levelName: '雾海遗迹',
authorDisplayName: '星桥旅人',
themeTags: ['奇幻', '遗迹'],
coverImageSrc: null,
similarityScore: 0.91,
},
{
profileId: 'profile-similar-2',
levelName: '风塔试炼',
authorDisplayName: '晨风',
themeTags: ['奇幻', '机关'],
coverImageSrc: null,
similarityScore: 0.84,
},
{
profileId: 'profile-similar-3',
levelName: '月井秘路',
authorDisplayName: '月井守望',
themeTags: ['秘境', '魔法'],
coverImageSrc: null,
similarityScore: 0.79,
},
],
};
renderPuzzleRuntime(
<PuzzleRuntimeShell
run={similarWorksRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
act(() => {
vi.advanceTimersByTime(1_400);
});
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getByText('雾海遗迹')).toBeTruthy();
expect(within(dialog).getByText('风塔试炼')).toBeTruthy();
expect(within(dialog).getByText('月井秘路')).toBeTruthy();
expect(within(dialog).queryByRole('button', { name: '下一关' })).toBeNull();
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
expect(onAdvanceNextLevel).toHaveBeenCalledWith({
profileId: 'profile-similar-2',
});
vi.useRealTimers();
});
@@ -326,6 +451,106 @@ test('基础单块使用圆角裁剪图片', () => {
expect(basePiece?.className).toContain('rounded-[0.85rem]');
});
test('移动端点击拼图片时立即触发一次震动反馈', () => {
const originalVibrate = navigator.vibrate;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const vibrate = vi.fn();
const playingRun: PuzzleRunSnapshot = {
...clearedRun,
currentLevel: {
...clearedRun.currentLevel!,
status: 'playing',
startedAtMs: Date.now(),
board: {
...clearedRun.currentLevel!.board,
allTilesResolved: false,
},
},
};
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: vibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: vi.fn(() => 1),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: vi.fn(),
});
const { container, unmount } = renderPuzzleRuntime(
<PuzzleRuntimeShell
run={playingRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
const piece = container.querySelector(
'[data-piece-id="piece-0"]',
) as HTMLElement | null;
if (!piece) {
throw new Error('缺少测试拼图片');
}
act(() => {
dispatchPointerEvent(piece, 'pointerdown', {
pointerId: 1,
clientX: 100,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
expect(vibrate).toHaveBeenCalledWith([12]);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 104,
clientY: 104,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 112,
clientY: 100,
});
});
act(() => {
dispatchPointerEvent(piece, 'pointermove', {
pointerId: 1,
clientX: 132,
clientY: 100,
});
});
expect(vibrate).toHaveBeenCalledTimes(1);
unmount();
Object.defineProperty(navigator, 'vibrate', {
configurable: true,
value: originalVibrate,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
value: originalCancelAnimationFrame,
});
});
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
const onPauseChange = vi.fn();
const onUseProp = vi.fn().mockResolvedValue(clearedRun);

View File

@@ -16,13 +16,14 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRecommendedNextWork,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
@@ -34,7 +35,8 @@ type PuzzleRuntimeShellProps = {
onBack: () => void;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: () => void;
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
onRestartLevel?: () => void | Promise<void>;
onPauseChange?: (paused: boolean) => void | Promise<void>;
onUseProp?: (
propKind: PuzzleRuntimePropKind,
@@ -42,6 +44,11 @@ type PuzzleRuntimeShellProps = {
onTimeExpired?: () => void | Promise<void>;
};
export type PuzzleNextLevelTarget = {
profileId?: string;
levelId?: string | null;
};
type PuzzleBoardPieceViewModel = {
pieceId: string;
row: number;
@@ -260,6 +267,10 @@ function formatTimerMs(value: number | null | undefined) {
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function resolveAuthorAvatarLabel(authorDisplayName: string) {
return authorDisplayName.trim().slice(0, 1) || '玩';
}
function resolveActiveFreezeElapsedMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
@@ -309,6 +320,7 @@ const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
@@ -329,6 +341,19 @@ type PuzzleHintDemoState = {
offsetYPercent: number;
};
function triggerPuzzlePiecePressHapticFeedback() {
if (typeof navigator === 'undefined') {
return;
}
const vibrate = navigator.vibrate;
if (typeof vibrate !== 'function') {
return;
}
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
@@ -342,6 +367,7 @@ export function PuzzleRuntimeShell({
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onRestartLevel,
onPauseChange,
onUseProp,
onTimeExpired,
@@ -907,6 +933,8 @@ export function PuzzleRuntimeShell({
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
triggerPuzzlePiecePressHapticFeedback();
dragSessionRef.current = {
pieceId,
pointerId: event.pointerId,
@@ -959,9 +987,24 @@ export function PuzzleRuntimeShell({
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const nextLevelMode =
run.nextLevelMode ?? 'none';
const recommendedNextWorks = run.recommendedNextWorks ?? [];
const hasSimilarWorkChoices =
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
const canAdvanceDefaultNextLevel =
currentLevel.status === 'cleared' &&
(nextLevelMode === 'sameWork' ||
(nextLevelMode === 'similarWorks'
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
!hasSimilarWorkChoices
: Boolean(run.recommendedNextProfileId)));
const canShowNextAction =
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const levelLabel = `${currentLevel.levelIndex}`;
const authorAvatarLabel = resolveAuthorAvatarLabel(
currentLevel.authorDisplayName,
);
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
@@ -974,7 +1017,11 @@ export function PuzzleRuntimeShell({
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
if (runtimeStatus !== 'playing') {
const canOpen =
propKind === 'extendTime'
? runtimeStatus === 'failed'
: runtimeStatus === 'playing';
if (!canOpen) {
return;
}
setPropConfirmError(null);
@@ -1048,6 +1095,9 @@ export function PuzzleRuntimeShell({
setIsFreezeEffectVisible(false);
}, 900);
}
if (propKind === 'extendTime') {
setTimerNowMs(Date.now());
}
};
return (
@@ -1064,7 +1114,7 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
<button
type="button"
onClick={onBack}
@@ -1074,26 +1124,34 @@ export function PuzzleRuntimeShell({
<ArrowLeft className="h-4 w-4" />
</button>
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
<div className="flex min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
{currentLevel.levelName}
</div>
<div className="line-clamp-1 text-xs text-white/78">
{currentLevel.authorDisplayName}
</div>
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
<div
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'bg-red-500/22 text-red-100'
: 'bg-white/10 text-white/86'
? 'bg-red-500/24 text-red-100'
: 'bg-white/12 text-white'
}`}
>
<Clock className="h-3.5 w-3.5" />
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
{formatTimerMs(displayRemainingMs)}
</div>
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
aria-hidden="true"
>
{authorAvatarLabel}
</span>
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
{currentLevel.authorDisplayName}
</span>
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
{levelLabel}
</span>
</div>
</div>
<button
@@ -1371,15 +1429,47 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
<div className="absolute bottom-0 left-0 z-20 flex w-full flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{canShowNextAction ? (
<button
type="button"
disabled={isBusy}
onClick={() => {
if (hasSimilarWorkChoices) {
setDismissedClearKey(null);
setIsClearResultReady(true);
return;
}
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
</button>
) : null}
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Lightbulb className="h-4 w-4 text-amber-100" />
<Lightbulb className="h-6 w-6 text-amber-100" />
</button>
<button
@@ -1393,59 +1483,25 @@ export function PuzzleRuntimeShell({
}
openPropDialog('reference', '查看原图');
}}
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
className={`inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition hover:bg-white/10 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'bg-sky-200 text-slate-950'
: 'text-white/86'
}`}
>
<Eye className="h-4 w-4" />
<Eye className="h-6 w-6" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Snowflake className="h-4 w-4 text-cyan-100" />
<Snowflake className="h-6 w-6 text-cyan-100" />
</button>
</div>
<div className="flex flex-col items-end gap-2">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{nextAvailable ? (
<button
type="button"
disabled={isBusy}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 disabled:opacity-45"
>
<ArrowRight className="h-4 w-4" />
</button>
) : (
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
{isBusy
? '同步中...'
: runtimeStatus === 'cleared'
? '等待下一关候选'
: runtimeStatus === 'failed'
? '本关失败'
: '完成整张图即可通关'}
</div>
)}
</div>
</div>
{isClearFlashVisible ? (
@@ -1678,13 +1734,24 @@ export function PuzzleRuntimeShell({
{currentLevel.levelName}
</div>
</header>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<footer className="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onBack}
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => openPropDialog('extendTime', '继续1分钟')}
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
>
1
</button>
</footer>
</section>
@@ -1783,23 +1850,47 @@ export function PuzzleRuntimeShell({
</div>
</div>
</div>
{hasSimilarWorkChoices ? (
<div className="mt-4">
<div className="grid gap-2 sm:grid-cols-3">
{recommendedNextWorks.slice(0, 3).map((item) => (
<PuzzleNextWorkCard
key={item.profileId}
item={item}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({ profileId: item.profileId });
}}
/>
))}
</div>
</div>
) : null}
</div>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy || !nextAvailable}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
{canAdvanceDefaultNextLevel ? (
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
) : null}
</section>
</div>
) : null}
@@ -1808,4 +1899,54 @@ export function PuzzleRuntimeShell({
);
}
function PuzzleNextWorkCard({
item,
disabled,
onClick,
}: {
item: PuzzleRecommendedNextWork;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
>
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
)}
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black text-white">
{item.levelName}
</div>
<div className="mt-1 truncate text-xs font-semibold text-white/58">
{item.authorDisplayName}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{item.themeTags.slice(0, 2).map((tag) => (
<span
key={tag}
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
>
{tag}
</span>
))}
</div>
</div>
</button>
);
}
export default PuzzleRuntimeShell;

View File

@@ -1,6 +1,6 @@
/* @vitest-environment jsdom */
import { render, screen, within } from '@testing-library/react';
import { act, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { afterEach, expect, test, vi } from 'vitest';
@@ -118,10 +118,24 @@ vi.mock('../../services/rpg-entry/rpgProfileClient', () => ({
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
ResolvedAssetImage: ({
src,
alt,
className,
...rest
}: {
src?: string | null;
alt?: string;
className?: string;
}) =>
src ? (
<img src={src} alt={alt ?? ''} className={className} {...rest} />
) : null,
}));
const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -156,6 +170,33 @@ const remixRankEntry = {
updatedAt: '2026-04-25T11:00:00.000Z',
} satisfies PlatformPublicGalleryCard;
function buildCarouselPuzzleEntry(
id: string,
worldName: string,
coverPrefix: string,
) {
return {
...puzzlePublicEntry,
workId: `puzzle-work-${id}`,
profileId: `puzzle-profile-${id}`,
publicWorkCode: `PZ-${id.toUpperCase()}`,
worldName,
coverImageSrc: `${coverPrefix}-fallback.png`,
coverSlides: [
{
id: `${id}-cover-1`,
imageSrc: `${coverPrefix}-1.png`,
label: `${worldName} 1`,
},
{
id: `${id}-cover-2`,
imageSrc: `${coverPrefix}-2.png`,
label: `${worldName} 2`,
},
],
} satisfies PlatformPublicGalleryCard;
}
const hotRankEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-hot-rank',
@@ -414,12 +455,23 @@ function renderStatefulLoggedOutHomeView(
}
afterEach(() => {
vi.useRealTimers();
vi.clearAllMocks();
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: originalMatchMedia,
});
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: originalRequestAnimationFrame,
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: originalCancelAnimationFrame,
});
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: undefined,
@@ -430,7 +482,7 @@ test('opens wallet ledger modal from narrative coin card', async () => {
const user = userEvent.setup();
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('陶泥币账单')).toBeTruthy();
expect(mockGetRpgProfileWalletLedger).toHaveBeenCalledTimes(1);
@@ -446,7 +498,7 @@ test('profile total play time card always uses hours', () => {
});
const playTimeCard = screen.getByRole('button', {
name: //u,
name: //u,
});
expect(within(playTimeCard).getByText('1.5小时')).toBeTruthy();
@@ -470,12 +522,12 @@ test('wallet ledger modal shows empty and error states', async () => {
mockGetRpgProfileWalletLedger.mockResolvedValueOnce({ entries: [] });
renderProfileView();
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('暂无账单记录')).toBeTruthy();
await user.click(screen.getByLabelText('关闭陶泥币账单'));
mockGetRpgProfileWalletLedger.mockRejectedValueOnce(new Error('加载失败'));
await user.click(screen.getByText('剩余陶泥币'));
await user.click(screen.getByRole('button', { name: /\s*0/u }));
expect(await screen.findByText('加载失败')).toBeTruthy();
expect(screen.getByText('重新加载')).toBeTruthy();
@@ -622,6 +674,126 @@ test('mobile public work cards render cover, author, kind and cover stats', () =
).toBe('推荐');
});
test('mobile home feed only rotates the card closest to screen center', () => {
vi.useFakeTimers();
Object.defineProperty(window, 'requestAnimationFrame', {
configurable: true,
writable: true,
value: (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(0), 0),
});
Object.defineProperty(window, 'cancelAnimationFrame', {
configurable: true,
writable: true,
value: (handle: number) => window.clearTimeout(handle),
});
const firstEntry = buildCarouselPuzzleEntry('center1', '中心拼图一', 'center-one');
const secondEntry = buildCarouselPuzzleEntry(
'center2',
'中心拼图二',
'center-two',
);
const cardRects = new Map<string, DOMRect>();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [firstEntry, secondEntry],
});
const tabPanel = document.querySelector('.platform-tab-panel--active');
const firstCard = screen.getByRole('button', { name: //u });
const secondCard = screen.getByRole('button', { name: //u });
if (!tabPanel) {
throw new Error('缺少移动端首页滚动面板');
}
tabPanel.getBoundingClientRect = vi.fn(
() =>
({
top: 0,
bottom: 600,
height: 600,
left: 0,
right: 360,
width: 360,
}) as DOMRect,
);
firstCard.getBoundingClientRect = vi.fn(() => cardRects.get('first')!);
secondCard.getBoundingClientRect = vi.fn(() => cardRects.get('second')!);
cardRects.set('first', {
top: 170,
bottom: 370,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 420,
bottom: 620,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-2.png',
);
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-1.png',
);
cardRects.set('first', {
top: -120,
bottom: 80,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
cardRects.set('second', {
top: 200,
bottom: 400,
height: 200,
left: 0,
right: 320,
width: 320,
} as DOMRect);
act(() => {
tabPanel.dispatchEvent(new Event('scroll'));
vi.runOnlyPendingTimers();
});
expect(within(firstCard).getByRole('img').getAttribute('src')).toBe(
'center-one-1.png',
);
act(() => {
vi.advanceTimersByTime(4200);
});
expect(within(secondCard).getByRole('img').getAttribute('src')).toBe(
'center-two-2.png',
);
});
test('mobile today channel only shows newly published works from today', async () => {
const user = userEvent.setup();
const now = new Date();
@@ -676,13 +848,31 @@ test('mobile today channel only shows newly published works from today', async (
expect(screen.queryByText('今日更新旧作')).toBeNull();
});
test('desktop trending list shows kind instead of work code or timestamp text', () => {
test('desktop home syncs mobile home modules without square or latest labels', () => {
mockDesktopLayout();
const todayPublishedAt = new Date().toISOString();
const todayEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-desktop-today',
profileId: 'puzzle-profile-desktop-today',
publicWorkCode: 'PZ-DTODAY',
worldName: '桌面今日新游',
publishedAt: todayPublishedAt,
updatedAt: todayPublishedAt,
} satisfies PlatformPublicGalleryCard;
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
latestEntries: [puzzlePublicEntry, todayEntry],
});
expect(screen.getByText('今日游戏')).toBeTruthy();
expect(screen.getByText('推荐')).toBeTruthy();
expect(screen.getByText('作品分类')).toBeTruthy();
expect(screen.getAllByText('桌面今日新游').length).toBeGreaterThan(0);
expect(screen.queryByText('趋势关注')).toBeNull();
expect(screen.queryByText('最新发布')).toBeNull();
expect(screen.queryByText('作品广场')).toBeNull();
expect(screen.queryByText('公开作品')).toBeNull();
expect(screen.queryByText('PZ-EPUBLIC1')).toBeNull();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('1777110165.990127Z')).toBeNull();

View File

@@ -76,6 +76,7 @@ import {
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
} from './rpgEntryWorldPresentation';
@@ -145,6 +146,7 @@ const PLATFORM_HOME_TABS: PlatformHomeTab[] = [
const AVATAR_MAX_FILE_SIZE = 5 * 1024 * 1024;
const AVATAR_OUTPUT_SIZE = 256;
const AVATAR_ALLOWED_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
const PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS = 4200;
type ProfilePopupPanel = 'invite' | 'redeem' | 'community';
type MobileHomeChannel = 'recommend' | 'today' | 'category';
type PlatformRankingTab = 'hot' | 'remix' | 'new' | 'like';
@@ -365,12 +367,38 @@ function WorldCard({
entry,
onClick,
className,
feedCardKey,
enableCoverCarousel = false,
isCoverCarouselActive = false,
}: {
entry: PlatformPublicGalleryCard;
onClick: () => void;
className?: string;
feedCardKey?: string;
enableCoverCarousel?: boolean;
isCoverCarouselActive?: boolean;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry);
const coverSlides = useMemo(() => {
if (!enableCoverCarousel) {
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
return resolvePlatformWorldCoverSlides(entry);
}, [enableCoverCarousel, entry, fallbackCoverImage]);
const [activeCoverIndex, setActiveCoverIndex] = useState(0);
const visibleCoverIndex = isCoverCarouselActive ? activeCoverIndex : 0;
const activeCoverSlide =
coverSlides[visibleCoverIndex] ?? coverSlides[0] ?? null;
const coverImage = activeCoverSlide?.imageSrc ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const tags = buildPlatformWorldDisplayTags(entry, 3);
const playCount = getPlatformWorldPlayCount(entry);
@@ -398,11 +426,36 @@ function WorldCard({
},
];
useEffect(() => {
setActiveCoverIndex(0);
}, [entry.ownerUserId, entry.profileId, coverSlides.length]);
useEffect(() => {
if (!isCoverCarouselActive) {
setActiveCoverIndex(0);
}
}, [isCoverCarouselActive]);
useEffect(() => {
if (!isCoverCarouselActive || coverSlides.length <= 1) {
return undefined;
}
const timerId = window.setInterval(() => {
setActiveCoverIndex((current) => (current + 1) % coverSlides.length);
}, PLATFORM_WORK_COVER_CAROUSEL_INTERVAL_MS);
return () => {
window.clearInterval(timerId);
};
}, [coverSlides.length, isCoverCarouselActive]);
return (
<button
type="button"
onClick={onClick}
aria-label={cardLabel}
data-mobile-feed-card-key={feedCardKey}
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 ?? ''}`}
>
<div className="platform-public-work-card__cover relative aspect-video overflow-hidden">
@@ -1191,7 +1244,7 @@ function formatCompactPlayTime(playTimeMs: number) {
return `${Math.max(0, totalMinutes)}`;
}
// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。
// “游戏时长”固定使用小时,避免短时长切到分钟或长时长切到天。
function formatTotalPlayTimeHours(playTimeMs: number) {
const roundedHours = Math.max(0, Math.round(playTimeMs / 360000) / 10);
@@ -2056,7 +2109,7 @@ function ProfilePlayedWorksModal({
type="button"
onClick={onClose}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/80 text-[#ff4056] shadow-sm"
aria-label="关闭玩过作品"
aria-label="关闭玩过"
>
×
</button>
@@ -2065,7 +2118,7 @@ function ProfilePlayedWorksModal({
<div className="text-[10px] font-black tracking-[0.22em] text-[#ff4056]">
PLAYED
</div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-1 text-2xl font-black"></div>
<div className="mt-2 inline-flex items-center gap-2 rounded-full border border-rose-100 bg-rose-50 px-3 py-1.5 text-xs font-bold text-zinc-600">
<Clock3 className="h-3.5 w-3.5 text-[#ff4056]" />
<span>
@@ -2129,7 +2182,7 @@ function ProfilePlayedWorksModal({
</div>
) : (
<div className="mt-5 rounded-xl border border-zinc-200 bg-zinc-50 px-4 py-4 text-sm text-zinc-600">
</div>
)}
</div>
@@ -2202,6 +2255,10 @@ export function RpgEntryHomeView({
);
const [mobileHomeChannel, setMobileHomeChannel] =
useState<MobileHomeChannel>('recommend');
const mobileFeedRef = useRef<HTMLElement | null>(null);
const [mobileCenteredCardKey, setMobileCenteredCardKey] = useState<
string | null
>(null);
const [activeRankingTab, setActiveRankingTab] =
useState<PlatformRankingTab>('hot');
const [visitedTabs, setVisitedTabs] = useState<Set<PlatformHomeTab>>(
@@ -2631,9 +2688,21 @@ export function RpgEntryHomeView({
const desktopHeroStripEntries = (
featuredShelf.length > 0 ? featuredShelf : latestEntries
).slice(0, 5);
const desktopTrendingEntries = latestEntries.slice(0, 3);
const desktopFeaturedGrid = featuredShelf.slice(0, 4);
const desktopReleaseGrid = latestEntries.slice(0, 6);
// 网页端保留原有宽屏布局,只把模块数据同步到移动端首页频道语义。
const desktopRecommendEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
[...featuredShelf, ...latestEntries].forEach((entry) => {
entryMap.set(buildPublicGalleryCardKey(entry), entry);
});
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries]);
const desktopTodayEntries = useMemo(
() => filterTodayPublishedEntries(latestEntries),
[latestEntries],
);
const desktopFeaturedGrid = desktopRecommendEntries.slice(0, 4);
const desktopCategoryGrid = activeCategoryEntries.slice(0, 6);
const desktopLibraryPreview = myEntries.slice(0, 2);
const mobileFeedEntries = useMemo(() => {
const entryMap = new Map<string, PlatformPublicGalleryCard>();
@@ -2648,6 +2717,86 @@ export function RpgEntryHomeView({
return Array.from(entryMap.values());
}, [featuredShelf, latestEntries, mobileHomeChannel]);
const mobileFeedCarouselEnabled =
!isDesktopLayout && activeTab === 'home' && mobileHomeChannel !== 'category';
useEffect(() => {
if (!mobileFeedCarouselEnabled) {
setMobileCenteredCardKey(null);
return undefined;
}
const feedElement = mobileFeedRef.current;
const scrollElement = feedElement?.closest('.platform-tab-panel');
if (!feedElement || !scrollElement) {
setMobileCenteredCardKey(null);
return undefined;
}
let frameId: number | null = null;
const updateCenteredCard = () => {
frameId = null;
const cards = Array.from(
feedElement.querySelectorAll<HTMLElement>('[data-mobile-feed-card-key]'),
);
const viewportRect = scrollElement.getBoundingClientRect();
const viewportCenterY =
viewportRect.top + Math.max(0, viewportRect.height) / 2;
let closestKey: string | null = null;
let closestDistance = Number.POSITIVE_INFINITY;
cards.forEach((card) => {
const cardKey = card.dataset.mobileFeedCardKey;
if (!cardKey) {
return;
}
const cardRect = card.getBoundingClientRect();
if (
cardRect.bottom <= viewportRect.top ||
cardRect.top >= viewportRect.bottom
) {
return;
}
const cardCenterY = cardRect.top + cardRect.height / 2;
const distance = Math.abs(cardCenterY - viewportCenterY);
if (distance < closestDistance) {
closestDistance = distance;
closestKey = cardKey;
}
});
setMobileCenteredCardKey((current) =>
current === closestKey ? current : closestKey,
);
};
const scheduleUpdate = () => {
if (frameId !== null) {
return;
}
frameId =
typeof window.requestAnimationFrame === 'function'
? window.requestAnimationFrame(updateCenteredCard)
: window.setTimeout(updateCenteredCard, 0);
};
scheduleUpdate();
scrollElement.addEventListener('scroll', scheduleUpdate, { passive: true });
window.addEventListener('resize', scheduleUpdate);
return () => {
if (frameId !== null) {
if (typeof window.cancelAnimationFrame === 'function') {
window.cancelAnimationFrame(frameId);
} else {
window.clearTimeout(frameId);
}
}
scrollElement.removeEventListener('scroll', scheduleUpdate);
window.removeEventListener('resize', scheduleUpdate);
};
}, [mobileFeedCarouselEnabled, mobileFeedEntries, mobileHomeChannel]);
const activeRankingConfig = PLATFORM_RANKING_TABS.find(
(tab) => tab.id === activeRankingTab,
) as (typeof PLATFORM_RANKING_TABS)[number];
@@ -2757,19 +2906,26 @@ export function RpgEntryHomeView({
)}
</section>
) : (
<section className="platform-mobile-home-feed">
<section ref={mobileFeedRef} className="platform-mobile-home-feed">
{isLoadingPlatform ? (
<EmptyShelf text="正在读取公开作品..." />
) : mobileFeedEntries.length > 0 ? (
<div className="grid min-w-0 gap-3">
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
/>
))}
{mobileFeedEntries.map((entry: PlatformPublicGalleryCard) => {
const cardKey = buildPublicGalleryCardKey(entry);
return (
<WorldCard
key={`${cardKey}:mobile-feed:${mobileHomeChannel}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full"
feedCardKey={cardKey}
enableCoverCarousel={mobileFeedCarouselEnabled}
isCoverCarouselActive={mobileCenteredCardKey === cardKey}
/>
);
})}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有可展示的作品。" />
@@ -3040,21 +3196,21 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="剩余陶泥币"
label="陶泥币"
value="暂不可用"
icon={Coins}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="游戏时长"
value="暂不可用"
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
label="玩过"
value="暂不可用"
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
@@ -3064,21 +3220,21 @@ export function RpgEntryHomeView({
<>
<ProfileStatCard
cardKey="wallet"
label="剩余陶泥币"
label="陶泥币"
value={formatDashboardCount(remainingNarrativeCoins)}
icon={Coins}
onClick={openWalletLedgerPanel}
/>
<ProfileStatCard
cardKey="playTime"
label="游戏时长"
label="游戏时长"
value={totalPlayTime}
icon={Clock3}
onClick={onOpenProfileDashboardCard}
/>
<ProfileStatCard
cardKey="playedWorks"
label="玩过作品"
label="玩过"
value={formatDashboardCount(playedWorkCount)}
icon={BookOpen}
onClick={onOpenProfileDashboardCard}
@@ -3185,7 +3341,7 @@ export function RpgEntryHomeView({
<span className="platform-pill platform-pill--neutral px-3">
{leadPublicEntry
? describePublicGalleryCardKind(leadPublicEntry)
: '作品广场'}
: '作品'}
</span>
</div>
@@ -3196,7 +3352,7 @@ export function RpgEntryHomeView({
<div className="mt-4 text-base leading-8 text-zinc-200/86">
{leadPublicEntry?.summaryText ||
leadPublicEntry?.subtitle ||
'从公开广场进入作品详情,挑一个世界开始游玩。'}
'挑一个玩家作品,开始今天的游玩。'}
</div>
<div className="mt-5 inline-flex items-center gap-2 rounded-full border border-white/18 bg-white/18 px-4 py-2 text-sm font-semibold text-white/92">
<span></span>
@@ -3243,18 +3399,18 @@ export function RpgEntryHomeView({
<section className="platform-desktop-panel px-5 py-5">
<div className="mb-4 flex items-start justify-between gap-3">
<SectionHeader title="趋势关注" detail="TRENDING NOW" />
<SectionHeader title="今日游戏" detail="TODAY GAMES" />
<span className="platform-pill platform-pill--neutral px-3">
LIVE
TODAY
</span>
</div>
{isLoadingPlatform ? (
<EmptyShelf text="正在整理趋势作品..." />
) : desktopTrendingEntries.length > 0 ? (
<EmptyShelf text="正在读取今日游戏..." />
) : desktopTodayEntries.length > 0 ? (
<div className="space-y-3">
{desktopTrendingEntries.map((entry, index) => (
{desktopTodayEntries.slice(0, 3).map((entry, index) => (
<DesktopTrendingItem
key={`${buildPublicGalleryCardKey(entry)}:desktop-trend`}
key={`${buildPublicGalleryCardKey(entry)}:desktop-today`}
entry={entry}
rank={index + 1}
onClick={() => onOpenGalleryDetail(entry)}
@@ -3262,16 +3418,18 @@ export function RpgEntryHomeView({
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有趋势作品。" />
<EmptyShelf text="今天暂时还没有新游戏。" />
)}
</section>
</div>
<div className="grid gap-5 2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]">
<div
className={`grid gap-5 ${desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? '2xl:grid-cols-[minmax(0,1.2fr)_minmax(22rem,0.8fr)]' : ''}`}
>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="精选推荐" detail="CURATED WORLDS" />
<SectionHeader title="推荐" detail="RECOMMENDED" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取精选作品..." />
<EmptyShelf text="正在读取推荐作品..." />
) : desktopFeaturedGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-2">
{desktopFeaturedGrid.map((entry) => (
@@ -3284,136 +3442,142 @@ export function RpgEntryHomeView({
))}
</div>
) : (
<EmptyShelf text="公开广场暂时还没有精选作品。" />
<EmptyShelf text="暂时还没有推荐作品。" />
)}
</section>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={
desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '作品广场'
}
detail="QUICK ACCESS"
/>
{desktopLibraryPreview.length > 0 || historyEntries.length > 0 ? (
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader
title={desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
detail="QUICK ACCESS"
/>
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0
? '最近作品'
: historyEntries.length > 0
? '最近浏览'
: '公开作品'}
<div>
<div className="text-[10px] font-semibold tracking-[0.24em] text-[var(--platform-text-soft)]">
{desktopLibraryPreview.length > 0 ? '最近作品' : '最近浏览'}
</div>
{desktopLibraryPreview.length > 0 ? (
<div className="mt-3 space-y-3">
{desktopLibraryPreview.map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
type="button"
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</span>
</button>
);
})}
</div>
) : (
<div className="mt-3 space-y-3">
{historyEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
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"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
</span>
</button>
);
})}
</div>
)}
</div>
{desktopLibraryPreview.length > 0 ? (
<div className="mt-3 space-y-3">
{desktopLibraryPreview.map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-mine`}
type="button"
onClick={() => onOpenLibraryDetail(entry)}
className="platform-desktop-trending-item flex w-full items-center justify-between gap-3 px-4 py-4 text-left"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.visibility === 'published'
? `发布于 ${formatPlatformWorldTime(entry.publishedAt)}`
: '草稿待完善'}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
{entry.visibility === 'published' ? '已发布' : '草稿'}
</span>
</button>
);
})}
</div>
) : historyEntries.length > 0 ? (
<div className="mt-3 space-y-3">
{historyEntries.slice(0, 2).map((entry) => {
const displayName = formatPlatformWorkDisplayName(
entry.worldName,
);
return (
<button
key={`${entry.ownerUserId}:${entry.profileId}:desktop-history`}
type="button"
onClick={() =>
onOpenGalleryDetail({
ownerUserId: entry.ownerUserId,
profileId: entry.profileId,
publicWorkCode: null,
authorPublicUserCode: null,
visibility: 'published',
publishedAt: entry.visitedAt,
updatedAt: entry.visitedAt,
worldName: entry.worldName,
subtitle: entry.subtitle,
summaryText: entry.summaryText,
coverImageSrc: entry.coverImageSrc,
themeMode: entry.themeMode,
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"
>
<div className="min-w-0">
<div className="line-clamp-1 text-base font-semibold text-[var(--platform-text-strong)]">
{displayName}
</div>
<div className="mt-1 text-sm text-[var(--platform-text-soft)]">
{entry.authorDisplayName}
</div>
</div>
<span className="platform-pill platform-pill--neutral px-3">
</span>
</button>
);
})}
</div>
) : (
<div className="platform-subpanel mt-3 rounded-[1.35rem] px-4 py-4 text-sm leading-6 text-[var(--platform-text-base)]">
广
</div>
)}
</div>
</section>
</section>
) : null}
</div>
<section className="platform-desktop-panel px-5 py-5">
<SectionHeader title="最新发布" detail="PLAYER SQUARE" />
<SectionHeader title="作品分类" detail="GAME CATEGORY" />
{isLoadingPlatform ? (
<EmptyShelf text="正在读取最新发布..." />
) : desktopReleaseGrid.length > 0 ? (
<div className="grid gap-4 xl:grid-cols-3">
{desktopReleaseGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-latest`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
))}
</div>
<EmptyShelf text="正在读取作品分类..." />
) : activeCategoryGroup && desktopCategoryGrid.length > 0 ? (
<>
<div className="mb-4 flex min-w-0 items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
{categoryGroups.map((group) => {
const active = group.tag === activeCategoryGroup.tag;
return (
<button
key={`${group.tag}:desktop-category`}
type="button"
onClick={() => setSelectedCategoryTag(group.tag)}
className={`platform-category-chip shrink-0 ${active ? 'platform-category-chip--active' : ''}`}
>
{group.tag}
</button>
);
})}
</div>
<div className="grid gap-4 xl:grid-cols-3">
{desktopCategoryGrid.map((entry) => (
<WorldCard
key={`${buildPublicGalleryCardKey(entry)}:desktop-category:${activeCategoryGroup.tag}`}
entry={entry}
onClick={() => onOpenGalleryDetail(entry)}
className="w-full min-w-0"
/>
))}
</div>
</>
) : (
<EmptyShelf text="公开广场暂时还没有作品。" />
<EmptyShelf text="暂时还没有可分类的作品。" />
)}
</section>
</div>

View File

@@ -1,6 +1,7 @@
import { expect, test } from 'vitest';
import {
buildPuzzleWorkCoverSlides,
formatPlatformWorkDisplayName,
formatPlatformWorkDisplayTags,
formatPlatformWorldTime,
@@ -36,3 +37,72 @@ test('platform work display text limits names and tags by character count', () =
'星桥',
]);
});
test('buildPuzzleWorkCoverSlides prefers each level formal image', () => {
const slides = buildPuzzleWorkCoverSlides({
workId: 'work-1',
profileId: 'profile-1',
ownerUserId: 'user-1',
authorDisplayName: '玩家',
levelName: '第一关',
summary: '拼图摘要',
themeTags: ['拼图'],
coverImageSrc: '/cover.png',
publicationStatus: 'published',
updatedAt: '2026-04-25T00:00:00.000Z',
publishedAt: '2026-04-25T00:00:00.000Z',
publishReady: true,
levels: [
{
levelId: 'level-1',
levelName: '石桥',
pictureDescription: '石桥画面',
selectedCandidateId: 'candidate-2',
coverImageSrc: '/level-1-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/level-1-a.png',
assetId: 'asset-1',
prompt: '',
sourceType: 'generated',
selected: false,
},
{
candidateId: 'candidate-2',
imageSrc: '/level-1-b.png',
assetId: 'asset-2',
prompt: '',
sourceType: 'generated',
selected: false,
},
],
},
{
levelId: 'level-2',
levelName: '星港',
pictureDescription: '星港画面',
selectedCandidateId: null,
coverImageSrc: '/level-2-cover.png',
coverAssetId: null,
generationStatus: 'ready',
candidates: [],
},
],
});
expect(slides).toEqual([
{
id: 'level-1',
imageSrc: '/level-1-b.png',
label: '石桥',
},
{
id: 'level-2',
imageSrc: '/level-2-cover.png',
label: '星港',
},
]);
});

View File

@@ -1,4 +1,5 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleDraftLevel } from '../../../packages/shared/src/contracts/puzzleAgentDraft';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type {
CustomWorldGalleryCard,
@@ -32,6 +33,7 @@ export type PlatformPuzzleGalleryCard = {
subtitle: string;
summaryText: string;
coverImageSrc: string | null;
coverSlides?: PlatformPuzzleCoverSlide[];
themeTags: string[];
playCount?: number;
remixCount?: number;
@@ -42,6 +44,12 @@ export type PlatformPuzzleGalleryCard = {
updatedAt: string;
};
export type PlatformPuzzleCoverSlide = {
id: string;
imageSrc: string;
label: string;
};
export type PlatformBigFishGalleryCard = {
sourceType: 'big-fish';
workId: string;
@@ -100,6 +108,7 @@ export function mapPuzzleWorkToPlatformGalleryCard(
subtitle: '拼图关卡',
summaryText: work.workDescription || work.summary,
coverImageSrc: work.coverImageSrc,
coverSlides: buildPuzzleWorkCoverSlides(work),
themeTags: work.themeTags,
playCount: work.playCount ?? 0,
remixCount: work.remixCount ?? 0,
@@ -160,6 +169,89 @@ export function resolvePlatformWorldCoverImage(entry: PlatformWorldCardLike) {
return '';
}
export function resolvePlatformWorldCoverSlides(
entry: PlatformWorldCardLike,
): PlatformPuzzleCoverSlide[] {
const fallbackCoverImage = resolvePlatformWorldCoverImage(entry).trim();
const puzzleCoverSlides = isPuzzleGalleryEntry(entry)
? (entry.coverSlides ?? [])
: [];
const normalizedSlides = puzzleCoverSlides
.map((slide, index) => ({
id: slide.id.trim() || `cover-${index + 1}`,
imageSrc: slide.imageSrc.trim(),
label: slide.label.trim() || entry.worldName,
}))
.filter((slide) => Boolean(slide.imageSrc));
if (normalizedSlides.length > 0) {
return normalizedSlides;
}
return fallbackCoverImage
? [
{
id: 'cover',
imageSrc: fallbackCoverImage,
label: entry.worldName,
},
]
: [];
}
export function resolvePuzzleLevelFormalImageSrc(level: PuzzleDraftLevel) {
const selectedCandidate =
level.candidates.find(
(candidate) =>
candidate.selected ||
(level.selectedCandidateId
? candidate.candidateId === level.selectedCandidateId
: false),
) ??
level.candidates[level.candidates.length - 1] ??
null;
return (
selectedCandidate?.imageSrc?.trim() || level.coverImageSrc?.trim() || ''
);
}
export function buildPuzzleWorkCoverSlides(
work: PuzzleWorkSummary,
): PlatformPuzzleCoverSlide[] {
const slides: PlatformPuzzleCoverSlide[] = [];
const usedImageSrcSet = new Set<string>();
work.levels?.forEach((level, index) => {
const imageSrc = resolvePuzzleLevelFormalImageSrc(level);
if (!imageSrc || usedImageSrcSet.has(imageSrc)) {
return;
}
usedImageSrcSet.add(imageSrc);
slides.push({
id: level.levelId?.trim() || `puzzle-level-${index + 1}`,
imageSrc,
label: level.levelName?.trim() || `${index + 1}`,
});
});
if (slides.length > 0) {
return slides;
}
const fallbackImageSrc = work.coverImageSrc?.trim() ?? '';
return fallbackImageSrc
? [
{
id: 'cover',
imageSrc: fallbackImageSrc,
label: work.levelName,
},
]
: [];
}
export function resolvePlatformWorldLeadPortrait(entry: PlatformWorldCardLike) {
if (!isLibraryWorldEntry(entry)) {
return '';

View File

@@ -3933,6 +3933,59 @@ button {
object-fit: cover;
}
.platform-work-detail__cover-nav {
position: absolute;
top: 50%;
z-index: 2;
display: inline-flex;
height: 2.4rem;
width: 2.4rem;
align-items: center;
justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(15, 23, 42, 0.36);
color: #fff;
transform: translateY(-50%);
backdrop-filter: blur(14px);
}
.platform-work-detail__cover-nav--prev {
left: 0.8rem;
}
.platform-work-detail__cover-nav--next {
right: 0.8rem;
}
.platform-work-detail__cover-dots {
position: absolute;
right: 1rem;
bottom: 0.8rem;
left: 1rem;
z-index: 2;
display: flex;
justify-content: center;
gap: 0.38rem;
}
.platform-work-detail__cover-dot {
height: 0.45rem;
width: 0.45rem;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.46);
padding: 0;
transition:
background 160ms ease,
width 160ms ease;
}
.platform-work-detail__cover-dot--active {
width: 1.25rem;
background: #fff;
}
.platform-work-detail__cover-fallback {
position: absolute;
inset: 0;

View File

@@ -4,15 +4,17 @@ import { act, render, screen } from '@testing-library/react';
import { createElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RouteImageReadyGate } from './RouteImageReadyGate';
import {
collectRouteImageUrls,
extractCssImageUrls,
normalizePreloadImageUrl,
} from './routeImageReadyGateUtils';
import { RouteImageReadyGate } from './RouteImageReadyGate';
import { RouteLoadingScreen } from './RouteLoadingScreen';
afterEach(() => {
vi.useRealTimers();
window.localStorage.clear();
});
describe('RouteImageReadyGate image url helpers', () => {
@@ -84,4 +86,27 @@ describe('RouteImageReadyGate image url helpers', () => {
expect(visibilityGate?.getAttribute('aria-hidden')).toBe('false');
expect(visibilityGate?.style.visibility).toBe('visible');
});
it('uses the saved platform theme tokens for the route loading screen', () => {
window.localStorage.setItem(
'tavernrealms.settings.v1',
JSON.stringify({
version: 1,
musicVolume: 0.42,
platformTheme: 'dark',
}),
);
const { container } = render(
createElement(RouteLoadingScreen, {
eyebrow: '正在载入游戏',
text: '正在载入冒险...',
}),
);
const shell = container.firstElementChild;
expect(shell?.classList.contains('platform-theme')).toBe(true);
expect(shell?.classList.contains('platform-theme--dark')).toBe(true);
expect(shell?.className).toContain('bg-[image:var(--platform-body-fill)]');
});
});

View File

@@ -1,3 +1,12 @@
import { readSavedSettings } from '../persistence/gameSettingsStorage';
function resolveRouteLoadingPlatformThemeClass() {
// 中文注释:路由级等待态早于 AuthUiContext 挂载,只能从本地设置读取平台主题。
return readSavedSettings().platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
}
export function RouteLoadingScreen({
eyebrow,
text,
@@ -5,11 +14,19 @@ export function RouteLoadingScreen({
eyebrow: string;
text: string;
}) {
const platformThemeClass = resolveRouteLoadingPlatformThemeClass();
return (
<div className="flex min-h-screen items-center justify-center bg-[#0d1016] px-6 text-zinc-200">
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} flex min-h-screen items-center justify-center bg-[image:var(--platform-body-fill)] px-6 text-[var(--platform-text-base)]`}
>
<div className="text-center">
<div className="text-sm tracking-[0.26em] text-zinc-500">{eyebrow}</div>
<div className="mt-3 text-lg font-semibold text-white">{text}</div>
<div className="text-sm tracking-[0.26em] text-[var(--platform-text-soft)]">
{eyebrow}
</div>
<div className="mt-3 text-lg font-semibold text-[var(--platform-text-strong)]">
{text}
</div>
</div>
</div>
);

View File

@@ -14,6 +14,7 @@ import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/p
const LOCAL_PUZZLE_RUN_ID_PREFIX = 'local-puzzle-run-';
const PUZZLE_FREEZE_TIME_DURATION_MS = 10_000;
const PUZZLE_EXTEND_TIME_DURATION_MS = 60_000;
const PUZZLE_LEVEL_TIME_LIMIT_MS_BY_GRID_SIZE: Record<PuzzleGridSize, number> = {
3: 180_000,
4: 300_000,
@@ -499,6 +500,10 @@ function applyNextBoard(
},
leaderboardEntries: justCleared ? [] : run.leaderboardEntries,
recommendedNextProfileId: run.recommendedNextProfileId,
nextLevelMode: run.nextLevelMode ?? 'none',
nextLevelProfileId: run.nextLevelProfileId ?? null,
nextLevelId: run.nextLevelId ?? null,
recommendedNextWorks: run.recommendedNextWorks ?? [],
};
}
@@ -551,6 +556,7 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
...currentLevel,
runId: run.runId,
levelIndex: nextLevelIndex,
levelId: null,
gridSize,
profileId: nextProfileId,
levelName: buildLocalLevelName(currentLevel.levelName, nextLevelIndex),
@@ -563,6 +569,10 @@ function buildFallbackLocalLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
leaderboardEntries: [],
},
recommendedNextProfileId: null,
nextLevelMode: 'none',
nextLevelProfileId: null,
nextLevelId: null,
recommendedNextWorks: [],
leaderboardEntries: [],
};
}
@@ -571,6 +581,10 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
const gridSize = resolvePuzzleGridSize(0);
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${item.profileId}-${Date.now()}`;
const startedAtMs = Date.now();
const firstLevel = item.levels?.[0] ?? null;
const firstLevelName = firstLevel?.levelName || item.levelName;
const firstCoverImageSrc = firstLevel?.coverImageSrc ?? item.coverImageSrc;
const secondLevel = item.levels?.[1] ?? null;
return {
runId,
entryProfileId: item.profileId,
@@ -582,12 +596,13 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
currentLevel: {
runId,
levelIndex: 1,
levelId: firstLevel?.levelId ?? null,
gridSize,
profileId: item.profileId,
levelName: item.levelName,
levelName: firstLevelName,
authorDisplayName: item.authorDisplayName,
themeTags: item.themeTags,
coverImageSrc: item.coverImageSrc,
coverImageSrc: firstCoverImageSrc,
board: buildInitialBoard(gridSize, runId, item.profileId, 1),
status: 'playing',
startedAtMs,
@@ -597,6 +612,10 @@ export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot
leaderboardEntries: [],
},
recommendedNextProfileId: null,
nextLevelMode: secondLevel ? 'sameWork' : 'none',
nextLevelProfileId: secondLevel ? item.profileId : null,
nextLevelId: secondLevel?.levelId ?? null,
recommendedNextWorks: [],
leaderboardEntries: [],
};
}
@@ -784,6 +803,37 @@ export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapsh
return buildFallbackLocalLevel(run);
}
export function restartLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
const currentLevel = run.currentLevel;
if (!currentLevel) {
return run;
}
const runId = `${LOCAL_PUZZLE_RUN_ID_PREFIX}${currentLevel.profileId}-${Date.now()}`;
const startedAtMs = Date.now();
return {
...run,
runId,
leaderboardEntries: [],
currentLevel: {
...currentLevel,
runId,
board: buildInitialBoard(
currentLevel.gridSize,
runId,
currentLevel.profileId,
currentLevel.levelIndex,
),
status: 'playing',
startedAtMs,
clearedAtMs: null,
elapsedMs: null,
...buildLevelTimerFields(currentLevel.gridSize),
leaderboardEntries: [],
},
};
}
/**
* 判断当前拼图运行态是否为前端本地兜底 run。
* 这类 run 没有后端持久化记录,不能再调用依赖真实 runId 的排行榜接口。
@@ -877,3 +927,34 @@ export function applyLocalPuzzleFreezeTime(
},
};
}
export function extendLocalPuzzleTime(run: PuzzleRunSnapshot): PuzzleRunSnapshot {
const timedRun = withResolvedTimer(run);
const currentLevel = timedRun.currentLevel;
if (!currentLevel || currentLevel.status !== 'failed') {
return timedRun;
}
const nowMs = Date.now();
const consumedBeforeExtend = Math.max(
0,
currentLevel.timeLimitMs - PUZZLE_EXTEND_TIME_DURATION_MS,
);
return {
...timedRun,
currentLevel: {
...currentLevel,
status: 'playing',
startedAtMs: nowMs - consumedBeforeExtend,
clearedAtMs: null,
elapsedMs: null,
remainingMs: PUZZLE_EXTEND_TIME_DURATION_MS,
pausedAccumulatedMs: 0,
pauseStartedAtMs: null,
freezeAccumulatedMs: 0,
freezeStartedAtMs: null,
freezeUntilMs: null,
},
};
}