update:更新大纲管理和章节管理页面,支持前端搜索/分页。
This commit is contained in:
@@ -115,19 +115,37 @@ async def get_outlines(
|
|||||||
.order_by(Outline.order_index)
|
.order_by(Outline.order_index)
|
||||||
)
|
)
|
||||||
outlines = result.scalars().all()
|
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对象
|
# 🔧 优化:后端完全解析structure,提取所有字段填充到outline对象
|
||||||
for outline in outlines:
|
for outline in outlines:
|
||||||
|
# 动态附加是否已有章节展开状态,供前端直接使用
|
||||||
|
setattr(outline, "has_chapters", outline_has_chapters_map.get(outline.id, False))
|
||||||
|
|
||||||
if outline.structure:
|
if outline.structure:
|
||||||
try:
|
try:
|
||||||
structure_data = json.loads(outline.structure)
|
structure_data = json.loads(outline.structure)
|
||||||
|
|
||||||
# 从structure中提取所有字段填充到outline对象
|
# 从structure中提取所有字段填充到outline对象
|
||||||
outline.title = structure_data.get("title", f"第{outline.order_index}章")
|
outline.title = structure_data.get("title", f"第{outline.order_index}章")
|
||||||
outline.content = structure_data.get("summary") or structure_data.get("content", "")
|
outline.content = structure_data.get("summary") or structure_data.get("content", "")
|
||||||
|
|
||||||
# structure字段保持不变,供前端使用其他字段(如characters、scenes等)
|
# structure字段保持不变,供前端使用其他字段(如characters、scenes等)
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
logger.warning(f"解析大纲 {outline.id} 的structure失败")
|
logger.warning(f"解析大纲 {outline.id} 的structure失败")
|
||||||
outline.title = f"第{outline.order_index}章"
|
outline.title = f"第{outline.order_index}章"
|
||||||
@@ -136,7 +154,7 @@ async def get_outlines(
|
|||||||
# 没有structure的异常情况
|
# 没有structure的异常情况
|
||||||
outline.title = f"第{outline.order_index}章"
|
outline.title = f"第{outline.order_index}章"
|
||||||
outline.content = "暂无内容"
|
outline.content = "暂无内容"
|
||||||
|
|
||||||
return OutlineListResponse(total=total, items=outlines)
|
return OutlineListResponse(total=total, items=outlines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ class OutlineResponse(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
structure: Optional[str] = None
|
structure: Optional[str] = None
|
||||||
order_index: int
|
order_index: int
|
||||||
|
has_chapters: Optional[bool] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+313
-238
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
|
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 { 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';
|
||||||
@@ -10,7 +10,6 @@ import ChapterAnalysis from '../components/ChapterAnalysis';
|
|||||||
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
|
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 ChapterReader from '../components/ChapterReader';
|
import ChapterReader from '../components/ChapterReader';
|
||||||
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
|
import PartialRegenerateToolbar from '../components/PartialRegenerateToolbar';
|
||||||
import PartialRegenerateModal from '../components/PartialRegenerateModal';
|
import PartialRegenerateModal from '../components/PartialRegenerateModal';
|
||||||
@@ -69,8 +68,13 @@ export default function Chapters() {
|
|||||||
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
|
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
|
||||||
// 分析任务状态管理
|
// 分析任务状态管理
|
||||||
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
|
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
|
||||||
const pollingIntervalsRef = useRef<Record<string, number>>({});
|
const analysisPollingIntervalRef = useRef<number | null>(null);
|
||||||
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
|
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);
|
const [readerVisible, setReaderVisible] = useState(false);
|
||||||
@@ -95,6 +99,7 @@ export default function Chapters() {
|
|||||||
// 批量生成相关状态
|
// 批量生成相关状态
|
||||||
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
|
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
|
||||||
const [batchGenerating, setBatchGenerating] = useState(false);
|
const [batchGenerating, setBatchGenerating] = useState(false);
|
||||||
|
const [batchAnalyzingUnanalyzed, setBatchAnalyzingUnanalyzed] = useState(false);
|
||||||
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
|
const [batchTaskId, setBatchTaskId] = useState<string | null>(null);
|
||||||
const [batchForm] = Form.useForm();
|
const [batchForm] = Form.useForm();
|
||||||
const [manualCreateForm] = Form.useForm();
|
const [manualCreateForm] = Form.useForm();
|
||||||
@@ -341,94 +346,116 @@ export default function Chapters() {
|
|||||||
|
|
||||||
// 清理轮询定时器
|
// 清理轮询定时器
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pollingIntervals = pollingIntervalsRef.current;
|
|
||||||
const batchPollingInterval = batchPollingIntervalRef.current;
|
const batchPollingInterval = batchPollingIntervalRef.current;
|
||||||
return () => {
|
return () => {
|
||||||
Object.values(pollingIntervals).forEach(interval => {
|
if (analysisPollingIntervalRef.current) {
|
||||||
clearInterval(interval);
|
clearInterval(analysisPollingIntervalRef.current);
|
||||||
});
|
analysisPollingIntervalRef.current = null;
|
||||||
|
}
|
||||||
if (batchPollingInterval) {
|
if (batchPollingInterval) {
|
||||||
clearInterval(batchPollingInterval);
|
clearInterval(batchPollingInterval);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 加载所有章节的分析任务状态
|
const clearAnalysisPollingIfIdle = useCallback(() => {
|
||||||
// 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题
|
if (activeAnalysisPollingIdsRef.current.size === 0 && analysisPollingIntervalRef.current) {
|
||||||
const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => {
|
clearInterval(analysisPollingIntervalRef.current);
|
||||||
const targetChapters = chaptersToLoad || chapters;
|
analysisPollingIntervalRef.current = null;
|
||||||
if (!targetChapters || targetChapters.length === 0) return;
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const tasksMap: Record<string, AnalysisTask> = {};
|
const pollActiveAnalysisTasks = useCallback(async () => {
|
||||||
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
for (const chapter of targetChapters) {
|
const activeIds = Array.from(activeAnalysisPollingIdsRef.current);
|
||||||
// 只查询有内容的章节
|
if (activeIds.length === 0) {
|
||||||
if (chapter.content && chapter.content.trim() !== '') {
|
clearAnalysisPollingIfIdle();
|
||||||
try {
|
return;
|
||||||
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} 暂无分析任务`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setAnalysisTasksMap(tasksMap);
|
try {
|
||||||
};
|
const response = await chapterApi.getBatchAnalysisStatuses(currentProject.id, activeIds);
|
||||||
|
const tasksMap = response.items || {};
|
||||||
|
|
||||||
// 启动单个章节的任务轮询
|
setAnalysisTasksMap(prev => ({
|
||||||
const startPollingTask = (chapterId: string) => {
|
...prev,
|
||||||
// 如果已经在轮询,先清除
|
...tasksMap,
|
||||||
if (pollingIntervalsRef.current[chapterId]) {
|
}));
|
||||||
clearInterval(pollingIntervalsRef.current[chapterId]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = window.setInterval(async () => {
|
activeIds.forEach((chapterId) => {
|
||||||
try {
|
const task = tasksMap[chapterId];
|
||||||
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
|
if (!task || task.status === 'completed' || task.status === 'failed' || task.status === 'none') {
|
||||||
if (!response.ok) return;
|
activeAnalysisPollingIdsRef.current.delete(chapterId);
|
||||||
|
|
||||||
const task: AnalysisTask = await response.json();
|
if (task?.status === 'completed') {
|
||||||
|
message.success('章节分析完成');
|
||||||
setAnalysisTasksMap(prev => ({
|
} else if (task?.status === 'failed') {
|
||||||
...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') {
|
|
||||||
message.error(`章节分析失败: ${task.error_message || '未知错误'}`);
|
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);
|
}, 2000);
|
||||||
|
|
||||||
pollingIntervalsRef.current[chapterId] = interval;
|
// 立即执行一次
|
||||||
|
void pollActiveAnalysisTasks();
|
||||||
|
}, [pollActiveAnalysisTasks]);
|
||||||
|
|
||||||
// 5分钟超时
|
// 加载所有章节的分析任务状态(批量接口,避免逐章请求风暴)
|
||||||
setTimeout(() => {
|
// 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题
|
||||||
if (pollingIntervalsRef.current[chapterId]) {
|
const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => {
|
||||||
clearInterval(pollingIntervalsRef.current[chapterId]);
|
const targetChapters = chaptersToLoad || chapters;
|
||||||
delete pollingIntervalsRef.current[chapterId];
|
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 () => {
|
const loadWritingStyles = async () => {
|
||||||
@@ -559,9 +586,9 @@ export default function Chapters() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则)
|
// 按章节号排序并按大纲分组章节 (必须在早返回之前调用,避免违反 Hooks 规则)
|
||||||
const { sortedChapters, groupedChapters } = useMemo(() => {
|
const { sortedChapters } = useMemo(() => {
|
||||||
const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
|
const sorted = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
|
||||||
|
|
||||||
const groups: Record<string, {
|
const groups: Record<string, {
|
||||||
outlineId: string | null;
|
outlineId: string | null;
|
||||||
outlineTitle: string;
|
outlineTitle: string;
|
||||||
@@ -584,12 +611,120 @@ export default function Chapters() {
|
|||||||
groups[key].chapters.push(chapter);
|
groups[key].chapters.push(chapter);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 转换为数组并按大纲顺序排序
|
return { sortedChapters: sorted };
|
||||||
const grouped = Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
|
|
||||||
|
|
||||||
return { sortedChapters: sorted, groupedChapters: grouped };
|
|
||||||
}, [chapters]);
|
}, [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;
|
if (!currentProject) return null;
|
||||||
|
|
||||||
// 获取人称的中文显示文本(同时支持中英文值)
|
// 获取人称的中文显示文本(同时支持中英文值)
|
||||||
@@ -608,84 +743,11 @@ export default function Chapters() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const canGenerateChapter = (chapter: Chapter): boolean => {
|
const canGenerateChapter = (chapter: Chapter): boolean => {
|
||||||
if (chapter.chapter_number === 1) {
|
return chapterGenerateGateMap[chapter.id]?.canGenerate ?? true;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGenerateDisabledReason = (chapter: Chapter): string => {
|
const getGenerateDisabledReason = (chapter: Chapter): string => {
|
||||||
if (chapter.chapter_number === 1) {
|
return chapterGenerateGateMap[chapter.id]?.reason || '';
|
||||||
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 '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenModal = (id: string) => {
|
const handleOpenModal = (id: string) => {
|
||||||
@@ -954,6 +1016,41 @@ export default function Chapters() {
|
|||||||
setAnalysisVisible(true);
|
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: {
|
const handleBatchGenerate = async (values: {
|
||||||
startChapterNumber: number;
|
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) => {
|
const handleOpenReader = (chapter: Chapter) => {
|
||||||
setReadingChapter(chapter);
|
setReadingChapter(chapter);
|
||||||
@@ -1801,11 +1885,28 @@ export default function Chapters() {
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: isMobile ? 'stretch' : 'center'
|
alignItems: isMobile ? 'stretch' : 'center'
|
||||||
}}>
|
}}>
|
||||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
<BookOutlined style={{ marginRight: 8 }} />
|
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>
|
||||||
章节管理
|
<BookOutlined style={{ marginRight: 8 }} />
|
||||||
</h2>
|
章节管理
|
||||||
|
</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' }}>
|
<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' && (
|
{currentProject.outline_mode === 'one-to-many' && (
|
||||||
<Button
|
<Button
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
@@ -1816,6 +1917,19 @@ export default function Chapters() {
|
|||||||
手动创建
|
手动创建
|
||||||
</Button>
|
</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
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<RocketOutlined />}
|
icon={<RocketOutlined />}
|
||||||
@@ -1837,23 +1951,18 @@ export default function Chapters() {
|
|||||||
>
|
>
|
||||||
导出为TXT
|
导出为TXT
|
||||||
</Button>
|
</Button>
|
||||||
{!isMobile && (
|
|
||||||
<Tag color="blue">
|
|
||||||
{currentProject.outline_mode === 'one-to-one'
|
|
||||||
? '传统模式:章节由大纲管理,请在大纲页面操作'
|
|
||||||
: '细化模式:章节可在大纲页面展开'}
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
{chapters.length === 0 ? (
|
{chapters.length === 0 ? (
|
||||||
<Empty description="还没有章节,开始创作吧!" />
|
<Empty description="还没有章节,开始创作吧!" />
|
||||||
|
) : filteredSortedChapters.length === 0 ? (
|
||||||
|
<Empty description="未找到匹配章节" />
|
||||||
) : currentProject.outline_mode === 'one-to-one' ? (
|
) : currentProject.outline_mode === 'one-to-one' ? (
|
||||||
// one-to-one 模式:直接显示扁平列表
|
// one-to-one 模式:直接显示扁平列表
|
||||||
<List
|
<List
|
||||||
dataSource={sortedChapters}
|
dataSource={pagedSortedChapters}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
id={`chapter-item-${item.id}`}
|
id={`chapter-item-${item.id}`}
|
||||||
@@ -2007,11 +2116,12 @@ export default function Chapters() {
|
|||||||
// one-to-many 模式:按大纲分组显示
|
// one-to-many 模式:按大纲分组显示
|
||||||
<Collapse
|
<Collapse
|
||||||
bordered={false}
|
bordered={false}
|
||||||
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
|
defaultActiveKey={pagedGroupedChapters.length > 0 ? ['0'] : []}
|
||||||
|
destroyInactivePanel
|
||||||
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
|
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
|
||||||
style={{ background: 'transparent' }}
|
style={{ background: 'transparent' }}
|
||||||
>
|
>
|
||||||
{groupedChapters.map((group, groupIndex) => (
|
{pagedGroupedChapters.map((group, groupIndex) => (
|
||||||
<Collapse.Panel
|
<Collapse.Panel
|
||||||
key={groupIndex.toString()}
|
key={groupIndex.toString()}
|
||||||
header={
|
header={
|
||||||
@@ -2252,6 +2362,27 @@ export default function Chapters() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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
|
<Modal
|
||||||
title={editingId ? '编辑章节信息' : '添加章节'}
|
title={editingId ? '编辑章节信息' : '添加章节'}
|
||||||
open={isModalOpen}
|
open={isModalOpen}
|
||||||
@@ -2558,51 +2689,10 @@ export default function Chapters() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 延迟500ms后刷新该章节的分析状态,给后端足够时间完成数据库写入
|
// 延迟500ms后批量刷新分析状态,避免单章接口高频调用
|
||||||
if (analysisChapterId) {
|
setTimeout(() => {
|
||||||
const chapterIdToRefresh = analysisChapterId;
|
loadAnalysisTasks();
|
||||||
|
}, 500);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAnalysisChapterId(null);
|
setAnalysisChapterId(null);
|
||||||
}}
|
}}
|
||||||
@@ -2867,21 +2957,6 @@ export default function Chapters() {
|
|||||||
cancelButtonText="取消任务"
|
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 && (
|
{readingChapter && (
|
||||||
<ChapterReader
|
<ChapterReader
|
||||||
|
|||||||
+133
-83
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs } from 'antd';
|
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 { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useOutlineSync } from '../store/hooks';
|
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);
|
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;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
export default function Outline() {
|
export default function Outline() {
|
||||||
@@ -93,9 +120,6 @@ export default function Outline() {
|
|||||||
const [isExpanding, setIsExpanding] = useState(false);
|
const [isExpanding, setIsExpanding] = useState(false);
|
||||||
const [projectCharacters, setProjectCharacters] = useState<Array<{ label: string; value: string }>>([]);
|
const [projectCharacters, setProjectCharacters] = useState<Array<{ label: string; value: string }>>([]);
|
||||||
|
|
||||||
// ✅ 新增:记录每个大纲的展开状态
|
|
||||||
const [outlineExpandStatus, setOutlineExpandStatus] = useState<Record<string, boolean>>({});
|
|
||||||
|
|
||||||
// ✅ 新增:记录场景区域的展开/折叠状态
|
// ✅ 新增:记录场景区域的展开/折叠状态
|
||||||
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({});
|
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
@@ -122,6 +146,11 @@ export default function Outline() {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 大纲查询与分页状态
|
||||||
|
const [outlineSearchKeyword, setOutlineSearchKeyword] = useState('');
|
||||||
|
const [outlinePage, setOutlinePage] = useState(1);
|
||||||
|
const [outlinePageSize, setOutlinePageSize] = useState(20);
|
||||||
|
|
||||||
// 使用同步 hooks
|
// 使用同步 hooks
|
||||||
const {
|
const {
|
||||||
refreshOutlines,
|
refreshOutlines,
|
||||||
@@ -155,25 +184,22 @@ export default function Outline() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ✅ 新增:加载所有大纲的展开状态
|
// 从后端返回字段直接构建展开状态,避免前端 N+1 请求
|
||||||
useEffect(() => {
|
const outlineExpandStatus = useMemo(() => {
|
||||||
const loadExpandStatus = async () => {
|
const statusMap: Record<string, boolean> = {};
|
||||||
if (outlines.length === 0) return;
|
outlines.forEach((outline) => {
|
||||||
|
statusMap[outline.id] = Boolean(outline.has_chapters);
|
||||||
|
});
|
||||||
|
return statusMap;
|
||||||
|
}, [outlines]);
|
||||||
|
|
||||||
const statusMap: Record<string, boolean> = {};
|
// 统一预解析 structure,避免 render 阶段重复 JSON.parse
|
||||||
for (const outline of outlines) {
|
const outlineStructureMap = useMemo(() => {
|
||||||
try {
|
const parsedMap: Record<string, OutlineStructureData> = {};
|
||||||
const chapters = await outlineApi.getOutlineChapters(outline.id);
|
outlines.forEach((outline) => {
|
||||||
statusMap[outline.id] = chapters.has_chapters;
|
parsedMap[outline.id] = parseOutlineStructure(outline.structure);
|
||||||
} catch (error) {
|
});
|
||||||
console.error(`加载大纲 ${outline.id} 状态失败:`, error);
|
return parsedMap;
|
||||||
statusMap[outline.id] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setOutlineExpandStatus(statusMap);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadExpandStatus();
|
|
||||||
}, [outlines]);
|
}, [outlines]);
|
||||||
|
|
||||||
// 当角色确认数据变化时,初始化选中状态(默认全选)
|
// 当角色确认数据变化时,初始化选中状态(默认全选)
|
||||||
@@ -181,34 +207,48 @@ export default function Outline() {
|
|||||||
// 移除事件监听,避免无限循环
|
// 移除事件监听,避免无限循环
|
||||||
// Hook 内部已经更新了 store,不需要再次刷新
|
// Hook 内部已经更新了 store,不需要再次刷新
|
||||||
|
|
||||||
if (!currentProject) return null;
|
|
||||||
|
|
||||||
// 确保大纲按 order_index 排序
|
// 确保大纲按 order_index 排序
|
||||||
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.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 handleOpenEditModal = (id: string) => {
|
||||||
const outline = outlines.find(o => o.id === id);
|
const outline = outlines.find(o => o.id === id);
|
||||||
if (outline) {
|
if (outline) {
|
||||||
// 解析structure数据
|
const structureData = outlineStructureMap[outline.id] || {};
|
||||||
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 editEntries = parseCharacterEntries(structureData.characters);
|
const editEntries = parseCharacterEntries(structureData.characters);
|
||||||
@@ -357,8 +397,8 @@ export default function Outline() {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const values = await editForm.validateFields();
|
const values = await editForm.validateFields();
|
||||||
try {
|
try {
|
||||||
// 解析并重构structure数据
|
// 解析并重构structure数据(使用预解析缓存,避免重复 JSON.parse)
|
||||||
const originalStructure = outline.structure ? JSON.parse(outline.structure) : {};
|
const originalStructure = outlineStructureMap[outline.id] || {};
|
||||||
|
|
||||||
// 处理角色和组织数据 - 合并为带类型标识的新格式
|
// 处理角色和组织数据 - 合并为带类型标识的新格式
|
||||||
const charNames = Array.isArray(values.characters)
|
const charNames = Array.isArray(values.characters)
|
||||||
@@ -1059,18 +1099,6 @@ export default function Outline() {
|
|||||||
const updatedProject = await projectApi.getProject(currentProject.id);
|
const updatedProject = await projectApi.getProject(currentProject.id);
|
||||||
setCurrentProject(updatedProject);
|
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) {
|
} catch (error: unknown) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
message.error(apiError.response?.data?.detail || '删除章节失败');
|
message.error(apiError.response?.data?.detail || '删除章节失败');
|
||||||
@@ -1901,6 +1929,13 @@ export default function Outline() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Space size="small" wrap={isMobile}>
|
<Space size="small" wrap={isMobile}>
|
||||||
|
<Input.Search
|
||||||
|
allowClear
|
||||||
|
placeholder="搜索大纲(序号/标题/内容)"
|
||||||
|
value={outlineSearchKeyword}
|
||||||
|
onChange={(e) => setOutlineSearchKeyword(e.target.value)}
|
||||||
|
style={{ width: isMobile ? '100%' : 280 }}
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={showManualCreateOutlineModal}
|
onClick={showManualCreateOutlineModal}
|
||||||
@@ -1935,33 +1970,14 @@ export default function Outline() {
|
|||||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||||
{outlines.length === 0 ? (
|
{outlines.length === 0 ? (
|
||||||
<Empty description="还没有大纲,开始创建吧!" />
|
<Empty description="还没有大纲,开始创建吧!" />
|
||||||
|
) : filteredOutlines.length === 0 ? (
|
||||||
|
<Empty description="未找到匹配大纲" />
|
||||||
) : (
|
) : (
|
||||||
<List
|
<List
|
||||||
dataSource={sortedOutlines}
|
dataSource={pagedOutlines}
|
||||||
renderItem={(item) => {
|
renderItem={(item) => {
|
||||||
// 解析structure字段获取所有信息
|
const structureData = outlineStructureMap[item.id] || {};
|
||||||
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 characterEntries = parseCharacterEntries(structureData.characters);
|
const characterEntries = parseCharacterEntries(structureData.characters);
|
||||||
const characterNames = getCharacterNames(characterEntries);
|
const characterNames = getCharacterNames(characterEntries);
|
||||||
@@ -2703,7 +2719,41 @@ export default function Outline() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export interface Outline {
|
|||||||
content: string;
|
content: string;
|
||||||
structure?: string;
|
structure?: string;
|
||||||
order_index: number;
|
order_index: number;
|
||||||
|
has_chapters?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
@@ -541,6 +542,26 @@ export interface AnalysisTask {
|
|||||||
completed_at?: string | null;
|
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 {
|
export interface AnalysisHook {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -886,6 +907,93 @@ export interface ForeshadowContextResponse {
|
|||||||
recently_planted: Foreshadow[];
|
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 {
|
export interface PromptWorkshopItem {
|
||||||
|
|||||||
Reference in New Issue
Block a user