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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -62,3 +62,12 @@ describe('activeAppTitle', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('创作者主页和关系列表使用各自标题', () => {
|
||||
expect(resolveAppTitleForSelectionStage('creator-home')).toBe(
|
||||
'创作者主页 - 陶泥儿',
|
||||
);
|
||||
expect(resolveAppTitleForSelectionStage('creator-connections')).toBe(
|
||||
'关注与粉丝 - 陶泥儿',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user