358 lines
11 KiB
TypeScript
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 };
|