update:更新大纲管理和章节管理页面,支持前端搜索/分页。

This commit is contained in:
xiamuceer-j
2026-03-04 16:27:18 +08:00
parent cfbc32505e
commit ec5398d60a
5 changed files with 579 additions and 327 deletions
+313 -238
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, 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, Pagination } from 'antd';
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 { useChapterSync } from '../store/hooks';
@@ -10,7 +10,6 @@ import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel';
import ChapterReader from '../components/ChapterReader';
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
import PartialRegenerateModal from '../components/PartialRegenerateModal';
@@ -69,8 +68,13 @@ export default function Chapters() {
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
// 分析任务状态管理
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
const pollingIntervalsRef = useRef<Record<string, number>>({});
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
const analysisPollingIntervalRef = useRef<number | null>(null);
const activeAnalysisPollingIdsRef = useRef<Set<string>>(new Set());
// 列表查询与分页状态
const [chapterSearchKeyword, setChapterSearchKeyword] = useState('');
const [chapterPage, setChapterPage] = useState(1);
const [chapterPageSize, setChapterPageSize] = useState(20);
// 阅读器状态
const [readerVisible, setReaderVisible] = useState(false);
@@ -95,6 +99,7 @@ export default function Chapters() {
// 批量生成相关状态
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
const [batchGenerating, setBatchGenerating] = useState(false);
const [batchAnalyzingUnanalyzed, setBatchAnalyzingUnanalyzed] = useState(false);
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
const [batchForm] = Form.useForm();
const [manualCreateForm] = Form.useForm();
@@ -341,94 +346,116 @@ export default function Chapters() {
// 清理轮询定时器
useEffect(() => {
const pollingIntervals = pollingIntervalsRef.current;
const batchPollingInterval = batchPollingIntervalRef.current;
return () => {
Object.values(pollingIntervals).forEach(interval => {
clearInterval(interval);
});
if (analysisPollingIntervalRef.current) {
clearInterval(analysisPollingIntervalRef.current);
analysisPollingIntervalRef.current = null;
}
if (batchPollingInterval) {
clearInterval(batchPollingInterval);
}
};
}, []);
// 加载所有章节的分析任务状态
// 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题
const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => {
const targetChapters = chaptersToLoad || chapters;
if (!targetChapters || targetChapters.length === 0) return;
const clearAnalysisPollingIfIdle = useCallback(() => {
if (activeAnalysisPollingIdsRef.current.size === 0 && analysisPollingIntervalRef.current) {
clearInterval(analysisPollingIntervalRef.current);
analysisPollingIntervalRef.current = null;
}
}, []);
const tasksMap: Record<string, AnalysisTask> = {};
const pollActiveAnalysisTasks = useCallback(async () => {
if (!currentProject?.id) return;
for (const chapter of targetChapters) {
// 只查询有内容的章节
if (chapter.content && chapter.content.trim() !== '') {
try {
const response = await fetch(`/api/chapters/${chapter.id}/analysis/status`);
if (response.ok) {
const task: AnalysisTask = await response.json();
tasksMap[chapter.id] = task;
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapter.id);
}
}
} catch {
// 404或其他错误表示没有分析任务,忽略
console.debug(`章节 ${chapter.id} 暂无分析任务`);
}
}
const activeIds = Array.from(activeAnalysisPollingIdsRef.current);
if (activeIds.length === 0) {
clearAnalysisPollingIfIdle();
return;
}
setAnalysisTasksMap(tasksMap);
};
try {
const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, activeIds);
const tasksMap = response.items || {};
// 启动单个章节的任务轮询
const startPollingTask = (chapterId: string) => {
// 如果已经在轮询,先清除
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
}
setAnalysisTasksMap(prev => ({
...prev,
...tasksMap,
}));
const interval = window.setInterval(async () => {
try {
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
if (!response.ok) return;
activeIds.forEach((chapterId) => {
const task = tasksMap[chapterId];
if (!task || task.status === 'completed' || task.status === 'failed' || task.status === 'none') {
activeAnalysisPollingIdsRef.current.delete(chapterId);
const task: AnalysisTask = await response.json();
setAnalysisTasksMap(prev => ({
...prev,
[chapterId]: task
}));
// 任务完成或失败,停止轮询
if (task.status === 'completed' || task.status === 'failed') {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
if (task.status === 'completed') {
message.success(`章节分析完成`);
} else if (task.status === 'failed') {
if (task?.status === 'completed') {
message.success('章节分析完成');
} else if (task?.status === 'failed') {
message.error(`章节分析失败: ${task.error_message || '未知错误'}`);
}
}
} catch (error) {
console.error('轮询分析任务失败:', error);
}
});
clearAnalysisPollingIfIdle();
} catch (error) {
console.error('批量轮询分析任务失败:', error);
}
}, [clearAnalysisPollingIfIdle, currentProject?.id]);
const ensureAnalysisPolling = useCallback(() => {
if (analysisPollingIntervalRef.current) return;
analysisPollingIntervalRef.current = window.setInterval(() => {
void pollActiveAnalysisTasks();
}, 2000);
pollingIntervalsRef.current[chapterId] = interval;
// 立即执行一次
void pollActiveAnalysisTasks();
}, [pollActiveAnalysisTasks]);
// 5分钟超时
setTimeout(() => {
if (pollingIntervalsRef.current[chapterId]) {
clearInterval(pollingIntervalsRef.current[chapterId]);
delete pollingIntervalsRef.current[chapterId];
// 加载所有章节的分析任务状态(批量接口,避免逐章请求风暴)
// 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题
const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => {
const targetChapters = chaptersToLoad || chapters;
if (!targetChapters || targetChapters.length === 0 || !currentProject?.id) return;
const chapterIds = targetChapters
.filter(chapter => chapter.content && chapter.content.trim() !== '')
.map(chapter => chapter.id);
if (chapterIds.length === 0) {
setAnalysisTasksMap({});
activeAnalysisPollingIdsRef.current.clear();
clearAnalysisPollingIfIdle();
return;
}
try {
const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, chapterIds);
const tasksMap = response.items || {};
setAnalysisTasksMap(tasksMap);
activeAnalysisPollingIdsRef.current.clear();
Object.entries(tasksMap).forEach(([chapterId, task]) => {
if (task?.status === 'pending' || task?.status === 'running') {
activeAnalysisPollingIdsRef.current.add(chapterId);
}
});
if (activeAnalysisPollingIdsRef.current.size > 0) {
ensureAnalysisPolling();
} else {
clearAnalysisPollingIfIdle();
}
}, 300000);
} catch (error) {
console.error('批量加载分析任务状态失败:', error);
}
};
// 启动单个章节的任务轮询(内部合并到批量轮询)
const startPollingTask = (chapterId: string) => {
activeAnalysisPollingIdsRef.current.add(chapterId);
ensureAnalysisPolling();
};
const loadWritingStyles = async () => {
@@ -559,9 +586,9 @@ export default function Chapters() {
};
// 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则)
const { sortedChapters, groupedChapters } = useMemo(() => {
const { sortedChapters } = useMemo(() => {
const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
@@ -584,12 +611,120 @@ export default function Chapters() {
groups[key].chapters.push(chapter);
});
// 转换为数组并按大纲顺序排序
const grouped = Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
return { sortedChapters: sorted, groupedChapters: grouped };
return { sortedChapters: sorted };
}, [chapters]);
// 章节查询过滤(前端过滤,减少渲染压力)
const filteredSortedChapters = useMemo(() => {
const keyword = chapterSearchKeyword.trim().toLowerCase();
if (!keyword) return sortedChapters;
return sortedChapters.filter((chapter) => {
return (
String(chapter.chapter_number).includes(keyword) ||
chapter.title.toLowerCase().includes(keyword) ||
(chapter.outline_title || '').toLowerCase().includes(keyword)
);
});
}, [sortedChapters, chapterSearchKeyword]);
// 分页后的扁平章节
const pagedSortedChapters = useMemo(() => {
const start = (chapterPage - 1) * chapterPageSize;
return filteredSortedChapters.slice(start, start + chapterPageSize);
}, [filteredSortedChapters, chapterPage, chapterPageSize]);
// one-to-many 模式分页后再按大纲分组
const pagedGroupedChapters = useMemo(() => {
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
outlineOrder: number;
chapters: Chapter[];
}> = {};
pagedSortedChapters.forEach(chapter => {
const key = chapter.outline_id || 'uncategorized';
if (!groups[key]) {
groups[key] = {
outlineId: chapter.outline_id || null,
outlineTitle: chapter.outline_title || '未分类章节',
outlineOrder: chapter.outline_order ?? 999,
chapters: []
};
}
groups[key].chapters.push(chapter);
});
return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
}, [pagedSortedChapters]);
// 搜索词或分页大小变化时重置到第一页
useEffect(() => {
setChapterPage(1);
}, [chapterSearchKeyword, chapterPageSize, currentProject?.outline_mode]);
// 数据变化导致页码越界时自动纠正
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredSortedChapters.length / chapterPageSize));
if (chapterPage > maxPage) {
setChapterPage(maxPage);
}
}, [filteredSortedChapters.length, chapterPage, chapterPageSize]);
// 预计算每章可生成状态,避免在渲染阶段重复 O(n²) 扫描
const chapterGenerateGateMap = useMemo(() => {
const gateMap: Record<string, { canGenerate: boolean; reason: string }> = {};
const incompleteChapterNumbers: number[] = [];
const unanalyzedChapters: Array<{ chapterNumber: number; reason: string }> = [];
sortedChapters.forEach((chapter) => {
if (incompleteChapterNumbers.length > 0) {
gateMap[chapter.id] = {
canGenerate: false,
reason: `需要先完成前置章节:第 ${incompleteChapterNumbers.join('、')}`
};
} else if (unanalyzedChapters.length > 0) {
gateMap[chapter.id] = {
canGenerate: false,
reason: `需要先分析前置章节:第 ${unanalyzedChapters.map(c => c.chapterNumber).join('、')} 章 (${unanalyzedChapters.map(c => c.reason).join('、')})`
};
} else {
gateMap[chapter.id] = { canGenerate: true, reason: '' };
}
// 将当前章纳入“后续章节”的前置条件
if (!chapter.content || chapter.content.trim() === '') {
incompleteChapterNumbers.push(chapter.chapter_number);
}
const task = analysisTasksMap[chapter.id];
if (!task || !task.has_task) {
unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '未分析' });
} else if (task.status === 'pending') {
unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '等待分析' });
} else if (task.status === 'running') {
unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '分析中' });
} else if (task.status === 'failed') {
unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '分析失败' });
} else if (task.status !== 'completed') {
unanalyzedChapters.push({ chapterNumber: chapter.chapter_number, reason: '状态未知' });
}
});
return gateMap;
}, [sortedChapters, analysisTasksMap]);
// 当前可被“一键分析”的章节(有内容且未处于完成/进行中)
const batchAnalyzableChapterCount = useMemo(() => {
return sortedChapters.filter((chapter) => {
if (!chapter.content || chapter.content.trim() === '') return false;
const task = analysisTasksMap[chapter.id];
if (!task || !task.has_task) return true;
return task.status !== 'completed' && task.status !== 'pending' && task.status !== 'running';
}).length;
}, [sortedChapters, analysisTasksMap]);
if (!currentProject) return null;
// 获取人称的中文显示文本(同时支持中英文值)
@@ -608,84 +743,11 @@ export default function Chapters() {
};
const canGenerateChapter = (chapter: Chapter): boolean => {
if (chapter.chapter_number === 1) {
return true;
}
const previousChapters = chapters.filter(
c => c.chapter_number < chapter.chapter_number
);
// 检查所有前置章节是否有内容
const allHaveContent = previousChapters.every(c => c.content && c.content.trim() !== '');
if (!allHaveContent) {
return false;
}
// 检查所有前置章节是否分析成功
const allAnalyzed = previousChapters.every(c => {
const task = analysisTasksMap[c.id];
// 如果没有分析任务或分析失败,则不允许生成
if (!task || !task.has_task) {
return false;
}
// 只有completed状态才算分析成功
return task.status === 'completed';
});
return allAnalyzed;
return chapterGenerateGateMap[chapter.id]?.canGenerate ?? true;
};
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}`;
}
// 检查是否有未分析或分析失败的章节
const unanalyzedChapters = previousChapters.filter(c => {
const task = analysisTasksMap[c.id];
if (!task || !task.has_task) {
return true; // 没有分析任务
}
return task.status !== 'completed'; // 分析未完成或失败
});
if (unanalyzedChapters.length > 0) {
const numbers = unanalyzedChapters.map(c => c.chapter_number).join('、');
const reasons = unanalyzedChapters.map(c => {
const task = analysisTasksMap[c.id];
if (!task || !task.has_task) {
return '未分析';
}
if (task.status === 'pending') {
return '等待分析';
}
if (task.status === 'running') {
return '分析中';
}
if (task.status === 'failed') {
return '分析失败';
}
return '状态未知';
});
return `需要先分析前置章节:第 ${numbers} 章 (${reasons.join('、')})`;
}
return '';
return chapterGenerateGateMap[chapter.id]?.reason || '';
};
const handleOpenModal = (id: string) => {
@@ -954,6 +1016,41 @@ export default function Chapters() {
setAnalysisVisible(true);
};
// 一键按章节顺序分析未分析章节
const handleBatchAnalyzeUnanalyzed = async () => {
if (!currentProject?.id) return;
try {
setBatchAnalyzingUnanalyzed(true);
const result = await chapterApi.batchAnalyzeUnanalyzed(currentProject.id);
if (result.total_started > 0) {
setAnalysisTasksMap((prev) => ({
...prev,
...result.started_tasks,
}));
Object.keys(result.started_tasks).forEach((chapterId) => {
startPollingTask(chapterId);
});
message.success(
`已加入 ${result.total_started} 章顺序分析队列(跳过已分析 ${result.total_already_completed} 章,分析中/排队中 ${result.total_skipped_running} 章)`
);
} else {
message.info('没有可启动分析的章节:当前章节要么无内容、要么已分析完成、要么正在分析中');
}
// 刷新一次状态,确保前端与后端一致
await loadAnalysisTasks();
} catch (error: unknown) {
const err = error as Error;
message.error(`一键分析失败:${err.message || '未知错误'}`);
} finally {
setBatchAnalyzingUnanalyzed(false);
}
};
// 批量生成函数
const handleBatchGenerate = async (values: {
startChapterNumber: number;
@@ -1730,19 +1827,6 @@ export default function Chapters() {
}
};
const handleChapterSelect = (chapterId: string) => {
const element = document.getElementById(`chapter-item-${chapterId}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Optional: add a visual highlight effect
element.style.transition = 'background-color 0.5s ease';
element.style.backgroundColor = '#e6f7ff';
setTimeout(() => {
element.style.backgroundColor = '';
}, 1500);
}
};
// 打开阅读器
const handleOpenReader = (chapter: Chapter) => {
setReadingChapter(chapter);
@@ -1801,11 +1885,28 @@ export default function Chapters() {
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<BookOutlined style={{ marginRight: 8 }} />
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
<BookOutlined style={{ marginRight: 8 }} />
</h2>
<Tag
color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'}
style={{ width: 'fit-content' }}
>
{currentProject.outline_mode === 'one-to-one'
? '传统模式:章节由大纲管理,请在大纲页面操作'
: '细化模式:章节可在大纲页面展开'}
</Tag>
</div>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<Input.Search
allowClear
placeholder="搜索章节(序号/标题/大纲)"
value={chapterSearchKeyword}
onChange={(e) => setChapterSearchKeyword(e.target.value)}
style={{ width: isMobile ? '100%' : 280 }}
/>
{currentProject.outline_mode === 'one-to-many' && (
<Button
icon={<PlusOutlined />}
@@ -1816,6 +1917,19 @@ export default function Chapters() {
</Button>
)}
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={handleBatchAnalyzeUnanalyzed}
loading={batchAnalyzingUnanalyzed}
disabled={chapters.length === 0 || batchAnalyzableChapterCount === 0}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
style={{ background: '#fa8c16', borderColor: '#fa8c16' }}
title={batchAnalyzableChapterCount === 0 ? '暂无可一键分析章节' : `可一键分析 ${batchAnalyzableChapterCount}`}
>
{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''}
</Button>
<Button
type="primary"
icon={<RocketOutlined />}
@@ -1837,23 +1951,18 @@ export default function Chapters() {
>
TXT
</Button>
{!isMobile && (
<Tag color="blue">
{currentProject.outline_mode === 'one-to-one'
? '传统模式:章节由大纲管理,请在大纲页面操作'
: '细化模式:章节可在大纲页面展开'}
</Tag>
)}
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" />
) : filteredSortedChapters.length === 0 ? (
<Empty description="未找到匹配章节" />
) : currentProject.outline_mode === 'one-to-one' ? (
// one-to-one 模式:直接显示扁平列表
<List
dataSource={sortedChapters}
dataSource={pagedSortedChapters}
renderItem={(item) => (
<List.Item
id={`chapter-item-${item.id}`}
@@ -2007,11 +2116,12 @@ export default function Chapters() {
// one-to-many 模式:按大纲分组显示
<Collapse
bordered={false}
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
defaultActiveKey={pagedGroupedChapters.length > 0 ? ['0'] : []}
destroyInactivePanel
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
style={{ background: 'transparent' }}
>
{groupedChapters.map((group, groupIndex) => (
{pagedGroupedChapters.map((group, groupIndex) => (
<Collapse.Panel
key={groupIndex.toString()}
header={
@@ -2252,6 +2362,27 @@ export default function Chapters() {
)}
</div>
{filteredSortedChapters.length > 0 && (
<div style={{ paddingTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<Pagination
current={chapterPage}
pageSize={chapterPageSize}
total={filteredSortedChapters.length}
showSizeChanger
pageSizeOptions={['10', '20', '50', '100']}
onChange={(page, size) => {
setChapterPage(page);
if (size !== chapterPageSize) {
setChapterPageSize(size);
setChapterPage(1);
}
}}
showTotal={(total) => `${total}`}
size={isMobile ? 'small' : 'default'}
/>
</div>
)}
<Modal
title={editingId ? '编辑章节信息' : '添加章节'}
open={isModalOpen}
@@ -2558,51 +2689,10 @@ export default function Chapters() {
});
}
// 延迟500ms后刷新该章节的分析状态,给后端足够时间完成数据库写入
if (analysisChapterId) {
const chapterIdToRefresh = analysisChapterId;
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => {
if (response.ok) {
return response.json();
}
throw new Error('获取状态失败');
})
.then((task: AnalysisTask) => {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
// 如果任务正在运行,启动轮询
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
})
.catch(error => {
console.error('刷新分析状态失败:', error);
// 如果查询失败,再延迟尝试一次
setTimeout(() => {
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
.then(response => response.ok ? response.json() : null)
.then((task: AnalysisTask | null) => {
if (task) {
setAnalysisTasksMap(prev => ({
...prev,
[chapterIdToRefresh]: task
}));
if (task.status === 'pending' || task.status === 'running') {
startPollingTask(chapterIdToRefresh);
}
}
})
.catch(err => console.error('第二次刷新失败:', err));
}, 1000);
});
}, 500);
}
// 延迟500ms后批量刷新分析状态,避免单章接口高频调用
setTimeout(() => {
loadAnalysisTasks();
}, 500);
setAnalysisChapterId(null);
}}
@@ -2867,21 +2957,6 @@ export default function Chapters() {
cancelButtonText="取消任务"
/>
<FloatButton
icon={<BookOutlined />}
type="primary"
tooltip="章节目录"
onClick={() => setIsIndexPanelVisible(true)}
style={{ right: isMobile ? 24 : 48, bottom: isMobile ? 80 : 48 }}
/>
<FloatingIndexPanel
visible={isIndexPanelVisible}
onClose={() => setIsIndexPanelVisible(false)}
groupedChapters={groupedChapters}
onChapterSelect={handleChapterSelect}
/>
{/* 章节阅读器 */}
{readingChapter && (
<ChapterReader