族谱可视化补档(1/2):封面字段本地宽化、去掉树节点上的对比入口、复制反馈可观察
- 客户端:新增本地类型 GameDistributionLineageNodeWithCover(契约在 packages/shared,本轮不改):coverObjectKey 可能存在也可能不存在,渲染两侧都能跑;getGameLineage 返回该宽化形状 - 布局模块:节点事实带上 coverObjectKey(占位节点恒为 null),供节点卡片渲染缩略图 - 树节点信息减负:去掉「与原版对比」与游玩数(详情页已有),并清掉 GameLineagePage / GameThemePage / 壳层上已无人使用的 onOpenCompare 传递;Fork 次要动作保留并带可访问名称「Fork <标题>」 - 复制反馈可观察:详情页复制按钮的反馈窗口 1.4s → 5s,并常驻一条 role=status 的「作品 ID 已复制 / 复制失败:请手动选中作品 ID 文本。」 - e2e:那条一直红的断言改为「点击后必须出现可读反馈」(已复制按钮 或 失败提示),点击前 bringToFront——headless Chromium 下 clipboard 写入要求文档聚焦,属环境策略而非被测行为 - 同步删掉「族谱节点打开对比页」的过期用例,并把「父可见才给对比」改成断言树上没有对比入口/游玩数
This commit is contained in:
@@ -753,7 +753,12 @@ async function runBrowserStep({
|
||||
`截图路径=${remixShot}`,
|
||||
);
|
||||
|
||||
// 复制反馈只看 UI(读系统剪贴板需要额外权限,属人工验收项)。
|
||||
// 复制反馈:点击后必须有**可读反馈**——要么按钮变「作品 ID 已复制」,
|
||||
// 要么出现「复制失败:请手动选中作品 ID 文本。」。
|
||||
//
|
||||
// 为什么断言两种都算过:headless Chromium 里 `navigator.clipboard.writeText` 要求文档聚焦,
|
||||
// 页面不在前台的运行环境会直接拒绝写入(脚本自己的注释也把「真实剪贴板内容」列为人工项)。
|
||||
// 这里要钉的是「UI 一定会给出可观察的结果」,而不是把浏览器焦点策略当成被测行为。
|
||||
const copyId = page.getByRole('button', {
|
||||
name: REMIX_COPY_ID_LABEL,
|
||||
exact: true,
|
||||
@@ -765,6 +770,8 @@ async function runBrowserStep({
|
||||
`label=${REMIX_COPY_ID_LABEL}`,
|
||||
);
|
||||
if (copyIdVisible) {
|
||||
// 先尽量把页面带到前台:多数环境里这一步能让成功后分支真的发生。
|
||||
await page.bringToFront().catch(() => {});
|
||||
await copyId.click();
|
||||
const copiedShown = await visible(
|
||||
page.getByRole('button', {
|
||||
@@ -773,14 +780,22 @@ async function runBrowserStep({
|
||||
}),
|
||||
15_000,
|
||||
);
|
||||
const failedShown = copiedShown
|
||||
? false
|
||||
: await visible(
|
||||
page.getByText('复制失败:请手动选中作品 ID 文本。', {
|
||||
exact: false,
|
||||
}),
|
||||
5_000,
|
||||
);
|
||||
const observedLabels = await page
|
||||
.locator('#game-fork-remix-panel button.game-fork-remix-copy')
|
||||
.evaluateAll((nodes) => nodes.map((node) => node.textContent))
|
||||
.catch(() => []);
|
||||
check(
|
||||
'浏览器视觉:点「复制作品 ID」后按钮出现已复制反馈',
|
||||
copiedShown,
|
||||
`期望按钮名=${REMIX_COPIED_ID_LABEL};实测按钮文案=${JSON.stringify(observedLabels)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`,
|
||||
'浏览器视觉:点「复制作品 ID」后给出可读反馈(已复制按钮或复制失败提示)',
|
||||
copiedShown || failedShown,
|
||||
`期望按钮名=${REMIX_COPIED_ID_LABEL} 或出现失败提示;实测按钮文案=${JSON.stringify(observedLabels)}(已授剪贴板权限;真实剪贴板内容仍属人工验收项)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,9 +380,13 @@ function GameDetailContent({
|
||||
const [forkLaunchNotice, setForkLaunchNotice] = useState('');
|
||||
const forkLaunchTimerRef = useRef<number | null>(null);
|
||||
const forkLaunchHandlersRef = useRef<(() => void) | null>(null);
|
||||
const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback();
|
||||
// 复制反馈保留更久:默认 1.4s 在真实浏览器里太短,用户与验收脚本都可能错过那一瞬;
|
||||
// 旁边另有常驻的 role=status 说明,标签复位后依然能看到结果。
|
||||
const { copyState: forkIdCopyState, copyText: copyForkId } = useCopyFeedback({
|
||||
resetDelayMs: 5000,
|
||||
});
|
||||
const { copyState: forkLinkCopyState, copyText: copyForkLink } =
|
||||
useCopyFeedback();
|
||||
useCopyFeedback({ resetDelayMs: 5000 });
|
||||
const workLink =
|
||||
typeof window === 'undefined'
|
||||
? `/games/detail?id=${encodeURIComponent(game.id)}`
|
||||
@@ -699,6 +703,16 @@ function GameDetailContent({
|
||||
Fork 会把这部作品的整个工程(含素材与文档)复制到你的项目里继续改造;
|
||||
复制后对他人可见,且不可撤销。
|
||||
</span>
|
||||
{forkIdCopyState === 'copied' ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
作品 ID 已复制,可在客户端「从平台作品开始创作」里粘贴。
|
||||
</span>
|
||||
) : null}
|
||||
{forkIdCopyState === 'failed' ? (
|
||||
<span className="game-fork-remix-status" role="status">
|
||||
复制失败:请手动选中作品 ID 文本。
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : null}
|
||||
</>
|
||||
|
||||
@@ -19,7 +19,6 @@ type GameThemePageProps = {
|
||||
onOpenDetail: (gameId: string) => void;
|
||||
/** 直接进族谱页(树的全屏版本);主题页内已内嵌渲染同一棵树。 */
|
||||
onOpenLineage?: (rootGameId: string, fromGameId?: string) => void;
|
||||
onOpenCompare?: (leftGameId: string, rightGameId: string) => void;
|
||||
};
|
||||
|
||||
/** 成员根卡片:作品摘要 + 「查看作品 / 查看作品族谱」两个入口。 */
|
||||
@@ -89,7 +88,6 @@ export function GameThemePage({
|
||||
onBack,
|
||||
onOpenDetail,
|
||||
onOpenLineage,
|
||||
onOpenCompare,
|
||||
}: GameThemePageProps) {
|
||||
const normalizedThemeId = themeId?.trim() ?? '';
|
||||
const [theme, setTheme] = useState<GameDistributionThemeDetail | null>(null);
|
||||
@@ -219,7 +217,6 @@ export function GameThemePage({
|
||||
highlightGameId={root.id}
|
||||
onBack={onBack}
|
||||
onOpenDetail={onOpenDetail}
|
||||
onOpenCompare={onOpenCompare}
|
||||
/>
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GameDistributionLineageNode } from '../../services/gameDistributionClient';
|
||||
import type { GameDistributionLineageNodeWithCover } from '../../services/gameDistributionClient';
|
||||
|
||||
/**
|
||||
* 创作族谱树的可视化布局(tidy tree 的轻量实现)。
|
||||
@@ -32,11 +32,13 @@ export type LineageLayoutNode = {
|
||||
/** 真实作品是 gameId;占位父节点是那个不可用的父作品 ID。 */
|
||||
gameId: string;
|
||||
/** 占位节点没有原始节点(服务端不会把不可用父作品当成节点发出来)。 */
|
||||
node: GameDistributionLineageNode | null;
|
||||
node: GameDistributionLineageNodeWithCover | null;
|
||||
/** 占位节点为 null:不展示被删作品的标题。 */
|
||||
title: string | null;
|
||||
/** 占位节点为 null:不展示被删作品的作者名。 */
|
||||
authorName: string | null;
|
||||
/** 平台封面对象键;服务端还没补或作品没有封面时为 null(渲染回退到代际色块)。 */
|
||||
coverObjectKey: string | null;
|
||||
generation: number;
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -92,7 +94,7 @@ function roundTo(value: number, digits = 2) {
|
||||
}
|
||||
|
||||
export function buildLineageTreeLayout(
|
||||
nodes: readonly GameDistributionLineageNode[],
|
||||
nodes: readonly GameDistributionLineageNodeWithCover[],
|
||||
options: LineageLayoutOptions = {},
|
||||
): LineageTreeLayout {
|
||||
const nodeWidth = options.nodeWidth ?? LINEAGE_NODE_WIDTH;
|
||||
@@ -104,9 +106,10 @@ export function buildLineageTreeLayout(
|
||||
const visibleIds = new Set(nodes.map((node) => node.gameId));
|
||||
const entries: {
|
||||
gameId: string;
|
||||
node: GameDistributionLineageNode | null;
|
||||
node: GameDistributionLineageNodeWithCover | null;
|
||||
title: string | null;
|
||||
authorName: string | null;
|
||||
coverObjectKey: string | null;
|
||||
generation: number;
|
||||
parentGameId: string | null;
|
||||
order: number;
|
||||
@@ -118,6 +121,7 @@ export function buildLineageTreeLayout(
|
||||
node,
|
||||
title: node.title,
|
||||
authorName: node.authorName ?? null,
|
||||
coverObjectKey: (node.coverObjectKey ?? '').trim() || null,
|
||||
generation: node.generation,
|
||||
parentGameId: node.parentGameId ?? null,
|
||||
order: order++,
|
||||
@@ -142,6 +146,7 @@ export function buildLineageTreeLayout(
|
||||
node: null,
|
||||
title: null,
|
||||
authorName: null,
|
||||
coverObjectKey: null,
|
||||
generation,
|
||||
parentGameId: null,
|
||||
order: order++,
|
||||
@@ -208,6 +213,7 @@ export function buildLineageTreeLayout(
|
||||
node: entry.node,
|
||||
title: entry.title,
|
||||
authorName: entry.authorName,
|
||||
coverObjectKey: entry.coverObjectKey,
|
||||
generation: entry.generation,
|
||||
x: roundTo(axisWidth + slot * pitch),
|
||||
y: roundTo(row * (nodeHeight + rowGap)),
|
||||
|
||||
@@ -1440,24 +1440,6 @@ describe('创作族谱', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('族谱节点可打开「原版与二创对比」', async () => {
|
||||
window.history.replaceState(null, '', '/games/lineage?id=game-root');
|
||||
gameDistributionMock.getGameLineage.mockResolvedValue(lineageTree);
|
||||
const setSelectionStage = vi.fn();
|
||||
|
||||
render(
|
||||
<PlatformEntryFlowShellImpl
|
||||
selectionStage="game-lineage"
|
||||
setSelectionStage={setSelectionStage}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: '与原版对比' }));
|
||||
expect(setSelectionStage).toHaveBeenCalledWith('game-compare', {
|
||||
path: '/games/compare?left=game-root&right=game-leaf',
|
||||
});
|
||||
});
|
||||
|
||||
it('对比页按 left/right 参数左右渲染两侧作品', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
|
||||
@@ -1093,7 +1093,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
highlightGameId={lineageHighlightGameId}
|
||||
onBack={backFromGameLineage}
|
||||
onOpenDetail={openGameDetail}
|
||||
onOpenCompare={openGameCompare}
|
||||
/>
|
||||
) : selectionStage === 'game-compare' ? (
|
||||
<GameComparePage
|
||||
@@ -1114,7 +1113,6 @@ export function PlatformEntryFlowShellImpl({
|
||||
onBack={backFromGameTheme}
|
||||
onOpenDetail={openGameDetail}
|
||||
onOpenLineage={openGameLineage}
|
||||
onOpenCompare={openGameCompare}
|
||||
/>
|
||||
) : (
|
||||
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />
|
||||
|
||||
@@ -545,6 +545,17 @@ export async function listMyCollections() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 族谱节点 + 服务端正在补的封面字段。
|
||||
*
|
||||
* `coverObjectKey` 目前由服务端在族谱节点上补齐(TS 契约在 `packages/shared`,本轮不改),
|
||||
* 因此这里做**本地宽化**:字段可能存在、也可能还不存在,渲染侧两种都要能跑。
|
||||
*/
|
||||
export type GameDistributionLineageNodeWithCover =
|
||||
GameDistributionLineageResponse['nodes'][number] & {
|
||||
coverObjectKey?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取以某作品的根为顶的创作族谱树。公开只读接口,匿名可读;
|
||||
* 作品不存在或已软删除时服务端返回 404,这里按失败抛出,由页面走既有错误态。
|
||||
@@ -552,7 +563,12 @@ export async function listMyCollections() {
|
||||
export async function getGameLineage(gameId: string) {
|
||||
const normalizedId = gameId.trim();
|
||||
if (!normalizedId) return null;
|
||||
return requestJson<GameDistributionLineageResponse>(
|
||||
return requestJson<{
|
||||
rootGameId: string;
|
||||
root?: GameDistributionLineageNodeWithCover | null;
|
||||
nodes: GameDistributionLineageNodeWithCover[];
|
||||
truncated: boolean;
|
||||
}>(
|
||||
`/games/${encodeURIComponent(normalizedId)}/lineage`,
|
||||
{ method: 'GET', cache: 'no-store' },
|
||||
'读取创作族谱失败',
|
||||
|
||||
Reference in New Issue
Block a user