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

# 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:
2026-10-06 00:19:40 +08:00
131 changed files with 15727 additions and 5835 deletions
+4
View File
@@ -45,6 +45,7 @@ function resolveInitialAppSelectionStage() {
export default function App() {
const authUi = useAuthUi();
const hostNavigation = useHostNavigationCanGoBack();
const [, setLocationRevision] = useState(0);
const [, setHostRuntimeRevision] = useState(0);
const [selectionStage, setRawSelectionStage] = useState<SelectionStage>(
resolveInitialAppSelectionStage,
@@ -53,6 +54,7 @@ export default function App() {
const setSelectionStage = useCallback(
(stage: SelectionStage, options?: { path?: string }) => {
setRawSelectionStage(stage);
setLocationRevision((revision) => revision + 1);
pushAppHistoryPath(options?.path ?? resolvePathForSelectionStage(stage));
},
[],
@@ -70,6 +72,8 @@ export default function App() {
useEffect(() => {
const syncStageFromHistory = () => {
// 同一舞台切换用户、列表方向或浏览器返回也要重读 URL。
setLocationRevision((revision) => revision + 1);
if (
shouldRedirectEditorCanvasWithoutProject(
window.location.pathname,
@@ -0,0 +1,289 @@
import './creator.css';
import { CreatorUserRow } from '@genarrative/shared/components';
import { useCallback, useEffect, useRef, useState } from 'react';
import type {
CreatorConnection,
CreatorRelationship,
} from '../../../packages/shared/src/contracts/creator';
import { APP_PAGE_VIEW_STATE_KEY } from '../../routing/activeAppPageRoutes';
import {
connectionsPath,
type CreatorConnectionKind,
creatorPath,
getCreatorConnections,
} from '../../services/creatorProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { followCreatorLink } from './creatorNavigation';
import { CreatorRelationshipControl } from './CreatorRelationshipControl';
import { CREATOR_CHANGED_EVENT } from './creatorRelationshipState';
type Props = {
id: string | null;
tab: CreatorConnectionKind | null;
onOpenCreator: (id: string) => void;
onOpenConnections: (id: string, tab: CreatorConnectionKind) => void;
onBack: () => void;
};
export function CreatorConnectionsPage(props: Props) {
const auth = useAuthUi();
const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null;
if (!props.id || !props.tab)
return (
<div className="creator-page">
<button type="button" className="creator-follow" onClick={props.onBack}>
返回
</button>
<p role="alert">{!props.id ? '缺少创作者 ID' : '无效的关系列表链接'}</p>
</div>
);
return (
<ConnectionsContent
key={`${viewer}:${props.id}:${props.tab}`}
{...props}
id={props.id}
tab={props.tab}
viewer={viewer}
/>
);
}
function ConnectionsContent({
id,
tab,
viewer,
onOpenCreator,
onOpenConnections,
onBack,
}: Props & { id: string; tab: CreatorConnectionKind; viewer: string | null }) {
const own = viewer === id;
const root = useRef<HTMLElement>(null);
const locationKey = window.location.pathname + window.location.search;
const saved = useRef(window.history.state?.[APP_PAGE_VIEW_STATE_KEY]);
const restoration =
saved.current?.url === locationKey && saved.current?.viewer === viewer
? saved.current
: null;
const pages = useRef<number>(restoration?.pages ?? 1);
const scrollTop = useRef<number>(restoration?.scrollTop ?? 0);
const [items, setItems] = useState<CreatorConnection[]>([]);
const [total, setTotal] = useState<number | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const retained = useRef(new Map<string, CreatorConnection>());
const sequence = useRef(0);
const mounted = useRef(true);
const loaded = useRef(false);
const save = useCallback(() => {
if (window.location.pathname + window.location.search !== locationKey)
return;
window.history.replaceState(
{
...window.history.state,
[APP_PAGE_VIEW_STATE_KEY]: {
url: locationKey,
viewer,
pages: pages.current,
scrollTop: scrollTop.current,
},
},
'',
);
}, [locationKey, viewer]);
const load = useCallback(
async (wanted = pages.current, refresh = false) => {
if (!mounted.current) return;
const serial = ++sequence.current;
setLoading(true);
setError('');
if (refresh) retained.current.clear();
try {
let next: string | null = null;
const rows = new Map<string, CreatorConnection>();
let count = 0,
actual = 0;
for (let page = 0; page < wanted; page++) {
const result = await getCreatorConnections(
id,
tab,
next,
viewer !== null,
);
if (serial !== sequence.current) return;
for (const item of result.items) rows.set(item.user.id, item);
next = result.nextCursor;
count = result.total;
actual++;
if (!next) break;
}
setItems((previous) => {
const merged = [...rows.values()];
for (const [key, row] of retained.current) {
if (!rows.has(key))
merged.splice(
Math.max(
0,
previous.findIndex((item) => item.user.id === key),
),
0,
row,
);
}
return merged;
});
setTotal(count);
setCursor(next);
pages.current = actual;
if (!loaded.current) {
loaded.current = true;
requestAnimationFrame(() => {
if (serial === sequence.current)
root.current
?.closest('.platform-tab-panel')
?.scrollTo({ top: scrollTop.current });
});
}
save();
} catch (reason) {
if (serial === sequence.current)
setError(reason instanceof Error ? reason.message : '加载失败');
} finally {
if (serial === sequence.current) setLoading(false);
}
},
[id, tab, viewer, save],
);
useEffect(() => {
mounted.current = true;
void load();
const refresh = () => {
void load();
};
window.addEventListener('focus', refresh);
// 写后事件异步回读,先让行回调记录暂留状态。
const changed = () => {
queueMicrotask(refresh);
};
window.addEventListener(CREATOR_CHANGED_EVENT, changed);
const panel = root.current?.closest('.platform-tab-panel');
const scroll = () => {
scrollTop.current = panel?.scrollTop ?? 0;
save();
};
panel?.addEventListener('scroll', scroll);
const invalidate = () => {
sequence.current++;
};
return () => {
mounted.current = false;
invalidate();
window.removeEventListener('focus', refresh);
window.removeEventListener(CREATOR_CHANGED_EVENT, changed);
panel?.removeEventListener('scroll', scroll);
};
}, [load, save]);
const result = (row: CreatorConnection, value: CreatorRelationship) => {
if (!mounted.current) return;
if (tab === 'following') {
if (value.isFollowing) retained.current.delete(row.user.id);
else retained.current.set(row.user.id, { ...row, relationship: value });
}
if (tab === 'followers' && !value.isFollowedBy)
setItems((current) =>
current.filter((item) => item.user.id !== row.user.id),
);
void load();
};
return (
<section ref={root} className="creator-page">
<button className="creator-follow" onClick={onBack}>
返回
</button>
<h1>
{own ? '我的' : ''}
{tab === 'following' ? '关注' : '粉丝'}
{total !== null && ` · ${total}`}
</h1>
<nav className="creator-tabs" aria-label="关系列表">
{(['following', 'followers'] as const).map((kind) => (
<a
key={kind}
aria-current={kind === tab ? 'page' : undefined}
href={connectionsPath(id, kind)}
onClick={(event) =>
followCreatorLink(event, () => onOpenConnections(id, kind))
}
>
{kind === 'following' ? '关注' : '粉丝'}
</a>
))}
</nav>
<button
className="creator-follow"
disabled={loading}
onClick={() => {
void load(pages.current, true);
}}
>
刷新列表
</button>
{error && (
<p role="alert">
{error}{' '}
<button
className="creator-follow"
onClick={() => {
void load();
}}
>
重试
</button>
</p>
)}
<ul className="creator-list">
{items.map((row) => (
<li key={row.user.id}>
<CreatorUserRow
name={row.user.displayName}
avatarUrl={row.user.avatarUrl}
subtitle={`ID ${row.user.publicUserCode}`}
href={creatorPath(row.user.id)}
onClick={(event) =>
followCreatorLink(event, () => onOpenCreator(row.user.id))
}
actions={
own ? (
<CreatorRelationshipControl
target={row.user.id}
initial={row.relationship}
followedLabel="取消关注"
followLabel={tab === 'followers' ? '回关' : '重新关注'}
allowRemove={tab === 'followers'}
onResult={(value) => result(row, value)}
/>
) : undefined
}
/>
</li>
))}
</ul>
{loading ? (
<p role="status">正在加载列表…</p>
) : !error && items.length === 0 ? (
<p>{tab === 'following' ? '暂无关注' : '暂无粉丝'}</p>
) : null}
{cursor && (
<button
className="creator-follow"
disabled={loading}
onClick={() => {
void load(pages.current + 1);
}}
>
加载更多
</button>
)}
</section>
);
}
+179
View File
@@ -0,0 +1,179 @@
import '../game-distribution/gameDistribution.css';
import './creator.css';
import { CreatorUserRow, GameCard } from '@genarrative/shared/components';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { CreatorProfile } from '../../../packages/shared/src/contracts/creator';
import {
connectionsPath,
type CreatorConnectionKind,
getCreator,
} from '../../services/creatorProfileClient';
import {
type GameDistributionGame,
listGames,
} from '../../services/gameDistributionClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { GameCover } from '../game-distribution/GameCover';
import { followCreatorLink } from './creatorNavigation';
import { CreatorRelationshipControl } from './CreatorRelationshipControl';
import { CREATOR_CHANGED_EVENT } from './creatorRelationshipState';
export function CreatorPage({
id,
onOpenConnections,
onOpenGame,
onBack,
}: {
id: string | null;
onOpenConnections: (id: string, tab: CreatorConnectionKind) => void;
onOpenGame: (id: string) => void;
onBack: () => void;
}) {
const auth = useAuthUi();
const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null;
const target = id ?? viewer;
if (!target)
return (
<section className="creator-page">
<h1>创作者主页</h1>
<p>登录后查看自己的创作者主页。</p>
<button
className="creator-follow"
onClick={() => auth?.openLoginModal()}
>
登录
</button>
</section>
);
return (
<CreatorProfileContent
key={`${viewer}:${target}`}
id={target}
onOpenConnections={onOpenConnections}
onOpenGame={onOpenGame}
onBack={onBack}
/>
);
}
function CreatorProfileContent({
id,
onOpenConnections,
onOpenGame,
onBack,
}: {
id: string;
onOpenConnections: (id: string, tab: CreatorConnectionKind) => void;
onOpenGame: (id: string) => void;
onBack: () => void;
}) {
const [profile, setProfile] = useState<CreatorProfile | null>(null);
const [error, setError] = useState('');
const [games, setGames] = useState<GameDistributionGame[] | null>(null);
const [gameError, setGameError] = useState('');
const serial = useRef(0);
const load = useCallback(() => {
const request = ++serial.current;
setError('');
setGameError('');
void getCreator(id)
.then((value) => {
if (serial.current === request) setProfile(value);
})
.catch((reason) => {
if (serial.current === request) setError(reason.message);
});
void listGames({ authorId: id })
.then((value) => {
if (serial.current === request) setGames(value);
})
.catch((reason) => {
if (serial.current === request) setGameError(reason.message);
});
}, [id]);
useEffect(() => {
load();
window.addEventListener('focus', load);
window.addEventListener(CREATOR_CHANGED_EVENT, load);
const invalidate = () => {
serial.current++;
};
return () => {
invalidate();
window.removeEventListener('focus', load);
window.removeEventListener(CREATOR_CHANGED_EVENT, load);
};
}, [load]);
return (
<section className="creator-page">
<button className="creator-follow" onClick={onBack}>
返回
</button>
<h1>创作者主页</h1>
{error && (
<p role="alert">
{error}{' '}
<button className="creator-follow" onClick={load}>
重试
</button>
</p>
)}
{!profile && !error && <p role="status">正在加载创作者…</p>}
{profile && (
<>
<div className="creator-profile">
<CreatorUserRow
name={profile.user.displayName}
avatarUrl={profile.user.avatarUrl}
subtitle={`ID ${profile.user.publicUserCode}`}
actions={<CreatorRelationshipControl target={id} />}
/>
<nav className="creator-counts" aria-label="关注与粉丝">
{(['following', 'followers'] as const).map((tab) => (
<a
key={tab}
href={connectionsPath(id, tab)}
onClick={(event) =>
followCreatorLink(event, () => onOpenConnections(id, tab))
}
>
<strong>
{tab === 'following'
? profile.followingCount
: profile.followerCount}
</strong>{' '}
{tab === 'following' ? '关注' : '粉丝'}
</a>
))}
</nav>
</div>
<h2>公开游戏</h2>
{gameError ? (
<p role="alert">
{gameError}{' '}
<button className="creator-follow" onClick={load}>
重试
</button>
</p>
) : games === null ? (
<p role="status">正在加载游戏…</p>
) : games.length === 0 ? (
<p>还没有公开游戏</p>
) : (
<div className="game-grid">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
cover={<GameCover game={game} />}
onOpen={() => onOpenGame(game.id)}
/>
))}
</div>
)}
</>
)}
</section>
);
}
@@ -0,0 +1,336 @@
/* @vitest-environment jsdom */
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { CreatorConnection } from '../../../packages/shared/src/contracts/creator';
import { CreatorConnectionsPage } from './CreatorConnectionsPage';
import { CreatorPage } from './CreatorPage';
import { CreatorAuthor } from './CreatorRelationshipControl';
import { seedRelationship, setCreatorViewer } from './creatorRelationshipState';
const api = vi.hoisted(() => ({
viewer: 'a' as string | null,
list: vi.fn(),
read: vi.fn(),
write: vi.fn(),
profile: vi.fn(),
games: vi.fn(),
login: vi.fn(),
}));
vi.mock('../auth/AuthUiContext', () => ({
useAuthUi: () => ({
user: api.viewer ? { id: api.viewer } : null,
canAccessProtectedData: !!api.viewer,
openLoginModal: api.login,
}),
}));
vi.mock('../../services/creatorProfileClient', async (original) => ({
...(await original<typeof import('../../services/creatorProfileClient')>()),
getCreatorConnections: api.list,
getCreatorRelationship: api.read,
changeCreatorRelationship: api.write,
getCreator: api.profile,
}));
vi.mock('../../services/gameDistributionClient', () => ({
listGames: api.games,
}));
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({ resolvedUrl: '' }),
}));
const following = { isSelf: false, isFollowing: true, isFollowedBy: true };
const user = {
id: 'b',
displayName: '用户乙',
publicUserCode: '10002',
avatarUrl: null,
};
const row = (relation = following): CreatorConnection => ({
user,
followedAt: '2026-10-05T00:00:00Z',
relationship: relation,
});
const callbacks = {
onOpenCreator: vi.fn(),
onOpenConnections: vi.fn(),
onBack: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
api.viewer = 'a';
setCreatorViewer(null);
setCreatorViewer('a');
window.history.replaceState(
{},
'',
'/creators/connections?id=a&tab=following',
);
Object.defineProperty(Element.prototype, 'scrollTo', {
configurable: true,
value: vi.fn(),
});
api.read.mockResolvedValue({ relationship: following });
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
api.profile.mockResolvedValue({ user, followingCount: 1, followerCount: 2 });
api.games.mockResolvedValue([]);
});
afterEach(cleanup);
it.each(['following', 'followers'] as const)(
'他人的 %s 列表有主页链接,但缓存已有关系时仍无操作和补查',
async (tab) => {
seedRelationship('a', 'b', following);
api.list.mockResolvedValue({
items: [{ ...row(), relationship: null }],
nextCursor: null,
total: 1,
});
render(<CreatorConnectionsPage id="c" tab={tab} {...callbacks} />);
const link = await screen.findByRole('link', { name: /用户乙/ });
expect(link.getAttribute('href')).toBe('/creators?id=b');
fireEvent.click(link);
expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b');
expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull();
expect(screen.queryByRole('button', { name: '回关' })).toBeNull();
expect(screen.queryByRole('button', { name: '移除粉丝' })).toBeNull();
expect(api.read).not.toHaveBeenCalled();
},
);
it('本人关注取消后保留原行,重新关注,再取消并刷新后去行', async () => {
render(<CreatorConnectionsPage id="a" tab="following" {...callbacks} />);
await screen.findByRole('button', { name: '取消关注' });
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
api.write.mockResolvedValue({
relationship: { ...following, isFollowing: false },
});
fireEvent.click(screen.getByRole('button', { name: '取消关注' }));
await screen.findByRole('button', { name: '重新关注' });
expect(screen.getByRole('link', { name: /用户乙/ })).toBeTruthy();
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
api.write.mockResolvedValue({ relationship: following });
fireEvent.click(screen.getByRole('button', { name: '重新关注' }));
await screen.findByRole('button', { name: '取消关注' });
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
api.write.mockResolvedValue({
relationship: { ...following, isFollowing: false },
});
fireEvent.click(screen.getByRole('button', { name: '取消关注' }));
await screen.findByRole('button', { name: '重新关注' });
await waitFor(() =>
expect(
(screen.getByRole('button', { name: '刷新列表' }) as HTMLButtonElement)
.disabled,
).toBe(false),
);
fireEvent.click(screen.getByRole('button', { name: '刷新列表' }));
await screen.findByText('暂无关注');
expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull();
});
it('自己的粉丝支持回关和取消回关,移除须确认且只移除该行', async () => {
api.list.mockResolvedValue({
items: [row({ ...following, isFollowing: false })],
nextCursor: null,
total: 1,
});
render(<CreatorConnectionsPage id="a" tab="followers" {...callbacks} />);
await screen.findByRole('button', { name: '回关' });
api.write.mockResolvedValue({ relationship: following });
api.list.mockResolvedValue({ items: [row()], nextCursor: null, total: 1 });
fireEvent.click(screen.getByRole('button', { name: '回关' }));
await screen.findByRole('button', { name: '取消关注' });
expect(api.write).toHaveBeenLastCalledWith('b', 'follow');
fireEvent.click(screen.getByRole('button', { name: '移除粉丝' }));
const dialog = await screen.findByRole('dialog');
expect(api.write).toHaveBeenCalledTimes(1);
api.write.mockResolvedValue({
relationship: { ...following, isFollowedBy: false },
});
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
fireEvent.click(within(dialog).getByRole('button', { name: '移除粉丝' }));
await screen.findByText('暂无粉丝');
expect(api.write).toHaveBeenLastCalledWith('b', 'remove-follower');
});
it('列表切换账号,迟到响应不能把原账号的私有行带入', async () => {
let resolve!: (value: unknown) => void;
api.list.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
const view = render(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
api.viewer = 'c';
api.list.mockResolvedValue({ items: [], nextCursor: null, total: 0 });
view.rerender(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
await screen.findByText('暂无关注');
await act(async () => {
resolve({ items: [row()], nextCursor: null, total: 1 });
});
expect(screen.queryByRole('link', { name: /用户乙/ })).toBeNull();
});
it('主页查询作者游戏,自己不显示关注按钮,空游戏仍有主页', async () => {
render(
<CreatorPage
id={null}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
expect(api.games).toHaveBeenCalledWith({ authorId: 'a' });
expect(screen.queryByRole('button', { name: '取消关注' })).toBeNull();
fireEvent.click(screen.getByRole('link', { name: '2 粉丝' }));
expect(callbacks.onOpenConnections).toHaveBeenCalledWith('a', 'followers');
});
it('游客点击关注仅唤起登录,自己作者也能打开主页', async () => {
api.viewer = null;
const view = render(
<CreatorAuthor user={user} onOpen={callbacks.onOpenCreator} />,
);
fireEvent.click(screen.getByRole('button', { name: '关注' }));
expect(api.login).toHaveBeenCalledTimes(1);
expect(api.write).not.toHaveBeenCalled();
api.viewer = 'b';
view.rerender(<CreatorAuthor user={user} onOpen={callbacks.onOpenCreator} />);
fireEvent.click(screen.getByRole('link', { name: '用户乙' }));
expect(callbacks.onOpenCreator).toHaveBeenCalledWith('b');
expect(screen.queryByRole('button', { name: '关注' })).toBeNull();
expect(api.write).not.toHaveBeenCalled();
});
it('分页加载追加,离开重进重新读取,临时取消的行不持久化', async () => {
api.list.mockImplementation((_id, _tab, cursor) =>
Promise.resolve(
cursor
? {
items: [
{ ...row(), user: { ...user, id: 'c', displayName: '用户丙' } },
],
nextCursor: null,
total: 2,
}
: { items: [row()], nextCursor: 'page2', total: 2 },
),
);
const view = render(
<CreatorConnectionsPage id="a" tab="following" {...callbacks} />,
);
fireEvent.click(await screen.findByRole('button', { name: '加载更多' }));
await screen.findByRole('link', { name: /用户丙/ });
view.unmount();
api.list.mockClear();
render(<CreatorConnectionsPage id="a" tab="following" {...callbacks} />);
await screen.findByRole('link', { name: /用户丙/ });
expect(api.list).toHaveBeenCalledTimes(2);
});
it('显式主页登录后保持原作者,裸主页游客显示登录入口', async () => {
api.viewer = null;
const view = render(
<CreatorPage
id={null}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
expect(screen.getByText('登录后查看自己的创作者主页。')).toBeTruthy();
view.rerender(
<CreatorPage
id="b"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
fireEvent.click(screen.getByRole('button', { name: '关注' }));
api.viewer = 'a';
view.rerender(
<CreatorPage
id="b"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByRole('button', { name: '取消关注' });
expect(api.profile).toHaveBeenLastCalledWith('b');
expect(api.write).not.toHaveBeenCalled();
});
it('同账号切换主页,迟到资料不覆盖新目标;关系读取失败不阻断公开内容', async () => {
let resolve!: (value: unknown) => void;
api.profile.mockReturnValueOnce(
new Promise((done) => {
resolve = done;
}),
);
api.read.mockRejectedValue(new Error('关系读取暂时失败'));
const view = render(
<CreatorPage
id="old"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
api.profile.mockResolvedValue({
user: { ...user, id: 'new', displayName: '新的创作者' },
followingCount: 0,
followerCount: 0,
});
view.rerender(
<CreatorPage
id="new"
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
await screen.findByText('还没有公开游戏');
await screen.findByText('关系读取暂时失败');
await act(async () => {
resolve({
user: { ...user, displayName: '旧作者' },
followingCount: 1,
followerCount: 2,
});
});
expect(screen.getByText('新的创作者')).toBeTruthy();
expect(screen.queryByText('旧作者')).toBeNull();
});
it.each([null, 'a', 'b'])(
'主页 %s 顶部资料不自跳转,关系入口仍可用',
async (id) => {
const target = id ?? 'a';
api.profile.mockResolvedValue({
user: { ...user, id: target },
followingCount: 1,
followerCount: 2,
});
render(
<CreatorPage
id={id}
onOpenConnections={callbacks.onOpenConnections}
onOpenGame={vi.fn()}
onBack={vi.fn()}
/>,
);
const name = await screen.findByText('用户乙');
expect(name.closest('a, button, [tabindex]')).toBeNull();
fireEvent.click(screen.getByRole('link', { name: '1 关注' }));
expect(callbacks.onOpenConnections).toHaveBeenCalledWith(
target,
'following',
);
},
);
@@ -0,0 +1,116 @@
import {
CreatorFollowButton,
CreatorUserRow,
} from '@genarrative/shared/components';
import { useState } from 'react';
import type {
CreatorRelationship,
CreatorUser,
} from '../../../packages/shared/src/contracts/creator';
import { creatorPath } from '../../services/creatorProfileClient';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { followCreatorLink } from './creatorNavigation';
import { useCreatorRelationship } from './useCreatorRelationship';
export function CreatorRelationshipControl({
target,
initial,
followLabel,
followedLabel = '已关注',
allowRemove = false,
onResult,
}: {
target: string;
initial?: CreatorRelationship | null;
followLabel?: string;
followedLabel?: string;
allowRemove?: boolean;
onResult?: (value: CreatorRelationship) => void;
}) {
const relation = useCreatorRelationship(target, initial);
const [confirm, setConfirm] = useState(false);
if (relation.isSelf) return null;
const disabled = Boolean(
relation.viewer &&
(!relation.value || relation.loading || relation.uncertain),
);
const act = async (action: 'follow' | 'unfollow' | 'remove-follower') => {
const result = await relation.mutate(action);
if (result) onResult?.(result);
setConfirm(false);
};
return (
<div className="creator-relation">
<div className="creator-user-actions">
<CreatorFollowButton
followed={relation.value?.isFollowing ?? false}
pending={relation.pending}
disabled={disabled}
followLabel={followLabel}
followedLabel={followedLabel}
onClick={() =>
relation.viewer
? void act(relation.value?.isFollowing ? 'unfollow' : 'follow')
: relation.login()
}
/>
{allowRemove && (
<button
type="button"
className="creator-follow"
disabled={disabled || relation.pending}
onClick={() => setConfirm(true)}
>
移除粉丝
</button>
)}
</div>
{relation.error && (
<div role="status" className="creator-error">
{relation.error}
<button
type="button"
className="creator-follow"
disabled={relation.loading || relation.pending}
onClick={relation.refresh}
>
刷新关注状态
</button>
</div>
)}
<PlatformDangerConfirmDialog
open={confirm}
title="移除粉丝"
description="移除后,对方将不再关注你。你对对方的关注不受影响,对方仍可再次关注你。"
confirmLabel="移除粉丝"
busy={relation.pending}
onClose={() => {
if (!relation.pending) setConfirm(false);
}}
onConfirm={() => {
void act('remove-follower');
}}
/>
</div>
);
}
export function CreatorAuthor({
user,
onOpen,
}: {
user: CreatorUser;
onOpen?: (id: string) => void;
}) {
return (
<CreatorUserRow
name={user.displayName}
avatarUrl={user.avatarUrl}
href={creatorPath(user.id)}
onClick={(event) =>
followCreatorLink(event, onOpen && (() => onOpen(user.id)))
}
actions={<CreatorRelationshipControl target={user.id} />}
/>
);
}
+82
View File
@@ -0,0 +1,82 @@
.creator-page {
width: 100%;
max-width: 1120px;
margin: 0 auto;
color: var(--platform-text-base);
}
.creator-page h1 {
margin: 1.25rem 0;
font-size: 1.6rem;
font-weight: 700;
}
.creator-page h2 {
margin: 1.5rem 0 1rem;
font-size: 1.2rem;
font-weight: 600;
}
.creator-profile {
padding: 1.5rem;
border: 1px solid var(--platform-surface-border);
border-radius: 1.5rem;
background: var(--platform-panel-fill);
}
.creator-counts {
display: flex;
gap: 1.5rem;
margin-top: 1rem;
}
.creator-counts a {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 44px;
color: inherit;
text-decoration: none;
}
.creator-counts strong {
font-size: 1.4rem;
}
.creator-tabs {
display: flex;
gap: 1rem;
margin: 1rem 0;
}
.creator-tabs a {
min-height: 44px;
display: flex;
align-items: center;
color: inherit;
}
.creator-tabs a[aria-current] {
font-weight: 700;
text-decoration: underline;
}
.creator-list {
list-style: none;
padding: 0;
margin: 1rem 0;
}
.creator-list > li {
padding: 1rem 0;
border-bottom: 1px solid var(--platform-surface-border);
}
.creator-error {
font-size: 0.85rem;
margin-top: 0.5rem;
max-width: 28rem;
overflow-wrap: anywhere;
}
.creator-relation {
min-width: 0;
}
@media (max-width: 600px) {
.creator-profile {
padding: 1rem;
}
.creator-list .creator-user-actions {
gap: 0.25rem;
}
.creator-list .creator-follow {
padding: 0.5rem 0.75rem;
}
}
@@ -0,0 +1,18 @@
import type { MouseEvent } from 'react';
export function followCreatorLink(
event: MouseEvent<HTMLAnchorElement>,
navigate?: () => void,
) {
if (
navigate &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey &&
event.button === 0
) {
event.preventDefault();
navigate();
}
}
@@ -0,0 +1,95 @@
/* @vitest-environment jsdom */
import { beforeEach, expect, it, vi } from 'vitest';
import { ApiClientError } from '../../services/apiClient';
import {
mutateRelationship,
readRelationship,
relationshipSnapshot,
seedRelationship,
setCreatorViewer,
} from './creatorRelationshipState';
const api = vi.hoisted(() => ({ read: vi.fn(), write: vi.fn() }));
vi.mock('../../services/creatorProfileClient', () => ({
getCreatorRelationship: api.read,
changeCreatorRelationship: api.write,
}));
const no = { isSelf: false, isFollowing: false, isFollowedBy: true };
const yes = { ...no, isFollowing: true };
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
beforeEach(() => {
vi.resetAllMocks();
setCreatorViewer(null);
setCreatorViewer('a');
seedRelationship('a', 'b', no);
});
it('同一目标写入单飞,正式回执到达前不修改关系', async () => {
const request = deferred<{ relationship: typeof yes }>();
api.write.mockReturnValue(request.promise);
const first = mutateRelationship('a', 'b', 'follow');
expect(await mutateRelationship('a', 'b', 'follow')).toBeNull();
expect(api.write).toHaveBeenCalledTimes(1);
expect(relationshipSnapshot('a', 'b').value).toEqual(no);
request.resolve({ relationship: yes });
await first;
expect(relationshipSnapshot('a', 'b').value).toEqual(yes);
});
it('未知写结果只回读,失败后禁止继续写,读取成功才恢复', async () => {
api.write.mockRejectedValue(new Error('网络中断'));
api.read.mockRejectedValue(new Error('离线'));
await mutateRelationship('a', 'b', 'follow');
expect(relationshipSnapshot('a', 'b').uncertain).toBe(true);
await mutateRelationship('a', 'b', 'follow');
expect(api.write).toHaveBeenCalledTimes(1);
api.read.mockResolvedValue({ relationship: yes });
await readRelationship('a', 'b');
expect(relationshipSnapshot('a', 'b').uncertain).toBe(false);
expect(relationshipSnapshot('a', 'b').value).toEqual(yes);
});
it('未收到回执而已落库时,以回读结果为准且不重放', async () => {
api.write.mockRejectedValue(new Error('timeout'));
api.read.mockResolvedValue({ relationship: yes });
expect(await mutateRelationship('a', 'b', 'follow')).toEqual(yes);
expect(api.write).toHaveBeenCalledTimes(1);
expect(api.read).toHaveBeenCalledTimes(1);
});
it('明确的业务拒绝保留原状态', async () => {
api.write.mockRejectedValue(
new ApiClientError({
status: 404,
code: 'not_found',
message: '用户不存在',
}),
);
await mutateRelationship('a', 'b', 'follow');
expect(api.read).not.toHaveBeenCalled();
expect(relationshipSnapshot('a', 'b').value).toEqual(no);
});
it('退出后重新登录同一账号,旧写回执也不能污染新会话', async () => {
const request = deferred<{ relationship: typeof yes }>();
api.write.mockReturnValue(request.promise);
const pending = mutateRelationship('a', 'b', 'follow');
setCreatorViewer(null);
setCreatorViewer('a');
request.resolve({ relationship: yes });
await pending;
expect(relationshipSnapshot('a', 'b').value).toBeNull();
});
it('旧读响应不能覆盖新读或另一个目标', async () => {
const old = deferred<{ relationship: typeof no }>();
api.read
.mockReturnValueOnce(old.promise)
.mockResolvedValue({ relationship: yes });
const pending = readRelationship('a', 'b');
await readRelationship('a', 'b');
old.resolve({ relationship: no });
await pending;
expect(relationshipSnapshot('a', 'b').value).toEqual(yes);
expect(relationshipSnapshot('a', 'c').value).toBeNull();
});
@@ -0,0 +1,159 @@
import type { CreatorRelationship } from '../../../packages/shared/src/contracts/creator';
import { ApiClientError } from '../../services/apiClient';
import {
changeCreatorRelationship,
type CreatorAction,
getCreatorRelationship,
} from '../../services/creatorProfileClient';
export const CREATOR_CHANGED_EVENT = 'genarrative-creator-changed';
export interface RelationshipState {
value: CreatorRelationship | null;
loading: boolean;
pending: boolean;
uncertain: boolean;
error: string;
}
const EMPTY: RelationshipState = {
value: null,
loading: false,
pending: false,
uncertain: false,
error: '',
};
let activeViewer: string | null = null;
let epoch = 0;
const states = new Map<string, RelationshipState>();
const serials = new Map<string, number>();
const listeners = new Set<() => void>();
const key = (viewer: string, target: string) =>
JSON.stringify([viewer, target]);
const emit = () => listeners.forEach((listener) => listener());
export function subscribeRelationships(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function setCreatorViewer(viewer: string | null) {
if (activeViewer === viewer) return;
activeViewer = viewer;
epoch += 1;
states.clear();
serials.clear();
emit();
}
export function relationshipSnapshot(viewer: string | null, target: string) {
return viewer ? (states.get(key(viewer, target)) ?? EMPTY) : EMPTY;
}
function update(id: string, patch: Partial<RelationshipState>) {
states.set(id, { ...(states.get(id) ?? EMPTY), ...patch });
emit();
}
export function seedRelationship(
viewer: string,
target: string,
value: CreatorRelationship,
) {
if (activeViewer !== viewer) return;
const id = key(viewer, target);
if (states.get(id)?.pending) return;
serials.set(id, (serials.get(id) ?? 0) + 1);
update(id, { value, loading: false, uncertain: false, error: '' });
}
export async function readRelationship(viewer: string, target: string) {
if (activeViewer !== viewer) return;
const id = key(viewer, target),
generation = epoch;
if (states.get(id)?.pending) return;
const serial = (serials.get(id) ?? 0) + 1;
serials.set(id, serial);
update(id, { loading: true, error: '' });
try {
const result = await getCreatorRelationship(target);
if (generation !== epoch || serials.get(id) !== serial) return;
const previous = states.get(id);
update(id, {
value: result.relationship,
loading: false,
uncertain: false,
});
if (
previous?.uncertain ||
(previous?.value &&
(previous.value.isFollowing !== result.relationship.isFollowing ||
previous.value.isFollowedBy !== result.relationship.isFollowedBy))
) {
window.dispatchEvent(
new CustomEvent(CREATOR_CHANGED_EVENT, { detail: { viewer, target } }),
);
}
} catch (error) {
if (generation !== epoch || serials.get(id) !== serial) return;
update(id, {
loading: false,
error: error instanceof Error ? error.message : '读取关注状态失败',
});
}
}
export async function mutateRelationship(
viewer: string,
target: string,
action: CreatorAction,
): Promise<CreatorRelationship | null> {
if (activeViewer !== viewer || viewer === target) return null;
const id = key(viewer, target),
generation = epoch;
const state = states.get(id) ?? EMPTY;
if (state.pending || state.loading || state.uncertain || !state.value)
return null;
serials.set(id, (serials.get(id) ?? 0) + 1);
update(id, { pending: true, loading: false, error: '' });
const changed = () =>
window.dispatchEvent(
new CustomEvent(CREATOR_CHANGED_EVENT, { detail: { viewer, target } }),
);
try {
const result = await changeCreatorRelationship(target, action);
if (generation !== epoch) return null;
update(id, {
value: result.relationship,
pending: false,
uncertain: false,
});
changed();
return result.relationship;
} catch (error) {
if (generation !== epoch) return null;
const knownFailure =
error instanceof ApiClientError &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 408;
if (knownFailure) {
update(id, { pending: false, error: error.message });
return null;
}
// 结果未知时只读正式状态,不重试写入或反转意图。
try {
const result = await getCreatorRelationship(target);
if (generation !== epoch) return null;
update(id, {
value: result.relationship,
pending: false,
uncertain: false,
error: '请求未收到回执,已刷新关注状态',
});
changed();
return result.relationship;
} catch {
if (generation !== epoch) return null;
update(id, {
pending: false,
uncertain: true,
error: '状态未确认,请刷新关注状态后再操作',
});
return null;
}
}
}
@@ -0,0 +1,51 @@
import { useEffect, useSyncExternalStore } from 'react';
import type { CreatorRelationship } from '../../../packages/shared/src/contracts/creator';
import { type CreatorAction } from '../../services/creatorProfileClient';
import { useAuthUi } from '../auth/AuthUiContext';
import {
mutateRelationship,
readRelationship,
relationshipSnapshot,
seedRelationship,
setCreatorViewer,
subscribeRelationships,
} from './creatorRelationshipState';
export function useCreatorRelationship(
target: string,
initial?: CreatorRelationship | null,
) {
const auth = useAuthUi();
const viewer = auth?.canAccessProtectedData ? (auth.user?.id ?? null) : null;
const state = useSyncExternalStore(subscribeRelationships, () =>
relationshipSnapshot(viewer, target),
);
useEffect(() => {
setCreatorViewer(viewer);
if (!viewer || viewer === target) return;
if (initial) seedRelationship(viewer, target, initial);
else if (initial === undefined) void readRelationship(viewer, target);
}, [viewer, target, initial]);
useEffect(() => {
if (!viewer || viewer === target || initial !== undefined) return;
const refresh = () => {
void readRelationship(viewer, target);
};
window.addEventListener('focus', refresh);
return () => window.removeEventListener('focus', refresh);
}, [viewer, target, initial]);
return {
...state,
viewer,
isSelf: viewer === target,
refresh: () => {
if (viewer) void readRelationship(viewer, target);
},
mutate: (action: CreatorAction) =>
viewer
? mutateRelationship(viewer, target, action)
: Promise.resolve(null),
login: () => auth?.openLoginModal(),
};
}
@@ -0,0 +1,54 @@
import './gameDistribution.css';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import type { GameDistributionGame } from '../../services/gameDistributionClient';
/**
* 游戏封面。
*
* 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的
* 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时
* 展示成选中的截图。
*/
export function GameCover({
game,
compact = false,
priority = false,
objectKey,
}: {
game: GameDistributionGame;
compact?: boolean;
/** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */
priority?: boolean;
objectKey?: string | null;
}) {
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
const { resolvedUrl } = useResolvedAssetReadUrl(null, {
objectKey: targetKey,
});
return (
<div
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
style={{
background: `linear-gradient(145deg, ${game.coverColor}, #16151c)`,
}}
aria-hidden="true"
>
{resolvedUrl ? (
<img
className="game-cover__image"
src={resolvedUrl}
alt=""
loading={priority ? 'eager' : 'lazy'}
decoding="async"
/>
) : (
<>
<span className="game-cover__glow" />
<span className="game-cover__icon">{game.icon}</span>
<span className="game-cover__grid" />
</>
)}
</div>
);
}
@@ -28,6 +28,7 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { useCopyFeedback } from '../common/useCopyFeedback';
import { CreatorAuthor } from '../creator/CreatorRelationshipControl';
import {
normalizeGameEntryUrl,
normalizeGameId,
@@ -49,6 +50,7 @@ type GameDetailPageProps = {
gameId: string | null;
onBack: () => void;
onPlay: (gameId: string) => void;
onOpenCreator?: (id: string) => void;
/**
* 打开创作族谱页;参数是根作品 ID 与「当前作品」ID(用于族谱页高亮)。
* 回调缺失时不渲染族谱入口(测试或没有导航能力的宿主)。
@@ -115,6 +117,7 @@ export function GameDetailPage({
onBack,
onPlay,
onOpenLineage,
onOpenCreator,
ownerView = false,
}: GameDetailPageProps) {
const authUi = useAuthUi();
@@ -264,6 +267,7 @@ export function GameDetailPage({
<GameDetailContent
key={`${resolvedGame.id}:${authUi?.user?.id ?? 'guest'}:${canReview}`}
game={resolvedGame}
onOpenCreator={onOpenCreator}
ownerView={ownerView}
ownerGame={ownerGame}
recoveryActions={recoveryActions}
@@ -290,6 +294,7 @@ function GameDetailContent({
onBack,
onPlay,
onOpenLineage,
onOpenCreator,
}: {
game: GameDistributionGame;
ownerView: boolean;
@@ -303,6 +308,7 @@ function GameDetailContent({
onBack: () => void;
onPlay: (gameId: string) => void;
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
onOpenCreator?: (id: string) => void;
}) {
/**
* 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架
@@ -312,7 +318,7 @@ function GameDetailContent({
const reviews = useGameReviews(game.id, canReview, publiclyVisible);
/**
* 作者展示名:公开投影只会退化成占位角色词,真实昵称按作者 ID 补查一次
* (见 useGameAuthorName);查不到时给空串,展示层就不渲染「由 X 制作」那一行。
* (见 useGameAuthorName);查不到时不显示昵称,仍保留头像、主页入口和关注操作。
*/
const authorName = useGameAuthorDisplayName(game.author);
const displayGame = {
@@ -358,6 +364,17 @@ function GameDetailContent({
</button>
<GameDetailDisplay
game={displayGame}
authorContent={
<CreatorAuthor
user={{
id: game.author.id,
displayName: authorName ?? '',
avatarUrl: game.author.avatarUrl ?? null,
publicUserCode: '',
}}
onOpen={onOpenCreator}
/>
}
coverObjectKey={game.coverObjectKey}
screenshotObjectKeys={game.screenshots}
coverColor={game.coverColor}
@@ -1,5 +1,6 @@
/* @vitest-environment jsdom */
import { PLATFORM_GAME_LOADING_TIMEOUT_MS } from '@genarrative/shared/components';
import {
act,
cleanup,
@@ -18,10 +19,11 @@ import type {
GameDistributionMyGame,
GameDistributionVersionStatusEntry,
} from '../../services/gameDistributionClient';
import { setCreatorViewer } from '../creator/creatorRelationshipState';
import { GameDetailPage } from './GameDetailPage';
import { GameGalleryPage } from './GameGalleryPage';
import { GameLineagePage } from './GameLineagePage';
import { GAME_PLAY_STARTUP_TIMEOUT_MS, GamePlayPage } from './GamePlayPage';
import { GamePlayPage } from './GamePlayPage';
import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel';
const listGamesMock = vi.hoisted(() => vi.fn());
@@ -33,6 +35,19 @@ const recordGamePlayMock = vi.hoisted(() => vi.fn());
const resolvedAssetUrlMock = vi.hoisted(() => vi.fn());
const listGameReviewsMock = vi.hoisted(() => vi.fn());
const getMyGameReviewMock = vi.hoisted(() => vi.fn());
const getPublicAuthUserByIdMock = vi.hoisted(() => vi.fn());
const getCreatorRelationshipMock = vi.hoisted(() => vi.fn());
const changeCreatorRelationshipMock = vi.hoisted(() => vi.fn());
vi.mock('../../services/authService', async (original) => ({
...(await original<typeof import('../../services/authService')>()),
getPublicAuthUserById: getPublicAuthUserByIdMock,
}));
vi.mock('../../services/creatorProfileClient', async (original) => ({
...(await original<typeof import('../../services/creatorProfileClient')>()),
getCreatorRelationship: getCreatorRelationshipMock,
changeCreatorRelationship: changeCreatorRelationshipMock,
}));
vi.mock('../../services/gameDistributionClient', () => ({
listGames: listGamesMock,
@@ -151,6 +166,13 @@ beforeEach(() => {
authUiMock.value.openLoginModal.mockReset();
copyTextToClipboardMock.mockReset();
copyTextToClipboardMock.mockResolvedValue(true);
setCreatorViewer(null);
getCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: false, isFollowedBy: false },
});
changeCreatorRelationshipMock.mockResolvedValue({
relationship: { isSelf: false, isFollowing: true, isFollowedBy: false },
});
setViewportWidth(1024);
listGamesMock.mockResolvedValue([]);
getGameMock.mockResolvedValue(createGame());
@@ -211,6 +233,57 @@ describe('GameGalleryPage', () => {
});
describe('GameDetailPage', () => {
it.each([
['创作者', '成功'],
['未知作者', '空名'],
['创作者', '失败'],
])(
'作者为占位词 %s 时,补查%s均不闪现伪昵称且保留作者操作',
async (name, outcome) => {
const lookup = deferred<{ displayName: string }>();
getPublicAuthUserByIdMock.mockReturnValueOnce(lookup.promise);
getGameMock.mockResolvedValueOnce(
createGame({ author: { id: 'author-2', name } }),
);
const onOpenCreator = vi.fn();
render(
<GameDetailPage
gameId="game-1"
onBack={vi.fn()}
onPlay={vi.fn()}
onOpenCreator={onOpenCreator}
/>,
);
const link = await screen.findByRole('link', { name: '查看创作者主页' });
expect(link.getAttribute('href')).toBe('/creators?id=author-2');
expect(link.querySelector('strong')?.textContent).toBe('');
expect(getPublicAuthUserByIdMock).toHaveBeenCalledWith('author-2');
await waitFor(() => {
expect(
(screen.getByRole('button', { name: '关注' }) as HTMLButtonElement)
.disabled,
).toBe(false);
});
await act(async () => {
if (outcome === '失败') lookup.reject(new Error('查询失败'));
else
lookup.resolve({ displayName: outcome === '成功' ? '真实昵称' : '' });
});
expect(link.querySelector('strong')?.textContent).toBe(
outcome === '成功' ? '真实昵称' : '',
);
expect(screen.queryByText(name, { exact: true })).toBeNull();
fireEvent.click(link);
expect(onOpenCreator).toHaveBeenCalledWith('author-2');
fireEvent.click(screen.getByRole('button', { name: '关注' }));
await screen.findByRole('button', { name: '取消关注' });
expect(changeCreatorRelationshipMock).toHaveBeenCalledWith(
'author-2',
'follow',
);
},
);
it('blocks desktop-only games on a mobile viewport', async () => {
setViewportWidth(375);
getGameMock.mockResolvedValueOnce(
@@ -558,6 +631,28 @@ describe('GamePlayPage', () => {
expect(recordGamePlayMock).toHaveBeenCalledWith('game-1');
});
it('shows the shared loading surface until the release frame is ready', async () => {
render(<GamePlayPage gameId="game-1" onBack={vi.fn()} />);
await screen.findByRole('button', { name: /开始游戏/u });
fireEvent.click(screen.getByRole('button', { name: /开始游戏/u }));
const iframe = await screen.findByTitle('星轨防线 在线游玩');
// 发行包首屏要下完整个引擎:加载期必须有看得见的加载面,而不是一行小字或黑屏。
const loadingSurface = screen.getByRole('status');
expect(loadingSurface.textContent).toContain('星轨防线');
expect(
loadingSurface.classList.contains('platform-game-loading--player'),
).toBe(true);
expect(
loadingSurface.querySelector('.platform-game-loading__track'),
).not.toBeNull();
fireEvent.load(iframe);
expect(screen.queryByRole('status')).toBeNull();
expect(iframe.className.includes('game-player-frame--ready')).toBe(true);
});
it('rejects an unsafe release entry URL before rendering an iframe', async () => {
getGameMock.mockResolvedValueOnce(
createGame({
@@ -784,7 +879,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
const firstFrame = screen.getByTitle('星轨防线 在线游玩');
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.getByText(/启动超时/u)).toBeTruthy();
expect(screen.getByRole('button', { name: /^返回$/u })).toBeTruthy();
@@ -800,7 +895,7 @@ describe('GamePlayPage 启动超时与旋转提示', () => {
);
act(() => {
vi.advanceTimersByTime(GAME_PLAY_STARTUP_TIMEOUT_MS);
vi.advanceTimersByTime(PLATFORM_GAME_LOADING_TIMEOUT_MS);
});
expect(screen.queryByText(/启动超时/u)).toBeNull();
@@ -1,11 +1,10 @@
import './gameDistribution.css';
import './gameDistributionShowcase.css';
import { GameRatingSummaryText } from '@genarrative/shared/components';
import { GameCard } from '@genarrative/shared/components';
import { Gamepad2, Monitor, Search, Smartphone, Sparkles } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
type GameCategory,
type GameDistributionGame,
@@ -14,6 +13,7 @@ import {
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { GameCover } from './GameCover';
import {
GAME_GALLERY_CATEGORIES as CATEGORIES,
GAME_GALLERY_DEVICE_FILTERS as DEVICE_FILTERS,
@@ -37,87 +37,6 @@ type GameGalleryPageProps = {
onOpenPublish?: () => void;
};
/**
* 游戏封面。
*
* 有平台封面素材时换签名地址后显示真实封面,未上传、换签失败或还在解析时保留原有的
* 渐变色 + 图标占位,保证首屏不出现空框或布局跳动;`objectKey` 允许详情页把封面位临时
* 展示成选中的截图。
*/
export function GameCover({
game,
compact = false,
priority = false,
objectKey,
}: {
game: GameDistributionGame;
compact?: boolean;
/** 首屏 hero 用的封面立即加载,列表卡片保持延迟加载。 */
priority?: boolean;
objectKey?: string | null;
}) {
const targetKey = (objectKey ?? game.coverObjectKey ?? '').trim();
const { resolvedUrl } = useResolvedAssetReadUrl(null, {
objectKey: targetKey,
});
return (
<div
className={`game-cover ${compact ? 'game-cover--compact' : ''}`}
style={{
background: `linear-gradient(145deg, ${game.coverColor}, #16151c)`,
}}
aria-hidden="true"
>
{resolvedUrl ? (
<img
className="game-cover__image"
src={resolvedUrl}
alt=""
loading={priority ? 'eager' : 'lazy'}
decoding="async"
/>
) : (
<>
<span className="game-cover__glow" />
<span className="game-cover__icon">{game.icon}</span>
<span className="game-cover__grid" />
</>
)}
</div>
);
}
function GameCard({
game,
onOpen,
}: {
game: GameDistributionGame;
onOpen: () => void;
}) {
return (
<button type="button" className="game-card" onClick={onOpen}>
<GameCover game={game} />
<span className="game-card__body">
<span className="game-card__title-row">
<strong>{game.title}</strong>
<span className="game-card__play-count">
{game.playCount.toLocaleString()} 次游玩
</span>
</span>
<span className="game-card__summary">{game.summary}</span>
<span className="game-card__rating">
<GameRatingSummaryText summary={game.ratingSummary} />
</span>
<span className="game-card__meta">
<span>{game.category}</span>
<span>·</span>
<span>{game.author.name}</span>
</span>
</span>
</button>
);
}
export function GameGalleryPage({
searchKeyword = '',
onOpenDetail,
@@ -446,6 +365,7 @@ export function GameGalleryPage({
<div className="game-grid">
{visibleGames.map((game) => (
<GameCard
cover={<GameCover game={game} />}
key={game.id}
game={game}
onOpen={() => openDetail(game.id)}
@@ -1,5 +1,9 @@
import './gameDistribution.css';
import {
PLATFORM_GAME_LOADING_TIMEOUT_MS,
PlatformGameLoadingSurface,
} from '@genarrative/shared/components';
import {
ArrowLeft,
Expand,
@@ -16,19 +20,16 @@ import {
} from '../../services/gameDistributionClient';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { GameCover } from './GameCover';
import {
normalizeGameEntryUrl,
normalizeGameId,
useIsMobileViewport,
useIsPortraitViewport,
} from './gameDistributionGuards';
import { GameCover } from './GameGalleryPage';
type GamePlayPageProps = { gameId: string | null; onBack: () => void };
/** 发行包首屏加载上限:超过后给出显式超时与重试,不用无限期“正在启动”。 */
export const GAME_PLAY_STARTUP_TIMEOUT_MS = 20_000;
type GameStartupState = 'idle' | 'loading' | 'ready' | 'timeout';
export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
@@ -93,7 +94,7 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
setStartupState((current) =>
current === 'loading' ? 'timeout' : current,
);
}, GAME_PLAY_STARTUP_TIMEOUT_MS);
}, PLATFORM_GAME_LOADING_TIMEOUT_MS);
return () => window.clearTimeout(timeoutId);
}, [hasStarted, startupState]);
@@ -212,7 +213,12 @@ export function GamePlayPage({ gameId, onBack }: GamePlayPageProps) {
</div>
) : null}
{hasStarted && startupState === 'loading' ? (
<div className="game-player-loading">游戏正在启动…</div>
<PlatformGameLoadingSurface
key={`game-loading-${frameRunId}`}
tone="player"
title={game.title}
cover={<GameCover game={game} compact />}
/>
) : null}
{hasStarted && startupState === 'timeout' ? (
<PlatformStatusMessage
@@ -31,7 +31,7 @@ import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { GameCover } from './GameGalleryPage';
import { GameCover } from './GameCover';
import {
type GamePublishDraft,
readPublishDraft,
@@ -188,71 +188,6 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1.1rem;
}
.game-card {
display: block;
min-width: 0;
overflow: hidden;
border: 1px solid var(--platform-surface-border);
border-radius: 1.45rem;
padding: 0;
background: var(--platform-panel-fill);
color: inherit;
cursor: pointer;
text-align: left;
box-shadow: var(--platform-panel-shadow);
transition: 180ms ease;
}
.game-card:hover {
border-color: rgba(199, 101, 61, 0.38);
box-shadow: 0 1rem 2.4rem rgba(104, 54, 36, 0.14);
transform: translateY(-3px);
}
.game-card__body {
display: block;
padding: 0.95rem 1rem 1.1rem;
}
.game-card__title-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem;
}
.game-card__title-row strong {
overflow: hidden;
font-size: 1rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card__play-count {
flex: none;
color: var(--platform-text-soft);
font-size: 0.66rem;
}
.game-card__summary {
display: block;
overflow: hidden;
margin-top: 0.42rem;
color: var(--platform-text-base);
font-size: 0.78rem;
line-height: 1.55;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-card__rating {
display: block;
margin-top: 0.42rem;
color: var(--platform-text-base);
font-size: 0.78rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.game-card__meta {
display: flex;
gap: 0.38rem;
margin-top: 0.85rem;
color: var(--platform-text-soft);
font-size: 0.7rem;
}
.game-cover {
position: relative;
display: flex;
@@ -638,15 +573,6 @@
.game-player-frame--ready {
opacity: 1;
}
.game-player-loading {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: rgba(255, 241, 227, 0.75);
font-size: 0.88rem;
font-weight: 700;
}
.game-play-safety-note {
margin: 0;
color: var(--platform-text-soft);
@@ -766,7 +692,6 @@
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.game-player-loading,
.game-player-timeout {
font-size: 0.8rem;
}
@@ -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'
+19 -1
View File
@@ -1,6 +1,11 @@
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import viteConfig, { isRetiredApiPath } from '../../vite.config';
import viteConfig, {
isRetiredApiPath,
isRetiredFrontendModuleId,
} from '../../vite.config';
describe('vite dev api proxy', () => {
it('forwards the profile main route to the Rust API server', async () => {
@@ -74,3 +79,16 @@ describe('vite dev api proxy', () => {
);
});
});
it('创作者访问层进入现役依赖图并代理到 Rust API', async () => {
const config =
typeof viteConfig === 'function'
? await viteConfig({ command: 'serve', mode: 'test' })
: viteConfig;
expect(config.server?.proxy).toHaveProperty('/api/creators');
expect(
isRetiredFrontendModuleId(
path.resolve('src/services/creatorProfileClient.ts'),
),
).toBe(false);
});
+15
View File
@@ -3,6 +3,7 @@
import { describe, expect, it } from 'vitest';
import {
APP_PAGE_VIEW_STATE_KEY,
hasAppHistoryBackEntry,
isAppHistoryState,
isKnownMainAppPagePath,
@@ -102,3 +103,17 @@ describe('appPageRoutes', () => {
expect(hasAppHistoryBackEntry()).toBe(true);
});
});
it('创作者深链能直接打开,新导航不继承旧页恢复数据', () => {
expect(resolveSelectionStageFromPath('/creators')).toBe('creator-home');
expect(resolveSelectionStageFromPath('/creators/connections')).toBe(
'creator-connections',
);
window.history.replaceState(
{ [APP_PAGE_VIEW_STATE_KEY]: { pages: 3, viewer: 'a' } },
'',
'/creators/connections?id=a&tab=following',
);
pushAppHistoryPath('/creators?id=b');
expect(window.history.state[APP_PAGE_VIEW_STATE_KEY]).toBeUndefined();
});
+4
View File
@@ -8,6 +8,8 @@ const STAGE_ROUTE_ENTRIES = [
['creation-home', '/creation'],
['project', '/project'],
['profile', '/profile'],
['creator-home', '/creators'],
['creator-connections', '/creators/connections'],
['profile-payment', '/profile/payment'],
['payment-checkout', '/pay'],
['image-editor', '/editor/canvas'],
@@ -38,6 +40,7 @@ const ROUTE_STAGE_BY_PATH = new Map(
const APP_RUNTIME_CONTEXT_QUERY_KEYS =
HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS;
export const APP_PAGE_VIEW_STATE_KEY = '__genarrativePageView';
export const APP_HISTORY_STATE_KEY = '__genarrativeAppHistoryEntry';
const APP_HISTORY_STATE_DEPTH_KEY = '__genarrativeAppHistoryDepth';
@@ -157,6 +160,7 @@ function buildAppHistoryState(mode: 'push' | 'replace') {
);
return {
...currentState,
[APP_PAGE_VIEW_STATE_KEY]: undefined,
[APP_HISTORY_STATE_KEY]: true,
[APP_HISTORY_STATE_DEPTH_KEY]:
mode === 'push' ? currentDepth + 1 : currentDepth,
+9
View File
@@ -62,3 +62,12 @@ describe('activeAppTitle', () => {
});
});
});
test('创作者主页和关系列表使用各自标题', () => {
expect(resolveAppTitleForSelectionStage('creator-home')).toBe(
'创作者主页 - 陶泥儿',
);
expect(resolveAppTitleForSelectionStage('creator-connections')).toBe(
'关注与粉丝 - 陶泥儿',
);
});
+2
View File
@@ -8,6 +8,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record<SelectionStage, string> = {
platform: APP_HOME_TITLE,
'creation-home': APP_HOME_TITLE,
project: '项目 - 陶泥儿',
'creator-home': '创作者主页 - 陶泥儿',
'creator-connections': '关注与粉丝 - 陶泥儿',
profile: '我的 - 陶泥儿',
'profile-payment': '支付服务 - 陶泥儿',
'payment-checkout': '扫码支付 - 陶泥儿',

Some files were not shown because too many files have changed in this diff Show More