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