共创移动端:大纲式缩进树为默认 + 图谱视图(横向画布) + bottom sheet;同步规范 3.6.1
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled

This commit is contained in:
2026-10-07 00:47:30 +08:00
parent 0b9b9827dd
commit d4aae981ee
8 changed files with 946 additions and 301 deletions
@@ -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 灰度与可观测
@@ -2652,37 +2652,6 @@ describe('GameLineagePage 可视化与交互', () => {
);
});
it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => {
setViewportWidth(375);
getGameLineageMock.mockResolvedValueOnce(treeWithSibling);
render(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={onOpenDetail}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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(
<GameLineagePage
rootGameId="game-root"
onBack={vi.fn()}
onOpenDetail={vi.fn()}
/>,
);
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 () => {
File diff suppressed because it is too large Load Diff
@@ -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 ? (
<button
type="button"
className="game-lineage-sheet-backdrop"
aria-label="关闭信息面板"
onClick={onClose}
/>
) : null}
<aside
className={`game-lineage-panel${
isSheet ? ' game-lineage-panel--sheet' : ''
}`}
aria-label="选中作品信息"
>
{isSheet ? (
<button
type="button"
className="game-lineage-panel__close"
aria-label="关闭"
onClick={onClose}
>
关闭
</button>
) : null}
<h3 className="game-lineage-panel__title">
{node.isStub ? '原作品已不可用' : (node.title ?? '未命名作品')}
</h3>
<p className="game-lineage-panel__meta">
{generationAxisLabel(node.generation)} ·{' '}
{node.isStub
? '标题与作者名不对外展示'
: (node.authorName ?? '').trim() || '未署名作者'}
</p>
{node.isStub ? (
<p className="game-lineage-panel__note">
来源作品已删除或未公开,只保留代际位置。
</p>
) : (
<>
<p className="game-lineage-panel__summary">
{game?.summary?.trim() || '暂无简介'}
</p>
<p className="game-lineage-panel__meta">
授权:{authorizationLabel ?? '读取中…'}
</p>
{coreChangeNote ? (
<p className="game-lineage-panel__note">
核心改动说明:{coreChangeNote}
</p>
) : null}
<span className="game-lineage-panel__actions">
<button
type="button"
className="game-lineage-panel__button"
onClick={() => onOpenDetail(node.gameId)}
>
前往体验
</button>
<button
type="button"
className="game-lineage-panel__button game-lineage-panel__button--primary"
onClick={() => onOpenFork(node.gameId)}
>
Fork 此版本
</button>
</span>
</>
)}
</aside>
</>
);
}
export default LineageInfoPanel;
@@ -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<string>;
selectedGameId: string;
onSelect: (gameId: string) => void;
onOpenDetail: (gameId: string) => void;
onOpenFork: (gameId: string) => void;
}) {
return (
<ul className="lineage-outline" aria-label="创作族谱大纲">
{nodes.map((node) => {
const isCurrent = node.gameId === currentGameId;
const isSelected = node.gameId === selectedGameId;
return (
<li
key={node.gameId}
className={`lineage-outline__row${
isSelected ? ' lineage-outline__row--selected' : ''
}`}
data-generation={node.generation}
// 层级用左缩进表达:每代 1.1rem,最多缩进 6 代避免窄屏被挤没。
style={{ paddingLeft: `${Math.min(node.generation, 6) * 1.1}rem` }}
>
<span
className={`lineage-outline__guide${
trunkGameIds.has(node.gameId)
? ' lineage-outline__guide--trunk'
: ''
}`}
aria-hidden="true"
>
{node.generation === 0 ? '' : '└─'}
</span>
<button
type="button"
className={`lineage-outline__main${
isCurrent ? ' lineage-outline__main--current' : ''
}`}
aria-current={isCurrent ? 'true' : undefined}
aria-label={`第 ${node.generation} 代《${node.isStub ? '原作品已不可用' : node.title}》· ${
node.isStub
? '标题与作者名不对外展示'
: node.authorName?.trim() || '未署名作者'
}`}
onClick={() => onSelect(node.gameId)}
>
<LineageNodeContent
layout="inline"
title={node.title}
authorName={node.authorName}
generation={node.generation}
coverObjectKey={node.coverObjectKey}
isAnchor={isCurrent}
isStub={node.isStub}
isTrunk={trunkGameIds.has(node.gameId)}
/>
</button>
{node.isStub ? null : (
<span className="lineage-outline__actions">
<button
type="button"
className="lineage-outline__action"
aria-label={`打开《${node.title}》详情`}
onClick={() => onOpenDetail(node.gameId)}
>
打开
</button>
<button
type="button"
className="lineage-outline__action"
aria-label={`Fork ${node.title}`}
onClick={() => onOpenFork(node.gameId)}
>
Fork
</button>
</span>
)}
</li>
);
})}
</ul>
);
}
export default LineageOutlineList;
@@ -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);
}
}
@@ -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 = [
@@ -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,
),
};
}