refactor: 深化前端入口作品流与作品架模块
This commit is contained in:
@@ -24,6 +24,22 @@
|
||||
- 验证方式:`npm run test -- src/services/sseStream.test.ts src/services/creation-agent/creationAgentSse.test.ts src/services/creative-agent/creativeAgentSse.test.ts src/services/visual-novel-runtime/visualNovelRuntimeSse.test.ts src/services/rpg-entry/rpgProfileClient.test.ts src/services/ai.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 `npx eslint ... --max-warnings 0` 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md`。
|
||||
|
||||
## 2026-06-03 平台入口公开作品流身份规则收口
|
||||
|
||||
- 背景:平台入口公开作品推荐流需要同时处理 RPG、拼图、抓大鹅、跳一跳、敲木鱼、视觉小说、Bark Battle、宝贝识物等卡片,公开作品身份、跨玩法去重、排序和推荐运行态 kind 判定曾放在 `PlatformEntryFlowShellImpl.tsx` 巨型实现里。
|
||||
- 决策:公开作品身份和排序规则统一收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`;入口壳层只调用该 Module 的 `getPlatformPublicGalleryEntryKey`、`getPlatformRecommendRuntimeKind`、`isSamePlatformPublicGalleryEntry` 和 `mergePlatformPublicGalleryEntries`。`edutainment` key 必须带 `templateId`,RPG 卡片回退为 `rpg`。
|
||||
- 影响范围:平台入口推荐流、公开作品详情、推荐 runtime 启动、跨玩法公开作品合并,以及后续新增玩法的入口接入。
|
||||
- 验证方式:`npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`、`npm run typecheck`、`npm run check:encoding`、相关文件 ESLint 通过。
|
||||
- 关联文档:`docs/technical/【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md`。
|
||||
|
||||
## 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、后续新增玩法作品架接入。
|
||||
- 验证方式:`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`。
|
||||
|
||||
## 2026-06-03 最近创作只复用创作模板入口
|
||||
|
||||
- 背景:底部加号创作入口的“最近创作”最初由真实作品架摘要驱动,但页面曾按作品标题、摘要和生成状态渲染独立最近创作卡,和其它模板页签的卡片样式及点击语义不一致。
|
||||
|
||||
@@ -39,6 +39,10 @@ AI 文字游戏模板接入以 [AI_NATIVE_TEXT_GAME_TEMPLATE_MOKU_REFERENCE_PRD_
|
||||
|
||||
前端 Server-Sent Events 客户端传输层收口到 `src/services/sseStream.ts`,事件边界、UTF-8 flush、JSON 解析跳过和提前取消约定见 [【前端架构】SSE客户端传输层收口约定-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91SSE%E5%AE%A2%E6%88%B7%E7%AB%AF%E4%BC%A0%E8%BE%93%E5%B1%82%E6%94%B6%E5%8F%A3%E7%BA%A6%E5%AE%9A-2026-06-03.md)。
|
||||
|
||||
平台入口公开作品身份、跨玩法去重、推荐运行态 kind 判定和最新排序收口到 `src/components/platform-entry/platformPublicGalleryFlow.ts`,规则见 [【前端架构】平台入口PublicGalleryFlowModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%B9%B3%E5%8F%B0%E5%85%A5%E5%8F%A3PublicGalleryFlowModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
创作中心作品架打开动作由 `CreationWorkShelfItem.actions.open` 统一承载,Hub 不再按玩法 `kind` 分发,规则见 [【前端架构】WorkShelfModule收口计划-2026-06-03.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91WorkShelfModule%E6%94%B6%E5%8F%A3%E8%AE%A1%E5%88%92-2026-06-03.md)。
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. 先看 [经验沉淀](./experience/README.md),快速建立这个项目的开发共识。
|
||||
|
||||
27
docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md
Normal file
27
docs/technical/【前端架构】WorkShelfModule收口计划-2026-06-03.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 【前端架构】Work Shelf Module 收口计划
|
||||
|
||||
## 背景
|
||||
|
||||
创作中心作品架需要同时展示 RPG、拼图、抓大鹅、方洞、跳一跳、敲木鱼、视觉小说、Bark Battle 和宝贝识物等作品。`creationWorkShelf.ts` 已经统一了卡片标题、摘要、封面、发布码、分享路径、指标、生成态和动作 Adapter,但 `CustomWorldCreationHub.tsx` 仍在点击作品卡时按玩法 `kind` 再写一遍打开逻辑,导致调用方仍须理解每种玩法。
|
||||
|
||||
## 决策
|
||||
|
||||
`CreationWorkShelfItem.actions.open` 是打开作品的正式 **Interface**。`CustomWorldCreationHub.tsx` 只负责卡片点击与 `onOpenShelfItem` 通知,然后调用 `item.actions.open()`,不再根据 `item.source.kind` 分发玩法。
|
||||
|
||||
此决策让 `creationWorkShelf.ts` 的 **Module** 更 deep:
|
||||
|
||||
- **Implementation**:玩法差异、草稿 / 已发布分支、profileId 进入方式和回调绑定都留在 Work Shelf Adapter 内。
|
||||
- **Interface**:Hub 只需要 `CreationWorkShelfItem`,不需要知道每种玩法的打开规则。
|
||||
- **Leverage**:新增玩法时只补 shelf item 映射与 Adapter,Hub 不再新增 switch 分支。
|
||||
- **Locality**:作品架点击行为的错误集中在 `creationWorkShelf.ts` 与其测试里定位。
|
||||
|
||||
## 后续深化
|
||||
|
||||
下一步可把 `buildCreationWorkShelfItems` 当前的长参数列表继续收口为 per-kind Source Adapter registry。届时 Hub / 平台壳传入玩法数据源和回调时,可逐步减少按玩法平铺的参数数量。删除、刷新和直达恢复也可沿同一 seam 收口。
|
||||
|
||||
## 验证
|
||||
|
||||
- `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
|
||||
@@ -0,0 +1,35 @@
|
||||
# 【前端架构】平台入口 Public Gallery Flow Module 收口计划
|
||||
|
||||
## 背景
|
||||
|
||||
`PlatformEntryFlowShellImpl.tsx` 同时承载平台入口、推荐流、公开作品详情、运行态启动和作品架刷新。公开作品列表中的身份识别、跨玩法去重、时间排序和推荐运行态类型判定原本散落在入口巨型实现中,后续每新增一种玩法都需要在巨型文件内追加判断,影响前端架构的复用、统一和扩展。
|
||||
|
||||
## 决策
|
||||
|
||||
新增 `src/components/platform-entry/platformPublicGalleryFlow.ts`,作为平台入口公开作品流的 **Module**。该 Module 的 **Interface** 固定收口为:
|
||||
|
||||
- `getPlatformPublicGalleryEntryKey(entry)`:按玩法类型、作者和 `profileId` 生成公开作品身份。
|
||||
- `getPlatformRecommendRuntimeKind(entry)`:把公开作品卡映射为推荐运行态 kind。
|
||||
- `isSamePlatformPublicGalleryEntry(left, right)`:按公开作品身份比较。
|
||||
- `mergePlatformPublicGalleryEntries(rpgEntries, puzzleEntries)`:统一完成 RPG 与各玩法公开作品去重、覆盖和倒序排序。
|
||||
|
||||
入口壳层只调用这些函数,不再在 `PlatformEntryFlowShellImpl.tsx` 内手写公开作品身份和排序规则。`isRecommendRuntimeReadyForEntry` 暂留入口壳层,因为它依赖各运行态 run 状态,直接抽出会把更多 runtime 状态类型拖入这个 Module,降低本次改造的 locality。
|
||||
|
||||
## 玩法身份规则
|
||||
|
||||
- `big-fish`、`puzzle`、`jump-hop`、`wooden-fish`、`match3d`、`square-hole`、`visual-novel`、`bark-battle` 使用自身 `sourceType` 作为 key kind。
|
||||
- `edutainment` 使用 `edutainment:${templateId}` 作为 key kind,避免后续幼教类模板共用 `sourceType` 时互相覆盖。
|
||||
- 没有 `sourceType` 的 RPG 公开作品回退为 `rpg`。
|
||||
- 最终 key 格式为 `${kind}:${ownerUserId}:${profileId}`。
|
||||
- 合并时后进入的相同 key 会覆盖先进入的卡片,然后按 `publishedAt ?? updatedAt` 新到旧排序;非法时间按 `0` 处理。
|
||||
|
||||
## 后续深化
|
||||
|
||||
下一步可继续把平台入口的作品架刷新、删除确认和直达恢复逻辑收口成更深的 Work Shelf **Module**。当前 `platformPublicGalleryFlow` 先提供一个稳定 seam,使公开作品 identity 与 runtime kind 的修改集中在一处。
|
||||
|
||||
## 验证
|
||||
|
||||
- `npm run test -- src/components/platform-entry/platformPublicGalleryFlow.test.ts`
|
||||
- `npm run typecheck`
|
||||
- `npm run check:encoding`
|
||||
- 针对变更文件执行 ESLint
|
||||
@@ -7,10 +7,10 @@ import type { BabyObjectMatchDraft } from '../../../packages/shared/src/contract
|
||||
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 { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
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 { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import type {
|
||||
@@ -20,10 +20,10 @@ import type {
|
||||
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
|
||||
import {
|
||||
buildCreationWorkShelfItems,
|
||||
getCreationWorkShelfItemTime,
|
||||
type CreationWorkShelfItem,
|
||||
type CreationWorkShelfMetricId,
|
||||
type CreationWorkShelfRuntimeState,
|
||||
getCreationWorkShelfItemTime,
|
||||
} from './creationWorkShelf';
|
||||
import {
|
||||
CustomWorldCreationStartCard,
|
||||
@@ -274,6 +274,7 @@ export function CustomWorldCreationHub({
|
||||
barkBattleItems,
|
||||
items,
|
||||
match3dItems,
|
||||
squareHoleItems,
|
||||
onDeleteBigFish,
|
||||
onDeleteMatch3D,
|
||||
onDeleteSquareHole,
|
||||
@@ -341,44 +342,8 @@ export function CustomWorldCreationHub({
|
||||
|
||||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||||
onOpenShelfItem?.(item);
|
||||
switch (item.source.kind) {
|
||||
case 'puzzle':
|
||||
onOpenPuzzleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'baby-object-match':
|
||||
onOpenBabyObjectMatchDetail?.(item.source.item);
|
||||
return;
|
||||
case 'visual-novel':
|
||||
onOpenVisualNovelDetail?.(item.source.item);
|
||||
return;
|
||||
case 'bark-battle':
|
||||
onOpenBarkBattleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'big-fish':
|
||||
onOpenBigFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'match3d':
|
||||
onOpenMatch3DDetail?.(item.source.item);
|
||||
return;
|
||||
case 'square-hole':
|
||||
onOpenSquareHoleDetail?.(item.source.item);
|
||||
return;
|
||||
case 'jump-hop':
|
||||
onOpenJumpHopDetail?.(item.source.item);
|
||||
return;
|
||||
case 'wooden-fish':
|
||||
onOpenWoodenFishDetail?.(item.source.item);
|
||||
return;
|
||||
case 'rpg':
|
||||
if (item.status === 'draft') {
|
||||
onOpenDraft(item.source.item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.source.item.profileId) {
|
||||
onEnterPublished(item.source.item.profileId);
|
||||
}
|
||||
}
|
||||
// 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。
|
||||
item.actions.open();
|
||||
}
|
||||
|
||||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||||
|
||||
@@ -108,6 +108,7 @@ import type {
|
||||
VisualNovelWorkDetail,
|
||||
VisualNovelWorkSummary,
|
||||
} from '../../../packages/shared/src/contracts/visualNovel';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import { buildCustomWorldPlayableCharacters } from '../../data/characterPresets';
|
||||
import {
|
||||
buildPublicWorkStagePath,
|
||||
@@ -216,17 +217,12 @@ import {
|
||||
buildSquareHoleGenerationAnchorEntries,
|
||||
buildWoodenFishGenerationAnchorEntries,
|
||||
createMiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
type MiniGameDraftGenerationKind,
|
||||
type MiniGameDraftGenerationPhase,
|
||||
type MiniGameDraftGenerationState,
|
||||
resolveMiniGameDraftGenerationStartedAtMs,
|
||||
} from '../../services/miniGameDraftGenerationProgress';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
} from '../unified-creation/unifiedCreationSpecs';
|
||||
import {
|
||||
buildBabyObjectMatchPublicWorkCode,
|
||||
buildBarkBattlePublicWorkCode,
|
||||
@@ -350,7 +346,6 @@ import {
|
||||
type WoodenFishWorkProfileResponse,
|
||||
type WoodenFishWorkspaceCreateRequest,
|
||||
} from '../../services/wooden-fish/woodenFishClient';
|
||||
import type { WoodenFishWorkSummaryResponse } from '../../../packages/shared/src/contracts/woodenFish';
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PublishShareModal } from '../common/PublishShareModal';
|
||||
@@ -390,6 +385,11 @@ import { useRpgCreationAgentOperationPolling } from '../rpg-entry/useRpgCreation
|
||||
import { useRpgCreationEnterWorld } from '../rpg-entry/useRpgCreationEnterWorld';
|
||||
import { useRpgCreationResultAutosave } from '../rpg-entry/useRpgCreationResultAutosave';
|
||||
import { useRpgCreationSessionController } from '../rpg-entry/useRpgCreationSessionController';
|
||||
import { UnifiedCreationPage } from '../unified-creation/UnifiedCreationPage';
|
||||
import {
|
||||
getUnifiedCreationSpec,
|
||||
type UnifiedCreationPlayId,
|
||||
} from '../unified-creation/unifiedCreationSpecs';
|
||||
import {
|
||||
buildVisualNovelEntryGenerationAnchorEntries,
|
||||
buildVisualNovelEntryGenerationProgress,
|
||||
@@ -438,11 +438,18 @@ import {
|
||||
PlatformErrorDialog,
|
||||
type PlatformErrorDialogPayload,
|
||||
} from './PlatformErrorDialog';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import {
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
mergePlatformPublicGalleryEntries,
|
||||
type RecommendRuntimeKind,
|
||||
} from './platformPublicGalleryFlow';
|
||||
import {
|
||||
PlatformTaskCompletionDialog,
|
||||
type PlatformTaskCompletionDialogPayload,
|
||||
} from './PlatformTaskCompletionDialog';
|
||||
import { PlatformFeedbackView } from './PlatformFeedbackView';
|
||||
import { PlatformWorkDetailView } from './PlatformWorkDetailView';
|
||||
import { usePlatformCreationAgentFlowController } from './usePlatformCreationAgentFlowController';
|
||||
import { usePlatformEntryBootstrap } from './usePlatformEntryBootstrap';
|
||||
@@ -511,17 +518,6 @@ type BarkBattleRuntimeReturnStage =
|
||||
| 'work-detail'
|
||||
| 'platform';
|
||||
type BigFishRuntimeSessionSource = 'draft' | 'work' | null;
|
||||
type RecommendRuntimeKind =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'edutainment'
|
||||
| 'jump-hop'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole'
|
||||
| 'wooden-fish'
|
||||
| 'visual-novel'
|
||||
| 'rpg';
|
||||
type SquareHoleRuntimeReturnStage =
|
||||
| 'square-hole-result'
|
||||
| 'work-detail'
|
||||
@@ -625,77 +621,6 @@ const PUZZLE_DRAFT_GENERATION_POINT_COST = 2;
|
||||
const MATCH3D_DRAFT_GENERATION_POINT_COST = 10;
|
||||
const BARK_BATTLE_DRAFT_GENERATION_POINT_COST = 3;
|
||||
|
||||
function getPlatformPublicGalleryEntryTime(entry: PlatformPublicGalleryCard) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
function getPlatformPublicGalleryEntryKey(entry: PlatformPublicGalleryCard) {
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
function getPlatformRecommendRuntimeKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): RecommendRuntimeKind {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
function isRecommendRuntimeReadyForEntry(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
state: RecommendRuntimeState,
|
||||
@@ -739,33 +664,6 @@ function isRecommendRuntimeReadyForEntry(
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: CustomWorldGalleryCard[],
|
||||
puzzleEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
|
||||
function mapRpgGalleryCardToPublicWorkDetail(
|
||||
entry: CustomWorldGalleryCard,
|
||||
): PlatformPublicGalleryCard {
|
||||
|
||||
204
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
204
src/components/platform-entry/platformPublicGalleryFlow.test.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
import {
|
||||
getPlatformPublicGalleryEntryKey,
|
||||
getPlatformPublicGalleryEntryTime,
|
||||
getPlatformRecommendRuntimeKind,
|
||||
isSamePlatformPublicGalleryEntry,
|
||||
mergePlatformPublicGalleryEntries,
|
||||
type RecommendRuntimeKind,
|
||||
} from './platformPublicGalleryFlow';
|
||||
|
||||
type TypedPlatformPublicGalleryCard = Extract<
|
||||
PlatformPublicGalleryCard,
|
||||
{ sourceType: string }
|
||||
>;
|
||||
type PlatformGallerySourceType = TypedPlatformPublicGalleryCard['sourceType'];
|
||||
type TypedPlatformPublicGalleryCardOverrides = Partial<
|
||||
Omit<TypedPlatformPublicGalleryCard, 'sourceType'>
|
||||
>;
|
||||
|
||||
function buildRpgEntry(
|
||||
overrides: Partial<CustomWorldGalleryCard> = {},
|
||||
): CustomWorldGalleryCard {
|
||||
return {
|
||||
ownerUserId: 'user-1',
|
||||
profileId: 'rpg-profile',
|
||||
publicWorkCode: 'CW-RPG',
|
||||
authorPublicUserCode: null,
|
||||
visibility: 'published',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: 'RPG 世界',
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildTypedEntry(
|
||||
sourceType: PlatformGallerySourceType,
|
||||
overrides: TypedPlatformPublicGalleryCardOverrides = {},
|
||||
): PlatformPublicGalleryCard {
|
||||
const common = {
|
||||
workId: `${sourceType}-work`,
|
||||
profileId: `${sourceType}-profile`,
|
||||
publicWorkCode: `${sourceType}-code`,
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '玩家',
|
||||
worldName: `${sourceType} 作品`,
|
||||
subtitle: '公开作品',
|
||||
summaryText: '公开作品摘要',
|
||||
coverImageSrc: null,
|
||||
themeTags: [sourceType],
|
||||
visibility: 'published' as const,
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
updatedAt: '2026-06-01T01:00:00.000Z',
|
||||
};
|
||||
|
||||
switch (sourceType) {
|
||||
case 'puzzle':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'big-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'match3d':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'square-hole':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'visual-novel':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'jump-hop':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'wooden-fish':
|
||||
return { ...common, ...overrides, sourceType };
|
||||
case 'edutainment':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
templateId: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID,
|
||||
templateName: EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_NAME,
|
||||
};
|
||||
case 'bark-battle':
|
||||
return {
|
||||
...common,
|
||||
...overrides,
|
||||
sourceType,
|
||||
authorPublicUserCode: null,
|
||||
coverRenderMode: 'image',
|
||||
coverCharacterImageSrcs: [],
|
||||
themeMode: 'martial',
|
||||
playableNpcCount: 1,
|
||||
landmarkCount: 1,
|
||||
};
|
||||
default: {
|
||||
const exhaustive: never = sourceType;
|
||||
return exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('platform public gallery flow resolves stable key and runtime kind for every play kind', () => {
|
||||
const cases: Array<
|
||||
[sourceType: PlatformGallerySourceType, keyKind: string, kind: RecommendRuntimeKind]
|
||||
> = [
|
||||
['big-fish', 'big-fish', 'big-fish'],
|
||||
['puzzle', 'puzzle', 'puzzle'],
|
||||
['jump-hop', 'jump-hop', 'jump-hop'],
|
||||
['wooden-fish', 'wooden-fish', 'wooden-fish'],
|
||||
['match3d', 'match3d', 'match3d'],
|
||||
['square-hole', 'square-hole', 'square-hole'],
|
||||
['visual-novel', 'visual-novel', 'visual-novel'],
|
||||
['bark-battle', 'bark-battle', 'bark-battle'],
|
||||
[
|
||||
'edutainment',
|
||||
`edutainment:${EDUTAINMENT_BABY_OBJECT_MATCH_TEMPLATE_ID}`,
|
||||
'edutainment',
|
||||
],
|
||||
];
|
||||
|
||||
cases.forEach(([sourceType, keyKind, kind]) => {
|
||||
const entry = buildTypedEntry(sourceType);
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(entry)).toBe(
|
||||
`${keyKind}:user-1:${sourceType}-profile`,
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(entry)).toBe(kind);
|
||||
});
|
||||
|
||||
const rpgEntry = buildRpgEntry();
|
||||
|
||||
expect(getPlatformPublicGalleryEntryKey(rpgEntry)).toBe(
|
||||
'rpg:user-1:rpg-profile',
|
||||
);
|
||||
expect(getPlatformRecommendRuntimeKind(rpgEntry)).toBe('rpg');
|
||||
});
|
||||
|
||||
test('platform public gallery flow compares entries by resolved identity', () => {
|
||||
const left = buildTypedEntry('puzzle');
|
||||
const sameIdentity = buildTypedEntry('puzzle', {
|
||||
workId: 'other-work',
|
||||
worldName: '新标题',
|
||||
});
|
||||
const otherKind = buildTypedEntry('match3d', {
|
||||
ownerUserId: left.ownerUserId,
|
||||
profileId: left.profileId,
|
||||
});
|
||||
|
||||
expect(isSamePlatformPublicGalleryEntry(left, sameIdentity)).toBe(true);
|
||||
expect(isSamePlatformPublicGalleryEntry(left, otherKind)).toBe(false);
|
||||
});
|
||||
|
||||
test('platform public gallery flow merges duplicate identities and sorts newest first', () => {
|
||||
const staleRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '旧版 RPG',
|
||||
publishedAt: '2026-06-01T00:00:00.000Z',
|
||||
});
|
||||
const freshRpgEntry = buildRpgEntry({
|
||||
profileId: 'shared-rpg',
|
||||
worldName: '新版 RPG',
|
||||
publishedAt: '2026-06-04T00:00:00.000Z',
|
||||
});
|
||||
const middleRpgEntry = buildRpgEntry({
|
||||
profileId: 'middle-rpg',
|
||||
worldName: '中间 RPG',
|
||||
publishedAt: '2026-06-02T00:00:00.000Z',
|
||||
});
|
||||
const updatedOnlyEntry = buildTypedEntry('big-fish', {
|
||||
profileId: 'updated-only',
|
||||
publishedAt: null,
|
||||
updatedAt: '2026-06-03T00:00:00.000Z',
|
||||
});
|
||||
const invalidTimeEntry = buildTypedEntry('puzzle', {
|
||||
profileId: 'invalid-time',
|
||||
publishedAt: 'not-a-date',
|
||||
updatedAt: 'still-not-a-date',
|
||||
});
|
||||
|
||||
const merged = mergePlatformPublicGalleryEntries(
|
||||
[staleRpgEntry, middleRpgEntry],
|
||||
[invalidTimeEntry, updatedOnlyEntry, freshRpgEntry],
|
||||
);
|
||||
|
||||
expect(merged).toHaveLength(4);
|
||||
expect(merged.map((entry) => entry.profileId)).toEqual([
|
||||
'shared-rpg',
|
||||
'updated-only',
|
||||
'middle-rpg',
|
||||
'invalid-time',
|
||||
]);
|
||||
expect(merged[0]?.worldName).toBe('新版 RPG');
|
||||
expect(getPlatformPublicGalleryEntryTime(invalidTimeEntry)).toBe(0);
|
||||
});
|
||||
128
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
128
src/components/platform-entry/platformPublicGalleryFlow.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import type { CustomWorldGalleryCard } from '../../../packages/shared/src/contracts/runtime';
|
||||
import {
|
||||
isBarkBattleGalleryEntry,
|
||||
isBigFishGalleryEntry,
|
||||
isEdutainmentGalleryEntry,
|
||||
isJumpHopGalleryEntry,
|
||||
isMatch3DGalleryEntry,
|
||||
isPuzzleGalleryEntry,
|
||||
isSquareHoleGalleryEntry,
|
||||
isVisualNovelGalleryEntry,
|
||||
isWoodenFishGalleryEntry,
|
||||
type PlatformPublicGalleryCard,
|
||||
} from '../rpg-entry/rpgEntryWorldPresentation';
|
||||
|
||||
export type RecommendRuntimeKind =
|
||||
| 'bark-battle'
|
||||
| 'big-fish'
|
||||
| 'edutainment'
|
||||
| 'jump-hop'
|
||||
| 'match3d'
|
||||
| 'puzzle'
|
||||
| 'square-hole'
|
||||
| 'wooden-fish'
|
||||
| 'visual-novel'
|
||||
| 'rpg';
|
||||
|
||||
export function getPlatformPublicGalleryEntryTime(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
const rawTime = entry.publishedAt ?? entry.updatedAt;
|
||||
const timestamp = new Date(rawTime).getTime();
|
||||
return Number.isNaN(timestamp) ? 0 : timestamp;
|
||||
}
|
||||
|
||||
export function getPlatformPublicGalleryEntryKey(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
) {
|
||||
// 同一作品身份由玩法、作者与 profile 共同确定,避免不同玩法共享 profileId 时误合并。
|
||||
const kind = isBigFishGalleryEntry(entry)
|
||||
? 'big-fish'
|
||||
: isPuzzleGalleryEntry(entry)
|
||||
? 'puzzle'
|
||||
: isJumpHopGalleryEntry(entry)
|
||||
? 'jump-hop'
|
||||
: isWoodenFishGalleryEntry(entry)
|
||||
? 'wooden-fish'
|
||||
: isMatch3DGalleryEntry(entry)
|
||||
? 'match3d'
|
||||
: isSquareHoleGalleryEntry(entry)
|
||||
? 'square-hole'
|
||||
: isVisualNovelGalleryEntry(entry)
|
||||
? 'visual-novel'
|
||||
: isBarkBattleGalleryEntry(entry)
|
||||
? 'bark-battle'
|
||||
: isEdutainmentGalleryEntry(entry)
|
||||
? `edutainment:${entry.templateId}`
|
||||
: 'rpg';
|
||||
return `${kind}:${entry.ownerUserId}:${entry.profileId}`;
|
||||
}
|
||||
|
||||
export function getPlatformRecommendRuntimeKind(
|
||||
entry: PlatformPublicGalleryCard,
|
||||
): RecommendRuntimeKind {
|
||||
if (isBigFishGalleryEntry(entry)) {
|
||||
return 'big-fish';
|
||||
}
|
||||
|
||||
if (isPuzzleGalleryEntry(entry)) {
|
||||
return 'puzzle';
|
||||
}
|
||||
|
||||
if (isJumpHopGalleryEntry(entry)) {
|
||||
return 'jump-hop';
|
||||
}
|
||||
|
||||
if (isWoodenFishGalleryEntry(entry)) {
|
||||
return 'wooden-fish';
|
||||
}
|
||||
|
||||
if (isMatch3DGalleryEntry(entry)) {
|
||||
return 'match3d';
|
||||
}
|
||||
|
||||
if (isSquareHoleGalleryEntry(entry)) {
|
||||
return 'square-hole';
|
||||
}
|
||||
|
||||
if (isVisualNovelGalleryEntry(entry)) {
|
||||
return 'visual-novel';
|
||||
}
|
||||
|
||||
if (isBarkBattleGalleryEntry(entry)) {
|
||||
return 'bark-battle';
|
||||
}
|
||||
|
||||
if (isEdutainmentGalleryEntry(entry)) {
|
||||
return 'edutainment';
|
||||
}
|
||||
|
||||
return 'rpg';
|
||||
}
|
||||
|
||||
export function isSamePlatformPublicGalleryEntry(
|
||||
left: PlatformPublicGalleryCard,
|
||||
right: PlatformPublicGalleryCard,
|
||||
) {
|
||||
return (
|
||||
getPlatformPublicGalleryEntryKey(left) ===
|
||||
getPlatformPublicGalleryEntryKey(right)
|
||||
);
|
||||
}
|
||||
|
||||
export function mergePlatformPublicGalleryEntries(
|
||||
rpgEntries: CustomWorldGalleryCard[],
|
||||
puzzleEntries: PlatformPublicGalleryCard[],
|
||||
) {
|
||||
const entryMap = new Map<string, PlatformPublicGalleryCard>();
|
||||
|
||||
[...rpgEntries, ...puzzleEntries].forEach((entry) => {
|
||||
entryMap.set(getPlatformPublicGalleryEntryKey(entry), entry);
|
||||
});
|
||||
|
||||
return Array.from(entryMap.values()).sort(
|
||||
(left, right) =>
|
||||
getPlatformPublicGalleryEntryTime(right) -
|
||||
getPlatformPublicGalleryEntryTime(left),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user