327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react';
|
||
|
||
import { resolveSelectionStageFromPath } from '../../routing/appPageRoutes';
|
||
import type { CreationEntryConfig } from '../../services/creationEntryConfigService';
|
||
import type { PublishShareModalPayload } from '../common/publishShareModalModel';
|
||
import type {
|
||
PlatformCreationTypeCard,
|
||
PlatformCreationTypeId,
|
||
} from '../platform-entry/platformEntryCreationTypes';
|
||
import {
|
||
type CreationWorkShelfItem,
|
||
type CreationWorkShelfMetricId,
|
||
getCreationWorkShelfItemTime,
|
||
} from './creationWorkShelf';
|
||
import {
|
||
CustomWorldCreationStartCard,
|
||
} from './CustomWorldCreationStartCard';
|
||
import { CustomWorldWorkCard } from './CustomWorldWorkCard';
|
||
import {
|
||
type CustomWorldWorkFilter,
|
||
CustomWorldWorkTabs,
|
||
} from './CustomWorldWorkTabs';
|
||
|
||
const WORK_GRID_CLASS =
|
||
'creation-work-list grid min-w-0 gap-3 sm:gap-3.5 xl:gap-4';
|
||
const WORK_METRIC_CACHE_KEY = 'genarrative.creationHub.publishedMetrics.v1';
|
||
const RECENT_CREATION_WINDOW_DAYS = 7;
|
||
const RECENT_CREATION_WINDOW_MS =
|
||
RECENT_CREATION_WINDOW_DAYS * 24 * 60 * 60 * 1000;
|
||
|
||
type WorkMetricSnapshot = Record<
|
||
string,
|
||
Partial<Record<CreationWorkShelfMetricId, number>>
|
||
>;
|
||
|
||
type CustomWorldCreationHubProps = {
|
||
shelfItems: CreationWorkShelfItem[];
|
||
loading: boolean;
|
||
error: string | null;
|
||
onRetry: () => void;
|
||
createBusy?: boolean;
|
||
entryConfig: CreationEntryConfig;
|
||
creationTypes: readonly PlatformCreationTypeCard[];
|
||
onCreateType: (type: PlatformCreationTypeId) => void;
|
||
deletingWorkId?: string | null;
|
||
claimingPuzzleProfileId?: string | null;
|
||
onOpenShelfItem?: (item: CreationWorkShelfItem) => void;
|
||
onShareWork?: ((payload: PublishShareModalPayload) => void) | null;
|
||
// 中文注释:底部加号入口可传入后端作品架摘要,用于推导最近使用过的模板。
|
||
recentWorkItems?: CreationWorkShelfItem[];
|
||
mode?: 'full' | 'start-only' | 'works-only';
|
||
};
|
||
|
||
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 时降级为无缓存动画,不影响作品列表使用。
|
||
}
|
||
}
|
||
|
||
function resolveShelfShareStage(
|
||
sharePath: string,
|
||
): PublishShareModalPayload['stage'] | null {
|
||
let pathname = '';
|
||
try {
|
||
pathname = new URL(sharePath, 'https://genarrative.local').pathname;
|
||
} catch {
|
||
pathname = sharePath.split(/[?#]/u)[0] ?? '';
|
||
}
|
||
|
||
const stage = resolveSelectionStageFromPath(pathname);
|
||
return stage === 'platform' ? null : stage;
|
||
}
|
||
|
||
function buildCreationWorkShelfSharePayload(
|
||
item: CreationWorkShelfItem,
|
||
): PublishShareModalPayload | null {
|
||
const publicWorkCode = item.publicWorkCode?.trim();
|
||
const sharePath = item.sharePath?.trim();
|
||
if (!publicWorkCode || !sharePath) {
|
||
return null;
|
||
}
|
||
|
||
const stage = resolveShelfShareStage(sharePath);
|
||
if (!stage) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
title: item.title,
|
||
publicWorkCode,
|
||
stage,
|
||
};
|
||
}
|
||
|
||
/** 渲染底部加号创作入口页与草稿作品架,最近创作复用最近使用过的模板入口。 */
|
||
export function CustomWorldCreationHub({
|
||
shelfItems,
|
||
loading,
|
||
error,
|
||
onRetry,
|
||
createBusy = false,
|
||
entryConfig,
|
||
creationTypes,
|
||
onCreateType,
|
||
deletingWorkId = null,
|
||
claimingPuzzleProfileId = null,
|
||
onOpenShelfItem,
|
||
onShareWork = null,
|
||
recentWorkItems: recentWorkSourceItems,
|
||
mode = 'full',
|
||
}: CustomWorldCreationHubProps) {
|
||
const [activeFilter, setActiveFilter] =
|
||
useState<CustomWorldWorkFilter>('all');
|
||
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],
|
||
);
|
||
// 中文注释:最近创作只取 7 天内作品架摘要,再推导模板 ID 复用模板入口卡片。
|
||
const recentCreationCutoffMs = Date.now() - RECENT_CREATION_WINDOW_MS;
|
||
const recentWorkItems =
|
||
mode === 'start-only'
|
||
? (recentWorkSourceItems ?? shelfItems)
|
||
.filter(
|
||
(item) =>
|
||
getCreationWorkShelfItemTime(item.updatedAt) >=
|
||
recentCreationCutoffMs,
|
||
)
|
||
.slice(0, 4)
|
||
: [];
|
||
const recentCreationTypeIds = [
|
||
...new Set(recentWorkItems.map((item) => item.kind)),
|
||
];
|
||
|
||
function handleOpenShelfItem(item: CreationWorkShelfItem) {
|
||
onOpenShelfItem?.(item);
|
||
// 中文注释:玩法差异由 Work Shelf Adapter 承载,Hub 只负责响应卡片点击。
|
||
item.actions.open();
|
||
}
|
||
|
||
function buildDeleteAction(item: CreationWorkShelfItem) {
|
||
if (!item.canDelete) {
|
||
return null;
|
||
}
|
||
|
||
return item.actions.delete ?? null;
|
||
}
|
||
|
||
function buildShareAction(item: CreationWorkShelfItem) {
|
||
const payload = buildCreationWorkShelfSharePayload(item);
|
||
if (!payload) {
|
||
return null;
|
||
}
|
||
|
||
return () => {
|
||
onShareWork?.(payload);
|
||
};
|
||
}
|
||
|
||
function buildPointIncentiveAction(item: CreationWorkShelfItem) {
|
||
return item.actions.claimPointIncentive ?? null;
|
||
}
|
||
|
||
const showStartCard = mode !== 'works-only';
|
||
const showWorkShelf = mode !== 'start-only';
|
||
|
||
return (
|
||
<div className="platform-remap-surface w-full 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">
|
||
{showStartCard ? (
|
||
<CustomWorldCreationStartCard
|
||
busy={createBusy}
|
||
entryConfig={entryConfig}
|
||
creationTypes={creationTypes}
|
||
recentCreationTypeIds={recentCreationTypeIds}
|
||
recentWindowDays={RECENT_CREATION_WINDOW_DAYS}
|
||
onCreateType={onCreateType}
|
||
/>
|
||
) : null}
|
||
|
||
{showWorkShelf ? (
|
||
<CustomWorldWorkTabs
|
||
activeFilter={activeFilter}
|
||
draftCount={draftCount}
|
||
publishedCount={publishedCount}
|
||
onChange={setActiveFilter}
|
||
/>
|
||
) : null}
|
||
|
||
{showWorkShelf && error ? (
|
||
<div className="flex justify-end">
|
||
<button
|
||
type="button"
|
||
onClick={onRetry}
|
||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
|
||
{showWorkShelf ? (
|
||
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}
|
||
onShare={buildShareAction(item)}
|
||
onClaimPointIncentive={buildPointIncentiveAction(item)}
|
||
pointIncentiveBusy={
|
||
item.source.kind === 'puzzle' &&
|
||
claimingPuzzleProfileId === item.source.item.profileId
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : shelfItems.length === 0 ? (
|
||
<EmptyState title="还没有作品" />
|
||
) : (
|
||
<EmptyState title="当前筛选下没有内容" />
|
||
)
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export type { CustomWorldWorkFilter };
|