Fix DashScope env loading for scene image generation
This commit is contained in:
376
src/components/CustomWorldGenerationView.tsx
Normal file
376
src/components/CustomWorldGenerationView.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
import { motion } from 'motion/react';
|
||||
|
||||
import type {
|
||||
CustomWorldGenerationProgress,
|
||||
} from '../services/ai';
|
||||
import { AnimationState, type Character } from '../types';
|
||||
import { getNineSliceStyle, UI_CHROME } from '../uiAssets';
|
||||
import { CharacterAnimator } from './CharacterAnimator';
|
||||
|
||||
interface CustomWorldGenerationViewProps {
|
||||
settingText: string;
|
||||
actionPreviewCharacters: Character[];
|
||||
progress: CustomWorldGenerationProgress | null;
|
||||
isGenerating: boolean;
|
||||
error: string | null;
|
||||
onBack: () => void;
|
||||
onEditSetting: () => void;
|
||||
onRetry: () => void;
|
||||
onInterrupt: () => void;
|
||||
}
|
||||
|
||||
const ACTION_SHOWCASE: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
state: AnimationState;
|
||||
}> = [
|
||||
{
|
||||
label: '冲阵测试',
|
||||
description: '检查角色前探、推进与开场压迫感。',
|
||||
state: AnimationState.RUN,
|
||||
},
|
||||
{
|
||||
label: '交战演示',
|
||||
description: '预热战斗站姿与交锋节奏。',
|
||||
state: AnimationState.ATTACK,
|
||||
},
|
||||
{
|
||||
label: '驻场待命',
|
||||
description: '确认角色在剧情停驻时的氛围姿态。',
|
||||
state: AnimationState.IDLE,
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const safeMs = Math.max(0, Math.round(ms));
|
||||
const totalSeconds = Math.ceil(safeMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
|
||||
if (minutes <= 0) {
|
||||
return `${Math.max(1, seconds)} 秒`;
|
||||
}
|
||||
|
||||
if (seconds === 0) {
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
|
||||
return `${minutes} 分 ${seconds} 秒`;
|
||||
}
|
||||
|
||||
function getProgressPercentage(progress: CustomWorldGenerationProgress | null) {
|
||||
return Math.max(0, Math.min(100, progress?.overallProgress ?? 0));
|
||||
}
|
||||
|
||||
export function CustomWorldGenerationView({
|
||||
settingText,
|
||||
actionPreviewCharacters,
|
||||
progress,
|
||||
isGenerating,
|
||||
error,
|
||||
onBack,
|
||||
onEditSetting,
|
||||
onRetry,
|
||||
onInterrupt,
|
||||
}: CustomWorldGenerationViewProps) {
|
||||
const progressValue = getProgressPercentage(progress);
|
||||
const steps = progress?.steps ?? [];
|
||||
const estimatedWaitText =
|
||||
progress?.estimatedRemainingMs != null
|
||||
? `预计还需 ${formatDuration(progress.estimatedRemainingMs)}`
|
||||
: '正在校准预计等待时间';
|
||||
const elapsedText =
|
||||
progress != null ? `已耗时 ${formatDuration(progress.elapsedMs)}` : '正在启动世界生成';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full min-h-0 flex-col overflow-y-auto overscroll-y-contain pr-1 pb-[max(1rem,env(safe-area-inset-bottom))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="sticky top-0 z-20 -mx-3 mb-4 flex items-center justify-between gap-3 bg-[linear-gradient(180deg,rgba(10,12,18,0.96),rgba(10,12,18,0.86),rgba(10,12,18,0))] px-3 pb-3 pt-1 sm:static sm:mx-0 sm:bg-none sm:px-0 sm:pb-0 sm:pt-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
disabled={isGenerating}
|
||||
className={`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 ? 'cursor-not-allowed opacity-45' : ''}`}
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
<div className="rounded-full border border-sky-300/16 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.2em] text-sky-100">
|
||||
{isGenerating ? '世界建设中' : error ? '生成已暂停' : '等待操作'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid flex-none gap-4 xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.1fr)_minmax(22rem,0.9fr)]">
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.storyPanel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
玩家设定
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-zinc-400">
|
||||
这段文本会直接驱动本轮世界框架、角色与场景生成。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
disabled={isGenerating}
|
||||
className={`rounded-full border border-white/10 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-300 transition-colors hover:text-white ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
||||
>
|
||||
修改设定
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/22 px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
|
||||
{settingText}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel flex flex-col xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
生成进度
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-black leading-tight text-white sm:text-[2rem]">
|
||||
{progress?.phaseLabel ?? '正在启动世界生成'}
|
||||
</div>
|
||||
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
|
||||
{progress?.phaseDetail ?? '正在初始化世界生成链路与阶段监控。'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 sm:text-right">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
总进度
|
||||
</div>
|
||||
<div className="mt-1 text-3xl font-black text-sky-100 sm:text-4xl">
|
||||
{progressValue}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-4 overflow-hidden rounded-full border border-white/10 bg-black/35">
|
||||
<motion.div
|
||||
className="h-full bg-[linear-gradient(90deg,#38bdf8_0%,#67e8f9_45%,#fde68a_100%)]"
|
||||
animate={{ width: `${progressValue}%` }}
|
||||
transition={{ duration: 0.35, ease: 'easeOut' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
当前批次
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{progress?.batchLabel ?? '准备中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
预计等待
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{estimatedWaitText}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-4 py-3">
|
||||
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
|
||||
计时
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-semibold text-white">
|
||||
{elapsedText}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-2xl border px-4 py-3 transition-colors ${
|
||||
step.status === 'completed'
|
||||
? 'border-emerald-400/16 bg-emerald-500/8'
|
||||
: step.status === 'active'
|
||||
? 'border-sky-300/22 bg-sky-500/10'
|
||||
: 'border-white/8 bg-black/18'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{step.label}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-300">
|
||||
{step.completed}/{step.total}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{step.detail}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="mt-4 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 sm:flex-row sm:flex-wrap sm:justify-end">
|
||||
{!isGenerating ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onEditSetting}
|
||||
className="rounded-full border border-white/10 bg-black/20 px-4 py-2 text-sm text-zinc-300 transition-colors hover:text-white"
|
||||
>
|
||||
返回修改
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="pixel-nine-slice pixel-pressable w-full text-left sm:w-auto"
|
||||
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>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onInterrupt}
|
||||
className="rounded-full border border-rose-300/18 bg-rose-500/10 px-4 py-2 text-sm text-rose-100 transition-colors hover:text-white"
|
||||
>
|
||||
中断世界生成
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 xl:min-h-0">
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel relative overflow-hidden"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute -left-8 top-0 h-36 w-36 rounded-full bg-sky-400/18 blur-3xl"
|
||||
animate={{
|
||||
opacity: [0.22, 0.48, 0.22],
|
||||
scale: [0.92, 1.08, 0.92],
|
||||
}}
|
||||
transition={{ duration: 6.5, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute bottom-0 right-0 h-32 w-32 rounded-full bg-amber-200/12 blur-3xl"
|
||||
animate={{ opacity: [0.18, 0.4, 0.18], scale: [1, 1.12, 1] }}
|
||||
transition={{ duration: 7.2, repeat: Infinity, ease: 'easeInOut' }}
|
||||
/>
|
||||
<div className="relative z-10">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-sky-100/85">
|
||||
世界建造氛围
|
||||
</div>
|
||||
<div className="mt-2 text-xl font-black leading-tight text-white sm:text-2xl">
|
||||
世界正在搭建地标、势力与角色关系
|
||||
</div>
|
||||
<div className="mt-3 max-w-[26rem] text-sm leading-6 text-zinc-300">
|
||||
生成页不再只是一根等待条。这里会持续展示本轮设定的建造状态,让等待过程也像在看一场世界开局演出。
|
||||
</div>
|
||||
<div className="mt-5 grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
世界气候
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200">
|
||||
势力碰撞
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/8 bg-black/20 px-3 py-3 text-center text-xs text-zinc-200 col-span-2 sm:col-span-1">
|
||||
场景拓扑
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
className="pixel-nine-slice pixel-panel xl:min-h-0 xl:flex-1"
|
||||
style={getNineSliceStyle(UI_CHROME.panel, {
|
||||
paddingX: 16,
|
||||
paddingY: 14,
|
||||
})}
|
||||
>
|
||||
<div className="mb-3">
|
||||
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
|
||||
可扮演角色动作素材
|
||||
</div>
|
||||
<div className="mt-1 text-sm leading-6 text-zinc-300">
|
||||
先加载一组动作素材,让世界创建阶段也保持角色演出感。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-1 2xl:grid-cols-3">
|
||||
{ACTION_SHOWCASE.map((showcase, index) => {
|
||||
const character =
|
||||
actionPreviewCharacters[
|
||||
index % Math.max(1, actionPreviewCharacters.length)
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={showcase.label}
|
||||
className="rounded-[1.5rem] border border-white/8 bg-black/22 px-4 py-4"
|
||||
>
|
||||
<div className="flex h-28 items-end justify-center overflow-hidden rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(125,211,252,0.18),rgba(10,12,18,0.1)_38%,rgba(10,12,18,0.76)_100%)] sm:h-32">
|
||||
{character ? (
|
||||
<CharacterAnimator
|
||||
state={showcase.state}
|
||||
character={character}
|
||||
className="h-full w-full"
|
||||
imageClassName="object-bottom"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-3 text-sm font-semibold text-white">
|
||||
{showcase.label}
|
||||
</div>
|
||||
<div className="mt-1 text-xs leading-6 text-zinc-400">
|
||||
{showcase.description}
|
||||
</div>
|
||||
{character ? (
|
||||
<div className="mt-3 rounded-full border border-sky-300/14 bg-sky-500/10 px-3 py-1 text-[10px] tracking-[0.16em] text-sky-100">
|
||||
{character.name}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user