diff --git a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md index c7d0f7c49..4f9834838 100644 --- a/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md +++ b/docs/【技术方案】游戏共创与作品Fork-2026-10-03.md @@ -434,15 +434,30 @@ A 路线里有一个必须提前知道的互斥点:`create_npm_scaffold` 的 | 文件 | 改动 | | --- | --- | | `src/routing/activeAppPageRoutes.ts` | 新增 `['game-lineage', '/games/lineage']`;同步 `SelectionStage` 类型与 `PlatformEntryActiveFlowShell` 的 stage 分支 | -| `src/components/game-distribution/GameDetailPage.tsx` | 授权徽章、溯源卡、「改造这个作品」主行动作、族谱入口 | -| `src/components/game-distribution/GameLineagePage.tsx`(新) | 族谱树页面 | -| `src/components/game-distribution/MyGamesPage.tsx` | 授权三态设置(只升)、「被改编 N」弹层 | -| `src/components/game-distribution/GamePublishPage.tsx` | 「授权共创」三态单选 + 不可撤销提示 | -| `src/services/gameDistributionClient.ts` | 新增 `setForkAuthorization` / `getGameLineage`;`getGame` 类型增量 | +| `src/components/game-distribution/GameDetailPage.tsx` | 世代/授权标签、数据概览(累计衍生作品 + 累计创作世代数)、血缘链(纵向滚动单链)、热门衍生栏、族谱入口、主题入口、「一键 Fork 共创」主行动作 | +| `src/components/game-distribution/GameLineagePage.tsx`(新) | 族谱页:桌面画布(节点-连线树)、移动端大纲模式、筛选器、主干线、信息面板 / bottom sheet | +| `src/components/game-distribution/LineageNodeContent.tsx`(新) | 族谱**节点内容**(封面 + 代际角标 + 标题 + 作者)唯一实现,画布卡片与血缘链共用(避免第二套省略规则导致逐字换行) | +| `src/components/game-distribution/LineageOutlineList.tsx`(新) | 移动端**大纲式缩进树**:全宽行 + 左缩进 + 肘形导线,无横向滚动 | +| `src/components/game-distribution/LineageInfoPanel.tsx`(新) | 选中节点信息面板:桌面右侧浮层 / 移动端 bottom sheet(同一组件两种形态) | +| `src/components/game-distribution/MyGamesPage.tsx` | 授权三态设置(只升,**仅母版**;衍生作品显示只读「继承自父作品」)、分类标签、「被改编 N」行内展开 | +| `src/components/game-distribution/GamePublishPage.tsx` | 发布新版本的**「本次核心改动说明」**(衍生作品必填,20~500 字,前端同等校验) | +| `src/services/gameDistributionClient.ts` | `setForkAuthorization` / `getGameLineage` / `getDerivedGames` / `setGameCollection` / `listMyCollections` / `listThemes` / `getTheme`;`getGame` 类型增量 | | `apps/ai-game-creator-shell` | 发布面板授权选择;`genarrative://fork` deep link 注册;改造入口与进度/失败提示 | | `apps/admin-web/src/pages/AdminGameManagementPage.tsx` | 列表新增授权 / 代际列 | -移动端:移动 dock 只有游戏 / 我的,族谱页与详情页可读;「改造这个作品」在移动端提示「请在桌面端改造」,与现有创作/项目入口的桌面端提示同口径。 +#### 3.6.1 交互口径(2026-10-06 补充) + +- **世代与授权标签**:世代按文档三档命名 `0 代原创 / 1 代二创 / 2 代三创 / N 代迭代`(`coCreationCopy.ts`);授权类型沿用平台既有常量(禁止共创 / 允许非商用共创 / 允许全开放共创)。 +- **主操作「一键 Fork 共创」始终显示**:授权为「禁止共创」时**置灰(`aria-disabled`)并给出原因**(悬停 `title` + 点击可读提示),不再静默隐藏;未登录走既有登录门禁。 +- **衍生作品的授权不可单独提升**:服务端会拒绝对衍生作品的授权 PUT,因此我的作品页对衍生作品**隐藏提升入口**,改为只读展示「共创授权继承自父作品(当前档位)」。 +- **血缘链(详情页共创卡)**:只画**祖先链**(根 → … → 当前),**纵向滚动**(`overflow-y: auto` / `overflow-x: hidden`)、**初次进入与当前作品变化时自动滚到底**;节点卡片为「横版封面缩略 + 标题 + 代际角标」的矮宽行,标题 2 行截断(`min-width: 0` + line-clamp),**不允许逐字换行**。整棵树由「查看创作族谱」入口承担——右栏只有 200 多像素,塞整棵树必然不可读。 +- **「累计创作世代数」在前端计算**:=本作品子树深度(子树最大代际 − 本作品代际),用详情页**已经请求的同一份 `/lineage` 数据**算,不新增请求、也不加服务端字段。 +- **移动端形态**(跟随仓库既有窄断点,`useIsMobileViewport`): + - **默认大纲式缩进树**(`LineageOutlineList`):全宽行、层级用左缩进 + 肘形导线表达、主干链导线加粗用主题色;**无横向滚动**、无需缩放平移;行高 ≥44px,点击行 = 选中(无 hover)。 + - **图谱视图为可选开关**:切到画布后支持拖拽平移 / 缩放;布局层支持 `orientation: 'horizontal'`(根在左、代际向右,兄弟分支沿纵向排开)——窄屏上比顶→下更省横向空间。 + - **信息面板用 bottom sheet**:从底部升起(含简介 / 授权 / 核心改动说明 / 前往体验 / Fork 此版本),遮罩点击或关闭按钮收起;桌面保持画布右上浮层。 + - 面包屑横向可滚动且**当前项常驻可见**;尊重 `prefers-reduced-motion`;底部留 `env(safe-area-inset-bottom)`。 +- **截断 / 空树 / 不可用父节点**在两种形态下都有降级:截断提示、空态、以及「原作品已不可用」的占位节点(可聚焦、不可点进详情、不展示被删作品标题与作者名)。 ### 3.7 灰度与可观测 diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 0cada2f58..0fb937476 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -2652,37 +2652,6 @@ describe('GameLineagePage 可视化与交互', () => { ); }); - it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => { - setViewportWidth(375); - getGameLineageMock.mockResolvedValueOnce(treeWithSibling); - render( - , - ); - - const leafCard = await cardOf('叶节点'); - expect(leafCard.className).toContain('game-lineage-node--compact'); - // 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。 - expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull(); - expect( - within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), - ).toBeTruthy(); - // 卡片上只保留「打开作品 / Fork」两个动作按钮(第三个次要动作已按复验意见移除), - // 封面+标题本身也是一个打开详情的按钮。 - expect( - within(leafCard).getByRole('button', { name: '打开作品' }), - ).toBeTruthy(); - expect( - within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), - ).toBeTruthy(); - expect( - within(leafCard).queryByRole('button', { name: /选中|锁定/u }), - ).toBeNull(); - }); - it('缩放按钮改变百分比,「适应窗口」重新适配', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); render( @@ -2932,6 +2901,199 @@ describe('GameLineagePage 筛选器 / 主干线 / 信息面板(文档对齐) fireEvent.click(within(panel).getByRole('button', { name: 'Fork 此版本' })); expect(openForkProtocolMock).toHaveBeenCalledWith('hot'); }); + + it('窄屏默认大纲模式:缩进层级、顺序、主干导线、无横向溢出', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + // 移动端默认不再用画布。 + expect(document.querySelector('.game-lineage-canvas')).toBeNull(); + // 顺序:根(0 代)→ 1 代 → 1 代 → 2 代。 + const rows = [...outline.querySelectorAll('.lineage-outline__row')]; + expect(rows.map((row) => row.getAttribute('data-generation'))).toEqual([ + '0', + '1', + '1', + '2', + ]); + const indentOf = (index: number) => + parseFloat((rows[index] as HTMLElement).style.paddingLeft); + expect(indentOf(0)).toBe(0); + expect(indentOf(1)).toBeGreaterThan(indentOf(0)); + expect(indentOf(3)).toBeGreaterThan(indentOf(1)); + // 主干导线标记存在(热门二创是该代热度最高者)。 + expect( + outline.querySelectorAll('.lineage-outline__guide--trunk').length, + ).toBeGreaterThan(0); + // 无横向溢出:大纲是全宽行 + 左缩进,绝不横向滚。 + expect(outline.scrollWidth <= outline.clientWidth).toBe(true); + }); + + it('大纲行:点击行是选中(不导航),行内「打开」才导航,键盘可达', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + getGameMock.mockResolvedValueOnce(createGame({ summary: '选中后的简介' })); + const onOpenDetail = vi.fn(); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + const rowButton = (await within(outline).findByRole('button', { + name: /第 1 代《热门二创》/u, + })) as HTMLElement; + expect(rowButton.tagName).toBe('BUTTON'); + rowButton.focus(); + expect(document.activeElement).toBe(rowButton); + + fireEvent.click(rowButton); + // 点击行 = 选中,不导航。 + expect(onOpenDetail).not.toHaveBeenCalled(); + expect(await screen.findByLabelText('选中作品信息')).toBeTruthy(); + + fireEvent.click( + within(outline).getByRole('button', { name: '打开《热门二创》详情' }), + ); + expect(onOpenDetail).toHaveBeenCalledWith('hot'); + }); + + it('窄屏选中节点后用 bottom sheet 展示简介/授权/改动说明与两个操作', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + getGameMock.mockResolvedValueOnce( + createGame({ + summary: '这是一个二创作品的简介', + forkAuthorization: 'nonCommercial', + currentVersion: { + id: 'v1', + versionNumber: 1, + changeSummary: '把跳台改成三段,并重画全部背景', + }, + }), + ); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + fireEvent.click( + await within(outline).findByRole('button', { + name: /第 1 代《热门二创》/u, + }), + ); + + const sheet = await screen.findByLabelText('选中作品信息'); + expect(sheet.className).toContain('game-lineage-panel--sheet'); + expect(within(sheet).getByText('这是一个二创作品的简介')).toBeTruthy(); + expect(within(sheet).getByText(/允许非商用共创/u)).toBeTruthy(); + expect( + within(sheet).getByText(/核心改动说明:把跳台改成三段/u), + ).toBeTruthy(); + expect( + within(sheet).getByRole('button', { name: '前往体验' }), + ).toBeTruthy(); + expect( + within(sheet).getByRole('button', { name: 'Fork 此版本' }), + ).toBeTruthy(); + // 遮罩点击关闭。 + fireEvent.click(screen.getByLabelText('关闭信息面板')); + await waitFor(() => + expect(screen.queryByLabelText('选中作品信息')).toBeNull(), + ); + }); + + it('面包屑在选中后出现,当前项在链尾(横向滚动容器里常驻可见)', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + getGameMock.mockResolvedValueOnce(createGame({ summary: '简介' })); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + fireEvent.click( + await within(outline).findByRole('button', { + name: /第 2 代《深代作品》/u, + }), + ); + + const breadcrumb = await screen.findByLabelText('已选中的路径'); + const crumbs = [ + ...breadcrumb.querySelectorAll('.game-lineage-breadcrumb__crumb'), + ]; + expect(crumbs.map((crumb) => crumb.textContent)).toEqual([ + '母版《根原创》', + '第 1 代《热门二创》', + '第 2 代《深代作品》', + ]); + expect(crumbs[crumbs.length - 1]?.textContent).toContain('深代作品'); + }); + + it('图谱视图开关:移动端可切到画布(形态 B)并仍能选中节点', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + getGameMock.mockResolvedValueOnce(createGame({ summary: '简介' })); + render( + , + ); + + await screen.findByLabelText('创作族谱大纲'); + fireEvent.click(screen.getByRole('button', { name: '图谱视图' })); + + await waitFor(() => + expect(document.querySelector('.game-lineage-canvas')).not.toBeNull(), + ); + expect(screen.queryByLabelText('创作族谱大纲')).toBeNull(); + expect(screen.getByLabelText('画布控件')).toBeTruthy(); + fireEvent.click(await cardOf('热门二创')); + expect(await screen.findByLabelText('选中作品信息')).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: '大纲视图' })); + expect(await screen.findByLabelText('创作族谱大纲')).toBeTruthy(); + }); + + it('窄屏仍能点进详情页与查看完整族谱入口(大纲模式不丢入口)', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(tree); + render( + , + ); + + const outline = await screen.findByLabelText('创作族谱大纲'); + expect( + within(outline).getByRole('button', { name: '打开《根原创》详情' }), + ).toBeTruthy(); + expect( + within(outline).getByRole('button', { name: 'Fork 根原创' }), + ).toBeTruthy(); + }); }); describe('GameDetailPage 共创模块(文档对齐)', () => { @@ -3163,9 +3325,9 @@ describe('GameDetailPage 共创模块(文档对齐)', () => { expect( scroll.querySelectorAll('.game-lineage-node__content--inline').length, ).toBeGreaterThan(0); - expect(scroll.querySelectorAll('.co-creation-chain__connector').length).toBe( - 2, - ); + expect( + scroll.querySelectorAll('.co-creation-chain__connector').length, + ).toBe(2); }); it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => { diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 2afc4c560..c37f95a0d 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -11,10 +11,6 @@ import { type WheelEvent as ReactWheelEvent, } from 'react'; -import { - GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, - resolveGameForkAuthorization, -} from '../../../packages/shared/src/contracts/gameDistribution'; import { type GameDistributionGame, type GameDistributionLineageResponse, @@ -30,7 +26,9 @@ import { FORK_LAUNCH_FALLBACK_DELAY_MS, openForkProtocol, } from './gameForkProtocol'; +import { LineageInfoPanel } from './LineageInfoPanel'; import { LineageNodeContent } from './LineageNodeContent'; +import { LineageOutlineList } from './LineageOutlineList'; import { buildLineageTreeLayout, collectLineageHighlight, @@ -241,6 +239,11 @@ export function GameLineagePage({ ); const isMobileViewport = useIsMobileViewport(); const [includeSubtree, setIncludeSubtree] = useState(true); + /** + * 形态:移动端默认**大纲式缩进树**(无横向滚动、触控友好),可用「图谱视图」切回画布; + * 桌面默认画布。`null` = 跟随当前断点的默认值。 + */ + const [viewMode, setViewMode] = useState<'outline' | 'canvas' | null>(null); const [viewport, setViewport] = useState({ scale: 1, offsetX: 0, @@ -255,6 +258,7 @@ export function GameLineagePage({ resetDelayMs: 5000, }); const viewportRef = useRef(null); + const currentCrumbRef = useRef(null); const forkTimerRef = useRef(null); const forkHandlersRef = useRef<(() => void) | null>(null); const dragRef = useRef<{ @@ -370,6 +374,15 @@ export function GameLineagePage({ })); }, [layout.nodes, pathIds, pinnedGameId]); const anchorId = highlightGameId?.trim() || normalizedRootId; + const effectiveViewMode = + viewMode ?? (isMobileViewport ? 'outline' : 'canvas'); + const trunkGameIds = useMemo( + () => + new Set( + layout.nodes.filter((item) => item.isTrunk).map((item) => item.gameId), + ), + [layout.nodes], + ); const derivedCount = nodes.filter((node) => node.generation > 0).length; const nodeCountLabel = layout.nodes.length > 0 @@ -466,12 +479,13 @@ export function GameLineagePage({ const selectedLayoutNode = pinnedGameId ? (layout.nodes.find((item) => item.gameId === pinnedGameId) ?? null) : null; - const selectedForkAuthorization = selectedGame - ? resolveGameForkAuthorization(selectedGame.forkAuthorization) - : null; - /** 核心改动说明在**版本摘要层**(`currentVersion.changeSummary`),从这一次公开详情里读。 */ - const selectedCoreChangeNote = - selectedGame?.currentVersion?.changeSummary?.trim() ?? ''; + // 面包屑在移动端横向滚动:当前项常驻可见(jsdom 里 scrollIntoView 可能不存在,故用可选调用)。 + useEffect(() => { + currentCrumbRef.current?.scrollIntoView?.({ + inline: 'nearest', + block: 'nearest', + }); + }, [pinnedGameId]); const zoomBy = useCallback((factor: number) => { setViewport((current) => { @@ -669,6 +683,19 @@ export function GameLineagePage({ /> 按热度排序 + {/* 形态切换:移动端默认大纲,需要看全局时可以切到图谱(画布)。 */} + {/* 改动类型筛选:`changeSummary` 字段已上线,但它是**自由文本**(服务端没有分类枚举), 因此仍然**禁用 + 说明**,不编造筛选值域;等后端给出可枚举的改动类型再接。 @@ -725,255 +752,235 @@ export function GameLineagePage({ /> ) : null} + {pinnedChain.length > 0 ? (
- {pinnedChain.length > 0 ? ( -
( + - {pinnedChain.map((crumb, index) => ( - - {index > 0 ? : null} - - - ))} + {index > 0 ? : null} -
- ) : null} -
+ ))} +
+ ) : null} + {effectiveViewMode === 'outline' ? ( + + setPinnedGameId((current) => + current === gameId ? '' : gameId, + ) + } + onOpenDetail={onOpenDetail} + onOpenFork={launchForkFromNode} + /> + ) : ( +
+
- {layout.edges.map((edge) => ( -
- {/* +
+ {/* 代际轴是**固定层**:在 transform 之外,位置按视口换算,字号不随缩放变小, 因此放大或平移后轴标签始终在画布左侧可读,而不会被内容层带出去裁掉。 */} - + {/* 画布内悬浮控件:放在**右下角**——左侧是代际轴、顶部是路径面包屑, 右下角是地图/画布类应用的习惯位置,也最不容易遮住根节点与主干。 */} -
event.stopPropagation()} - > - - - {Math.round(viewport.scale * 100)}% - - -
- {/* + +
+ {/* 右侧信息面板:只在**选中节点**时出现,浮动叠加(不推挤画布)。 简介 / 授权类型只为选中的这一个节点请求一次公开详情; 「核心改动说明」是后端待补字段,缺失时整行不渲染。 */} - {selectedLayoutNode ? ( - + {selectedLayoutNode ? ( + setPinnedGameId('')} + onOpenDetail={onOpenDetail} + onOpenFork={launchForkFromNode} + /> ) : null} - + + )} + {/* 大纲模式下的 bottom sheet(桌面形态的面板在画布内,见上一分支)。 */} + {effectiveViewMode === 'outline' && selectedLayoutNode ? ( + setPinnedGameId('')} + onOpenDetail={onOpenDetail} + onOpenFork={launchForkFromNode} + /> + ) : null} {derivedCount === 0 ? ( 这部作品还没有衍生作品,欢迎从它开始共创。 diff --git a/src/components/game-distribution/LineageInfoPanel.tsx b/src/components/game-distribution/LineageInfoPanel.tsx new file mode 100644 index 000000000..fe9c8065b --- /dev/null +++ b/src/components/game-distribution/LineageInfoPanel.tsx @@ -0,0 +1,115 @@ +import { + GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS, + resolveGameForkAuthorization, +} from '../../../packages/shared/src/contracts/gameDistribution'; +import type { GameDistributionGame } from '../../services/gameDistributionClient'; +import type { LineageLayoutNode } from './lineageTreeLayout'; +import { generationAxisLabel } from './lineageTreeLayout'; + +/** + * 选中节点的信息面板:**桌面用右侧浮层、移动端用 bottom sheet**(同一个组件,只换形态)。 + * + * 分成两种形态是为了不写两套:内容(简介 / 授权类型 / 核心改动说明 / 两个操作)完全一致, + * 差别只在 CSS 类与容器位置——桌面挂在画布右上,移动端从底部升起(带遮罩与关闭按钮)。 + * 简介与授权只对**选中的这一个节点**请求一次公开详情(不发 N 次请求)。 + */ +export function LineageInfoPanel({ + node, + game, + isSheet, + onClose, + onOpenDetail, + onOpenFork, +}: { + node: LineageLayoutNode; + /** 选中节点的公开详情(简介 / 授权 / 版本摘要层的改动说明);读取中为 null。 */ + game: GameDistributionGame | null; + isSheet: boolean; + onClose: () => void; + onOpenDetail: (gameId: string) => void; + onOpenFork: (gameId: string) => void; +}) { + const authorizationLabel = game + ? GAME_DISTRIBUTION_FORK_AUTHORIZATION_LABELS[ + resolveGameForkAuthorization(game.forkAuthorization) + ] + : null; + /** 核心改动说明在**版本摘要层**(`currentVersion.changeSummary`),缺失就不渲染这一行。 */ + const coreChangeNote = game?.currentVersion?.changeSummary?.trim() ?? ''; + + return ( + <> + {isSheet ? ( + + ) : null} +

+ {node.isStub ? '原作品已不可用' : (node.title ?? '未命名作品')} +

+

+ {generationAxisLabel(node.generation)} ·{' '} + {node.isStub + ? '标题与作者名不对外展示' + : (node.authorName ?? '').trim() || '未署名作者'} +

+ {node.isStub ? ( +

+ 来源作品已删除或未公开,只保留代际位置。 +

+ ) : ( + <> +

+ {game?.summary?.trim() || '暂无简介'} +

+

+ 授权:{authorizationLabel ?? '读取中…'} +

+ {coreChangeNote ? ( +

+ 核心改动说明:{coreChangeNote} +

+ ) : null} + + + + + + )} + + + ); +} + +export default LineageInfoPanel; diff --git a/src/components/game-distribution/LineageOutlineList.tsx b/src/components/game-distribution/LineageOutlineList.tsx new file mode 100644 index 000000000..01dbe8a44 --- /dev/null +++ b/src/components/game-distribution/LineageOutlineList.tsx @@ -0,0 +1,114 @@ +import { LineageNodeContent } from './LineageNodeContent'; + +/** + * 移动端**大纲式缩进树**(默认形态):全宽行 + 左缩进 + 肘形导线。 + * + * 为什么移动端默认不用画布:窄屏上把画布缩小只会让字更小、还得缩放平移;大纲模式 + * 宽度自适应、**无横向滚动**、每行都是 44px 以上的触控目标,读起来最省力。 + * 节点内容复用与画布/血缘链同一个 `LineageNodeContent`(只有一套封面色块与省略规则)。 + * + * 语义与画布一致:点击行 = **选中**(高亮祖先链 + 面包屑),行内「打开」= 进详情, + * 「Fork」= 唤起/降级链路;主干链的导线加粗并用主题色。 + */ +export function LineageOutlineList({ + nodes, + currentGameId, + trunkGameIds, + selectedGameId, + onSelect, + onOpenDetail, + onOpenFork, +}: { + /** 已按代际升序排好的节点(含占位父节点)。 */ + nodes: readonly { + gameId: string; + generation: number; + isStub: boolean; + title: string | null; + authorName: string | null; + coverObjectKey?: string | null; + }[]; + currentGameId: string; + trunkGameIds: ReadonlySet; + selectedGameId: string; + onSelect: (gameId: string) => void; + onOpenDetail: (gameId: string) => void; + onOpenFork: (gameId: string) => void; +}) { + return ( +
    + {nodes.map((node) => { + const isCurrent = node.gameId === currentGameId; + const isSelected = node.gameId === selectedGameId; + return ( +
  • + + + {node.isStub ? null : ( + + + + + )} +
  • + ); + })} +
+ ); +} + +export default LineageOutlineList; diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css index 40da09d52..77b335f1c 100644 --- a/src/components/game-distribution/gameDistribution.css +++ b/src/components/game-distribution/gameDistribution.css @@ -2376,3 +2376,157 @@ font-size: 0.78rem; font-variant-numeric: tabular-nums; } + +/* + * 移动端「大纲式缩进树」(默认形态):全宽行 + 左缩进 + 肘形导线,**无横向滚动**。 + * 每行 ≥44px 触控目标;点击行=选中,行内「打开 / Fork」是次要动作。 + */ +.lineage-outline { + display: flex; + flex-direction: column; + gap: 0.15rem; + margin: 0; + padding: 0; + list-style: none; + width: 100%; + max-width: 100%; + overflow-x: hidden; +} +.lineage-outline__row { + display: flex; + align-items: center; + gap: 0.2rem; + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} +.lineage-outline__guide { + flex: 0 0 auto; + width: 1.1rem; + color: var(--platform-surface-border); + font-size: 0.8rem; + line-height: 1; + text-align: center; +} +/* 主干链的导线加粗并用主题色(与桌面「主干线粗实线」同语义)。 */ +.lineage-outline__guide--trunk { + color: var(--platform-accent-strong); + font-weight: 700; +} +.lineage-outline__main { + display: block; + flex: 1 1 auto; + min-width: 0; + min-height: 2.75rem; + border: 1px solid transparent; + border-radius: 0.6rem; + padding: 0.3rem 0.4rem; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.lineage-outline__row--selected .lineage-outline__main, +.lineage-outline__main--current { + border-color: var(--platform-accent-strong); + background: rgba(199, 101, 61, 0.12); +} +.lineage-outline__action { + flex: 0 0 auto; + min-height: 2.4rem; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0 0.7rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + font-size: 0.76rem; + font-weight: 600; + cursor: pointer; +} +.lineage-outline__actions { + display: flex; + flex: 0 0 auto; + gap: 0.25rem; +} +/* 大纲行里的标题:单行省略(不逐字换行)。 */ +.lineage-outline .game-lineage-node__title { + -webkit-line-clamp: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.lineage-outline .game-lineage-node__meta { + font-size: 0.72rem; + color: var(--platform-text-soft); +} +.lineage-filter__switch, +.game-lineage-filter__switch { + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0.25rem 0.8rem; + background: var(--platform-surface, #fff); + color: var(--platform-text-base); + font: inherit; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; +} +.game-lineage-filter__switch[aria-pressed='true'] { + border-color: var(--platform-accent-strong); + color: var(--platform-accent-strong); +} +/* 移动端信息面板 = bottom sheet:底部升起、可下拉关闭(遮罩点击关闭)。 */ +.game-lineage-sheet-backdrop { + position: absolute; + inset: 0; + z-index: 3; + border: 0; + padding: 0; + background: rgba(0, 0, 0, 0.32); + cursor: pointer; +} +.game-lineage-panel--sheet { + /* bottom sheet 用 fixed:不依赖页面里有没有定位祖先。 */ + position: fixed; + top: auto; + left: 0; + right: 0; + bottom: 0; + width: 100%; + max-height: 70%; + border-radius: 1rem 1rem 0 0; + /* 底部安全区:iPhone 手势条不被内容压住。 */ + padding-bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px)); + box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.22); +} +.game-lineage-panel__close { + align-self: flex-end; + border: 1px solid var(--platform-surface-border); + border-radius: 999px; + padding: 0.15rem 0.7rem; + background: transparent; + color: var(--platform-text-base); + font: inherit; + font-size: 0.76rem; + cursor: pointer; +} +/* 面包屑:移动端横向可滚动,当前项常驻可见(组件里用 scrollIntoView 保证)。 */ +.game-lineage-breadcrumb { + overflow-x: auto; + overflow-y: hidden; + flex-wrap: nowrap; +} +.game-lineage-breadcrumb__item { + flex: 0 0 auto; +} +/* 移动端:画布高度更紧凑,悬浮控件只留图标(已有规则),并给底部安全区留白。 */ +@media (max-width: 720px) { + .game-lineage-canvas { + height: clamp(18rem, 52vh, 30rem); + } + .game-lineage-page { + padding-bottom: env(safe-area-inset-bottom, 0px); + } +} diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts index f9348d591..7a03c1200 100644 --- a/src/components/game-distribution/lineageTreeLayout.test.ts +++ b/src/components/game-distribution/lineageTreeLayout.test.ts @@ -297,6 +297,49 @@ describe('主干线(每代热度最高的版本)', () => { }); }); +describe('横向朝向(移动端图谱视图)', () => { + const nodes = [ + node('root', 0, null), + node('a', 1, 'root'), + node('b', 1, 'root'), + node('deep', 2, 'a'), + ]; + + it('代际沿 x 推进、兄弟沿 y 排开,且同代不重叠', () => { + const vertical = buildLineageTreeLayout(nodes); + const horizontal = buildLineageTreeLayout(nodes, { + orientation: 'horizontal', + }); + const hById = new Map(horizontal.nodes.map((item) => [item.gameId, item])); + // 竖版:同代 y 相同;横版:同代 x 相同,兄弟 y 不同。 + expect(hById.get('root')!.x).toBeLessThan(hById.get('a')!.x); + expect(hById.get('a')!.x).toBeLessThan(hById.get('deep')!.x); + expect(hById.get('a')!.y).not.toBe(hById.get('b')!.y); + expect(hById.get('a')!.x).toBe(hById.get('b')!.x); + // 同代(同一列)节点在纵向上至少隔一个列间距,结构上不重叠。 + expect(Math.abs(hById.get('a')!.y - hById.get('b')!.y)).toBeGreaterThanOrEqual( + hById.get('a')!.height + 1, + ); + expect(vertical.nodes[0]!.y).toBeLessThan(vertical.nodes[1]!.y); + }); + + it('连线改成「父右边中点 → 子左边中点」的水平 elbow', () => { + const horizontal = buildLineageTreeLayout(nodes, { + orientation: 'horizontal', + }); + const edge = horizontal.edges.find((item) => item.id === 'root->a')!; + const root = horizontal.nodes.find((item) => item.gameId === 'root')!; + const child = horizontal.nodes.find((item) => item.gameId === 'a')!; + expect(edge.from).toEqual({ + x: root.x + root.width, + y: root.y + root.height / 2, + }); + expect(edge.to).toEqual({ x: child.x, y: child.y + child.height / 2 }); + expect(edge.path).toContain(' H '); + expect(edge.path).not.toContain(' C '); + }); +}); + describe('collectLineageSubtreeDepth', () => { it('深度 = 子树最大代际 − 本作品代际', () => { const nodes = [ diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts index 1605a475c..936c03915 100644 --- a/src/components/game-distribution/lineageTreeLayout.ts +++ b/src/components/game-distribution/lineageTreeLayout.ts @@ -114,6 +114,13 @@ export type LineageLayoutOptions = { columnGap?: number; rowGap?: number; axisWidth?: number; + /** + * 朝向:`vertical`(默认,根在上、代际向下)或 `horizontal`(根在左、代际向右)。 + * + * 移动端「图谱视图」用横向:窄屏上代际沿横向推进、兄弟分支沿纵向排开,比顶→下省横向空间。 + * 布局算法本身不变(只是把「行/列」这两个轴的像素映射与连线端点对调)。 + */ + orientation?: 'vertical' | 'horizontal'; }; export function generationAxisLabel(generation: number) { @@ -244,6 +251,8 @@ export function buildLineageTreeLayout( generations.map((generation, index) => [generation, index] as const), ); const pitch = nodeWidth + columnGap; + const slotPitch = nodeHeight + rowGap; + const orientation = options.orientation ?? 'vertical'; // 只在真正用到的槽位区间上铺像素:把左侧空槽位裁掉,内容因此更紧凑, // 「整棵树居中」时右侧也不会留下大片空白。 const usedSlots = [...slotByGameId.values()]; @@ -253,6 +262,8 @@ export function buildLineageTreeLayout( const layoutNodes: LineageLayoutNode[] = entries.map((entry) => { const slot = slotByGameId.get(entry.gameId) ?? 0; const row = rowByGeneration.get(entry.generation) ?? 0; + // 竖版:代际→y、槽位→x;横版:代际→x、槽位→y(同一套槽位算法,只换轴)。 + const horizontal = orientation === 'horizontal'; return { gameId: entry.gameId, node: entry.node, @@ -260,8 +271,12 @@ export function buildLineageTreeLayout( authorName: entry.authorName, coverObjectKey: entry.coverObjectKey, generation: entry.generation, - x: roundTo(axisWidth + (slot - minSlot) * pitch), - y: roundTo(row * (nodeHeight + rowGap)), + x: horizontal + ? roundTo(axisWidth + row * pitch) + : roundTo(axisWidth + (slot - minSlot) * pitch), + y: horizontal + ? roundTo((slot - minSlot) * slotPitch) + : roundTo(row * slotPitch), width: nodeWidth, height: nodeHeight, isStub: entry.node === null, @@ -290,33 +305,47 @@ export function buildLineageTreeLayout( if (!parentGameId) continue; const parent = layoutById.get(parentGameId); if (!parent) continue; - const from = { - x: roundTo(parent.x + parent.width / 2), - y: roundTo(parent.y + parent.height), - }; - const to = { - x: roundTo(node.x + node.width / 2), - y: roundTo(node.y), - }; - const delta = to.y - from.y; - // 正交路由(elbow):父底 → 竖直下行到本层与下一层之间的中线 → 水平走线 → 竖直进入子顶; - // 转角用二次贝塞尔倒一个小圆角。比三次贝塞尔更容易一眼看出「谁是谁的子节点」, - // 竖直段共用中线时分叉看起来是一条主干 + 侧枝,而不是一串弧线。 + const horizontal = orientation === 'horizontal'; + // 竖版:父底中点 → 子顶中点;横版:父右边中点 → 子左边中点。 + const from = horizontal + ? { x: roundTo(parent.x + parent.width), y: roundTo(parent.y + parent.height / 2) } + : { x: roundTo(parent.x + parent.width / 2), y: roundTo(parent.y + parent.height) }; + const to = horizontal + ? { x: roundTo(node.x), y: roundTo(node.y + node.height / 2) } + : { x: roundTo(node.x + node.width / 2), y: roundTo(node.y) }; + const delta = horizontal ? to.x - from.x : to.y - from.y; const cornerRadius = Math.min( 12, - Math.abs(to.x - from.x) / 2, + Math.abs(horizontal ? to.y - from.y : to.x - from.x) / 2, Math.abs(delta) / 2, ); - const midY = roundTo(from.y + delta * 0.5); - const path = - from.x === to.x || cornerRadius <= 0 - ? `M ${from.x},${from.y} V ${to.y}` - : `M ${from.x},${from.y} ` + - `V ${roundTo(midY - cornerRadius)} ` + - `Q ${from.x},${midY} ${roundTo(from.x + Math.sign(to.x - from.x) * cornerRadius)},${midY} ` + - `H ${roundTo(to.x - Math.sign(to.x - from.x) * cornerRadius)} ` + - `Q ${to.x},${midY} ${to.x},${roundTo(midY + cornerRadius)} ` + - `V ${to.y}`; + const mid = roundTo((horizontal ? from.x : from.y) + delta * 0.5); + const path = (() => { + if (cornerRadius <= 0 || (horizontal ? from.y === to.y : from.x === to.x)) { + return horizontal + ? `M ${from.x},${from.y} H ${to.x}` + : `M ${from.x},${from.y} V ${to.y}`; + } + if (horizontal) { + const direction = Math.sign(to.y - from.y); + return ( + `M ${from.x},${from.y} ` + + `H ${roundTo(mid - cornerRadius)} ` + + `Q ${mid},${from.y} ${mid},${roundTo(from.y + direction * cornerRadius)} ` + + `V ${roundTo(to.y - direction * cornerRadius)} ` + + `Q ${mid},${to.y} ${roundTo(mid + cornerRadius)},${to.y} ` + + `H ${to.x}` + ); + } + return ( + `M ${from.x},${from.y} ` + + `V ${roundTo(mid - cornerRadius)} ` + + `Q ${from.x},${mid} ${roundTo(from.x + Math.sign(to.x - from.x) * cornerRadius)},${mid} ` + + `H ${roundTo(to.x - Math.sign(to.x - from.x) * cornerRadius)} ` + + `Q ${to.x},${mid} ${to.x},${roundTo(mid + cornerRadius)} ` + + `V ${to.y}` + ); + })(); edges.push({ id: `${parentGameId}->${node.gameId}`, parentGameId, @@ -337,12 +366,18 @@ export function buildLineageTreeLayout( })); const maxSlotCount = slotByGameId.size > 0 ? maxSlot - minSlot + 1 : 0; + const horizontal = orientation === 'horizontal'; return { nodes: layoutNodes, edges, axis, - width: roundTo(axisWidth + maxSlotCount * pitch), - height: roundTo(generations.length * (nodeHeight + rowGap)), + // 竖版:横向由槽位数决定、纵向由代际数决定;横版反过来(代际沿横向推进)。 + width: roundTo( + axisWidth + (horizontal ? generations.length : maxSlotCount) * pitch, + ), + height: roundTo( + (horizontal ? maxSlotCount : generations.length) * slotPitch, + ), }; }