refactor: 收口作品架 Source Adapter registry
This commit is contained in:
@@ -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`。
|
||||
|
||||
|
||||
@@ -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 收口。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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<CustomWorldProfile>[];
|
||||
@@ -252,70 +257,135 @@ export function buildCreationWorkShelfItems(params: {
|
||||
getItemState,
|
||||
} = params;
|
||||
|
||||
return [
|
||||
...rpgItems.map((item) =>
|
||||
return buildCreationWorkShelfItemsFromSources({
|
||||
sources: [
|
||||
{
|
||||
kind: 'rpg',
|
||||
buildItems: () =>
|
||||
rpgItems.map((item) =>
|
||||
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries, {
|
||||
onOpenDraft: onOpenRpgDraft,
|
||||
onEnterPublished: onEnterRpgPublished,
|
||||
onDelete: onDeleteRpg,
|
||||
}),
|
||||
),
|
||||
...bigFishItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'big-fish',
|
||||
buildItems: () =>
|
||||
bigFishItems.map((item) =>
|
||||
mapBigFishWorkToShelfItem(item, canDeleteBigFish, {
|
||||
onOpen: onOpenBigFishDetail,
|
||||
onDelete: onDeleteBigFish,
|
||||
}),
|
||||
),
|
||||
...match3dItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'match3d',
|
||||
buildItems: () =>
|
||||
match3dItems.map((item) =>
|
||||
mapMatch3DWorkToShelfItem(item, canDeleteMatch3D, {
|
||||
onOpen: onOpenMatch3DDetail,
|
||||
onDelete: onDeleteMatch3D,
|
||||
}),
|
||||
),
|
||||
...squareHoleItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'square-hole',
|
||||
buildItems: () =>
|
||||
squareHoleItems.map((item) =>
|
||||
mapSquareHoleWorkToShelfItem(item, canDeleteSquareHole, {
|
||||
onOpen: onOpenSquareHoleDetail,
|
||||
onDelete: onDeleteSquareHole,
|
||||
}),
|
||||
),
|
||||
...jumpHopItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'jump-hop',
|
||||
buildItems: () =>
|
||||
jumpHopItems.map((item) =>
|
||||
mapJumpHopWorkToShelfItem(item, canDeleteJumpHop, {
|
||||
onOpen: onOpenJumpHopDetail,
|
||||
onDelete: onDeleteJumpHop,
|
||||
}),
|
||||
),
|
||||
...woodenFishItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'wooden-fish',
|
||||
buildItems: () =>
|
||||
woodenFishItems.map((item) =>
|
||||
mapWoodenFishWorkToShelfItem(item, canDeleteWoodenFish, {
|
||||
onOpen: onOpenWoodenFishDetail,
|
||||
onDelete: onDeleteWoodenFish,
|
||||
}),
|
||||
),
|
||||
...puzzleItems.map((item) =>
|
||||
},
|
||||
{
|
||||
kind: 'puzzle',
|
||||
buildItems: () =>
|
||||
puzzleItems.map((item) =>
|
||||
mapPuzzleWorkToShelfItem(item, canDeletePuzzle, {
|
||||
onOpen: onOpenPuzzleDetail,
|
||||
onDelete: onDeletePuzzle,
|
||||
onClaimPointIncentive: onClaimPuzzlePointIncentive,
|
||||
}),
|
||||
),
|
||||
...babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(item, canDeleteBabyObjectMatch, {
|
||||
},
|
||||
{
|
||||
kind: 'baby-object-match',
|
||||
buildItems: () =>
|
||||
babyObjectMatchItems.map((item) =>
|
||||
mapBabyObjectMatchDraftToShelfItem(
|
||||
item,
|
||||
canDeleteBabyObjectMatch,
|
||||
{
|
||||
onOpen: onOpenBabyObjectMatchDetail,
|
||||
onDelete: onDeleteBabyObjectMatch,
|
||||
}),
|
||||
},
|
||||
),
|
||||
...mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
|
||||
),
|
||||
},
|
||||
{
|
||||
kind: 'bark-battle',
|
||||
buildItems: () =>
|
||||
mergeBarkBattleShelfSourceItems(barkBattleItems).map((item) =>
|
||||
mapBarkBattleWorkToShelfItem(item, canDeleteBarkBattle, {
|
||||
onOpen: onOpenBarkBattleDetail,
|
||||
onDelete: onDeleteBarkBattle,
|
||||
}),
|
||||
),
|
||||
...visualNovelItems.map((item) =>
|
||||
},
|
||||
{
|
||||
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<CreationWorkShelfItem[]>(
|
||||
(items, source) => {
|
||||
items.push(...source.buildItems());
|
||||
return items;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return sourceItems
|
||||
.map((item) => {
|
||||
const state = getItemState?.(item);
|
||||
const persistedIsGenerating = isPersistedCreationWorkGenerating(item);
|
||||
|
||||
Reference in New Issue
Block a user