1339 lines
45 KiB
TypeScript
1339 lines
45 KiB
TypeScript
|
|
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;
|