({
scale: 1,
@@ -260,16 +298,56 @@ export function GameLineagePage({
}, [loadLineage]);
const nodes = useMemo(() => lineage?.nodes ?? [], [lineage]);
- const layout = useMemo(() => buildLineageTreeLayout(nodes), [nodes]);
+ // 窄屏用紧凑布局(更小的卡片与间距 + 更大的相对字号),避免「桌面画布缩小 + 字太小」。
+ const compactTree = isMobileViewport;
+ const layout = useMemo(
+ () =>
+ buildLineageTreeLayout(
+ nodes,
+ compactTree
+ ? {
+ nodeWidth: 168,
+ nodeHeight: 78,
+ columnGap: 20,
+ rowGap: 86,
+ axisWidth: 62,
+ }
+ : undefined,
+ ),
+ [compactTree, nodes],
+ );
+ // 高亮目标:锁定(点击「锁定路径」)优先于 hover/聚焦;锁定后即使祖先移出视口,路径仍由面包屑兜底。
+ const highlightTargetId = pinnedGameId || activeGameId;
const highlighted = useMemo(
() =>
- activeGameId
- ? collectLineageHighlight(layout.nodes, activeGameId, {
+ highlightTargetId
+ ? collectLineageHighlight(layout.nodes, highlightTargetId, {
includeSubtree,
})
: null,
- [activeGameId, includeSubtree, layout.nodes],
+ [highlightTargetId, includeSubtree, layout.nodes],
);
+ const pathIds = useMemo(
+ () =>
+ highlightTargetId
+ ? collectLineageHighlight(layout.nodes, highlightTargetId, {
+ includeSubtree: false,
+ })
+ : null,
+ [highlightTargetId, layout.nodes],
+ );
+ /** 锁定路径的面包屑:从母版到锁定节点,按代际升序。 */
+ const pinnedChain = useMemo(() => {
+ if (!pinnedGameId || !pathIds) return [];
+ return layout.nodes
+ .filter((item) => pathIds.has(item.gameId))
+ .sort((left, right) => left.generation - right.generation)
+ .map((item) => ({
+ gameId: item.gameId,
+ label: item.isStub ? '原作品已不可用' : (item.title ?? '未命名作品'),
+ generation: item.generation,
+ }));
+ }, [layout.nodes, pathIds, pinnedGameId]);
const anchorId = highlightGameId?.trim() || normalizedRootId;
const derivedCount = nodes.filter((node) => node.generation > 0).length;
@@ -589,6 +667,35 @@ export function GameLineagePage({
onPointerLeave={endDrag}
aria-label="创作族谱树"
>
+ {pinnedChain.length > 0 ? (
+
+ {pinnedChain.map((crumb, index) => (
+
+ {index > 0 ? → : null}
+
+
+ ))}
+
+
+ ) : null}
setActiveGameId('')}
diff --git a/src/components/game-distribution/gameDistribution.css b/src/components/game-distribution/gameDistribution.css
index 4fb0c10a7..181673b6b 100644
--- a/src/components/game-distribution/gameDistribution.css
+++ b/src/components/game-distribution/gameDistribution.css
@@ -857,6 +857,76 @@
.game-lineage-node--dimmed {
opacity: 0.34;
}
+/* 高亮分三档,差异要肉眼可见:路径节点(强调边框 + 淡底)、仅子树节点(淡底 + 中等边框)、
+ 目标节点(外发光 + 强调边框)。全都比「只把别人变淡」更容易看出结构。 */
+.game-lineage-node--path {
+ border-color: var(--platform-accent-strong);
+ background: rgba(199, 101, 61, 0.08);
+}
+.game-lineage-node--subtree {
+ border-color: var(--platform-accent-strong);
+ border-style: dashed;
+ background: rgba(199, 101, 61, 0.04);
+}
+.game-lineage-node--pinned {
+ border-style: solid;
+ border-width: 2px;
+ box-shadow: 0 0 0 3px rgba(199, 101, 61, 0.22);
+}
+.game-lineage-breadcrumb {
+ position: absolute;
+ z-index: 2;
+ top: 0.4rem;
+ left: 0.5rem;
+ right: 0.5rem;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.25rem;
+ padding: 0.25rem 0.4rem;
+ border-radius: 999px;
+ background: var(--platform-surface, #fff);
+ box-shadow: 0 1px 6px rgba(0, 0, 0, 0.12);
+ font-size: 0.72rem;
+}
+.game-lineage-breadcrumb__item {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ color: var(--platform-text-soft);
+}
+.game-lineage-breadcrumb__crumb,
+.game-lineage-breadcrumb__clear {
+ border: none;
+ padding: 0.1rem 0.3rem;
+ border-radius: 999px;
+ background: transparent;
+ color: var(--platform-text-base);
+ font: inherit;
+ font-size: 0.72rem;
+ cursor: pointer;
+ overflow-wrap: anywhere;
+}
+.game-lineage-breadcrumb__crumb:hover,
+.game-lineage-breadcrumb__clear:hover {
+ color: var(--platform-text-strong);
+ background: rgba(199, 101, 61, 0.12);
+}
+.game-lineage-node__pin {
+ border: 1px solid var(--platform-surface-border);
+ border-radius: 999px;
+ padding: 0.14rem 0.6rem;
+ background: transparent;
+ color: var(--platform-text-base);
+ font: inherit;
+ font-size: 0.72rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.game-lineage-node__pin[aria-pressed='true'] {
+ border-color: var(--platform-accent-strong);
+ color: var(--platform-accent-strong);
+}
.game-lineage-node--stub {
border-style: dashed;
cursor: default;
diff --git a/src/components/game-distribution/lineageTreeLayout.test.ts b/src/components/game-distribution/lineageTreeLayout.test.ts
index b75a831d7..21ccf5ec4 100644
--- a/src/components/game-distribution/lineageTreeLayout.test.ts
+++ b/src/components/game-distribution/lineageTreeLayout.test.ts
@@ -173,7 +173,7 @@ describe('buildLineageTreeLayout', () => {
}
});
- it('连线端点落在父底边中点与子顶边中点,路径是贝塞尔', () => {
+ it('连线端点落在父底边中点与子顶边中点,路径是正交 elbow', () => {
const layout = buildLineageTreeLayout([
node('root', 0, null),
node('child', 1, 'root'),
@@ -186,9 +186,23 @@ describe('buildLineageTreeLayout', () => {
y: root.y + root.height,
});
expect(edge.to).toEqual({ x: child.x + child.width / 2, y: child.y });
+ // 单链:父与子同一条竖线 → 纯竖直段。
expect(edge.path.startsWith('M ')).toBe(true);
- expect(edge.path).toContain(' C ');
+ expect(edge.path).toContain(' V ');
+ expect(edge.path).not.toContain(' C ');
expect(edge.id).toBe('root->child');
+
+ // 分叉:父与子不同列 → 竖直段 + 水平段 + 圆角(elbow),而不是一条弧。
+ const branched = buildLineageTreeLayout([
+ node('root', 0, null),
+ node('a', 1, 'root'),
+ node('b', 1, 'root'),
+ ]);
+ const elbow = branched.edges.find((item) => item.childGameId === 'a')!;
+ expect(elbow.path).toContain(' V ');
+ expect(elbow.path).toContain(' H ');
+ expect(elbow.path).toContain(' Q ');
+ expect(elbow.path).not.toContain(' C ');
});
it('确定性:同一输入两次调用结果逐字段相同', () => {
diff --git a/src/components/game-distribution/lineageTreeLayout.ts b/src/components/game-distribution/lineageTreeLayout.ts
index 99aeedb6e..27eac2b4f 100644
--- a/src/components/game-distribution/lineageTreeLayout.ts
+++ b/src/components/game-distribution/lineageTreeLayout.ts
@@ -15,8 +15,9 @@ import type { GameDistributionLineageNode } from '../../services/gameDistributio
* 子节点槽位的**中点**。因为 DFS 让每棵子树的槽位区间连续且互不相交,同层节点的中心
* 间距必然 ≥ 1 个槽位;再用「槽位间距 = 节点宽 + 列间距」把槽位映射成像素,
* 于是**同层任意两节点至少隔着 `LINEAGE_COLUMN_GAP`,结构上不可能重叠**。
- * 4. **连线**:父节点底边中点 → 子节点顶边中点,三次贝塞尔(`path` 字符串里同时给出
- * 端点坐标,测试可以直接断言端点而不是解析路径)。
+ * 4. **连线**:正交路由(elbow)——父节点底边中点竖直下行、在两代之间水平走线、再从子节点
+ * 顶边中点竖直进入,转角倒小圆角;`path` 字符串里同时给出端点坐标,测试可以直接断言端点
+ * 与「竖直段/水平段存在」而不是解析路径。
*/
export const LINEAGE_NODE_WIDTH = 208;
@@ -239,17 +240,31 @@ export function buildLineageTreeLayout(
y: roundTo(node.y),
};
const delta = to.y - from.y;
+ // 正交路由(elbow):父底 → 竖直下行到本层与下一层之间的中线 → 水平走线 → 竖直进入子顶;
+ // 转角用二次贝塞尔倒一个小圆角。比三次贝塞尔更容易一眼看出「谁是谁的子节点」,
+ // 竖直段共用中线时分叉看起来是一条主干 + 侧枝,而不是一串弧线。
+ const cornerRadius = Math.min(
+ 12,
+ Math.abs(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}`;
edges.push({
id: `${parentGameId}->${node.gameId}`,
parentGameId,
childGameId: node.gameId,
from,
to,
- path:
- `M ${from.x},${from.y} ` +
- `C ${from.x},${roundTo(from.y + delta * 0.5)} ` +
- `${to.x},${roundTo(to.y - delta * 0.5)} ` +
- `${to.x},${to.y}`,
+ path,
});
}
diff --git a/src/services/clipboard.test.ts b/src/services/clipboard.test.ts
index f88d6dd24..afcdd6b0c 100644
--- a/src/services/clipboard.test.ts
+++ b/src/services/clipboard.test.ts
@@ -48,6 +48,30 @@ describe('copyTextToClipboard', () => {
expect(writeText).toHaveBeenCalledWith('PZ-00000002');
});
+ test('Clipboard API 永不 settle 时超时降级,不把调用方挂死', async () => {
+ vi.spyOn(hostBridge, 'writeHostClipboardText').mockResolvedValue(false);
+ // 页面未聚焦 / 无系统剪贴板时 writeText 可能既不成功也不拒绝——这里固定复现这个 promise。
+ const writeText = vi.fn(() => new Promise(() => {}));
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText },
+ });
+ const execCommand = vi.fn(() => false);
+ Object.defineProperty(document, 'execCommand', {
+ configurable: true,
+ value: execCommand,
+ });
+ vi.useFakeTimers();
+
+ const pending = copyTextToClipboard(' PZ-00000003 ');
+ await vi.advanceTimersByTimeAsync(1000);
+
+ // 超时后走兼容路径,得到确定结果(这里是 false = 复制失败),而不是永远挂着。
+ await expect(pending).resolves.toBe(false);
+ expect(writeText).toHaveBeenCalledWith('PZ-00000003');
+ vi.useRealTimers();
+ });
+
test('空文本不触发宿主或浏览器复制', async () => {
const writeHostClipboardText = vi
.spyOn(hostBridge, 'writeHostClipboardText')
diff --git a/src/services/clipboard.ts b/src/services/clipboard.ts
index eb6a62606..e590c51ff 100644
--- a/src/services/clipboard.ts
+++ b/src/services/clipboard.ts
@@ -1,5 +1,16 @@
import { writeHostClipboardText } from './host-bridge/hostBridge';
+/**
+ * 标准 Clipboard API 的等待上限。
+ *
+ * 为什么需要它:`navigator.clipboard.writeText` 在**页面未聚焦 / 无系统剪贴板 /
+ * 自动化上下文**里可能既不成功也不拒绝(promise 永不 settle)。调用方(复制按钮)是在
+ * await 之后才切状态的,一旦这里挂住,用户点下去就是「按钮没反应、剪贴板也没变」——
+ * 反馈窗口调多长都没用。超时后走下面的兼容路径(textarea + execCommand),
+ * 那条路径是同步的,总能给出一个确定结果。
+ */
+const CLIPBOARD_API_TIMEOUT_MS = 800;
+
export async function copyTextToClipboard(value: string) {
const text = value.trim();
if (!text) {
@@ -12,8 +23,16 @@ export async function copyTextToClipboard(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
- await navigator.clipboard.writeText(text);
- return true;
+ const written = await Promise.race([
+ navigator.clipboard.writeText(text).then(() => true),
+ new Promise((resolve) => {
+ setTimeout(() => resolve(false), CLIPBOARD_API_TIMEOUT_MS);
+ }),
+ ]);
+ if (written) {
+ return true;
+ }
+ // 超时:不在这里卡住,继续走兼容路径(结果是同步可判定的)。
} catch {
// 部分内嵌浏览器会暴露 Clipboard API,但会因权限上下文拒绝写入,继续走兼容路径。
}