Files
Genarrative/src/components/CustomWorldResultView.tsx

232 lines
7.9 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 { type ReactNode,useMemo, useState } from 'react';
import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
import { Character, CustomWorldProfile } from '../types';
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
import { CustomWorldEntityCatalog, type ResultTab } from './CustomWorldEntityCatalog';
import { type CustomWorldEditorTarget,CustomWorldEntityEditorModal } from './CustomWorldEntityEditorModal';
interface CustomWorldResultViewProps {
profile: CustomWorldProfile;
previewCharacters: Character[];
isGenerating: boolean;
progress: number;
progressLabel: string;
error: string | null;
onBack: () => void;
onEditSetting: () => void;
onRegenerate: () => void;
onContinueExpand?: () => void;
onSave: () => void;
onProfileChange: (profile: CustomWorldProfile) => void;
}
function SmallButton({
onClick,
children,
tone = 'default',
disabled = false,
}: {
onClick: () => void;
children: ReactNode;
tone?: 'default' | 'sky';
disabled?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className={`rounded-full border px-3 py-2 text-sm transition-colors ${
tone === 'sky'
? 'border-sky-300/20 bg-sky-500/10 text-sky-100 hover:text-white'
: 'border-white/10 bg-black/20 text-zinc-300 hover:text-white'
} ${disabled ? 'cursor-not-allowed opacity-45' : ''}`}
>
{children}
</button>
);
}
function getCreateTargetByTab(activeTab: ResultTab): CustomWorldEditorTarget | 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 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 CustomWorldResultView({
profile,
previewCharacters,
isGenerating,
progress,
progressLabel,
error,
onBack,
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onSave,
onProfileChange,
}: CustomWorldResultViewProps) {
const [editorTarget, setEditorTarget] = useState<CustomWorldEditorTarget | null>(null);
const [activeTab, setActiveTab] = useState<ResultTab>('world');
const createTarget = useMemo(() => getCreateTargetByTab(activeTab), [activeTab]);
const createLabel = useMemo(() => getCreateLabelByTab(activeTab), [activeTab]);
const onRegenerate = () => {
if (isGenerating) 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 (
<div className="flex h-full min-h-0 flex-col">
<div className="mb-4 flex justify-start">
<button
type="button"
onClick={onBack}
disabled={isGenerating}
className={`self-start rounded-full border border-white/10 bg-black/18 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'opacity-45' : ''}`}
>
</button>
</div>
<div className="min-h-0 flex-1 overflow-hidden">
<CustomWorldEntityCatalog
profile={profile}
previewCharacters={previewCharacters}
activeTab={activeTab}
onActiveTabChange={setActiveTab}
onEditTarget={setEditorTarget}
onProfileChange={onProfileChange}
onDeleteStoryNpcs={handleDeleteStoryNpcs}
onDeleteLandmarks={handleDeleteLandmarks}
createActionLabel={createLabel}
onCreateAction={createTarget ? () => setEditorTarget(createTarget) : undefined}
/>
</div>
{isGenerating && (
<div className="mt-3 rounded-2xl border border-sky-400/18 bg-sky-500/10 px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-white">{progressLabel}</div>
<div className="text-xs text-sky-100">{Math.round(progress)}%</div>
</div>
<div className="mt-3 h-3 overflow-hidden rounded-full border border-white/10 bg-black/35">
<div
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_48%,#fef08a_100%)] transition-[width] duration-300"
style={{ width: `${Math.max(0, Math.min(100, progress))}%` }}
/>
</div>
</div>
)}
{error ? (
<div className="mt-3 rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{error}
</div>
) : null}
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
<div className="rounded-2xl border border-amber-300/16 bg-amber-500/10 px-4 py-3 text-sm leading-6 text-amber-100">
</div>
) : null}
<div className="flex items-center justify-end gap-3">
<SmallButton onClick={onEditSetting}></SmallButton>
<SmallButton onClick={onRegenerate} tone="sky"></SmallButton>
{profile.generationStatus === 'key_only' && onContinueExpand ? (
<SmallButton onClick={onContinueExpand} tone="sky" disabled={isGenerating}>
</SmallButton>
) : null}
<button
type="button"
onClick={onSave}
disabled={isGenerating}
className={`pixel-nine-slice pixel-pressable text-left ${isGenerating ? 'opacity-55' : ''}`}
style={getNineSliceStyle(UI_CHROME.choiceButton, { paddingX: 16, paddingY: 10 })}
>
<div className="flex items-center justify-between gap-4">
<span className="text-sm font-semibold text-white"></span>
<span className="text-white/60"></span>
</div>
</button>
</div>
</div>
<CustomWorldEntityEditorModal
profile={profile}
target={editorTarget}
onClose={() => setEditorTarget(null)}
onProfileChange={onProfileChange}
/>
</div>
);
}