实现游戏共创 M3:创作族谱树与衍生列表

- 新增 module-game-distribution/src/lineage.rs:锚点可读性、可见性过滤、稳定排序、上限截断的纯函数,附 15 个单测(复用 M1 的软删除判定,不重写第二套规则)
- 新增公开只读 procedure get_game_distribution_lineage / list_game_distribution_derived_games;锚点必须公开可读(未公开/已软删除/不存在一律按不存在处理),树内只出现未软删除且已公开的节点,不补 null 占位节点
- api-server 新增 GET /games/{id}/lineage 与 /games/{id}/derived(匿名可读、no-store),响应走结构化 DTO,节点键集合由测试钉死,杜绝下发对象键/素材键
- 作者自有游戏聚合追加 forkCount(与公开详情 forkCount 同口径),供「被改编 N」入口使用
- shared-contracts 与 TS 契约新增 LineageNode / LineageResponse / DerivedResponse 三型并登记 DTO 一致性检查
- 前端新增 /games/lineage 族谱页,登记 stage 类型、路由表、页面标题与壳层渲染分支;详情页共创卡新增「查看创作族谱」入口(仅已公开作品)
- 我的作品页新增「被改编 N」行内展开,列出直接子代的标题/作者/代际/状态,含空态与失败重试(仅已公开作品)
- 补前端组件/路由/标题/客户端用例与 api-server 路由、负载映射用例;同步里程碑验收标准与技术方案 §3.4 的锚点可读性口径
This commit is contained in:
2026-10-05 01:02:03 +08:00
parent 3d51d1309a
commit 39669763fe
36 changed files with 2780 additions and 27 deletions
@@ -100,6 +100,11 @@ const GamePlayPage = lazy(async () => {
return { default: module.GamePlayPage };
});
const GameLineagePage = lazy(async () => {
const module = await import('../game-distribution/GameLineagePage');
return { default: module.GameLineagePage };
});
type ActiveRailButtonProps = {
active: boolean;
emphasized?: boolean;
@@ -473,6 +478,32 @@ export function PlatformEntryFlowShellImpl({
[setSelectionStage],
);
/**
* 打开创作族谱页。路由按规范只要求 `?id=<rootGameId>`;这里额外带上 `from=<当前作品>`,
* 让页面对「用户点进来时所在的作品」做高亮,直接打开链接时缺省高亮根节点。
*/
const openGameLineage = useCallback(
(rootGameId: string, fromGameId?: string) => {
const params = new URLSearchParams({ id: rootGameId });
if (fromGameId && fromGameId !== rootGameId) {
params.set('from', fromGameId);
}
const path = `/games/lineage?${params.toString()}`;
setSelectionStage('game-lineage', { path });
},
[setSelectionStage],
);
/** 族谱页从详情页进来,返回优先走浏览器原生返回,兜底回广场。 */
const backFromGameLineage = useCallback(() => {
if (hasAppHistoryBackEntry()) {
window.history.back();
return;
}
replaceAppHistoryPath('/games');
setSelectionStage('games', { path: '/games' });
}, [setSelectionStage]);
/**
* 详情页可能从广场或「我的游戏」进入,返回优先走浏览器原生返回,这样能回到
* 用户真正的来源页;直接打开详情链接等没有可回退的应用内历史时再兜底回广场。
@@ -604,6 +635,7 @@ export function PlatformEntryFlowShellImpl({
selectionStage === 'game-detail' ||
selectionStage === 'game-play' ||
selectionStage === 'game-mine' ||
selectionStage === 'game-lineage' ||
selectionStage === 'game-publish';
/**
* 游玩页自己撑满剩余高度,面板不能再滚动,否则移动端会出现整页可滚的
@@ -614,6 +646,7 @@ export function PlatformEntryFlowShellImpl({
typeof window === 'undefined' ? '' : window.location.search,
);
const gameId = gameSearchParams.get('id');
const lineageHighlightGameId = gameSearchParams.get('from');
const publishGameId = gameSearchParams.get('game');
const avatarUrl = authUi?.user?.avatarUrl?.trim() || null;
const avatarLabel = resolveActiveUserAvatarLabel(authUi?.user);
@@ -870,6 +903,14 @@ export function PlatformEntryFlowShellImpl({
ownerView={gameSearchParams.get('owner') === '1'}
onBack={backFromGameDetail}
onPlay={openGamePlay}
onOpenLineage={openGameLineage}
/>
) : selectionStage === 'game-lineage' ? (
<GameLineagePage
rootGameId={gameId}
highlightGameId={lineageHighlightGameId}
onBack={backFromGameLineage}
onOpenDetail={openGameDetail}
/>
) : (
<GamePlayPage gameId={gameId} onBack={backFromGamePlay} />