diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx
index 21b33b349..ade916523 100644
--- a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx
+++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.test.tsx
@@ -47,9 +47,12 @@ test('shows the active mobile welcome when entering the mobile site shell', () =
test('mobile welcome exempts read-only browsing stages(族谱 / 共创主题)', () => {
// 防退化:白名单内容必须与实现一致(不是只断言某一个 stage)。
- expect(
- Object.keys(MOBILE_WELCOME_EXEMPT_STAGES).sort(),
- ).toEqual(['game-lineage', 'game-theme', 'game-themes']);
+ expect(Object.keys(MOBILE_WELCOME_EXEMPT_STAGES).sort()).toEqual([
+ 'game-co-creation',
+ 'game-lineage',
+ 'game-theme',
+ 'game-themes',
+ ]);
// 每个豁免 stage(含带 ?id= 的公开深链)在移动端都不再弹。
for (const stage of Object.keys(MOBILE_WELCOME_EXEMPT_STAGES)) {
diff --git a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx
index 54338e941..384fd50ad 100644
--- a/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx
+++ b/src/components/platform-entry/PlatformActiveMobileWelcomeDialog.tsx
@@ -33,6 +33,8 @@ export const MOBILE_WELCOME_EXEMPT_STAGES: Readonly
> = {
'game-lineage': true,
// 共创主题列表:`/games/themes`(公开只读)
'game-themes': true,
+ // 「共创」页签:只列支持共创的作品(公开只读,本身就是「作品展示」)
+ 'game-co-creation': true,
// 共创主题详情:`/games/theme?id=`(公开只读)
'game-theme': true,
};
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
index 976c3c4e7..e3d0774ad 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
@@ -1538,8 +1538,8 @@ describe('创作族谱', () => {
});
});
-describe('共创主题', () => {
- it('广场「共创」页签切到主题列表', async () => {
+describe('共创页签(只列支持共创的作品)', () => {
+ it('广场「共创」页签切到 game-co-creation(不再是主题列表)', async () => {
window.history.replaceState(null, '', '/games');
gameDistributionMock.listGames.mockResolvedValue({
games: [],
@@ -1559,12 +1559,66 @@ describe('共创主题', () => {
);
fireEvent.click(await screen.findByRole('tab', { name: '共创' }));
- expect(setSelectionStage).toHaveBeenCalledWith('game-themes', {
- path: '/games/themes',
+ expect(setSelectionStage).toHaveBeenCalledWith('game-co-creation', {
+ path: '/games/co-creation',
});
+ // 主题列表接口在这条路径上**不该**被请求(页签已不指向主题)。
+ expect(gameDistributionMock.listThemes).not.toHaveBeenCalled();
});
- it('主题列表页渲染主题并进入主题页', async () => {
+ it('game-co-creation 渲染的是游戏卡片,且请求带 forkable=1', async () => {
+ window.history.replaceState(null, '', '/games/co-creation');
+ gameDistributionMock.listGames.mockResolvedValue({
+ games: [
+ {
+ id: 'game-forkable',
+ title: '可以共创的作品',
+ summary: '简介',
+ description: '描述',
+ category: '动作',
+ tags: [],
+ coverColor: '#d77a51',
+ icon: '✦',
+ author: { id: 'author-1', name: '演示作者' },
+ deviceSupport: { desktop: true, mobile: true, touch: true },
+ status: 'published',
+ publicationRevision: 1,
+ currentVersion: {
+ id: 'version-1',
+ version: '1.0.0',
+ entryUrl: 'https://play.example.test/v1/index.html',
+ sha256: 'sha256',
+ publishedAt: '2026-09-18T00:00:00.000Z',
+ controls: ['键盘'],
+ },
+ playCount: 3,
+ createdAt: '2026-09-18T00:00:00.000Z',
+ forkAuthorization: 'full',
+ },
+ ],
+ nextCursor: null,
+ });
+ const setSelectionStage = vi.fn();
+
+ render(
+ ,
+ );
+
+ // 卡片来自广场同一套组件(标题可见),而不是主题卡。
+ expect(await screen.findByText('可以共创的作品')).toBeTruthy();
+ expect(gameDistributionMock.listGames).toHaveBeenCalledWith(
+ expect.objectContaining({ forkable: true }),
+ );
+ // 共创页签里不该出现主题列表页。
+ expect(document.querySelector('.game-themes-page')).toBeNull();
+ });
+});
+
+describe('共创主题', () => {
+ it('主题列表页(保留路由、无页签入口)渲染主题并进入主题页', async () => {
window.history.replaceState(null, '', '/games/themes');
gameDistributionMock.listThemes.mockResolvedValue({
themes: [
diff --git a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
index 78cd2da6e..95e2f3741 100644
--- a/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
+++ b/src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
@@ -606,9 +606,12 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
- /** 共创主题列表(`/games` 的「共创」页签)。 */
- const openGameThemes = useCallback(() => {
- setSelectionStage('game-themes', { path: '/games/themes' });
+ /**
+ * 「共创」页签:**只列支持共创的作品**(游戏卡片 + 同一套游标分页)。
+ * 它不再指向共创主题列表——主题列表页(stage `game-themes`)与路由保留,只是没有页签入口。
+ */
+ const openGameCoCreation = useCallback(() => {
+ setSelectionStage('game-co-creation', { path: '/games/co-creation' });
}, [setSelectionStage]);
const backFromGameThemes = useCallback(() => {
@@ -634,8 +637,8 @@ export function PlatformEntryFlowShellImpl({
window.history.back();
return;
}
- replaceAppHistoryPath('/games/themes');
- setSelectionStage('game-themes', { path: '/games/themes' });
+ replaceAppHistoryPath('/games/co-creation');
+ setSelectionStage('game-co-creation', { path: '/games/co-creation' });
}, [setSelectionStage]);
/**
@@ -774,6 +777,7 @@ export function PlatformEntryFlowShellImpl({
selectionStage === 'game-detail' ||
selectionStage === 'game-compare' ||
selectionStage === 'game-themes' ||
+ selectionStage === 'game-co-creation' ||
selectionStage === 'game-theme' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
@@ -1049,7 +1053,7 @@ export function PlatformEntryFlowShellImpl({
searchKeyword={activeSearchKeyword}
onOpenDetail={openGameDetail}
onOpenMyGames={openMyGames}
- onOpenThemes={openGameThemes}
+ onOpenThemes={openGameCoCreation}
onOpenPublish={
gamePublishGateAllowed ? openGamePublish : undefined
}
@@ -1103,6 +1107,13 @@ export function PlatformEntryFlowShellImpl({
onBack={backFromGameCompare}
onOpenDetail={openGameDetail}
/>
+ ) : selectionStage === 'game-co-creation' ? (
+
) : selectionStage === 'game-themes' ? (
{
'game-compare',
);
expect(resolveSelectionStageFromPath('/games/themes')).toBe('game-themes');
+ expect(resolveSelectionStageFromPath('/games/co-creation')).toBe(
+ 'game-co-creation',
+ );
expect(resolveSelectionStageFromPath('/games/theme')).toBe('game-theme');
expect(resolveSelectionStageFromPath('/games/play')).toBe('game-play');
expect(resolveSelectionStageFromPath('/games/mine')).toBe('game-mine');
@@ -54,11 +57,12 @@ describe('appPageRoutes', () => {
expect(resolvePathForSelectionStage('games')).toBe('/games');
expect(resolvePathForSelectionStage('game-compare')).toBe('/games/compare');
expect(resolvePathForSelectionStage('game-themes')).toBe('/games/themes');
+ expect(resolvePathForSelectionStage('game-co-creation')).toBe(
+ '/games/co-creation',
+ );
expect(resolvePathForSelectionStage('game-theme')).toBe('/games/theme');
expect(resolvePathForSelectionStage('game-mine')).toBe('/games/mine');
- expect(resolvePathForSelectionStage('game-lineage')).toBe(
- '/games/lineage',
- );
+ expect(resolvePathForSelectionStage('game-lineage')).toBe('/games/lineage');
expect(resolvePathForSelectionStage('game-publish')).toBe('/games/publish');
expect(resolvePathForSelectionStage('profile-payment')).toBe(
'/profile/payment',
diff --git a/src/routing/activeAppPageRoutes.ts b/src/routing/activeAppPageRoutes.ts
index d366ce807..5b4ed76a4 100644
--- a/src/routing/activeAppPageRoutes.ts
+++ b/src/routing/activeAppPageRoutes.ts
@@ -17,6 +17,9 @@ const STAGE_ROUTE_ENTRIES = [
['game-detail', '/games/detail'],
['game-compare', '/games/compare'],
['game-themes', '/games/themes'],
+ // 「共创」页签:只列**支持共创**的作品(游戏卡片 + 同一套游标分页)。
+ // 主题列表(`/games/themes`)与主题详情仍保留路由,只是页签不再指向它们。
+ ['game-co-creation', '/games/co-creation'],
['game-theme', '/games/theme'],
['game-play', '/games/play'],
['game-mine', '/games/mine'],
diff --git a/src/services/activeAppTitle.test.ts b/src/services/activeAppTitle.test.ts
index beb61e0b0..e8dbd564d 100644
--- a/src/services/activeAppTitle.test.ts
+++ b/src/services/activeAppTitle.test.ts
@@ -45,6 +45,9 @@ describe('activeAppTitle', () => {
expect(resolveAppTitleForSelectionStage('game-themes')).toBe(
'共创主题 - 陶泥儿',
);
+ expect(resolveAppTitleForSelectionStage('game-co-creation')).toBe(
+ '共创 - 陶泥儿',
+ );
expect(resolveAppTitleForSelectionStage('game-theme')).toBe(
'共创主题详情 - 陶泥儿',
);
diff --git a/src/services/activeAppTitle.ts b/src/services/activeAppTitle.ts
index 2111df2e3..a76a2a382 100644
--- a/src/services/activeAppTitle.ts
+++ b/src/services/activeAppTitle.ts
@@ -18,6 +18,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Record = {
'game-detail': '游戏详情 - 陶泥儿',
'game-compare': '原版与二创对比 - 陶泥儿',
'game-themes': '共创主题 - 陶泥儿',
+ 'game-co-creation': '共创 - 陶泥儿',
'game-theme': '共创主题详情 - 陶泥儿',
'game-play': '正在游玩 - 陶泥儿',
'game-mine': '我的游戏 - 陶泥儿',
diff --git a/src/services/gameDistributionClient.test.ts b/src/services/gameDistributionClient.test.ts
index c2918c54f..61daf11ba 100644
--- a/src/services/gameDistributionClient.test.ts
+++ b/src/services/gameDistributionClient.test.ts
@@ -135,6 +135,25 @@ describe('gameDistributionClient', () => {
expect(fetchMock.mock.calls[2][0]).toBe('/api/game-distribution/games');
});
+ it('forkable 只发 1、省略时不带该参数(服务端未上线也不影响)', async () => {
+ const fetchMock = vi.fn().mockImplementation(() =>
+ Promise.resolve(
+ new Response(JSON.stringify({ games: [], nextCursor: null }), {
+ status: 200,
+ }),
+ ),
+ );
+ vi.stubGlobal('fetch', fetchMock);
+ await listGames({ forkable: true });
+ expect(fetchMock.mock.calls[0][0]).toBe(
+ '/api/game-distribution/games?forkable=1',
+ );
+ await listGames({ forkable: false });
+ expect(fetchMock.mock.calls[1][0]).toBe('/api/game-distribution/games');
+ await listGames();
+ expect(fetchMock.mock.calls[2][0]).toBe('/api/game-distribution/games');
+ });
+
it('作者过滤由查询参数交给后端,显式空值不退回全站游戏', async () => {
const fetchMock = vi
.fn()
diff --git a/src/services/gameDistributionClient.ts b/src/services/gameDistributionClient.ts
index 27038d7ff..cb798ee70 100644
--- a/src/services/gameDistributionClient.ts
+++ b/src/services/gameDistributionClient.ts
@@ -60,6 +60,14 @@ export type GameListQuery = {
* 一页条数。缺省 = 服务端默认 **48**;上限 **100**(超界服务端**截断**不报错,`0` 视作缺省)。
*/
limit?: number;
+ /**
+ * 只返回**支持共创**的作品(`forkAuthorization != 'forbidden'`)。
+ *
+ * 契约(2026-10-07 冻结):请求参数 `forkable=1`;省略 = 全部(与现状一致)。
+ * 分页语义完全沿用(默认 48 / 上限 100、`created_at` 倒序 + game_id 升序、游标不透明、
+ * 末页 `null`、非法游标 `CATALOG_INVALID_CURSOR`)。
+ */
+ forkable?: boolean;
/**
* 上一页响应里的 `nextCursor`,**原样回传**即可(对客户端不透明;末页为 `null`)。
* 自己拼游标会破坏服务端的稳定全序(`created_at` 倒序 + `game_id` 升序),所以不允许。
@@ -163,6 +171,7 @@ export async function listGames(
if (typeof query.limit === 'number' && query.limit > 0) {
params.set('limit', String(Math.trunc(query.limit)));
}
+ if (query.forkable) params.set('forkable', '1');
if (query.cursor?.trim()) params.set('cursor', query.cursor.trim());
const search = params.toString();
const payload = await requestJson<{ games?: unknown; nextCursor?: unknown }>(