307 lines
10 KiB
TypeScript
307 lines
10 KiB
TypeScript
import { X } from 'lucide-react';
|
||
import { type ReactNode, useState } from 'react';
|
||
import { createPortal } from 'react-dom';
|
||
|
||
import { resolveCustomWorldCoverPresentation } from '../../services/customWorldCover';
|
||
import type { CustomWorldProfile } from '../../types';
|
||
import { useAuthUi } from '../auth/AuthUiContext';
|
||
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
|
||
|
||
function SmallButton({
|
||
children,
|
||
disabled = false,
|
||
onClick,
|
||
tone = 'default',
|
||
}: {
|
||
children: ReactNode;
|
||
disabled?: boolean;
|
||
onClick: () => void;
|
||
tone?: 'default' | 'sky';
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className={`${
|
||
tone === 'sky'
|
||
? 'platform-button platform-button--primary'
|
||
: 'platform-button platform-button--ghost'
|
||
} min-h-0 rounded-full px-3 py-2 text-sm ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PublishPanelDialog({
|
||
blockers,
|
||
profile,
|
||
publishReady,
|
||
isPublishing,
|
||
onClose,
|
||
onEditCover,
|
||
onPublish,
|
||
}: {
|
||
blockers: string[];
|
||
profile: CustomWorldProfile;
|
||
publishReady: boolean;
|
||
isPublishing: boolean;
|
||
onClose: () => void;
|
||
onEditCover: () => void;
|
||
onPublish: () => void;
|
||
}) {
|
||
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
|
||
const coverPresentation = resolveCustomWorldCoverPresentation(profile);
|
||
|
||
if (typeof document === 'undefined') {
|
||
return null;
|
||
}
|
||
|
||
return createPortal(
|
||
<div
|
||
className={`platform-theme platform-theme--${platformTheme} platform-overlay fixed inset-0 z-[140] flex items-end justify-center p-3 backdrop-blur-sm sm:items-center sm:p-4`}
|
||
onClick={(event) => {
|
||
if (event.target === event.currentTarget) {
|
||
onClose();
|
||
}
|
||
}}
|
||
>
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label="发布作品"
|
||
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,46rem)] w-full max-w-4xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||
<div>
|
||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||
发布作品
|
||
</div>
|
||
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
|
||
发布前检查与封面设置
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
aria-label="关闭"
|
||
className="platform-icon-button"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.78fr)]">
|
||
<div className="space-y-3">
|
||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
发布检查
|
||
</div>
|
||
{blockers.length > 0 ? (
|
||
<div className="space-y-2">
|
||
{blockers.map((blocker, index) => (
|
||
<div
|
||
key={`publish-blocker-${index}-${blocker}`}
|
||
className="platform-banner platform-banner--warning text-sm leading-6"
|
||
>
|
||
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
|
||
阻断项 {index + 1}
|
||
</div>
|
||
<div className="mt-1 text-[var(--platform-text-strong)]">
|
||
{blocker}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="platform-banner platform-banner--success text-sm leading-6">
|
||
当前作品已满足发布条件。
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
|
||
封面设置
|
||
</div>
|
||
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||
{coverPresentation.sourceType === 'uploaded'
|
||
? '上传封面'
|
||
: coverPresentation.sourceType === 'generated'
|
||
? 'AI封面'
|
||
: '默认封面'}
|
||
</span>
|
||
</div>
|
||
<div className="platform-subpanel rounded-[1.25rem] p-2">
|
||
<CustomWorldCoverArtwork
|
||
imageSrc={coverPresentation.imageSrc}
|
||
title={profile.name}
|
||
fallbackLabel={profile.name.slice(0, 4) || '封面'}
|
||
renderMode={coverPresentation.renderMode}
|
||
characterImageSrcs={coverPresentation.characterImageSrcs}
|
||
className="aspect-[16/9] max-h-[15rem] rounded-[1rem]"
|
||
/>
|
||
</div>
|
||
<SmallButton onClick={onEditCover} tone="sky">
|
||
设置封面
|
||
</SmallButton>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
|
||
<SmallButton onClick={onClose}>取消</SmallButton>
|
||
<button
|
||
type="button"
|
||
onClick={onPublish}
|
||
disabled={!publishReady || isPublishing}
|
||
className={`platform-button platform-button--primary ${!publishReady || isPublishing ? 'cursor-not-allowed opacity-55' : ''}`}
|
||
>
|
||
{isPublishing ? '发布中...' : '发布到广场'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>,
|
||
document.body,
|
||
);
|
||
}
|
||
|
||
interface RpgCreationResultActionBarProps {
|
||
editActionLabel: string;
|
||
enterWorldActionLabel: string;
|
||
isGenerating: boolean;
|
||
onContinueExpand?: () => void;
|
||
onEditSetting?: () => void;
|
||
onEnterWorld?: () => void;
|
||
onOpenCoverEditor?: () => void;
|
||
onPublishWorld?: () => Promise<void> | void;
|
||
onTestWorld?: () => void;
|
||
onRegenerate?: () => void;
|
||
profile: CustomWorldProfile;
|
||
regenerateActionLabel: string;
|
||
publishReady: boolean;
|
||
publishBlockers: string[];
|
||
}
|
||
|
||
export function RpgCreationResultActionBar({
|
||
editActionLabel,
|
||
enterWorldActionLabel,
|
||
isGenerating,
|
||
onContinueExpand,
|
||
onEditSetting,
|
||
onEnterWorld,
|
||
onOpenCoverEditor,
|
||
onPublishWorld,
|
||
onTestWorld,
|
||
onRegenerate,
|
||
profile,
|
||
regenerateActionLabel,
|
||
publishReady,
|
||
publishBlockers,
|
||
}: RpgCreationResultActionBarProps) {
|
||
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
|
||
useState(false);
|
||
const [isPublishing, setIsPublishing] = useState(false);
|
||
|
||
// 结果页只在用户点击发布动作时展示阻断项,不做吸底常驻提示。
|
||
const handleEnterWorld = () => {
|
||
if (!publishReady) {
|
||
setShowPublishBlockersDialog(true);
|
||
return;
|
||
}
|
||
|
||
onEnterWorld?.();
|
||
};
|
||
|
||
const handlePublish = async () => {
|
||
if (!publishReady || isPublishing || !onPublishWorld) {
|
||
return;
|
||
}
|
||
|
||
setIsPublishing(true);
|
||
try {
|
||
await onPublishWorld();
|
||
setShowPublishBlockersDialog(false);
|
||
} finally {
|
||
setIsPublishing(false);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="mt-4 flex flex-col gap-3">
|
||
{profile.generationStatus === 'key_only' ? (
|
||
<div className="platform-banner platform-banner--warning rounded-2xl text-sm leading-6">
|
||
当前世界处于快速预览模式,只生成了关键对象。继续补全后,系统会生成长尾场景角色与完整场景网络。
|
||
</div>
|
||
) : null}
|
||
<div className="flex items-center justify-end gap-3">
|
||
{onEditSetting ? (
|
||
<SmallButton onClick={onEditSetting}>{editActionLabel}</SmallButton>
|
||
) : null}
|
||
{onRegenerate ? (
|
||
<SmallButton onClick={onRegenerate} tone="sky">
|
||
{regenerateActionLabel}
|
||
</SmallButton>
|
||
) : null}
|
||
{profile.generationStatus === 'key_only' && onContinueExpand ? (
|
||
<SmallButton
|
||
onClick={onContinueExpand}
|
||
tone="sky"
|
||
disabled={isGenerating}
|
||
>
|
||
继续补全世界
|
||
</SmallButton>
|
||
) : null}
|
||
{onTestWorld ? (
|
||
<button
|
||
type="button"
|
||
onClick={onTestWorld}
|
||
disabled={isGenerating}
|
||
className={`platform-button platform-button--secondary ${isGenerating ? 'opacity-55' : ''}`}
|
||
>
|
||
作品测试
|
||
</button>
|
||
) : null}
|
||
{onPublishWorld ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowPublishBlockersDialog(true)}
|
||
disabled={isGenerating}
|
||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||
>
|
||
发布
|
||
</button>
|
||
) : onEnterWorld ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleEnterWorld}
|
||
disabled={isGenerating}
|
||
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
|
||
>
|
||
{enterWorldActionLabel}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
{showPublishBlockersDialog ? (
|
||
<PublishPanelDialog
|
||
blockers={publishBlockers}
|
||
profile={profile}
|
||
publishReady={publishReady}
|
||
isPublishing={isPublishing}
|
||
onClose={() => setShowPublishBlockersDialog(false)}
|
||
onEditCover={() => {
|
||
setShowPublishBlockersDialog(false);
|
||
onOpenCoverEditor?.();
|
||
}}
|
||
onPublish={() => {
|
||
void handlePublish();
|
||
}}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default RpgCreationResultActionBar;
|