init with react+axum+spacetimedb
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-26 18:06:23 +08:00
commit cbc27bad4a
20199 changed files with 883714 additions and 0 deletions

View File

@@ -0,0 +1,272 @@
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, expect, test, vi } from 'vitest';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
const originalClipboard = navigator.clipboard;
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: originalClipboard,
});
});
const baseDraftItem: CustomWorldWorkSummary = {
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '潮雾列岛',
subtitle: '补齐关键锚点',
summary: '玩家是失职返乡的守灯人。',
coverImageSrc: null,
updatedAt: new Date('2026-04-14T10:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'object_refining',
stageLabel: '待完善草稿',
playableNpcCount: 3,
landmarkCount: 4,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
};
test('creation hub reflects updated draft title summary and counts after rerender', () => {
const { rerender } = render(
<CustomWorldCreationHub
items={[baseDraftItem]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('玩家是失职返乡的守灯人。')).toBeTruthy();
expect(screen.getByText('角色 3')).toBeTruthy();
expect(screen.getByText('地点 4')).toBeTruthy();
expect(screen.getByRole('button', { name: / RPG/u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
rerender(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
title: '潮雾列岛·回潮版',
summary: '世界总卡和角色网已经继续长出了新的支线。',
playableNpcCount: 5,
landmarkCount: 6,
updatedAt: new Date('2026-04-14T10:10:00.000Z').toISOString(),
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛·回潮版')).toBeTruthy();
expect(
screen.getByText('世界总卡和角色网已经继续长出了新的支线。'),
).toBeTruthy();
expect(screen.getByText('角色 5')).toBeTruthy();
expect(screen.getByText('地点 6')).toBeTruthy();
});
test('creation hub mixes puzzle works into the same grid and uses puzzle tag to distinguish', () => {
render(
<CustomWorldCreationHub
items={[baseDraftItem]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '拼图作品会与其他创作作品一起展示。',
themeTags: ['潮雾', '沉钟'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getByText('PZ-PROFILE1')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull();
});
test('creation hub shows RPG public work code from published library entry', () => {
render(
<CustomWorldCreationHub
items={[
{
...baseDraftItem,
workId: 'published:world-public-1',
sourceType: 'published_profile',
status: 'published',
title: '潮雾列岛已发布版',
profileId: 'world-public-1',
canResume: false,
canEnterWorld: true,
},
]}
rpgLibraryEntries={[
{
ownerUserId: 'user-1',
profileId: 'world-public-1',
publicWorkCode: 'CW-00000001',
authorPublicUserCode: 'SY-00000001',
profile: {} as never,
visibility: 'published',
publishedAt: '2026-04-20T10:00:00.000Z',
updatedAt: '2026-04-20T10:00:00.000Z',
authorDisplayName: '测试玩家',
worldName: '潮雾列岛已发布版',
subtitle: '旧灯塔与失控航路',
summaryText: '已经发布的群岛世界作品。',
coverImageSrc: null,
themeMode: 'tide',
playableNpcCount: 3,
landmarkCount: 4,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛已发布版')).toBeTruthy();
expect(screen.getByText('CW-00000001')).toBeTruthy();
});
test('creation hub shows delete action for persisted rpg drafts', () => {
render(
<CustomWorldCreationHub
items={[{ ...baseDraftItem, profileId: 'profile-1' }]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onDeletePublished={() => {}}
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub opens persisted rpg drafts by card click', async () => {
const user = userEvent.setup();
const openedItems: CustomWorldWorkSummary[] = [];
const persistedDraft = {
...baseDraftItem,
workId: 'draft:profile-1',
sourceType: 'published_profile' as const,
sessionId: null,
profileId: 'profile-1',
title: '可继续整理的草稿',
};
render(
<CustomWorldCreationHub
items={[persistedDraft]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={(item) => {
openedItems.push(item);
}}
onEnterPublished={() => {}}
/>,
);
await user.click(
screen.getByRole('button', { name: /稿/u }),
);
expect(openedItems).toEqual([persistedDraft]);
});
test('creation hub work code copy button copies without opening the card', async () => {
const user = userEvent.setup();
const writeText = vi.fn(async () => undefined);
const onOpenPuzzleDetail = vi.fn();
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText },
});
render(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '拼图作者',
levelName: '沉钟拼图',
summary: '拼图作品会与其他创作作品一起展示。',
themeTags: ['潮雾', '沉钟'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T12:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T12:10:00.000Z').toISOString(),
playCount: 8,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={onOpenPuzzleDetail}
/>,
);
await user.click(
screen.getByRole('button', { name: '复制作品号 PZ-PROFILE1' }),
);
expect(writeText).toHaveBeenCalledWith('PZ-PROFILE1');
expect(onOpenPuzzleDetail).not.toHaveBeenCalled();
expect(await screen.findByText('已复制')).toBeTruthy();
});

View File

@@ -0,0 +1,86 @@
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, test } from 'vitest';
import { CustomWorldCreationHub } from './CustomWorldCreationHub';
const noopCreateType = () => {};
test('creation hub draft card renders compiled work summary fields', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[
{
workId: 'draft:session-1',
sourceType: 'agent_session',
status: 'draft',
title: '一个被潮雾切开的列岛世界',
subtitle: '补齐关键锚点',
summary:
'玩家是失职返乡的守灯人 · 核心冲突:守灯会与沉船商盟争夺航道解释权',
coverImageSrc: null,
updatedAt: new Date('2026-04-13T12:00:00.000Z').toISOString(),
publishedAt: null,
stage: 'clarifying',
stageLabel: '补齐关键锚点',
playableNpcCount: 0,
landmarkCount: 0,
sessionId: 'session-1',
profileId: null,
canResume: true,
canEnterWorld: false,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
/>,
);
expect(html).toContain('一个被潮雾切开的列岛世界');
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演 RPG');
expect(html).toContain('大鱼吃小鱼');
expect(html).toContain('拼图玩法');
});
test('creation hub renders puzzle works in the same unified list with puzzle tag', () => {
const html = renderToStaticMarkup(
<CustomWorldCreationHub
items={[]}
puzzleItems={[
{
workId: 'puzzle:work-1',
profileId: 'puzzle-profile-1',
ownerUserId: 'user-1',
authorDisplayName: '测试作者',
levelName: '潮雾拼图',
summary: '一张被切成拼图的潮雾港口主视觉。',
themeTags: ['潮雾', '港口'],
coverImageSrc: null,
publicationStatus: 'published',
updatedAt: new Date('2026-04-22T10:00:00.000Z').toISOString(),
publishedAt: new Date('2026-04-22T10:05:00.000Z').toISOString(),
playCount: 12,
publishReady: true,
},
]}
loading={false}
error={null}
onRetry={() => {}}
onCreateType={noopCreateType}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).toContain('作品号');
expect(html).toContain('PZ-PROFILE1');
expect(html).not.toContain('我的拼图作品');
});

View File

@@ -0,0 +1,258 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
} from './creationWorkShelf';
type CustomWorldCreationHubProps = {
items: CustomWorldWorkSummary[];
loading: boolean;
error: string | null;
onRetry: () => void;
createError?: string | null;
createBusy?: boolean;
onCreateType: (type: PlatformCreationTypeId) => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
onDeletePublished?: ((item: CustomWorldWorkSummary) => void) | null;
deletingWorkId?: string | null;
onExperienceRpg?: ((item: CustomWorldWorkSummary) => void) | null;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
};
function EmptyState({ title }: { title: string }) {
return (
<div className="platform-subpanel flex min-h-[14rem] flex-col items-center justify-center rounded-[1.6rem] px-6 py-8 text-center">
<div className="text-lg font-semibold text-[var(--platform-text-strong)]">
{title}
</div>
</div>
);
}
export function CustomWorldCreationHub({
items,
loading,
error,
onRetry,
createError = null,
createBusy = false,
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
onExperienceRpg = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
onDeleteBigFish = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
onDeletePuzzle = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeletePuzzle: Boolean(onDeletePuzzle),
}),
[
bigFishItems,
items,
onDeleteBigFish,
onDeletePublished,
onDeletePuzzle,
puzzleItems,
rpgLibraryEntries,
],
);
const draftCount = shelfItems.filter(
(entry) => entry.status === 'draft',
).length;
const publishedCount = shelfItems.filter(
(entry) => entry.status === 'published',
).length;
const filteredItems = useMemo(
() =>
shelfItems.filter((entry) =>
activeFilter === 'all' ? true : entry.status === activeFilter,
),
[activeFilter, shelfItems],
);
function handleOpenShelfItem(item: CreationWorkShelfItem) {
switch (item.source.kind) {
case 'puzzle':
onOpenPuzzleDetail?.(item.source.item);
return;
case 'big-fish':
onOpenBigFishDetail?.(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);
}
}
}
function buildExperienceAction(item: CreationWorkShelfItem) {
if (!item.canExperience) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onExperiencePuzzle?.(sourceItem.profileId);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onExperienceBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onExperienceRpg?.(sourceItem);
};
}
}
}
function buildDeleteAction(item: CreationWorkShelfItem) {
if (!item.canDelete) {
return null;
}
switch (item.source.kind) {
case 'puzzle': {
const sourceItem = item.source.item;
return () => {
onDeletePuzzle?.(sourceItem);
};
}
case 'big-fish': {
const sourceItem = item.source.item;
return () => {
onDeleteBigFish?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onDeletePublished?.(sourceItem);
};
}
}
}
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
onCreateType={onCreateType}
/>
<CustomWorldWorkTabs
activeFilter={activeFilter}
draftCount={draftCount}
publishedCount={publishedCount}
onChange={setActiveFilter}
/>
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1.4rem] px-4 py-4 text-sm leading-7">
<div>{error}</div>
<button
type="button"
onClick={onRetry}
className="platform-button platform-button--ghost mt-3 min-h-0 rounded-full px-4 py-2 text-sm"
>
</button>
</div>
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[12rem] rounded-[1.6rem] p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 h-8 w-36 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-4 h-4 w-full rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-8 flex gap-2">
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="h-7 w-20 rounded-full bg-[var(--platform-track-fill)]" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
onOpen={() => handleOpenShelfItem(item)}
onExperience={buildExperienceAction(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
/>
))}
</div>
) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
</div>
</div>
);
}
export type { CustomWorldWorkFilter };

View File

@@ -0,0 +1,94 @@
import { ArrowRight } from 'lucide-react';
import {
PLATFORM_CREATION_TYPES,
type PlatformCreationTypeId,
} from '../platform-entry/platformEntryCreationTypes';
type CustomWorldCreationStartCardProps = {
busy?: boolean;
error?: string | null;
onCreateType: (type: PlatformCreationTypeId) => void;
};
export function CustomWorldCreationStartCard({
busy = false,
error = null,
onCreateType,
}: CustomWorldCreationStartCardProps) {
return (
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
</div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy ? '正在开启' : '选择模板'}
</span>
</div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
{PLATFORM_CREATION_TYPES.map((item) => {
const disabled = item.locked || busy;
return (
<button
key={item.id}
type="button"
disabled={disabled}
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
} ${busy && !item.locked ? 'opacity-70' : ''}`}
>
<div className="flex items-center justify-between gap-2 sm:items-start sm:gap-3">
<span
className={`platform-pill px-2.5 text-xs sm:px-3 sm:text-sm ${
item.locked
? 'platform-pill--neutral text-[var(--platform-text-soft)]'
: 'platform-pill--neutral border-white/30 bg-white/18 text-white'
}`}
>
{item.locked ? item.badge : busy ? '正在开启' : item.badge}
</span>
{item.locked ? (
<span className="text-base leading-none text-white/40">·</span>
) : (
<ArrowRight className="h-4 w-4 text-white/80" />
)}
</div>
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>
{item.subtitle}
</div>
</button>
);
})}
</div>
{error ? (
<div className="platform-banner platform-banner--danger rounded-[1rem] px-3 py-2 text-sm leading-5 sm:rounded-[1.25rem] sm:leading-6">
{error}
</div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
import { Copy } from 'lucide-react';
import { useState } from 'react';
import { copyTextToClipboard } from '../../services/clipboard';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
import type { CreationWorkShelfItem } from './creationWorkShelf';
function formatUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '最近更新';
}
return new Intl.DateTimeFormat('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
type CustomWorldWorkCardProps = {
item: CreationWorkShelfItem;
onOpen: () => void;
onExperience?: (() => void) | null;
onDelete?: (() => void) | null;
deleteBusy?: boolean;
};
const BADGE_TONE_CLASS: Record<
CreationWorkShelfItem['badges'][number]['tone'],
string
> = {
warm: 'platform-pill--warm',
success: 'platform-pill--success',
neutral: 'platform-pill--neutral',
};
export function CustomWorldWorkCard({
item,
onOpen,
onExperience = null,
onDelete = null,
deleteBusy = false,
}: CustomWorldWorkCardProps) {
const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>(
'idle',
);
const copyPublicWorkCode = () => {
if (!item.publicWorkCode) {
return;
}
void copyTextToClipboard(item.publicWorkCode).then((copied) => {
setCopyState(copied ? 'copied' : 'failed');
window.setTimeout(() => setCopyState('idle'), 1400);
});
};
return (
<div
role="button"
tabIndex={0}
aria-label={`${item.openActionLabel}${item.title}`}
onClick={onOpen}
onKeyDown={(event) => {
if (event.key !== 'Enter' && event.key !== ' ') {
return;
}
event.preventDefault();
onOpen();
}}
className="platform-surface platform-interactive-card relative min-h-[16rem] cursor-pointer overflow-hidden px-4 py-4 text-left sm:min-h-[15.5rem] xl:min-h-[14rem] xl:px-4 xl:py-3.5"
>
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="pointer-events-none relative z-20 flex min-h-[14rem] flex-col sm:min-h-[13.5rem] xl:min-h-[12.5rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[badge.tone]} px-3 py-1 text-[10px]`}
>
{badge.label}
</span>
))}
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
</span>
{onDelete ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onDelete();
}}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : '删除'}
title={deleteBusy ? '删除中' : '删除作品'}
className="pointer-events-auto relative z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
</button>
) : null}
</div>
</div>
<div className="mt-4 min-h-0 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{item.title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
</div>
<div className="mt-3 line-clamp-2 text-sm leading-6 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2">
{item.summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-end sm:justify-between xl:gap-2 xl:pt-3">
<div className="min-w-0 space-y-2">
{item.publicWorkCode ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
copyPublicWorkCode();
}}
onKeyDown={(event) => {
event.stopPropagation();
}}
className="platform-pill platform-pill--neutral pointer-events-auto relative z-30 inline-flex max-w-full items-center gap-1.5 px-3 py-1 text-[10px]"
aria-label={`复制作品号 ${item.publicWorkCode}`}
title="复制作品号"
>
<span className="shrink-0"></span>
<span className="min-w-0 truncate">{item.publicWorkCode}</span>
<Copy className="h-3 w-3 shrink-0" />
{copyState !== 'idle' ? (
<span className="shrink-0">
{copyState === 'copied' ? '已复制' : '复制失败'}
</span>
) : null}
</button>
) : null}
<div className="flex flex-wrap gap-2">
{item.metrics.map((metric) => (
<span
key={`${item.id}-${metric.id}`}
className={`platform-pill ${BADGE_TONE_CLASS[metric.tone ?? 'neutral']} px-3 py-1 text-[10px]`}
>
{metric.label}
</span>
))}
</div>
</div>
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
{onExperience ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onExperience();
}}
className="platform-button platform-button--secondary pointer-events-auto relative z-30 min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,50 @@
export type CustomWorldWorkFilter = 'all' | 'draft' | 'published';
const FILTER_OPTIONS: Array<{
id: CustomWorldWorkFilter;
label: string;
}> = [
{ id: 'all', label: '全部' },
{ id: 'draft', label: '草稿' },
{ id: 'published', label: '已发布' },
];
type CustomWorldWorkTabsProps = {
activeFilter: CustomWorldWorkFilter;
draftCount: number;
publishedCount: number;
onChange: (filter: CustomWorldWorkFilter) => void;
};
export function CustomWorldWorkTabs({
activeFilter,
draftCount,
publishedCount,
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
? draftCount
: option.id === 'published'
? publishedCount
: draftCount + publishedCount;
return (
<button
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
activeFilter === option.id ? 'platform-tab--active' : ''
}`}
>
{option.label} {count}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,271 @@
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import { buildPuzzlePublicWorkCode } from '../../services/publicWorkCode';
import type { CustomWorldProfile } from '../../types';
export type CreationWorkShelfKind = 'rpg' | 'big-fish' | 'puzzle';
export type CreationWorkShelfStatus = 'draft' | 'published';
export type CreationWorkShelfBadgeTone = 'warm' | 'success' | 'neutral';
export type CreationWorkShelfBadge = {
id: string;
label: string;
tone: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfMetric = {
id: string;
label: string;
tone?: CreationWorkShelfBadgeTone;
};
export type CreationWorkShelfSource =
| {
kind: 'rpg';
item: CustomWorldWorkSummary;
}
| {
kind: 'big-fish';
item: BigFishWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
};
export type CreationWorkShelfItem = {
id: string;
kind: CreationWorkShelfKind;
status: CreationWorkShelfStatus;
title: string;
subtitle: string;
summary: string;
updatedAt: string;
coverImageSrc: string | null;
coverRenderMode: 'image' | 'scene_with_roles';
coverCharacterImageSrcs: string[];
publicWorkCode: string | null;
typeLabel: string;
openActionLabel: string;
canExperience: boolean;
canDelete: boolean;
badges: CreationWorkShelfBadge[];
metrics: CreationWorkShelfMetric[];
source: CreationWorkShelfSource;
};
export function buildCreationWorkShelfItems(params: {
rpgItems: CustomWorldWorkSummary[];
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems: BigFishWorkSummary[];
puzzleItems: PuzzleWorkSummary[];
canDeleteRpg?: boolean;
canDeleteBigFish?: boolean;
canDeletePuzzle?: boolean;
}) {
const {
rpgItems,
rpgLibraryEntries = [],
bigFishItems,
puzzleItems,
canDeleteRpg = false,
canDeleteBigFish = false,
canDeletePuzzle = false,
} = params;
return [
...rpgItems.map((item) =>
mapRpgWorkToShelfItem(item, canDeleteRpg, rpgLibraryEntries),
),
...bigFishItems.map((item) =>
mapBigFishWorkToShelfItem(item, canDeleteBigFish),
),
...puzzleItems.map((item) =>
mapPuzzleWorkToShelfItem(item, canDeletePuzzle),
),
].sort(
(left, right) =>
getShelfItemTime(right.updatedAt) - getShelfItemTime(left.updatedAt),
);
}
function mapRpgWorkToShelfItem(
item: CustomWorldWorkSummary,
canDelete: boolean,
libraryEntries: CustomWorldLibraryEntry<CustomWorldProfile>[],
): CreationWorkShelfItem {
const isDraft = item.status === 'draft';
const libraryEntry = item.profileId
? libraryEntries.find((entry) => entry.profileId === item.profileId)
: null;
const badges: CreationWorkShelfBadge[] = [
buildStatusBadge(item.status),
{ id: 'type', label: 'RPG', tone: 'neutral' },
];
if (item.stageLabel) {
badges.push({ id: 'stage', label: item.stageLabel, tone: 'neutral' });
}
const metrics: CreationWorkShelfMetric[] = [
{
id: 'playable-npc-count',
label: `${isDraft ? '角色' : '可扮演角色'} ${item.playableNpcCount}`,
},
{ id: 'landmark-count', label: `地点 ${item.landmarkCount}` },
];
if (item.roleVisualReadyCount) {
metrics.push({
id: 'role-visual-ready-count',
label: `主图 ${item.roleVisualReadyCount}`,
tone: 'warm',
});
}
if (item.roleAnimationReadyCount) {
metrics.push({
id: 'role-animation-ready-count',
label: `动作 ${item.roleAnimationReadyCount}`,
tone: 'success',
});
}
if (item.roleAssetSummaryLabel) {
metrics.push({
id: 'role-asset-summary',
label: item.roleAssetSummaryLabel,
});
}
return {
id: item.workId,
kind: 'rpg',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: item.coverRenderMode ?? 'image',
coverCharacterImageSrcs: item.coverCharacterImageSrcs ?? [],
publicWorkCode:
item.status === 'published'
? (libraryEntry?.publicWorkCode ?? null)
: null,
typeLabel: 'RPG',
openActionLabel: isDraft
? item.playableNpcCount > 0 || item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '查看详情',
canExperience: item.status === 'published' && item.canEnterWorld,
canDelete,
badges,
metrics,
source: { kind: 'rpg', item },
};
}
function mapBigFishWorkToShelfItem(
item: BigFishWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
return {
id: item.workId,
kind: 'big-fish',
status: item.status,
title: item.title,
subtitle: item.subtitle,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode: null,
typeLabel: '大鱼',
openActionLabel: item.status === 'draft' ? '继续创作' : '查看详情',
canExperience: item.status === 'published',
canDelete,
badges: [
buildStatusBadge(item.status),
{ id: 'type', label: '大鱼', tone: 'neutral' },
],
metrics: [
{ id: 'level-count', label: `关卡 ${item.levelCount}` },
{
id: 'level-main-image-ready-count',
label: `主图 ${item.levelMainImageReadyCount}`,
},
{
id: 'level-motion-ready-count',
label: `动作 ${item.levelMotionReadyCount}`,
},
...(item.backgroundReady
? [
{
id: 'background-ready',
label: '背景已就绪',
tone: 'success' as const,
},
]
: []),
],
source: { kind: 'big-fish', item },
};
}
function mapPuzzleWorkToShelfItem(
item: PuzzleWorkSummary,
canDelete: boolean,
): CreationWorkShelfItem {
const status = item.publicationStatus;
return {
id: item.workId,
kind: 'puzzle',
status,
title: item.levelName,
subtitle: item.authorDisplayName,
summary: item.summary,
updatedAt: item.updatedAt,
coverImageSrc: item.coverImageSrc ?? null,
coverRenderMode: 'image',
coverCharacterImageSrcs: [],
publicWorkCode:
status === 'published' ? buildPuzzlePublicWorkCode(item.profileId) : null,
typeLabel: '拼图',
openActionLabel:
status === 'published' && !item.sourceSessionId ? '查看详情' : '继续创作',
canExperience: status === 'published',
canDelete,
badges: [
buildStatusBadge(status),
{ id: 'type', label: '拼图', tone: 'neutral' },
...item.themeTags.slice(0, 2).map((tag) => ({
id: `tag:${tag}`,
label: tag,
tone: 'neutral' as const,
})),
],
metrics: [
{ id: 'author', label: `作者 ${item.authorDisplayName}` },
{ id: 'play-count', label: `游玩 ${item.playCount}` },
],
source: { kind: 'puzzle', item },
};
}
function buildStatusBadge(
status: CreationWorkShelfStatus,
): CreationWorkShelfBadge {
return {
id: 'status',
label: status === 'draft' ? '草稿' : '已发布',
tone: status === 'draft' ? 'warm' : 'success',
};
}
function getShelfItemTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isNaN(timestamp) ? 0 : timestamp;
}