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(null); const [extractMode, setExtractMode] = useState('tail'); const [tailChapterCount, setTailChapterCount] = useState(10); const [taskId, setTaskId] = useState(null); const [taskStatus, setTaskStatus] = useState(null); const [preview, setPreview] = useState(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(null); const [isApplyComplete, setIsApplyComplete] = useState(false); const [cacheReady, setCacheReady] = useState(false); // 步骤级失败和重试相关状态 const [failedSteps, setFailedSteps] = useState([]); const [retrying, setRetrying] = useState(false); const [retryProgress, setRetryProgress] = useState(0); const [retryMessage, setRetryMessage] = useState(''); const importedProjectId = useRef(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) => { setPreview(prev => { if (!prev) return prev; const next = [...prev.chapters]; next[index] = { ...next[index], ...patch }; return { ...prev, chapters: next }; }); }; return (
<InboxOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} /> 拆书导入 上传TXT并自动解析为章节、预览并导入项目 当前进度:{currentStepText} {currentStep === 0 && ( { setFile(f); return false; }} onRemove={() => { setFile(null); }} fileList={ file ? [ { uid: 'selected-txt', name: file.name, status: 'done', } as UploadFile, ] : [] } style={{ padding: '8px 0' }} >

点击或拖拽 TXT 文件到此区域

首版仅支持 .txt,建议不超过 50MB

setPreview(prev => prev ? ({ ...prev, project_suggestion: { ...prev.project_suggestion, title: e.target.value }, }) : prev) } /> 类型 setPreview(prev => prev ? ({ ...prev, project_suggestion: { ...prev.project_suggestion, genre: e.target.value }, }) : prev) } /> 主题