From 5783bfeea6d45bb66cb2a006be01e7a421864f9f Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 3 Jun 2026 15:47:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E5=8F=A3=E4=BD=9C?= =?UTF-8?q?=E5=93=81=E6=9E=B6=20Source=20Adapter=20registry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .hermes/shared-memory/decision-log.md | 4 +- ...架构】WorkShelfModule收口计划-2026-06-03.md | 8 +- .../creationWorkShelf.test.ts | 83 ++++++- .../custom-world-home/creationWorkShelf.ts | 202 ++++++++++++------ 4 files changed, 225 insertions(+), 72 deletions(-) diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index a3aff1f8..6b02e60d 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -35,8 +35,8 @@ ## 2026-06-03 Work Shelf 打开动作交由 item Adapter - 背景:`creationWorkShelf.ts` 已经为每个 `CreationWorkShelfItem` 生成 `actions.open`,但 `CustomWorldCreationHub.tsx` 点击卡片后仍按 `item.source.kind` 重复分发 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物的打开逻辑。 -- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。 -- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、后续新增玩法作品架接入。 +- 决策:`CreationWorkShelfItem.actions.open` 作为作品架打开动作的正式 Interface;Hub 只保留 `onOpenShelfItem` 通知和 `item.actions.open()` 调用,不再读取玩法 kind 做打开分支。`buildCreationWorkShelfItemsFromSources` 与 `CreationWorkShelfSourceAdapter` 作为 source registry Interface,统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序;旧 `buildCreationWorkShelfItems` 保留兼容,但内部改为组装 source adapters。 +- 影响范围:创作中心作品架卡片点击、作品架动作 Adapter、source registry、后续新增玩法作品架接入。 - 验证方式:`npm run test -- src/components/custom-world-home/creationWorkShelf.test.ts src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。 - 关联文档:`docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md`。 diff --git a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md index a1bbc0c0..b6e954b9 100644 --- a/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md +++ b/docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md @@ -8,16 +8,18 @@ `CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。 +`buildCreationWorkShelfItemsFromSources` 是作品架 source registry 的正式 **Interface**。每个玩法提供一个 `CreationWorkShelfSourceAdapter`,Adapter 负责把玩法数据、删除权限、打开动作和特殊动作映射为 `CreationWorkShelfItem[]`。registry 统一执行 flatten、运行态覆盖、持久化生成态兜底和更新时间排序。 + 此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep: - **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。 -- **Interface**:Hub 只需要 `CreationWorkShelfItem`,不需要知道每种玩法的打开规则。 +- **Interface**:Hub 只需要 `CreationWorkShelfItem`;后续调用方也可只传 `CreationWorkShelfSourceAdapter[]`,不需要知道每种玩法的打开规则、状态覆盖和排序规则。 - **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。 -- **Locality**:作品架点击行为的错误集中在 `creationWorkShelf.ts` 与其测试里定位。 +- **Locality**:作品架点击行为、source flatten、运行态覆盖和排序错误集中在 `creationWorkShelf.ts` 与其测试里定位。 ## 后续深化 -下一步可把 `buildCreationWorkShelfItems` 当前的长参数列表继续收口为 per-kind Source Adapter registry。届时 Hub / 平台壳传入玩法数据源和回调时,可逐步减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 +`buildCreationWorkShelfItems` 仍保留旧长参数兼容入口,但其 **Implementation** 已改为组装 `CreationWorkShelfSourceAdapter[]` 后复用 `buildCreationWorkShelfItemsFromSources`。下一步可让 Hub / 平台壳逐步直接传入 source adapters,从而减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。 ## 验证 diff --git a/src/components/custom-world-home/creationWorkShelf.test.ts b/src/components/custom-world-home/creationWorkShelf.test.ts index e0866d6d..d5d7f1fa 100644 --- a/src/components/custom-world-home/creationWorkShelf.test.ts +++ b/src/components/custom-world-home/creationWorkShelf.test.ts @@ -5,10 +5,11 @@ import { expect, test, vi } from 'vitest'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; import { buildCreationWorkShelfItems, + buildCreationWorkShelfItemsFromSources, + type CreationWorkShelfItem, getCreationWorkShelfItemTime, hasBarkBattleRequiredImages, isPersistedBarkBattleDraftGenerating, - type CreationWorkShelfItem, } from './creationWorkShelf'; import { CustomWorldWorkCard } from './CustomWorldWorkCard'; @@ -56,6 +57,86 @@ test('buildCreationWorkShelfItems maps visual novel items with VN public code', expect(items[1]?.publicWorkCode).toBeNull(); }); +test('buildCreationWorkShelfItemsFromSources flattens source adapters and applies runtime state', () => { + const [staleRpgItem] = buildCreationWorkShelfItems({ + rpgItems: [ + { + workId: 'draft:rpg-source-adapter', + sourceType: 'agent_session', + status: 'draft', + title: '旧 RPG 草稿', + subtitle: '待完善', + summary: '通过 source adapter 输入。', + coverImageSrc: null, + updatedAt: '2026-05-01T00:00:00.000Z', + publishedAt: null, + stage: 'clarifying', + stageLabel: '待完善', + playableNpcCount: 0, + landmarkCount: 0, + sessionId: 'rpg-source-adapter', + profileId: null, + canResume: true, + canEnterWorld: false, + }, + ], + bigFishItems: [], + puzzleItems: [], + }); + const [freshPuzzleItem] = buildCreationWorkShelfItems({ + rpgItems: [], + bigFishItems: [], + puzzleItems: [ + { + workId: 'puzzle:source-adapter', + profileId: 'puzzle-source-adapter', + ownerUserId: 'user-1', + authorDisplayName: '拼图作者', + levelName: '新拼图', + summary: '新近拼图。', + themeTags: ['灯塔'], + coverImageSrc: null, + publicationStatus: 'draft', + updatedAt: '2026-05-03T00:00:00.000Z', + publishedAt: null, + playCount: 0, + remixCount: 0, + likeCount: 0, + publishReady: false, + }, + ], + }); + + const items = buildCreationWorkShelfItemsFromSources({ + sources: [ + { + kind: 'rpg', + buildItems: () => (staleRpgItem ? [staleRpgItem] : []), + }, + { + kind: 'puzzle', + buildItems: () => (freshPuzzleItem ? [freshPuzzleItem] : []), + }, + ], + getItemState: (item) => + item.id === staleRpgItem?.id + ? { + isGenerating: true, + hasUnreadUpdate: true, + titleOverride: '生成中 RPG 草稿', + } + : null, + }); + + expect(items.map((item) => item.id)).toEqual([ + 'puzzle:source-adapter', + 'draft:rpg-source-adapter', + ]); + expect(items[1]?.title).toBe('生成中 RPG 草稿'); + expect(items[1]?.isGenerating).toBe(true); + expect(items[1]?.hasUnreadUpdate).toBe(true); +}); + test('buildCreationWorkShelfItems maps wooden fish items with WF public code', () => { const onOpenWoodenFishDetail = vi.fn(); const woodenFishWork = { diff --git a/src/components/custom-world-home/creationWorkShelf.ts b/src/components/custom-world-home/creationWorkShelf.ts index 1b12420f..eb068ca6 100644 --- a/src/components/custom-world-home/creationWorkShelf.ts +++ b/src/components/custom-world-home/creationWorkShelf.ts @@ -2,19 +2,19 @@ import type { BarkBattleWorkSummary } from '../../../packages/shared/src/contrac import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary'; import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent'; import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks'; import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime'; import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks'; import type { VisualNovelWorkSummary } from '../../../packages/shared/src/contracts/visualNovel'; -import type { JumpHopWorkSummaryResponse } from '../../../packages/shared/src/contracts/jumpHop'; import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish'; import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; import { buildBabyObjectMatchPublicWorkCode, - buildCustomWorldPublicWorkCode, buildBarkBattlePublicWorkCode, buildBigFishPublicWorkCode, + buildCustomWorldPublicWorkCode, buildJumpHopPublicWorkCode, buildMatch3DPublicWorkCode, buildPuzzlePublicWorkCode, @@ -157,6 +157,11 @@ export type CreationWorkShelfRuntimeState = { summaryOverride?: string; }; +export type CreationWorkShelfSourceAdapter = { + kind: CreationWorkShelfKind; + buildItems: () => readonly CreationWorkShelfItem[]; +}; + export function buildCreationWorkShelfItems(params: { rpgItems: CustomWorldWorkSummary[]; rpgLibraryEntries?: CustomWorldLibraryEntry[]; @@ -252,70 +257,135 @@ export function buildCreationWorkShelfItems(params: { getItemState, } = params; - return [ - ...rpgItems.map((item) => - mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, { - onOpenDraft: onOpenRpgDraft, - onEnterPublished: onEnterRpgPublished, - onDelete: onDeleteRpg, - }), - ), - ...bigFishItems.map((item) => - mapBigFishWorkToShelfItem(item, canDeleteBigFish, { - onOpen: onOpenBigFishDetail, - onDelete: onDeleteBigFish, - }), - ), - ...match3dItems.map((item) => - mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, { - onOpen: onOpenMatch3DDetail, - onDelete: onDeleteMatch3D, - }), - ), - ...squareHoleItems.map((item) => - mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, { - onOpen: onOpenSquareHoleDetail, - onDelete: onDeleteSquareHole, - }), - ), - ...jumpHopItems.map((item) => - mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { - onOpen: onOpenJumpHopDetail, - onDelete: onDeleteJumpHop, - }), - ), - ...woodenFishItems.map((item) => - mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { - onOpen: onOpenWoodenFishDetail, - onDelete: onDeleteWoodenFish, - }), - ), - ...puzzleItems.map((item) => - mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { - onOpen: onOpenPuzzleDetail, - onDelete: onDeletePuzzle, - onClaimPointIncentive: onClaimPuzzlePointIncentive, - }), - ), - ...babyObjectMatchItems.map((item) => - mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, { - onOpen: onOpenBabyObjectMatchDetail, - onDelete: onDeleteBabyObjectMatch, - }), - ), - ...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) => - mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, { - onOpen: onOpenBarkBattleDetail, - onDelete: onDeleteBarkBattle, - }), - ), - ...visualNovelItems.map((item) => - mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { - onOpen: onOpenVisualNovelDetail, - onDelete: onDeleteVisualNovel, - }), - ), - ] + return buildCreationWorkShelfItemsFromSources({ + sources: [ + { + kind: 'rpg', + buildItems: () => + rpgItems.map((item) => + mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, { + onOpenDraft: onOpenRpgDraft, + onEnterPublished: onEnterRpgPublished, + onDelete: onDeleteRpg, + }), + ), + }, + { + kind: 'big-fish', + buildItems: () => + bigFishItems.map((item) => + mapBigFishWorkToShelfItem(item, canDeleteBigFish, { + onOpen: onOpenBigFishDetail, + onDelete: onDeleteBigFish, + }), + ), + }, + { + kind: 'match3d', + buildItems: () => + match3dItems.map((item) => + mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, { + onOpen: onOpenMatch3DDetail, + onDelete: onDeleteMatch3D, + }), + ), + }, + { + kind: 'square-hole', + buildItems: () => + squareHoleItems.map((item) => + mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, { + onOpen: onOpenSquareHoleDetail, + onDelete: onDeleteSquareHole, + }), + ), + }, + { + kind: 'jump-hop', + buildItems: () => + jumpHopItems.map((item) => + mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, { + onOpen: onOpenJumpHopDetail, + onDelete: onDeleteJumpHop, + }), + ), + }, + { + kind: 'wooden-fish', + buildItems: () => + woodenFishItems.map((item) => + mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, { + onOpen: onOpenWoodenFishDetail, + onDelete: onDeleteWoodenFish, + }), + ), + }, + { + kind: 'puzzle', + buildItems: () => + puzzleItems.map((item) => + mapPuzzleWorkToShelfItem(item, canDeletePuzzle, { + onOpen: onOpenPuzzleDetail, + onDelete: onDeletePuzzle, + onClaimPointIncentive: onClaimPuzzlePointIncentive, + }), + ), + }, + { + kind: 'baby-object-match', + buildItems: () => + babyObjectMatchItems.map((item) => + mapBabyObjectMatchDraftToShelfItem( + item, + canDeleteBabyObjectMatch, + { + onOpen: onOpenBabyObjectMatchDetail, + onDelete: onDeleteBabyObjectMatch, + }, + ), + ), + }, + { + kind: 'bark-battle', + buildItems: () => + mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) => + mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, { + onOpen: onOpenBarkBattleDetail, + onDelete: onDeleteBarkBattle, + }), + ), + }, + { + kind: 'visual-novel', + buildItems: () => + visualNovelItems.map((item) => + mapVisualNovelWorkToShelfItem(item, canDeleteVisualNovel, { + onOpen: onOpenVisualNovelDetail, + onDelete: onDeleteVisualNovel, + }), + ), + }, + ], + getItemState, + }); +} + +export function buildCreationWorkShelfItemsFromSources(params: { + sources: readonly CreationWorkShelfSourceAdapter[]; + getItemState?: ( + item: CreationWorkShelfItem, + ) => CreationWorkShelfRuntimeState | null; +}) { + const { sources, getItemState } = params; + const sourceItems = sources.reduce( + (items, source) => { + items.push(...source.buildItems()); + return items; + }, + [], + ); + + return sourceItems .map((item) => { const state = getItemState?.(item); const persistedIsGenerating = isPersistedCreationWorkGenerating(item);