Files
ai-novel/src/pages/NovelGenerate.tsx
2026-04-16 21:32:21 +08:00

1339 lines
45 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 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字内
- 配角重要配角设定至少3个每个100字内
- 反派对立角色设定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字
预计字数1050字范围900-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;