feature: 新增章节阅读器功能,支持在章节列表中直接阅读章节内容

This commit is contained in:
xiamuceer
2026-01-01 17:26:38 +08:00
parent fba6922a5c
commit fb42aa9874
+138 -89
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd'; import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined } from '@ant-design/icons'; import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { useChapterSync } from '../store/hooks'; import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi, chapterApi } from '../services/api'; import { projectApi, writingStyleApi, chapterApi } from '../services/api';
@@ -10,6 +10,7 @@ import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal'; import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel'; import FloatingIndexPanel from '../components/FloatingIndexPanel';
import ChapterReader from '../components/ChapterReader';
const { TextArea } = Input; const { TextArea } = Input;
@@ -39,6 +40,10 @@ export default function Chapters() {
const pollingIntervalsRef = useRef<Record<string, number>>({}); const pollingIntervalsRef = useRef<Record<string, number>>({});
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false); const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
// 阅读器状态
const [readerVisible, setReaderVisible] = useState(false);
const [readingChapter, setReadingChapter] = useState<Chapter | null>(null);
// 规划编辑状态 // 规划编辑状态
const [planEditorVisible, setPlanEditorVisible] = useState(false); const [planEditorVisible, setPlanEditorVisible] = useState(false);
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null); const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
@@ -1170,11 +1175,9 @@ export default function Chapters() {
); );
case 'failed': case 'failed':
return ( return (
<Tooltip title={task.error_message}> <Tag icon={<CloseCircleOutlined />} color="error" title={task.error_message || undefined}>
<Tag icon={<CloseCircleOutlined />} color="error">
</Tag>
</Tag>
</Tooltip>
); );
default: default:
return null; return null;
@@ -1478,6 +1481,24 @@ export default function Chapters() {
} }
}; };
// 打开阅读器
const handleOpenReader = (chapter: Chapter) => {
setReadingChapter(chapter);
setReaderVisible(true);
};
// 阅读器切换章节
const handleReaderChapterChange = async (chapterId: string) => {
try {
const response = await fetch(`/api/chapters/${chapterId}`);
if (!response.ok) throw new Error('获取章节失败');
const newChapter = await response.json();
setReadingChapter(newChapter);
} catch {
message.error('加载章节失败');
}
};
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{contextHolder} {contextHolder}
@@ -1561,6 +1582,15 @@ export default function Chapters() {
alignItems: isMobile ? 'flex-start' : 'center', alignItems: isMobile ? 'flex-start' : 'center',
}} }}
actions={isMobile ? undefined : [ actions={isMobile ? undefined : [
<Button
type="text"
icon={<ReadOutlined />}
onClick={() => handleOpenReader(item)}
disabled={!item.content || item.content.trim() === ''}
title={!item.content || item.content.trim() === '' ? '暂无内容' : '沉浸式阅读'}
>
</Button>,
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
@@ -1574,23 +1604,20 @@ export default function Chapters() {
const hasContent = item.content && item.content.trim() !== ''; const hasContent = item.content && item.content.trim() !== '';
return ( return (
<Tooltip <Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
title={ title={
!hasContent ? '请先生成章节内容' : !hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' : isAnalyzing ? '分析进行中,请稍候...' :
'' ''
} }
> >
<Button {isAnalyzing ? '分析中' : '查看分析'}
type="text" </Button>
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
); );
})(), })(),
<Button <Button
@@ -1621,11 +1648,9 @@ export default function Chapters() {
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} /> <Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} />
{renderAnalysisStatus(item.id)} {renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && ( {!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}> <Tag icon={<LockOutlined />} color="warning" title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tag>
</Tooltip>
)} )}
</Space> </Space>
</div> </div>
@@ -1644,6 +1669,14 @@ export default function Chapters() {
{isMobile && ( {isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap> <Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<ReadOutlined />}
onClick={() => handleOpenReader(item)}
size="small"
disabled={!item.content || item.content.trim() === ''}
title={!item.content || item.content.trim() === '' ? '暂无内容' : '阅读'}
/>
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
@@ -1657,22 +1690,19 @@ export default function Chapters() {
const hasContent = item.content && item.content.trim() !== ''; const hasContent = item.content && item.content.trim() !== '';
return ( return (
<Tooltip <Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
title={ title={
!hasContent ? '请先生成章节内容' : !hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' : isAnalyzing ? '分析中' :
'查看分析' '查看分析'
} }
> />
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
); );
})()} })()}
<Button <Button
@@ -1737,6 +1767,15 @@ export default function Chapters() {
alignItems: isMobile ? 'flex-start' : 'center', alignItems: isMobile ? 'flex-start' : 'center',
}} }}
actions={isMobile ? undefined : [ actions={isMobile ? undefined : [
<Button
type="text"
icon={<ReadOutlined />}
onClick={() => handleOpenReader(item)}
disabled={!item.content || item.content.trim() === ''}
title={!item.content || item.content.trim() === '' ? '暂无内容' : '沉浸式阅读'}
>
</Button>,
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
@@ -1750,23 +1789,20 @@ export default function Chapters() {
const hasContent = item.content && item.content.trim() !== ''; const hasContent = item.content && item.content.trim() !== '';
return ( return (
<Tooltip <Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
title={ title={
!hasContent ? '请先生成章节内容' : !hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' : isAnalyzing ? '分析进行中,请稍候...' :
'' ''
} }
> >
<Button {isAnalyzing ? '分析中' : '查看分析'}
type="text" </Button>
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
); );
})(), })(),
<Button <Button
@@ -1816,33 +1852,29 @@ export default function Chapters() {
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} /> <Badge count={`${item.word_count || 0}`} style={{ backgroundColor: 'var(--color-success)' }} />
{renderAnalysisStatus(item.id)} {renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && ( {!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}> <Tag icon={<LockOutlined />} color="warning" title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tag>
</Tooltip>
)} )}
<Space size={4}> <Space size={4}>
{item.expansion_plan && ( {item.expansion_plan && (
<Tooltip title="查看展开详情"> <InfoCircleOutlined
<InfoCircleOutlined title="查看展开详情"
style={{ color: 'var(--color-primary)', cursor: 'pointer', fontSize: 16 }} style={{ color: 'var(--color-primary)', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
)}
<Tooltip title={item.expansion_plan ? "编辑规划信息" : "创建规划信息"}>
<FormOutlined
style={{ color: 'var(--color-success)', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleOpenPlanEditor(item); showExpansionPlanModal(item);
}} }}
/> />
</Tooltip> )}
<FormOutlined
title={item.expansion_plan ? "编辑规划信息" : "创建规划信息"}
style={{ color: 'var(--color-success)', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
handleOpenPlanEditor(item);
}}
/>
</Space> </Space>
</Space> </Space>
</div> </div>
@@ -1861,6 +1893,14 @@ export default function Chapters() {
{isMobile && ( {isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap> <Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<ReadOutlined />}
onClick={() => handleOpenReader(item)}
size="small"
disabled={!item.content || item.content.trim() === ''}
title={!item.content || item.content.trim() === '' ? '暂无内容' : '阅读'}
/>
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
@@ -1874,22 +1914,19 @@ export default function Chapters() {
const hasContent = item.content && item.content.trim() !== ''; const hasContent = item.content && item.content.trim() !== '';
return ( return (
<Tooltip <Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
title={ title={
!hasContent ? '请先生成章节内容' : !hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' : isAnalyzing ? '分析中' :
'查看分析' '查看分析'
} }
> />
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
); );
})()} })()}
<Button <Button
@@ -2045,19 +2082,18 @@ export default function Chapters() {
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : ''; const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
return ( return (
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}> <Button
<Button type="primary"
type="primary" icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />} onClick={() => currentChapter && showGenerateModal(currentChapter)}
onClick={() => currentChapter && showGenerateModal(currentChapter)} loading={isContinuing}
loading={isContinuing} disabled={!canGenerate}
disabled={!canGenerate} danger={!canGenerate}
danger={!canGenerate} style={{ fontWeight: 'bold' }}
style={{ fontWeight: 'bold' }} title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}
> >
{isMobile ? 'AI' : 'AI创作'} {isMobile ? 'AI' : 'AI创作'}
</Button> </Button>
</Tooltip>
); );
})()} })()}
</Space.Compact> </Space.Compact>
@@ -2556,6 +2592,19 @@ export default function Chapters() {
onChapterSelect={handleChapterSelect} onChapterSelect={handleChapterSelect}
/> />
{/* 章节阅读器 */}
{readingChapter && (
<ChapterReader
visible={readerVisible}
chapter={readingChapter}
onClose={() => {
setReaderVisible(false);
setReadingChapter(null);
}}
onChapterChange={handleReaderChapterChange}
/>
)}
{/* 规划编辑器 */} {/* 规划编辑器 */}
{editingPlanChapter && currentProject && (() => { {editingPlanChapter && currentProject && (() => {
let parsedPlanData = null; let parsedPlanData = null;