Merge remote-tracking branch 'origin/master' into hermes/hermes-1e775b03
Some checks failed
CI / verify (pull_request) Has been cancelled

# Conflicts:
#	docs/technical/README.md
#	src/components/custom-world-home/CustomWorldCreationHub.tsx
#	src/components/custom-world-home/creationWorkShelf.ts
#	src/components/platform-entry/PlatformEntryFlowShellImpl.tsx
#	src/components/rpg-entry/RpgEntryFlowShell.agent.interaction.test.tsx
This commit is contained in:
2026-05-12 15:02:47 +08:00
141 changed files with 13407 additions and 2277 deletions

View File

@@ -5,6 +5,7 @@ import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
@@ -51,7 +52,7 @@ const testEntryConfig = {
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: true,
visible: false,
open: true,
sortOrder: 50,
updatedAtMicros: 1,
@@ -164,6 +165,30 @@ const baseDraftItem: CustomWorldWorkSummary = {
canEnterWorld: false,
};
const hiddenSquareHoleItem: SquareHoleWorkSummary = {
workId: 'square-hole:work-hidden',
profileId: 'square-hole-profile-hidden',
ownerUserId: 'user-1',
gameName: '隐藏方洞挑战',
themeText: '方洞',
twistRule: '隐藏入口',
summary: '入口隐藏后,这条作品不应出现在创作页作品架。',
tags: ['方洞'],
coverImageSrc: null,
backgroundPrompt: '',
backgroundImageSrc: null,
shapeOptions: [],
holeOptions: [],
shapeCount: 0,
difficulty: 1,
publicationStatus: 'draft',
playCount: 0,
updatedAt: new Date('2026-05-10T10:00:00.000Z').toISOString(),
publishedAt: null,
publishReady: false,
sourceSessionId: 'square-hole-session-hidden',
};
test('creation hub reflects updated draft title summary and counts after rerender', async () => {
const user = userEvent.setup();
const onCreateType = vi.fn();
@@ -185,19 +210,20 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.queryByText('角色 3')).toBeNull();
expect(screen.queryByText('地点 4')).toBeNull();
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', {
name: /.*/u,
const puzzleButton = screen.getByRole('button', {
name: /.*/u,
});
const match3dButton = screen.getByRole('button', {
name: /.*3D /u,
});
const squareHoleButton = screen.getByRole('button', { name: //u });
expect((squareHoleButton as HTMLButtonElement).disabled).toBe(false);
expect(puzzleButton).toBeTruthy();
expect(match3dButton).toBeTruthy();
expect((puzzleButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(screen.getByText('反直觉形状分拣')).toBeTruthy();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByText('反直觉形状分拣')).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
expect(screen.queryByRole('button', { name: //u })).toBeNull();
await user.click(match3dButton);
@@ -234,6 +260,29 @@ test('creation hub reflects updated draft title summary and counts after rerende
expect(screen.queryByText('地点 6')).toBeNull();
});
test('creation hub hides square hole works when the creation type is hidden', () => {
const onOpenSquareHoleDetail = vi.fn();
render(
<CustomWorldCreationHub
items={[]}
squareHoleItems={[hiddenSquareHoleItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenSquareHoleDetail={onOpenSquareHoleDetail}
/>,
);
expect(screen.queryByText('隐藏方洞挑战')).toBeNull();
expect(screen.queryByText('入口隐藏后,这条作品不应出现在创作页作品架。')).toBeNull();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
render(
<CustomWorldCreationHub

View File

@@ -1,8 +1,8 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
import { derivePlatformCreationTypes } from '../platform-entry/platformEntryCreationTypes';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
@@ -47,7 +47,7 @@ const testEntryConfig = {
subtitle: '形状投放挑战',
badge: '可创建',
imageSrc: '/creation-type-references/square-hole.webp',
visible: true,
visible: false,
open: true,
sortOrder: 50,
updatedAtMicros: 1,
@@ -77,8 +77,9 @@ const testEntryConfig = {
],
} satisfies CreationEntryConfig;
const testCreationTypes = derivePlatformCreationTypes(testEntryConfig.creationTypes);
const testCreationTypes = derivePlatformCreationTypes(
testEntryConfig.creationTypes,
);
test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup(
@@ -120,10 +121,10 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).toContain('拼图关卡创作');
expect(html).toContain('抓大鹅');
expect(html).toContain('经典消除玩法');
expect(html).not.toContain('角色扮演');
expect(html).toContain('3D 消除关卡');
expect(html).not.toContain('文字冒险');
expect(html).not.toContain('大鱼吃小鱼');
});
@@ -174,6 +175,50 @@ test('creation hub renders puzzle works in the same unified list with puzzle tag
expect(html).not.toContain('我的拼图作品');
});
test('creation hub marks generating and newly completed drafts', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle-work-session-1',
profileId: 'puzzle-profile-session-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
workTitle: '潮雾拼图草稿',
workDescription: '正在生成首张拼图主视觉。',
levelName: '潮雾拼图',
summary: '正在生成首张拼图主视觉。',
themeTags: ['潮雾'],
coverImageSrc: null,
publicationStatus: 'draft',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: null,
publishReady: false,
sourceSessionId: 'puzzle-session-1',
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
entryConfig={testEntryConfig}
creationTypes={testCreationTypes}
onOpenPuzzleDetail={() => {}}
getWorkState={(item) =>
item.kind === 'puzzle'
? { isGenerating: true, hasUnreadUpdate: true }
: null
}
/>,
);
expect(html).toContain('生成中');
expect(html).toContain('aria-label="新生成完成"');
});
test('creation hub published work spans full mobile row', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub

View File

@@ -13,6 +13,7 @@ import type {
PlatformCreationTypeCard,
PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
import { isPlatformCreationTypeVisible } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
@@ -67,6 +68,10 @@ type CustomWorldCreationHubProps = {
visualNovelItems?: VisualNovelWorkSummary[];
onOpenVisualNovelDetail?: ((item: VisualNovelWorkSummary) => void) | null;
onDeleteVisualNovel?: ((item: VisualNovelWorkSummary) => void) | null;
getWorkState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
mode?: 'full' | 'start-only' | 'works-only';
};
@@ -165,10 +170,16 @@ export function CustomWorldCreationHub({
visualNovelItems = [],
onOpenVisualNovelDetail = null,
onDeleteVisualNovel = null,
getWorkState,
onOpenShelfItem,
mode = 'full',
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const isSquareHoleCreationVisible = isPlatformCreationTypeVisible(
creationTypes,
'square-hole',
);
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
@@ -176,13 +187,14 @@ export function CustomWorldCreationHub({
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems,
squareHoleItems: isSquareHoleCreationVisible ? squareHoleItems : [],
puzzleItems,
visualNovelItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeleteSquareHole:
isSquareHoleCreationVisible && Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
canDeleteVisualNovel: Boolean(onDeleteVisualNovel),
onOpenRpgDraft: onOpenDraft,
@@ -199,9 +211,11 @@ export function CustomWorldCreationHub({
onClaimPuzzlePointIncentive: onClaimPuzzlePointIncentive ?? undefined,
onOpenVisualNovelDetail: onOpenVisualNovelDetail ?? undefined,
onDeleteVisualNovel: onDeleteVisualNovel ?? undefined,
getItemState: getWorkState,
}),
[
bigFishItems,
isSquareHoleCreationVisible,
items,
match3dItems,
onDeleteBigFish,
@@ -218,6 +232,7 @@ export function CustomWorldCreationHub({
onOpenSquareHoleDetail,
onOpenVisualNovelDetail,
onEnterPublished,
getWorkState,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
@@ -244,6 +259,7 @@ export function CustomWorldCreationHub({
[activeFilter, shelfItems],
);
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
@@ -322,7 +338,10 @@ export function CustomWorldCreationHub({
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={item.actions.open}
onOpen={() => {
onOpenShelfItem?.(item);
item.actions.open();
}}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}

View File

@@ -267,6 +267,12 @@ export function CustomWorldWorkCard({
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
{item.hasUnreadUpdate ? (
<span
aria-label="新生成完成"
className="pointer-events-none absolute right-2 top-2 z-30 h-2.5 w-2.5 rounded-full bg-red-500 shadow-[0_0_0_3px_rgba(255,255,255,0.26),0_0_14px_rgba(239,68,68,0.75)]"
/>
) : null}
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
{onDelete ? (
@@ -335,6 +341,11 @@ export function CustomWorldWorkCard({
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
{item.isGenerating ? (
<span className="platform-pill platform-pill--cool max-w-full truncate px-2 py-0.5 text-[9px] sm:px-3 sm:py-1 sm:text-[10px]">
</span>
) : null}
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}

View File

@@ -89,6 +89,8 @@ export type CreationWorkShelfItem = {
id: string;
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
isGenerating?: boolean;
hasUnreadUpdate?: boolean;
title: string;
summary: string;
updatedAt: string;
@@ -135,6 +137,9 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive?: (item: PuzzleWorkSummary) => void;
onOpenVisualNovelDetail?: (item: VisualNovelWorkSummary) => void;
onDeleteVisualNovel?: (item: VisualNovelWorkSummary) => void;
getItemState?: (
item: CreationWorkShelfItem,
) => { isGenerating?: boolean; hasUnreadUpdate?: boolean } | null;
}) {
const {
rpgItems,
@@ -164,6 +169,7 @@ export function buildCreationWorkShelfItems(params: {
onClaimPuzzlePointIncentive,
onOpenVisualNovelDetail,
onDeleteVisualNovel,
getItemState,
} = params;
return [
@@ -205,10 +211,21 @@ export function buildCreationWorkShelfItems(params: {
onDelete: onDeleteVisualNovel,
}),
),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
);
]
.map((item) => {
const state = getItemState?.(item);
return state
? {
...item,
isGenerating: state.isGenerating,
hasUnreadUpdate: state.hasUnreadUpdate,
}
: item;
})
.sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
);
}
type RpgWorkShelfAdapter = {
@@ -434,14 +451,14 @@ function mapVisualNovelWorkToShelfItem(
canDelete: boolean,
adapter: WorkShelfAdapter<VisualNovelWorkSummary>,
): CreationWorkShelfItem {
const status =
item.publishStatus === 'published' ? 'published' : 'draft';
const status = item.publishStatus === 'published' ? 'published' : 'draft';
const publicWorkCode =
status === 'published' ? buildVisualNovelPublicWorkCode(item.profileId) : null;
status === 'published'
? buildVisualNovelPublicWorkCode(item.profileId)
: null;
const title = item.title?.trim() || '未命名视觉小说';
const summary =
item.description?.trim() ||
(status === 'draft' ? '未填写作品描述' : '');
item.description?.trim() || (status === 'draft' ? '未填写作品描述' : '');
return {
id: item.profileId,