Files
Genarrative/src/components/creation-home/CreationLandingView.tsx
lhk229 3d8e0211db
Project CI / Repository checks (push) Successful in 3m54s
Project CI / Frontend tests (push) Successful in 4m20s
Project CI / Backend tests (push) Successful in 4m33s
Project CI / Native shell tests (push) Successful in 16m31s
5分钟策划agent (#159)
Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/159
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-24 21:44:44 +08:00

1662 lines
52 KiB
TypeScript
Raw Permalink 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 { Film, Image as ImageIcon, Music2, Pause, Play } from 'lucide-react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { ApiClientError } from '../../services/apiClient';
import {
createEditorProject,
type EditorProjectResourceListPage,
type EditorProjectResourceSnapshot,
type EditorProjectSnapshot,
type EditorPublicShowcaseResourceSnapshot,
type EditorShowcaseCampaignSnapshot,
listEditorProjects,
listPublicEditorProjectResources,
toggleEditorShowcaseAssetLike,
} from '../../services/image-editor/editorProjectClient';
import { useAuthUi } from '../auth/AuthUiContext';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformEmptyState } from '../common/PlatformEmptyState';
import { PlatformMediaFrame } from '../common/PlatformMediaFrame';
import { PlatformModalCloseButton } from '../common/PlatformModalCloseButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { UnifiedModal } from '../common/UnifiedModal';
import { resolveProjectCoverSnapshotResource } from '../project/ProjectCanvasCover';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import { ResolvedAssetVideo } from '../ResolvedAssetVideo';
import {
buildCreationShowcaseItems,
SHOWCASE_TABS,
type ShowcaseAssetItem,
type ShowcaseAssetPreview,
type ShowcaseTabId,
} from './creationShowcaseModel';
import {
applySequentialShowcaseMasonry,
resetSequentialShowcaseMasonry,
} from './showcaseMasonryLayout';
type CreationLandingViewProps = {
onOpenProject: (
projectId: string,
options?: { guide?: boolean; tool?: CreationFeatureTool },
) => void;
onOpenProjects: () => void;
onOpenCommunity?: () => void;
searchKeyword?: string;
};
type CreationFeatureTool =
| 'character-spec'
| 'icon-spec'
| 'ui-design'
| 'background-music'
| 'image'
| 'publication-cover'
| 'scene';
type CreationFeatureItem = {
title: string;
description: string;
iconSrc: string;
action: { kind: 'tool'; tool: CreationFeatureTool } | { kind: 'pending' };
};
const CREATION_HOME_ASSETS = {
heroCreate: '/creation-home/hero-create.png',
heroCommunity: '/creation-home/hero-community.png',
projectNew: '/creation-home/project-new.png',
} as const;
const SHOWCASE_LIKE_ICON_SRC = '/creation-home/showcase-like-thumb.png';
const REDUCED_MOTION_MEDIA_QUERY = '(prefers-reduced-motion: reduce)';
function usePrefersReducedMotion() {
const [prefersReducedMotion, setPrefersReducedMotion] = useState(() =>
typeof window.matchMedia === 'function'
? window.matchMedia(REDUCED_MOTION_MEDIA_QUERY).matches
: false,
);
useEffect(() => {
if (typeof window.matchMedia !== 'function') {
return undefined;
}
const mediaQuery = window.matchMedia(REDUCED_MOTION_MEDIA_QUERY);
const handleChange = (event: MediaQueryListEvent) => {
setPrefersReducedMotion(event.matches);
};
setPrefersReducedMotion(mediaQuery.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
return prefersReducedMotion;
}
const CREATION_FEATURES: CreationFeatureItem[] = [
{
title: '游戏视觉规范',
description: '轻松约束多类素材视觉一致性',
iconSrc: '/creation-home/feature-visual-spec.png',
action: { kind: 'tool', tool: 'icon-spec' },
},
{
title: '游戏角色',
description: '整套高完成度2D/3D角色素材及动画',
iconSrc: '/creation-home/feature-character.png',
action: { kind: 'tool', tool: 'character-spec' },
},
{
title: '游戏UI',
description: '轻松生成高一致性的UI素材',
iconSrc: '/creation-home/feature-ui.png',
action: { kind: 'tool', tool: 'ui-design' },
},
{
title: '游戏音乐',
description: '轻松制作游戏背景音乐、音效',
iconSrc: '/creation-home/feature-music.png',
action: { kind: 'tool', tool: 'background-music' },
},
{
title: '游戏特效',
description: '低成本生成精品游戏特效',
iconSrc: '/creation-home/feature-effects.png',
action: { kind: 'pending' },
},
{
title: '游戏场景',
description: '轻松生成各类游戏场景素材',
iconSrc: '/creation-home/feature-scene.png',
action: { kind: 'tool', tool: 'scene' },
},
{
title: '游戏美宣',
description: '轻松生成游戏上架所需图片和视频',
iconSrc: '/creation-home/feature-marketing.png',
action: { kind: 'tool', tool: 'publication-cover' },
},
{
title: '游戏功能编码',
description: '自然语言驱动游戏生成',
iconSrc: '/creation-home/feature-coding.png',
action: { kind: 'pending' },
},
{
title: '一键导出',
description: '支持导出为微抖小游戏、Godot、Unity、UE工程',
iconSrc: '/creation-home/feature-export.png',
action: { kind: 'pending' },
},
];
function isUnauthorizedError(error: unknown) {
return error instanceof ApiClientError && error.status === 401;
}
function normalizeShowcaseResourcePage(
page: EditorProjectResourceListPage | EditorProjectResourceSnapshot[],
): EditorProjectResourceListPage {
return Array.isArray(page)
? {
resources: page.map((resource) => ({
...resource,
viewerLiked:
(resource as Partial<EditorPublicShowcaseResourceSnapshot>)
.viewerLiked === true,
})),
nextCursor: null,
campaign: null,
}
: page;
}
function mergeShowcaseResources(
current: EditorPublicShowcaseResourceSnapshot[],
incoming: EditorPublicShowcaseResourceSnapshot[],
) {
const seenResourceIds = new Set(
current.map((resource) => resource.resourceId),
);
return [
...current,
...incoming.filter((resource) => {
if (seenResourceIds.has(resource.resourceId)) {
return false;
}
seenResourceIds.add(resource.resourceId);
return true;
}),
];
}
function formatProjectUpdatedAt(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function getPreviewAspectRatio(preview: ShowcaseAssetPreview) {
return preview.width && preview.height
? `${Math.max(1, preview.width)} / ${Math.max(1, preview.height)}`
: undefined;
}
function useSequentialShowcaseMasonry(
itemOrderKey: string,
expectedItemCount: number,
) {
const containerRef = useRef<HTMLDivElement | null>(null);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) {
return;
}
const view = container.ownerDocument.defaultView;
let animationFrame: number | null = null;
const layout = () => {
animationFrame = null;
applySequentialShowcaseMasonry(container, { expectedItemCount });
};
const scheduleLayout = () => {
if (animationFrame !== null) {
return;
}
if (view?.requestAnimationFrame) {
animationFrame = view.requestAnimationFrame(layout);
return;
}
layout();
};
layout();
const ResizeObserverConstructor = globalThis.ResizeObserver;
const resizeObserver = ResizeObserverConstructor
? new ResizeObserverConstructor(scheduleLayout)
: null;
resizeObserver?.observe(container);
for (const card of container.querySelectorAll<HTMLElement>(
':scope > .creation-landing__asset-card',
)) {
resizeObserver?.observe(card);
}
return () => {
if (animationFrame !== null && view?.cancelAnimationFrame) {
view.cancelAnimationFrame(animationFrame);
}
resizeObserver?.disconnect();
resetSequentialShowcaseMasonry(container);
};
}, [expectedItemCount, itemOrderKey]);
return containerRef;
}
function getPreviewIcon(preview: ShowcaseAssetPreview) {
if (preview.mediaType === 'audio') {
return <Music2 aria-hidden="true" />;
}
if (preview.mediaType === 'video' || preview.mediaType === 'image-sequence') {
return <Film aria-hidden="true" />;
}
return <ImageIcon aria-hidden="true" />;
}
function ShowcaseAudioPreview({
preview,
controls = false,
}: {
preview: ShowcaseAssetPreview;
controls?: boolean;
}) {
const { resolvedUrl } = useResolvedAssetReadUrl(preview.src, {
objectKey: preview.objectKey,
});
const coverSrc =
preview.coverSrc?.trim() || '/creation-home/audio-asset-cover.png';
return (
<div className="creation-landing__asset-audio-preview">
<img
alt={preview.label}
className="creation-landing__asset-audio-cover"
draggable={false}
src={coverSrc}
/>
{controls && resolvedUrl ? (
<audio controls preload="metadata" src={resolvedUrl} />
) : null}
</div>
);
}
function ShowcasePreview({
preview,
variant = 'card',
sequencePlaying = false,
}: {
preview: ShowcaseAssetPreview;
variant?: 'card' | 'modal' | 'thumb';
sequencePlaying?: boolean;
}) {
if (preview.mediaType === 'audio') {
return (
<ShowcaseAudioPreview preview={preview} controls={variant === 'modal'} />
);
}
if (preview.mediaType === 'video') {
return (
<ShowcaseVideoPreview
preview={preview}
controls={variant === 'modal'}
variant={variant}
/>
);
}
if (preview.mediaType === 'image-sequence') {
return (
<ShowcaseImageSequencePreview
preview={preview}
variant={variant}
playing={sequencePlaying}
/>
);
}
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
function ShowcaseImageSequenceFrame({
preview,
frame,
frameKey,
visible,
loaded,
retryGeneration,
onReady,
onFailed,
}: {
preview: ShowcaseAssetPreview;
frame: NonNullable<ShowcaseAssetPreview['imageSequenceFrames']>[number];
frameKey: string;
visible: boolean;
loaded: boolean;
retryGeneration: number;
onReady: (frameKey: string) => void;
onFailed: (frameKey: string) => void;
}) {
const failureReportedRef = useRef(false);
const { resolvedUrl, isResolving, shouldResolve } = useResolvedAssetReadUrl(
frame.imageSrc,
{
objectKey: frame.objectKey,
refreshKey: `${preview.id}:${frameKey}:${retryGeneration}`,
},
);
const reportFailure = useCallback(() => {
if (failureReportedRef.current) {
return;
}
failureReportedRef.current = true;
onFailed(frameKey);
}, [frameKey, onFailed]);
useEffect(() => {
if (shouldResolve && !isResolving && !resolvedUrl) {
reportFailure();
}
}, [isResolving, reportFailure, resolvedUrl, shouldResolve]);
if (!resolvedUrl) {
return null;
}
return (
<img
src={resolvedUrl}
alt={visible ? `角色动作预览:${preview.label}` : ''}
aria-hidden={visible ? undefined : true}
className="creation-landing__image-sequence-frame"
decoding="async"
style={{
opacity: visible && loaded ? 1 : 0,
transition: 'none',
}}
onLoad={() => onReady(frameKey)}
onError={reportFailure}
/>
);
}
function ShowcaseImageSequencePreview({
preview,
variant,
playing,
}: {
preview: ShowcaseAssetPreview;
variant: 'card' | 'modal' | 'thumb';
playing: boolean;
}) {
const frames = useMemo(
() => preview.imageSequenceFrames ?? [],
[preview.imageSequenceFrames],
);
const durationMs = preview.imageSequenceDurationMs ?? 0;
const shouldMountSequence = variant === 'modal' || playing;
const frameItems = useMemo(
() =>
frames.map((frame, index) => ({
frame,
index,
key: [preview.id, frame.objectKey ?? '', frame.imageSrc, index].join(
':',
),
})),
[frames, preview.id],
);
const frameSequenceKey = frameItems.map((item) => item.key).join('|');
const firstFrameKey = frameItems[0]?.key ?? '';
const [frameIndex, setFrameIndex] = useState(0);
const [isPlaying, setIsPlaying] = useState(playing);
const [loadedFrameKeys, setLoadedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [failedFrameKeys, setFailedFrameKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
const [retryGeneration, setRetryGeneration] = useState(0);
const [visibleFrameKey, setVisibleFrameKey] = useState(firstFrameKey);
const currentFrameItem =
frameItems[Math.min(frameIndex, frameItems.length - 1)];
const currentFrameKey = currentFrameItem?.key ?? '';
const mountedFrameKeys = new Set<string>();
if (visibleFrameKey && !failedFrameKeys.has(visibleFrameKey)) {
mountedFrameKeys.add(visibleFrameKey);
}
for (
let offset = 0;
offset < frameItems.length && mountedFrameKeys.size < 3;
offset += 1
) {
const item = frameItems[(frameIndex + offset) % frameItems.length];
if (item && !failedFrameKeys.has(item.key)) {
mountedFrameKeys.add(item.key);
}
}
const availableFrameCount = frameItems.length - failedFrameKeys.size;
const handleFrameReady = useCallback((frameKey: string) => {
setLoadedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const handleFrameFailed = useCallback((frameKey: string) => {
setFailedFrameKeys((currentKeys) => {
if (currentKeys.has(frameKey)) {
return currentKeys;
}
const nextKeys = new Set(currentKeys);
nextKeys.add(frameKey);
return nextKeys;
});
}, []);
const retryFailedFrames = useCallback(() => {
setFrameIndex(0);
setIsPlaying(playing);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setRetryGeneration((current) => current + 1);
}, [firstFrameKey, playing]);
useEffect(() => {
setFrameIndex(0);
setLoadedFrameKeys(new Set());
setFailedFrameKeys(new Set());
setVisibleFrameKey(firstFrameKey);
setIsPlaying(playing);
}, [firstFrameKey, frameSequenceKey, playing]);
useEffect(() => {
if (!currentFrameKey || !failedFrameKeys.has(currentFrameKey)) {
return;
}
const nextIndex = findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
frameIndex,
);
if (nextIndex === null) {
setVisibleFrameKey('');
setIsPlaying(false);
return;
}
setFrameIndex(nextIndex);
}, [currentFrameKey, failedFrameKeys, frameIndex, frameItems]);
useEffect(() => {
if (!currentFrameKey) {
setVisibleFrameKey('');
return;
}
if (!loadedFrameKeys.has(currentFrameKey) && visibleFrameKey) {
return;
}
setVisibleFrameKey(currentFrameKey);
}, [currentFrameKey, loadedFrameKeys, visibleFrameKey]);
useEffect(() => {
if (!isPlaying || availableFrameCount < 2 || durationMs <= 0) {
return undefined;
}
const frameIntervalMs = Math.min(2_147_483_647, durationMs / frames.length);
const timer = window.setInterval(() => {
setFrameIndex(
(currentIndex) =>
findNextShowcaseFrameIndex(
frameItems,
failedFrameKeys,
currentIndex,
) ?? currentIndex,
);
}, frameIntervalMs);
return () => window.clearInterval(timer);
}, [
availableFrameCount,
durationMs,
failedFrameKeys,
frameItems,
frames.length,
isPlaying,
]);
if (!shouldMountSequence) {
return <ShowcaseImagePreview preview={preview} variant={variant} />;
}
const mediaClassName =
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media';
return (
<div
aria-label={`角色动作预览:${preview.label}`}
className={`${mediaClassName} creation-landing__image-sequence`}
>
{frameItems
.filter((item) => mountedFrameKeys.has(item.key))
.map((item) => (
<ShowcaseImageSequenceFrame
key={`${item.key}:${retryGeneration}`}
preview={preview}
frame={item.frame}
frameKey={item.key}
visible={item.key === visibleFrameKey}
loaded={loadedFrameKeys.has(item.key)}
retryGeneration={retryGeneration}
onReady={handleFrameReady}
onFailed={handleFrameFailed}
/>
))}
{variant === 'modal' ? (
<div className="creation-landing__image-sequence-controls">
<button
type="button"
aria-label={isPlaying ? '暂停角色动作' : '播放角色动作'}
onClick={() => setIsPlaying((current) => !current)}
>
{isPlaying ? (
<Pause size={16} aria-hidden="true" />
) : (
<Play size={16} aria-hidden="true" />
)}
</button>
<span>{`${currentFrameItem ? currentFrameItem.index + 1 : 0}/${frames.length}`}</span>
{failedFrameKeys.size ? (
<>
<span aria-live="polite">{`${failedFrameKeys.size} 帧加载失败`}</span>
<button type="button" onClick={retryFailedFrames}>
重试失败帧
</button>
</>
) : null}
</div>
) : availableFrameCount === 0 ? (
<span
aria-live="polite"
className="creation-landing__image-sequence-error"
>
角色动作加载失败,打开预览可重试
</span>
) : null}
</div>
);
}
function findNextShowcaseFrameIndex(
frameItems: ReadonlyArray<{ key: string }>,
failedFrameKeys: ReadonlySet<string>,
currentIndex: number,
) {
for (let offset = 1; offset <= frameItems.length; offset += 1) {
const nextIndex = (currentIndex + offset) % frameItems.length;
const item = frameItems[nextIndex];
if (item && !failedFrameKeys.has(item.key)) {
return nextIndex;
}
}
return null;
}
function ShowcaseImagePreview({
preview,
variant,
}: {
preview: ShowcaseAssetPreview;
variant: 'card' | 'modal' | 'thumb';
}) {
return (
<ResolvedAssetImage
src={preview.src}
objectKey={preview.objectKey}
alt={preview.label}
className={
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media'
}
/>
);
}
function ShowcaseVideoPreview({
preview,
controls,
variant,
}: {
preview: ShowcaseAssetPreview;
controls: boolean;
variant: 'card' | 'modal' | 'thumb';
}) {
return (
<ResolvedAssetVideo
src={preview.src}
objectKey={preview.objectKey}
className={
variant === 'modal'
? 'creation-landing__showcase-media'
: 'creation-landing__asset-preview-media'
}
controls={controls}
muted={!controls}
loop={!controls}
playsInline
/>
);
}
function ShowcaseBundlePreview({
item,
playSequences,
}: {
item: ShowcaseAssetItem;
playSequences: boolean;
}) {
const visiblePreviews = item.previews.slice(0, 4);
const primaryPreview = visiblePreviews[0];
const previewClassName = [
'creation-landing__asset-preview',
item.previews.length > 1 ? 'creation-landing__asset-preview--bundle' : '',
item.campaign ? 'creation-landing__asset-preview--campaign' : '',
]
.filter(Boolean)
.join(' ');
if (!primaryPreview) {
return null;
}
return (
<div
className={previewClassName}
style={{
aspectRatio: getPreviewAspectRatio(primaryPreview),
}}
>
{visiblePreviews.map((preview) => (
<span
key={preview.id}
className="creation-landing__asset-preview-tile"
aria-label={preview.label}
>
<ShowcasePreview preview={preview} sequencePlaying={playSequences} />
</span>
))}
{item.previews.length > visiblePreviews.length ? (
<span className="creation-landing__asset-preview-more">
+{item.previews.length - visiblePreviews.length}
</span>
) : null}
</div>
);
}
function CreationShowcaseModal({
item,
activeIndex,
playSequences,
onSelectIndex,
onClose,
}: {
item: ShowcaseAssetItem | null;
activeIndex: number;
playSequences: boolean;
onSelectIndex: (index: number) => void;
onClose: () => void;
}) {
const safeActiveIndex = item
? Math.min(Math.max(activeIndex, 0), item.previews.length - 1)
: 0;
const activePreview = item?.previews[safeActiveIndex] ?? null;
return (
<UnifiedModal
open={Boolean(item && activePreview)}
title={item?.label ?? '素材预览'}
ariaLabel={item?.label ?? '素材预览'}
onClose={onClose}
showHeader={false}
showCloseButton={false}
size="fullscreen"
// 素材舞台是独立黑底视觉;避免平台主题 remap 把它改成普通白底工具弹窗。
portalTheme="none"
zIndexClassName="z-[120]"
overlayClassName="creation-landing__showcase-modal-overlay"
panelClassName="creation-landing__showcase-modal-panel"
bodyClassName="creation-landing__showcase-modal-body"
closeOnBackdrop
>
{item && activePreview ? (
<div className="creation-landing__showcase-modal">
<PlatformModalCloseButton
label="关闭素材预览"
variant="floating"
className="creation-landing__showcase-modal-close"
onClick={onClose}
/>
<div className="creation-landing__showcase-modal-stage">
<div className="creation-landing__showcase-current">
<ShowcasePreview
preview={activePreview}
variant="modal"
sequencePlaying={playSequences}
/>
</div>
</div>
<div className="creation-landing__showcase-dock">
<div className="creation-landing__showcase-dock-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
<dl>
<div>
<dt>作者</dt>
<dd>{item.author}</dd>
</div>
<div>
<dt>生成总成本</dt>
<dd>{item.cost}</dd>
</div>
{typeof item.likeCount === 'number' ? (
<div>
<dt></dt>
<dd>{item.likeCount}</dd>
</div>
) : null}
</dl>
</div>
<div
className="creation-landing__showcase-thumbs"
aria-label="素材缩略图导航"
>
{item.previews.map((preview, index) => (
<button
key={preview.id}
type="button"
className={
index === safeActiveIndex
? 'creation-landing__showcase-thumb creation-landing__showcase-thumb--active'
: 'creation-landing__showcase-thumb'
}
aria-label={`查看${preview.label}`}
aria-current={index === safeActiveIndex ? 'true' : undefined}
onClick={() => onSelectIndex(index)}
>
<span className="creation-landing__showcase-thumb-media">
{preview.mediaType === 'image' ||
preview.mediaType === 'audio' ||
preview.mediaType === 'image-sequence' ? (
<ShowcasePreview preview={preview} variant="thumb" />
) : (
getPreviewIcon(preview)
)}
</span>
<span>{preview.label}</span>
</button>
))}
</div>
</div>
</div>
) : null}
</UnifiedModal>
);
}
export function CreationLandingView({
onOpenProject,
onOpenProjects,
onOpenCommunity,
searchKeyword = '',
}: CreationLandingViewProps) {
const prefersReducedMotion = usePrefersReducedMotion();
const authUi = useAuthUi();
const isAuthenticated = Boolean(authUi?.user);
const showcaseViewerUserId = authUi?.user?.id.trim() || null;
const canLoadShowcaseViewer =
!showcaseViewerUserId || Boolean(authUi?.canAccessProtectedData);
const showcaseViewerKey = showcaseViewerUserId
? `user:${showcaseViewerUserId}`
: 'anonymous';
const showcaseRequestScope = `${showcaseViewerKey}:${canLoadShowcaseViewer ? 'ready' : 'recovering'}`;
const normalizedSearchKeyword = searchKeyword.trim().toLocaleLowerCase();
const [recentProjects, setRecentProjects] = useState<EditorProjectSnapshot[]>(
[],
);
const [isLoadingProjects, setIsLoadingProjects] = useState(false);
const [isLoadingShowcase, setIsLoadingShowcase] = useState(false);
const [isLoadingMoreShowcase, setIsLoadingMoreShowcase] = useState(false);
const [isCreatingProject, setIsCreatingProject] = useState(false);
const [projectError, setProjectError] = useState<string | null>(null);
const [projectShowcaseResources, setProjectShowcaseResources] = useState<
EditorPublicShowcaseResourceSnapshot[]
>([]);
const [showcaseCampaign, setShowcaseCampaign] =
useState<EditorShowcaseCampaignSnapshot | null>(null);
const [showcaseNextCursor, setShowcaseNextCursor] = useState<string | null>(
null,
);
const [assetError, setAssetError] = useState<string | null>(null);
const [showcaseLoadMoreError, setShowcaseLoadMoreError] = useState<
string | null
>(null);
const [activeShowcaseTab, setActiveShowcaseTab] =
useState<ShowcaseTabId>('all');
const [selectedShowcaseItem, setSelectedShowcaseItem] =
useState<ShowcaseAssetItem | null>(null);
const [hoveredShowcaseSequenceCardId, setHoveredShowcaseSequenceCardId] =
useState<string | null>(null);
const [focusedShowcaseSequenceCardId, setFocusedShowcaseSequenceCardId] =
useState<string | null>(null);
const [selectedShowcaseIndex, setSelectedShowcaseIndex] = useState(0);
const [pendingLikeShowcaseIds, setPendingLikeShowcaseIds] = useState<
Set<string>
>(() => new Set());
const [showcaseLikeError, setShowcaseLikeError] = useState<string | null>(
null,
);
const [isPendingFeatureDialogOpen, setIsPendingFeatureDialogOpen] =
useState(false);
const showcaseLoadMoreRef = useRef<HTMLDivElement | null>(null);
const showcaseLoadingMoreCursorRef = useRef<string | null>(null);
const showcaseFailedLoadMoreCursorRef = useRef<string | null>(null);
const showcaseRequestStateRef = useRef({
scope: showcaseRequestScope,
generation: 0,
});
const refreshRecentProjects = useCallback(() => {
if (!isAuthenticated) {
setRecentProjects([]);
setProjectError(null);
return;
}
setIsLoadingProjects(true);
setProjectError(null);
listEditorProjects()
.then((projects) => {
const sortedProjects = [...projects].sort(
(left, right) =>
new Date(right.updatedAt).getTime() -
new Date(left.updatedAt).getTime(),
);
setRecentProjects(sortedProjects.slice(0, 4));
})
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(refreshRecentProjects);
return;
}
const message =
error instanceof Error ? error.message : '读取最近项目失败';
setProjectError(message);
})
.finally(() => setIsLoadingProjects(false));
}, [authUi, isAuthenticated]);
useEffect(() => {
refreshRecentProjects();
}, [refreshRecentProjects]);
useEffect(() => {
const requestState = {
scope: showcaseRequestScope,
generation: showcaseRequestStateRef.current.generation + 1,
};
showcaseRequestStateRef.current = requestState;
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation === requestState.generation;
const invalidateRequest = () => {
if (isCurrentRequest()) {
showcaseRequestStateRef.current = {
scope: requestState.scope,
generation: requestState.generation + 1,
};
}
};
setIsLoadingShowcase(true);
setIsLoadingMoreShowcase(false);
showcaseLoadingMoreCursorRef.current = null;
showcaseFailedLoadMoreCursorRef.current = null;
setProjectShowcaseResources([]);
setShowcaseCampaign(null);
setShowcaseNextCursor(null);
setAssetError(null);
setShowcaseLoadMoreError(null);
setShowcaseLikeError(null);
setPendingLikeShowcaseIds(new Set());
setSelectedShowcaseItem(null);
if (!canLoadShowcaseViewer) {
return invalidateRequest;
}
listPublicEditorProjectResources({
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
})
.then((page) => {
if (!isCurrentRequest()) {
return;
}
const normalizedPage = normalizeShowcaseResourcePage(page);
setProjectShowcaseResources(normalizedPage.resources);
setShowcaseCampaign(normalizedPage.campaign ?? null);
setShowcaseNextCursor(normalizedPage.nextCursor);
})
.catch((error: unknown) => {
if (!isCurrentRequest()) {
return;
}
setProjectShowcaseResources([]);
setShowcaseCampaign(null);
setShowcaseNextCursor(null);
setShowcaseLoadMoreError(null);
setAssetError(
error instanceof Error ? error.message : '读取陶泥儿精选失败',
);
})
.finally(() => {
if (isCurrentRequest()) {
setIsLoadingShowcase(false);
}
});
return invalidateRequest;
}, [canLoadShowcaseViewer, showcaseRequestScope, showcaseViewerUserId]);
const loadMoreShowcaseResources = useCallback(
(options: { force?: boolean } = {}) => {
const cursor = showcaseNextCursor;
if (!cursor || isLoadingShowcase || isLoadingMoreShowcase) {
return;
}
if (showcaseLoadingMoreCursorRef.current === cursor) {
return;
}
if (
!options.force &&
showcaseFailedLoadMoreCursorRef.current === cursor
) {
return;
}
showcaseLoadingMoreCursorRef.current = cursor;
const requestState = { ...showcaseRequestStateRef.current };
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation === requestState.generation;
setIsLoadingMoreShowcase(true);
setShowcaseLoadMoreError(null);
listPublicEditorProjectResources({
cursor,
viewer: showcaseViewerUserId ? 'authenticated' : 'anonymous',
})
.then((page) => {
if (!isCurrentRequest()) {
return;
}
const normalizedPage = normalizeShowcaseResourcePage(page);
setProjectShowcaseResources((current) =>
mergeShowcaseResources(current, normalizedPage.resources),
);
setShowcaseCampaign((current) => normalizedPage.campaign ?? current);
setShowcaseNextCursor(normalizedPage.nextCursor);
showcaseFailedLoadMoreCursorRef.current = null;
})
.catch((error: unknown) => {
if (!isCurrentRequest()) {
return;
}
showcaseFailedLoadMoreCursorRef.current = cursor;
setShowcaseLoadMoreError(
error instanceof Error ? error.message : '读取陶泥儿精选失败',
);
})
.finally(() => {
if (!isCurrentRequest()) {
return;
}
if (showcaseLoadingMoreCursorRef.current === cursor) {
showcaseLoadingMoreCursorRef.current = null;
}
setIsLoadingMoreShowcase(false);
});
},
[
isLoadingMoreShowcase,
isLoadingShowcase,
showcaseNextCursor,
showcaseViewerUserId,
],
);
useEffect(() => {
const element = showcaseLoadMoreRef.current;
if (
!element ||
!showcaseNextCursor ||
isLoadingShowcase ||
isLoadingMoreShowcase
) {
return;
}
if (showcaseFailedLoadMoreCursorRef.current === showcaseNextCursor) {
return;
}
if (typeof window === 'undefined' || !('IntersectionObserver' in window)) {
loadMoreShowcaseResources();
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadMoreShowcaseResources();
}
},
{ rootMargin: '520px 0px', threshold: 0 },
);
observer.observe(element);
return () => observer.disconnect();
}, [
isLoadingMoreShowcase,
isLoadingShowcase,
loadMoreShowcaseResources,
showcaseNextCursor,
]);
const createProject = useCallback(
(tool?: CreationFeatureTool) => {
if (authUi && !authUi.user) {
authUi.openLoginModal(() => createProject(tool));
return;
}
setIsCreatingProject(true);
setProjectError(null);
createEditorProject()
.then((project) =>
onOpenProject(project.projectId, tool ? { tool } : { guide: true }),
)
.catch((error: unknown) => {
if (isUnauthorizedError(error)) {
authUi?.openLoginModal(() => createProject(tool));
return;
}
setProjectError(
error instanceof Error ? error.message : '创建项目失败',
);
})
.finally(() => setIsCreatingProject(false));
},
[authUi, onOpenProject],
);
const handleFeatureClick = useCallback(
(feature: CreationFeatureItem) => {
if (feature.action.kind === 'pending') {
setIsPendingFeatureDialogOpen(true);
return;
}
createProject(feature.action.tool);
},
[createProject],
);
const updateShowcaseViewerState = useCallback(
(showcaseId: string, likeCount: number, viewerLiked: boolean) => {
setProjectShowcaseResources((current) =>
current.map((resource) =>
resource.showcaseId === showcaseId ||
resource.resourceId === showcaseId
? { ...resource, likeCount, viewerLiked }
: resource,
),
);
setSelectedShowcaseItem((current) =>
current?.showcaseId === showcaseId
? { ...current, likeCount, viewerLiked }
: current,
);
},
[],
);
const handleToggleShowcaseLike = useCallback(
(item: ShowcaseAssetItem) => {
const showcaseId = item.showcaseId?.trim();
if (!showcaseId) {
return;
}
if (!authUi?.user) {
authUi?.openLoginModal();
return;
}
if (pendingLikeShowcaseIds.has(showcaseId)) {
return;
}
const nextLiked = item.viewerLiked !== true;
const requestState = { ...showcaseRequestStateRef.current };
const requestViewerKey = showcaseViewerKey;
const isCurrentRequest = () =>
showcaseRequestStateRef.current.scope === requestState.scope &&
showcaseRequestStateRef.current.generation ===
requestState.generation &&
requestViewerKey === showcaseViewerKey;
setShowcaseLikeError(null);
setPendingLikeShowcaseIds((current) => new Set(current).add(showcaseId));
toggleEditorShowcaseAssetLike(showcaseId, nextLiked)
.then((record) => {
if (!isCurrentRequest()) {
return;
}
updateShowcaseViewerState(
showcaseId,
record.likeCount ?? item.likeCount ?? 0,
record.viewerLiked,
);
})
.catch(() => {
if (isCurrentRequest()) {
setShowcaseLikeError(
nextLiked ? '点赞失败,请重试' : '取消点赞失败,请重试',
);
}
})
.finally(() => {
if (!isCurrentRequest()) {
return;
}
setPendingLikeShowcaseIds((current) => {
const next = new Set(current);
next.delete(showcaseId);
return next;
});
});
},
[
authUi,
pendingLikeShowcaseIds,
showcaseViewerKey,
updateShowcaseViewerState,
],
);
const showcaseItems = useMemo(
() =>
buildCreationShowcaseItems({
activeTab: activeShowcaseTab,
projectResources: projectShowcaseResources,
campaign: showcaseCampaign,
fallbackAuthorName: null,
}),
[activeShowcaseTab, projectShowcaseResources, showcaseCampaign],
);
const visibleShowcaseItems = useMemo(() => {
if (!normalizedSearchKeyword) {
return showcaseItems;
}
return showcaseItems.filter((item) =>
[item.label, item.author, item.prompt].some((value) =>
value.toLocaleLowerCase().includes(normalizedSearchKeyword),
),
);
}, [normalizedSearchKeyword, showcaseItems]);
const showcaseMasonryOrderKey = JSON.stringify(
visibleShowcaseItems.map((item) => item.id),
);
const showcaseMasonryRef = useSequentialShowcaseMasonry(
showcaseMasonryOrderKey,
visibleShowcaseItems.length,
);
const visibleRecentProjects = useMemo(() => {
if (!normalizedSearchKeyword) {
return recentProjects;
}
return recentProjects.filter((project) =>
project.title.toLocaleLowerCase().includes(normalizedSearchKeyword),
);
}, [normalizedSearchKeyword, recentProjects]);
const isShowcaseLoading = isLoadingShowcase;
const showcaseEmptyText = '暂无素材';
const renderShowcaseLoadMoreSentinel = () =>
showcaseNextCursor || isLoadingMoreShowcase || showcaseLoadMoreError ? (
<div
ref={showcaseLoadMoreRef}
className="creation-landing__asset-load-more"
aria-live="polite"
>
{isLoadingMoreShowcase ? '正在读取更多素材' : null}
{!isLoadingMoreShowcase && showcaseLoadMoreError ? (
<button
type="button"
className="creation-landing__asset-load-more-button"
onClick={() => loadMoreShowcaseResources({ force: true })}
>
读取更多素材失败,点击重试
</button>
) : null}
</div>
) : null;
const renderAssetShowcase = () => {
if (assetError) {
return (
<PlatformStatusMessage tone="error" surface="platform" role="alert">
{assetError}
</PlatformStatusMessage>
);
}
if (isShowcaseLoading) {
return (
<PlatformEmptyState surface="subpanel" size="inline">
正在读取素材
</PlatformEmptyState>
);
}
if (visibleShowcaseItems.length === 0) {
return (
<>
<PlatformEmptyState surface="subpanel" size="inline">
{showcaseNextCursor || isLoadingMoreShowcase
? '正在读取更多素材'
: normalizedSearchKeyword
? '没有匹配素材'
: showcaseEmptyText}
</PlatformEmptyState>
{renderShowcaseLoadMoreSentinel()}
</>
);
}
return (
<>
{showcaseLikeError ? (
<PlatformStatusMessage tone="error" surface="platform" role="alert">
{showcaseLikeError}
</PlatformStatusMessage>
) : null}
<div
ref={showcaseMasonryRef}
className="creation-landing__asset-waterfall"
aria-label="陶泥儿精选素材列表"
role="list"
>
{visibleShowcaseItems.map((item) => {
const showcaseId = item.showcaseId?.trim();
const isLiked = item.viewerLiked === true;
const isLikePending = Boolean(
showcaseId && pendingLikeShowcaseIds.has(showcaseId),
);
return (
<article
key={item.id}
className="creation-landing__asset-card"
role="listitem"
>
<button
type="button"
className="creation-landing__asset-card-open"
onPointerEnter={() =>
setHoveredShowcaseSequenceCardId(item.id)
}
onPointerLeave={() =>
setHoveredShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onFocus={() => setFocusedShowcaseSequenceCardId(item.id)}
onBlur={() =>
setFocusedShowcaseSequenceCardId((current) =>
current === item.id ? null : current,
)
}
onClick={() => {
setSelectedShowcaseItem(item);
setSelectedShowcaseIndex(0);
}}
>
<ShowcaseBundlePreview
item={item}
playSequences={
!prefersReducedMotion &&
(hoveredShowcaseSequenceCardId === item.id ||
focusedShowcaseSequenceCardId === item.id) &&
!selectedShowcaseItem
}
/>
<div className="creation-landing__asset-meta">
<h3>{item.label}</h3>
<p>{item.prompt}</p>
<dl>
<div>
<dt>作者</dt>
<dd>{item.author}</dd>
</div>
<div>
<dt>生成总成本</dt>
<dd>{item.cost}</dd>
</div>
</dl>
</div>
</button>
{showcaseId ? (
<button
type="button"
className={
isLiked
? 'creation-landing__asset-like-button creation-landing__asset-like-button--active'
: 'creation-landing__asset-like-button'
}
aria-label={`${isLiked ? '取消点赞' : '点赞'}${item.label}`}
aria-busy={isLikePending}
disabled={isLikePending}
onClick={() => handleToggleShowcaseLike(item)}
>
<img
src={SHOWCASE_LIKE_ICON_SRC}
alt=""
aria-hidden="true"
/>
<span>{item.likeCount ?? 0}</span>
</button>
) : null}
</article>
);
})}
</div>
{renderShowcaseLoadMoreSentinel()}
</>
);
};
return (
<main className="creation-landing" aria-label="陶泥儿创作主页">
<section className="creation-landing__hero">
<div className="creation-landing__hero-copy">
<span className="creation-landing__eyebrow">
陶泥儿 Genarrative|游戏美术 AI 创作工具
</span>
<h1>陶泥儿 · 开启全民精品游戏创作</h1>
<div className="creation-landing__hero-detail">
<p className="creation-landing__hero-subtitle">
面向个人创作者的游戏美术 AI 工作台。
<br />
用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。
</p>
<p className="creation-landing__hero-benefit">
登录即送 100 泥点,可以免费制作 50 个素材
</p>
</div>
<div className="creation-landing__hero-actions">
<PlatformActionButton
size="md"
shape="pill"
className="creation-landing__hero-action creation-landing__hero-action--primary"
onClick={() => createProject()}
disabled={isCreatingProject}
>
<img
src={CREATION_HOME_ASSETS.heroCreate}
alt=""
aria-hidden="true"
draggable={false}
className="creation-landing__button-art"
/>
开始创作
</PlatformActionButton>
{onOpenCommunity ? (
<PlatformActionButton
tone="secondary"
size="md"
shape="pill"
className="creation-landing__hero-action creation-landing__hero-action--secondary"
onClick={onOpenCommunity}
>
<img
src={CREATION_HOME_ASSETS.heroCommunity}
alt=""
aria-hidden="true"
draggable={false}
className="creation-landing__button-art"
/>
玩家社区
</PlatformActionButton>
) : null}
</div>
</div>
<div className="creation-landing__hero-board" aria-hidden="true">
<span className="creation-landing__hero-tile creation-landing__hero-tile--main" />
<span className="creation-landing__hero-tile creation-landing__hero-tile--side" />
<span className="creation-landing__hero-tile creation-landing__hero-tile--small" />
</div>
</section>
<section className="creation-landing__section">
<div className="creation-landing__section-header">
<div>
<h2>游戏美术 AI 创作工具</h2>
</div>
</div>
<div className="creation-landing__feature-grid">
{CREATION_FEATURES.map((feature) => (
<button
key={feature.title}
type="button"
className="creation-landing__feature"
onClick={() => handleFeatureClick(feature)}
disabled={isCreatingProject && feature.action.kind === 'tool'}
>
<span className="creation-landing__feature-figure">
<span className="creation-landing__feature-icon">
<img
src={feature.iconSrc}
alt=""
aria-hidden="true"
loading="lazy"
draggable={false}
className="creation-landing__feature-art"
/>
</span>
</span>
<span className="creation-landing__feature-copy">
<h3>{feature.title}</h3>
<p>{feature.description}</p>
</span>
</button>
))}
</div>
</section>
{isAuthenticated ? (
<section className="creation-landing__section">
<div className="creation-landing__section-header">
<div>
<h2>最近项目</h2>
</div>
<button
type="button"
className="creation-landing__link-button"
onClick={onOpenProjects}
>
查看全部
</button>
</div>
{projectError ? (
<PlatformStatusMessage tone="error" surface="platform" role="alert">
{projectError}
</PlatformStatusMessage>
) : null}
<div className="creation-landing__project-row">
<button
type="button"
className="creation-landing__project-card creation-landing__project-card--new"
onClick={() => createProject()}
disabled={isCreatingProject}
>
<span>
<img
src={CREATION_HOME_ASSETS.projectNew}
alt=""
aria-hidden="true"
draggable={false}
className="creation-landing__project-new-art"
/>
</span>
新建项目
</button>
{isLoadingProjects ? (
<PlatformEmptyState surface="subpanel" size="inline">
正在读取项目
</PlatformEmptyState>
) : (
visibleRecentProjects.map((project) => {
const coverSnapshot =
resolveProjectCoverSnapshotResource(project);
return (
<button
key={project.projectId}
type="button"
className="creation-landing__project-card"
onClick={() => onOpenProject(project.projectId)}
>
<PlatformMediaFrame
src={coverSnapshot?.imageSrc ?? null}
objectKey={coverSnapshot?.objectKey ?? null}
alt=""
fallbackLabel="项目"
aspect="standard"
surface="bright"
refreshKey={
coverSnapshot?.updatedAt ??
coverSnapshot?.createdAt ??
coverSnapshot?.resourceId ??
null
}
className="creation-landing__project-cover"
/>
<span className="creation-landing__project-meta">
<strong>{project.title}</strong>
<small>{formatProjectUpdatedAt(project.updatedAt)}</small>
</span>
</button>
);
})
)}
</div>
</section>
) : null}
<section className="creation-landing__section">
<div className="creation-landing__section-header">
<div>
<h2>陶泥儿精选</h2>
</div>
</div>
<div
className="creation-landing__tabs"
role="tablist"
aria-label="用户素材分类"
>
{SHOWCASE_TABS.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeShowcaseTab === tab.id}
className={
activeShowcaseTab === tab.id
? 'creation-landing__tab creation-landing__tab--active'
: 'creation-landing__tab'
}
onClick={() => setActiveShowcaseTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{renderAssetShowcase()}
</section>
<CreationShowcaseModal
item={selectedShowcaseItem}
activeIndex={selectedShowcaseIndex}
playSequences={!prefersReducedMotion}
onSelectIndex={setSelectedShowcaseIndex}
onClose={() => setSelectedShowcaseItem(null)}
/>
<UnifiedModal
open={isPendingFeatureDialogOpen}
title="抱歉"
onClose={() => setIsPendingFeatureDialogOpen(false)}
showHeader={false}
showCloseButton={false}
size="sm"
portalTheme="light"
overlayClassName="platform-mobile-home-welcome-overlay !items-center !p-4"
panelClassName="platform-remap-surface platform-mobile-home-welcome-dialog"
bodyClassName="platform-mobile-home-welcome-dialog__body"
footerClassName="platform-mobile-home-welcome-dialog__footer"
footer={
<PlatformActionButton
onClick={() => setIsPendingFeatureDialogOpen(false)}
size="md"
shape="pill"
fullWidth
className="min-h-11"
>
期待一下
</PlatformActionButton>
}
>
<img
src="/branding/taonier-product-ip.png"
alt=""
className="platform-mobile-home-welcome-dialog__icon"
/>
<h2 className="platform-mobile-home-welcome-dialog__title">抱歉</h2>
<p className="platform-mobile-home-welcome-dialog__copy">
功能还在调试中
<br />
暂未开放
</p>
</UnifiedModal>
</main>
);
}
export default CreationLandingView;