Files
Genarrative/src/components/rpg-creation-result/RpgCreationResultActionBar.tsx
2026-04-24 22:25:13 +08:00

307 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 { 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;