详情页血缘链改竖向滚动单链:复用族谱节点内容组件,卡片不再逐字换行
Project CI / AI game creator shell Rust lane 1/2 (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 Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (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 Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (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 2/2 (pull_request) Has been cancelled
This commit is contained in:
@@ -1,17 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import {
|
||||
coCreationAvatarInitial,
|
||||
coCreationGenerationShortLabel,
|
||||
} from './coCreationCopy';
|
||||
import { LineageNodeContent } from './LineageNodeContent';
|
||||
|
||||
/**
|
||||
* 血缘溯源条:横向完整链路「根原创 → 1 代 → 2 代 → … → 当前作品」。
|
||||
* 详情页「共创」卡里的**血缘链**:竖向单链(根在最上、当前作品在最下)。
|
||||
*
|
||||
* 数据来自既有 `/games/{id}/lineage`(在详情页请求一次,按 `parentGameId` 上溯成链,
|
||||
* 不用整棵大树、也不新增接口)。每个节点显示作者字标 + 世代,点击跳该代作品详情。
|
||||
* 复用族谱那一套:链条顺序来自 `collectLineageAncestorChain`(族谱布局模块的纯函数)、
|
||||
* 节点内容来自 `LineageNodeContent`(与族谱画布同一个组件),这里只负责**嵌入态的排布**:
|
||||
* 窄容器(~220–260px)里**只能纵向滚动**,横向裁掉(`overflow-x: hidden`),
|
||||
* 初次进入与「当前作品 id 变化」时自动滚到底部,让当前游戏可见。
|
||||
*
|
||||
* 头像:`/lineage` 节点只带 `authorName`(没有头像字段),因此用姓名首字的圆形字标降级,
|
||||
* 不伪造头像图片;后端补字段后换成真实头像即可(结构不用改)。
|
||||
* 为什么只画祖先链、不画整棵树:右栏只有 200 多像素宽,塞整棵树必然又挤成一片;
|
||||
* 整棵树由既有的「查看创作族谱」入口负责(点击进入 `/games/lineage?id=<root>`)。
|
||||
*/
|
||||
export function CoCreationLineageStrip({
|
||||
chain,
|
||||
@@ -23,48 +24,104 @@ export function CoCreationLineageStrip({
|
||||
currentGameId: string;
|
||||
onOpenGameDetail: (gameId: string) => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
/** 上面还有几代没露出来(滚动位置之上还有内容时给「↑ 还有 N 代」提示)。 */
|
||||
const [hiddenAboveCount, setHiddenAboveCount] = useState(0);
|
||||
|
||||
// 默认滚到底部(当前作品在最下),并在当前作品变化时重新滚到底。
|
||||
useEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
element.scrollTop = element.scrollHeight - element.clientHeight;
|
||||
setHiddenAboveCount(0);
|
||||
}, [currentGameId, chain.length]);
|
||||
|
||||
const handleScroll = () => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const above = element.scrollTop;
|
||||
const nodeHeight = element.scrollHeight / Math.max(chain.length, 1);
|
||||
setHiddenAboveCount(Math.max(0, Math.round(above / Math.max(nodeHeight, 1))));
|
||||
};
|
||||
|
||||
const ancestors = useMemo(() => chain.slice(0, -1), [chain]);
|
||||
/** 主干:每代 playCount 最高者(与族谱页同一语义:主干链的导线加粗)。 */
|
||||
const trunkGameIds = useMemo(() => {
|
||||
const bestByGeneration = new Map<number, GameDistributionLineageNode>();
|
||||
for (const node of chain) {
|
||||
const current = bestByGeneration.get(node.generation);
|
||||
if (!current || node.playCount > current.playCount) {
|
||||
bestByGeneration.set(node.generation, node);
|
||||
}
|
||||
}
|
||||
return new Set([...bestByGeneration.values()].map((node) => node.gameId));
|
||||
}, [chain]);
|
||||
if (chain.length <= 1) return null;
|
||||
|
||||
return (
|
||||
<div className="co-creation-lineage" aria-label="血缘溯源">
|
||||
{chain.map((node, index) => {
|
||||
const isCurrent = node.gameId === currentGameId;
|
||||
return (
|
||||
<span key={node.gameId} className="co-creation-lineage__item">
|
||||
{index > 0 ? (
|
||||
<span className="co-creation-lineage__arrow" aria-hidden="true">
|
||||
→
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`co-creation-lineage__node${
|
||||
isCurrent ? ' co-creation-lineage__node--current' : ''
|
||||
}`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
aria-label={`${coCreationGenerationShortLabel(node.generation)}作品《${node.title}》· ${node.authorName?.trim() || '未署名作者'}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) onOpenGameDetail(node.gameId);
|
||||
}}
|
||||
>
|
||||
<span className="co-creation-lineage__avatar" aria-hidden="true">
|
||||
{coCreationAvatarInitial(node.authorName)}
|
||||
</span>
|
||||
<span className="co-creation-lineage__text">
|
||||
<span className="co-creation-lineage__generation">
|
||||
{coCreationGenerationShortLabel(node.generation)}
|
||||
</span>
|
||||
<span className="co-creation-lineage__title">{node.title}</span>
|
||||
<span className="co-creation-lineage__author">
|
||||
{node.authorName?.trim() || '未署名作者'}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<div className="co-creation-chain">
|
||||
<div
|
||||
className="co-creation-chain__scroll"
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
aria-label="创作血缘链"
|
||||
tabIndex={0}
|
||||
>
|
||||
{chain.map((node, index) => {
|
||||
const isCurrent = node.gameId === currentGameId;
|
||||
return (
|
||||
<span key={node.gameId} className="co-creation-chain__row">
|
||||
{index > 0 ? (
|
||||
<span
|
||||
className={`co-creation-chain__connector${
|
||||
trunkGameIds.has(node.gameId) ? ' co-creation-chain__connector--trunk' : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`co-creation-chain__node${
|
||||
isCurrent ? ' co-creation-chain__node--current' : ''
|
||||
}`}
|
||||
aria-current={isCurrent ? 'true' : undefined}
|
||||
aria-label={`${generationLabel(node.generation)}《${node.title}》· ${
|
||||
node.authorName?.trim() || '未署名作者'
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isCurrent) onOpenGameDetail(node.gameId);
|
||||
}}
|
||||
>
|
||||
<LineageNodeContent
|
||||
layout="inline"
|
||||
title={node.title}
|
||||
authorName={node.authorName ?? null}
|
||||
generation={node.generation}
|
||||
coverObjectKey={node.coverObjectKey}
|
||||
isAnchor={isCurrent}
|
||||
isTrunk={trunkGameIds.has(node.gameId)}
|
||||
/>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hiddenAboveCount > 0 ? (
|
||||
<span className="co-creation-chain__notice" role="status">
|
||||
↑ 还有 {hiddenAboveCount} 代(共 {chain.length} 代)
|
||||
</span>
|
||||
) : null}
|
||||
{ancestors.length > 0 ? (
|
||||
<span className="co-creation-chain__legend">
|
||||
根在最上、当前作品在最下,可纵向滚动
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function generationLabel(generation: number) {
|
||||
return generation <= 0 ? '0 代原创' : `${generation} 代`;
|
||||
}
|
||||
|
||||
export default CoCreationLineageStrip;
|
||||
|
||||
@@ -2353,6 +2353,38 @@ describe('GameDetailPage 共创主题入口', () => {
|
||||
});
|
||||
});
|
||||
|
||||
/** 4 节点、3 代的血缘链数据集(根 → 1 代 → 2 代 → 当前),供详情页血缘链用例共用。 */
|
||||
function buildChainDataset() {
|
||||
return {
|
||||
rootGameId: 'game-root',
|
||||
root: null,
|
||||
nodes: [
|
||||
createLineageNode({
|
||||
gameId: 'game-root',
|
||||
title: '根原创',
|
||||
authorName: '根作者',
|
||||
generation: 0,
|
||||
parentGameId: null,
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-mid',
|
||||
title: '一代作品',
|
||||
authorName: '一代作者',
|
||||
generation: 1,
|
||||
parentGameId: 'game-root',
|
||||
}),
|
||||
createLineageNode({
|
||||
gameId: 'game-current',
|
||||
title: '当前作品',
|
||||
authorName: '当前作者',
|
||||
generation: 2,
|
||||
parentGameId: 'game-mid',
|
||||
}),
|
||||
],
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** 节点卡片上的主行动作按钮(打开详情)与它的卡片容器:多个 describe 共用。 */
|
||||
const openButton = (title: string) =>
|
||||
screen.getByRole('button', { name: `打开《${title}》详情` });
|
||||
@@ -2980,21 +3012,160 @@ describe('GameDetailPage 共创模块(文档对齐)', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const strip = await screen.findByLabelText('血缘溯源');
|
||||
const strip = await screen.findByLabelText('创作血缘链');
|
||||
expect(strip.textContent).toContain('根原创');
|
||||
expect(strip.textContent).toContain('一代作品');
|
||||
expect(strip.textContent).toContain('当前作品');
|
||||
// 头像位是姓名首字(链路节点没有头像字段,不伪造头像图)。
|
||||
expect(strip.textContent).toContain('根');
|
||||
// 当前作品标记为 current 且点击不跳转;点中间一代跳该作品。
|
||||
// 顺序必须是 根 → 1 代 → 当前(DOM 顺序)。
|
||||
const chainText = strip.textContent ?? '';
|
||||
expect(chainText.indexOf('根原创')).toBeLessThan(
|
||||
chainText.indexOf('一代作品'),
|
||||
);
|
||||
expect(chainText.indexOf('一代作品')).toBeLessThan(
|
||||
chainText.indexOf('当前作品'),
|
||||
);
|
||||
// 当前作品有明确标记(aria-current + current 类)。
|
||||
const currentNode = strip.querySelector(
|
||||
'.co-creation-chain__node--current',
|
||||
) as HTMLElement;
|
||||
expect(currentNode.getAttribute('aria-current')).toBe('true');
|
||||
expect(currentNode.textContent).toContain('当前作品');
|
||||
// 点击根节点跳该作品;点当前作品不跳(本就是当前页)。
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /0 代作品《根原创》/u }),
|
||||
screen.getByRole('button', { name: /0 代原创《根原创》/u }),
|
||||
);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledWith('game-root');
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: /2 代作品《当前作品》/u }),
|
||||
);
|
||||
fireEvent.click(currentNode);
|
||||
expect(onOpenGameDetail).toHaveBeenCalledTimes(1);
|
||||
// 节点是真实 button:键盘可达(Tab 聚焦 + Enter 原生激活)。
|
||||
const rootNode = screen.getByRole('button', {
|
||||
name: /0 代原创《根原创》/u,
|
||||
});
|
||||
expect(rootNode.tagName).toBe('BUTTON');
|
||||
rootNode.focus();
|
||||
expect(document.activeElement).toBe(rootNode);
|
||||
});
|
||||
|
||||
it('血缘链只允许纵向滚动并默认滚到底部(当前作品可见)', async () => {
|
||||
// jsdom 不计算布局:把可滚动尺寸打桩成「内容 400 / 可视 100」,才能断言「滚到底」。
|
||||
const scrollHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'scrollHeight',
|
||||
);
|
||||
const clientHeightDescriptor = Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
'clientHeight',
|
||||
);
|
||||
Object.defineProperty(Element.prototype, 'scrollHeight', {
|
||||
configurable: true,
|
||||
get: () => 400,
|
||||
});
|
||||
Object.defineProperty(Element.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
get: () => 100,
|
||||
});
|
||||
try {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
|
||||
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
|
||||
const first = render(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const scroll = await screen.findByLabelText('创作血缘链');
|
||||
// 默认滚到底:scrollTop = scrollHeight - clientHeight = 400 - 100。
|
||||
await waitFor(() => expect(scroll.scrollTop).toBe(300));
|
||||
// 只允许纵向滚动:容器类名对应的 CSS 里 overflow-x 是 hidden(另有 CSS 防退化断言)。
|
||||
expect(scroll.className).toContain('co-creation-chain__scroll');
|
||||
first.unmount();
|
||||
|
||||
// 当前作品 id 变化 → 重新滚到底(而不是停在之前的位置)。
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-mid' }));
|
||||
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-mid"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const next = await screen.findByLabelText('创作血缘链');
|
||||
await waitFor(() => expect(next.scrollTop).toBe(300));
|
||||
} finally {
|
||||
if (scrollHeightDescriptor) {
|
||||
Object.defineProperty(
|
||||
Element.prototype,
|
||||
'scrollHeight',
|
||||
scrollHeightDescriptor,
|
||||
);
|
||||
}
|
||||
if (clientHeightDescriptor) {
|
||||
Object.defineProperty(
|
||||
Element.prototype,
|
||||
'clientHeight',
|
||||
clientHeightDescriptor,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('血缘链节点卡片:标题不逐字换行、容器不横向滚动(防退化)', async () => {
|
||||
const css = await import('node:fs').then((fs) =>
|
||||
fs.readFileSync(
|
||||
'src/components/game-distribution/gameDistribution.css',
|
||||
'utf8',
|
||||
),
|
||||
);
|
||||
// 防退化:这几条是「窄容器里不被挤成竖排」的根因,改坏了必须变红。
|
||||
expect(css).toContain('-webkit-line-clamp: 2');
|
||||
expect(css).toContain('overflow-x: hidden');
|
||||
expect(css).toContain('min-width: 0');
|
||||
expect(css).toContain('text-overflow: ellipsis');
|
||||
expect(css).toContain('.game-lineage-node__content--inline');
|
||||
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
|
||||
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroll = await screen.findByLabelText('创作血缘链');
|
||||
expect(scroll.className).toContain('co-creation-chain__scroll');
|
||||
// 横向不溢出(jsdom 里两者均为 0,断言的是「没有设置横向滚动」这一契约)。
|
||||
expect(scroll.scrollWidth <= scroll.clientWidth).toBe(true);
|
||||
const title = scroll.querySelector('.game-lineage-node__title');
|
||||
expect(title?.className).toContain('game-lineage-node__title');
|
||||
expect(title?.textContent).toBe('根原创');
|
||||
});
|
||||
|
||||
it('节点封面用横版小缩略图,链节点是矮而宽的横向排布', async () => {
|
||||
getGameMock.mockResolvedValueOnce(createGame({ id: 'game-current' }));
|
||||
getGameLineageMock.mockResolvedValueOnce(buildChainDataset());
|
||||
render(
|
||||
<GameDetailPage
|
||||
gameId="game-current"
|
||||
onBack={vi.fn()}
|
||||
onPlay={vi.fn()}
|
||||
onOpenGameDetail={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const scroll = await screen.findByLabelText('创作血缘链');
|
||||
expect(
|
||||
scroll.querySelectorAll('.game-lineage-node__content--inline').length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(scroll.querySelectorAll('.co-creation-chain__connector').length).toBe(
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
it('热门衍生栏按 playCount 倒序取前 5 个并可在点击后进入作品', async () => {
|
||||
|
||||
@@ -25,17 +25,16 @@ import { CopyFeedbackButton } from '../common/CopyFeedbackButton';
|
||||
import { PlatformEmptyState } from '../common/PlatformEmptyState';
|
||||
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
|
||||
import { useCopyFeedback } from '../common/useCopyFeedback';
|
||||
import { GameCover } from './GameCover';
|
||||
import { useIsMobileViewport } from './gameDistributionGuards';
|
||||
import {
|
||||
FORK_LAUNCH_FALLBACK_DELAY_MS,
|
||||
openForkProtocol,
|
||||
} from './gameForkProtocol';
|
||||
import { LineageNodeContent } from './LineageNodeContent';
|
||||
import {
|
||||
buildLineageTreeLayout,
|
||||
collectLineageHighlight,
|
||||
generationAxisLabel,
|
||||
LINEAGE_COVER_ASPECT,
|
||||
type LineageLayoutNode,
|
||||
} from './lineageTreeLayout';
|
||||
|
||||
@@ -109,15 +108,6 @@ function LineageTreeNodeCard({
|
||||
? `原作品已不可用 · ${generationAxisLabel(generation)}`
|
||||
: `${generationAxisLabel(generation)}作品《${displayTitle}》· ${authorLabel}`;
|
||||
// 父作品不可用的占位节点只能聚焦(不可点进详情,也没有 Fork 动作)。
|
||||
// 封面:族谱节点只带 `coverObjectKey`,没有完整游戏行;`GameCover` 只读
|
||||
// title / coverColor / icon / coverObjectKey 四个字段,这里用与 `game_payload` 相同的
|
||||
// 兜底色与图标补上,其余字段不参与渲染——不做逐节点详情请求。
|
||||
const coverGame = {
|
||||
title: displayTitle,
|
||||
coverColor: '#F3E4D0',
|
||||
icon: '🎮',
|
||||
coverObjectKey: layoutNode.coverObjectKey,
|
||||
} as unknown as GameDistributionGame;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -182,38 +172,16 @@ function LineageTreeNodeCard({
|
||||
onOpenDetail(gameId);
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="game-lineage-node__cover"
|
||||
// 16:9 横版(与发布面板封面预览同一约定):容器比例由常量给出,CSS 不再另写一套。
|
||||
style={{ aspectRatio: LINEAGE_COVER_ASPECT }}
|
||||
>
|
||||
<GameCover
|
||||
game={coverGame}
|
||||
objectKey={layoutNode.coverObjectKey}
|
||||
/>
|
||||
<span className="game-lineage-node__badge">
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{layoutNode.isTrunk ? (
|
||||
<span
|
||||
className="game-lineage-node__trunk"
|
||||
title="该代热度最高的版本"
|
||||
>
|
||||
主干
|
||||
</span>
|
||||
) : null}
|
||||
{isAnchor ? (
|
||||
<span className="game-lineage-node__anchor">当前作品</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="game-lineage-node__strip">
|
||||
<span className="game-lineage-node__title">{displayTitle}</span>
|
||||
{compact ? null : (
|
||||
<span className="game-lineage-node__meta">
|
||||
<span>{authorLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<LineageNodeContent
|
||||
layout="stacked"
|
||||
title={layoutNode.title}
|
||||
authorName={layoutNode.authorName}
|
||||
generation={generation}
|
||||
coverObjectKey={layoutNode.coverObjectKey}
|
||||
isAnchor={isAnchor}
|
||||
isStub={isStub}
|
||||
isTrunk={layoutNode.isTrunk}
|
||||
/>
|
||||
</button>
|
||||
<span className="game-lineage-node__actions">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { GameDistributionGame } from '../../services/gameDistributionClient';
|
||||
import { GameCover } from './GameCover';
|
||||
import { generationAxisLabel } from './lineageTreeLayout';
|
||||
|
||||
/**
|
||||
* 族谱节点卡片的**内容**(封面 + 代际角标 + 标题 + 作者),族谱画布与详情页血缘链共用。
|
||||
*
|
||||
* 抽出来是为了只有一套「封面色块 / 角标 / 省略规则」:详情页的血缘链曾经自己写过一份窄卡片,
|
||||
* 结果在 ~230px 的右栏里把标题挤成逐字换行。现在两处都用这里的规则:
|
||||
* - 标题容器 `min-width: 0`(flex 子项默认不收缩的经典坑)+ 2 行 line-clamp + ellipsis;
|
||||
* - `layout="stacked"`:画布卡片,封面在上、信息条在下;
|
||||
* - `layout="inline"`:血缘链节点,封面在左、文字在右,**矮而宽**,绝不允许逐字换行。
|
||||
*/
|
||||
export function LineageNodeContent({
|
||||
title,
|
||||
authorName,
|
||||
generation,
|
||||
coverObjectKey,
|
||||
layout,
|
||||
isAnchor = false,
|
||||
isStub = false,
|
||||
isTrunk = false,
|
||||
}: {
|
||||
title: string | null;
|
||||
authorName: string | null;
|
||||
generation: number;
|
||||
coverObjectKey?: string | null;
|
||||
layout: 'stacked' | 'inline';
|
||||
isAnchor?: boolean;
|
||||
isStub?: boolean;
|
||||
isTrunk?: boolean;
|
||||
}) {
|
||||
const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品');
|
||||
const authorLabel = isStub
|
||||
? '标题与作者名不对外展示'
|
||||
: (authorName ?? '').trim() || '未署名作者';
|
||||
/** `GameCover` 只读 title / coverColor / icon / coverObjectKey;族谱节点没有完整游戏行。 */
|
||||
const coverGame = {
|
||||
title: displayTitle,
|
||||
coverColor: '#F3E4D0',
|
||||
icon: '🎮',
|
||||
coverObjectKey,
|
||||
} as unknown as GameDistributionGame;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`game-lineage-node__content game-lineage-node__content--${layout}`}
|
||||
>
|
||||
<span className="game-lineage-node__cover">
|
||||
<GameCover game={coverGame} objectKey={coverObjectKey} />
|
||||
<span className="game-lineage-node__badge">
|
||||
{generationAxisLabel(generation)}
|
||||
</span>
|
||||
{isTrunk ? (
|
||||
<span className="game-lineage-node__trunk" title="该代热度最高的版本">
|
||||
主干
|
||||
</span>
|
||||
) : null}
|
||||
{isAnchor ? (
|
||||
<span className="game-lineage-node__anchor">当前作品</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="game-lineage-node__text">
|
||||
<span className="game-lineage-node__title">{displayTitle}</span>
|
||||
{layout === 'stacked' ? null : (
|
||||
<span className="game-lineage-node__meta">
|
||||
<span className="game-lineage-node__author">{authorLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default LineageNodeContent;
|
||||
@@ -764,72 +764,70 @@
|
||||
color: var(--platform-text-soft);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
/* 血缘溯源条:横向链路,可横向滚动不撑破卡片。 */
|
||||
.game-detail-info-card .co-creation-lineage {
|
||||
/*
|
||||
* 详情页共创卡里的血缘链(竖向单链):根在最上、当前作品在最下。
|
||||
* 容器窄(右栏 ~220–260px)→ **只允许纵向滚动**,横向裁掉,避免被挤成逐字换行;
|
||||
* 首屏与当前作品变化时由组件滚到底部(当前游戏可见)。
|
||||
*/
|
||||
.game-detail-info-card .co-creation-chain {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0.2rem;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 0.2rem;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__arrow {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
.game-detail-info-card .co-creation-chain__scroll {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-right: 0.15rem;
|
||||
border: 1px solid var(--platform-surface-border);
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
background: var(--platform-surface, #fff);
|
||||
color: var(--platform-text-base);
|
||||
background: var(--platform-surface-muted, rgba(104, 54, 36, 0.03));
|
||||
}
|
||||
.game-detail-info-card .co-creation-chain__row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 父→子短竖线:主干链节的连线加粗、用主题色(与桌面「主干线粗实线」同语义)。 */
|
||||
.game-detail-info-card .co-creation-chain__connector {
|
||||
width: 2px;
|
||||
height: 0.7rem;
|
||||
margin-left: 1.6rem;
|
||||
background: var(--platform-surface-border);
|
||||
}
|
||||
.game-detail-info-card .co-creation-chain__connector--trunk {
|
||||
width: 4px;
|
||||
background: var(--platform-accent-strong);
|
||||
}
|
||||
.game-detail-info-card .co-creation-chain__node {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.3rem 0.4rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__node--current {
|
||||
.game-detail-info-card .co-creation-chain__node:hover {
|
||||
background: rgba(199, 101, 61, 0.08);
|
||||
}
|
||||
.game-detail-info-card .co-creation-chain__node--current {
|
||||
border-color: var(--platform-accent-strong);
|
||||
box-shadow: 0 0 0 2px rgba(199, 101, 61, 0.16);
|
||||
cursor: default;
|
||||
background: rgba(199, 101, 61, 0.12);
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__avatar {
|
||||
display: grid;
|
||||
flex: none;
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: rgba(199, 101, 61, 0.18);
|
||||
color: var(--platform-accent-strong);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__generation {
|
||||
color: var(--platform-accent-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__title {
|
||||
color: var(--platform-text-strong);
|
||||
font-weight: 600;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.game-detail-info-card .co-creation-lineage__author {
|
||||
.game-detail-info-card .co-creation-chain__notice,
|
||||
.game-detail-info-card .co-creation-chain__legend {
|
||||
color: var(--platform-text-soft);
|
||||
font-size: 0.7rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 热门衍生栏:横向卡片。 */
|
||||
.game-detail-info-card .co-creation-hot {
|
||||
@@ -1288,6 +1286,68 @@
|
||||
font-weight: 700;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
/* 链节点与画布卡片共用的内容布局(LineageNodeContent)。 */
|
||||
.game-lineage-node__content {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.game-lineage-node__content--stacked {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.game-lineage-node__content--inline {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0.15rem 0;
|
||||
}
|
||||
.game-lineage-node__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
/* 防逐字换行:min-width:0 让 flex 子项可收缩,标题 2 行截断(line-clamp)+ 省略号。 */
|
||||
.game-lineage-node__title {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
}
|
||||
.game-lineage-node__meta {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 0.3rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.game-lineage-node__author {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 链节点:横版小缩略图(64×36),矮而宽,触控目标 ≥44px。 */
|
||||
.game-lineage-node__content--inline .game-lineage-node__cover {
|
||||
flex: 0 0 auto;
|
||||
width: 4rem;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
.game-lineage-node__content--inline .game-lineage-node__badge,
|
||||
.game-lineage-node__content--inline .game-lineage-node__anchor {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.05rem 0.35rem;
|
||||
}
|
||||
.game-detail-info-card .co-creation-chain__node {
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
/* 卡片主体 = 选中;标题/封面 = 打开详情。封面做主视觉(大图 + 圆角 + 轻阴影)。 */
|
||||
.game-lineage-node {
|
||||
padding: 0;
|
||||
|
||||
Reference in New Issue
Block a user