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

2469 lines
90 KiB
TypeScript
Raw Normal View History

2025-11-18 22:14:55 +08:00
import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons';
2025-10-30 11:14:43 +08:00
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi, chapterApi } from '../services/api';
2025-11-18 22:14:55 +08:00
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
2025-11-14 10:24:53 +08:00
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel';
2025-10-30 11:14:43 +08:00
const { TextArea } = Input;
export default function Chapters() {
const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore();
const [modal, contextHolder] = Modal.useModal();
2025-10-30 11:14:43 +08:00
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form] = Form.useForm();
const [editorForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const contentTextAreaRef = useRef<any>(null);
2025-10-31 17:23:25 +08:00
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
const [availableModels, setAvailableModels] = useState<Array<{ value: string, label: string }>>([]);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
const [batchSelectedModel, setBatchSelectedModel] = useState<string | undefined>(); // 批量生成的模型选择
const [temporaryNarrativePerspective, setTemporaryNarrativePerspective] = useState<string | undefined>(); // 临时人称选择
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
2025-11-05 00:11:27 +08:00
// 分析任务状态管理
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
const pollingIntervalsRef = useRef<Record<string, number>>({});
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
// 规划编辑状态
const [planEditorVisible, setPlanEditorVisible] = useState(false);
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
2025-11-14 10:24:53 +08:00
// 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
// 批量生成相关状态
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
const [batchGenerating, setBatchGenerating] = useState(false);
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
const [batchForm] = Form.useForm();
const [manualCreateForm] = Form.useForm();
const [batchProgress, setBatchProgress] = useState<{
status: string;
total: number;
completed: number;
current_chapter_number: number | null;
estimated_time_minutes?: number;
} | null>(null);
const batchPollingIntervalRef = useRef<number | null>(null);
2025-10-30 11:14:43 +08:00
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const {
refreshChapters,
updateChapter,
2025-11-19 14:01:30 +08:00
deleteChapter,
2025-10-30 11:14:43 +08:00
generateChapterContentStream
} = useChapterSync();
useEffect(() => {
if (currentProject?.id) {
refreshChapters();
2025-10-31 17:23:25 +08:00
loadWritingStyles();
2025-11-05 00:11:27 +08:00
loadAnalysisTasks();
checkAndRestoreBatchTask();
2025-10-30 11:14:43 +08:00
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
2025-11-05 00:11:27 +08:00
// 清理轮询定时器
useEffect(() => {
return () => {
Object.values(pollingIntervalsRef.current).forEach(interval => {
clearInterval(interval);
});
if (batchPollingIntervalRef.current) {
clearInterval(batchPollingIntervalRef.current);
}
2025-11-05 00:11:27 +08:00
};
}, []);
// 加载所有章节的分析任务状态
const loadAnalysisTasks = async () => {
if (!chapters || chapters.length === 0) return;
2025-11-05 00:11:27 +08:00
const tasksMap: Record<string, AnalysisTask> = {};
2025-11-05 00:11:27 +08:00
for (const chapter of chapters) {
// 只查询有内容的章节
if (chapter.content && chapter.content.trim() !== '') {
try {
const response = await fetch(`/api/chapters/${chapter.id}/analysis/status`);
if (response.ok) {
const task: AnalysisTask = await response.json();
tasksMap[chapter.id] = task;
2025-11-05 00:11:27 +08:00
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapter.id);
}
}
} catch (error) {
// 404或其他错误表示没有分析任务,忽略
console.debug(`章节 ${chapter.id} 暂无分析任务`);
}
}
}
2025-11-05 00:11:27 +08:00
setAnalysisTasksMap(tasksMap);
};
// 启动单个章节的任务轮询
const startPollingTask = (chapterId: string) => {
// 如果已经在轮询,先清除
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
}
2025-11-05 00:11:27 +08:00
const interval = window.setInterval(async () => {
try {
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (!response.ok) return;
2025-11-05 00:11:27 +08:00
const task: AnalysisTask = await response.json();
2025-11-05 00:11:27 +08:00
setAnalysisTasksMap(prev => ({
...prev,
[chapterId]: task
}));
2025-11-05 00:11:27 +08:00
// 任务完成或失败,停止轮询
if (task.status === 'completed' || task.status === 'failed') {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
2025-11-05 00:11:27 +08:00
if (task.status === 'completed') {
message.success(`章节分析完成`);
} else if (task.status === 'failed') {
message.error(`章节分析失败: ${task.error_message || '未知错误'}`);
}
}
} catch (error) {
console.error('轮询分析任务失败:', error);
}
}, 2000);
2025-11-05 00:11:27 +08:00
pollingIntervalsRef.current[chapterId] = interval;
2025-11-05 00:11:27 +08:00
// 5分钟超时
setTimeout(() => {
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
}
}, 300000);
};
2025-10-31 17:23:25 +08:00
const loadWritingStyles = async () => {
if (!currentProject?.id) return;
2025-10-31 17:23:25 +08:00
try {
const response = await writingStyleApi.getProjectStyles(currentProject.id);
setWritingStyles(response.styles);
2025-10-31 17:23:25 +08:00
// 设置默认风格为初始选中
const defaultStyle = response.styles.find(s => s.is_default);
if (defaultStyle) {
setSelectedStyleId(defaultStyle.id);
}
} catch (error) {
console.error('加载写作风格失败:', error);
message.error('加载写作风格失败');
}
};
const loadAvailableModels = async () => {
try {
// 从设置API获取用户配置的模型列表
const settingsResponse = await fetch('/api/settings');
if (settingsResponse.ok) {
const settings = await settingsResponse.json();
const { api_key, api_base_url, api_provider } = settings;
if (api_key && api_base_url) {
try {
const modelsResponse = await fetch(
`/api/settings/models?api_key=${encodeURIComponent(api_key)}&api_base_url=${encodeURIComponent(api_base_url)}&provider=${api_provider}`
);
if (modelsResponse.ok) {
const data = await modelsResponse.json();
if (data.models && data.models.length > 0) {
setAvailableModels(data.models);
// 设置默认模型为当前配置的模型
setSelectedModel(settings.llm_model);
return settings.llm_model; // 返回模型名称
}
}
} catch (error) {
console.log('获取模型列表失败,将使用默认模型');
}
}
}
} catch (error) {
console.error('加载可用模型失败:', error);
}
return null;
};
// 检查并恢复批量生成任务
const checkAndRestoreBatchTask = async () => {
if (!currentProject?.id) return;
try {
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate/active`);
if (!response.ok) return;
const data = await response.json();
if (data.has_active_task && data.task) {
const task = data.task;
// 恢复任务状态
setBatchTaskId(task.batch_id);
setBatchProgress({
status: task.status,
total: task.total,
completed: task.completed,
current_chapter_number: task.current_chapter_number,
});
setBatchGenerating(true);
setBatchGenerateVisible(true);
// 启动轮询
startBatchPolling(task.batch_id);
message.info('检测到未完成的批量生成任务,已自动恢复');
}
} catch (error) {
console.error('检查批量生成任务失败:', error);
}
};
2025-10-30 11:14:43 +08:00
if (!currentProject) return null;
// 获取人称的中文显示文本
const getNarrativePerspectiveText = (perspective?: string): string => {
const texts: Record<string, string> = {
'first_person': '第一人称(我)',
'third_person': '第三人称(他/她)',
'omniscient': '全知视角',
};
return texts[perspective || ''] || '第三人称(默认)';
};
2025-10-30 11:14:43 +08:00
const canGenerateChapter = (chapter: Chapter): boolean => {
if (chapter.chapter_number === 1) {
return true;
}
2025-10-30 11:14:43 +08:00
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
);
2025-10-30 11:14:43 +08:00
return previousChapters.every(c => c.content && c.content.trim() !== '');
};
const getGenerateDisabledReason = (chapter: Chapter): string => {
if (chapter.chapter_number === 1) {
return '';
}
2025-10-30 11:14:43 +08:00
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
);
2025-10-30 11:14:43 +08:00
const incompleteChapters = previousChapters.filter(
c => !c.content || c.content.trim() === ''
);
2025-10-30 11:14:43 +08:00
if (incompleteChapters.length > 0) {
const numbers = incompleteChapters.map(c => c.chapter_number).join('、');
return `需要先完成前置章节:第 ${numbers}`;
}
2025-10-30 11:14:43 +08:00
return '';
};
const handleOpenModal = (id: string) => {
const chapter = chapters.find(c => c.id === id);
if (chapter) {
form.setFieldsValue(chapter);
setEditingId(id);
setIsModalOpen(true);
}
};
const handleSubmit = async (values: ChapterUpdate) => {
if (!editingId) return;
2025-10-30 11:14:43 +08:00
try {
await updateChapter(editingId, values);
// 刷新章节列表以获取完整的章节数据(包括outline_title等联查字段)
await refreshChapters();
2025-10-30 11:14:43 +08:00
message.success('章节更新成功');
setIsModalOpen(false);
form.resetFields();
} catch {
message.error('操作失败');
}
};
const handleOpenEditor = (id: string) => {
const chapter = chapters.find(c => c.id === id);
if (chapter) {
setCurrentChapter(chapter);
editorForm.setFieldsValue({
title: chapter.title,
content: chapter.content,
});
setEditingId(id);
setTemporaryNarrativePerspective(undefined); // 重置人称选择
2025-10-30 11:14:43 +08:00
setIsEditorOpen(true);
// 打开编辑窗口时加载模型列表
loadAvailableModels();
2025-10-30 11:14:43 +08:00
}
};
const handleEditorSubmit = async (values: ChapterUpdate) => {
if (!editingId || !currentProject) return;
2025-10-30 11:14:43 +08:00
try {
await updateChapter(editingId, values);
2025-10-30 11:14:43 +08:00
// 刷新项目信息以更新总字数统计
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
2025-10-30 11:14:43 +08:00
message.success('章节保存成功');
setIsEditorOpen(false);
} catch {
message.error('保存失败');
}
};
const handleGenerate = async () => {
if (!editingId) return;
try {
setIsContinuing(true);
setIsGenerating(true);
2025-11-14 10:24:53 +08:00
setSingleChapterProgress(0);
setSingleChapterProgressMessage('准备开始生成...');
2025-11-14 10:24:53 +08:00
const result = await generateChapterContentStream(
editingId,
(content) => {
editorForm.setFieldsValue({ content });
2025-11-14 10:24:53 +08:00
if (contentTextAreaRef.current) {
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
if (textArea) {
textArea.scrollTop = textArea.scrollHeight;
}
2025-10-30 11:14:43 +08:00
}
2025-11-14 10:24:53 +08:00
},
selectedStyleId,
targetWordCount,
(progressMsg, progressValue) => {
// 进度回调
setSingleChapterProgress(progressValue);
setSingleChapterProgressMessage(progressMsg);
},
selectedModel, // 传递选中的模型
temporaryNarrativePerspective // 传递临时人称参数
2025-11-14 10:24:53 +08:00
);
2025-11-05 00:11:27 +08:00
message.success('AI创作成功,正在分析章节内容...');
2025-11-05 00:11:27 +08:00
// 如果返回了分析任务ID,启动轮询
if (result?.analysis_task_id) {
const taskId = result.analysis_task_id;
setAnalysisTasksMap(prev => ({
...prev,
[editingId]: {
2025-11-10 21:16:55 +08:00
has_task: true,
2025-11-05 00:11:27 +08:00
task_id: taskId,
chapter_id: editingId,
status: 'pending',
progress: 0
}
}));
2025-11-05 00:11:27 +08:00
// 启动轮询
startPollingTask(editingId);
}
2025-10-30 11:14:43 +08:00
} catch (error) {
const apiError = error as ApiError;
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
} finally {
setIsContinuing(false);
setIsGenerating(false);
2025-11-14 10:24:53 +08:00
setSingleChapterProgress(0);
setSingleChapterProgressMessage('');
2025-10-30 11:14:43 +08:00
}
};
const showGenerateModal = (chapter: Chapter) => {
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
).sort((a, b) => a.chapter_number - b.chapter_number);
2025-10-31 17:23:25 +08:00
const selectedStyle = writingStyles.find(s => s.id === selectedStyleId);
const instance = modal.confirm({
2025-10-30 11:14:43 +08:00
title: 'AI创作章节内容',
width: 700,
centered: true,
content: (
<div style={{ marginTop: 16 }}>
<p>AI将根据以下信息创作本章内容</p>
<ul>
<li></li>
<li></li>
<li></li>
<li><strong></strong></li>
2025-10-31 17:23:25 +08:00
{selectedStyle && (
<li><strong>{selectedStyle.name}</strong></li>
)}
<li><strong>{targetWordCount}</strong></li>
2025-10-30 11:14:43 +08:00
</ul>
2025-10-30 11:14:43 +08:00
{previousChapters.length > 0 && (
<div style={{
marginTop: 16,
padding: 12,
background: 'var(--color-info-bg)',
2025-10-30 11:14:43 +08:00
borderRadius: 4,
border: '1px solid var(--color-info-border)'
2025-10-30 11:14:43 +08:00
}}>
<div style={{ marginBottom: 8, fontWeight: 500, color: 'var(--color-primary)' }}>
2025-10-30 11:14:43 +08:00
📚 {previousChapters.length}
</div>
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
{previousChapters.map(ch => (
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
{ch.chapter_number}{ch.title} ({ch.word_count || 0})
</div>
))}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
💡 AI会参考这些章节内容
</div>
</div>
)}
2025-10-30 11:14:43 +08:00
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
</p>
</div>
),
okText: '开始创作',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
instance.update({
2025-10-30 11:14:43 +08:00
okButtonProps: { danger: true, loading: true },
cancelButtonProps: { disabled: true },
closable: false,
maskClosable: false,
keyboard: false,
});
2025-10-30 11:14:43 +08:00
try {
2025-10-31 17:23:25 +08:00
if (!selectedStyleId) {
message.error('请先选择写作风格');
instance.update({
2025-10-31 17:23:25 +08:00
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
closable: true,
maskClosable: true,
keyboard: true,
});
return;
}
2025-10-30 11:14:43 +08:00
await handleGenerate();
instance.destroy();
2025-10-30 11:14:43 +08:00
} catch (error) {
instance.update({
2025-10-30 11:14:43 +08:00
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
closable: true,
maskClosable: true,
keyboard: true,
});
}
},
onCancel: () => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成');
return false;
}
},
});
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'draft': 'default',
'writing': 'processing',
'completed': 'success',
};
return colors[status] || 'default';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
'draft': '草稿',
'writing': '创作中',
'completed': '已完成',
};
return texts[status] || status;
};
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
2025-11-18 22:14:55 +08:00
// 按大纲分组章节
const groupedChapters = useMemo(() => {
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
outlineOrder: number;
chapters: Chapter[];
}> = {};
sortedChapters.forEach(chapter => {
const key = chapter.outline_id || 'uncategorized';
2025-11-18 22:14:55 +08:00
if (!groups[key]) {
groups[key] = {
outlineId: chapter.outline_id || null,
outlineTitle: chapter.outline_title || '未分类章节',
outlineOrder: chapter.outline_order ?? 999,
chapters: []
};
}
2025-11-18 22:14:55 +08:00
groups[key].chapters.push(chapter);
});
// 转换为数组并按大纲顺序排序
return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
}, [sortedChapters]);
2025-10-30 11:14:43 +08:00
const handleExport = () => {
if (chapters.length === 0) {
message.warning('当前项目没有章节,无法导出');
return;
}
modal.confirm({
2025-10-30 11:14:43 +08:00
title: '导出项目章节',
content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`,
centered: true,
okText: '确定导出',
cancelText: '取消',
onOk: () => {
try {
projectApi.exportProject(currentProject.id);
message.success('开始下载导出文件');
} catch {
message.error('导出失败,请重试');
}
},
});
};
const handleShowAnalysis = (chapterId: string) => {
setAnalysisChapterId(chapterId);
setAnalysisVisible(true);
};
// 批量生成函数
const handleBatchGenerate = async (values: {
startChapterNumber: number;
count: number;
enableAnalysis: boolean;
styleId?: number;
targetWordCount?: number;
model?: string;
}) => {
if (!currentProject?.id) return;
// 调试日志
console.log('[批量生成] 表单values:', values);
console.log('[批量生成] batchSelectedModel状态:', batchSelectedModel);
// 使用批量生成对话框中选择的风格和字数,如果没有选择则使用默认值
const styleId = values.styleId || selectedStyleId;
const wordCount = values.targetWordCount || targetWordCount;
// 使用批量生成专用的模型状态
const model = batchSelectedModel;
console.log('[批量生成] 最终使用的model:', model);
if (!styleId) {
message.error('请选择写作风格');
return;
}
try {
setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
const requestBody: any = {
start_chapter_number: values.startChapterNumber,
count: values.count,
enable_analysis: values.enableAnalysis,
style_id: styleId,
target_word_count: wordCount,
};
// 如果有模型参数,添加到请求体中
if (model) {
requestBody.model = model;
console.log('[批量生成] 请求体包含model:', model);
} else {
console.log('[批量生成] 请求体不包含model,使用后端默认模型');
}
console.log('[批量生成] 完整请求体:', JSON.stringify(requestBody, null, 2));
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '创建批量生成任务失败');
}
const result = await response.json();
setBatchTaskId(result.batch_id);
setBatchProgress({
status: 'running',
total: result.chapters_to_generate.length,
completed: 0,
current_chapter_number: values.startChapterNumber,
estimated_time_minutes: result.estimated_time_minutes,
});
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
// 开始轮询任务状态
startBatchPolling(result.batch_id);
} catch (error: any) {
message.error('创建批量生成任务失败:' + (error.message || '未知错误'));
setBatchGenerating(false);
setBatchGenerateVisible(false);
}
};
// 轮询批量生成任务状态
const startBatchPolling = (taskId: string) => {
if (batchPollingIntervalRef.current) {
clearInterval(batchPollingIntervalRef.current);
}
const poll = async () => {
try {
const response = await fetch(`/api/chapters/batch-generate/${taskId}/status`);
if (!response.ok) return;
const status = await response.json();
setBatchProgress({
status: status.status,
total: status.total,
completed: status.completed,
current_chapter_number: status.current_chapter_number,
});
// 每次轮询时刷新章节列表和分析状态,实时显示新生成的章节和分析进度
if (status.completed > 0) {
refreshChapters();
loadAnalysisTasks();
// 刷新项目信息以实时更新总字数统计
if (currentProject?.id) {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
}
// 任务完成或失败,停止轮询
if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') {
if (batchPollingIntervalRef.current) {
clearInterval(batchPollingIntervalRef.current);
batchPollingIntervalRef.current = null;
}
setBatchGenerating(false);
// 立即刷新章节列表和分析任务状态(在显示消息前)
await refreshChapters();
await loadAnalysisTasks();
// 刷新项目信息以更新总字数统计
if (currentProject?.id) {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
if (status.status === 'completed') {
message.success(`批量生成完成!成功生成 ${status.completed}`);
} else if (status.status === 'failed') {
message.error(`批量生成失败:${status.error_message || '未知错误'}`);
} else if (status.status === 'cancelled') {
message.warning('批量生成已取消');
}
// 延迟关闭对话框,让用户看到最终状态
setTimeout(() => {
setBatchGenerateVisible(false);
setBatchTaskId(null);
setBatchProgress(null);
}, 2000);
}
} catch (error) {
console.error('轮询批量生成状态失败:', error);
}
};
// 立即执行一次
poll();
// 每2秒轮询一次
batchPollingIntervalRef.current = window.setInterval(poll, 2000);
};
// 取消批量生成
const handleCancelBatchGenerate = async () => {
if (!batchTaskId) return;
try {
const response = await fetch(`/api/chapters/batch-generate/${batchTaskId}/cancel`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('取消失败');
}
message.success('批量生成已取消');
// 取消后立即刷新章节列表和分析任务,显示已生成的章节
await refreshChapters();
await loadAnalysisTasks();
// 刷新项目信息以更新总字数统计
if (currentProject?.id) {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
} catch (error: any) {
message.error('取消失败:' + (error.message || '未知错误'));
}
};
// 打开批量生成对话框
const handleOpenBatchGenerate = async () => {
// 找到第一个未生成的章节
const firstIncompleteChapter = sortedChapters.find(
ch => !ch.content || ch.content.trim() === ''
);
if (!firstIncompleteChapter) {
message.info('所有章节都已生成内容');
return;
}
// 检查该章节是否可以生成
if (!canGenerateChapter(firstIncompleteChapter)) {
const reason = getGenerateDisabledReason(firstIncompleteChapter);
message.warning(reason);
return;
}
// 打开对话框时加载模型列表,等待完成
const defaultModel = await loadAvailableModels();
console.log('[打开批量生成] defaultModel:', defaultModel);
console.log('[打开批量生成] selectedStyleId:', selectedStyleId);
// 设置批量生成的模型选择状态
setBatchSelectedModel(defaultModel || undefined);
// 重置表单并设置初始值
batchForm.setFieldsValue({
startChapterNumber: firstIncompleteChapter.chapter_number,
count: 5,
enableAnalysis: false,
styleId: selectedStyleId,
targetWordCount: 3000,
});
setBatchGenerateVisible(true);
};
// 手动创建章节(仅one-to-many模式)
const showManualCreateChapterModal = () => {
// 计算下一个章节号
const nextChapterNumber = chapters.length > 0
? Math.max(...chapters.map(c => c.chapter_number)) + 1
: 1;
modal.confirm({
title: '手动创建章节',
width: 600,
centered: true,
content: (
<Form
form={manualCreateForm}
layout="vertical"
initialValues={{
chapter_number: nextChapterNumber,
status: 'draft'
}}
style={{ marginTop: 16 }}
>
<Form.Item
label="章节序号"
name="chapter_number"
rules={[{ required: true, message: '请输入章节序号' }]}
tooltip="建议按顺序创建章节,确保内容连贯性"
>
<InputNumber min={1} style={{ width: '100%' }} placeholder="自动计算的下一个序号" />
</Form.Item>
<Form.Item
label="章节标题"
name="title"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder="例如:第一章 初遇" />
</Form.Item>
<Form.Item
label="关联大纲"
name="outline_id"
rules={[{ required: true, message: '请选择关联的大纲' }]}
tooltip="one-to-many模式下,章节必须关联到大纲"
>
<Select placeholder="请选择所属大纲">
{sortedChapters.length > 0 && (() => {
// 从现有章节中提取大纲信息
const outlineMap = new Map();
sortedChapters.forEach(ch => {
if (ch.outline_id && ch.outline_title) {
outlineMap.set(ch.outline_id, {
id: ch.outline_id,
title: ch.outline_title,
order: ch.outline_order || 0
});
}
});
const uniqueOutlines = Array.from(outlineMap.values())
.sort((a, b) => a.order - b.order);
return uniqueOutlines.map(outline => (
<Select.Option key={outline.id} value={outline.id}>
{outline.order}{outline.title}
</Select.Option>
));
})()}
</Select>
</Form.Item>
<Form.Item
label="章节摘要(可选)"
name="summary"
tooltip="简要描述本章的主要内容和情节发展"
>
<TextArea
rows={4}
placeholder="简要描述本章内容..."
/>
</Form.Item>
<Form.Item
label="状态"
name="status"
>
<Select>
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="writing"></Select.Option>
<Select.Option value="completed"></Select.Option>
</Select>
</Form.Item>
</Form>
),
okText: '创建',
cancelText: '取消',
onOk: async () => {
const values = await manualCreateForm.validateFields();
// 检查章节序号是否已存在
const conflictChapter = chapters.find(
ch => ch.chapter_number === values.chapter_number
);
if (conflictChapter) {
// 显示冲突提示Modal
modal.confirm({
title: '章节序号冲突',
icon: <InfoCircleOutlined style={{ color: '#ff4d4f' }} />,
width: 500,
centered: true,
content: (
<div>
<p style={{ marginBottom: 12 }}>
<strong>{values.chapter_number}</strong>
</p>
<div style={{
padding: 12,
background: '#fff7e6',
borderRadius: 4,
border: '1px solid #ffd591',
marginBottom: 12
}}>
<div><strong></strong>{conflictChapter.title}</div>
<div><strong></strong>{getStatusText(conflictChapter.status)}</div>
<div><strong></strong>{conflictChapter.word_count || 0}</div>
{conflictChapter.outline_title && (
<div><strong></strong>{conflictChapter.outline_title}</div>
)}
</div>
<p style={{ color: '#ff4d4f', marginBottom: 8 }}>
</p>
<p style={{ fontSize: 12, color: '#666', marginBottom: 0 }}>
</p>
</div>
),
okText: '删除并创建',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
try {
// 先删除旧章节
await handleDeleteChapter(conflictChapter.id);
// 等待一小段时间确保删除完成
await new Promise(resolve => setTimeout(resolve, 300));
// 创建新章节
await chapterApi.createChapter({
project_id: currentProject.id,
...values
});
message.success('已删除旧章节并创建新章节');
await refreshChapters();
// 刷新项目信息以更新字数统计
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
manualCreateForm.resetFields();
} catch (error: any) {
message.error('操作失败:' + (error.message || '未知错误'));
throw error;
}
}
});
// 阻止外层Modal关闭
return Promise.reject();
}
// 没有冲突,直接创建
try {
await chapterApi.createChapter({
project_id: currentProject.id,
...values
});
message.success('章节创建成功');
await refreshChapters();
// 刷新项目信息以更新字数统计
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
manualCreateForm.resetFields();
} catch (error: any) {
message.error('创建失败:' + (error.message || '未知错误'));
throw error;
}
}
});
};
2025-11-05 00:11:27 +08:00
// 渲染分析状态标签
const renderAnalysisStatus = (chapterId: string) => {
const task = analysisTasksMap[chapterId];
2025-11-05 00:11:27 +08:00
if (!task) {
return null;
}
2025-11-05 00:11:27 +08:00
switch (task.status) {
case 'pending':
return (
<Tag icon={<SyncOutlined spin />} color="processing">
</Tag>
);
case 'running':
return (
<Tag icon={<SyncOutlined spin />} color="processing">
{task.progress}%
</Tag>
);
case 'completed':
return (
<Tag icon={<CheckCircleOutlined />} color="success">
</Tag>
);
case 'failed':
return (
<Tooltip title={task.error_message}>
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
</Tooltip>
);
default:
return null;
}
};
2025-11-18 22:14:55 +08:00
// 显示展开规划详情
const showExpansionPlanModal = (chapter: Chapter) => {
if (!chapter.expansion_plan) return;
2025-11-18 22:14:55 +08:00
try {
const planData: ExpansionPlanData = JSON.parse(chapter.expansion_plan);
modal.info({
2025-11-18 22:14:55 +08:00
title: (
<Space style={{ flexWrap: 'wrap' }}>
<InfoCircleOutlined style={{ color: 'var(--color-primary)' }} />
<span style={{ wordBreak: 'break-word' }}>{chapter.chapter_number}</span>
2025-11-18 22:14:55 +08:00
</Space>
),
width: isMobile ? '95%' : 800,
centered: true,
style: isMobile ? {
top: 20,
maxWidth: 'calc(100vw - 16px)',
margin: '0 8px'
} : undefined,
styles: {
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
},
2025-11-18 22:14:55 +08:00
content: (
<div style={{ marginTop: 16 }}>
<Descriptions
column={1}
size="small"
bordered
labelStyle={{
whiteSpace: 'normal',
wordBreak: 'break-word',
width: isMobile ? '80px' : '100px'
}}
contentStyle={{
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}}
>
2025-11-18 22:14:55 +08:00
<Descriptions.Item label="章节标题">
<strong style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{chapter.title}
</strong>
2025-11-18 22:14:55 +08:00
</Descriptions.Item>
<Descriptions.Item label="情感基调">
<Tag
color="blue"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{planData.emotional_tone}
</Tag>
2025-11-18 22:14:55 +08:00
</Descriptions.Item>
<Descriptions.Item label="冲突类型">
<Tag
color="orange"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{planData.conflict_type}
</Tag>
2025-11-18 22:14:55 +08:00
</Descriptions.Item>
<Descriptions.Item label="预估字数">
<Tag color="green">{planData.estimated_words}</Tag>
</Descriptions.Item>
<Descriptions.Item label="叙事目标">
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{planData.narrative_goal}
</span>
2025-11-18 22:14:55 +08:00
</Descriptions.Item>
<Descriptions.Item label="关键事件">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.key_events.map((event, idx) => (
<div
key={idx}
style={{
padding: '4px 0',
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}
>
<Tag color="purple" style={{ flexShrink: 0 }}>{idx + 1}</Tag>{' '}
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{event}
</span>
2025-11-18 22:14:55 +08:00
</div>
))}
</Space>
</Descriptions.Item>
<Descriptions.Item label="涉及角色">
<Space wrap style={{ maxWidth: '100%' }}>
2025-11-18 22:14:55 +08:00
{planData.character_focus.map((char, idx) => (
<Tag
key={idx}
color="cyan"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5'
}}
>
{char}
</Tag>
2025-11-18 22:14:55 +08:00
))}
</Space>
</Descriptions.Item>
{planData.scenes && planData.scenes.length > 0 && (
<Descriptions.Item label="场景规划">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.scenes.map((scene, idx) => (
<Card
key={idx}
size="small"
style={{
backgroundColor: '#fafafa',
maxWidth: '100%',
overflow: 'hidden'
}}
>
<div style={{
marginBottom: 4,
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong>📍 </strong>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{scene.location}
</span>
2025-11-18 22:14:55 +08:00
</div>
<div style={{ marginBottom: 4 }}>
<strong>👥 </strong>
<Space
size="small"
wrap
style={{
marginLeft: isMobile ? 0 : 8,
marginTop: isMobile ? 4 : 0,
display: isMobile ? 'flex' : 'inline-flex'
}}
>
2025-11-18 22:14:55 +08:00
{scene.characters.map((char, charIdx) => (
<Tag
key={charIdx}
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto'
}}
>
{char}
</Tag>
2025-11-18 22:14:55 +08:00
))}
</Space>
</div>
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong>🎯 </strong>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{scene.purpose}
</span>
2025-11-18 22:14:55 +08:00
</div>
</Card>
))}
</Space>
</Descriptions.Item>
)}
</Descriptions>
<Alert
message="提示"
description="这些是AI在大纲展开时生成的规划信息,可以作为创作章节内容时的参考。"
type="info"
showIcon
style={{ marginTop: 16 }}
/>
</div>
),
okText: '关闭',
});
} catch (error) {
console.error('解析展开规划失败:', error);
message.error('展开规划数据格式错误');
}
};
2025-11-19 14:01:30 +08:00
// 删除章节处理函数
const handleDeleteChapter = async (chapterId: string) => {
try {
await deleteChapter(chapterId);
2025-11-19 14:01:30 +08:00
// 刷新章节列表
await refreshChapters();
2025-11-19 14:01:30 +08:00
// 刷新项目信息以更新总字数统计
if (currentProject) {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
2025-11-19 14:01:30 +08:00
message.success('章节删除成功');
} catch (error: any) {
message.error('删除章节失败:' + (error.message || '未知错误'));
}
};
// 打开规划编辑器
const handleOpenPlanEditor = (chapter: Chapter) => {
// 直接打开编辑器,如果没有规划数据则创建新的
setEditingPlanChapter(chapter);
setPlanEditorVisible(true);
};
// 保存规划信息
const handleSavePlan = async (planData: ExpansionPlanData) => {
if (!editingPlanChapter) return;
try {
const response = await fetch(`/api/chapters/${editingPlanChapter.id}/expansion-plan`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(planData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '更新失败');
}
// 刷新章节列表
await refreshChapters();
message.success('规划信息更新成功');
// 关闭编辑器
setPlanEditorVisible(false);
setEditingPlanChapter(null);
} catch (error: any) {
message.error('保存规划失败:' + (error.message || '未知错误'));
throw error;
}
};
const handleChapterSelect = (chapterId: string) => {
const element = document.getElementById(`chapter-item-${chapterId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Optional: add a visual highlight effect
element.style.transition = 'background-color 0.5s ease';
element.style.backgroundColor = '#e6f7ff';
setTimeout(() => {
element.style.backgroundColor = '';
}, 1500);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{contextHolder}
2025-10-30 11:14:43 +08:00
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: 'var(--color-bg-container)',
2025-10-30 11:14:43 +08:00
padding: isMobile ? '12px 0' : '16px 0',
marginBottom: isMobile ? 12 : 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0,
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
{currentProject.outline_mode === 'one-to-many' && (
<Button
icon={<PlusOutlined />}
onClick={showManualCreateChapterModal}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
>
</Button>
)}
2025-10-30 11:14:43 +08:00
<Button
type="primary"
icon={<RocketOutlined />}
onClick={handleOpenBatchGenerate}
disabled={chapters.length === 0}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
style={{ background: '#722ed1', borderColor: '#722ed1' }}
>
</Button>
<Button
type="default"
2025-10-30 11:14:43 +08:00
icon={<DownloadOutlined />}
onClick={handleExport}
disabled={chapters.length === 0}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
>
TXT
</Button>
{!isMobile && (
<Tag color="blue">
{currentProject.outline_mode === 'one-to-one'
? '传统模式:章节由大纲一对一管理,请在大纲页面操作'
: '细化模式:章节可在大纲页面展开'}
</Tag>
)}
2025-10-30 11:14:43 +08:00
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
2025-10-30 11:14:43 +08:00
{chapters.length === 0 ? (
2025-11-18 22:14:55 +08:00
<Empty description="还没有章节,开始创作吧!" />
) : currentProject.outline_mode === 'one-to-one' ? (
// one-to-one 模式:直接显示扁平列表
<List
dataSource={sortedChapters}
renderItem={(item) => (
<List.Item
id={`chapter-item-${item.id}`}
style={{
padding: '16px',
marginBottom: 16,
background: '#fff',
borderRadius: 8,
border: '1px solid #f0f0f0',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
}}
actions={isMobile ? undefined : [
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' :
''
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
);
})(),
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
>
</Button>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: 'var(--color-primary)' }} />}
title={
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 6 : 12,
width: '100%'
}}>
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500, flexShrink: 0 }}>
{item.chapter_number}{item.title}
</span>
<Space wrap size={isMobile ? 4 : 8}>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} />
{renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
</Space>
</div>
}
description={
item.content ? (
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
{item.content.substring(0, isMobile ? 80 : 150)}
{item.content.length > (isMobile ? 80 : 150) && '...'}
</div>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}></span>
)
}
/>
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
title="编辑内容"
/>
{(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
);
})()}
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
)}
</div>
</List.Item>
)}
/>
2025-11-18 22:14:55 +08:00
) : (
// one-to-many 模式:按大纲分组显示
2025-11-18 22:14:55 +08:00
<Collapse
bordered={false}
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
style={{ background: 'transparent' }}
>
{groupedChapters.map((group, groupIndex) => (
<Collapse.Panel
key={groupIndex.toString()}
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Tag color={group.outlineId ? 'blue' : 'default'} style={{ margin: 0 }}>
{group.outlineId ? `📖 大纲 ${group.outlineOrder}` : '📝 未分类'}
</Tag>
<span style={{ fontWeight: 600, fontSize: 16 }}>
{group.outlineTitle}
</span>
<Badge
count={`${group.chapters.length}`}
style={{ backgroundColor: 'var(--color-success)' }}
2025-11-18 22:14:55 +08:00
/>
<Badge
count={`${group.chapters.reduce((sum, ch) => sum + (ch.word_count || 0), 0)}`}
style={{ backgroundColor: 'var(--color-primary)' }}
2025-11-18 22:14:55 +08:00
/>
</div>
}
2025-10-30 11:14:43 +08:00
style={{
2025-11-18 22:14:55 +08:00
marginBottom: 16,
background: '#fff',
2025-10-30 11:14:43 +08:00
borderRadius: 8,
2025-11-18 22:14:55 +08:00
border: '1px solid #f0f0f0',
2025-10-30 11:14:43 +08:00
}}
2025-11-18 22:14:55 +08:00
>
<List
dataSource={group.chapters}
renderItem={(item) => (
<List.Item
id={`chapter-item-${item.id}`}
2025-11-18 22:14:55 +08:00
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
}}
actions={isMobile ? undefined : [
2025-11-05 00:11:27 +08:00
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
2025-11-05 00:11:27 +08:00
>
</Button>,
(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' :
''
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
);
})(),
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
>
</Button>,
// 只在 one-to-many 模式下显示删除按钮
...(currentProject.outline_mode === 'one-to-many' ? [
<Popconfirm
title="确定删除这个章节吗?"
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
onConfirm={() => handleDeleteChapter(item.id)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
] : []),
2025-11-18 22:14:55 +08:00
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: 'var(--color-primary)' }} />}
2025-11-18 22:14:55 +08:00
title={
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 6 : 12,
width: '100%'
}}>
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500, flexShrink: 0 }}>
2025-11-18 22:14:55 +08:00
{item.chapter_number}{item.title}
</span>
<Space wrap size={isMobile ? 4 : 8}>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} />
{renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
<Space size={4}>
{item.expansion_plan && (
<Tooltip title="查看展开详情">
<InfoCircleOutlined
style={{ color: 'var(--color-primary)', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
)}
<Tooltip title={item.expansion_plan ? "编辑规划信息" : "创建规划信息"}>
<FormOutlined
style={{ color: 'var(--color-success)', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
handleOpenPlanEditor(item);
}}
/>
</Tooltip>
</Space>
</Space>
2025-11-18 22:14:55 +08:00
</div>
}
description={
item.content ? (
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
{item.content.substring(0, isMobile ? 80 : 150)}
{item.content.length > (isMobile ? 80 : 150) && '...'}
</div>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}></span>
)
}
/>
2025-11-18 22:14:55 +08:00
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
title="编辑内容"
/>
{(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
2025-11-18 22:14:55 +08:00
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
2025-11-18 22:14:55 +08:00
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
);
})()}
2025-11-05 00:11:27 +08:00
<Button
type="text"
2025-11-18 22:14:55 +08:00
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
2025-11-05 00:11:27 +08:00
size="small"
2025-11-18 22:14:55 +08:00
title="修改信息"
2025-11-05 00:11:27 +08:00
/>
{/* 只在 one-to-many 模式下显示删除按钮 */}
{currentProject.outline_mode === 'one-to-many' && (
<Popconfirm
title="确定删除?"
description="删除后无法恢复"
onConfirm={() => handleDeleteChapter(item.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
title="删除章节"
/>
</Popconfirm>
)}
2025-11-18 22:14:55 +08:00
</Space>
)}
</div>
</List.Item>
2025-10-30 11:14:43 +08:00
)}
2025-11-18 22:14:55 +08:00
/>
</Collapse.Panel>
))}
</Collapse>
2025-10-30 11:14:43 +08:00
)}
</div>
<Modal
title={editingId ? '编辑章节信息' : '添加章节'}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
centered={!isMobile}
width={isMobile ? 'calc(100% - 32px)' : 520}
style={isMobile ? {
top: 20,
paddingBottom: 0,
maxWidth: 'calc(100vw - 32px)',
margin: '0 16px'
} : undefined}
styles={{
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="章节标题"
name="title"
tooltip={
currentProject.outline_mode === 'one-to-one'
? "章节标题由大纲管理,建议在大纲页面统一修改"
: "一对多模式下可以修改章节标题"
}
rules={
currentProject.outline_mode === 'one-to-many'
? [{ required: true, message: '请输入章节标题' }]
: undefined
}
2025-10-30 11:14:43 +08:00
>
<Input
placeholder="输入章节标题"
disabled={currentProject.outline_mode === 'one-to-one'}
/>
2025-10-30 11:14:43 +08:00
</Form.Item>
<Form.Item
label="章节序号"
name="chapter_number"
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
>
<Input type="number" placeholder="章节排序序号" disabled />
</Form.Item>
<Form.Item label="状态" name="status">
<Select placeholder="选择状态">
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="writing"></Select.Option>
<Select.Option value="completed"></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑章节内容"
open={isEditorOpen}
onCancel={() => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成后再关闭');
return;
}
setIsEditorOpen(false);
}}
closable={!isGenerating}
maskClosable={!isGenerating}
keyboard={!isGenerating}
width={isMobile ? 'calc(100% - 32px)' : '85%'}
centered={!isMobile}
style={isMobile ? {
top: 20,
paddingBottom: 0,
maxWidth: 'calc(100vw - 32px)',
margin: '0 16px'
} : undefined}
styles={{
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 110px)',
2025-10-30 11:14:43 +08:00
overflowY: 'auto',
padding: isMobile ? '16px 12px' : '8px'
}
}}
footer={null}
>
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
{/* 章节标题和AI创作按钮 */}
2025-10-30 11:14:43 +08:00
<Form.Item
label="章节标题"
tooltip="(1-1模式请在大纲修改,1-N模式请使用修改按钮编辑)"
style={{ marginBottom: isMobile ? 16 : 12 }}
2025-10-30 11:14:43 +08:00
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item name="title" noStyle>
<Input disabled style={{ flex: 1 }} />
2025-10-30 11:14:43 +08:00
</Form.Item>
{editingId && (() => {
const currentChapter = chapters.find(c => c.id === editingId);
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
2025-10-30 11:14:43 +08:00
return (
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
<Button
type="primary"
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing}
disabled={!canGenerate}
danger={!canGenerate}
style={{ fontWeight: 'bold' }}
>
{isMobile ? 'AI' : 'AI创作'}
2025-10-30 11:14:43 +08:00
</Button>
</Tooltip>
);
})()}
</Space.Compact>
</Form.Item>
{/* 第一行:写作风格 + 叙事角度 */}
<div style={{
display: isMobile ? 'block' : 'flex',
gap: isMobile ? 0 : 16,
marginBottom: isMobile ? 0 : 12
}}>
<Form.Item
label="写作风格"
tooltip="选择AI创作时使用的写作风格"
required
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
2025-10-31 17:23:25 +08:00
>
<Select
placeholder="请选择写作风格"
value={selectedStyleId}
onChange={setSelectedStyleId}
disabled={isGenerating}
status={!selectedStyleId ? 'error' : undefined}
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}{style.is_default && ' (默认)'}
</Select.Option>
))}
</Select>
{!selectedStyleId && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}></div>
)}
</Form.Item>
2025-10-31 17:23:25 +08:00
<Form.Item
label="叙事角度"
tooltip="第一人称(我)代入感强;第三人称(他/她)更客观;全知视角洞悉一切"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
<Select
placeholder={`项目默认: ${getNarrativePerspectiveText(currentProject?.narrative_perspective)}`}
value={temporaryNarrativePerspective}
onChange={setTemporaryNarrativePerspective}
allowClear
disabled={isGenerating}
>
<Select.Option value="first_person">()</Select.Option>
<Select.Option value="third_person">(/)</Select.Option>
<Select.Option value="omniscient"></Select.Option>
</Select>
{temporaryNarrativePerspective && (
<div style={{ color: 'var(--color-success)', fontSize: 12, marginTop: 4 }}>
{getNarrativePerspectiveText(temporaryNarrativePerspective)}
</div>
)}
</Form.Item>
</div>
{/* 第二行:目标字数 + AI模型 */}
<div style={{
display: isMobile ? 'block' : 'flex',
gap: isMobile ? 0 : 16,
marginBottom: isMobile ? 16 : 12
}}>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际可能略有偏差"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
<InputNumber
min={500}
max={10000}
step={100}
value={targetWordCount}
onChange={(value) => setTargetWordCount(value || 3000)}
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
</Form.Item>
<Form.Item
label="AI模型"
tooltip="选择用于生成章节内容的AI模型,不选择则使用默认模型"
style={{ flex: 1, marginBottom: isMobile ? 16 : 0 }}
>
<Select
placeholder={selectedModel ? `默认: ${availableModels.find(m => m.value === selectedModel)?.label || selectedModel}` : "使用默认模型"}
value={selectedModel}
onChange={setSelectedModel}
allowClear
disabled={isGenerating}
showSearch
optionFilterProp="label"
>
{availableModels.map(model => (
<Select.Option key={model.value} value={model.value} label={model.label}>
{model.label}
</Select.Option>
))}
</Select>
</Form.Item>
</div>
2025-10-30 11:14:43 +08:00
<Form.Item label="章节内容" name="content">
<TextArea
ref={contentTextAreaRef}
rows={isMobile ? 12 : 20}
placeholder="开始写作..."
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
disabled={isGenerating}
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
onClick={() => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成后再关闭');
return;
}
setIsEditorOpen(false);
}}
block={isMobile}
disabled={isGenerating}
>
</Button>
<Button
type="primary"
htmlType="submit"
block={isMobile}
disabled={isGenerating}
>
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
{analysisChapterId && (
<ChapterAnalysis
chapterId={analysisChapterId}
visible={analysisVisible}
onClose={() => {
setAnalysisVisible(false);
// 刷新章节列表以显示最新内容
refreshChapters();
// 刷新项目信息以更新字数统计
if (currentProject) {
projectApi.getProject(currentProject.id)
.then(updatedProject => {
setCurrentProject(updatedProject);
})
.catch(error => {
console.error('刷新项目信息失败:', error);
});
}
2025-11-05 00:11:27 +08:00
// 延迟500ms后刷新该章节的分析状态,给后端足够时间完成数据库写入
if (analysisChapterId) {
const chapterIdToRefresh = analysisChapterId;
2025-11-05 00:11:27 +08:00
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('获取状态失败');
})
.then((task: AnalysisTask) => {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
2025-11-05 00:11:27 +08:00
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
})
.catch(error => {
console.error('刷新分析状态失败:', error);
// 如果查询失败,再延迟尝试一次
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => response.ok ? response.json() : null)
.then((task: AnalysisTask | null) => {
if (task) {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
}
})
.catch(err => console.error('第二次刷新失败:', err));
}, 1000);
});
}, 500);
}
setAnalysisChapterId(null);
}}
/>
)}
{/* 批量生成对话框 */}
<Modal
title={
<Space>
<RocketOutlined style={{ color: '#722ed1' }} />
<span></span>
</Space>
}
open={batchGenerateVisible}
onCancel={() => {
if (batchGenerating) {
modal.confirm({
title: '确认取消',
content: '批量生成正在进行中,确定要取消吗?',
okText: '确定取消',
cancelText: '继续生成',
onOk: () => {
handleCancelBatchGenerate();
setBatchGenerateVisible(false);
},
});
} else {
setBatchGenerateVisible(false);
}
}}
footer={null}
width={600}
centered
closable={!batchGenerating}
maskClosable={!batchGenerating}
>
{!batchGenerating ? (
<Form
form={batchForm}
layout="vertical"
onFinish={handleBatchGenerate}
initialValues={{
startChapterNumber: sortedChapters.find(ch => !ch.content || ch.content.trim() === '')?.chapter_number || 1,
count: 5,
enableAnalysis: false,
styleId: selectedStyleId,
targetWordCount: 3000,
model: selectedModel,
}}
>
<Alert
message="批量生成说明"
description={
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
<li></li>
<li>使</li>
<li></li>
</ul>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<Form.Item
label="起始章节"
name="startChapterNumber"
rules={[{ required: true, message: '请选择起始章节' }]}
>
<Select placeholder="选择起始章节" size="large">
{sortedChapters
.filter(ch => !ch.content || ch.content.trim() === '')
.filter(ch => canGenerateChapter(ch))
.map(ch => (
<Select.Option key={ch.id} value={ch.chapter_number}>
{ch.chapter_number}{ch.title}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="生成数量"
name="count"
rules={[{ required: true, message: '请选择生成数量' }]}
>
<Radio.Group buttonStyle="solid" size="large">
<Radio.Button value={5}>5</Radio.Button>
<Radio.Button value={10}>10</Radio.Button>
<Radio.Button value={15}>15</Radio.Button>
<Radio.Button value={20}>20</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item
label="写作风格"
name="styleId"
rules={[{ required: true, message: '请选择写作风格' }]}
tooltip="批量生成时所有章节使用相同的写作风格"
>
<Select
placeholder="请选择写作风格"
size="large"
showSearch
optionFilterProp="children"
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}
{style.is_default && ' (默认)'}
{style.description && ` - ${style.description}`}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差"
>
<Form.Item
name="targetWordCount"
rules={[{ required: true, message: '请设置目标字数' }]}
noStyle
>
<InputNumber
min={500}
max={10000}
step={100}
size="large"
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
</Form.Item>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
500-100003000
</div>
</Form.Item>
<Form.Item
label="AI模型"
tooltip="批量生成时所有章节使用相同模型,不选择则使用默认模型"
>
<Select
placeholder={batchSelectedModel ? `默认: ${availableModels.find(m => m.value === batchSelectedModel)?.label || batchSelectedModel}` : "使用默认模型"}
value={batchSelectedModel}
onChange={setBatchSelectedModel}
size="large"
allowClear
showSearch
optionFilterProp="label"
>
{availableModels.map(model => (
<Select.Option key={model.value} value={model.value} label={model.label}>
{model.label}
{model.value === batchSelectedModel && ' (默认)'}
</Select.Option>
))}
</Select>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
{batchSelectedModel ? `当前默认模型: ${availableModels.find(m => m.value === batchSelectedModel)?.label || batchSelectedModel}` : '加载模型列表中...'}
</div>
</Form.Item>
<Form.Item
label="同步分析"
name="enableAnalysis"
tooltip="开启后每章生成完立即分析,会增加约50%耗时,但能提升后续章节质量"
>
<Radio.Group>
<Radio value={false}>
<Space direction="vertical" size={0}>
<span></span>
<span style={{ fontSize: 12, color: '#666' }}></span>
</Space>
</Radio>
<Radio value={true}>
<Space direction="vertical" size={0}>
<span></span>
<span style={{ fontSize: 12, color: '#ff9800' }}>50%</span>
</Space>
</Radio>
</Radio.Group>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setBatchGenerateVisible(false)}>
</Button>
<Button type="primary" htmlType="submit" icon={<RocketOutlined />}>
</Button>
</Space>
</Form.Item>
</Form>
) : (
<div>
<Alert
message="温馨提示"
description={
<ul style={{ margin: '8px 0 0 0', paddingLeft: 20 }}>
<li></li>
<li></li>
<li>"取消任务"</li>
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
<li> {batchProgress.estimated_time_minutes} </li>
)}
</ul>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
<div style={{ textAlign: 'center' }}>
<Button
danger
icon={<StopOutlined />}
onClick={() => {
modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
onOk: handleCancelBatchGenerate,
});
}}
>
</Button>
</div>
</div>
)}
</Modal>
2025-11-14 10:24:53 +08:00
{/* 单章节生成进度显示 */}
<SSELoadingOverlay
loading={isGenerating}
progress={singleChapterProgress}
message={singleChapterProgressMessage}
/>
{/* 批量生成进度显示 - 使用统一的进度组件 */}
<SSEProgressModal
visible={batchGenerating}
progress={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
message={
batchProgress?.current_chapter_number
? `正在生成第 ${batchProgress.current_chapter_number} 章... (${batchProgress.completed}/${batchProgress.total})`
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
}
title="批量生成章节"
onCancel={() => {
modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
centered: true,
onOk: handleCancelBatchGenerate,
});
}}
cancelButtonText="取消任务"
/>
<FloatButton
icon={<BookOutlined />}
type="primary"
tooltip="章节目录"
onClick={() => setIsIndexPanelVisible(true)}
style={{ right: isMobile ? 24 : 48, bottom: isMobile ? 80 : 48 }}
/>
<FloatingIndexPanel
visible={isIndexPanelVisible}
onClose={() => setIsIndexPanelVisible(false)}
groupedChapters={groupedChapters}
onChapterSelect={handleChapterSelect}
/>
{/* 规划编辑器 */}
{editingPlanChapter && currentProject && (() => {
let parsedPlanData = null;
try {
if (editingPlanChapter.expansion_plan) {
parsedPlanData = JSON.parse(editingPlanChapter.expansion_plan);
}
} catch (error) {
console.error('解析规划数据失败:', error);
}
return (
<ExpansionPlanEditor
visible={planEditorVisible}
planData={parsedPlanData}
chapterSummary={editingPlanChapter.summary || null}
projectId={currentProject.id}
onSave={handleSavePlan}
onCancel={() => {
setPlanEditorVisible(false);
setEditingPlanChapter(null);
}}
/>
);
})()}
2025-10-30 11:14:43 +08:00
</div>
);
}