This commit is contained in:
2026-04-22 20:14:15 +08:00
parent 0773a0d0ca
commit 0e9c286a57
205 changed files with 25790 additions and 1623 deletions

View File

@@ -72,3 +72,42 @@ test('creation hub reflects updated draft title summary and counts after rerende
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}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(screen.getByText('潮雾列岛')).toBeTruthy();
expect(screen.getByText('沉钟拼图')).toBeTruthy();
expect(screen.getAllByText('拼图').length).toBeGreaterThan(0);
expect(screen.queryByText('我的拼图作品')).toBeNull();
expect(screen.queryByText('拼图玩法')).toBeNull();
});

View File

@@ -42,3 +42,41 @@ test('creation hub draft card renders compiled work summary fields', () => {
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}
onBack={() => {}}
onRetry={() => {}}
onCreateNew={() => {}}
onOpenDraft={() => {}}
onEnterPublished={() => {}}
onOpenPuzzleDetail={() => {}}
/>,
);
expect(html).toContain('潮雾拼图');
expect(html).toContain('拼图');
expect(html).not.toContain('我的拼图作品');
expect(html).not.toContain('拼图玩法');
});

View File

@@ -1,8 +1,12 @@
import { useMemo, useState } from 'react';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
CustomWorldWorkCard,
type UnifiedCreationWorkItem,
} from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
@@ -17,6 +21,8 @@ type CustomWorldCreationHubProps = {
onCreateNew: () => void;
onOpenDraft: (item: CustomWorldWorkSummary) => void;
onEnterPublished: (profileId: string) => void;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (profileId: string) => void;
};
function EmptyState({ title }: { title: string }) {
@@ -38,19 +44,38 @@ export function CustomWorldCreationHub({
onCreateNew,
onOpenDraft,
onEnterPublished,
puzzleItems = [],
onOpenPuzzleDetail,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const draftCount = items.filter((item) => item.status === 'draft').length;
const publishedCount = items.filter(
(item) => item.status === 'published',
const unifiedItems = useMemo<UnifiedCreationWorkItem[]>(
() => [
...items.map((item) => ({ kind: 'rpg', item }) as const),
...puzzleItems.map((item) => ({ kind: 'puzzle', item }) as const),
],
[items, puzzleItems],
);
const draftCount = unifiedItems.filter((entry) =>
entry.kind === 'puzzle'
? entry.item.publicationStatus === 'draft'
: entry.item.status === 'draft',
).length;
const publishedCount = unifiedItems.filter((entry) =>
entry.kind === 'puzzle'
? entry.item.publicationStatus === 'published'
: entry.item.status === 'published',
).length;
const filteredItems = useMemo(
() =>
items.filter((item) =>
activeFilter === 'all' ? true : item.status === activeFilter,
unifiedItems.filter((entry) =>
activeFilter === 'all'
? true
: entry.kind === 'puzzle'
? entry.item.publicationStatus === activeFilter
: entry.item.status === activeFilter,
),
[activeFilter, items],
[activeFilter, unifiedItems],
);
return (
@@ -125,22 +150,30 @@ export function CustomWorldCreationHub({
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={item.workId}
key={`${item.kind}-${item.item.workId}`}
item={item}
onClick={() => {
if (item.sourceType === 'agent_session' && item.sessionId) {
onOpenDraft(item);
if (item.kind === 'puzzle') {
onOpenPuzzleDetail?.(item.item.profileId);
return;
}
if (item.profileId) {
onEnterPublished(item.profileId);
if (
item.item.sourceType === 'agent_session' &&
item.item.sessionId
) {
onOpenDraft(item.item);
return;
}
if (item.item.profileId) {
onEnterPublished(item.item.profileId);
}
}}
/>
))}
</div>
) : items.length === 0 ? (
) : unifiedItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />

View File

@@ -1,4 +1,5 @@
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
function formatUpdatedAt(value: string) {
@@ -15,8 +16,18 @@ function formatUpdatedAt(value: string) {
}).format(date);
}
export type UnifiedCreationWorkItem =
| {
kind: 'rpg';
item: CustomWorldWorkSummary;
}
| {
kind: 'puzzle';
item: PuzzleWorkSummary;
};
type CustomWorldWorkCardProps = {
item: CustomWorldWorkSummary;
item: UnifiedCreationWorkItem;
onClick: () => void;
};
@@ -24,24 +35,36 @@ export function CustomWorldWorkCard({
item,
onClick,
}: CustomWorldWorkCardProps) {
const isDraft = item.status === 'draft';
const hasFoundationDraft =
item.playableNpcCount > 0 || item.landmarkCount > 0;
const actionLabel = isDraft
? hasFoundationDraft
? '继续完善'
: '继续创作'
: '进入世界';
const roleCountLabel = isDraft ? '角色' : '可扮演角色';
const isPuzzle = item.kind === 'puzzle';
const isDraft =
item.kind === 'puzzle'
? item.item.publicationStatus === 'draft'
: item.item.status === 'draft';
const actionLabel = isPuzzle
? '查看详情'
: isDraft
? item.item.playableNpcCount > 0 || item.item.landmarkCount > 0
? '继续完善'
: '继续创作'
: '进入世界';
const title = isPuzzle ? item.item.levelName : item.item.title;
const subtitle = isPuzzle ? item.item.authorDisplayName : item.item.subtitle;
const summary = item.item.summary;
const updatedAt = item.item.updatedAt;
const coverImageSrc = item.item.coverImageSrc ?? null;
const coverRenderMode =
item.kind === 'rpg' ? item.item.coverRenderMode : 'image';
const coverCharacterImageSrcs =
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
<CustomWorldCoverArtwork
imageSrc={item.coverImageSrc}
title={item.title}
imageSrc={coverImageSrc}
title={title}
fallbackLabel="封面"
renderMode={item.coverRenderMode}
characterImageSrcs={item.coverCharacterImageSrcs}
renderMode={coverRenderMode}
characterImageSrcs={coverCharacterImageSrcs}
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
@@ -57,52 +80,78 @@ export function CustomWorldWorkCard({
>
{isDraft ? '草稿' : '已发布'}
</span>
{item.stageLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isPuzzle ? '拼图' : 'RPG'}
</span>
{!isPuzzle && item.item.stageLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.stageLabel}
{item.item.stageLabel}
</span>
) : null}
{isPuzzle
? item.item.themeTags.slice(0, 2).map((tag) => (
<span
key={`${item.item.profileId}-${tag}`}
className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]"
>
{tag}
</span>
))
: null}
</div>
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(item.updatedAt)}
{formatUpdatedAt(updatedAt)}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
{item.title}
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{item.subtitle}
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
{item.summary}
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap gap-2">
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{roleCountLabel} {item.playableNpcCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.landmarkCount}
</span>
{item.roleVisualReadyCount ? (
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
{item.roleVisualReadyCount}
</span>
) : null}
{item.roleAnimationReadyCount ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
{item.roleAnimationReadyCount}
</span>
) : null}
{item.roleAssetSummaryLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.roleAssetSummaryLabel}
</span>
) : null}
{isPuzzle ? (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.authorDisplayName}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.playCount}
</span>
</>
) : (
<>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{isDraft ? '角色' : '可扮演角色'} {item.item.playableNpcCount}
</span>
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.landmarkCount}
</span>
{item.item.roleVisualReadyCount ? (
<span className="platform-pill platform-pill--warm px-3 py-1 text-[10px]">
{item.item.roleVisualReadyCount}
</span>
) : null}
{item.item.roleAnimationReadyCount ? (
<span className="platform-pill platform-pill--success px-3 py-1 text-[10px]">
{item.item.roleAnimationReadyCount}
</span>
) : null}
{item.item.roleAssetSummaryLabel ? (
<span className="platform-pill platform-pill--neutral px-3 py-1 text-[10px]">
{item.item.roleAssetSummaryLabel}
</span>
) : null}
</>
)}
</div>
<button
type="button"