This commit is contained in:
2026-05-09 17:15:23 +08:00
parent 80a4183b45
commit a0ed128bde
43 changed files with 2573 additions and 381 deletions

View File

@@ -319,6 +319,7 @@ type PuzzleRuntimeReturnStage =
| 'puzzle-gallery-detail'
| 'work-detail'
| 'platform';
type PuzzleRuntimeAuthMode = 'default' | 'isolated';
type PuzzleOnboardingPhase = 'input' | 'generating' | 'generated';
@@ -387,6 +388,8 @@ const RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS = {
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
const PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS =
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS;
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
const rawTime = entry.publishedAt ?? entry.updatedAt;
@@ -1568,6 +1571,8 @@ export function PlatformEntryFlowShellImpl({
useState<PuzzleDetailReturnTarget | null>(null);
const [puzzleRuntimeReturnStage, setPuzzleRuntimeReturnStage] =
useState<PuzzleRuntimeReturnStage>('puzzle-gallery-detail');
const [puzzleRuntimeAuthMode, setPuzzleRuntimeAuthMode] =
useState<PuzzleRuntimeAuthMode>('default');
const [isPuzzleLeaderboardBusy, setIsPuzzleLeaderboardBusy] = useState(false);
const submittedPuzzleLeaderboardKeysRef = useRef(new Set<string>());
const [puzzleRun, setPuzzleRun] = useState<PuzzleRunSnapshot | null>(null);
@@ -2230,6 +2235,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleWorks((current) => [response.item, ...current]);
setSelectedPuzzleDetail(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleOnboardingDraft(null);
setPuzzleOnboardingPrompt('');
setPuzzleOnboardingPhase('input');
@@ -2268,10 +2274,11 @@ export function PlatformEntryFlowShellImpl({
setPuzzleOnboardingPhase('input');
setPuzzleOnboardingError(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setSelectedPuzzleDetail(null);
platformBootstrap.setPlatformTab('home');
platformBootstrap.setPlatformTab(authUi?.user ? 'home' : 'category');
setSelectionStage('platform');
}, [platformBootstrap, setSelectionStage]);
}, [authUi?.user, platformBootstrap, setSelectionStage]);
useEffect(() => {
if (
@@ -2321,6 +2328,7 @@ export function PlatformEntryFlowShellImpl({
markPuzzleOnboardingSeen();
window.setTimeout(() => {
setPuzzleRun(startLocalPuzzleRun(item));
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('platform');
setSelectionStage('puzzle-runtime');
}, PUZZLE_ONBOARDING_GENERATED_DELAY_MS);
@@ -2953,6 +2961,7 @@ export function PlatformEntryFlowShellImpl({
const openPuzzleAgentWorkspace = useCallback(async () => {
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
@@ -2997,6 +3006,7 @@ export function PlatformEntryFlowShellImpl({
}
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleOperation(null);
setPuzzleGenerationState(null);
setPuzzleFormDraftPayload(null);
@@ -3137,6 +3147,7 @@ export function PlatformEntryFlowShellImpl({
setSelectedPuzzleDetail(null);
setPuzzleRuntimeReturnStage('puzzle-gallery-detail');
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setPuzzleShelfError(null);
@@ -3283,6 +3294,7 @@ export function PlatformEntryFlowShellImpl({
const leavePuzzleFlow = useCallback(() => {
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setPuzzleGenerationState(null);
setIsPuzzleNextLevelGenerating(false);
setActiveCreativeAgentSessionId(null);
@@ -3773,6 +3785,7 @@ export function PlatformEntryFlowShellImpl({
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
enterCreateTab();
setActiveCreativeAgentSessionId(creativeAgentSession.sessionId);
setCreativeDraftEditError(null);
@@ -4007,7 +4020,7 @@ export function PlatformEntryFlowShellImpl({
detailItem?: PuzzleWorkSummary,
mirrorErrorToPublicDetail = false,
levelId?: string | null,
options: { embedded?: boolean } = {},
options: { embedded?: boolean; authMode?: PuzzleRuntimeAuthMode } = {},
) => {
if (isPuzzleBusy) {
return false;
@@ -4023,14 +4036,19 @@ export function PlatformEntryFlowShellImpl({
profileId: item.profileId,
levelId: levelId ?? null,
};
const { run } = options.embedded
? await startPuzzleRun(
startRunPayload,
RECOMMEND_RUNTIME_BACKGROUND_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
const authMode = options.embedded
? 'isolated'
: (options.authMode ?? 'default');
const { run } =
authMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode(authMode);
setPuzzleRuntimeReturnStage(returnStage);
if (!options.embedded) {
setSelectionStage('puzzle-runtime');
@@ -4255,6 +4273,7 @@ export function PlatformEntryFlowShellImpl({
const run = startLocalPuzzleRun(item);
setSelectedPuzzleDetail(item);
setPuzzleRun(run);
setPuzzleRuntimeAuthMode('default');
setPuzzleRuntimeReturnStage('puzzle-result');
setSelectionStage('puzzle-runtime');
} catch (error) {
@@ -4496,10 +4515,17 @@ export function PlatformEntryFlowShellImpl({
: await getPuzzleGalleryDetail(currentLevel.profileId).then(
(response) => response.item,
);
const { run } = await startPuzzleRun({
const startRunPayload = {
profileId: currentLevel.profileId,
levelId: resolvePuzzleRestartLevelId(currentRun, detailItem),
});
};
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await startPuzzleRun(
startRunPayload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await startPuzzleRun(startRunPayload);
setSelectedPuzzleDetail(detailItem);
puzzleRunRef.current = run;
setPuzzleRun(run);
@@ -4513,6 +4539,7 @@ export function PlatformEntryFlowShellImpl({
}, [
isPuzzleBusy,
puzzleRun,
puzzleRuntimeAuthMode,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -4618,7 +4645,16 @@ export function PlatformEntryFlowShellImpl({
return;
}
void submitPuzzleLeaderboard(puzzleRun.runId, payload)
const submitLeaderboardPromise =
puzzleRuntimeAuthMode === 'isolated'
? submitPuzzleLeaderboard(
puzzleRun.runId,
payload,
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: submitPuzzleLeaderboard(puzzleRun.runId, payload);
void submitLeaderboardPromise
.then(({ run }) => {
setPuzzleRun((currentRun) => {
if (!currentRun) {
@@ -4641,6 +4677,7 @@ export function PlatformEntryFlowShellImpl({
authUi?.user?.displayName,
platformBootstrap,
puzzleRun,
puzzleRuntimeAuthMode,
resolvePuzzleErrorMessage,
setPuzzleError,
]);
@@ -4678,10 +4715,20 @@ export function PlatformEntryFlowShellImpl({
: getPuzzleGalleryDetail(targetProfileId).then(
(response) => response.item,
);
const advancePromise =
puzzleRuntimeAuthMode === 'isolated'
? advancePuzzleNextLevel(
puzzleRun.runId,
{
targetProfileId,
},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
});
const [{ run }, item] = await Promise.all([
advancePuzzleNextLevel(puzzleRun.runId, {
targetProfileId,
}),
advancePromise,
itemPromise,
]);
setSelectedPuzzleDetail(item);
@@ -4695,7 +4742,14 @@ export function PlatformEntryFlowShellImpl({
return;
}
const { run } = await advancePuzzleNextLevel(puzzleRun.runId);
const { run } =
puzzleRuntimeAuthMode === 'isolated'
? await advancePuzzleNextLevel(
puzzleRun.runId,
{},
PUBLIC_PUZZLE_RUNTIME_AUTH_OPTIONS,
)
: await advancePuzzleNextLevel(puzzleRun.runId);
setPuzzleRun(run);
} catch (error) {
setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。'));
@@ -4708,6 +4762,7 @@ export function PlatformEntryFlowShellImpl({
isPuzzleBusy,
isPuzzleLeaderboardBusy,
puzzleRun,
puzzleRuntimeAuthMode,
resolvePuzzleErrorMessage,
selectedPuzzleDetail,
setIsPuzzleBusy,
@@ -4732,6 +4787,7 @@ export function PlatformEntryFlowShellImpl({
puzzleFlow.setSession(response.session);
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
enterCreateTab();
setSelectionStage('puzzle-result');
})
@@ -4777,6 +4833,7 @@ export function PlatformEntryFlowShellImpl({
: currentRun;
puzzleRunRef.current = null;
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setActiveRecommendRuntimeKind(null);
if (closedRun.currentLevel) {
@@ -5596,34 +5653,36 @@ export function PlatformEntryFlowShellImpl({
const openRecommendGalleryDetail = useCallback(
(entry: PlatformPublicGalleryCard) => {
if (isBigFishGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
runProtectedAction(() => {
if (isBigFishGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
if (isPuzzleGalleryEntry(entry)) {
void openPuzzlePublicWorkDetail(entry.profileId, {
tab: platformBootstrap.platformTab,
});
return;
}
if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isMatch3DGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isSquareHoleGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isSquareHoleGalleryEntry(entry)) {
openPublicWorkDetail(entry);
return;
}
if (isVisualNovelGalleryEntry(entry)) {
void openVisualNovelPublicWorkDetail(entry.profileId);
return;
}
if (isVisualNovelGalleryEntry(entry)) {
void openVisualNovelPublicWorkDetail(entry.profileId);
return;
}
void openRpgPublicWorkDetail(entry);
void openRpgPublicWorkDetail(entry);
});
},
[
openPuzzlePublicWorkDetail,
@@ -5631,6 +5690,7 @@ export function PlatformEntryFlowShellImpl({
openRpgPublicWorkDetail,
openVisualNovelPublicWorkDetail,
platformBootstrap.platformTab,
runProtectedAction,
],
);
const openPuzzleDetail = useCallback(
@@ -5673,6 +5733,7 @@ export function PlatformEntryFlowShellImpl({
async (item: PuzzleWorkSummary) => {
setPuzzleOperation(null);
setPuzzleRun(null);
setPuzzleRuntimeAuthMode('default');
setSelectedPuzzleDetail(null);
if (!item.sourceSessionId?.trim()) {
if (item.publicationStatus === 'published') {
@@ -5960,6 +6021,8 @@ export function PlatformEntryFlowShellImpl({
'work-detail',
work,
true,
null,
{ authMode: 'isolated' },
);
return;
}
@@ -6462,6 +6525,7 @@ export function PlatformEntryFlowShellImpl({
if (
selectionStage !== 'platform' ||
platformBootstrap.platformTab !== 'home' ||
!platformBootstrap.isAuthenticated ||
!platformBootstrap.canReadProtectedData ||
platformBootstrap.isLoadingPlatform
) {
@@ -6494,6 +6558,7 @@ export function PlatformEntryFlowShellImpl({
isStartingRecommendEntry,
platformBootstrap.canReadProtectedData,
platformBootstrap.isLoadingPlatform,
platformBootstrap.isAuthenticated,
platformBootstrap.platformTab,
recommendRuntimeEntries,
selectRecommendRuntimeEntry,
@@ -8594,6 +8659,9 @@ export function PlatformEntryFlowShellImpl({
selectedPuzzleDetail.profileId,
'puzzle-gallery-detail',
selectedPuzzleDetail,
false,
null,
{ authMode: 'isolated' },
);
}}
/>

View File

@@ -442,6 +442,40 @@ describe('PuzzleResultView', () => {
]);
});
test('keeps publish dialog open and shows backend publish error', () => {
const onExecuteAction = vi.fn();
const { rerender } = render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '发布拼图作品' });
fireEvent.click(
within(dialog).getByRole('button', { name: '发布到广场' }),
);
rerender(
<PuzzleResultView
session={createSession()}
error="光点余额不足"
isBusy={false}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
const publishDialog = screen.getByRole('dialog', {
name: '发布拼图作品',
});
expect(publishDialog).toBeTruthy();
expect(within(publishDialog).getByText('光点余额不足')).toBeTruthy();
});
test('generates six tags after work title and description are filled', () => {
const onExecuteAction = vi.fn();

View File

@@ -1014,6 +1014,7 @@ function PuzzleLevelDetailDialog({
}
function PuzzlePublishDialog({
actionError,
blockers,
editState,
imageRefreshKey,
@@ -1022,6 +1023,7 @@ function PuzzlePublishDialog({
onClose,
onPublish,
}: {
actionError: string | null;
blockers: string[];
editState: DraftEditState;
imageRefreshKey: string;
@@ -1076,7 +1078,11 @@ function PuzzlePublishDialog({
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
{publishReady ? (
{actionError ? (
<div className="platform-banner platform-banner--danger text-sm leading-6">
{actionError}
</div>
) : publishReady ? (
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
@@ -1361,6 +1367,7 @@ function PuzzleWorkInfoTab({
}
function PuzzleResultActionBar({
actionError,
editState,
imageRefreshKey,
isBusy,
@@ -1368,6 +1375,7 @@ function PuzzleResultActionBar({
publishBlockers,
onPublish,
}: {
actionError: string | null;
editState: DraftEditState;
imageRefreshKey: string;
isBusy: boolean;
@@ -1376,12 +1384,21 @@ function PuzzleResultActionBar({
onPublish: () => void;
}) {
const [showPublishDialog, setShowPublishDialog] = useState(false);
const [hasAttemptedPublish, setHasAttemptedPublish] = useState(false);
const closePublishDialog = () => {
setHasAttemptedPublish(false);
setShowPublishDialog(false);
};
return (
<div className="mt-4 flex items-center justify-end gap-3 pb-[max(0.25rem,env(safe-area-inset-bottom))]">
<button
type="button"
onClick={() => setShowPublishDialog(true)}
onClick={() => {
setHasAttemptedPublish(false);
setShowPublishDialog(true);
}}
disabled={isBusy}
className={`platform-button platform-button--primary ${isBusy ? 'opacity-55' : ''}`}
>
@@ -1393,13 +1410,17 @@ function PuzzleResultActionBar({
{showPublishDialog ? (
<PuzzlePublishDialog
actionError={hasAttemptedPublish ? actionError : null}
blockers={publishBlockers}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}
publishReady={publishReady}
onClose={() => setShowPublishDialog(false)}
onPublish={onPublish}
onClose={closePublishDialog}
onPublish={() => {
setHasAttemptedPublish(true);
onPublish();
}}
/>
) : null}
</div>
@@ -1671,6 +1692,7 @@ export function PuzzleResultView({
) : null}
<PuzzleResultActionBar
actionError={error}
editState={editState}
imageRefreshKey={imageRefreshKey}
isBusy={isBusy}

View File

@@ -210,6 +210,12 @@ async function openExistingRpgDraft(
await user.click(await screen.findByRole('button', { name: actionName }));
}
const ISOLATED_RUNTIME_AUTH_OPTIONS = {
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
};
function getPlatformTabPanel(tab: string) {
const panel = document.getElementById(`platform-tab-panel-${tab}`);
if (!panel) {
@@ -3054,11 +3060,7 @@ test('home recommendation starts embedded puzzle without global auth reset on lo
profileId: 'puzzle-profile-public-1',
levelId: null,
},
{
skipRefresh: true,
notifyAuthStateChange: false,
clearAuthOnUnauthorized: false,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
});
@@ -3673,10 +3675,13 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
await waitFor(() => {
expect(screen.getByTestId('puzzle-board')).toBeTruthy();
});
expect(startPuzzleRun).toHaveBeenCalledWith({
profileId: 'puzzle-profile-public-1',
levelId: null,
});
expect(startPuzzleRun).toHaveBeenCalledWith(
{
profileId: 'puzzle-profile-public-1',
levelId: null,
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
await user.click(document.querySelector('[data-piece-id="piece-0"]')!);
await user.click(document.querySelector('[data-piece-id="piece-1"]')!);
@@ -3695,6 +3700,7 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
elapsedMs: 18_000,
nickname: '测试玩家',
},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
@@ -3711,6 +3717,8 @@ test('formal puzzle runtime uses frontend move merge logic and backend leaderboa
await waitFor(() => {
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedFirstLevel.runId,
{},
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(
@@ -3875,6 +3883,7 @@ test('formal puzzle similar work keeps current run level progression', async ()
expect(advancePuzzleNextLevel).toHaveBeenCalledWith(
clearedThirdLevel.runId,
{ targetProfileId: 'puzzle-profile-similar-2' },
ISOLATED_RUNTIME_AUTH_OPTIONS,
);
});
expect(startPuzzleRun).not.toHaveBeenCalled();

View File

@@ -310,16 +310,6 @@ const originalMatchMedia = window.matchMedia;
const originalRequestAnimationFrame = window.requestAnimationFrame;
const originalCancelAnimationFrame = window.cancelAnimationFrame;
function dispatchClientYPointerEvent(
target: HTMLElement,
type: string,
clientY: number,
) {
const event = new Event(type, { bubbles: true, cancelable: true });
Object.assign(event, { clientY });
target.dispatchEvent(event);
}
const puzzlePublicEntry = {
sourceType: 'puzzle',
workId: 'puzzle-work-public-1',
@@ -534,6 +524,7 @@ function renderLoggedOutHomeView(
| 'onSelectPreviousRecommendEntry'
>
> = {},
activeTab: RpgEntryHomeViewProps['activeTab'] = 'home',
) {
return render(
<AuthUiContext.Provider
@@ -556,7 +547,7 @@ function renderLoggedOutHomeView(
}}
>
<RpgEntryHomeView
activeTab="home"
activeTab={activeTab}
onTabChange={vi.fn()}
hasSavedGame={false}
savedSnapshot={null}
@@ -602,19 +593,27 @@ function renderStatefulLoggedOutHomeView(
| 'latestEntries'
| 'onOpenGalleryDetail'
| 'onSearchPublicCode'
| 'recommendRuntimeContent'
| 'activeRecommendEntryKey'
| 'onSelectNextRecommendEntry'
| 'onSelectPreviousRecommendEntry'
>
> = {},
) {
const authSpies = {
openLoginModal: vi.fn(),
};
function StatefulLoggedOutHomeView() {
const [activeTab, setActiveTab] =
useState<RpgEntryHomeViewProps['activeTab']>('home');
useState<RpgEntryHomeViewProps['activeTab']>('category');
return (
<AuthUiContext.Provider
value={{
user: null,
canAccessProtectedData: false,
openLoginModal: vi.fn(),
openLoginModal: authSpies.openLoginModal,
requireAuth: vi.fn(),
openSettingsModal: vi.fn(),
openAccountModal: vi.fn(),
@@ -651,7 +650,14 @@ function renderStatefulLoggedOutHomeView(
onOpenCreateWorld={vi.fn()}
onOpenCreateTypePicker={vi.fn()}
onOpenGalleryDetail={overrides.onOpenGalleryDetail ?? vi.fn()}
recommendRuntimeContent={<div data-testid="recommend-runtime" />}
recommendRuntimeContent={
overrides.recommendRuntimeContent ?? (
<div data-testid="recommend-runtime" />
)
}
activeRecommendEntryKey={overrides.activeRecommendEntryKey}
onSelectNextRecommendEntry={overrides.onSelectNextRecommendEntry}
onSelectPreviousRecommendEntry={overrides.onSelectPreviousRecommendEntry}
onOpenLibraryDetail={vi.fn()}
onSearchPublicCode={overrides.onSearchPublicCode ?? vi.fn()}
/>
@@ -659,7 +665,10 @@ function renderStatefulLoggedOutHomeView(
);
}
return render(<StatefulLoggedOutHomeView />);
return {
...render(<StatefulLoggedOutHomeView />),
openLoginModal: authSpies.openLoginModal,
};
}
afterEach(() => {
@@ -1111,35 +1120,80 @@ test('public gallery cards hide work code until detail is opened', async () => {
expect(onOpenGalleryDetail).toHaveBeenCalledWith(puzzlePublicEntry);
});
test('mobile recommend page renders runtime viewport without bottom work cards', () => {
test('logged out mobile shell defaults to discover tab', () => {
const { container } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
});
const activePanel = container.querySelector('.platform-tab-panel--active');
expect(activePanel?.id).toBe('platform-tab-panel-category');
expect(screen.getByPlaceholderText('搜索作品号、名称、作者、描述')).toBeTruthy();
});
test('logged out recommend tab opens login modal and shows cover only', async () => {
const user = userEvent.setup();
const { container, openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
});
const bottomNav = container.querySelector('.platform-bottom-nav');
if (!bottomNav) {
throw new Error('缺少底部导航');
}
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
expect(openLoginModal).toHaveBeenCalledTimes(1);
expect(container.querySelector('.platform-recommend-cover-only')).toBeTruthy();
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(screen.queryByLabelText('奇幻拼图 作品信息')).toBeNull();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
});
test('logged out recommend cover opens login modal again', async () => {
const user = userEvent.setup();
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
const { openLoginModal } = renderStatefulLoggedOutHomeView({
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onOpenGalleryDetail,
});
const bottomNav = document.querySelector('.platform-bottom-nav');
if (!bottomNav) {
throw new Error('缺少底部导航');
}
expect(screen.getByTestId('recommend-runtime')).toBeTruthy();
const runtimePanel = document.querySelector('.platform-recommend-runtime-panel');
expect(runtimePanel).toBeTruthy();
expect(runtimePanel?.className).not.toContain('bg-black');
expect(screen.queryByText('一张用于公开分享的拼图作品。')).toBeNull();
await user.click(
within(bottomNav as HTMLElement).getByRole('button', { name: '推荐' }),
);
await user.click(screen.getByRole('button', { name: / /u }));
expect(openLoginModal).toHaveBeenCalledTimes(2);
expect(openLoginModal).toHaveBeenLastCalledWith(expect.any(Function));
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
test('logged out mobile recommend page renders cover instead of runtime', () => {
const onOpenGalleryDetail = vi.fn();
renderLoggedOutHomeView(
vi.fn(),
{
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onOpenGalleryDetail,
},
'home',
);
expect(screen.queryByTestId('recommend-runtime')).toBeNull();
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
expect(
document.querySelector('.platform-public-work-card__cover'),
).toBeNull();
expect(screen.getByText('拼图玩家')).toBeTruthy();
expect(screen.getAllByText('奇幻拼图').length).toBeGreaterThan(0);
expect(screen.getAllByText('20').length).toBeGreaterThan(0);
expect(screen.getAllByText('12').length).toBeGreaterThan(0);
expect(screen.queryByRole('button', { name: '切换到 奇幻拼图' })).toBeNull();
expect(
screen.queryByRole('button', { name: '查看 奇幻拼图 详情' }),
).toBeNull();
expect(
screen.queryByRole('button', { name: '打开 奇幻拼图 详情' }),
).toBeNull();
expect(document.querySelector('.platform-recommend-switcher')).toBeNull();
fireEvent.click(screen.getByLabelText('奇幻拼图 作品信息'));
fireEvent.click(screen.getByRole('button', { name: / /u }));
expect(onOpenGalleryDetail).not.toHaveBeenCalled();
});
@@ -1151,38 +1205,15 @@ test('mobile recommend loading state is themed instead of hardcoded black', () =
recommendRuntimeContent: null,
});
const loadingState = screen.getByText('加载中...');
expect(loadingState.className).toContain('platform-recommend-runtime-state');
expect(loadingState.className).not.toContain('bg-black');
expect(document.querySelector('.platform-recommend-cover-only')).toBeTruthy();
});
test('mobile recommend meta swipes between public works', () => {
const onSelectNextRecommendEntry = vi.fn();
const onSelectPreviousRecommendEntry = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectNextRecommendEntry,
onSelectPreviousRecommendEntry,
});
const meta = screen.getByLabelText('奇幻拼图 作品信息');
dispatchClientYPointerEvent(meta, 'pointerdown', 240);
dispatchClientYPointerEvent(meta, 'pointerup', 180);
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(onSelectPreviousRecommendEntry).not.toHaveBeenCalled();
dispatchClientYPointerEvent(meta, 'pointerdown', 180);
dispatchClientYPointerEvent(meta, 'pointerup', 240);
expect(onSelectPreviousRecommendEntry).toHaveBeenCalledTimes(1);
});
test('active recommend bottom tab selects next work instead of navigating', async () => {
test('logged out active recommend bottom tab selects next work without login', async () => {
const user = userEvent.setup();
const onSelectNextRecommendEntry = vi.fn();
const openLoginModal = vi.fn();
renderLoggedOutHomeView(vi.fn(), {
renderLoggedOutHomeView(openLoginModal, {
latestEntries: [puzzlePublicEntry],
activeRecommendEntryKey: 'puzzle:user-2:puzzle-profile-public-1',
onSelectNextRecommendEntry,
@@ -1191,6 +1222,7 @@ test('active recommend bottom tab selects next work instead of navigating', asyn
await user.click(screen.getByRole('button', { name: '下一个' }));
expect(onSelectNextRecommendEntry).toHaveBeenCalledTimes(1);
expect(openLoginModal).not.toHaveBeenCalled();
});
test('mobile recommend meta loads real author avatar from public user summary', async () => {
@@ -1210,7 +1242,7 @@ test('mobile recommend meta loads real author avatar from public user summary',
await waitFor(() => {
expect(
document
.querySelector('.platform-recommend-work-meta__avatar img')
.querySelector('.platform-recommend-cover-only__author img')
?.getAttribute('src'),
).toBe('data:image/png;base64,AUTHOR');
});

View File

@@ -561,6 +561,66 @@ function WorldCard({
);
}
function RecommendCoverOnlyCard({
entry,
authorAvatarUrl,
onClick,
}: {
entry: PlatformPublicGalleryCard;
authorAvatarUrl?: string | null;
onClick: () => void;
}) {
const coverImage = resolvePlatformWorldCoverImage(entry);
const displayName = formatPlatformWorkDisplayName(entry.worldName);
const typeLabel = describePublicGalleryCardKind(entry);
const authorName = entry.authorDisplayName.trim() || '玩家';
const authorAvatarLabel = getPublicAuthorAvatarLabel(authorName);
const normalizedAuthorAvatarUrl = authorAvatarUrl?.trim() ?? '';
return (
<button
type="button"
onClick={onClick}
aria-label={`登录后游玩 ${entry.worldName}`}
className="platform-recommend-cover-only"
>
{coverImage ? (
<ResolvedAssetBackdrop
src={coverImage}
alt={entry.worldName}
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_16%,rgba(255,255,255,0.28),transparent_30%),linear-gradient(135deg,rgba(255,118,117,0.42),rgba(89,164,255,0.34))]" />
)}
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(0,0,0,0.04),rgba(0,0,0,0.42))]" />
<div className="platform-recommend-cover-only__body">
<span className="platform-public-work-card__kind">{typeLabel}</span>
<span className="platform-recommend-cover-only__title">
{displayName}
</span>
<span className="platform-recommend-cover-only__author">
<span
aria-hidden="true"
className="platform-public-work-card__author-avatar"
>
{normalizedAuthorAvatarUrl ? (
<img
src={normalizedAuthorAvatarUrl}
alt=""
className="platform-public-work-card__author-avatar-image"
/>
) : (
authorAvatarLabel
)}
</span>
<span className="truncate">{authorName}</span>
</span>
</div>
</button>
);
}
function CreationLibraryCard({
entry,
onClick,
@@ -3049,9 +3109,9 @@ export function RpgEntryHomeView({
useEffect(() => {
if (!visibleTabs.includes(activeTab)) {
onTabChange('home');
onTabChange(isAuthenticated ? 'home' : 'category');
}
}, [activeTab, onTabChange, visibleTabs]);
}, [activeTab, isAuthenticated, onTabChange, visibleTabs]);
useEffect(() => {
setVisitedTabs((currentTabs) => {
@@ -3705,6 +3765,18 @@ export function RpgEntryHomeView({
) ??
recommendedFeedEntries[0] ??
null;
const openActiveRecommendEntry = useCallback(() => {
if (!activeRecommendEntry) {
return;
}
if (!isAuthenticated) {
authUi?.openLoginModal(() => onOpenGalleryDetail(activeRecommendEntry));
return;
}
onOpenGalleryDetail(activeRecommendEntry);
}, [activeRecommendEntry, authUi, isAuthenticated, onOpenGalleryDetail]);
const selectNextRecommendEntry = useCallback(() => {
onSelectNextRecommendEntry?.();
}, [onSelectNextRecommendEntry]);
@@ -3787,6 +3859,12 @@ export function RpgEntryHomeView({
<div className="platform-recommend-runtime-state">
...
</div>
) : !isAuthenticated && activeRecommendEntry ? (
<RecommendCoverOnlyCard
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
onClick={openActiveRecommendEntry}
/>
) : recommendRuntimeError ? (
<button
type="button"
@@ -3808,7 +3886,7 @@ export function RpgEntryHomeView({
)}
</section>
{activeRecommendEntry ? (
{activeRecommendEntry && isAuthenticated ? (
<RecommendRuntimeMeta
entry={activeRecommendEntry}
authorAvatarUrl={getPublicEntryAuthorAvatarUrl(activeRecommendEntry)}
@@ -4685,6 +4763,12 @@ export function RpgEntryHomeView({
return;
}
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
@@ -4818,7 +4902,15 @@ export function RpgEntryHomeView({
label={tabLabels[tab]}
icon={tabIcons[tab]}
emphasized={tab === 'create'}
onClick={() => onTabChange(tab)}
onClick={() => {
if (!isAuthenticated && tab === 'home') {
onTabChange(tab);
authUi?.openLoginModal();
return;
}
onTabChange(tab);
}}
/>
))}
</aside>

View File

@@ -66,7 +66,8 @@ export function useRpgEntryBootstrap(
PlatformBrowseHistoryEntry[]
>([]);
const [saveEntries, setSaveEntries] = useState<ProfileSaveArchiveSummary[]>([]);
const [platformTab, setPlatformTabState] = useState<PlatformHomeTab>('home');
const [platformTab, setPlatformTabState] =
useState<PlatformHomeTab>('category');
const [platformError, setPlatformError] = useState<string | null>(null);
const [dashboardError, setDashboardError] = useState<string | null>(null);
const [historyError, setHistoryError] = useState<string | null>(null);
@@ -329,8 +330,8 @@ export function useRpgEntryBootstrap(
!hasInitialAgentSession &&
!hasExplicitPlatformTabSelectionRef.current
) {
// 中文注释:saves 现在承载草稿列表,存档入口已并入“我的-玩过”,默认仍回到推荐页
setPlatformTabState('home');
// 中文注释:新用户先进入发现页;推荐页只在用户主动点击后作为登录门禁入口
setPlatformTabState(isAuthenticated ? 'home' : 'category');
}
} finally {
if (isActive) {