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

918 lines
31 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,
Progress,
Row,
Select,
Space,
Spin,
Steps,
Tag,
Typography,
Upload,
} 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,
BookImportPreview,
BookImportStepFailure,
BookImportTask,
} from '../types';
const { Text } = 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 [file, setFile] = useState<File | null>(null);
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]);
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 response = await bookImportApi.createTask({
file,
});
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 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={{ height: '100%', overflow: 'auto', paddingRight: 8 }}>
<Card style={{ marginBottom: 16 }}>
<Steps
current={currentStep}
items={[
{ title: '上传文件' },
{ title: '解析中' },
{ title: '预览修改' },
{ title: '生成导入' },
]}
/>
</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>
<Space>
<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>
);
}