This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import type { CustomWorldProfile } from '../../types';
|
||||
import { resolveAssetReadUrl } from '../../services/assetReadUrlService';
|
||||
|
||||
type RpgCreationAssetDebugEntry = {
|
||||
id: string;
|
||||
kind: 'playable' | 'story' | 'landmark' | 'scene-act';
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
readUrl?: string;
|
||||
};
|
||||
|
||||
type AssetDebugLoadStatus = 'loading' | 'loaded' | 'error';
|
||||
|
||||
const RPG_CREATION_ASSET_DEBUG_QUERY_KEY = 'debugCustomWorldAssets';
|
||||
const RPG_CREATION_ASSET_DEBUG_STORAGE_KEY =
|
||||
'genarrative.debug.customWorldAssets';
|
||||
|
||||
function isPresent<T>(value: T | null): value is T {
|
||||
return value !== null;
|
||||
}
|
||||
|
||||
export function shouldEnableRpgCreationAssetDebugPanel() {
|
||||
if (!import.meta.env.DEV || typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
if (searchParams.get(RPG_CREATION_ASSET_DEBUG_QUERY_KEY) === '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
window.localStorage.getItem(RPG_CREATION_ASSET_DEBUG_STORAGE_KEY) === '1'
|
||||
);
|
||||
}
|
||||
|
||||
function collectRpgCreationAssetDebugEntries(
|
||||
profile: CustomWorldProfile,
|
||||
): RpgCreationAssetDebugEntry[] {
|
||||
const playableEntries = profile.playableNpcs
|
||||
.map((role) => {
|
||||
const imageSrc = role.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `playable:${role.id}`,
|
||||
label: `${role.name}主形象`,
|
||||
imageSrc,
|
||||
kind: 'playable' as const,
|
||||
};
|
||||
})
|
||||
.filter(isPresent);
|
||||
const storyEntries = profile.storyNpcs
|
||||
.map((role) => {
|
||||
const imageSrc = role.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `story:${role.id}`,
|
||||
label: `${role.name}场景角色主图`,
|
||||
imageSrc,
|
||||
kind: 'story' as const,
|
||||
};
|
||||
})
|
||||
.filter(isPresent);
|
||||
const landmarkEntries = profile.landmarks
|
||||
.map((landmark) => {
|
||||
const imageSrc = landmark.imageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `landmark:${landmark.id}`,
|
||||
label: `${landmark.name}场景主图`,
|
||||
imageSrc,
|
||||
kind: 'landmark' as const,
|
||||
};
|
||||
})
|
||||
.filter(isPresent);
|
||||
const sceneActEntries =
|
||||
profile.sceneChapterBlueprints?.flatMap((chapter) =>
|
||||
chapter.acts
|
||||
.map((act) => {
|
||||
const imageSrc = act.backgroundImageSrc?.trim() || '';
|
||||
if (!imageSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: `scene-act:${chapter.id}:${act.id}`,
|
||||
label: `${chapter.title || chapter.sceneId} / ${act.title}幕图`,
|
||||
imageSrc,
|
||||
kind: 'scene-act' as const,
|
||||
};
|
||||
})
|
||||
.filter(isPresent),
|
||||
) ?? [];
|
||||
|
||||
return [
|
||||
...playableEntries,
|
||||
...storyEntries,
|
||||
...landmarkEntries,
|
||||
...sceneActEntries,
|
||||
];
|
||||
}
|
||||
|
||||
function resolveAssetDebugStatusLabel(status: AssetDebugLoadStatus | undefined) {
|
||||
if (status === 'loaded') {
|
||||
return '已加载';
|
||||
}
|
||||
if (status === 'error') {
|
||||
return '加载失败';
|
||||
}
|
||||
return '检测中';
|
||||
}
|
||||
|
||||
function resolveAssetDebugSummary(profile: CustomWorldProfile) {
|
||||
return [
|
||||
{
|
||||
label: '可扮演角色主图',
|
||||
value: `${profile.playableNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.playableNpcs.length}`,
|
||||
},
|
||||
{
|
||||
label: '场景角色主图',
|
||||
value: `${profile.storyNpcs.filter((role) => Boolean(role.imageSrc?.trim())).length}/${profile.storyNpcs.length}`,
|
||||
},
|
||||
{
|
||||
label: '场景主图',
|
||||
value: `${profile.landmarks.filter((landmark) => Boolean(landmark.imageSrc?.trim())).length}/${profile.landmarks.length}`,
|
||||
},
|
||||
{
|
||||
label: '分幕图',
|
||||
value: `${profile.sceneChapterBlueprints?.reduce(
|
||||
(sum, chapter) =>
|
||||
sum +
|
||||
chapter.acts.filter((act) => Boolean(act.backgroundImageSrc?.trim()))
|
||||
.length,
|
||||
0,
|
||||
) ?? 0}/${
|
||||
profile.sceneChapterBlueprints?.reduce(
|
||||
(sum, chapter) => sum + chapter.acts.length,
|
||||
0,
|
||||
) ?? 0
|
||||
}`,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function RpgCreationAssetDebugPanel({
|
||||
profile,
|
||||
}: {
|
||||
profile: CustomWorldProfile;
|
||||
}) {
|
||||
const assetDebugEntries = useMemo(
|
||||
() => collectRpgCreationAssetDebugEntries(profile),
|
||||
[profile],
|
||||
);
|
||||
const assetDebugSummary = useMemo(
|
||||
() => resolveAssetDebugSummary(profile),
|
||||
[profile],
|
||||
);
|
||||
const [assetDebugStatusMap, setAssetDebugStatusMap] = useState<
|
||||
Record<string, AssetDebugLoadStatus>
|
||||
>({});
|
||||
const [assetDebugReadUrlMap, setAssetDebugReadUrlMap] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (assetDebugEntries.length === 0) {
|
||||
setAssetDebugStatusMap({});
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const cleanupList: Array<() => void> = [];
|
||||
|
||||
setAssetDebugStatusMap(
|
||||
Object.fromEntries(
|
||||
assetDebugEntries.map((entry) => [entry.id, 'loading' as const]),
|
||||
),
|
||||
);
|
||||
|
||||
assetDebugEntries.forEach((entry) => {
|
||||
const image = new Image();
|
||||
const updateStatus = (status: AssetDebugLoadStatus) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugStatusMap((current) => {
|
||||
if (current[entry.id] === status) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[entry.id]: status,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
image.onload = () => updateStatus('loaded');
|
||||
image.onerror = () => updateStatus('error');
|
||||
void resolveAssetReadUrl(entry.imageSrc)
|
||||
.then((readUrl) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAssetDebugReadUrlMap((current) => ({
|
||||
...current,
|
||||
[entry.id]: readUrl,
|
||||
}));
|
||||
image.src = readUrl;
|
||||
})
|
||||
.catch(() => {
|
||||
image.src = entry.imageSrc;
|
||||
});
|
||||
cleanupList.push(() => {
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanupList.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [assetDebugEntries]);
|
||||
|
||||
return (
|
||||
<div className="platform-surface platform-surface--soft mt-3 px-3.5 py-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-bold tracking-[0.16em] text-white">
|
||||
资产诊断
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-500">
|
||||
仅开发模式显示,用来核对结果页当前拿到的图片字段和实际加载状态。
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{assetDebugEntries.length}项
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 xl:grid-cols-4">
|
||||
{assetDebugSummary.map((entry) => (
|
||||
<div
|
||||
key={entry.label}
|
||||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||||
>
|
||||
<div className="text-[11px] text-zinc-500">{entry.label}</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{entry.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2">
|
||||
{assetDebugEntries.length > 0 ? (
|
||||
assetDebugEntries.map((entry) => {
|
||||
const readUrl = assetDebugReadUrlMap[entry.id] || entry.imageSrc;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="platform-subpanel rounded-2xl px-3 py-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{entry.label}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-[11px] leading-5 text-zinc-400">
|
||||
{entry.imageSrc}
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
|
||||
{resolveAssetDebugStatusLabel(assetDebugStatusMap[entry.id])}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<a
|
||||
href={readUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`打开 ${entry.label}`}
|
||||
className="text-xs font-semibold text-amber-200 underline decoration-white/20 underline-offset-2"
|
||||
>
|
||||
打开原图
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="platform-subpanel rounded-2xl px-3 py-3 text-sm text-zinc-400">
|
||||
当前结果页 profile 里没有拿到任何可诊断的图片地址。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationAssetDebugPanel;
|
||||
@@ -0,0 +1,306 @@
|
||||
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;
|
||||
@@ -0,0 +1,59 @@
|
||||
interface RpgCreationResultHeaderProps {
|
||||
autoSaveState: 'idle' | 'saving' | 'saved' | 'error';
|
||||
backLabel: string;
|
||||
isGenerating: boolean;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
function renderAutoSaveBadge(
|
||||
autoSaveState: RpgCreationResultHeaderProps['autoSaveState'],
|
||||
) {
|
||||
if (autoSaveState === 'saved') {
|
||||
return (
|
||||
<div className="platform-pill platform-pill--success px-3 py-1 text-[11px]">
|
||||
已自动保存
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (autoSaveState === 'saving') {
|
||||
return (
|
||||
<div className="platform-pill platform-pill--warm px-3 py-1 text-[11px]">
|
||||
保存中
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (autoSaveState === 'error') {
|
||||
return (
|
||||
<div className="platform-pill platform-pill--rose px-3 py-1 text-[11px]">
|
||||
保存失败
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function RpgCreationResultHeader({
|
||||
autoSaveState,
|
||||
backLabel,
|
||||
isGenerating,
|
||||
onBack,
|
||||
}: RpgCreationResultHeaderProps) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`platform-button platform-button--ghost min-h-0 self-start px-3 py-1.5 text-[11px] ${isGenerating ? 'opacity-45' : ''}`}
|
||||
>
|
||||
{backLabel}
|
||||
</button>
|
||||
{renderAutoSaveBadge(autoSaveState)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default RpgCreationResultHeader;
|
||||
17
src/components/rpg-creation-result/RpgCreationResultView.tsx
Normal file
17
src/components/rpg-creation-result/RpgCreationResultView.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
import { RpgCreationResultView as RpgCreationResultViewImpl } from './RpgCreationResultViewImpl';
|
||||
|
||||
/**
|
||||
* 工作包 C 完成后,结果页入口统一桥接 RPG 创作目录下的真实实现。
|
||||
* 旧 `CustomWorldResultView.tsx` 兼容入口已经删除,后续结果页细化继续在该目录内部推进。
|
||||
*/
|
||||
export type RpgCreationResultViewProps = ComponentProps<
|
||||
typeof RpgCreationResultViewImpl
|
||||
>;
|
||||
|
||||
export function RpgCreationResultView(props: RpgCreationResultViewProps) {
|
||||
return <RpgCreationResultViewImpl {...props} />;
|
||||
}
|
||||
|
||||
export default RpgCreationResultView;
|
||||
246
src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx
Normal file
246
src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Character, CustomWorldProfile } from '../../types';
|
||||
import {
|
||||
CustomWorldEntityCatalog,
|
||||
type ResultTab,
|
||||
} from '../CustomWorldEntityCatalog';
|
||||
import RpgCreationEntityEditorModal from '../rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
import RpgCreationAssetDebugPanel, {
|
||||
shouldEnableRpgCreationAssetDebugPanel,
|
||||
} from './RpgCreationAssetDebugPanel';
|
||||
import RpgCreationResultActionBar from './RpgCreationResultActionBar';
|
||||
import RpgCreationResultHeader from './RpgCreationResultHeader';
|
||||
import { useRpgCreationResultActions } from './useRpgCreationResultActions';
|
||||
import type { EntityGenerationKind } from './useRpgCreationResultActions';
|
||||
|
||||
export interface RpgCreationResultViewProps {
|
||||
profile: CustomWorldProfile;
|
||||
previewCharacters: Character[];
|
||||
isGenerating: boolean;
|
||||
progress: number;
|
||||
progressLabel: string;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting?: () => void;
|
||||
onRegenerate?: () => void;
|
||||
onContinueExpand?: () => void;
|
||||
onEnterWorld?: () => void;
|
||||
onOpenCoverEditor?: () => void;
|
||||
onPublishWorld?: () => Promise<void> | void;
|
||||
onTestWorld?: () => void;
|
||||
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
|
||||
onGenerateEntity?:
|
||||
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
|
||||
| undefined;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
readOnly?: boolean;
|
||||
backLabel?: string;
|
||||
editActionLabel?: string;
|
||||
regenerateActionLabel?: string;
|
||||
enterWorldActionLabel?: string;
|
||||
autoSaveState?: 'idle' | 'saving' | 'saved' | 'error';
|
||||
compactAgentResultMode?: boolean;
|
||||
publishReady?: boolean;
|
||||
publishBlockers?: string[];
|
||||
qualityFindings?: Array<{
|
||||
id: string;
|
||||
severity: 'info' | 'warning' | 'blocker';
|
||||
code: string;
|
||||
targetId?: string | null;
|
||||
message: string;
|
||||
}>;
|
||||
previewSourceLabel?: string | null;
|
||||
}
|
||||
|
||||
export function RpgCreationResultView({
|
||||
profile,
|
||||
previewCharacters,
|
||||
isGenerating,
|
||||
progress,
|
||||
progressLabel,
|
||||
error,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRegenerate: triggerRegenerate,
|
||||
onContinueExpand,
|
||||
onOpenCoverEditor,
|
||||
onPublishWorld,
|
||||
onTestWorld,
|
||||
onDeleteEntities,
|
||||
onEnterWorld,
|
||||
onGenerateEntity,
|
||||
onProfileChange,
|
||||
readOnly = false,
|
||||
backLabel = '返回',
|
||||
editActionLabel = '修改设定',
|
||||
regenerateActionLabel = '重新生成',
|
||||
enterWorldActionLabel = '进入世界',
|
||||
autoSaveState = 'idle',
|
||||
compactAgentResultMode = false,
|
||||
publishReady = true,
|
||||
publishBlockers = [],
|
||||
qualityFindings = [],
|
||||
}: RpgCreationResultViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<ResultTab>('world');
|
||||
const assetDebugEnabled = useMemo(
|
||||
() => shouldEnableRpgCreationAssetDebugPanel(),
|
||||
[],
|
||||
);
|
||||
const {
|
||||
closeEditorTarget,
|
||||
createLabel,
|
||||
createTarget,
|
||||
editorTarget,
|
||||
handleDeleteLandmarks,
|
||||
handleDeleteStoryNpcs,
|
||||
handleGenerateEntity,
|
||||
handleRegenerate,
|
||||
localGenerationError,
|
||||
pendingGeneratedEntity,
|
||||
recentGeneratedIds,
|
||||
setEditorTarget,
|
||||
} = useRpgCreationResultActions({
|
||||
activeTab,
|
||||
agentEntityGenerator: onGenerateEntity
|
||||
? async (kind) => {
|
||||
return onGenerateEntity(kind);
|
||||
}
|
||||
: undefined,
|
||||
isGenerating,
|
||||
onProfileChange,
|
||||
profile,
|
||||
readOnly,
|
||||
triggerRegenerate,
|
||||
});
|
||||
|
||||
const deleteStoryNpcs = onDeleteEntities
|
||||
? (ids: string[]) => {
|
||||
void onDeleteEntities('story', ids);
|
||||
}
|
||||
: handleDeleteStoryNpcs;
|
||||
const deleteLandmarks = onDeleteEntities
|
||||
? (ids: string[]) => {
|
||||
void onDeleteEntities('landmark', ids);
|
||||
}
|
||||
: handleDeleteLandmarks;
|
||||
|
||||
return (
|
||||
<div className="platform-remap-surface flex h-full min-h-0 flex-col">
|
||||
<RpgCreationResultHeader
|
||||
autoSaveState={autoSaveState}
|
||||
backLabel={backLabel}
|
||||
isGenerating={isGenerating}
|
||||
onBack={onBack}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<CustomWorldEntityCatalog
|
||||
profile={profile}
|
||||
previewCharacters={previewCharacters}
|
||||
activeTab={activeTab}
|
||||
onActiveTabChange={setActiveTab}
|
||||
onEditTarget={setEditorTarget}
|
||||
onProfileChange={onProfileChange}
|
||||
onDeleteStoryNpcs={deleteStoryNpcs}
|
||||
onDeleteLandmarks={deleteLandmarks}
|
||||
createActionLabel={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity)
|
||||
? undefined
|
||||
: createLabel
|
||||
}
|
||||
onCreateAction={
|
||||
readOnly || (compactAgentResultMode && !onGenerateEntity) || !createTarget
|
||||
? undefined
|
||||
: () => {
|
||||
if (activeTab === 'playable') {
|
||||
void handleGenerateEntity('playable');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'story') {
|
||||
void handleGenerateEntity('story');
|
||||
return;
|
||||
}
|
||||
if (activeTab === 'landmarks') {
|
||||
void handleGenerateEntity('landmark');
|
||||
return;
|
||||
}
|
||||
setEditorTarget(createTarget);
|
||||
}
|
||||
}
|
||||
createActionDisabled={Boolean(
|
||||
isGenerating || pendingGeneratedEntity,
|
||||
)}
|
||||
pendingGeneratedEntity={pendingGeneratedEntity}
|
||||
recentGeneratedIds={recentGeneratedIds}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isGenerating && (
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl px-4 py-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-[var(--platform-text-strong)]">
|
||||
{progressLabel}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--platform-text-base)]">
|
||||
{Math.round(progress)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="platform-progress-track mt-3 h-3 overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_48%,#ffd2a6_100%)] transition-[width] duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{!error &&
|
||||
compactAgentResultMode &&
|
||||
publishBlockers.length <= 0 &&
|
||||
qualityFindings.some((entry) => entry.severity === 'warning') ? (
|
||||
<div className="platform-banner platform-banner--info mt-3 rounded-2xl text-sm leading-6">
|
||||
发布后仍有 {qualityFindings.filter((entry) => entry.severity === 'warning').length} 条 warning 可继续优化。
|
||||
</div>
|
||||
) : null}
|
||||
{!error && localGenerationError ? (
|
||||
<div className="platform-banner platform-banner--danger mt-3 rounded-2xl text-sm leading-6">
|
||||
{localGenerationError}
|
||||
</div>
|
||||
) : null}
|
||||
{assetDebugEnabled ? <RpgCreationAssetDebugPanel profile={profile} /> : null}
|
||||
|
||||
<RpgCreationResultActionBar
|
||||
editActionLabel={editActionLabel}
|
||||
enterWorldActionLabel={enterWorldActionLabel}
|
||||
isGenerating={isGenerating}
|
||||
onContinueExpand={onContinueExpand}
|
||||
onEditSetting={onEditSetting}
|
||||
onEnterWorld={onEnterWorld}
|
||||
onOpenCoverEditor={
|
||||
onOpenCoverEditor ?? (() => setEditorTarget({ kind: 'cover' }))
|
||||
}
|
||||
onPublishWorld={onPublishWorld}
|
||||
onTestWorld={onTestWorld}
|
||||
onRegenerate={triggerRegenerate ? handleRegenerate : undefined}
|
||||
profile={profile}
|
||||
regenerateActionLabel={regenerateActionLabel}
|
||||
publishReady={publishReady}
|
||||
publishBlockers={publishBlockers}
|
||||
/>
|
||||
|
||||
<RpgCreationEntityEditorModal
|
||||
profile={profile}
|
||||
target={editorTarget}
|
||||
onClose={closeEditorTarget}
|
||||
onProfileChange={onProfileChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { normalizeCustomWorldLandmarks } from '../../data/customWorldSceneGraph';
|
||||
import { rpgCreationAssetClient } from '../../services/rpg-creation/rpgCreationAssetClient';
|
||||
import type {
|
||||
CustomWorldLandmark,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
} from '../../types';
|
||||
import type { ResultTab } from '../CustomWorldEntityCatalog';
|
||||
import type { RpgCreationEditorTarget } from '../rpg-creation-editor/RpgCreationEntityEditorModal';
|
||||
|
||||
export type EntityGenerationKind = 'playable' | 'story' | 'landmark';
|
||||
|
||||
export type PendingGeneratedEntity = {
|
||||
id: string;
|
||||
kind: EntityGenerationKind;
|
||||
title: string;
|
||||
progress: number;
|
||||
phaseLabel: string;
|
||||
};
|
||||
|
||||
export type RecentGeneratedIds = Record<EntityGenerationKind, string[]>;
|
||||
|
||||
export type AgentEntityGenerationResult = {
|
||||
profile?: CustomWorldProfile | null;
|
||||
};
|
||||
|
||||
function getCreateTargetByTab(
|
||||
activeTab: ResultTab,
|
||||
): RpgCreationEditorTarget | null {
|
||||
if (activeTab === 'playable') return { kind: 'playable', mode: 'create' };
|
||||
if (activeTab === 'story') return { kind: 'story', mode: 'create' };
|
||||
if (activeTab === 'landmarks') return { kind: 'landmark', mode: 'create' };
|
||||
return null;
|
||||
}
|
||||
|
||||
function getCreateLabelByTab(activeTab: ResultTab) {
|
||||
if (activeTab === 'playable') return '新增可扮演角色';
|
||||
if (activeTab === 'story') return '新增场景角色';
|
||||
if (activeTab === 'landmarks') return '新增场景';
|
||||
return '';
|
||||
}
|
||||
|
||||
function createPendingGeneratedEntity(
|
||||
kind: EntityGenerationKind,
|
||||
): PendingGeneratedEntity {
|
||||
return {
|
||||
id: `pending-${kind}-${Date.now()}`,
|
||||
kind,
|
||||
title:
|
||||
kind === 'playable'
|
||||
? '新可扮演角色'
|
||||
: kind === 'story'
|
||||
? '新场景角色'
|
||||
: '新场景',
|
||||
progress: 8,
|
||||
phaseLabel: '正在整理世界上下文',
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePendingPhaseLabel(
|
||||
kind: EntityGenerationKind,
|
||||
progress: number,
|
||||
) {
|
||||
if (progress < 28) {
|
||||
return '正在整理世界上下文';
|
||||
}
|
||||
if (progress < 72) {
|
||||
return kind === 'landmark' ? '正在推理场景结构' : '正在推理角色结构';
|
||||
}
|
||||
return '正在回写结果';
|
||||
}
|
||||
|
||||
function prependPlayableNpc(
|
||||
profile: CustomWorldProfile,
|
||||
npc: CustomWorldPlayableNpc,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs: [npc, ...profile.playableNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependStoryNpc(profile: CustomWorldProfile, npc: CustomWorldNpc) {
|
||||
return {
|
||||
...profile,
|
||||
storyNpcs: [npc, ...profile.storyNpcs],
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function prependLandmark(
|
||||
profile: CustomWorldProfile,
|
||||
landmark: CustomWorldLandmark,
|
||||
) {
|
||||
return {
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: [landmark, ...profile.landmarks],
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function getEntityCountByKind(
|
||||
profile: CustomWorldProfile,
|
||||
kind: EntityGenerationKind,
|
||||
) {
|
||||
if (kind === 'playable') return profile.playableNpcs.length;
|
||||
if (kind === 'story') return profile.storyNpcs.length;
|
||||
return profile.landmarks.length;
|
||||
}
|
||||
|
||||
function removeStoryNpcsFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
) {
|
||||
const idSet = new Set(ids);
|
||||
const nextStoryNpcs = profile.storyNpcs.filter((npc) => !idSet.has(npc.id));
|
||||
|
||||
return {
|
||||
...profile,
|
||||
storyNpcs: nextStoryNpcs,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: profile.landmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
sceneNpcIds: landmark.sceneNpcIds.filter((npcId) => !idSet.has(npcId)),
|
||||
})),
|
||||
storyNpcs: nextStoryNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
function removeLandmarksFromProfile(
|
||||
profile: CustomWorldProfile,
|
||||
ids: string[],
|
||||
) {
|
||||
const idSet = new Set(ids);
|
||||
const nextLandmarks = profile.landmarks.filter(
|
||||
(landmark) => !idSet.has(landmark.id),
|
||||
);
|
||||
|
||||
return {
|
||||
...profile,
|
||||
landmarks: normalizeCustomWorldLandmarks({
|
||||
landmarks: nextLandmarks.map((landmark) => ({
|
||||
...landmark,
|
||||
connections: landmark.connections.filter(
|
||||
(connection) => !idSet.has(connection.targetLandmarkId),
|
||||
),
|
||||
})),
|
||||
storyNpcs: profile.storyNpcs,
|
||||
}),
|
||||
} satisfies CustomWorldProfile;
|
||||
}
|
||||
|
||||
export function useRpgCreationResultActions(params: {
|
||||
activeTab: ResultTab;
|
||||
agentEntityGenerator?:
|
||||
| ((kind: EntityGenerationKind) => Promise<AgentEntityGenerationResult | void>)
|
||||
| undefined;
|
||||
isGenerating: boolean;
|
||||
onProfileChange: (profile: CustomWorldProfile) => void;
|
||||
profile: CustomWorldProfile;
|
||||
readOnly: boolean;
|
||||
triggerRegenerate?: () => void;
|
||||
}) {
|
||||
const {
|
||||
activeTab,
|
||||
agentEntityGenerator,
|
||||
isGenerating,
|
||||
onProfileChange,
|
||||
profile,
|
||||
readOnly,
|
||||
triggerRegenerate,
|
||||
} = params;
|
||||
const [editorTarget, setEditorTarget] =
|
||||
useState<RpgCreationEditorTarget | null>(null);
|
||||
const [pendingGeneratedEntity, setPendingGeneratedEntity] =
|
||||
useState<PendingGeneratedEntity | null>(null);
|
||||
const [recentGeneratedIds, setRecentGeneratedIds] = useState<RecentGeneratedIds>(
|
||||
{
|
||||
playable: [],
|
||||
story: [],
|
||||
landmark: [],
|
||||
},
|
||||
);
|
||||
const [localGenerationError, setLocalGenerationError] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const pendingProgressTimerRef = useRef<number | null>(null);
|
||||
|
||||
const createTarget = useMemo(
|
||||
() => getCreateTargetByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
const createLabel = useMemo(
|
||||
() => getCreateLabelByTab(activeTab),
|
||||
[activeTab],
|
||||
);
|
||||
|
||||
const stopPendingProgressTimer = () => {
|
||||
if (pendingProgressTimerRef.current !== null) {
|
||||
window.clearInterval(pendingProgressTimerRef.current);
|
||||
pendingProgressTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => () => stopPendingProgressTimer(), []);
|
||||
|
||||
const startPendingProgress = (kind: EntityGenerationKind) => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(createPendingGeneratedEntity(kind));
|
||||
pendingProgressTimerRef.current = window.setInterval(() => {
|
||||
setPendingGeneratedEntity((current) => {
|
||||
if (!current || current.kind !== kind) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const nextProgress = Math.min(
|
||||
current.progress + (current.progress < 56 ? 11 : 5),
|
||||
88,
|
||||
);
|
||||
|
||||
return {
|
||||
...current,
|
||||
progress: nextProgress,
|
||||
phaseLabel: resolvePendingPhaseLabel(kind, nextProgress),
|
||||
};
|
||||
});
|
||||
}, 520);
|
||||
};
|
||||
|
||||
const finishPendingProgress = () => {
|
||||
stopPendingProgressTimer();
|
||||
setPendingGeneratedEntity(null);
|
||||
};
|
||||
|
||||
const markGeneratedAsRecent = (
|
||||
kind: EntityGenerationKind,
|
||||
generatedId: string,
|
||||
) => {
|
||||
setRecentGeneratedIds((current) => ({
|
||||
...current,
|
||||
[kind]: [
|
||||
generatedId,
|
||||
...current[kind].filter((id) => id !== generatedId),
|
||||
].slice(0, 6),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleGenerateEntity = async (kind: EntityGenerationKind) => {
|
||||
if (readOnly || isGenerating || pendingGeneratedEntity) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalGenerationError(null);
|
||||
startPendingProgress(kind);
|
||||
|
||||
try {
|
||||
if (agentEntityGenerator) {
|
||||
const previousCount = getEntityCountByKind(profile, kind);
|
||||
const generationResult = await agentEntityGenerator(kind);
|
||||
const currentCount = generationResult?.profile
|
||||
? getEntityCountByKind(generationResult.profile, kind)
|
||||
: previousCount;
|
||||
if (currentCount <= previousCount) {
|
||||
throw new Error('生成请求已完成,但结果页未收到新增内容,请返回创作页重新打开草稿后重试。');
|
||||
}
|
||||
} else if (kind === 'playable') {
|
||||
const nextNpc = await rpgCreationAssetClient.generatePlayableNpc({
|
||||
profile,
|
||||
});
|
||||
onProfileChange(prependPlayableNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('playable', nextNpc.id);
|
||||
} else if (kind === 'story') {
|
||||
const nextNpc = await rpgCreationAssetClient.generateStoryNpc({
|
||||
profile,
|
||||
});
|
||||
onProfileChange(prependStoryNpc(profile, nextNpc));
|
||||
markGeneratedAsRecent('story', nextNpc.id);
|
||||
} else {
|
||||
const nextLandmark = await rpgCreationAssetClient.generateLandmark({
|
||||
profile,
|
||||
});
|
||||
onProfileChange(prependLandmark(profile, nextLandmark));
|
||||
markGeneratedAsRecent('landmark', nextLandmark.id);
|
||||
}
|
||||
} catch (generationError) {
|
||||
setLocalGenerationError(
|
||||
generationError instanceof Error
|
||||
? generationError.message
|
||||
: '生成失败,请稍后重试。',
|
||||
);
|
||||
} finally {
|
||||
finishPendingProgress();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerate = () => {
|
||||
if (isGenerating || !triggerRegenerate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = window.confirm(
|
||||
`确认重新生成“${profile.name}”吗?\n\n重新生成会重新生成当前世界中的所有信息,包括你修改和新增的所有内容。`,
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
triggerRegenerate();
|
||||
};
|
||||
|
||||
const handleDeleteStoryNpcs = (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
onProfileChange(removeStoryNpcsFromProfile(profile, ids));
|
||||
};
|
||||
|
||||
const handleDeleteLandmarks = (ids: string[]) => {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
onProfileChange(removeLandmarksFromProfile(profile, ids));
|
||||
};
|
||||
|
||||
return {
|
||||
createLabel,
|
||||
createTarget,
|
||||
editorTarget,
|
||||
handleDeleteLandmarks,
|
||||
handleDeleteStoryNpcs,
|
||||
handleGenerateEntity,
|
||||
handleRegenerate,
|
||||
localGenerationError,
|
||||
pendingGeneratedEntity,
|
||||
recentGeneratedIds,
|
||||
setEditorTarget,
|
||||
closeEditorTarget: () => setEditorTarget(null),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user