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

View File

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

View File

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