276 lines
8.8 KiB
TypeScript
276 lines
8.8 KiB
TypeScript
import { X } from 'lucide-react';
|
||
import type { ReactNode } from 'react';
|
||
|
||
import type {
|
||
CustomWorldCreatorIntent,
|
||
CustomWorldGenerationMode,
|
||
} from '../types';
|
||
|
||
type BaseModalProps = {
|
||
isOpen: boolean;
|
||
title: string;
|
||
onClose: () => void;
|
||
children: ReactNode;
|
||
footer?: ReactNode;
|
||
};
|
||
|
||
function SelectionModal({
|
||
isOpen,
|
||
title,
|
||
onClose,
|
||
children,
|
||
footer = null,
|
||
}: BaseModalProps) {
|
||
if (!isOpen) return null;
|
||
|
||
return (
|
||
<div className="platform-overlay fixed inset-0 z-[90] flex items-center justify-center p-4 backdrop-blur-sm">
|
||
<div className="platform-modal-shell platform-remap-surface flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl shadow-[0_30px_80px_rgba(0,0,0,0.55)]">
|
||
<div className="flex items-center justify-between border-b border-white/10 px-5 py-4">
|
||
<div className="text-base font-semibold text-white">{title}</div>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-full border border-white/10 bg-white/5 p-2 text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
|
||
{children}
|
||
</div>
|
||
{footer ? (
|
||
<div className="flex flex-wrap items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||
{footer}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CharacterDraftModal(props: {
|
||
isOpen: boolean;
|
||
characterLabel: string;
|
||
draftName: string;
|
||
draftBackstory: string;
|
||
onNameChange: (value: string) => void;
|
||
onBackstoryChange: (value: string) => void;
|
||
onClose: () => void;
|
||
onConfirm: () => void;
|
||
error?: string | null;
|
||
}) {
|
||
const {
|
||
isOpen,
|
||
characterLabel,
|
||
draftName,
|
||
draftBackstory,
|
||
onNameChange,
|
||
onBackstoryChange,
|
||
onClose,
|
||
onConfirm,
|
||
error = null,
|
||
} = props;
|
||
|
||
return (
|
||
<SelectionModal
|
||
isOpen={isOpen}
|
||
title="角色自定义"
|
||
onClose={onClose}
|
||
footer={(
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onConfirm}
|
||
className="rounded-2xl bg-emerald-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-emerald-300"
|
||
>
|
||
确认进入
|
||
</button>
|
||
</>
|
||
)}
|
||
>
|
||
<div className="space-y-4">
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-zinc-300">
|
||
当前角色:{characterLabel}
|
||
</div>
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-zinc-200">角色名字</div>
|
||
<input
|
||
value={draftName}
|
||
onChange={(event) => onNameChange(event.target.value)}
|
||
placeholder="输入一个更贴合这次旅程的称呼"
|
||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-emerald-400/40"
|
||
/>
|
||
</label>
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-zinc-200">背景补充</div>
|
||
<textarea
|
||
value={draftBackstory}
|
||
onChange={(event) => onBackstoryChange(event.target.value)}
|
||
rows={6}
|
||
placeholder="可以补充这次开局想强调的身份、经历、执念或禁忌。"
|
||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-emerald-400/40"
|
||
/>
|
||
</label>
|
||
{error ? (
|
||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SelectionModal>
|
||
);
|
||
}
|
||
|
||
type CustomWorldCreatorModalProps = {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onSubmit: () => void;
|
||
isGenerating: boolean;
|
||
progress: number;
|
||
progressLabel: string;
|
||
error?: string | null;
|
||
} & (
|
||
| {
|
||
draft: string;
|
||
onDraftChange: (value: string) => void;
|
||
creatorIntent?: never;
|
||
onCreatorIntentChange?: never;
|
||
generationMode?: never;
|
||
onGenerationModeChange?: never;
|
||
}
|
||
| {
|
||
draft?: never;
|
||
onDraftChange?: never;
|
||
creatorIntent: CustomWorldCreatorIntent;
|
||
onCreatorIntentChange: (value: CustomWorldCreatorIntent) => void;
|
||
generationMode: CustomWorldGenerationMode;
|
||
onGenerationModeChange: (value: CustomWorldGenerationMode) => void;
|
||
}
|
||
);
|
||
|
||
function hasCreatorIntentProps(
|
||
props: CustomWorldCreatorModalProps,
|
||
): props is Extract<
|
||
CustomWorldCreatorModalProps,
|
||
{ creatorIntent: CustomWorldCreatorIntent }
|
||
> {
|
||
return 'creatorIntent' in props;
|
||
}
|
||
|
||
export function CustomWorldCreatorModal(props: CustomWorldCreatorModalProps) {
|
||
const {
|
||
isOpen,
|
||
onClose,
|
||
onSubmit,
|
||
isGenerating,
|
||
progress,
|
||
progressLabel,
|
||
error = null,
|
||
} = props;
|
||
|
||
const draftText = hasCreatorIntentProps(props)
|
||
? props.creatorIntent.rawSettingText
|
||
: props.draft;
|
||
|
||
const updateDraftText = (value: string) => {
|
||
if (hasCreatorIntentProps(props)) {
|
||
props.onCreatorIntentChange({
|
||
...props.creatorIntent,
|
||
rawSettingText: value,
|
||
});
|
||
return;
|
||
}
|
||
|
||
props.onDraftChange(value);
|
||
};
|
||
|
||
return (
|
||
<SelectionModal
|
||
isOpen={isOpen}
|
||
title="创建自定义世界"
|
||
onClose={onClose}
|
||
footer={(
|
||
<>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
disabled={isGenerating}
|
||
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-2 text-sm text-zinc-300 transition hover:bg-white/10 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={onSubmit}
|
||
disabled={isGenerating}
|
||
className="rounded-2xl bg-sky-400 px-4 py-2 text-sm font-semibold text-slate-950 transition hover:bg-sky-300 disabled:cursor-not-allowed disabled:opacity-50"
|
||
>
|
||
{isGenerating ? '生成中...' : '开始生成'}
|
||
</button>
|
||
</>
|
||
)}
|
||
>
|
||
<div className="space-y-4">
|
||
{hasCreatorIntentProps(props) ? (
|
||
<label className="block">
|
||
<div className="mb-2 text-sm font-medium text-zinc-200">生成模式</div>
|
||
<select
|
||
value={props.generationMode}
|
||
onChange={(event) =>
|
||
props.onGenerationModeChange(
|
||
event.target.value as CustomWorldGenerationMode,
|
||
)
|
||
}
|
||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-400/40"
|
||
>
|
||
<option value="fast">快速</option>
|
||
<option value="full">完整</option>
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
|
||
<div className="text-sm leading-7 text-zinc-300">
|
||
用几句话描述世界观、核心矛盾、时代气质和你想体验的叙事方向。系统会据此生成可游玩的自定义世界。
|
||
</div>
|
||
|
||
<textarea
|
||
value={draftText}
|
||
onChange={(event) => updateDraftText(event.target.value)}
|
||
rows={8}
|
||
placeholder="例:一个被潮雾与失落列岛切碎的边境世界,旧盟约、沉船秘术与灯塔守望者纠缠在一起……"
|
||
className="w-full rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm leading-7 text-white outline-none transition focus:border-sky-400/40"
|
||
/>
|
||
|
||
{isGenerating ? (
|
||
<div className="rounded-2xl border border-sky-300/15 bg-sky-500/10 px-4 py-3">
|
||
<div className="mb-2 flex items-center justify-between text-xs tracking-[0.16em] text-sky-100/80">
|
||
<span>{progressLabel}</span>
|
||
<span>{Math.max(0, Math.min(100, Math.round(progress)))}%</span>
|
||
</div>
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<div
|
||
className="h-full rounded-full bg-gradient-to-r from-sky-300 to-cyan-200 transition-[width] duration-300"
|
||
style={{ width: `${Math.max(6, Math.min(100, progress))}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
|
||
{error}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</SelectionModal>
|
||
);
|
||
}
|