update:更新大纲管理和章节管理页面,支持前端搜索/分页。
This commit is contained in:
+313
-238
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user