This commit is contained in:
2026-05-10 22:20:54 +08:00
parent d6219f1a0c
commit 192accd796
92 changed files with 7045 additions and 1559 deletions

View File

@@ -67,6 +67,7 @@ import {
} from '../../services/match3d-works';
import {
createPuzzleAgentSession,
executePuzzleAgentAction,
getPuzzleAgentSession,
} from '../../services/puzzle-agent';
import {
@@ -463,9 +464,17 @@ vi.mock('../puzzle-agent/PuzzleAgentWorkspace', () => ({
vi.mock('../puzzle-result/PuzzleResultView', () => ({
PuzzleResultView: ({
isBusy,
onExecuteAction,
session,
onBack,
}: {
isBusy?: boolean;
onExecuteAction: (payload: {
action: string;
levelId?: string;
promptText?: string;
}) => void;
session: { draft?: { levelName: string } | null };
onBack: () => void;
}) => (
@@ -475,6 +484,21 @@ vi.mock('../puzzle-result/PuzzleResultView', () => ({
<input readOnly value={session.draft?.levelName ?? ''} />
</label>
<button
type="button"
onClick={() => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '重新生成猫街',
});
}}
>
</button>
<button type="button" disabled={isBusy}>
</button>
<button type="button" onClick={onBack}>
</button>
@@ -550,14 +574,36 @@ vi.mock('../big-fish-result/BigFishResultView', () => ({
vi.mock('../match3d-creation/Match3DAgentWorkspace', () => ({
Match3DAgentWorkspace: ({
session,
onCreateFromForm,
}: {
session: { sessionId: string; messages: Array<{ text: string }> } | null;
onCreateFromForm?: (payload: {
seedText: string;
themeText: string;
referenceImageSrc: string | null;
clearCount: number;
difficulty: number;
}) => void;
}) => (
<div className="match3d-agent-workspace-mock">
<div>{session?.sessionId ?? 'missing-session'}</div>
{session?.messages.map((message) => (
<div key={`${session.sessionId}-${message.text}`}>{message.text}</div>
))}
<button
type="button"
onClick={() => {
onCreateFromForm?.({
seedText: '赛博水果摊题材消除9次难度6',
themeText: '赛博水果摊',
referenceImageSrc: null,
clearCount: 9,
difficulty: 6,
});
}}
>
稿
</button>
</div>
),
}));
@@ -2355,6 +2401,9 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(
screen.getByRole('tab', { name: 'AIRP' }).querySelector('img')?.src,
).toContain('/creation-type-references/airp.webp');
expect(
screen.getByRole('tab', { name: '抓大鹅' }).querySelector('img')?.src,
).toContain('/creation-type-references/match3d.webp');
expect(
screen.getByRole('tab', { name: '拼图' }).querySelector('.text-white'),
).toBeTruthy();
@@ -2364,12 +2413,29 @@ test('create tab shows template tabs and embeds puzzle form by default', async (
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByPlaceholderText('问一问百梦')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('tab', { name: //u })).toBeNull();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
});
test('create tab switches match3d into the embedded entry form', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
await openCreateTemplateHub(user);
await user.click(screen.getByRole('tab', { name: '抓大鹅' }));
expect(
screen.getByRole('tab', { name: '抓大鹅' }).getAttribute('aria-selected'),
).toBe('true');
expect(
await screen.findByText('抓大鹅工作区missing-session'),
).toBeTruthy();
expect(createPuzzleAgentSession).not.toHaveBeenCalled();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});
test('embedded puzzle form routes through requireAuth while logged out', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2826,6 +2892,159 @@ test('owned public puzzle detail edits original draft instead of remixing', asyn
expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1');
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
expect(await screen.findByText('拼图结果页')).toBeTruthy();
vi.mocked(executePuzzleAgentAction).mockResolvedValueOnce({
operation: {
operationId: 'puzzle-image-generation-1',
type: 'generate_puzzle_images',
status: 'running',
phaseLabel: '生成中',
phaseDetail: '正在生成拼图画面',
progress: 0.3,
},
session: {
sessionId: 'puzzle-session-1',
currentTurn: 3,
progressPercent: 88,
stage: 'ready_to_publish',
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
draft: {
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
levelName: '雨夜猫街',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
forbiddenDirectives: [],
creatorIntent: null,
anchorPack: {
themePromise: {
key: 'theme_promise',
label: '主题承诺',
value: '雨夜猫街',
status: 'confirmed',
},
visualSubject: {
key: 'visual_subject',
label: '视觉主体',
value: '屋檐下的猫',
status: 'confirmed',
},
visualMood: {
key: 'visual_mood',
label: '视觉气质',
value: '温暖',
status: 'confirmed',
},
compositionHooks: {
key: 'composition_hooks',
label: '构图钩子',
value: '雨滴与灯牌',
status: 'confirmed',
},
tagsAndForbidden: {
key: 'tags_and_forbidden',
label: '标签与禁区',
value: '猫咪、雨夜',
status: 'confirmed',
},
},
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
levels: [
{
levelId: 'puzzle-level-1',
levelName: '雨夜猫街',
pictureDescription: '屋檐下的猫与暖灯街角。',
pictureReference: null,
candidates: [
{
candidateId: 'candidate-1',
imageSrc: '/puzzle/candidate-1.png',
assetId: 'asset-1',
prompt: '雨夜猫咪',
actualPrompt: null,
sourceType: 'generated',
selected: true,
},
],
selectedCandidateId: 'candidate-1',
coverImageSrc: '/puzzle/candidate-1.png',
coverAssetId: 'asset-1',
generationStatus: 'generating',
},
],
metadata: null,
},
messages: [],
lastAssistantReply: '大鱼结果页草稿已经生成,可以补正式资产。',
publishedProfileId: null,
suggestedActions: [],
resultPreview: {
draft: null,
publishReady: false,
blockers: [],
qualityFindings: [],
},
updatedAt: '2026-04-26T10:10:00.000Z',
},
});
await user.click(screen.getByRole('button', { name: '重新生成画面' }));
expect(executePuzzleAgentAction).toHaveBeenCalledWith(
'puzzle-session-1',
expect.objectContaining({
action: 'generate_puzzle_images',
}),
);
expect(screen.getByRole('button', { name: '新增关卡' })).toHaveProperty(
'disabled',
false,
);
});
test('logged out public detail gates big fish start before local runtime', async () => {
@@ -3027,7 +3246,6 @@ test('published puzzle works appear on home and mobile game category channel', a
});
test('home recommendation starts embedded puzzle without global auth reset on local failure', async () => {
const user = userEvent.setup();
const publishedPuzzleWork = {
workId: 'puzzle-work-public-1',
profileId: 'puzzle-profile-public-1',
@@ -3396,7 +3614,7 @@ test('embedded puzzle form timeout exits busy state and shows a readable error',
expect(streamCreativeAgentMessage).not.toHaveBeenCalled();
});
test('hidden match3d creation card stays closed even when public galleries fail', async () => {
test('match3d creation tab stays usable even when public galleries fail', async () => {
const user = userEvent.setup();
vi.mocked(listRpgEntryWorldGallery).mockRejectedValueOnce(
@@ -3411,9 +3629,7 @@ test('hidden match3d creation card stays closed even when public galleries fail'
await openCreateTemplateHub(user);
expect(screen.queryByText('读取作品广场失败')).toBeNull();
expect(screen.queryByText('读取抓大鹅广场失败')).toBeNull();
expect(
screen.queryByRole('tab', { name: /.*/u }),
).toBeNull();
expect(screen.getByRole('tab', { name: '抓大鹅' })).toBeTruthy();
expect(match3dCreationClient.createSession).not.toHaveBeenCalled();
});

View File

@@ -1312,6 +1312,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
vi.useFakeTimers();
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
const onLikeRecommendEntry = vi.fn();
const onRemixRecommendEntry = vi.fn();
const firstEntry = {
...puzzlePublicEntry,
workId: 'puzzle-work-feed-1',
@@ -1397,6 +1399,8 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
activeRecommendEntryKey="puzzle:user-feed-1:puzzle-profile-feed-1"
onSelectNextRecommendEntry={onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={onSelectPreviousRecommendEntry}
onLikeRecommendEntry={onLikeRecommendEntry}
onRemixRecommendEntry={onRemixRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={vi.fn()}
/>
@@ -1412,8 +1416,38 @@ test('logged in recommend runtime preloads adjacent work previews and drag switc
).toHaveLength(3);
expect(screen.getAllByText('下一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByText('上一拼图').length).toBeGreaterThanOrEqual(2);
expect(screen.queryByText('评论')).toBeNull();
expect(screen.queryByLabelText(//u)).toBeNull();
const clipboardWriteText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: clipboardWriteText },
});
const meta = screen.getByLabelText('当前拼图 作品信息') as HTMLElement;
const activeRecommendCard = within(meta);
const likeButton = activeRecommendCard.getByRole('button', {
name: '点赞 12',
});
expect(likeButton).toBeTruthy();
expect(activeRecommendCard.getByLabelText('12 个赞')).toBeTruthy();
const shareButton = activeRecommendCard.getByRole('button', { name: '分享' });
const remixButton = activeRecommendCard.getByRole('button', {
name: '改造 5',
});
expect(shareButton).toBeTruthy();
expect(remixButton).toBeTruthy();
fireEvent.click(likeButton);
fireEvent.click(shareButton);
fireEvent.click(remixButton);
expect(onLikeRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(onRemixRecommendEntry).toHaveBeenCalledWith(firstEntry);
expect(clipboardWriteText).toHaveBeenCalledWith(
expect.stringContaining('作品号PZ-FEED1'),
);
act(() => {
dispatchPointerEvent(meta, 'pointerdown', { pointerId: 1, clientY: 300 });
dispatchPointerEvent(meta, 'pointermove', { pointerId: 1, clientY: 210 });

View File

@@ -9,6 +9,7 @@ import {
Coins,
Compass,
Copy,
GitFork,
Gamepad2,
Heart,
LogIn,
@@ -16,10 +17,12 @@ import {
Pencil,
Plus,
Search,
Share2,
Settings,
SlidersHorizontal,
Sparkles,
Star,
ThumbsUp,
Ticket,
UserPlus,
UserRound,
@@ -54,6 +57,7 @@ import type {
RedeemProfileRewardCodeResponse,
} from '../../../packages/shared/src/contracts/runtime';
import type { HydratedSavedGameSnapshot } from '../../persistence/runtimeSnapshotTypes';
import { buildPublicWorkDetailUrl } from '../../routing/appPageRoutes';
import type { AuthUser } from '../../services/authService';
import {
getPublicAuthUserByCode,
@@ -86,6 +90,7 @@ import {
isVisualNovelGalleryEntry,
type PlatformPublicGalleryCard,
type PlatformWorldCardLike,
resolvePlatformPublicWorkCode,
resolvePlatformWorldCoverImage,
resolvePlatformWorldCoverSlides,
resolvePlatformWorldLeadPortrait,
@@ -126,6 +131,8 @@ export interface RpgEntryHomeViewProps {
recommendRuntimeError?: string | null;
onSelectNextRecommendEntry?: () => void;
onSelectPreviousRecommendEntry?: () => void;
onLikeRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onRemixRecommendEntry?: (entry: PlatformPublicGalleryCard) => void;
onOpenLibraryDetail: (
entry: CustomWorldLibraryEntry<CustomWorldProfile>,
) => void;
@@ -772,19 +779,27 @@ function RecommendSwipeCard({
authorAvatarUrl,
isActive,
visual,
shareState,
onDragPointerDown,
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
onLike,
onShare,
onRemix,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
isActive: boolean;
visual: ReactNode;
shareState?: 'idle' | 'copied' | 'failed';
onDragPointerDown?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
}) {
return (
<div
@@ -797,10 +812,14 @@ function RecommendSwipeCard({
entry={entry}
authorAvatarUrl={authorAvatarUrl}
isActive={isActive}
shareState={shareState}
onDragPointerDown={onDragPointerDown}
onDragPointerMove={onDragPointerMove}
onDragPointerUp={onDragPointerUp}
onDragPointerCancel={onDragPointerCancel}
onLike={onLike}
onShare={onShare}
onRemix={onRemix}
/>
</div>
</div>
@@ -814,6 +833,10 @@ function RecommendRuntimeMeta({
onDragPointerMove,
onDragPointerUp,
onDragPointerCancel,
shareState = 'idle',
onLike,
onShare,
onRemix,
isActive = true,
}: {
entry: PlatformPublicGalleryCard;
@@ -822,20 +845,21 @@ function RecommendRuntimeMeta({
onDragPointerMove?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerUp?: (event: PointerEvent<HTMLElement>) => void;
onDragPointerCancel?: (event: PointerEvent<HTMLElement>) => void;
shareState?: 'idle' | 'copied' | 'failed';
onLike?: () => void;
onShare?: () => void;
onRemix?: () => void;
isActive?: boolean;
}) {
const playCount = getPlatformWorldPlayCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const likeCount = getPlatformWorldLikeCount(entry);
const remixCount = getPlatformWorldRemixCount(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const statItems = [
{ label: '游玩', value: playCount, icon: Gamepad2 },
{ label: '点赞', value: likeCount, icon: Heart },
{ label: '改造', value: remixCount, icon: MessageCircle },
];
const stopActionPointer = (event: PointerEvent<HTMLButtonElement>) => {
event.stopPropagation();
};
return (
<section
@@ -854,19 +878,6 @@ function RecommendRuntimeMeta({
onPointerUp={onDragPointerUp}
onPointerCancel={onDragPointerCancel}
>
<div className="platform-recommend-work-meta__stats">
{statItems.map(({ label, value, icon: Icon }) => (
<span
key={label}
className="platform-recommend-work-meta__stat"
aria-label={`${label} ${formatCompactCount(value)}`}
>
<Icon className="h-4 w-4" aria-hidden="true" />
<span>{formatCompactCount(value)}</span>
</span>
))}
</div>
<div className="platform-recommend-work-meta__row">
<div
className="platform-recommend-work-meta__identity"
@@ -894,6 +905,62 @@ function RecommendRuntimeMeta({
</span>
</span>
</div>
<div className="platform-recommend-work-meta__actions">
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--like"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onLike?.();
}}
disabled={!isActive || !onLike}
aria-label={`点赞 ${formatCompactCount(likeCount)}`}
title="点赞"
>
<ThumbsUp className="h-5 w-5" aria-hidden="true" />
</button>
<span
className="platform-recommend-work-meta__like-count"
aria-label={`${formatCompactCount(likeCount)} 个赞`}
>
{formatCompactCount(likeCount)}
</span>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onShare?.();
}}
disabled={!isActive || !onShare}
aria-label={
shareState === 'copied'
? '分享内容已复制'
: shareState === 'failed'
? '分享内容复制失败'
: '分享'
}
title="分享"
>
<Share2 className="h-5 w-5" aria-hidden="true" />
</button>
<button
type="button"
className="platform-recommend-work-meta__action platform-recommend-work-meta__action--icon platform-recommend-work-meta__action--remix"
onPointerDown={stopActionPointer}
onClick={(event) => {
event.stopPropagation();
onRemix?.();
}}
disabled={!isActive || !onRemix}
aria-label={`改造 ${formatCompactCount(remixCount)}`}
title="改造"
>
<GitFork className="h-5 w-5" aria-hidden="true" />
</button>
</div>
</div>
</section>
);
@@ -2977,6 +3044,8 @@ export function RpgEntryHomeView({
recommendRuntimeError = null,
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
onLikeRecommendEntry,
onRemixRecommendEntry,
onOpenLibraryDetail,
onDeleteLibraryEntry,
deletingLibraryEntryId = null,
@@ -3863,6 +3932,10 @@ export function RpgEntryHomeView({
const [recommendDragOffsetY, setRecommendDragOffsetY] = useState(0);
const [recommendDragCommitDirection, setRecommendDragCommitDirection] =
useState<1 | -1 | null>(null);
const [recommendShareState, setRecommendShareState] = useState<
'idle' | 'copied' | 'failed'
>('idle');
const recommendShareResetTimerRef = useRef<number | null>(null);
const recommendCardStageRef = useRef<HTMLDivElement | null>(null);
const recommendDragStartRef = useRef<{
pointerId: number;
@@ -4005,6 +4078,36 @@ export function RpgEntryHomeView({
onSelectNextRecommendEntry,
recommendedFeedEntries.length,
]);
useEffect(
() => () => {
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
},
[],
);
useEffect(() => {
setRecommendShareState('idle');
}, [activeRecommendEntryKey]);
const shareRecommendEntry = useCallback((entry: PlatformPublicGalleryCard) => {
const publicWorkCode = resolvePlatformPublicWorkCode(entry)?.trim();
if (!publicWorkCode) {
setRecommendShareState('failed');
return;
}
const shareText = `邀请你来玩《${entry.worldName}\n作品号${publicWorkCode}\n${buildPublicWorkDetailUrl(publicWorkCode)}`;
void copyTextToClipboard(shareText).then((copied) => {
setRecommendShareState(copied ? 'copied' : 'failed');
if (recommendShareResetTimerRef.current !== null) {
window.clearTimeout(recommendShareResetTimerRef.current);
}
recommendShareResetTimerRef.current = window.setTimeout(() => {
recommendShareResetTimerRef.current = null;
setRecommendShareState('idle');
}, 1400);
});
}, []);
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
@@ -4168,6 +4271,10 @@ export function RpgEntryHomeView({
onDragPointerMove={moveRecommendDrag}
onDragPointerUp={endRecommendDrag}
onDragPointerCancel={cancelRecommendDrag}
shareState={recommendShareState}
onLike={() => onLikeRecommendEntry?.(activeRecommendEntry)}
onShare={() => shareRecommendEntry(activeRecommendEntry)}
onRemix={() => onRemixRecommendEntry?.(activeRecommendEntry)}
/>
</div>