共创移动端:大纲式缩进树为默认 + 图谱视图(横向画布) + 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
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:
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user