From e2056db146f5c4c9fb13edd9558da086e4281cdc Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Tue, 6 Oct 2026 15:11:07 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=8F=E8=B0=B1=E6=A0=91=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=9A=E5=89=AA=E8=B4=B4=E6=9D=BF=E6=B0=B8=E4=B8=8D=20settle?= =?UTF-8?q?=20=E4=BF=AE=E5=A4=8D=E3=80=81elbow=20=E8=BF=9E=E7=BA=BF?= =?UTF-8?q?=E3=80=81=E9=94=81=E5=AE=9A=E8=B7=AF=E5=BE=84=E4=B8=8E=E9=9D=A2?= =?UTF-8?q?=E5=8C=85=E5=B1=91=E3=80=81=E7=AA=84=E5=B1=8F=E7=B4=A7=E5=87=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clipboard: race navigator.clipboard.writeText with 800ms timeout then fall back (fixes dead copy button); edges: rounded elbow routing; pin path + breadcrumb; three-tier highlight; compact mobile cards; 265 tests green. --- .../GameDistributionPages.test.tsx | 121 ++++++++++++++--- .../game-distribution/GameLineagePage.tsx | 127 ++++++++++++++++-- .../game-distribution/gameDistribution.css | 70 ++++++++++ .../lineageTreeLayout.test.ts | 18 ++- .../game-distribution/lineageTreeLayout.ts | 29 +++- src/services/clipboard.test.ts | 24 ++++ src/services/clipboard.ts | 23 +++- 7 files changed, 371 insertions(+), 41 deletions(-) diff --git a/src/components/game-distribution/GameDistributionPages.test.tsx b/src/components/game-distribution/GameDistributionPages.test.tsx index 6c793673b..816b44d4c 100644 --- a/src/components/game-distribution/GameDistributionPages.test.tsx +++ b/src/components/game-distribution/GameDistributionPages.test.tsx @@ -1130,7 +1130,7 @@ describe('GameLineagePage', () => { // 父作品不在可见集合里:只标注「原作品已不可用」,不回显父作品内容。 expect(screen.getByText('原作品已不可用')).toBeTruthy(); // 任一公开节点都能进入对应详情页。 - fireEvent.click(screen.getByRole('button', { name: /《母版》/u })); + fireEvent.click(screen.getByRole('button', { name: /作品《母版》/u })); expect(onOpenDetail).toHaveBeenCalledWith('game-root'); }); @@ -1219,7 +1219,7 @@ describe('GameLineagePage', () => { ); expect( - await screen.findByRole('button', { name: /《母版》/u }), + await screen.findByRole('button', { name: /作品《母版》/u }), ).toBeTruthy(); expect( screen.getByText('这部作品还没有衍生作品,欢迎从它开始共创。'), @@ -2026,8 +2026,10 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const leafCard = await screen.findByRole('button', { name: /《叶节点》/u }); - const rootCard = screen.getByRole('button', { name: /《母版》/u }); + const leafCard = await screen.findByRole('button', { + name: /作品《叶节点》/u, + }); + const rootCard = screen.getByRole('button', { name: /作品《母版》/u }); // 当前作品标记 expect(leafCard.getAttribute('aria-current')).toBe('true'); expect(rootCard.getAttribute('aria-current')).toBeNull(); @@ -2036,10 +2038,13 @@ describe('GameLineagePage 可视化与交互', () => { expect(parseFloat(leafCard.style.top)).toBeGreaterThan( parseFloat(rootCard.style.top), ); - // 贝塞尔连线 + // 正交 elbow 连线 const paths = document.querySelectorAll('path.game-lineage-edge'); expect(paths.length).toBeGreaterThan(0); - expect(paths[0]!.getAttribute('d')).toContain(' C '); + // 正交 elbow:竖直段 + 水平段 + 圆角(不再是三次贝塞尔弧)。 + const firstEdgePath = paths[0]!.getAttribute('d') ?? ''; + expect(firstEdgePath).toContain(' V '); + expect(firstEdgePath).not.toContain(' C '); // 键盘 Enter 与点击等价 fireEvent.keyDown(leafCard, { key: 'Enter' }); expect(onOpenDetail).toHaveBeenCalledWith('game-leaf'); @@ -2057,10 +2062,12 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const midCard = await screen.findByRole('button', { name: /《中间代》/u }); - const rootCard = screen.getByRole('button', { name: /《母版》/u }); - const leafCard = screen.getByRole('button', { name: /《叶节点》/u }); - const otherCard = screen.getByRole('button', { name: /《旁支》/u }); + const midCard = await screen.findByRole('button', { + name: /作品《中间代》/u, + }); + const rootCard = screen.getByRole('button', { name: /作品《母版》/u }); + const leafCard = screen.getByRole('button', { name: /作品《叶节点》/u }); + const otherCard = screen.getByRole('button', { name: /作品《旁支》/u }); fireEvent.mouseEnter(midCard); // 祖先(母版)+ 自身 + 子树(叶节点)都不降透明,旁支降透明。 @@ -2093,7 +2100,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /《母版》/u }); + await screen.findByRole('button', { name: /作品《母版》/u }); // 初次适配在 effect 里落地:先等它生效(jsdom 无布局尺寸 → min(scaleX, scaleY) 远小于 1, // 因此命中可读下限 60%)。 await waitFor(() => @@ -2104,9 +2111,9 @@ describe('GameLineagePage 可视化与交互', () => { expect(screen.getByText(/已按可读缩放显示/u)).toBeTruthy(); // 主动点「适应窗口」才做真正的全览(不受可读下限约束),提示随之收起。 fireEvent.click(screen.getByRole('button', { name: '适应窗口' })); - expect( - document.querySelector('.game-lineage-zoom')?.textContent, - ).toBe('35%'); + expect(document.querySelector('.game-lineage-zoom')?.textContent).toBe( + '35%', + ); expect(screen.queryByText(/已按可读缩放显示/u)).toBeNull(); }); @@ -2120,13 +2127,15 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /《母版》/u }); + await screen.findByRole('button', { name: /作品《母版》/u }); const axisLayer = document.querySelector('.game-lineage-canvas__axis'); expect(axisLayer).not.toBeNull(); expect(axisLayer?.querySelectorAll('.game-lineage-axis').length).toBe(3); // 轴不在内容层里:不会跟着 transform 缩放/平移。 expect( - document.querySelector('.game-lineage-canvas__content .game-lineage-axis'), + document.querySelector( + '.game-lineage-canvas__content .game-lineage-axis', + ), ).toBeNull(); expect(axisLayer?.textContent).toContain('母版'); @@ -2168,17 +2177,83 @@ describe('GameLineagePage 可视化与交互', () => { await screen.findByText('有封面'); // 只有带 coverObjectKey 的那个节点渲染 GameCover(复用既有封面组件与换签逻辑)。 - expect(document.querySelectorAll('.game-lineage-node .game-cover')).toHaveLength( - 1, - ); expect( - document.querySelectorAll('img.game-cover__image'), + document.querySelectorAll('.game-lineage-node .game-cover'), ).toHaveLength(1); + expect(document.querySelectorAll('img.game-cover__image')).toHaveLength(1); // 没有封面的节点不渲染空封面块。 const withoutCover = screen.getByRole('button', { name: /《没封面》/u }); expect(withoutCover.querySelector('.game-cover')).toBeNull(); }); + it('锁定路径后祖先链持续强化并给出面包屑;取消锁定后复原', async () => { + getGameLineageMock.mockResolvedValueOnce(treeWithSibling); + render( + , + ); + + const leafCard = await screen.findByRole('button', { + name: /作品《叶节点》/u, + }); + fireEvent.click( + within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }), + ); + + // 面包屑:母版 → 中间代 → 叶节点,祖先移出视口也能读。 + const breadcrumb = screen.getByLabelText('已锁定的路径'); + expect(breadcrumb.textContent).toContain('母版'); + expect(breadcrumb.textContent).toContain('中间代'); + expect(breadcrumb.textContent).toContain('叶节点'); + // 祖先链与目标持续强化(不依赖 hover),旁支仍是被压暗的一方。 + expect( + screen.getByRole('button', { name: /作品《母版》/u }).className, + ).toContain('game-lineage-node--path'); + expect( + screen.getByRole('button', { name: /作品《中间代》/u }).className, + ).toContain('game-lineage-node--path'); + expect(leafCard.className).toContain('game-lineage-node--pinned'); + expect( + screen.getByRole('button', { name: /作品《旁支》/u }).className, + ).toContain('game-lineage-node--dimmed'); + + fireEvent.click( + within(leafCard).getByRole('button', { name: '取消锁定 叶节点 的路径' }), + ); + expect(screen.queryByLabelText('已锁定的路径')).toBeNull(); + expect( + screen.getByRole('button', { name: /作品《旁支》/u }).className, + ).not.toContain('game-lineage-node--dimmed'); + }); + + it('窄屏用紧凑卡片:少一行信息、卡片标记为 compact', async () => { + setViewportWidth(375); + getGameLineageMock.mockResolvedValueOnce(treeWithSibling); + render( + , + ); + + const leafCard = await screen.findByRole('button', { + name: /作品《叶节点》/u, + }); + expect(leafCard.className).toContain('game-lineage-node--compact'); + // 紧凑模式不渲染作者行,但标题/Fork/锁定仍在(可访问性不降级)。 + expect(within(leafCard).queryByText('作者 作者 game-leaf')).toBeNull(); + expect( + within(leafCard).getByRole('button', { name: 'Fork 叶节点' }), + ).toBeTruthy(); + expect( + within(leafCard).getByRole('button', { name: '锁定 叶节点 的路径' }), + ).toBeTruthy(); + }); + it('缩放按钮改变百分比,「适应窗口」重新适配', async () => { getGameLineageMock.mockResolvedValueOnce(treeWithSibling); render( @@ -2189,7 +2264,7 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - await screen.findByRole('button', { name: /《母版》/u }); + await screen.findByRole('button', { name: /作品《母版》/u }); const zoomLabel = () => document.querySelector('.game-lineage-zoom')?.textContent ?? ''; // 初次适配是 effect 里跑的:先等它落定(jsdom 无布局尺寸 → 命中可读下限 60%)。 @@ -2214,7 +2289,9 @@ describe('GameLineagePage 可视化与交互', () => { />, ); - const leafCard = await screen.findByRole('button', { name: /《叶节点》/u }); + const leafCard = await screen.findByRole('button', { + name: /作品《叶节点》/u, + }); const forkButton = within(leafCard).getByRole('button', { name: 'Fork 叶节点', }); diff --git a/src/components/game-distribution/GameLineagePage.tsx b/src/components/game-distribution/GameLineagePage.tsx index 313b894c9..01f8d67be 100644 --- a/src/components/game-distribution/GameLineagePage.tsx +++ b/src/components/game-distribution/GameLineagePage.tsx @@ -22,6 +22,7 @@ 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, @@ -71,18 +72,28 @@ function LineageTreeNodeCard({ layoutNode, isAnchor, isDimmed, + isOnPath, + isPinned, + compact, onOpenDetail, onActivate, onDeactivate, onOpenFork, + onTogglePin, }: { layoutNode: LineageLayoutNode; isAnchor: boolean; isDimmed: boolean; + /** 该节点在「锁定/高亮目标」的祖先链上(含目标自身)。 */ + isOnPath: boolean; + isPinned: boolean; + /** 窄屏紧凑模式:少一行信息、卡片更小。 */ + compact: boolean; onOpenDetail: (gameId: string) => void; onActivate: (gameId: string) => void; onDeactivate: () => void; onOpenFork: (gameId: string) => void; + onTogglePin: (gameId: string) => void; }) { const { gameId, title, authorName, generation, isStub } = layoutNode; const displayTitle = isStub ? '原作品已不可用' : (title ?? '未命名作品'); @@ -114,6 +125,12 @@ function LineageTreeNodeCard({ isAnchor ? 'game-lineage-node--anchor' : '', isStub ? 'game-lineage-node--stub' : '', isDimmed ? 'game-lineage-node--dimmed' : '', + // 路径上的节点(祖先链 + 目标)与「仅子树」的节点给不同强度的强化, + // 这样「整棵子树」在默认全览下也有明显差异,而不是只把别人变淡。 + !isDimmed && isOnPath ? 'game-lineage-node--path' : '', + !isDimmed && !isOnPath ? 'game-lineage-node--subtree' : '', + isPinned ? 'game-lineage-node--pinned' : '', + compact ? 'game-lineage-node--compact' : '', ] .filter(Boolean) .join(' ')} @@ -161,10 +178,12 @@ function LineageTreeNodeCard({ ) : null} {displayTitle} - {/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有,不在这里重复。 */} - - {authorLabel} - + {/* 树节点只留「代际 + 标题 + 作者 + Fork」:游玩数与「与原版对比」在详情页已有。 */} + {compact ? null : ( + + {authorLabel} + + )} {isInteractive ? ( @@ -181,6 +200,23 @@ function LineageTreeNodeCard({ > Fork + ) : null} @@ -207,6 +243,8 @@ export function GameLineagePage({ const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(''); const [activeGameId, setActiveGameId] = useState(''); + const [pinnedGameId, setPinnedGameId] = useState(''); + const isMobileViewport = useIsMobileViewport(); const [includeSubtree, setIncludeSubtree] = useState(true); const [viewport, setViewport] = useState({ 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,但会因权限上下文拒绝写入,继续走兼容路径。 }