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