1
This commit is contained in:
@@ -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">
|
||||
暂无封面
|
||||
|
||||
Reference in New Issue
Block a user