Files
Genarrative/src/components/CustomWorldGenerationView.tsx
kdletters 1ad25e30f8 收口前端平台组件库能力
新增 PlatformUiKit 通用弹窗、按钮、状态、空态、媒体、表单和标签等公共组件
迁移结果页、创作工作台、认证入口、RPG 暗色面板和运行态弹窗的重复 UI chrome
补充组件测试、页面回归测试、技术文档和 Hermes 共享决策记录
2026-06-10 10:24:18 +08:00

203 lines
6.2 KiB
TypeScript

import { ArrowLeft } from 'lucide-react';
import type { CustomWorldGenerationProgress } from '../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../services/customWorldAgentGenerationProgress';
import { PlatformActionButton } from './common/PlatformActionButton';
import { PlatformPillBadge } from './common/PlatformPillBadge';
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
);
}
export function CustomWorldGenerationView({
progress,
isGenerating,
onBack,
onRetry,
onInterrupt,
backLabel = '返回',
retryLabel = '重新开始生成',
interruptLabel = '中断世界生成',
progressTitle = '生成进度',
activeBadgeLabel = '世界建设中',
idleBadgeLabel = '等待操作',
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 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-hidden 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">
<GenerationPageBackdrop />
<div className="relative z-30 mb-4 flex shrink-0 items-center justify-between gap-3 py-2 sm:mb-5">
<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>
<PlatformPillBadge
tone="warning"
size="xs"
className="px-3 py-1.5 tracking-[0.08em] shadow-[0_12px_30px_rgba(214,77,31,0.08)] backdrop-blur-md sm:px-4 sm:text-xs"
>
{isGenerating ? activeBadgeLabel : idleBadgeLabel}
</PlatformPillBadge>
</div>
<div
className="relative z-10 flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-y-contain"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<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-5 px-0 sm:mt-[-0.15rem] 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 ? (
<PlatformActionButton
onClick={onRetry}
fullWidth
className="sm:w-auto"
>
{retryLabel}
</PlatformActionButton>
) : onInterrupt ? (
<PlatformActionButton
tone="danger"
shape="pill"
onClick={onInterrupt}
className="transition-colors hover:text-[var(--platform-text-strong)]"
>
{interruptLabel}
</PlatformActionButton>
) : null}
</div>
</section>
</div>
</div>
);
}