Files
Genarrative/src/components/custom-world-home/CustomWorldCreationHub.tsx
kdletters 995661e7cc
Some checks failed
CI / verify (push) Has been cancelled
Preserve partial creation replies on stream failure
2026-05-05 11:31:50 +08:00

358 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import type { BigFishWorkSummary } from '../../../packages/shared/src/contracts/bigFishWorkSummary';
import type { CustomWorldWorkSummary } from '../../../packages/shared/src/contracts/customWorldAgent';
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
import type { SquareHoleWorkSummary } from '../../../packages/shared/src/contracts/squareHoleWorks';
import type { CustomWorldLibraryEntry } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldProfile } from '../../types';
import type { PlatformCreationTypeId } from '../platform-entry/platformEntryCreationTypes';
import {
buildCreationWorkShelfItems,
type CreationWorkShelfItem,
type CreationWorkShelfMetricId,
} from './creationWorkShelf';
import { CustomWorldCreationStartCard } from './CustomWorldCreationStartCard';
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
import {
type CustomWorldWorkFilter,
CustomWorldWorkTabs,
} from './CustomWorldWorkTabs';
// 中文注释:草稿在手机端保持双列,已发布卡片由卡片自身跨两列展示公开指标。
const WORK_GRID_CLASS =
'grid grid-cols-2 gap-2.5 sm:gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4';
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
type WorkMetricSnapshot = Record<
string,
Partial<Record<CreationWorkShelfMetricId, number>>
>;
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;
rpgLibraryEntries?: CustomWorldLibraryEntry<CustomWorldProfile>[];
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
match3dItems?: Match3DWorkSummary[];
onOpenMatch3DDetail?: (item: Match3DWorkSummary) => void;
onDeleteMatch3D?: ((item: Match3DWorkSummary) => void) | null;
squareHoleItems?: SquareHoleWorkSummary[];
onOpenSquareHoleDetail?: (item: SquareHoleWorkSummary) => void;
onDeleteSquareHole?: ((item: SquareHoleWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
onClaimPuzzlePointIncentive?: ((item: PuzzleWorkSummary) => void) | null;
claimingPuzzleProfileId?: string | 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>
);
}
function buildWorkMetricCacheItemKey(item: CreationWorkShelfItem) {
return `${item.kind}:${item.id}`;
}
function readWorkMetricSnapshot(): WorkMetricSnapshot {
if (typeof window === 'undefined') {
return {};
}
try {
const rawSnapshot = window.sessionStorage.getItem(WORK_METRIC_CACHE_KEY);
if (!rawSnapshot) {
return {};
}
const parsed = JSON.parse(rawSnapshot) as WorkMetricSnapshot;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeWorkMetricSnapshot(items: CreationWorkShelfItem[]) {
if (typeof window === 'undefined') {
return;
}
const snapshot: WorkMetricSnapshot = {};
for (const item of items) {
if (item.status !== 'published' || item.metrics.length === 0) {
continue;
}
snapshot[buildWorkMetricCacheItemKey(item)] = Object.fromEntries(
item.metrics.map((metric) => [metric.id, metric.value]),
);
}
// 中文注释:缓存只作为下一次进入创作页的数字动画起点,真实展示值仍以接口返回为准。
if (Object.keys(snapshot).length === 0) {
return;
}
try {
window.sessionStorage.setItem(
WORK_METRIC_CACHE_KEY,
JSON.stringify(snapshot),
);
} catch {
// 中文注释:浏览器禁用 sessionStorage 时降级为无缓存动画,不影响作品列表使用。
}
}
export function CustomWorldCreationHub({
items,
loading,
error,
onRetry,
createError = null,
createBusy = false,
onCreateType,
onOpenDraft,
onEnterPublished,
onDeletePublished = null,
deletingWorkId = null,
rpgLibraryEntries = [],
bigFishItems = [],
onOpenBigFishDetail,
onDeleteBigFish = null,
match3dItems = [],
onOpenMatch3DDetail,
onDeleteMatch3D = null,
squareHoleItems = [],
onOpenSquareHoleDetail,
onDeleteSquareHole = null,
puzzleItems = [],
onOpenPuzzleDetail,
onDeletePuzzle = null,
onClaimPuzzlePointIncentive = null,
claimingPuzzleProfileId = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
const shelfItems = useMemo(
() =>
buildCreationWorkShelfItems({
rpgItems: items,
rpgLibraryEntries,
bigFishItems,
match3dItems,
squareHoleItems,
puzzleItems,
canDeleteRpg: Boolean(onDeletePublished),
canDeleteBigFish: Boolean(onDeleteBigFish),
canDeleteMatch3D: Boolean(onDeleteMatch3D),
canDeleteSquareHole: Boolean(onDeleteSquareHole),
canDeletePuzzle: Boolean(onDeletePuzzle),
}),
[
bigFishItems,
items,
match3dItems,
onDeleteBigFish,
onDeleteMatch3D,
onDeleteSquareHole,
onDeletePublished,
onDeletePuzzle,
puzzleItems,
rpgLibraryEntries,
squareHoleItems,
],
);
const [metricSnapshot] = useState<WorkMetricSnapshot>(() =>
readWorkMetricSnapshot(),
);
useEffect(() => {
writeWorkMetricSnapshot(shelfItems);
}, [shelfItems]);
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 'match3d':
onOpenMatch3DDetail?.(item.source.item);
return;
case 'square-hole':
onOpenSquareHoleDetail?.(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 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 'match3d': {
const sourceItem = item.source.item;
return () => {
onDeleteMatch3D?.(sourceItem);
};
}
case 'square-hole': {
const sourceItem = item.source.item;
return () => {
onDeleteSquareHole?.(sourceItem);
};
}
case 'rpg': {
const sourceItem = item.source.item;
return () => {
onDeletePublished?.(sourceItem);
};
}
}
}
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
if (item.source.kind !== 'puzzle' || !onClaimPuzzlePointIncentive) {
return null;
}
const sourceItem = item.source.item;
return () => {
onClaimPuzzlePointIncentive(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={WORK_GRID_CLASS}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
className="platform-subpanel min-h-[10.5rem] rounded-[1.2rem] p-3 sm:min-h-[12rem] sm:rounded-[1.6rem] sm:p-5"
>
<div className="h-4 w-20 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-5 h-6 w-24 rounded-full bg-[var(--platform-track-fill)] sm:mt-6 sm:h-8 sm:w-36" />
<div className="mt-3 h-3 w-full rounded-full bg-[var(--platform-track-fill)] sm:mt-4 sm:h-4" />
<div className="mt-2 h-4 w-4/5 rounded-full bg-[var(--platform-track-fill)]" />
<div className="mt-6 flex flex-col gap-2 sm:mt-8 sm:flex-row">
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
<div className="h-6 w-16 rounded-full bg-[var(--platform-track-fill)] sm:h-7 sm:w-20" />
</div>
</div>
))}
</div>
) : filteredItems.length > 0 ? (
<div className={WORK_GRID_CLASS}>
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.id}`}
item={item}
previousMetricValues={
metricSnapshot[buildWorkMetricCacheItemKey(item)]
}
onOpen={() => handleOpenShelfItem(item)}
onDelete={buildDeleteAction(item)}
deleteBusy={deletingWorkId === item.id}
onClaimPointIncentive={buildPointIncentiveAction(item)}
pointIncentiveBusy={
item.source.kind === 'puzzle' &&
claimingPuzzleProfileId === item.source.item.profileId
}
/>
))}
</div>
) : shelfItems.length === 0 ? (
<EmptyState title="还没有作品" />
) : (
<EmptyState title="当前筛选下没有内容" />
)}
</div>
</div>
);
}
export type { CustomWorldWorkFilter };