Files
Genarrative/src/components/custom-world-home/CustomWorldCreationHub.tsx
kdletters f4eee2d585 再次合并 master
合入 origin/master 最新订阅消息与计费相关更新

保留作品架 actions 收口并接入统一分享弹窗

修复创作生成泥点预检与本地余额扣减回归
2026-06-08 17:18:38 +08:00

327 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 };