diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index 18ae95044..8acf2e6b4 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -157,7 +157,7 @@ http { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" { try_files $uri /index.html =404; } diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 41a1bb440..b8c2974d0 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -206,7 +206,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index 43a42017f..7e6e599a1 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -234,7 +234,7 @@ server { try_files /index.html =404; } - location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish)/?$" { + location ~* "^/(?:creation|editor/canvas|pay|profile|profile/payment|project|components|design-system|creators|creators/connections|games|games/compare|games/detail|games/lineage|games/mine|games/play|games/publish|games/theme|games/themes)/?$" { error_page 503 /maintenance.html; if ($genarrative_maintenance) { diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index c09f5e2fa..07cfa1b56 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -74,6 +74,8 @@ const MAIN_SPA_PATHS: &[&str] = &[ "/games/mine", "/games/play", "/games/publish", + "/games/theme", + "/games/themes", "/creators", "/creators/connections", "/pay", diff --git a/src/components/game-distribution/GameDetailPage.tsx b/src/components/game-distribution/GameDetailPage.tsx index 447d080ee..442f57dba 100644 --- a/src/components/game-distribution/GameDetailPage.tsx +++ b/src/components/game-distribution/GameDetailPage.tsx @@ -66,6 +66,8 @@ type GameDetailPageProps = { * 只有存在父作品的二创作品才渲染该入口。 */ onOpenCompare?: (leftGameId: string, rightGameId: string) => void; + /** 打开所属共创主题页(`/games/theme?id=`)。 */ + onOpenTheme?: (themeId: string) => void; /** * 作者视角:走 `GET /my-games/{gameId}` 读取自己名下作品。 * @@ -137,6 +139,7 @@ export function GameDetailPage({ onOpenLineage, onOpenCreator, onOpenCompare, + onOpenTheme, ownerView = false, }: GameDetailPageProps) { const authUi = useAuthUi(); @@ -297,6 +300,7 @@ export function GameDetailPage({ onPlay={onPlay} onOpenLineage={onOpenLineage} onOpenCompare={onOpenCompare} + onOpenTheme={onOpenTheme} isAuthenticated={Boolean(authUi?.user)} /> ); @@ -316,6 +320,7 @@ function GameDetailContent({ onOpenLineage, onOpenCreator, onOpenCompare, + onOpenTheme, }: { game: GameDistributionGame; ownerView: boolean; @@ -331,6 +336,7 @@ function GameDetailContent({ onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; onOpenCreator?: (id: string) => void; onOpenCompare?: (leftGameId: string, rightGameId: string) => void; + onOpenTheme?: (themeId: string) => void; }) { /** * 只有已公开作品才有公开评价/评分投影;审核中、审核未通过、未公开、已下架 @@ -366,6 +372,8 @@ function GameDetailContent({ ); const lineage = game.lineage ?? null; const forkCount = game.forkCount ?? 0; + // 所属共创主题:匿名也返回;没有主题时是空数组(不渲染空容器)。 + const themes = game.themes ?? []; // Fork 的主路径是唤起客户端;只有「没失焦(没客户端接手)」或「平台不可能唤起」才降级成 // 「复制作品 ID / 链接 → 到客户端粘贴」。降级面板同理只在降级时出现。 const [isForkPanelOpen, setIsForkPanelOpen] = useState(false); @@ -603,6 +611,29 @@ function GameDetailContent({ 与原版对比 ) : null} + {onOpenTheme && themes.length > 0 ? ( + /* + 所属共创主题入口:服务端按作品的**根**反查,所以第 N 代作品也会有值; + 没有所属主题时是空数组(不是缺键),这里不渲染空容器。 + */ + + {themes.map((theme) => ( + + ))} + + ) : null} {canRemix ? ( /* Fork 入口:服务端取件通道要求 Bearer + 已公开 + 授权非禁止。 diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index a73c5af0f..094492635 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -15,6 +15,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameDistributionVersionStatus } from '../../../packages/shared/src/contracts/gameDistribution'; import type { GameDistributionGame, + GameDistributionGameWithCollection, GameDistributionLineageNode, GameDistributionMyGame, GameDistributionVersionStatusEntry, @@ -26,10 +27,14 @@ import { FORK_LAUNCH_FALLBACK_DELAY_MS } from './gameForkProtocol'; import { GameGalleryPage } from './GameGalleryPage'; import { GameLineagePage } from './GameLineagePage'; import { GamePlayPage } from './GamePlayPage'; +import { GameThemePage } from './GameThemePage'; +import { GameThemesPage } from './GameThemesPage'; import { resolveNonPublicCoverLabel } from './gameVersionHistoryModel'; const listGamesMock = vi.hoisted(() => vi.fn()); const setGameCollectionMock = vi.hoisted(() => vi.fn()); +const listThemesMock = vi.hoisted(() => vi.fn()); +const getThemeMock = vi.hoisted(() => vi.fn()); const getGameMock = vi.hoisted(() => vi.fn()); const getGameLineageMock = vi.hoisted(() => vi.fn()); const getMyGameMock = vi.hoisted(() => vi.fn()); @@ -55,6 +60,8 @@ vi.mock('../../services/creatorProfileClient', async (original) => ({ vi.mock('../../services/gameDistributionClient', () => ({ listGames: listGamesMock, setGameCollection: setGameCollectionMock, + listThemes: listThemesMock, + getTheme: getThemeMock, getGame: getGameMock, getGameLineage: getGameLineageMock, getMyGame: getMyGameMock, @@ -121,8 +128,8 @@ function deferred() { } function createGame( - overrides: Partial = {}, -): GameDistributionGame { + overrides: Partial = {}, +): GameDistributionGameWithCollection { return { id: 'game-1', title: '星轨防线', @@ -182,6 +189,10 @@ beforeEach(() => { openForkProtocolMock.mockReset(); setGameCollectionMock.mockReset(); setGameCollectionMock.mockResolvedValue({ collected: true }); + listThemesMock.mockReset(); + listThemesMock.mockResolvedValue({ themes: [], nextCursor: null }); + getThemeMock.mockReset(); + getThemeMock.mockResolvedValue(null); setCreatorViewer(null); getCreatorRelationshipMock.mockResolvedValue({ relationship: { isSelf: false, isFollowing: false, isFollowedBy: false }, @@ -1688,6 +1699,257 @@ describe('GameComparePage', () => { }); }); +describe('GameThemesPage', () => { + it('渲染主题卡片(名称 / 角标 / 可见成员数)并可进入主题页', async () => { + listThemesMock.mockResolvedValue({ + themes: [ + { + themeId: 'theme-1', + name: '精选共创', + summary: '运营精选的共创主题', + badge: '精选', + memberCount: 2, + }, + ], + nextCursor: null, + }); + const onOpenTheme = vi.fn(); + render( + , + ); + + expect(await screen.findByText('精选共创')).toBeTruthy(); + expect(screen.getByText('精选')).toBeTruthy(); + expect(screen.getByText('2 棵作品树')).toBeTruthy(); + // 末页 nextCursor 为 null:不渲染「加载更多」。 + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: /精选共创/u })); + expect(onOpenTheme).toHaveBeenCalledWith('theme-1'); + }); + + it('有 nextCursor 时渲染「加载更多」并用游标追加下一页', async () => { + listThemesMock + .mockResolvedValueOnce({ + themes: [ + { + themeId: 'theme-1', + name: '第一页主题', + summary: '', + badge: '', + memberCount: 1, + }, + ], + nextCursor: '100:theme-1', + }) + .mockResolvedValueOnce({ + themes: [ + { + themeId: 'theme-2', + name: '第二页主题', + summary: '', + badge: '', + memberCount: 3, + }, + ], + nextCursor: null, + }); + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: '加载更多' })); + await waitFor(() => + expect(listThemesMock).toHaveBeenLastCalledWith('100:theme-1'), + ); + expect(await screen.findByText('第二页主题')).toBeTruthy(); + // 追加而不是替换。 + expect(screen.getByText('第一页主题')).toBeTruthy(); + expect(screen.queryByRole('button', { name: '加载更多' })).toBeNull(); + }); + + it('没有主题时给出空态,失败时给出错误与重试', async () => { + listThemesMock.mockResolvedValueOnce({ themes: [], nextCursor: null }); + render( + , + ); + expect( + await screen.findByText('还没有已发布的共创主题,稍后再来看看。'), + ).toBeTruthy(); + + cleanup(); + listThemesMock.mockRejectedValueOnce(new Error('网络错误')); + render( + , + ); + expect(await screen.findByText('网络错误')).toBeTruthy(); + expect(screen.getByRole('button', { name: '重试' })).toBeTruthy(); + }); +}); + +describe('GameThemePage', () => { + const rootGame = (id: string, title: string) => + createGame({ + id, + title, + lineage: { + generation: 0, + rootGameId: id, + rootTitle: title, + parentGameId: id, + parentTitle: title, + parentAuthorName: '原作者', + }, + }); + + it('渲染主题头与逐根的内嵌作品树(复用族谱页)', async () => { + getThemeMock.mockResolvedValue({ + themeId: 'theme-1', + name: '精选共创', + summary: '运营精选的共创主题', + badge: '精选', + memberCount: 2, + roots: [rootGame('game-root-1', '根一'), rootGame('game-root-2', '根二')], + }); + getGameLineageMock.mockImplementation((rootGameId: string) => + Promise.resolve({ + rootGameId, + root: createLineageNode({ + gameId: rootGameId, + title: `树 ${rootGameId}`, + generation: 0, + parentGameId: null, + }), + nodes: [ + createLineageNode({ + gameId: rootGameId, + title: `树 ${rootGameId}`, + generation: 0, + parentGameId: null, + }), + ], + truncated: false, + }), + ); + const onOpenLineage = vi.fn(); + render( + , + ); + + expect(await screen.findByText('精选共创')).toBeTruthy(); + expect(screen.getByText('运营精选的共创主题')).toBeTruthy(); + // 两个根各内嵌一棵树:树数据来自同一个 `/lineage` 查询。 + expect(await screen.findByText('树 game-root-1')).toBeTruthy(); + expect(screen.getByText('树 game-root-2')).toBeTruthy(); + await waitFor(() => expect(getGameLineageMock).toHaveBeenCalledTimes(2)); + expect(getGameLineageMock).toHaveBeenCalledWith('game-root-1'); + expect(getGameLineageMock).toHaveBeenCalledWith('game-root-2'); + // 内嵌模式不渲染族谱页外壳(页头/返回按钮),但保留「全屏查看」入口。 + expect(screen.getAllByRole('button', { name: '全屏查看族谱' })).toHaveLength( + 2, + ); + fireEvent.click( + screen.getAllByRole('button', { name: '全屏查看族谱' })[0]!, + ); + expect(onOpenLineage).toHaveBeenCalledWith('game-root-1', 'game-root-1'); + }); + + it('已发布但没有可见成员时给出空态而不是错误', async () => { + getThemeMock.mockResolvedValue({ + themeId: 'theme-2', + name: '空主题', + summary: '', + badge: '', + memberCount: 0, + roots: [], + }); + render( + , + ); + + expect( + await screen.findByText('这个主题还没有可展示的作品树。'), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '重试' })).toBeNull(); + }); + + it('主题不存在或未发布时走错误态(服务端 404 不返回空壳)', async () => { + getThemeMock.mockRejectedValueOnce(new Error('主题不存在')); + render( + , + ); + + expect(await screen.findByText('主题不存在')).toBeTruthy(); + expect(screen.getByRole('button', { name: '重试' })).toBeTruthy(); + }); +}); + +describe('GameDetailPage 共创主题入口', () => { + it('渲染所属共创主题角标并可进入主题页', async () => { + getGameMock.mockResolvedValueOnce( + createGame({ + themes: [{ themeId: 'theme-1', name: '精选共创', badge: '精选' }], + }), + ); + const onOpenTheme = vi.fn(); + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: /精选共创/u })); + expect(onOpenTheme).toHaveBeenCalledWith('theme-1'); + }); + + it('没有所属主题时不渲染主题容器(空数组不是缺键)', async () => { + getGameMock.mockResolvedValueOnce(createGame({ themes: [] })); + render( + , + ); + + await screen.findByRole('heading', { name: '星轨防线' }); + expect(document.querySelector('.game-fork-theme-list')).toBeNull(); + }); +}); + describe('GameDetailPage 收藏按钮', () => { it('可判定为已收藏时显示「已收藏」,点击取消收藏', async () => { getGameMock.mockResolvedValueOnce(createGame({ collected: true })); diff --git a/src/components/game-distribution/GameGalleryPage.tsx b/src/components/game-distribution/GameGalleryPage.tsx index 3f222d484..57f2f7b17 100644 --- a/src/components/game-distribution/GameGalleryPage.tsx +++ b/src/components/game-distribution/GameGalleryPage.tsx @@ -35,6 +35,8 @@ type GameGalleryPageProps = { onOpenDetail: (gameId: string) => void; onOpenMyGames?: () => void; onOpenPublish?: () => void; + /** 切到「共创」页签(`/games/themes`);缺失时不渲染该页签。 */ + onOpenThemes?: () => void; }; export function GameGalleryPage({ @@ -42,6 +44,7 @@ export function GameGalleryPage({ onOpenDetail, onOpenMyGames, onOpenPublish, + onOpenThemes, }: GameGalleryPageProps) { const initialFiltersRef = useRef(null); if (initialFiltersRef.current === null) { @@ -276,6 +279,27 @@ export function GameGalleryPage({ + {onOpenThemes ? ( +
+ + +
+ ) : null}
{CATEGORIES.map((item) => ( -
-
- 创作族谱 -

{rootTitle || '创作族谱'}

+
+ {embedded ? null : ( + + )} + {embedded ? null : ( +
+
+ 创作族谱 +

{rootTitle || '创作族谱'}

+
+ {normalizedRootId ? ( + void loadLineage()} + disabled={isLoading} + > + + ) : null}
- {normalizedRootId ? ( - void loadLineage()} - disabled={isLoading} - > - - ) : null} -
+ )} {!normalizedRootId ? ( @@ -281,9 +293,11 @@ export function GameLineagePage({ ) : null} -

- 族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。 -

+ {embedded ? null : ( +

+ 族谱只展示已公开的作品;已删除或未公开的来源作品会标注为「原作品已不可用」。 +

+ )}
); } diff --git a/src/components/game-distribution/GameThemePage.tsx b/src/components/game-distribution/GameThemePage.tsx new file mode 100644 index 000000000..7928b50cd --- /dev/null +++ b/src/components/game-distribution/GameThemePage.tsx @@ -0,0 +1,220 @@ +import './gameDistribution.css'; + +import { ArrowLeft, RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + type GameDistributionThemeDetail, + getTheme, +} from '../../services/gameDistributionClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; +import { GameLineagePage } from './GameLineagePage'; +import { useGameAuthorDisplayName } from './useGameAuthorName'; + +type GameThemePageProps = { + themeId: string | null; + onBack: () => void; + onOpenDetail: (gameId: string) => void; + /** 直接进族谱页(树的全屏版本);主题页内已内嵌渲染同一棵树。 */ + onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; + onOpenCompare?: (leftGameId: string, rightGameId: string) => void; +}; + +/** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */ +function ThemeRootCard({ + root, + onOpenDetail, + onOpenLineage, +}: { + root: { + id: string; + title: string; + author: { id: string; name: string }; + playCount: number; + lineage?: { generation: number } | null; + }; + onOpenDetail: (gameId: string) => void; + onOpenLineage?: (rootGameId: string, fromGameId?: string) => void; +}) { + const authorName = useGameAuthorDisplayName(root.author); + const generation = root.lineage?.generation ?? 0; + return ( +
+ + {generation === 0 ? '母版' : `第 ${generation} 代`} + +

{root.title}

+

+ {authorName?.trim() || '未署名作者'} + + {root.playCount.toLocaleString()} 次游玩 +

+
+ onOpenDetail(root.id)} + > + 查看作品 + + {onOpenLineage ? ( + onOpenLineage(root.id, root.id)} + > + 全屏查看族谱 + + ) : null} +
+
+ ); +} + +/** + * 主题页:标题 / 简介 / 角标 + 成员根清单,**逐根内嵌渲染作品树**。 + * + * 树不在这里重建:内嵌的就是族谱页组件(`GameLineagePage embedded`),因此树查询、可见性过滤、 + * 排序与截断全部复用 M3 那一份实现,主题页不引入第二套树规则。代价是每个根一次 `/lineage` + * 请求(技术方案 §3.10.4 已接受的 N+1 取舍)。 + * + * 已发布但可见成员为空 → 明确空态(不是错误);主题不存在 / 未发布 → 服务端 404,这里走错误态。 + */ +export function GameThemePage({ + themeId, + onBack, + onOpenDetail, + onOpenLineage, + onOpenCompare, +}: GameThemePageProps) { + const normalizedThemeId = themeId?.trim() ?? ''; + const [theme, setTheme] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const loadTheme = useCallback(async () => { + if (!normalizedThemeId) { + setTheme(null); + setError('主题编号无效'); + return; + } + setIsLoading(true); + setError(''); + try { + const detail = await getTheme(normalizedThemeId); + if (!detail) { + setError('主题不存在或尚未发布'); + return; + } + setTheme(detail); + } catch (loadError: unknown) { + setError( + loadError instanceof Error + ? loadError.message + : '主题不存在或尚未发布', + ); + } finally { + setIsLoading(false); + } + }, [normalizedThemeId]); + + useEffect(() => { + void loadTheme(); + }, [loadTheme]); + + return ( +
+ +
+
+ 共创主题 +

{theme?.name?.trim() || '共创主题'}

+
+ void loadTheme()} + > + +
+ + {theme?.badge || theme?.summary ? ( +
+ {theme.badge ? ( + {theme.badge} + ) : null} + {theme.summary ? ( +

{theme.summary}

+ ) : null} +
+ ) : null} + + {isLoading && !theme ? ( +
正在加载共创主题…
+ ) : null} + {!isLoading && error ? ( + + {error} + {normalizedThemeId ? ( + + ) : null} + + ) : null} + {!isLoading && !error && theme && theme.roots.length === 0 ? ( + + 这个主题还没有可展示的作品树。 + + ) : null} + {!error && theme && theme.roots.length > 0 ? ( +
+ {theme.roots.map((root) => ( +
+ + {/* + 内嵌族谱页:树查询与渲染完全复用 M3 实现(embedded 只隐藏页面外壳: + 返回按钮 / 页头 / 脚注),不在主题侧重建第二套树。 + */} + +
+ ))} +
+ ) : null} + +

+ 树只展示已公开的作品;成员作品下架或删除后会从主题里消失,重新公开后自动回来。 +

+
+ ); +} + +export default GameThemePage; diff --git a/src/components/game-distribution/GameThemesPage.tsx b/src/components/game-distribution/GameThemesPage.tsx new file mode 100644 index 000000000..7e7f54c64 --- /dev/null +++ b/src/components/game-distribution/GameThemesPage.tsx @@ -0,0 +1,168 @@ +import './gameDistribution.css'; + +import { ArrowLeft, RefreshCcw } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +import { + type GameDistributionThemeSummary, + listThemes, +} from '../../services/gameDistributionClient'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformEmptyState } from '../common/PlatformEmptyState'; +import { PlatformStatusMessage } from '../common/PlatformStatusMessage'; + +type GameThemesPageProps = { + onBack: () => void; + /** 「游戏广场」页签:回到 `/games`(同一个 stage 的既有页面)。 */ + onOpenGallery: () => void; + onOpenTheme: (themeId: string) => void; +}; + +/** + * 共创主题列表(游戏区的「共创」页签)。 + * + * 匿名可读;分页沿用服务端游标惯例(默认 20 / 上限 50 / 末页 nextCursor 为 null): + * 只有 `nextCursor` 非空才渲染「加载更多」,点击时把已加载的页追加在后面。 + */ +export function GameThemesPage({ + onBack, + onOpenGallery, + onOpenTheme, +}: GameThemesPageProps) { + const [themes, setThemes] = useState([]); + const [nextCursor, setNextCursor] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(''); + + const loadThemes = useCallback(async (cursor?: string) => { + setIsLoading(true); + setError(''); + try { + const page = await listThemes(cursor); + setThemes((current) => + cursor ? [...current, ...page.themes] : page.themes, + ); + setNextCursor(page.nextCursor); + } catch (loadError: unknown) { + setError( + loadError instanceof Error ? loadError.message : '读取共创主题失败', + ); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadThemes(); + }, [loadThemes]); + + return ( +
+ +
+
+ 游戏 +

共创主题

+
+ void loadThemes()} + > + +
+
+ + +
+ + {error ? ( + + {error} + + + ) : null} + {isLoading && themes.length === 0 ? ( +
正在加载共创主题…
+ ) : null} + {!isLoading && !error && themes.length === 0 ? ( + + 还没有已发布的共创主题,稍后再来看看。 + + ) : null} + {themes.length > 0 ? ( + <> +
    + {themes.map((theme) => ( +
  • + +
  • + ))} +
+ {nextCursor ? ( +
+ void loadThemes(nextCursor)} + > + 加载更多 + +
+ ) : null} + + ) : null} + +

+ 共创主题由平台命名,一个主题可以包含多棵作品树;作品之间仍各自独立展示。 +

+
+ ); +} + +export default GameThemesPage; diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 60798e955..9c2d0d939 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -567,6 +567,156 @@ font-weight: 600; cursor: pointer; } +/* 共创主题:列表卡片与主题页(成员根 + 内嵌作品树)。 */ +.game-theme-list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(18rem, 100%), 1fr)); + gap: 0.7rem; + margin: 0; + padding: 0; + list-style: none; +} +.game-theme-card { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + width: 100%; + box-sizing: border-box; + border: 1px solid var(--platform-surface-border); + border-radius: 0.75rem; + padding: 0.8rem 0.9rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + text-align: left; + cursor: pointer; +} +.game-theme-card:hover { + border-color: var(--platform-accent-strong); +} +.game-theme-card__badge { + border-radius: 999px; + padding: 0.1rem 0.5rem; + background: rgba(199, 101, 61, 0.14); + color: var(--platform-accent-strong); + font-size: 0.7rem; + font-weight: 700; +} +.game-theme-card__name { + color: var(--platform-text-strong); + font-size: 0.95rem; + font-weight: 700; + overflow-wrap: anywhere; +} +.game-theme-card__summary, +.game-theme-card__meta { + color: var(--platform-text-soft); + font-size: 0.78rem; + overflow-wrap: anywhere; +} +.game-theme-summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} +.game-theme-summary__badge { + border-radius: 999px; + padding: 0.12rem 0.6rem; + background: rgba(199, 101, 61, 0.14); + color: var(--platform-accent-strong); + font-size: 0.74rem; + font-weight: 700; +} +.game-theme-summary__text { + margin: 0; + color: var(--platform-text-soft); + font-size: 0.8rem; + line-height: 1.6; + overflow-wrap: anywhere; +} +.game-theme-trees { + display: flex; + flex-direction: column; + gap: 1.1rem; +} +.game-theme-root { + display: flex; + flex-direction: column; + gap: 0.55rem; + min-width: 0; + border: 1px solid var(--platform-surface-border); + border-radius: 0.85rem; + padding: 0.8rem 0.9rem; +} +.game-theme-root__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; +} +.game-theme-root__generation { + border-radius: 999px; + padding: 0.1rem 0.5rem; + background: var(--platform-surface-border); + color: var(--platform-text-soft); + font-size: 0.7rem; + font-weight: 700; +} +.game-theme-root__title { + margin: 0; + color: var(--platform-text-strong); + font-size: 0.95rem; + overflow-wrap: anywhere; +} +.game-theme-root__meta { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin: 0; + color: var(--platform-text-soft); + font-size: 0.78rem; + overflow-wrap: anywhere; +} +.game-theme-root__actions { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} +/* 内嵌的族谱页:去掉页面级留白,避免主题页里出现两层「页面」盒。 */ +.game-lineage-page--embedded { + max-width: none; + margin: 0; +} +.game-detail-info-card .game-fork-theme-list { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + justify-items: start; +} +.game-detail-info-card .game-fork-theme-link { + display: inline-flex; + align-items: center; + gap: 0.25rem; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0.18rem 0.6rem; + background: transparent; + color: var(--platform-text-base); + font: inherit; + font-size: 0.74rem; + font-weight: 600; + cursor: pointer; + overflow-wrap: anywhere; +} +.game-detail-info-card .game-fork-theme-link:hover { + color: var(--platform-text-strong); +} +.game-detail-info-card .game-fork-theme-badge { + color: var(--platform-accent-strong); + font-weight: 700; +} .game-loading-panel { display: grid; min-height: 18rem; diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx index 3199fdf65..223c08e89 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx @@ -55,6 +55,8 @@ const gameDistributionMock = vi.hoisted(() => ({ getDerivedGames: vi.fn(), getGame: vi.fn(), getGameLineage: vi.fn(), + getTheme: vi.fn(), + listThemes: vi.fn(), getGameVersion: vi.fn(), getMyGame: vi.fn(), listGames: vi.fn(), @@ -1494,3 +1496,106 @@ describe('创作族谱', () => { ); }); }); + +describe('共创主题', () => { + it('广场「共创」页签切到主题列表', async () => { + window.history.replaceState(null, '', '/games'); + gameDistributionMock.listGames.mockResolvedValue([]); + gameDistributionMock.listThemes.mockResolvedValue({ + themes: [], + nextCursor: null, + }); + const setSelectionStage = vi.fn(); + + render( + , + ); + + fireEvent.click(await screen.findByRole('tab', { name: '共创' })); + expect(setSelectionStage).toHaveBeenCalledWith('game-themes', { + path: '/games/themes', + }); + }); + + it('主题列表页渲染主题并进入主题页', async () => { + window.history.replaceState(null, '', '/games/themes'); + gameDistributionMock.listThemes.mockResolvedValue({ + themes: [ + { + themeId: 'theme-1', + name: '精选共创', + summary: '运营精选', + badge: '精选', + memberCount: 1, + }, + ], + nextCursor: null, + }); + const setSelectionStage = vi.fn(); + + render( + , + ); + + fireEvent.click(await screen.findByRole('button', { name: /精选共创/u })); + expect(setSelectionStage).toHaveBeenCalledWith('game-theme', { + path: '/games/theme?id=theme-1', + }); + }); + + it('主题详情按 ?id= 渲染主题名与成员根,并复用族谱查询', async () => { + window.history.replaceState(null, '', '/games/theme?id=theme-1'); + gameDistributionMock.getTheme.mockResolvedValue({ + themeId: 'theme-1', + name: '精选共创', + summary: '运营精选', + badge: '精选', + memberCount: 1, + roots: [ + { + id: 'game-root-1', + title: '根作品', + summary: '根', + description: '', + category: '动作', + tags: [], + coverColor: '#d77a51', + icon: '✦', + author: { id: 'author-1', name: '测试作者' }, + deviceSupport: { desktop: true, mobile: true, touch: true }, + status: 'published', + currentVersion: null, + playCount: 1, + createdAt: '2026-09-18T00:00:00.000Z', + }, + ], + }); + gameDistributionMock.getGameLineage.mockResolvedValue({ + rootGameId: 'game-root-1', + root: null, + nodes: [], + truncated: false, + }); + + render( + , + ); + + expect(await screen.findByRole('heading', { name: '精选共创' })).toBeTruthy(); + expect(screen.getByText('根作品')).toBeTruthy(); + expect(gameDistributionMock.getTheme).toHaveBeenCalledWith('theme-1'); + // 树复用既有族谱查询:按根请求一次。 + expect(gameDistributionMock.getGameLineage).toHaveBeenCalledWith( + 'game-root-1', + ); + }); +}); diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx index 3c19a7bd2..662ca185e 100644 --- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx +++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx @@ -125,6 +125,16 @@ const GameComparePage = lazy(async () => { return { default: module.GameComparePage }; }); +const GameThemesPage = lazy(async () => { + const module = await import('../game-distribution/GameThemesPage'); + return { default: module.GameThemesPage }; +}); + +const GameThemePage = lazy(async () => { + const module = await import('../game-distribution/GameThemePage'); + return { default: module.GameThemePage }; +}); + type ActiveRailButtonProps = { active: boolean; emphasized?: boolean; @@ -593,6 +603,38 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('games', { path: '/games' }); }, [setSelectionStage]); + /** 共创主题列表(`/games` 的「共创」页签)。 */ + const openGameThemes = useCallback(() => { + setSelectionStage('game-themes', { path: '/games/themes' }); + }, [setSelectionStage]); + + const backFromGameThemes = useCallback(() => { + if (hasAppHistoryBackEntry()) { + window.history.back(); + return; + } + replaceAppHistoryPath('/games'); + setSelectionStage('games', { path: '/games' }); + }, [setSelectionStage]); + + /** 主题详情:复用 `?id=` 约定(与 `/games/lineage?id=`、`/games/detail?id=` 同款)。 */ + const openGameTheme = useCallback( + (themeId: string) => { + const path = `/games/theme?id=${encodeURIComponent(themeId)}`; + setSelectionStage('game-theme', { path }); + }, + [setSelectionStage], + ); + + const backFromGameTheme = useCallback(() => { + if (hasAppHistoryBackEntry()) { + window.history.back(); + return; + } + replaceAppHistoryPath('/games/themes'); + setSelectionStage('game-themes', { path: '/games/themes' }); + }, [setSelectionStage]); + /** * 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到 * 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。 @@ -728,6 +770,8 @@ export function PlatformEntryFlowShellImpl({ selectionStage === 'games' || selectionStage === 'game-detail' || selectionStage === 'game-compare' || + selectionStage === 'game-themes' || + selectionStage === 'game-theme' || selectionStage === 'game-play' || selectionStage === 'game-mine' || selectionStage === 'game-lineage' || @@ -1002,6 +1046,7 @@ export function PlatformEntryFlowShellImpl({ searchKeyword={activeSearchKeyword} onOpenDetail={openGameDetail} onOpenMyGames={openMyGames} + onOpenThemes={openGameThemes} onOpenPublish={ gamePublishGateAllowed ? openGamePublish : undefined } @@ -1037,6 +1082,7 @@ export function PlatformEntryFlowShellImpl({ onPlay={openGamePlay} onOpenLineage={openGameLineage} onOpenCompare={openGameCompare} + onOpenTheme={openGameTheme} /> ) : selectionStage === 'game-lineage' ? ( + ) : selectionStage === 'game-themes' ? ( + + ) : selectionStage === 'game-theme' ? ( + ) : ( )} diff --git a/src/components/platform-entry/platformEntryActiveTypes.ts b/src/components/platform-entry/platformEntryActiveTypes.ts index 96697db9c..882138f97 100644 --- a/src/components/platform-entry/platformEntryActiveTypes.ts +++ b/src/components/platform-entry/platformEntryActiveTypes.ts @@ -11,6 +11,8 @@ export type SelectionStage = | 'games' | 'game-detail' | 'game-compare' + | 'game-themes' + | 'game-theme' | 'game-play' | 'game-mine' | 'game-lineage' diff --git a/src/routing/activeAppPageRoutes.test.ts b/src/routing/activeAppPageRoutes.test.ts index 4ba99876e..90c5d44b6 100644 --- a/src/routing/activeAppPageRoutes.test.ts +++ b/src/routing/activeAppPageRoutes.test.ts @@ -29,6 +29,8 @@ describe('appPageRoutes', () => { expect(resolveSelectionStageFromPath('/games/compare')).toBe( 'game-compare', ); + expect(resolveSelectionStageFromPath('/games/themes')).toBe('game-themes'); + expect(resolveSelectionStageFromPath('/games/theme')).toBe('game-theme'); expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play'); expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine'); expect(resolveSelectionStageFromPath('/games/lineage')).toBe( @@ -51,6 +53,8 @@ describe('appPageRoutes', () => { expect(resolvePathForSelectionStage('profile')).toBe('/profile'); expect(resolvePathForSelectionStage('games')).toBe('/games'); expect(resolvePathForSelectionStage('game-compare')).toBe('/games/compare'); + expect(resolvePathForSelectionStage('game-themes')).toBe('/games/themes'); + expect(resolvePathForSelectionStage('game-theme')).toBe('/games/theme'); expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine'); expect(resolvePathForSelectionStage('game-lineage')).toBe( '/games/lineage', diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts index 27d8ab495..d366ce807 100644 --- a/src/routing/activeAppPageRoutes.ts +++ b/src/routing/activeAppPageRoutes.ts @@ -16,6 +16,8 @@ const STAGE_ROUTE_ENTRIES = [ ['games', '/games'], ['game-detail', '/games/detail'], ['game-compare', '/games/compare'], + ['game-themes', '/games/themes'], + ['game-theme', '/games/theme'], ['game-play', '/games/play'], ['game-mine', '/games/mine'], ['game-lineage', '/games/lineage'], diff --git a/src/services/activeAppTitle.test.ts b/src/services/activeAppTitle.test.ts index c2333f749..beb61e0b0 100644 --- a/src/services/activeAppTitle.test.ts +++ b/src/services/activeAppTitle.test.ts @@ -42,6 +42,12 @@ describe('activeAppTitle', () => { expect(resolveAppTitleForSelectionStage('game-compare')).toBe( '原版与二创对比 - 陶泥儿', ); + expect(resolveAppTitleForSelectionStage('game-themes')).toBe( + '共创主题 - 陶泥儿', + ); + expect(resolveAppTitleForSelectionStage('game-theme')).toBe( + '共创主题详情 - 陶泥儿', + ); expect(resolveAppTitleForSelectionStage('game-publish')).toBe( '发布游戏 - 陶泥儿', ); diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts index e67ff7358..2111df2e3 100644 --- a/src/services/activeAppTitle.ts +++ b/src/services/activeAppTitle.ts @@ -17,6 +17,8 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = { games: '游戏 - 陶泥儿', 'game-detail': '游戏详情 - 陶泥儿', 'game-compare': '原版与二创对比 - 陶泥儿', + 'game-themes': '共创主题 - 陶泥儿', + 'game-theme': '共创主题详情 - 陶泥儿', 'game-play': '正在游玩 - 陶泥儿', 'game-mine': '我的游戏 - 陶泥儿', 'game-lineage': '创作族谱 - 陶泥儿', diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts index 783d53ab3..c0dbcc563 100644 --- a/src/services/gameDistributionClient.ts +++ b/src/services/gameDistributionClient.ts @@ -373,8 +373,114 @@ export async function getGame(gameId: string) { */ export type GameDistributionGameWithCollection = GameDistributionGame & { collected?: boolean; + /** + * 该作品所属的公开共创主题(先取作品的根再按根反查,因此**第 N 代作品也会有值**)。 + * 匿名也返回:没有所属主题时是 `[]` 而不是缺键。 + */ + themes?: GameDistributionThemeReference[]; }; +/** 作品详情里的主题入口:只够渲染一个跳转(简介与成员数在主题页取)。 */ +export type GameDistributionThemeReference = { + themeId: string; + name: string; + badge: string; +}; + +/** 共创主题摘要(公开列表条目)。 */ +export type GameDistributionThemeSummary = { + themeId: string; + name: string; + summary: string; + badge: string; + /** 当前**可见**成员数(与主题详情 `roots` 长度同口径,不含草稿/已下架成员)。 */ + memberCount: number; +}; + +/** 主题详情:摘要 + 成员根清单(逐条是公开目录同一份投影)。 */ +export type GameDistributionThemeDetail = GameDistributionThemeSummary & { + roots: GameDistributionGame[]; +}; + +function normalizeThemeList(value: unknown): GameDistributionThemeSummary[] { + if (!Array.isArray(value)) return []; + return value.flatMap((item) => { + if (!item || typeof item !== 'object') return []; + const theme = item as Partial; + const themeId = typeof theme.themeId === 'string' ? theme.themeId.trim() : ''; + const name = typeof theme.name === 'string' ? theme.name.trim() : ''; + if (!themeId || !name) return []; + return [ + { + themeId, + name, + summary: typeof theme.summary === 'string' ? theme.summary : '', + badge: typeof theme.badge === 'string' ? theme.badge : '', + memberCount: + typeof theme.memberCount === 'number' && theme.memberCount >= 0 + ? theme.memberCount + : 0, + }, + ]; + }); +} + +/** + * 共创主题列表(匿名可读)。分页与 `/my-collections` 同口径:服务端默认 20 / 上限 50, + * `nextCursor` 是真实游标(末页为 `null`),客户端只在非空时渲染「加载更多」。 + */ +export async function listThemes(cursor?: string) { + const params = new URLSearchParams(); + const normalizedCursor = cursor?.trim(); + if (normalizedCursor) params.set('cursor', normalizedCursor); + const query = params.toString(); + const payload = await requestJson<{ + themes?: unknown; + nextCursor?: string | null; + }>( + `/themes${query ? `?${query}` : ''}`, + { method: 'GET', cache: 'no-store' }, + '读取共创主题失败', + PUBLIC_GAME_REQUEST_OPTIONS, + ); + return { + themes: normalizeThemeList(payload.themes), + nextCursor: payload.nextCursor ?? null, + }; +} + +/** + * 共创主题详情(匿名可读)。主题不存在 / 未发布一律 404(服务端不返回空壳), + * 这里按失败抛出,由主题页渲染成「主题不存在或尚未发布」的降级态。 + */ +export async function getTheme( + themeId: string, +): Promise { + const normalizedThemeId = themeId.trim(); + if (!normalizedThemeId) return null; + const payload = await requestJson<{ + themeId?: string; + name?: string; + summary?: string; + badge?: string; + memberCount?: number; + roots?: unknown; + }>( + `/themes/${encodeURIComponent(normalizedThemeId)}`, + { method: 'GET', cache: 'no-store' }, + '读取共创主题失败', + PUBLIC_GAME_REQUEST_OPTIONS, + ); + return { + themeId: payload.themeId ?? normalizedThemeId, + name: payload.name ?? '', + summary: payload.summary ?? '', + badge: payload.badge ?? '', + memberCount: payload.memberCount ?? 0, + roots: normalizeGameList(payload.roots), + }; +} + /** 收藏切换的响应;DELETE 不带 `replayed`。 */ export type GameDistributionCollectionState = { collected: boolean;