feature:新增拆书导入续写功能,给当年的ta一个结局。
This commit is contained in:
@@ -0,0 +1,918 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
|
||||
import SettingsPage from './Settings';
|
||||
import MCPPluginsPage from './MCPPlugins';
|
||||
import PromptTemplates from './PromptTemplates';
|
||||
import BookImport from './BookImport';
|
||||
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
|
||||
@@ -39,7 +40,7 @@ const formatWordCount = (count: number): string => {
|
||||
export default function ProjectList() {
|
||||
const navigate = useNavigate();
|
||||
const { projects, loading } = useStore();
|
||||
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts'>('projects');
|
||||
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'>('projects');
|
||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||
const [modal, contextHolder] = Modal.useModal();
|
||||
const [showApiTip, setShowApiTip] = useState(true);
|
||||
@@ -400,27 +401,27 @@ export default function ProjectList() {
|
||||
|
||||
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>创作工具</div>
|
||||
<div
|
||||
onClick={() => setActiveView('prompts')}
|
||||
onClick={() => setActiveView('book-import')}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
||||
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
|
||||
color: activeView === 'book-import' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
||||
background: activeView === 'book-import' ? '#e6f7ff' : 'transparent',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
transition: 'all 0.3s',
|
||||
marginBottom: 4,
|
||||
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
||||
borderRight: activeView === 'book-import' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
||||
}}
|
||||
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
||||
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
|
||||
onMouseEnter={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
||||
onMouseLeave={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<FileSearchOutlined />
|
||||
提示词管理
|
||||
<UploadOutlined />
|
||||
拆书导入
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveView('mcp')}
|
||||
@@ -445,6 +446,29 @@ export default function ProjectList() {
|
||||
<ApiOutlined />
|
||||
MCP 插件
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setActiveView('prompts')}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
||||
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
transition: 'all 0.3s',
|
||||
marginBottom: 4,
|
||||
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
||||
}}
|
||||
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
||||
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<FileSearchOutlined />
|
||||
提示词管理
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>系统设置</div>
|
||||
<div
|
||||
@@ -530,6 +554,7 @@ export default function ProjectList() {
|
||||
}}>
|
||||
{activeView === 'projects' ? '我的书架' :
|
||||
activeView === 'prompts' ? '提示词模板' :
|
||||
activeView === 'book-import' ? '拆书导入' :
|
||||
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -577,7 +602,7 @@ export default function ProjectList() {
|
||||
selectedKeys={[activeView]}
|
||||
style={{ borderRight: 0, paddingTop: 8 }}
|
||||
onClick={({ key }) => {
|
||||
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts');
|
||||
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import');
|
||||
setDrawerVisible(false);
|
||||
}}
|
||||
items={[
|
||||
@@ -591,15 +616,20 @@ export default function ProjectList() {
|
||||
label: '创作工具',
|
||||
children: [
|
||||
{
|
||||
key: 'prompts',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '提示词管理',
|
||||
key: 'book-import',
|
||||
icon: <UploadOutlined />,
|
||||
label: '拆书导入',
|
||||
},
|
||||
{
|
||||
key: 'mcp',
|
||||
icon: <ApiOutlined />,
|
||||
label: 'MCP 插件',
|
||||
},
|
||||
{
|
||||
key: 'prompts',
|
||||
icon: <FileSearchOutlined />,
|
||||
label: '提示词管理',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -676,6 +706,7 @@ export default function ProjectList() {
|
||||
}}>
|
||||
{activeView === 'projects' ? '我的书架' :
|
||||
activeView === 'prompts' ? '提示词模板' :
|
||||
activeView === 'book-import' ? '拆书导入' :
|
||||
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
||||
</h2>
|
||||
|
||||
@@ -743,7 +774,9 @@ export default function ProjectList() {
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
padding: activeView === 'projects' ? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px` : 0,
|
||||
padding: (activeView === 'projects' || activeView === 'book-import')
|
||||
? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px`
|
||||
: 0,
|
||||
background: 'var(--color-bg-base)',
|
||||
}}
|
||||
>
|
||||
@@ -751,6 +784,12 @@ export default function ProjectList() {
|
||||
{activeView === 'mcp' && <MCPPluginsPage />}
|
||||
{activeView === 'prompts' && <PromptTemplates />}
|
||||
|
||||
{activeView === 'book-import' && (
|
||||
<div style={{ maxWidth: 1200, margin: '0 auto', paddingBottom: 60 }}>
|
||||
<BookImport />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeView === 'projects' && (
|
||||
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user