Merge remote-tracking branch 'origin/master' into feat/game-fork
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 7m31s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 6m20s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 4m3s
Project CI / Frontend tests (pull_request) Successful in 2m54s
Project CI / Backend tests (pull_request) Successful in 7m36s
Project CI / AI game creator shell web tests (pull_request) Successful in 3m26s
Project CI / Repository checks (pull_request) Successful in 6m25s
Project CI / Native shell tests (pull_request) Successful in 9m34s
# Conflicts: # deploy/container/nginx.conf # deploy/nginx/genarrative-dev-http.conf # deploy/nginx/genarrative.conf # server-rs/crates/api-server/src/modules/game_distribution.rs # src/components/game-distribution/GameDetailPage.tsx # src/components/game-distribution/GameDistributionPages.test.tsx # src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
@@ -12,7 +12,10 @@ import { type ReactElement, type ReactNode, useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { AuthUser } from '../../../packages/shared/src/contracts/auth';
|
||||
import { pushAppHistoryPath } from '../../routing/activeAppPageRoutes';
|
||||
import {
|
||||
hasAppHistoryBackEntry,
|
||||
pushAppHistoryPath,
|
||||
} from '../../routing/activeAppPageRoutes';
|
||||
import {
|
||||
usePlatformWalletLifecycle,
|
||||
usePlatformWalletStore,
|
||||
@@ -65,6 +68,16 @@ const gameDistributionMock = vi.hoisted(() => ({
|
||||
|
||||
vi.mock('../../services/gameDistributionClient', () => gameDistributionMock);
|
||||
|
||||
const creatorClientMock = vi.hoisted(() => ({
|
||||
getCreator: vi.fn(),
|
||||
getCreatorConnections: vi.fn(),
|
||||
getCreatorRelationship: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../services/creatorProfileClient', async (original) => ({
|
||||
...(await original<typeof import('../../services/creatorProfileClient')>()),
|
||||
...creatorClientMock,
|
||||
}));
|
||||
|
||||
const frontendRuntimeConfigMock = vi.hoisted(() => ({
|
||||
loadFrontendRuntimeConfig: vi.fn(),
|
||||
}));
|
||||
@@ -458,7 +471,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['创作', '项目', '游戏', '我的']);
|
||||
).toEqual(['创作', '项目', '游戏', '创作者主页', '我的']);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '创作' })
|
||||
@@ -524,7 +537,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['游戏', '我的']);
|
||||
).toEqual(['游戏', '创作者主页', '我的']);
|
||||
expect(await screen.findByRole('main', { name: '我的' })).toBeTruthy();
|
||||
expect(screen.getByRole('button', { name: '下载客户端' })).toBeTruthy();
|
||||
expect(
|
||||
@@ -609,7 +622,7 @@ describe('PlatformEntryActiveFlowShell', () => {
|
||||
within(navigation)
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.getAttribute('aria-label')),
|
||||
).toEqual(['游戏', '我的']);
|
||||
).toEqual(['游戏', '创作者主页', '我的']);
|
||||
expect(
|
||||
within(navigation)
|
||||
.getByRole('button', { name: '我的' })
|
||||
@@ -946,6 +959,136 @@ describe('游戏发布灰度入口', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('创作者页面深链与返回', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(Element.prototype, 'scrollTo', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
authUiMock.value.user = {
|
||||
id: 'user-1',
|
||||
publicUserCode: '100001',
|
||||
displayName: '测试用户',
|
||||
avatarUrl: null,
|
||||
phoneNumberMasked: null,
|
||||
loginMethod: 'password',
|
||||
bindingStatus: 'active',
|
||||
wechatBound: false,
|
||||
};
|
||||
authUiMock.value.canAccessProtectedData = true;
|
||||
gameDistributionMock.listGames.mockResolvedValue([]);
|
||||
creatorClientMock.getCreator.mockResolvedValue({
|
||||
user: authUiMock.value.user,
|
||||
followingCount: 0,
|
||||
followerCount: 0,
|
||||
});
|
||||
creatorClientMock.getCreatorConnections.mockResolvedValue({
|
||||
items: [],
|
||||
nextCursor: null,
|
||||
total: 0,
|
||||
});
|
||||
creatorClientMock.getCreatorRelationship.mockResolvedValue({
|
||||
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['', '&tab=', '&tab=invalid'])(
|
||||
'关系列表拒绝无效 tab:%s,不请求列表且仍可返回',
|
||||
async (query) => {
|
||||
creatorClientMock.getCreatorConnections.mockClear();
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/creators/connections?id=user-2${query}`,
|
||||
);
|
||||
render(
|
||||
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
|
||||
);
|
||||
expect((await screen.findByRole('alert')).textContent).toBe(
|
||||
'无效的关系列表链接',
|
||||
);
|
||||
expect(creatorClientMock.getCreatorConnections).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
||||
await screen.findByRole('heading', { name: '创作者主页' });
|
||||
expect(window.location.pathname + window.location.search).toBe(
|
||||
'/creators?id=user-2',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['/creators', '/creators?id=user-1', '/creators?id=user-2'])(
|
||||
'直接打开 %s 后返回替换为游戏广场,不增加历史',
|
||||
async (path) => {
|
||||
window.history.replaceState(null, '', path);
|
||||
const historyLength = window.history.length;
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creator-home"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
expect(window.location.pathname).toBe('/games');
|
||||
expect(window.location.search).toBe('');
|
||||
expect(window.history.length).toBe(historyLength);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('games', {
|
||||
path: '/games',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['following', 'followers'])(
|
||||
'直接打开 %s 列表连续返回:所属主页、游戏广场,均不增加历史',
|
||||
async (tab) => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/creators/connections?id=user-2&tab=${tab}`,
|
||||
);
|
||||
const historyLength = window.history.length;
|
||||
render(
|
||||
<StatefulPlatformEntryFlowShell initialStage="creator-connections" />,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
await screen.findByRole('heading', { name: '创作者主页' });
|
||||
expect(window.location.pathname + window.location.search).toBe(
|
||||
'/creators?id=user-2',
|
||||
);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回' }));
|
||||
expect(window.location.pathname).toBe('/games');
|
||||
expect(window.history.length).toBe(historyLength);
|
||||
expect(hasAppHistoryBackEntry()).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
'/games/detail?id=game-1',
|
||||
'/creators/connections?id=user-1&tab=following',
|
||||
])('从 %s 进入主页后使用原生返回,不覆盖来源', async (source) => {
|
||||
window.history.replaceState(null, '', source);
|
||||
pushAppHistoryPath('/creators?id=user-2');
|
||||
const back = vi.spyOn(window.history, 'back').mockImplementation(() => {});
|
||||
try {
|
||||
const setSelectionStage = vi.fn();
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="creator-home"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole('button', { name: '返回' }));
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
expect(setSelectionStage).not.toHaveBeenCalled();
|
||||
expect(window.location.search).toBe('?id=user-2');
|
||||
} finally {
|
||||
back.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('游戏详情返回', () => {
|
||||
function mockDetailGame() {
|
||||
gameDistributionMock.getGame.mockResolvedValue({
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Palette,
|
||||
Search,
|
||||
UserRound,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
type ComponentType,
|
||||
@@ -27,6 +28,11 @@ import {
|
||||
pushAppHistoryPath,
|
||||
replaceAppHistoryPath,
|
||||
} from '../../routing/activeAppPageRoutes';
|
||||
import {
|
||||
connectionsPath,
|
||||
type CreatorConnectionKind,
|
||||
creatorPath,
|
||||
} from '../../services/creatorProfileClient';
|
||||
import { loadFrontendRuntimeConfig } from '../../services/frontendRuntimeConfigService';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { usePlatformWalletStore } from '../../stores/usePlatformWalletStore';
|
||||
@@ -35,6 +41,7 @@ import { FLOATING_FEEDBACK_FORM_URL } from '../common/floatingFeedbackEntryModel
|
||||
import { PlatformActionButton } from '../common/PlatformActionButton';
|
||||
import { PlatformSubpanel } from '../common/PlatformSubpanel';
|
||||
import { ClientDownloadEntry } from '../creation-home/ClientDownloadEntry';
|
||||
import { setCreatorViewer } from '../creator/creatorRelationshipState';
|
||||
import { PaymentCheckoutView } from './PaymentCheckoutView';
|
||||
import {
|
||||
PlatformActiveMobileWelcomeDialog,
|
||||
@@ -80,6 +87,14 @@ const GameGalleryPage = lazy(async () => {
|
||||
return { default: module.GameGalleryPage };
|
||||
});
|
||||
|
||||
const CreatorPage = lazy(async () => {
|
||||
const module = await import('../creator/CreatorPage');
|
||||
return { default: module.CreatorPage };
|
||||
});
|
||||
const CreatorConnectionsPage = lazy(async () => {
|
||||
const module = await import('../creator/CreatorConnectionsPage');
|
||||
return { default: module.CreatorConnectionsPage };
|
||||
});
|
||||
const GameDetailPage = lazy(async () => {
|
||||
const module = await import('../game-distribution/GameDetailPage');
|
||||
return { default: module.GameDetailPage };
|
||||
@@ -186,16 +201,20 @@ function MobileProfileDock({
|
||||
onOpenProfile,
|
||||
gameActive,
|
||||
onOpenGames,
|
||||
creatorActive,
|
||||
onOpenCreator,
|
||||
}: {
|
||||
active: boolean;
|
||||
onOpenProfile: () => void;
|
||||
gameActive: boolean;
|
||||
onOpenGames: () => void;
|
||||
creatorActive: boolean;
|
||||
onOpenCreator: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="platform-mobile-bottom-dock min-w-0 shrink-0 lg:hidden">
|
||||
<nav
|
||||
className="platform-bottom-nav grid grid-cols-2"
|
||||
className="platform-bottom-nav grid grid-cols-3"
|
||||
aria-label="移动平台导航"
|
||||
>
|
||||
<ActiveBottomNavButton
|
||||
@@ -204,6 +223,12 @@ function MobileProfileDock({
|
||||
label="游戏"
|
||||
onClick={onOpenGames}
|
||||
/>
|
||||
<ActiveBottomNavButton
|
||||
active={creatorActive}
|
||||
icon={UsersRound}
|
||||
label="创作者主页"
|
||||
onClick={onOpenCreator}
|
||||
/>
|
||||
<ActiveBottomNavButton
|
||||
active={active}
|
||||
icon={UserRound}
|
||||
@@ -415,6 +440,42 @@ export function PlatformEntryFlowShellImpl({
|
||||
setSelectionStage('profile-payment', { path: '/profile/payment' });
|
||||
}, [setSelectionStage]);
|
||||
|
||||
const creatorViewer = authUi?.canAccessProtectedData
|
||||
? (authUi.user?.id ?? null)
|
||||
: null;
|
||||
useEffect(() => {
|
||||
setCreatorViewer(creatorViewer);
|
||||
}, [creatorViewer]);
|
||||
const openCreator = useCallback(
|
||||
(id?: string) => {
|
||||
const path = id ? creatorPath(id) : '/creators';
|
||||
pushAppHistoryPath(path);
|
||||
setSelectionStage('creator-home', { path });
|
||||
},
|
||||
[setSelectionStage],
|
||||
);
|
||||
const openCreatorConnections = useCallback(
|
||||
(id: string, tab: CreatorConnectionKind) => {
|
||||
const path = connectionsPath(id, tab);
|
||||
pushAppHistoryPath(path);
|
||||
setSelectionStage('creator-connections', { path });
|
||||
},
|
||||
[setSelectionStage],
|
||||
);
|
||||
const backFromCreator = useCallback(() => {
|
||||
if (hasAppHistoryBackEntry()) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
const owner =
|
||||
selectionStage === 'creator-connections'
|
||||
? new URLSearchParams(window.location.search).get('id')
|
||||
: null;
|
||||
// 兜底返回替换当前条目,避免主页深链与本人主页互相新增历史。
|
||||
const path = owner ? creatorPath(owner) : '/games';
|
||||
replaceAppHistoryPath(path);
|
||||
setSelectionStage(owner ? 'creator-home' : 'games', { path });
|
||||
}, [selectionStage, setSelectionStage]);
|
||||
const openGames = useCallback(() => {
|
||||
setSelectionStage('games', { path: '/games' });
|
||||
}, [setSelectionStage]);
|
||||
@@ -592,6 +653,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
<MobileProfileDock
|
||||
active={false}
|
||||
gameActive={false}
|
||||
creatorActive={false}
|
||||
onOpenCreator={() => openCreator()}
|
||||
onOpenProfile={openProfile}
|
||||
onOpenGames={openGames}
|
||||
/>
|
||||
@@ -630,6 +693,9 @@ export function PlatformEntryFlowShellImpl({
|
||||
const isCreationStage =
|
||||
!isProfileStage &&
|
||||
(selectionStage === 'platform' || selectionStage === 'creation-home');
|
||||
const isCreatorStage =
|
||||
selectionStage === 'creator-home' ||
|
||||
selectionStage === 'creator-connections';
|
||||
const isGamesStage =
|
||||
selectionStage === 'games' ||
|
||||
selectionStage === 'game-detail' ||
|
||||
@@ -647,6 +713,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
);
|
||||
const gameId = gameSearchParams.get('id');
|
||||
const lineageHighlightGameId = gameSearchParams.get('from');
|
||||
const creatorConnectionTab = gameSearchParams.get('tab');
|
||||
const publishGameId = gameSearchParams.get('game');
|
||||
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
|
||||
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
|
||||
@@ -710,6 +777,13 @@ export function PlatformEntryFlowShellImpl({
|
||||
label="游戏"
|
||||
onClick={openGames}
|
||||
/>
|
||||
<ActiveRailButton
|
||||
active={isCreatorStage}
|
||||
icon={UsersRound}
|
||||
iconSrc="/creation-home/nav-profile.png"
|
||||
label="创作者主页"
|
||||
onClick={() => openCreator()}
|
||||
/>
|
||||
<ActiveRailButton
|
||||
active={isProfileStage}
|
||||
icon={UserRound}
|
||||
@@ -862,6 +936,32 @@ export function PlatformEntryFlowShellImpl({
|
||||
searchKeyword={activeSearchKeyword}
|
||||
/>
|
||||
</Suspense>
|
||||
) : isCreatorStage ? (
|
||||
<Suspense
|
||||
fallback={<LoadingPanel label="正在加载创作者主页…" />}
|
||||
>
|
||||
{selectionStage === 'creator-connections' ? (
|
||||
<CreatorConnectionsPage
|
||||
id={gameId}
|
||||
tab={
|
||||
creatorConnectionTab === 'following' ||
|
||||
creatorConnectionTab === 'followers'
|
||||
? creatorConnectionTab
|
||||
: null
|
||||
}
|
||||
onOpenCreator={openCreator}
|
||||
onOpenConnections={openCreatorConnections}
|
||||
onBack={backFromCreator}
|
||||
/>
|
||||
) : (
|
||||
<CreatorPage
|
||||
id={gameId}
|
||||
onOpenConnections={openCreatorConnections}
|
||||
onOpenGame={openGameDetail}
|
||||
onBack={backFromCreator}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
) : isGamesStage ? (
|
||||
<Suspense
|
||||
fallback={<LoadingPanel label="正在加载游戏广场..." />}
|
||||
@@ -900,6 +1000,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
) : selectionStage === 'game-detail' ? (
|
||||
<GameDetailPage
|
||||
gameId={gameId}
|
||||
onOpenCreator={openCreator}
|
||||
ownerView={gameSearchParams.get('owner') === '1'}
|
||||
onBack={backFromGameDetail}
|
||||
onPlay={openGamePlay}
|
||||
@@ -930,6 +1031,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
<MobileProfileDock
|
||||
active={isProfileStage}
|
||||
gameActive={isGamesStage}
|
||||
creatorActive={isCreatorStage}
|
||||
onOpenCreator={() => openCreator()}
|
||||
onOpenProfile={openProfile}
|
||||
onOpenGames={openGames}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,8 @@ export type SelectionStage =
|
||||
| 'platform'
|
||||
| 'creation-home'
|
||||
| 'project'
|
||||
| 'creator-home'
|
||||
| 'creator-connections'
|
||||
| 'profile'
|
||||
| 'profile-payment'
|
||||
| 'payment-checkout'
|
||||
|
||||
Reference in New Issue
Block a user