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
+23 -5
View File
@@ -115,19 +115,37 @@ async def get_outlines(
.order_by(Outline.order_index)
)
outlines = result.scalars().all()
# 批量查询是否已展开章节(避免前端 N+1 请求)
outline_ids = [outline.id for outline in outlines]
outline_has_chapters_map: Dict[str, bool] = {}
if outline_ids:
chapters_count_result = await db.execute(
select(Chapter.outline_id, func.count(Chapter.id))
.where(Chapter.outline_id.in_(outline_ids))
.group_by(Chapter.outline_id)
)
outline_has_chapters_map = {
str(outline_id): count > 0
for outline_id, count in chapters_count_result.all()
if outline_id
}
# 🔧 优化:后端完全解析structure,提取所有字段填充到outline对象
for outline in outlines:
# 动态附加是否已有章节展开状态,供前端直接使用
setattr(outline, "has_chapters", outline_has_chapters_map.get(outline.id, False))
if outline.structure:
try:
structure_data = json.loads(outline.structure)
# 从structure中提取所有字段填充到outline对象
outline.title = structure_data.get("title", f"{outline.order_index}")
outline.content = structure_data.get("summary") or structure_data.get("content", "")
# structure字段保持不变,供前端使用其他字段(如characters、scenes等)
except json.JSONDecodeError:
logger.warning(f"解析大纲 {outline.id} 的structure失败")
outline.title = f"{outline.order_index}"
@@ -136,7 +154,7 @@ async def get_outlines(
# 没有structure的异常情况
outline.title = f"{outline.order_index}"
outline.content = "暂无内容"
return OutlineListResponse(total=total, items=outlines)
+2 -1
View File
@@ -35,9 +35,10 @@ class OutlineResponse(BaseModel):
content: str
structure: Optional[str] = None
order_index: int
has_chapters: Optional[bool] = None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
+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
+133 -83
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs } from 'antd';
import { useState, useEffect, useMemo } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useOutlineSync } from '../store/hooks';
@@ -78,6 +78,33 @@ function getOrganizationNames(entries: CharacterEntry[]): string[] {
return entries.filter(e => e.type === 'organization').map(e => e.name);
}
interface OutlineStructureData {
key_events?: string[];
key_points?: string[];
characters_involved?: string[];
characters?: unknown[];
scenes?: string[] | Array<{
location: string;
characters: string[];
purpose: string;
}>;
emotion?: string;
goal?: string;
title?: string;
summary?: string;
content?: string;
}
function parseOutlineStructure(structure?: string): OutlineStructureData {
if (!structure) return {};
try {
return JSON.parse(structure) as OutlineStructureData;
} catch (e) {
console.error('解析structure失败:', e);
return {};
}
}
const { TextArea } = Input;
export default function Outline() {
@@ -93,9 +120,6 @@ export default function Outline() {
const [isExpanding, setIsExpanding] = useState(false);
const [projectCharacters, setProjectCharacters] = useState<Array<{ label: string; value: string }>>([]);
// ✅ 新增:记录每个大纲的展开状态
const [outlineExpandStatus, setOutlineExpandStatus] = useState<Record<string, boolean>>({});
// ✅ 新增:记录场景区域的展开/折叠状态
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({});
@@ -122,6 +146,11 @@ export default function Outline() {
return () => window.removeEventListener('resize', handleResize);
}, []);
// 大纲查询与分页状态
const [outlineSearchKeyword, setOutlineSearchKeyword] = useState('');
const [outlinePage, setOutlinePage] = useState(1);
const [outlinePageSize, setOutlinePageSize] = useState(20);
// 使用同步 hooks
const {
refreshOutlines,
@@ -155,25 +184,22 @@ export default function Outline() {
}
};
// ✅ 新增:加载所有大纲的展开状态
useEffect(() => {
const loadExpandStatus = async () => {
if (outlines.length === 0) return;
// 从后端返回字段直接构建展开状态,避免前端 N+1 请求
const outlineExpandStatus = useMemo(() => {
const statusMap: Record<string, boolean> = {};
outlines.forEach((outline) => {
statusMap[outline.id] = Boolean(outline.has_chapters);
});
return statusMap;
}, [outlines]);
const statusMap: Record<string, boolean> = {};
for (const outline of outlines) {
try {
const chapters = await outlineApi.getOutlineChapters(outline.id);
statusMap[outline.id] = chapters.has_chapters;
} catch (error) {
console.error(`加载大纲 ${outline.id} 状态失败:`, error);
statusMap[outline.id] = false;
}
}
setOutlineExpandStatus(statusMap);
};
loadExpandStatus();
// 统一预解析 structure,避免 render 阶段重复 JSON.parse
const outlineStructureMap = useMemo(() => {
const parsedMap: Record<string, OutlineStructureData> = {};
outlines.forEach((outline) => {
parsedMap[outline.id] = parseOutlineStructure(outline.structure);
});
return parsedMap;
}, [outlines]);
// 当角色确认数据变化时,初始化选中状态(默认全选)
@@ -181,34 +207,48 @@ export default function Outline() {
// 移除事件监听,避免无限循环
// Hook 内部已经更新了 store,不需要再次刷新
if (!currentProject) return null;
// 确保大纲按 order_index 排序
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index);
// 前端查询过滤
const filteredOutlines = useMemo(() => {
const keyword = outlineSearchKeyword.trim().toLowerCase();
if (!keyword) return sortedOutlines;
return sortedOutlines.filter((outline) => {
return (
String(outline.order_index).includes(keyword) ||
outline.title.toLowerCase().includes(keyword) ||
outline.content.toLowerCase().includes(keyword)
);
});
}, [sortedOutlines, outlineSearchKeyword]);
// 当前分页数据
const pagedOutlines = useMemo(() => {
const start = (outlinePage - 1) * outlinePageSize;
return filteredOutlines.slice(start, start + outlinePageSize);
}, [filteredOutlines, outlinePage, outlinePageSize]);
// 搜索词或页大小变化时,回到第一页
useEffect(() => {
setOutlinePage(1);
}, [outlineSearchKeyword, outlinePageSize]);
// 数据变化导致页码越界时自动纠正
useEffect(() => {
const maxPage = Math.max(1, Math.ceil(filteredOutlines.length / outlinePageSize));
if (outlinePage > maxPage) {
setOutlinePage(maxPage);
}
}, [filteredOutlines.length, outlinePage, outlinePageSize]);
if (!currentProject) return null;
const handleOpenEditModal = (id: string) => {
const outline = outlines.find(o => o.id === id);
if (outline) {
// 解析structure数据
let structureData: {
characters?: unknown[]; // 兼容新旧格式
scenes?: string[] | Array<{
location: string;
characters: string[];
purpose: string;
}>;
key_points?: string[];
emotion?: string;
goal?: string;
} = {};
if (outline.structure) {
try {
structureData = JSON.parse(outline.structure);
} catch (e) {
console.error('解析structure失败:', e);
}
}
const structureData = outlineStructureMap[outline.id] || {};
// 解析角色/组织条目(兼容新旧格式)
const editEntries = parseCharacterEntries(structureData.characters);
@@ -357,8 +397,8 @@ export default function Outline() {
onOk: async () => {
const values = await editForm.validateFields();
try {
// 解析并重构structure数据
const originalStructure = outline.structure ? JSON.parse(outline.structure) : {};
// 解析并重构structure数据(使用预解析缓存,避免重复 JSON.parse)
const originalStructure = outlineStructureMap[outline.id] || {};
// 处理角色和组织数据 - 合并为带类型标识的新格式
const charNames = Array.isArray(values.characters)
@@ -1059,18 +1099,6 @@ export default function Outline() {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
// 更新展开状态
setOutlineExpandStatus(prev => {
const newStatus = { ...prev };
// 找到被删除章节对应的大纲ID并更新其状态
const outlineId = Object.keys(newStatus).find(id =>
outlines.find(o => o.id === id && o.title === outlineTitle)
);
if (outlineId) {
newStatus[outlineId] = false;
}
return newStatus;
});
} catch (error: unknown) {
const apiError = error as ApiError;
message.error(apiError.response?.data?.detail || '删除章节失败');
@@ -1901,6 +1929,13 @@ export default function Outline() {
)}
</div>
<Space size="small" wrap={isMobile}>
<Input.Search
allowClear
placeholder="搜索大纲(序号/标题/内容)"
value={outlineSearchKeyword}
onChange={(e) => setOutlineSearchKeyword(e.target.value)}
style={{ width: isMobile ? '100%' : 280 }}
/>
<Button
icon={<PlusOutlined />}
onClick={showManualCreateOutlineModal}
@@ -1935,33 +1970,14 @@ export default function Outline() {
<div style={{ flex: 1, overflowY: 'auto' }}>
{outlines.length === 0 ? (
<Empty description="还没有大纲,开始创建吧!" />
) : filteredOutlines.length === 0 ? (
<Empty description="未找到匹配大纲" />
) : (
<List
dataSource={sortedOutlines}
dataSource={pagedOutlines}
renderItem={(item) => {
// 解析structure字段获取所有信息
let structureData: {
key_events?: string[];
key_points?: string[]; // AI生成的情节要点
characters_involved?: string[];
characters?: unknown[]; // 兼容新旧格式
scenes?: string[] | Array<{
location: string;
characters: string[];
purpose: string;
}>;
emotion?: string; // AI生成的情感基调
goal?: string; // AI生成的叙事目标
} = {};
if (item.structure) {
try {
structureData = JSON.parse(item.structure);
} catch (e) {
console.error('解析structure失败:', e);
}
}
const structureData = outlineStructureMap[item.id] || {};
// 解析角色/组织条目(兼容新旧格式)
const characterEntries = parseCharacterEntries(structureData.characters);
const characterNames = getCharacterNames(characterEntries);
@@ -2703,7 +2719,41 @@ export default function Outline() {
}}
/>
)}
</div>
{/* 固定底部分页栏 */}
{outlines.length > 0 && (
<div
style={{
position: 'sticky',
bottom: 0,
zIndex: 10,
backgroundColor: 'var(--color-bg-container)',
borderTop: '1px solid #f0f0f0',
padding: isMobile ? '8px 0' : '10px 0',
display: 'flex',
justifyContent: 'flex-end'
}}
>
<Pagination
current={outlinePage}
pageSize={outlinePageSize}
total={filteredOutlines.length}
showSizeChanger
pageSizeOptions={['10', '20', '50', '100']}
onChange={(page, size) => {
setOutlinePage(page);
if (size !== outlinePageSize) {
setOutlinePageSize(size);
setOutlinePage(1);
}
}}
showTotal={(total) => `${total}`}
size={isMobile ? 'small' : 'default'}
/>
</div>
)}
</div>
</>
);
+108
View File
@@ -178,6 +178,7 @@ export interface Outline {
content: string;
structure?: string;
order_index: number;
has_chapters?: boolean;
created_at: string;
updated_at: string;
}
@@ -541,6 +542,26 @@ export interface AnalysisTask {
completed_at?: string | null;
}
export interface BatchAnalysisStatusResponse {
project_id: string;
total: number;
items: Record<string, AnalysisTask>;
}
export interface BatchAnalyzeUnanalyzedRequest {
chapter_ids?: string[];
}
export interface BatchAnalyzeUnanalyzedResponse {
project_id: string;
total_candidates: number;
total_started: number;
total_skipped_no_content: number;
total_skipped_running: number;
total_already_completed: number;
started_tasks: Record<string, AnalysisTask>;
}
// 分析结果 - 钩子
export interface AnalysisHook {
type: string;
@@ -886,6 +907,93 @@ export interface ForeshadowContextResponse {
recently_planted: Foreshadow[];
}
// ==================== 拆书导入类型定义 ====================
export type BookImportTaskStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
export type BookImportWarningLevel = 'info' | 'warning' | 'error';
export interface BookImportWarning {
code: string;
message: string;
level: BookImportWarningLevel;
}
export interface BookImportProjectSuggestion {
title: string;
description?: string;
theme?: string;
genre?: string;
narrative_perspective: string;
target_words: number;
}
export interface BookImportChapter {
title: string;
content: string;
summary?: string;
chapter_number: number;
outline_title?: string;
}
export interface BookImportOutline {
title: string;
content?: string;
order_index: number;
structure?: Record<string, unknown>;
}
export interface BookImportTask {
task_id: string;
status: BookImportTaskStatus;
progress: number;
message?: string;
error?: string;
created_at: string;
updated_at: string;
}
export interface BookImportPreview {
task_id: string;
project_suggestion: BookImportProjectSuggestion;
chapters: BookImportChapter[];
outlines: BookImportOutline[];
warnings: BookImportWarning[];
}
export interface BookImportApplyPayload {
project_suggestion: BookImportProjectSuggestion;
chapters: BookImportChapter[];
outlines: BookImportOutline[];
import_mode?: 'append' | 'overwrite';
}
export interface BookImportResult {
success: boolean;
project_id: string;
statistics: {
chapters: number;
outlines: number;
generated_careers?: number;
generated_entities?: number;
generated_world_building?: number;
};
warnings: BookImportWarning[];
}
export interface BookImportStepFailure {
step_name: string; // world_building / career_system / characters
step_label: string; // 中文名
error: string; // 错误详情
retry_count?: number; // 已重试次数
}
export interface BookImportRetryResult {
success: boolean;
project_id: string;
retry_results: Record<string, number>;
still_failed: BookImportStepFailure[];
}
// ==================== 提示词工坊类型定义 ====================
export interface PromptWorkshopItem {