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

695 lines
24 KiB
TypeScript
Raw Normal View History

2025-10-30 11:14:43 +08:00
import { useState, useEffect, useRef } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined } from '@ant-design/icons';
2025-10-30 11:14:43 +08:00
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
2025-10-31 17:23:25 +08:00
import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle } from '../types';
2025-10-30 11:14:43 +08:00
import { cardStyles } from '../components/CardStyles';
import ChapterAnalysis from '../components/ChapterAnalysis';
2025-10-30 11:14:43 +08:00
const { TextArea } = Input;
export default function Chapters() {
const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore();
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isContinuing, setIsContinuing] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form] = Form.useForm();
const [editorForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const contentTextAreaRef = useRef<any>(null);
2025-10-31 17:23:25 +08:00
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
2025-10-30 11:14:43 +08:00
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const {
refreshChapters,
updateChapter,
generateChapterContentStream
} = useChapterSync();
useEffect(() => {
if (currentProject?.id) {
refreshChapters();
2025-10-31 17:23:25 +08:00
loadWritingStyles();
2025-10-30 11:14:43 +08:00
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
2025-10-31 17:23:25 +08:00
const loadWritingStyles = async () => {
if (!currentProject?.id) return;
try {
const response = await writingStyleApi.getProjectStyles(currentProject.id);
setWritingStyles(response.styles);
// 设置默认风格为初始选中
const defaultStyle = response.styles.find(s => s.is_default);
if (defaultStyle) {
setSelectedStyleId(defaultStyle.id);
}
} catch (error) {
console.error('加载写作风格失败:', error);
message.error('加载写作风格失败');
}
};
2025-10-30 11:14:43 +08:00
if (!currentProject) return null;
const canGenerateChapter = (chapter: Chapter): boolean => {
if (chapter.chapter_number === 1) {
return true;
}
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
);
return previousChapters.every(c => c.content && c.content.trim() !== '');
};
const getGenerateDisabledReason = (chapter: Chapter): string => {
if (chapter.chapter_number === 1) {
return '';
}
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
);
const incompleteChapters = previousChapters.filter(
c => !c.content || c.content.trim() === ''
);
if (incompleteChapters.length > 0) {
const numbers = incompleteChapters.map(c => c.chapter_number).join('、');
return `需要先完成前置章节:第 ${numbers}`;
}
return '';
};
const handleOpenModal = (id: string) => {
const chapter = chapters.find(c => c.id === id);
if (chapter) {
form.setFieldsValue(chapter);
setEditingId(id);
setIsModalOpen(true);
}
};
const handleSubmit = async (values: ChapterUpdate) => {
if (!editingId) return;
try {
await updateChapter(editingId, values);
message.success('章节更新成功');
setIsModalOpen(false);
form.resetFields();
} catch {
message.error('操作失败');
}
};
const handleOpenEditor = (id: string) => {
const chapter = chapters.find(c => c.id === id);
if (chapter) {
setCurrentChapter(chapter);
editorForm.setFieldsValue({
title: chapter.title,
content: chapter.content,
});
setEditingId(id);
setIsEditorOpen(true);
}
};
const handleEditorSubmit = async (values: ChapterUpdate) => {
if (!editingId || !currentProject) return;
try {
await updateChapter(editingId, values);
// 刷新项目信息以更新总字数统计
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
message.success('章节保存成功');
setIsEditorOpen(false);
} catch {
message.error('保存失败');
}
};
const handleGenerate = async () => {
if (!editingId) return;
try {
setIsContinuing(true);
setIsGenerating(true);
await generateChapterContentStream(editingId, (content) => {
editorForm.setFieldsValue({ content });
if (contentTextAreaRef.current) {
const textArea = contentTextAreaRef.current.resizableTextArea?.textArea;
if (textArea) {
textArea.scrollTop = textArea.scrollHeight;
}
}
}, selectedStyleId, targetWordCount);
2025-10-30 11:14:43 +08:00
message.success('AI创作成功');
} catch (error) {
const apiError = error as ApiError;
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
} finally {
setIsContinuing(false);
setIsGenerating(false);
}
};
const showGenerateModal = (chapter: Chapter) => {
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
).sort((a, b) => a.chapter_number - b.chapter_number);
2025-10-31 17:23:25 +08:00
const selectedStyle = writingStyles.find(s => s.id === selectedStyleId);
2025-10-30 11:14:43 +08:00
const modal = Modal.confirm({
title: 'AI创作章节内容',
width: 700,
centered: true,
content: (
<div style={{ marginTop: 16 }}>
<p>AI将根据以下信息创作本章内容</p>
<ul>
<li></li>
<li></li>
<li></li>
<li><strong></strong></li>
2025-10-31 17:23:25 +08:00
{selectedStyle && (
<li><strong>{selectedStyle.name}</strong></li>
)}
<li><strong>{targetWordCount}</strong></li>
2025-10-30 11:14:43 +08:00
</ul>
{previousChapters.length > 0 && (
<div style={{
marginTop: 16,
padding: 12,
background: '#f0f5ff',
borderRadius: 4,
border: '1px solid #adc6ff'
}}>
<div style={{ marginBottom: 8, fontWeight: 500, color: '#1890ff' }}>
📚 {previousChapters.length}
</div>
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
{previousChapters.map(ch => (
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
{ch.chapter_number}{ch.title} ({ch.word_count || 0})
</div>
))}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
💡 AI会参考这些章节内容
</div>
</div>
)}
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
</p>
</div>
),
okText: '开始创作',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: async () => {
modal.update({
okButtonProps: { danger: true, loading: true },
cancelButtonProps: { disabled: true },
closable: false,
maskClosable: false,
keyboard: false,
});
try {
2025-10-31 17:23:25 +08:00
if (!selectedStyleId) {
message.error('请先选择写作风格');
modal.update({
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
closable: true,
maskClosable: true,
keyboard: true,
});
return;
}
2025-10-30 11:14:43 +08:00
await handleGenerate();
modal.destroy();
} catch (error) {
modal.update({
okButtonProps: { danger: true, loading: false },
cancelButtonProps: { disabled: false },
closable: true,
maskClosable: true,
keyboard: true,
});
}
},
onCancel: () => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成');
return false;
}
},
});
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
'draft': 'default',
'writing': 'processing',
'completed': 'success',
};
return colors[status] || 'default';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
'draft': '草稿',
'writing': '创作中',
'completed': '已完成',
};
return texts[status] || status;
};
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
const handleExport = () => {
if (chapters.length === 0) {
message.warning('当前项目没有章节,无法导出');
return;
}
Modal.confirm({
title: '导出项目章节',
content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`,
centered: true,
okText: '确定导出',
cancelText: '取消',
onOk: () => {
try {
projectApi.exportProject(currentProject.id);
message.success('开始下载导出文件');
} catch {
message.error('导出失败,请重试');
}
},
});
};
const handleShowAnalysis = (chapterId: string) => {
setAnalysisChapterId(chapterId);
setAnalysisVisible(true);
};
2025-10-30 11:14:43 +08:00
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: isMobile ? '12px 0' : '16px 0',
marginBottom: isMobile ? 12 : 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0,
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExport}
disabled={chapters.length === 0}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
>
TXT
</Button>
{!isMobile && <Tag color="blue">/</Tag>}
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" />
) : (
<Card style={cardStyles.base}>
<List
dataSource={sortedChapters}
renderItem={(item) => (
<List.Item
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center'
}}
actions={isMobile ? undefined : [
<Button
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : ''}>
<Button
icon={<FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!item.content || item.content.trim() === ''}
>
</Button>
</Tooltip>,
2025-10-30 11:14:43 +08:00
<Button
type="text"
icon={<SettingOutlined />}
2025-10-30 11:14:43 +08:00
onClick={() => handleOpenModal(item.id)}
>
</Button>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
<span>{item.chapter_number}{item.title}</span>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
</div>
}
description={
item.content ? (
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
{item.content.substring(0, isMobile ? 80 : 150)}
{item.content.length > (isMobile ? 80 : 150) && '...'}
</div>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}></span>
)
}
/>
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
title="编辑内容"
/>
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : '查看分析'}>
<Button
type="text"
icon={<FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!item.content || item.content.trim() === ''}
/>
</Tooltip>
2025-10-30 11:14:43 +08:00
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
)}
</div>
</List.Item>
)}
/>
</Card>
)}
</div>
<Modal
title={editingId ? '编辑章节信息' : '添加章节'}
open={isModalOpen}
onCancel={() => setIsModalOpen(false)}
footer={null}
centered={!isMobile}
width={isMobile ? 'calc(100% - 32px)' : 520}
style={isMobile ? {
top: 20,
paddingBottom: 0,
maxWidth: 'calc(100vw - 32px)',
margin: '0 16px'
} : undefined}
styles={{
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="章节标题"
name="title"
tooltip="章节标题由大纲管理,建议在大纲页面统一修改"
>
<Input placeholder="输入章节标题" disabled />
</Form.Item>
<Form.Item
label="章节序号"
name="chapter_number"
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
>
<Input type="number" placeholder="章节排序序号" disabled />
</Form.Item>
<Form.Item label="状态" name="status">
<Select placeholder="选择状态">
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="writing"></Select.Option>
<Select.Option value="completed"></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑章节内容"
open={isEditorOpen}
onCancel={() => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成后再关闭');
return;
}
setIsEditorOpen(false);
}}
closable={!isGenerating}
maskClosable={!isGenerating}
keyboard={!isGenerating}
width={isMobile ? 'calc(100% - 32px)' : '85%'}
centered={!isMobile}
style={isMobile ? {
top: 20,
paddingBottom: 0,
maxWidth: 'calc(100vw - 32px)',
margin: '0 16px'
} : undefined}
styles={{
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(100vh - 110px)',
2025-10-30 11:14:43 +08:00
overflowY: 'auto',
padding: isMobile ? '16px 12px' : '8px'
}
}}
footer={null}
>
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
<Form.Item
label="章节标题"
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="title"
noStyle
>
<Input size="large" disabled style={{ flex: 1 }} />
</Form.Item>
{editingId && (() => {
const currentChapter = chapters.find(c => c.id === editingId);
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
return (
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
<Button
type="primary"
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing}
disabled={!canGenerate}
danger={!canGenerate}
size="large"
style={{ fontWeight: 'bold' }}
>
{isMobile ? 'AI创作' : 'AI创作章节内容'}
</Button>
</Tooltip>
);
})()}
</Space.Compact>
</Form.Item>
2025-10-31 17:23:25 +08:00
<Form.Item
label="写作风格"
tooltip="选择AI创作时使用的写作风格,可在写作风格菜单中管理"
required
>
<Select
placeholder="请选择写作风格"
value={selectedStyleId}
onChange={setSelectedStyleId}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
status={!selectedStyleId ? 'error' : undefined}
>
{writingStyles.map(style => (
<Select.Option key={style.id} value={style.id}>
{style.name}
{style.is_default && ' (默认)'}
{style.description && ` - ${style.description}`}
</Select.Option>
))}
</Select>
{!selectedStyleId && (
<div style={{ color: '#ff4d4f', fontSize: 12, marginTop: 4 }}>
</div>
)}
</Form.Item>
<Form.Item
label="目标字数"
tooltip="AI生成章节时的目标字数,实际生成字数可能略有偏差"
>
<InputNumber
min={500}
max={10000}
step={100}
value={targetWordCount}
onChange={(value) => setTargetWordCount(value || 3000)}
size="large"
disabled={isGenerating}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
<div style={{ color: '#666', fontSize: 12, marginTop: 4 }}>
500-100003000
</div>
</Form.Item>
2025-10-30 11:14:43 +08:00
<Form.Item label="章节内容" name="content">
<TextArea
ref={contentTextAreaRef}
rows={isMobile ? 12 : 20}
placeholder="开始写作..."
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
disabled={isGenerating}
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
onClick={() => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成后再关闭');
return;
}
setIsEditorOpen(false);
}}
block={isMobile}
disabled={isGenerating}
>
</Button>
<Button
type="primary"
htmlType="submit"
block={isMobile}
disabled={isGenerating}
>
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
{analysisChapterId && (
<ChapterAnalysis
chapterId={analysisChapterId}
visible={analysisVisible}
onClose={() => {
setAnalysisVisible(false);
setAnalysisChapterId(null);
}}
/>
)}
2025-10-30 11:14:43 +08:00
</div>
);
}