Files
ai-novel/src/pages/NovelGenerate.tsx

1339 lines
45 KiB
TypeScript
Raw Normal View History

2026-04-16 21:32:21 +08:00
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Button,
Card,
Form,
Input,
message,
Spin,
Space,
Alert,
Modal,
Descriptions,
Tag,
Select,
Radio,
InputNumber,
Row,
Col,
Divider
} from 'antd';
import {
ArrowLeftOutlined,
RobotOutlined,
CheckOutlined,
DownloadOutlined,
EditOutlined,
UnorderedListOutlined,
EyeOutlined
} from '@ant-design/icons';
import { storage } from '../utils/indexedDB';
import { Novel, NovelGenerationParams, ChapterOutline } from '../types';
import { useOllama } from '../contexts/OllamaContext';
import { NovelSettingManager } from '../utils/novelSettingManager';
const { TextArea } = Input;
const { Option } = Select;
// 小说题材选项
const NOVEL_GENRES = [
'穿越',
'都市',
'修仙',
'武侠',
'玄幻',
'科幻',
'言情',
'历史',
'游戏',
'灵异',
'军事',
'悬疑',
'其他'
];
// 字数选项
const WORD_COUNT_OPTIONS = [
{ label: '5万字 (短篇)', value: 50000 },
{ label: '10万字 (中篇)', value: 100000 },
{ label: '20万字 (长篇)', value: 200000 },
{ label: '30万字 (超长篇)', value: 300000 },
{ label: '50万字 (史诗篇)', value: 500000 },
{ label: '100万字 (巨著)', value: 1000000 }
];
const NovelGenerate: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [novel, setNovel] = useState<Novel | null>(null);
const [loading, setLoading] = useState(false);
const [generating, setGenerating] = useState(false);
const [generatedSettings, setGeneratedSettings] = useState<any>(null);
const [previewVisible, setPreviewVisible] = useState(false);
const [editModalVisible, setEditModalVisible] = useState(false);
const [editForm] = Form.useForm();
const [form] = Form.useForm();
const [chapterListVisible, setChapterListVisible] = useState(false);
const [editingChapter, setEditingChapter] = useState<ChapterOutline | null>(null);
const [chapterEditModalVisible, setChapterEditModalVisible] = useState(false);
const [chapterEditForm] = Form.useForm();
const [viewingChapter, setViewingChapter] = useState<ChapterOutline | null>(null);
const [chapterViewModalVisible, setChapterViewModalVisible] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0); // 添加强制更新状态
const { chat } = useOllama();
// 辅助函数:生成模块名称
const getModuleName = (index: number): string => {
const moduleNames = ['起始阶段', '发展阶段', '转折阶段', '高潮阶段', '解决阶段', '结局阶段'];
return moduleNames[index % moduleNames.length];
};
// 辅助函数:生成模块描述
const getModuleDescription = (index: number): string => {
const descriptions = [
'开篇设定,引入主要冲突和人物',
'情节推进,矛盾升级,人物成长',
'故事转折,新的挑战和机遇',
'核心冲突爆发,达到最高潮',
'矛盾逐步解决,铺垫结局',
'收尾总结,主题升华'
];
return descriptions[index % descriptions.length];
};
// 辅助函数:生成章节范围
const getChapterRange = (moduleIndex: number, totalChapters: number, totalModules: number): string => {
const chaptersPerModule = Math.ceil(totalChapters / totalModules);
const start = moduleIndex * chaptersPerModule + 1;
const end = Math.min((moduleIndex + 1) * chaptersPerModule, totalChapters);
return `${start}-${end}`;
};
useEffect(() => {
if (id) {
loadNovel();
}
}, [id]);
const loadNovel = async () => {
if (!id) return;
try {
setLoading(true);
const novels = await storage.getNovels();
const currentNovel = novels.find((n: Novel) => n.id === id);
if (!currentNovel) {
message.error('小说不存在');
navigate('/novels');
return;
}
setNovel(currentNovel);
if (currentNovel.generatedSettings) {
setGeneratedSettings(currentNovel.generatedSettings);
}
form.setFieldsValue({
title: currentNovel.title,
genre: currentNovel.genre === '未分类' ? undefined : currentNovel.genre,
targetWordCount: currentNovel.generatedSettings?.targetWordCount || 50000,
customRequirements: ''
});
} catch (error) {
message.error('加载数据失败');
} finally {
setLoading(false);
}
};
const handleGenerateSettings = async () => {
if (!novel) return;
try {
const values = await form.validateFields();
setGenerating(true);
const params: NovelGenerationParams = {
title: values.title,
genre: values.genre || '未分类',
customRequirements: values.customRequirements
};
// 计算章节数量和每章字数每章900-1200字平均1050字
const avgWordsPerChapter = 1050; // 平均每章字数
const targetWordCount = params.targetWordCount || 50000;
const chapterCount = Math.round(targetWordCount / avgWordsPerChapter); // 5万字约48章
const wordsPerChapterMin = 900; // 最少字数
const wordsPerChapterMax = 1200; // 最多字数
const moduleCount = params.preferredModuleCount || Math.max(3, Math.round(chapterCount / 15)); // 每15章左右一个模块
// 只生成基础设定,不包含任何章节规划
message.info({ content: '正在生成基础设定...', duration: 2 });
const basePrompt = `请为小说《${params.title}${params.genre ? `${params.genre}题材)` : ''}生成基础创作设定。
## 📏
- ****${targetWordCount.toLocaleString()}
- ****${chapterCount}
- ****${wordsPerChapterMin}-${wordsPerChapterMax}${avgWordsPerChapter}
- ****${moduleCount}
${params.customRequirements ? `## 🎯 特殊要求\n${params.customRequirements}\n\n` : ''}
## 📖
150
## 🏗
${Array.from({length: moduleCount}, (_, i) => `
- ${i + 1}${getModuleName(i)}${getChapterRange(i, chapterCount, moduleCount)}- ${getModuleDescription(i)}`).join('')}
## 👥
- 150
- 3100
- 100
`;
const baseResponse = await chat(basePrompt, `小说《${novel.title}》的基础设定生成`);
// 解析基础设定
const baseSettings = parseBaseSettings(baseResponse);
// 生成初始的章节规划结构(完全空的章节列表)
const initialSettings = {
storyOutline: baseSettings.storyOutline,
plotStructure: baseSettings.plotStructure,
characters: baseSettings.characters,
targetWordCount: targetWordCount,
chapterCount: chapterCount,
moduleCount: moduleCount,
chapters: [] as any[], // 完全空的章节列表
chapterOutline: ''
};
setGeneratedSettings(initialSettings);
message.success('基础设定生成成功!现在可以开始手动生成章节标题和细纲。');
} catch (error: any) {
message.error(error.message || 'AI生成失败请重试');
} finally {
setGenerating(false);
}
};
// 手动生成单个章节规划
const handleGenerateSingleChapter = async (chapterNumber: number) => {
if (!novel || !generatedSettings) return;
try {
const params = {
targetWordCount: generatedSettings.targetWordCount,
chapterCount: generatedSettings.chapterCount,
moduleCount: generatedSettings.moduleCount,
avgWordsPerChapter: Math.round(generatedSettings.targetWordCount / generatedSettings.chapterCount),
wordsPerChapterMin: 900,
wordsPerChapterMax: 1200
};
// 计算当前章节应该属于哪个模块
const moduleNumber = Math.min(Math.ceil(chapterNumber / Math.ceil(params.chapterCount / params.moduleCount)), params.moduleCount);
message.info({ content: `正在生成第${chapterNumber}章的标题和细纲...`, duration: 2 });
// 使用函数式状态更新获取最新的chapters
const currentChapters = generatedSettings.chapters || [];
const chapterPrompt = `基于以下小说设定,为第${chapterNumber}章生成详细规划。
##
### 📖
${generatedSettings.storyOutline}
### 🏗
${generatedSettings.plotStructure}
### 👥
${generatedSettings.characters}
##
****${chapterNumber}${params.chapterCount}
****${moduleNumber}
****${params.wordsPerChapterMin}-${params.wordsPerChapterMax}
${currentChapters.length > 0 ? `## 前面章节概览\n\n${currentChapters.slice(-3).map((ch: any) =>
`${ch.chapterNumber}章:${ch.title}\n细纲${ch.outline.substring(0, 50)}...`
).join('\n\n')}\n\n` : ''}
${chapterNumber}
${chapterNumber} - ${moduleNumber}
100-200
1050900-1200
****
1. 100-200
2. ${chapterNumber > 1 ? '(这是后续章节的延续)' : '(这是开篇章节)'}
3.
${chapterNumber}`;
const chapterResponse = await chat(chapterPrompt, `小说《${novel.title}》第${chapterNumber}章规划`);
// 解析单个章节
const parsedChapter = parseSingleChapter(chapterResponse, chapterNumber, moduleNumber, params);
// 检查解析是否成功
if (!parsedChapter || !parsedChapter.title || !parsedChapter.outline) {
throw new Error('章节解析失败,请重试');
}
// 使用函数式状态更新,确保基于最新状态
setGeneratedSettings((prevSettings: any) => {
const currentChapters = prevSettings.chapters || [];
// 添加到章节列表 - 深拷贝确保引用正确
const updatedChapters = currentChapters.map((ch: any) => ({ ...ch }));
// 替换或添加章节
const existingIndex = updatedChapters.findIndex((ch: any) => ch.chapterNumber === chapterNumber);
if (existingIndex >= 0) {
updatedChapters[existingIndex] = { ...parsedChapter };
} else {
updatedChapters.push({ ...parsedChapter });
}
// 按章节号排序
updatedChapters.sort((a: any, b: any) => a.chapterNumber - b.chapterNumber);
// 返回全新的设置对象确保React检测到变化
const newSettings = {
...prevSettings,
chapters: updatedChapters,
// 添加一个时间戳确保对象总是新的
lastUpdated: Date.now()
};
// 强制触发UI更新
setTimeout(() => {
setForceUpdate(prev => prev + 1);
}, 50);
return newSettings;
});
message.success(`${chapterNumber}章的标题和细纲生成成功!`);
} catch (error: any) {
message.error(error.message || '章节生成失败,请重试');
}
};
// 批量生成多个章节规划
const handleGenerateBatchChapters = async (startChapter: number, endChapter: number) => {
if (!novel || !generatedSettings) return;
try {
setGenerating(true);
for (let chapterNum = startChapter; chapterNum <= endChapter; chapterNum++) {
// 生成单个章节
await handleGenerateSingleChapter(chapterNum);
// 等待更长时间,确保状态更新和界面渲染完成
await new Promise(resolve => setTimeout(resolve, 300));
}
message.success(`${startChapter}-${endChapter}章的标题和细纲批量生成成功!`);
} catch (error: any) {
message.error(error.message || '批量生成失败,请重试');
} finally {
setGenerating(false);
}
};
// 解析单个章节
const parseSingleChapter = (response: string, chapterNumber: number, moduleNumber: number, metadata: any) => {
const lines = response.split('\n');
let chapter: any = {
chapterNumber: chapterNumber,
title: `${chapterNumber}`,
moduleNumber: moduleNumber,
outline: '',
estimatedWords: metadata.avgWordsPerChapter
};
let foundChapter = false;
let inOutline = false;
lines.forEach(line => {
const trimmedLine = line.trim();
// 匹配章节标题行第1章意外穿越 - 模块1
const chapterMatch = trimmedLine.match(/第(\d+)章[:]\s*(.+?)\s*[-—]\s*模块(\d+)/);
if (chapterMatch) {
const matchedChapterNum = parseInt(chapterMatch[1]);
if (matchedChapterNum === chapterNumber) {
chapter.title = chapterMatch[2].trim();
chapter.moduleNumber = parseInt(chapterMatch[3]);
foundChapter = true;
}
} else if (trimmedLine.includes('细纲:')) {
// 开始收集细纲内容
const outlineStart = trimmedLine.indexOf('细纲:') + 3;
chapter.outline = trimmedLine.substring(outlineStart).trim();
inOutline = true;
} else if (trimmedLine.includes('预计字数:')) {
// 解析字数
const wordMatch = trimmedLine.match(/(\d+)/);
if (wordMatch) {
chapter.estimatedWords = parseInt(wordMatch[1]);
}
inOutline = false; // 细纲结束
} else if (inOutline && trimmedLine && !trimmedLine.includes('第') && !trimmedLine.includes('模块')) {
// 继续收集细纲内容
if (chapter.outline) {
chapter.outline += ' ' + trimmedLine;
} else {
chapter.outline = trimmedLine;
}
}
});
// 如果没有找到章节标题,使用默认值
if (!foundChapter) {
console.warn(`章节${chapterNumber}解析失败,使用默认值`);
chapter.title = `${chapterNumber}`;
}
// 确保细纲不为空
if (!chapter.outline) {
chapter.outline = `${chapterNumber}章的详细细纲(待补充)`;
}
return chapter;
};
const parseBaseSettings = (response: string) => {
const settings: any = {
storyOutline: '',
plotStructure: '',
characters: ''
};
const lines = response.split('\n');
let currentSection = '';
let content = '';
lines.forEach(line => {
if (line.startsWith('## ')) {
if (currentSection && content) {
switch(currentSection) {
case '📖 故事大纲':
case '故事大纲':
settings.storyOutline = content.trim();
break;
case '🏗️ 情节结构':
case '情节结构':
settings.plotStructure = content.trim();
break;
case '👥 人物设定':
case '人物设定':
settings.characters = content.trim();
break;
}
}
currentSection = line.replace('## ', '').trim();
content = '';
} else if (line.trim()) {
content += line + '\n';
}
});
// 处理最后一个section
if (currentSection && content) {
if (currentSection.includes('故事大纲')) {
settings.storyOutline = content.trim();
} else if (currentSection.includes('情节结构')) {
settings.plotStructure = content.trim();
} else if (currentSection.includes('人物设定')) {
settings.characters = content.trim();
}
}
return settings;
};
const parseChapterBatch = (response: string, startChapter: number, metadata: any) => {
const chapters: any[] = [];
const lines = response.split('\n');
let currentChapter: any = null;
let expectedChapterNum = startChapter;
lines.forEach(line => {
// 匹配章节标题行第1章意外穿越 - 模块1
const chapterMatch = line.match(/第(\d+)章[:]\s*(.+?)\s*[-—]\s*模块(\d+)/);
if (chapterMatch) {
if (currentChapter && currentChapter.chapterNumber) {
chapters.push(currentChapter);
}
currentChapter = {
chapterNumber: parseInt(chapterMatch[1]),
title: chapterMatch[2].trim(),
moduleNumber: parseInt(chapterMatch[3]),
outline: '',
estimatedWords: metadata.avgWordsPerChapter
};
expectedChapterNum++;
} else if (line.includes('细纲:') && currentChapter) {
currentChapter.outline = line.replace('细纲:', '').trim();
} else if (line.includes('预计字数:') && currentChapter) {
const wordMatch = line.match(/(\d+)/);
if (wordMatch) {
currentChapter.estimatedWords = parseInt(wordMatch[1]);
}
} else if (line.trim() && currentChapter && !line.includes('第') && !line.includes('模块')) {
if (currentChapter.outline) {
currentChapter.outline += ' ' + line.trim();
} else {
currentChapter.outline = line.trim();
}
}
});
if (currentChapter && currentChapter.chapterNumber) {
chapters.push(currentChapter);
}
return chapters;
};
const handleConfirmSettings = async () => {
if (!novel || !generatedSettings || !id) return;
try {
// 确保保存所有章节信息,包括未生成的章节
const completeSettings = {
...generatedSettings,
// 确保章节数组包含所有章节的占位符
chapters: Array.from({ length: generatedSettings.chapterCount }, (_, i) => {
const chapterNum = i + 1;
const existingChapter = generatedSettings.chapters?.find((ch: any) => ch.chapterNumber === chapterNum);
// 如果该章节已存在,使用现有数据
if (existingChapter) {
return existingChapter;
}
// 如果该章节不存在,创建占位符
return {
chapterNumber: chapterNum,
title: `${chapterNum}章(待生成标题)`,
moduleNumber: Math.min(Math.ceil(chapterNum / Math.ceil(generatedSettings.chapterCount / generatedSettings.moduleCount)), generatedSettings.moduleCount),
outline: `${chapterNum}章的细纲(待生成)`,
estimatedWords: Math.round(generatedSettings.targetWordCount / generatedSettings.chapterCount),
status: 'pending'
};
})
};
// 生成并保存设置文件
const settingFilePath = await NovelSettingManager.generateAndSaveSettingFile(
novel.title,
completeSettings
);
// 更新小说记录
await storage.updateNovel(id, {
generatedSettings: completeSettings,
settingFilePath,
outline: completeSettings.storyOutline || novel.outline,
targetWordCount: completeSettings.targetWordCount
});
message.success('设定已保存并生成了配置文件,现在可以开始创作章节!');
// 更新本地状态
setGeneratedSettings(completeSettings);
// 重新加载数据
await loadNovel();
} catch (error: any) {
message.error(error.message || '保存失败,请重试');
}
};
const handleEditSettings = () => {
if (generatedSettings) {
editForm.setFieldsValue({
storyOutline: generatedSettings.storyOutline,
plotStructure: generatedSettings.plotStructure,
characters: generatedSettings.characters,
targetWordCount: generatedSettings.targetWordCount,
chapterCount: generatedSettings.chapterCount,
chapterOutline: generatedSettings.chapterOutline
});
setEditModalVisible(true);
}
};
const handleSaveEdit = async () => {
try {
const values = await editForm.validateFields();
if (!novel?.generatedSettings) return;
// 更新generatedSettings
const updatedSettings = {
...novel.generatedSettings,
...values
};
// 保存到数据库
await storage.updateNovel(novel.id, {
generatedSettings: updatedSettings
});
// 更新本地状态
setNovel({
...novel,
generatedSettings: updatedSettings
});
setGeneratedSettings(updatedSettings);
setEditModalVisible(false);
message.success('设定更新成功');
} catch (error) {
message.error('保存失败');
}
};
const handleViewChapter = (chapter: ChapterOutline) => {
setViewingChapter(chapter);
setChapterViewModalVisible(true);
};
const handleEditChapter = (chapter: ChapterOutline) => {
setEditingChapter(chapter);
chapterEditForm.setFieldsValue({
title: chapter.title,
moduleNumber: chapter.moduleNumber,
outline: chapter.outline,
estimatedWords: chapter.estimatedWords
});
setChapterEditModalVisible(true);
};
const handleSaveChapterEdit = async () => {
try {
const values = await chapterEditForm.validateFields();
if (!generatedSettings || !editingChapter) return;
// 更新章节信息
const updatedChapters = generatedSettings.chapters.map((chapter: any) =>
chapter.chapterNumber === editingChapter.chapterNumber
? { ...chapter, ...values }
: chapter
);
// 更新generatedSettings
const updatedSettings = {
...generatedSettings,
chapters: updatedChapters
};
setGeneratedSettings(updatedSettings);
// 如果已保存设定,同时更新到数据库
if (novel?.settingFilePath) {
await storage.updateNovel(novel.id, {
generatedSettings: updatedSettings
});
}
setChapterEditModalVisible(false);
chapterEditForm.resetFields();
message.success('章节标题和细纲更新成功');
} catch (error) {
message.error('保存失败');
}
};
const handleRegenerate = () => {
setPreviewVisible(false);
setGeneratedSettings(null);
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" tip="加载中..." />
</div>
);
}
if (!novel) {
return (
<div style={{ textAlign: 'center', padding: '50px' }}>
<Spin size="large" />
</div>
);
}
return (
<div>
<Card
title={
<Space>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/novels')}
style={{ marginRight: '8px' }}
/>
<span>AI </span>
</Space>
}
extra={
novel.generatedSettings && (
<Tag color="success"></Tag>
)
}
style={{ maxWidth: '800px', margin: '0 auto' }}
>
<Alert
message="AI设定生成"
description="输入小说基本要求AI将为您生成完整的故事大纲、人物设定、章节规划等内容。生成的设定将直接在页面上展示。"
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
<Form
form={form}
layout="horizontal"
labelCol={{ span: 6 }}
wrapperCol={{ span: 18 }}
>
<Form.Item
label="小说名称"
name="title"
rules={[{ required: true, message: '请输入小说名称' }]}
>
<Input placeholder="请输入小说名称" disabled={!!novel} />
</Form.Item>
<Form.Item
label="题材类型"
name="genre"
rules={[{ required: true, message: '请选择题材类型' }]}
>
{novel?.genre ? (
// 已有题材,只做展示
<div>
<Tag color="blue" style={{ fontSize: '14px', padding: '4px 12px' }}>
{novel.genre}
</Tag>
<span style={{ fontSize: '12px', color: '#8c8c8c', marginLeft: '8px' }}>
</span>
</div>
) : (
// 还没有选择题材,显示选择框
<Select
placeholder="请选择题材类型"
showSearch
style={{ width: '100%' }}
>
{NOVEL_GENRES.map(genre => (
<Option key={genre} value={genre}>
{genre}
</Option>
))}
</Select>
)}
</Form.Item>
<Form.Item
label="目标字数"
name="targetWordCount"
rules={[{ required: true, message: '请选择目标字数' }]}
>
<Radio.Group
style={{ width: '100%' }}
disabled={!!novel?.generatedSettings}
>
<Space wrap>
{WORD_COUNT_OPTIONS.map(option => (
<Radio.Button key={option.value} value={option.value}>
{option.label}
</Radio.Button>
))}
</Space>
</Radio.Group>
{novel?.generatedSettings && (
<div style={{ fontSize: '12px', color: '#ff4d4f', marginTop: '4px' }}>
</div>
)}
</Form.Item>
<Form.Item
label="特殊要求"
name="customRequirements"
>
<TextArea
rows={2}
placeholder="可选:描述您对这部小说的特殊要求或想法"
disabled={!!novel?.generatedSettings}
/>
</Form.Item>
<Form.Item wrapperCol={{ offset: 6 }}>
<Space>
<Button
type="primary"
icon={<RobotOutlined />}
onClick={handleGenerateSettings}
loading={generating}
disabled={!!novel?.generatedSettings}
>
{generating ? 'AI生成中...' : 'AI生成设定'}
</Button>
{novel.generatedSettings && (
<Button
type="default"
onClick={() => setGeneratedSettings(novel.generatedSettings)}
>
</Button>
)}
</Space>
</Form.Item>
</Form>
</Card>
{/* 生成的设定内容直接展示 */}
{generatedSettings && (
<div style={{ marginTop: '24px', maxWidth: '1200px', margin: '0 auto' }}>
{/* 设定概览和操作按钮 */}
<Card
title="📊 小说设定已生成"
style={{ marginBottom: '16px' }}
extra={
<Space>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={handleConfirmSettings}
disabled={!!novel?.settingFilePath}
>
{novel?.settingFilePath ? '设定已保存' : '确认并保存'}
</Button>
<Button
icon={<EditOutlined />}
onClick={() => setEditModalVisible(true)}
>
</Button>
</Space>
}
>
<Row gutter={16}>
<Col span={6}>
<div style={{ textAlign: 'center', padding: '12px', background: '#f0f9ff', borderRadius: '6px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>
{generatedSettings.targetWordCount.toLocaleString()}
</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center', padding: '12px', background: '#f0f9ff', borderRadius: '6px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>
{generatedSettings.chapterCount}
</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center', padding: '12px', background: '#f0f9ff', borderRadius: '6px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>
{generatedSettings.moduleCount}
</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
</div>
</div>
</Col>
<Col span={6}>
<div style={{ textAlign: 'center', padding: '12px', background: '#f0f9ff', borderRadius: '6px' }}>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#1890ff' }}>
{Math.round(generatedSettings.targetWordCount / generatedSettings.chapterCount)}
</div>
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
</div>
</div>
</Col>
</Row>
<Divider style={{ margin: '16px 0' }} />
<Row gutter={16}>
<Col span={12}>
<div style={{ marginBottom: '12px' }}>
<h4 style={{ fontSize: '13px', fontWeight: 'bold', marginBottom: '6px' }}>📖 </h4>
<div style={{
padding: '8px',
background: '#f5f5f5',
borderRadius: '4px',
lineHeight: '1.6',
fontSize: '13px'
}}>
{generatedSettings.storyOutline || '暂无内容'}
</div>
</div>
</Col>
<Col span={12}>
<div style={{ marginBottom: '12px' }}>
<h4 style={{ fontSize: '13px', fontWeight: 'bold', marginBottom: '6px' }}>🏗 </h4>
<div style={{
padding: '8px',
background: '#f5f5f5',
borderRadius: '4px',
lineHeight: '1.6',
fontSize: '13px',
whiteSpace: 'pre-line'
}}>
{generatedSettings.plotStructure || '暂无内容'}
</div>
</div>
</Col>
</Row>
<div style={{ marginBottom: '12px' }}>
<h4 style={{ fontSize: '13px', fontWeight: 'bold', marginBottom: '6px' }}>👥 </h4>
<div style={{
padding: '8px',
background: '#f5f5f5',
borderRadius: '4px',
lineHeight: '1.6',
fontSize: '13px',
whiteSpace: 'pre-line',
maxHeight: '100px',
overflowY: 'auto'
}}>
{generatedSettings.characters || '暂无内容'}
</div>
</div>
</Card>
{/* 章节列表和操作区域 */}
<Card
title={
<Space>
<span>📚 </span>
<Tag color="blue">{generatedSettings.chapters?.length || 0}/{generatedSettings.chapterCount}</Tag>
{novel?.settingFilePath && (
<Tag color="success"></Tag>
)}
</Space>
}
style={{ marginBottom: '16px' }}
extra={
<Space>
{generatedSettings.chapters.length < generatedSettings.chapterCount && (
<>
<Button
icon={<RobotOutlined />}
onClick={() => handleGenerateSingleChapter(generatedSettings.chapters.length + 1)}
disabled={generating}
>
+
</Button>
<Button
icon={<RobotOutlined />}
onClick={() => {
const nextChapter = generatedSettings.chapters.length + 1;
const endChapter = Math.min(nextChapter + 4, generatedSettings.chapterCount);
handleGenerateBatchChapters(nextChapter, endChapter);
}}
disabled={generating}
>
5+
</Button>
</>
)}
<Button
type="primary"
icon={<RobotOutlined />}
disabled={!novel?.settingFilePath}
onClick={() => navigate(`/novels/${id}`)}
>
</Button>
</Space>
}
>
<Alert
message="章节标题和细纲规划"
description={
<div>
<p><strong></strong>+</p>
<p> <strong></strong>"生成下一章"</p>
<p> <strong></strong>"批量生成5章"</p>
<p> <strong></strong></p>
<p> <strong></strong>绿</p>
</div>
}
type="info"
showIcon
style={{ marginBottom: '16px' }}
/>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
gap: '12px'
}}>
{Array.from({ length: generatedSettings.chapterCount }, (_, i) => {
const chapterNum = i + 1;
const chapter = generatedSettings.chapters?.find((ch: any) => ch.chapterNumber === chapterNum);
return (
<Card
key={chapterNum}
size="small"
hoverable
style={{
border: chapter ? '1px solid #52c41a' : '1px solid #f0f0f0',
borderRadius: '6px',
background: chapter ? '#f6ffed' : 'white'
}}
actions={[
chapter ? (
<Button
key="view"
type="text"
size="small"
icon={<EyeOutlined />}
onClick={() => handleViewChapter(chapter)}
>
</Button>
) : (
<Button
key="generate"
type="primary"
size="small"
icon={<RobotOutlined />}
onClick={() => handleGenerateSingleChapter(chapterNum)}
disabled={generating}
>
+
</Button>
),
<Button
key="edit"
type="text"
size="small"
icon={<EditOutlined />}
onClick={() => chapter ? handleEditChapter(chapter) : null}
disabled={!chapter}
>
</Button>
]}
>
<Card.Meta
title={
<Space>
<Tag color={chapter ? "green" : "blue"}>{chapterNum}</Tag>
{chapter?.moduleNumber && (
<Tag color="cyan">{chapter.moduleNumber}</Tag>
)}
{chapter?.estimatedWords && (
<Tag color="orange">{chapter.estimatedWords}</Tag>
)}
{chapter && (
<Tag color="success"></Tag>
)}
</Space>
}
description={
<div>
<div style={{
fontWeight: 500,
marginBottom: '6px',
fontSize: '14px',
color: '#262626'
}}>
{chapter?.title || `${chapterNum}章(待生成标题+细纲)`}
</div>
<div style={{
fontSize: '12px',
color: '#666',
lineHeight: '1.5',
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden'
}}>
{chapter?.outline || '点击下方按钮生成章节标题和细纲...'}
</div>
</div>
}
/>
</Card>
);
})}
</div>
</Card>
</div>
)}
{/* 编辑设定Modal */}
<Modal
title="编辑小说设定"
open={editModalVisible}
onCancel={() => setEditModalVisible(false)}
onOk={handleSaveEdit}
width={800}
okText="保存修改"
cancelText="取消"
>
<Form
form={editForm}
layout="vertical"
>
<Form.Item
label="故事大纲"
name="storyOutline"
>
<TextArea rows={3} />
</Form.Item>
<Form.Item
label="情节结构"
name="plotStructure"
>
<TextArea rows={4} />
</Form.Item>
<Form.Item
label="人物设定"
name="characters"
>
<TextArea rows={6} />
</Form.Item>
</Form>
</Modal>
{/* 章节编辑Modal */}
<Modal
title={`编辑第${editingChapter?.chapterNumber}`}
open={chapterEditModalVisible}
onCancel={() => setChapterEditModalVisible(false)}
onOk={handleSaveChapterEdit}
width={700}
okText="保存"
cancelText="取消"
>
<Form
form={chapterEditForm}
layout="vertical"
>
<Form.Item
label="章节标题"
name="title"
rules={[{ required: true, message: '请输入章节标题' }]}
>
<Input placeholder="请输入章节标题" />
</Form.Item>
<Form.Item
label="所属模块"
name="moduleNumber"
>
<InputNumber
min={1}
max={10}
style={{ width: '100%' }}
placeholder="请输入模块编号"
/>
</Form.Item>
<Form.Item
label="章节细纲"
name="outline"
rules={[
{ required: true, message: '请输入章节细纲' },
{
validator: (_, value) => {
if (value && value.length >= 100 && value.length <= 200) {
return Promise.resolve();
}
if (value && value.length < 100) {
return Promise.reject(new Error('细纲不能少于100字'));
}
if (value && value.length > 200) {
return Promise.reject(new Error('细纲不能超过200字'));
}
return Promise.resolve();
}
}
]}
extra="建议100-200字当前字数会实时显示"
>
<TextArea
rows={6}
placeholder="请输入本章的详细细纲100-200字..."
showCount
maxLength={200}
/>
</Form.Item>
<Form.Item
label="预计字数"
name="estimatedWords"
>
<InputNumber
min={900}
max={1200}
step={50}
style={{ width: '100%' }}
placeholder="请输入预计字数"
/>
</Form.Item>
</Form>
</Modal>
{/* 查看章节规划详情Modal */}
<Modal
title={`${viewingChapter?.chapterNumber}章规划详情:${viewingChapter?.title}`}
open={chapterViewModalVisible}
onCancel={() => setChapterViewModalVisible(false)}
width={700}
footer={[
<Button key="close" onClick={() => setChapterViewModalVisible(false)}>
</Button>,
<Button
key="edit"
type="primary"
icon={<EditOutlined />}
onClick={() => {
setChapterViewModalVisible(false);
if (viewingChapter) {
handleEditChapter(viewingChapter);
}
}}
>
</Button>
]}
>
{viewingChapter && (
<div style={{ padding: '16px 0' }}>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px',
fontSize: '16px',
fontWeight: 500
}}>
{viewingChapter.chapterNumber}
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px',
fontSize: '16px',
fontWeight: 500
}}>
{viewingChapter.title}
</div>
</div>
{viewingChapter.moduleNumber && (
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px'
}}>
<Tag color="cyan">{viewingChapter.moduleNumber}</Tag>
</div>
</div>
)}
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px'
}}>
<Tag color="orange">{viewingChapter.estimatedWords || 1050}</Tag>
</div>
</div>
<div style={{ marginBottom: '24px' }}>
<div style={{ fontSize: '14px', color: '#595959', marginBottom: '8px' }}>
</div>
<div style={{
padding: '12px',
background: '#f5f5f5',
borderRadius: '6px',
lineHeight: '1.8',
fontSize: '14px',
whiteSpace: 'pre-line',
maxHeight: '300px',
overflowY: 'auto'
}}>
{viewingChapter.outline}
</div>
<div style={{ marginTop: '8px', fontSize: '12px', color: '#8c8c8c' }}>
{viewingChapter.outline?.length || 0}100-200
</div>
</div>
</div>
)}
</Modal>
</div>
);
};
export default NovelGenerate;