Files
MuMuAINovel/frontend/src/pages/BookImport.tsx
T

1132 lines
39 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
Card,
Col,
Collapse,
Empty,
Input,
InputNumber,
List,
message,
Popconfirm,
Progress,
Row,
Select,
Space,
Spin,
Steps,
Tag,
Typography,
Upload,
theme,
} from 'antd';
import type { UploadFile } from 'antd/es/upload/interface';
import { InboxOutlined, PlayCircleOutlined, ReloadOutlined, StopOutlined, WarningOutlined, RedoOutlined } from '@ant-design/icons';
import { bookImportApi } from '../services/api';
import type {
BookImportApplyPayload,
BookImportExtractMode,
BookImportPreview,
BookImportStepFailure,
BookImportTask,
} from '../types';
const { Text, Title } = Typography;
const { Dragger } = Upload;
const { TextArea } = Input;
const BOOK_IMPORT_CACHE_KEY = 'book_import_page_cache_v1';
type BookImportPageCache = {
taskId: string | null;
taskStatus: BookImportTask | null;
preview: BookImportPreview | null;
applyProgress: number;
applyMessage: string;
applyError: string | null;
isApplyComplete: boolean;
cachedAt: number;
};
function loadBookImportCache(): BookImportPageCache | null {
try {
const raw = sessionStorage.getItem(BOOK_IMPORT_CACHE_KEY);
if (!raw) return null;
return JSON.parse(raw) as BookImportPageCache;
} catch (error) {
console.warn('读取拆书页面缓存失败:', error);
return null;
}
}
function saveBookImportCache(cache: BookImportPageCache) {
try {
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(cache));
} catch (error) {
const isQuotaExceeded =
error instanceof DOMException &&
(error.name === 'QuotaExceededError' || error.name === 'NS_ERROR_DOM_QUOTA_REACHED');
if (isQuotaExceeded) {
// 发生容量溢出时降级为轻量缓存(不保存预览正文),避免持续报错
try {
const lightweightCache: BookImportPageCache = {
...cache,
preview: null,
};
sessionStorage.setItem(BOOK_IMPORT_CACHE_KEY, JSON.stringify(lightweightCache));
return;
} catch (fallbackError) {
console.warn('写入轻量拆书页面缓存失败:', fallbackError);
try {
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
} catch {
// ignore
}
}
}
console.warn('写入拆书页面缓存失败:', error);
}
}
function clearBookImportCache() {
try {
sessionStorage.removeItem(BOOK_IMPORT_CACHE_KEY);
} catch (error) {
console.warn('清理拆书页面缓存失败:', error);
}
}
function isNotFoundError(error: unknown): boolean {
if (!error || typeof error !== 'object') return false;
const maybeError = error as { response?: { status?: number } };
return maybeError.response?.status === 404;
}
export default function BookImport() {
const navigate = useNavigate();
const { token } = theme.useToken();
const isMobile = window.innerWidth <= 768;
const [file, setFile] = useState<File | null>(null);
const [extractMode, setExtractMode] = useState<BookImportExtractMode>('tail');
const [tailChapterCount, setTailChapterCount] = useState(10);
const [taskId, setTaskId] = useState<string | null>(null);
const [taskStatus, setTaskStatus] = useState<BookImportTask | null>(null);
const [preview, setPreview] = useState<BookImportPreview | null>(null);
const [creatingTask, setCreatingTask] = useState(false);
const [loadingPreview, setLoadingPreview] = useState(false);
const [applying, setApplying] = useState(false);
const [applyProgress, setApplyProgress] = useState(0);
const [applyMessage, setApplyMessage] = useState('');
const [applyError, setApplyError] = useState<string | null>(null);
const [isApplyComplete, setIsApplyComplete] = useState(false);
const [cacheReady, setCacheReady] = useState(false);
// 步骤级失败和重试相关状态
const [failedSteps, setFailedSteps] = useState<BookImportStepFailure[]>([]);
const [retrying, setRetrying] = useState(false);
const [retryProgress, setRetryProgress] = useState(0);
const [retryMessage, setRetryMessage] = useState('');
const importedProjectId = useRef<string | null>(null);
const isTaskTerminal = useMemo(() => {
return !!taskStatus && ['completed', 'failed', 'cancelled'].includes(taskStatus.status);
}, [taskStatus]);
const currentStep = useMemo(() => {
if (!taskId) return 0;
if (taskStatus && ['pending', 'running'].includes(taskStatus.status)) return 1;
if (applying || isApplyComplete) return 3; // 新增生成导入步骤
if (preview) return 2;
return 1;
}, [taskId, taskStatus, preview, applying, isApplyComplete]);
const canRestart = useMemo(() => {
return Boolean(
file ||
taskId ||
taskStatus ||
preview ||
applyProgress > 0 ||
applyMessage ||
applyError ||
isApplyComplete ||
failedSteps.length > 0 ||
retrying
);
}, [
file,
taskId,
taskStatus,
preview,
applyProgress,
applyMessage,
applyError,
isApplyComplete,
failedSteps,
retrying,
]);
const stepItems = [
{ title: '上传文件' },
{ title: '解析中' },
{ title: '预览修改' },
{ title: '生成导入' },
];
const currentStepText = stepItems[currentStep]?.title || '上传文件';
useEffect(() => {
const cache = loadBookImportCache();
if (cache) {
const cacheAgeMs = typeof cache.cachedAt === 'number'
? Date.now() - cache.cachedAt
: Number.POSITIVE_INFINITY;
// 超过6小时的缓存直接视为失效,避免后端重启后继续使用旧taskId
if (cacheAgeMs > 6 * 60 * 60 * 1000) {
clearBookImportCache();
} else {
setTaskId(cache.taskId);
setTaskStatus(cache.taskStatus);
setPreview(cache.preview);
setApplyProgress(cache.applyProgress);
setApplyError(cache.applyError);
setIsApplyComplete(cache.isApplyComplete);
setApplyMessage(
cache.applyMessage || (cache.applyProgress > 0 && !cache.isApplyComplete
? '已恢复页面缓存,请重新点击“确认导入”继续。'
: '')
);
message.info('已恢复拆书导入页面缓存');
}
}
setCacheReady(true);
}, []);
useEffect(() => {
if (!cacheReady) return;
// 导入完成后必须清理缓存,避免后续回到页面时恢复到旧任务状态
if (isApplyComplete) {
clearBookImportCache();
return;
}
const hasCacheData = Boolean(
taskId ||
taskStatus ||
preview ||
applyError ||
applyProgress > 0 ||
applyMessage
);
if (!hasCacheData) {
clearBookImportCache();
return;
}
saveBookImportCache({
taskId,
taskStatus,
// preview 含完整章节正文,体积大,容易触发 sessionStorage 配额限制
// 页面恢复时可根据 taskId + taskStatus 重新拉取 preview
preview: null,
applyProgress,
applyMessage,
applyError,
isApplyComplete,
cachedAt: Date.now(),
});
}, [
cacheReady,
taskId,
taskStatus,
preview,
applyProgress,
applyMessage,
applyError,
isApplyComplete,
]);
useEffect(() => {
if (!taskId) return;
if (isTaskTerminal) return;
const timer = setInterval(async () => {
try {
const status = await bookImportApi.getTaskStatus(taskId);
setTaskStatus(status);
} catch (error) {
console.error('轮询任务状态失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('拆书任务已失效(可能因服务重启),请重新上传TXT并开始解析');
}
}
}, 1500);
return () => clearInterval(timer);
}, [taskId, isTaskTerminal]);
useEffect(() => {
const fetchPreview = async () => {
if (!taskId || !taskStatus) return;
if (taskStatus.status !== 'completed' || preview) return;
try {
setLoadingPreview(true);
const data = await bookImportApi.getPreview(taskId);
setPreview(data);
} catch (error) {
console.error('获取预览失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('拆书任务预览不存在(可能因服务重启),已清空缓存,请重新上传TXT');
} else {
message.error('获取预览失败');
}
} finally {
setLoadingPreview(false);
}
};
fetchPreview();
}, [taskId, taskStatus, preview]);
const startTask = async () => {
if (!file) {
message.warning('请先选择 TXT 文件');
return;
}
try {
setCreatingTask(true);
setPreview(null);
setTaskStatus(null);
const normalizedTailChapterCount = Math.max(5, Math.ceil(tailChapterCount / 5) * 5);
const normalizedExtractMode = normalizedTailChapterCount > 50 ? 'full' : extractMode;
const response = await bookImportApi.createTask({
file,
extract_mode: normalizedExtractMode,
tail_chapter_count: normalizedTailChapterCount,
});
setTaskId(response.task_id);
message.success('拆书任务已创建');
} catch (error) {
console.error('创建任务失败:', error);
message.error('创建拆书任务失败');
} finally {
setCreatingTask(false);
}
};
const refreshStatus = async () => {
if (!taskId) return;
try {
const status = await bookImportApi.getTaskStatus(taskId);
setTaskStatus(status);
} catch (error) {
console.error('刷新状态失败:', error);
if (isNotFoundError(error)) {
clearBookImportCache();
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
message.warning('任务不存在,已清空本地缓存,请重新创建拆书任务');
}
}
};
const cancelTask = async () => {
if (!taskId) return;
try {
await bookImportApi.cancelTask(taskId);
message.success('任务已取消');
await refreshStatus();
} catch (error) {
console.error('取消任务失败:', error);
message.error('取消任务失败');
}
};
const applyImport = async () => {
if (!taskId || !preview) return;
const payload: BookImportApplyPayload = {
project_suggestion: preview.project_suggestion,
chapters: preview.chapters,
outlines: preview.outlines,
import_mode: 'append',
};
try {
setApplying(true);
setApplyProgress(0);
setApplyMessage('准备导入...');
setApplyError(null);
setIsApplyComplete(false);
setFailedSteps([]);
await bookImportApi.applyImportStream(
taskId,
payload,
{
onProgress: (msg, prog, status) => {
// 检查是否是步骤失败的特殊消息
if (status === 'step_failures') {
try {
const parsed = JSON.parse(msg);
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
}
} catch {
// 不是JSON,忽略
}
return;
}
setApplyProgress(prog);
setApplyMessage(msg);
},
onResult: (result) => {
importedProjectId.current = result.project_id;
const generatedCareers = result.statistics?.generated_careers ?? 0;
const generatedEntities = result.statistics?.generated_entities ?? 0;
// 检查最终是否有失败步骤
setIsApplyComplete(true);
// 如果没有失败步骤才自动跳转
// 注意:这里需要延迟一帧来等待 failedSteps 的更新
setTimeout(() => {
setFailedSteps(prev => {
if (prev.length === 0) {
message.success(`导入成功:已生成职业${generatedCareers}个,角色/组织${generatedEntities}`);
clearBookImportCache();
setTimeout(() => {
navigate(`/project/${result.project_id}/chapters`);
}, 1000);
} else {
message.warning(`导入完成,但有 ${prev.length} 个生成步骤失败,可点击重试`);
}
return prev;
});
}, 100);
},
onError: (error) => {
console.error('导入过程发生错误:', error);
setApplyError(`导入失败: ${error}`);
message.error(`导入失败: ${error}`);
setApplying(false);
},
onComplete: () => {
setApplyProgress(100);
setApplyMessage('导入完成!');
}
}
);
} catch (error) {
console.error('确认导入失败:', error);
setApplyError('确认导入失败,无法连接到服务器');
message.error('确认导入失败');
setApplying(false);
}
};
const retryFailedSteps = useCallback(async () => {
if (!taskId || failedSteps.length === 0) return;
const stepsToRetry = failedSteps.map(f => f.step_name);
try {
setRetrying(true);
setRetryProgress(0);
setRetryMessage('正在重试失败的生成步骤...');
await bookImportApi.retryFailedStepsStream(
taskId,
stepsToRetry,
{
onProgress: (msg, prog, status) => {
if (status === 'step_failures') {
try {
const parsed = JSON.parse(msg);
if (parsed.failed_steps && Array.isArray(parsed.failed_steps)) {
setFailedSteps(parsed.failed_steps as BookImportStepFailure[]);
}
} catch {
// 不是JSON,忽略
}
return;
}
setRetryProgress(prog);
setRetryMessage(msg);
},
onResult: (result) => {
if (result.still_failed && result.still_failed.length > 0) {
setFailedSteps(result.still_failed);
message.warning(`重试完成,仍有 ${result.still_failed.length} 个步骤失败`);
} else {
setFailedSteps([]);
message.success('所有步骤重试成功!');
clearBookImportCache();
const projectId = result.project_id || importedProjectId.current;
if (projectId) {
setTimeout(() => {
navigate(`/project/${projectId}/chapters`);
}, 1000);
}
}
},
onError: (error) => {
console.error('重试失败:', error);
message.error(`重试失败: ${error}`);
},
onComplete: () => {
setRetrying(false);
setRetryProgress(100);
setRetryMessage('重试完成');
}
}
);
} catch (error) {
console.error('重试请求失败:', error);
message.error('重试请求失败,无法连接到服务器');
setRetrying(false);
}
}, [taskId, failedSteps, navigate]);
const skipFailedSteps = useCallback(() => {
setFailedSteps([]);
clearBookImportCache();
const projectId = importedProjectId.current;
if (projectId) {
message.info('已跳过失败步骤,正在跳转到项目...');
navigate(`/project/${projectId}/chapters`);
}
}, [navigate]);
const restartImport = useCallback(() => {
clearBookImportCache();
importedProjectId.current = null;
setFile(null);
setTaskId(null);
setTaskStatus(null);
setPreview(null);
setCreatingTask(false);
setLoadingPreview(false);
setApplying(false);
setApplyProgress(0);
setApplyMessage('');
setApplyError(null);
setIsApplyComplete(false);
setFailedSteps([]);
setRetrying(false);
setRetryProgress(0);
setRetryMessage('');
setExtractMode('tail');
setTailChapterCount(10);
message.success('已重新开始,请重新上传 TXT 并解析');
}, []);
const updateChapter = (index: number, patch: Partial<BookImportPreview['chapters'][number]>) => {
setPreview(prev => {
if (!prev) return prev;
const next = [...prev.chapters];
next[index] = { ...next[index], ...patch };
return { ...prev, chapters: next };
});
};
return (
<div
style={{
minHeight: '90vh',
overflow: 'auto',
background: `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`,
padding: isMobile ? '20px 16px 70px' : '24px 24px 70px',
}}
>
<div style={{ maxWidth: 1400, margin: '0 auto', width: '100%' }}>
<Card
variant="borderless"
style={{
background: `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`,
borderRadius: isMobile ? 16 : 20,
boxShadow: token.boxShadowSecondary,
marginBottom: isMobile ? 14 : 16,
border: 'none',
position: 'relative',
overflow: 'hidden',
}}
>
<div style={{ position: 'absolute', top: -48, right: -48, width: 160, height: 160, borderRadius: '50%', background: token.colorWhite, opacity: 0.08, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -40, left: '26%', width: 110, height: 110, borderRadius: '50%', background: token.colorWhite, opacity: 0.05, pointerEvents: 'none' }} />
<Row align="middle" justify="space-between" gutter={[16, 16]} style={{ position: 'relative', zIndex: 1 }}>
<Col xs={24} sm={12}>
<Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: token.colorWhite, textShadow: `0 2px 4px ${token.colorBgMask}` }}>
<InboxOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} />
</Title>
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextLightSolid, opacity: 0.85, marginLeft: isMobile ? 40 : 48 }}>
TXT并自动解析为章节
</Text>
</Space>
</Col>
<Col xs={24} sm={12}>
<Space
size={12}
style={{
width: '100%',
display: 'flex',
justifyContent: isMobile ? 'flex-start' : 'flex-end',
}}
>
<Tag
style={{
marginInlineEnd: 0,
background: token.colorWhite,
border: `1px solid ${token.colorWhite}`,
color: token.colorPrimary,
fontWeight: 600,
borderRadius: 8,
paddingInline: 10,
}}
>
{currentStepText}
</Tag>
<Popconfirm
title="确认重新开始?"
description="将清空当前拆书任务与缓存,并回到上传文件步骤。"
onConfirm={restartImport}
okText="重新开始"
cancelText="取消"
disabled={!canRestart}
>
<Button
danger
type="primary"
icon={<ReloadOutlined />}
disabled={!canRestart}
style={{ boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)', borderRadius: 10 }}
>
</Button>
</Popconfirm>
</Space>
</Col>
</Row>
<Card
variant="borderless"
style={{
marginTop: isMobile ? 14 : 18,
borderRadius: 12,
background: token.colorBgContainer,
border: `1px solid ${token.colorBorderSecondary}`,
boxShadow: token.boxShadow,
}}
styles={{ body: { padding: isMobile ? '10px 12px' : '12px 16px' } }}
>
<Steps current={currentStep} size={isMobile ? 'small' : 'default'} items={stepItems} />
</Card>
</Card>
{currentStep === 0 && (
<Card title="上传 TXT 并开始解析" style={{ marginBottom: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
<Dragger
accept=".txt"
multiple={false}
beforeUpload={(f) => {
setFile(f);
return false;
}}
onRemove={() => {
setFile(null);
}}
fileList={
file
? [
{
uid: 'selected-txt',
name: file.name,
status: 'done',
} as UploadFile,
]
: []
}
style={{ padding: '8px 0' }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text"> TXT </p>
<p className="ant-upload-hint"> .txt 50MB</p>
</Dragger>
<Card size="small" title="解析范围设置">
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Select
value={extractMode}
onChange={(value) => setExtractMode(value)}
options={[
{ label: '截取末 x 章反向生成', value: 'tail' },
{ label: '整本反向生成', value: 'full' },
]}
style={{ width: '100%' }}
/>
<InputNumber
min={5}
max={55}
step={5}
precision={0}
value={tailChapterCount}
disabled={extractMode !== 'tail'}
onChange={(value) => setTailChapterCount(typeof value === 'number' ? value : 10)}
addonBefore="末尾章节数"
style={{ width: '100%' }}
/>
<Text type="secondary">
{extractMode === 'tail'
? tailChapterCount > 50
? '当前输入已超过 50 章,将自动按整本拆处理。'
: `当前将截取末 ${Math.max(5, Math.ceil(tailChapterCount / 5) * 5)} 章进行反向生成;章节数必须为 5 的倍数,最多 50 章。`
: '当前将基于整本内容进行反向生成,适合完整拆书但耗时可能更长。'}
</Text>
</Space>
</Card>
<Alert
type="info"
showIcon
message="支持的拆书 TXT 格式要求"
description={
<div style={{ lineHeight: 1.8 }}>
<div>1. <strong>.txt</strong> 使</div>
<div>2. <strong>1 </strong></div>
<div>3. </div>
<div>4. 线</div>
<div style={{ marginTop: 8 }}>
<pre style={{ margin: '8px 0 0', padding: 12, borderRadius: 8, background: token.colorFillAlter, whiteSpace: 'pre-wrap' }}>
{`第1章 初入江湖
这里是第1章正文第一段。
这里是第1章正文第二段。
第2章 雨夜追踪
这里是第2章正文内容。`}
</pre>
</div>
</div>
}
/>
<Space wrap>
<Button
type="primary"
icon={<PlayCircleOutlined />}
loading={creatingTask}
onClick={startTask}
>
</Button>
{taskId && (
<Tag color="blue">ID: {taskId}</Tag>
)}
</Space>
</Space>
</Card>
)}
{currentStep === 1 && (
<Card title="解析任务状态" style={{ marginBottom: 16 }}>
{!taskId ? (
<Empty description="尚未创建任务" />
) : (
<div style={{ textAlign: 'center', padding: '24px 0' }}>
<Progress
type="circle"
percent={taskStatus?.progress || 0}
status={
taskStatus?.status === 'failed' ? 'exception' :
taskStatus?.status === 'completed' ? 'success' :
'active'
}
/>
<div style={{ marginTop: 24 }}>
<Text strong style={{ fontSize: 16 }}>
{taskStatus?.status === 'pending' && '等待调度...'}
{taskStatus?.status === 'running' && '正在解析TXT文件...'}
{taskStatus?.status === 'completed' && '解析完成!正在生成预览...'}
{taskStatus?.status === 'failed' && '解析失败'}
{taskStatus?.status === 'cancelled' && '已取消'}
</Text>
{taskStatus?.message && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">{taskStatus.message}</Text>
</div>
)}
</div>
{taskStatus?.error && (
<Alert type="error" message={taskStatus.error} showIcon style={{ marginTop: 16, textAlign: 'left' }} />
)}
<Space style={{ marginTop: 24 }}>
<Button icon={<ReloadOutlined />} onClick={refreshStatus}></Button>
{taskStatus && ['pending', 'running'].includes(taskStatus.status) && (
<Button danger icon={<StopOutlined />} onClick={cancelTask}></Button>
)}
</Space>
</div>
)}
</Card>
)}
{currentStep === 2 && (
<>
<Card
title="预览修正"
extra={
<Button
type="primary"
loading={applying}
disabled={!preview}
onClick={applyImport}
>
</Button>
}
style={{ marginBottom: 16 }}
>
<Spin spinning={loadingPreview}>
{!preview ? (
<Empty description="解析完成后将显示预览数据" />
) : (
<div style={{ maxHeight: '60vh', overflowY: 'auto', paddingRight: 8 }}>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
{preview.warnings.length > 0 && (
<Alert
type="warning"
showIcon
message="检测到告警"
description={
<ul style={{ margin: 0, paddingLeft: 20 }}>
{preview.warnings.map((w, idx) => (
<li key={`${w.code}-${idx}`}>[{w.level}] {w.message}</li>
))}
</ul>
}
/>
)}
<Card
size="small"
title="项目信息"
>
<Row gutter={12}>
<Col xs={24} md={12}>
<Text></Text>
<Input
value={preview.project_suggestion.title}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, title: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<Input
value={preview.project_suggestion.genre}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, genre: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24}>
<Text></Text>
<TextArea
rows={3}
value={preview.project_suggestion.theme}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, theme: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24}>
<Text></Text>
<TextArea
rows={3}
value={preview.project_suggestion.description}
onChange={(e) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, description: e.target.value },
}) : prev)
}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<Select
style={{ width: '100%' }}
value={preview.project_suggestion.narrative_perspective}
onChange={(v) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: { ...prev.project_suggestion, narrative_perspective: v },
}) : prev)
}
options={[
{ value: '第一人称', label: '第一人称' },
{ value: '第三人称', label: '第三人称' },
{ value: '全知视角', label: '全知视角' },
]}
/>
</Col>
<Col xs={24} md={12}>
<Text></Text>
<InputNumber
style={{ width: '100%' }}
min={1000}
step={1000}
value={preview.project_suggestion.target_words}
onChange={(v) =>
setPreview(prev => prev ? ({
...prev,
project_suggestion: {
...prev.project_suggestion,
target_words: Number(v || 100000),
},
}) : prev)
}
/>
</Col>
</Row>
</Card>
<Card size="small" title={`章节(${preview.chapters.length}`}>
<Collapse
items={preview.chapters.map((ch, idx) => ({
key: String(idx),
label: `${ch.chapter_number} 章 · ${ch.title}`,
children: (
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={ch.title}
addonBefore="标题"
onChange={(e) => updateChapter(idx, { title: e.target.value })}
/>
<TextArea
rows={2}
value={ch.summary}
placeholder="章节摘要"
onChange={(e) => updateChapter(idx, { summary: e.target.value })}
/>
<TextArea
rows={8}
value={ch.content}
placeholder="章节正文"
onChange={(e) => updateChapter(idx, { content: e.target.value })}
/>
</Space>
),
}))}
/>
</Card>
</Space>
</div>
)}
</Spin>
</Card>
</>
)}
{currentStep === 3 && (
<Card title="生成导入进度" style={{ marginBottom: 16 }}>
<div style={{ textAlign: 'center', padding: '40px 20px', maxWidth: 600, margin: '0 auto' }}>
<Typography.Title level={4} style={{ marginBottom: 32 }}>
{retrying ? '正在重试失败的生成步骤' : (failedSteps.length > 0 && isApplyComplete ? '导入完成,部分步骤需要重试' : '正在为您生成并导入项目内容')}
</Typography.Title>
<Progress
percent={retrying ? retryProgress : applyProgress}
status={
applyError ? 'exception' :
(failedSteps.length > 0 && isApplyComplete && !retrying) ? 'exception' :
(isApplyComplete && failedSteps.length === 0) ? 'success' :
'active'
}
strokeColor={{
'0%': 'var(--color-primary)',
'100%': failedSteps.length > 0 ? '#faad14' : 'var(--color-primary-active)',
}}
style={{ marginBottom: 24 }}
/>
<Typography.Paragraph
style={{
fontSize: 16,
marginBottom: 32,
color: applyError ? 'var(--color-error)' :
(failedSteps.length > 0 && isApplyComplete && !retrying) ? '#faad14' :
'var(--color-text-secondary)'
}}
>
{retrying ? retryMessage : (applyError || applyMessage)}
</Typography.Paragraph>
{applyError && (
<Alert
type="error"
message="导入出错"
description={applyError}
showIcon
style={{ textAlign: 'left', marginBottom: 24 }}
/>
)}
{/* 步骤失败提示与重试UI */}
{failedSteps.length > 0 && isApplyComplete && !retrying && (
<div style={{ textAlign: 'left', marginBottom: 24 }}>
<Alert
type="warning"
icon={<WarningOutlined />}
showIcon
message={`${failedSteps.length} 个生成步骤失败`}
description={
<div>
<Typography.Paragraph style={{ marginBottom: 12, color: 'rgba(0,0,0,0.65)' }}>
AI生成步骤未能完成
</Typography.Paragraph>
<List
size="small"
bordered
dataSource={failedSteps}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 12px' }}
>
<List.Item.Meta
title={
<Space>
<Tag color="error">{item.step_label}</Tag>
{(item.retry_count ?? 0) > 0 && (
<Tag color="orange"> {item.retry_count} </Tag>
)}
</Space>
}
description={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.error.length > 120 ? item.error.slice(0, 120) + '...' : item.error}
</Typography.Text>
}
/>
</List.Item>
)}
/>
<Space style={{ marginTop: 16, display: 'flex', justifyContent: 'center' }}>
<Button
type="primary"
icon={<RedoOutlined />}
onClick={retryFailedSteps}
loading={retrying}
>
</Button>
<Button onClick={skipFailedSteps}>
</Button>
</Space>
</div>
}
style={{ marginBottom: 16 }}
/>
</div>
)}
{/* 重试进行中 */}
{retrying && (
<div style={{ marginBottom: 24 }}>
<Spin spinning={retrying}>
<Alert
type="info"
showIcon
message="正在重试..."
description={retryMessage}
style={{ textAlign: 'left' }}
/>
</Spin>
</div>
)}
{!failedSteps.length && !retrying && (
<div style={{
background: 'var(--color-bg-layout)',
padding: 16,
borderRadius: 8,
textAlign: 'left',
marginTop: 32
}}>
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
AI会自动帮您补全<br />
<br />
<br />
<br />
{isApplyComplete ? '所有步骤已完成,即将自动跳转。' : '请耐心等待,完成后将自动跳转。'}
</Typography.Text>
</div>
)}
</div>
</Card>
)}
</div>
</div>
);
}