276 lines
9.7 KiB
TypeScript
276 lines
9.7 KiB
TypeScript
import { ArrowLeft } from 'lucide-react';
|
|
|
|
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
|
|
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
|
|
import {
|
|
GenerationCurrentStepCard,
|
|
GenerationPageBackdrop,
|
|
GenerationProgressHero,
|
|
} from './GenerationProgressHero';
|
|
|
|
interface CustomWorldGenerationViewProps {
|
|
settingText: string;
|
|
anchorEntries?: CustomWorldStructuredAnchorEntry[];
|
|
progress: CustomWorldGenerationProgress | null;
|
|
isGenerating: boolean;
|
|
error?: string | null;
|
|
onBack: () => void;
|
|
onEditSetting: () => void;
|
|
onRetry: () => void;
|
|
onInterrupt?: () => void;
|
|
backLabel?: string;
|
|
settingActionLabel?: string | null;
|
|
retryLabel?: string;
|
|
interruptLabel?: string;
|
|
settingTitle?: string;
|
|
settingDescription?: string | null;
|
|
progressTitle?: string;
|
|
activeBadgeLabel?: string;
|
|
pausedBadgeLabel?: string;
|
|
idleBadgeLabel?: string;
|
|
structuredEmptyText?: string;
|
|
hideBatchModule?: boolean;
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
function getStepProgressPercentage(step: {
|
|
completed: number;
|
|
total: number;
|
|
status: string;
|
|
}) {
|
|
if (step.status === 'completed') {
|
|
return 100;
|
|
}
|
|
|
|
if (step.total <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(
|
|
0,
|
|
Math.min(100, Math.round((step.completed / step.total) * 100)),
|
|
);
|
|
}
|
|
|
|
function getStepStatusLabel(step: { status: string }) {
|
|
if (step.status === 'completed') {
|
|
return '完成';
|
|
}
|
|
|
|
if (step.status === 'active') {
|
|
return '进行中';
|
|
}
|
|
|
|
return '待处理';
|
|
}
|
|
|
|
function resolveCurrentGenerationStep(
|
|
progress: CustomWorldGenerationProgress | null,
|
|
) {
|
|
const steps = progress?.steps ?? [];
|
|
return (
|
|
steps.find((step) => step.status === 'active') ??
|
|
steps[progress?.activeStepIndex ?? -1] ??
|
|
steps.find((step) => step.status === 'pending') ??
|
|
steps.at(-1) ??
|
|
null
|
|
);
|
|
}
|
|
|
|
function buildFallbackRenderKey(
|
|
value: string | null | undefined,
|
|
fallback: string,
|
|
) {
|
|
const normalizedValue = value?.trim();
|
|
return normalizedValue ? normalizedValue : fallback;
|
|
}
|
|
|
|
export function CustomWorldGenerationView({
|
|
settingText,
|
|
anchorEntries = [],
|
|
progress,
|
|
isGenerating,
|
|
onBack,
|
|
onEditSetting,
|
|
onRetry,
|
|
onInterrupt,
|
|
backLabel = '返回',
|
|
settingActionLabel = '修改设定',
|
|
retryLabel = '重新开始生成',
|
|
interruptLabel = '中断世界生成',
|
|
settingTitle = '玩家设定',
|
|
settingDescription = '这段文本会直接驱动本轮世界框架、角色与场景生成。',
|
|
progressTitle = '生成进度',
|
|
activeBadgeLabel = '世界建设中',
|
|
idleBadgeLabel = '等待操作',
|
|
structuredEmptyText = '正在整理当前设定结构,请稍后。',
|
|
hideBatchModule = false,
|
|
}: CustomWorldGenerationViewProps) {
|
|
void hideBatchModule;
|
|
const progressValue = getProgressPercentage(progress);
|
|
const currentStep = resolveCurrentGenerationStep(progress);
|
|
const currentStepProgress = currentStep
|
|
? getStepProgressPercentage(currentStep)
|
|
: progressValue;
|
|
const currentStepLabel = currentStep?.label ?? progress?.phaseLabel ?? '准备生成';
|
|
const currentStepStatusLabel = currentStep
|
|
? getStepStatusLabel(currentStep)
|
|
: isGenerating
|
|
? '进行中'
|
|
: '待处理';
|
|
const hasStructuredAnchors = anchorEntries.length > 0;
|
|
// 允许不同生成场景按需隐藏第二模块的说明和次级返回动作。
|
|
const normalizedSettingActionLabel = settingActionLabel?.trim() ?? '';
|
|
const normalizedSettingDescription = settingDescription?.trim() ?? '';
|
|
const hasSettingActionLabel = normalizedSettingActionLabel.length > 0;
|
|
const hasSettingDescription = normalizedSettingDescription.length > 0;
|
|
const estimatedWaitText =
|
|
progress?.estimatedRemainingMs != null
|
|
? formatDuration(progress.estimatedRemainingMs)
|
|
: '校准中';
|
|
const elapsedText =
|
|
progress != null ? formatDuration(progress.elapsedMs) : '启动中';
|
|
|
|
return (
|
|
<div
|
|
className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-y-auto overscroll-y-contain bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"
|
|
style={{ WebkitOverflowScrolling: 'touch' }}
|
|
>
|
|
<GenerationPageBackdrop />
|
|
<div className="relative z-10 mb-6 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-6">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="inline-flex items-center gap-2 rounded-full bg-transparent px-0 py-2 text-xs font-black text-[#171411] sm:text-sm"
|
|
>
|
|
<ArrowLeft className="h-5 w-5 shrink-0" strokeWidth={2.6} />
|
|
<span className="break-keep">{backLabel}</span>
|
|
</button>
|
|
<div className="rounded-full border border-[#f05816] bg-white/72 px-3 py-1.5 text-[11px] font-black tracking-[0.08em] text-[#df6118] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs">
|
|
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 flex flex-none flex-col gap-4">
|
|
<section className="overflow-hidden px-0 pb-2 pt-0 sm:px-0">
|
|
<GenerationProgressHero
|
|
title={progressTitle}
|
|
phaseLabel={progress?.phaseLabel ?? '正在启动世界生成'}
|
|
progressValue={progressValue}
|
|
estimatedWaitText={estimatedWaitText}
|
|
elapsedText={elapsedText}
|
|
/>
|
|
|
|
<div className="mt-[-0.15rem] px-0 sm:px-0">
|
|
<GenerationCurrentStepCard
|
|
label={currentStepLabel}
|
|
statusLabel={currentStepStatusLabel}
|
|
progressValue={currentStepProgress}
|
|
/>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
|
|
{!isGenerating ? (
|
|
<>
|
|
{hasSettingActionLabel ? (
|
|
<button
|
|
type="button"
|
|
onClick={onEditSetting}
|
|
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
|
>
|
|
{normalizedSettingActionLabel}
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
onClick={onRetry}
|
|
className="platform-button platform-button--primary w-full sm:w-auto"
|
|
>
|
|
{retryLabel}
|
|
</button>
|
|
</>
|
|
) : onInterrupt ? (
|
|
<button
|
|
type="button"
|
|
onClick={onInterrupt}
|
|
className="rounded-full border border-[var(--platform-button-danger-border)] bg-[var(--platform-button-danger-fill)] px-4 py-2 text-sm text-[var(--platform-button-danger-text)] transition-colors hover:text-[var(--platform-text-strong)]"
|
|
>
|
|
{interruptLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="overflow-hidden rounded-[1.75rem] border border-[#eadcd1] bg-[rgba(255,250,246,0.92)] px-4 py-4 shadow-[0_20px_56px_rgba(112,57,30,0.08)] backdrop-blur-[5px] sm:px-5 sm:py-5">
|
|
<div className="mb-4 flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="text-[13px] font-black tracking-[0.08em] text-[#111111]">
|
|
{settingTitle}
|
|
</div>
|
|
{hasSettingDescription ? (
|
|
<div className="mt-2 text-[13px] leading-6 text-[#7e6656]">
|
|
{normalizedSettingDescription}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
{hasSettingActionLabel ? (
|
|
<button
|
|
type="button"
|
|
onClick={onEditSetting}
|
|
disabled={isGenerating}
|
|
className={`platform-button platform-button--ghost min-h-0 px-3 py-1.5 text-[11px] ${isGenerating ? 'cursor-not-allowed opacity-40' : ''}`}
|
|
>
|
|
{normalizedSettingActionLabel}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
{hasStructuredAnchors ? (
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{anchorEntries.map((entry, index) => (
|
|
<div
|
|
key={buildFallbackRenderKey(
|
|
entry.id,
|
|
`anchor-entry-${index}`,
|
|
)}
|
|
className="rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4"
|
|
>
|
|
<div className="text-[9px] font-bold tracking-[0.12em] text-[#8e6f5d] sm:text-[10px]">
|
|
{entry.label}
|
|
</div>
|
|
<div className="mt-2 whitespace-pre-line text-[13px] leading-7 text-[#111111]">
|
|
{entry.value}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="whitespace-pre-line rounded-[1.15rem] border border-[#ead6c7] bg-white/74 px-4 py-4 text-[13px] leading-7 text-[#111111] md:max-h-[16rem] md:overflow-y-auto">
|
|
{settingText || structuredEmptyText}
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|