update:1.更新导入导出功能 2.实现RAG记忆功能,引入剧情分析功能

This commit is contained in:
xiamuceer
2025-11-04 14:38:59 +08:00
parent 1cde345ed9
commit e4f90d5da0
26 changed files with 6722 additions and 84 deletions
+494
View File
@@ -0,0 +1,494 @@
import React, { useState, useEffect } from 'react';
import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message, Progress } from 'antd';
import {
EyeOutlined,
EyeInvisibleOutlined,
MenuOutlined,
ReloadOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useParams } from 'react-router-dom';
import api from '../services/api';
import AnnotatedText, { type MemoryAnnotation } from '../components/AnnotatedText';
import MemorySidebar from '../components/MemorySidebar';
interface ChapterItem {
id: string;
chapter_number: number;
title: string;
content: string;
word_count: number;
status: string;
}
interface AnnotationsData {
chapter_id: string;
chapter_number: number;
title: string;
word_count: number;
annotations: MemoryAnnotation[];
has_analysis: boolean;
summary: {
total_annotations: number;
hooks: number;
foreshadows: number;
plot_points: number;
character_events: number;
};
}
interface NavigationData {
current: {
id: string;
chapter_number: number;
title: string;
};
previous: {
id: string;
chapter_number: number;
title: string;
} | null;
next: {
id: string;
chapter_number: number;
title: string;
} | null;
}
/**
* 项目内的章节剧情分析页面
* 显示章节列表和带标注的章节内容
*/
const ChapterAnalysis: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
const [chapters, setChapters] = useState<ChapterItem[]>([]);
const [selectedChapter, setSelectedChapter] = useState<ChapterItem | null>(null);
const [annotationsData, setAnnotationsData] = useState<AnnotationsData | null>(null);
const [navigation, setNavigation] = useState<NavigationData | null>(null);
const [loading, setLoading] = useState(true);
const [contentLoading, setContentLoading] = useState(false);
const [showAnnotations, setShowAnnotations] = useState(true);
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
const [sidebarVisible, setSidebarVisible] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
// 加载章节列表
useEffect(() => {
const loadChapters = async () => {
if (!projectId) return;
try {
setLoading(true);
const response = await api.get(`/chapters/project/${projectId}`);
// API 拦截器已经解析了 response.data,所以直接使用
const data = response.data || response;
const chapterList = data.items || [];
setChapters(chapterList);
// 自动选择第一个有内容的章节
const firstChapterWithContent = chapterList.find((ch: ChapterItem) => ch.content && ch.content.trim() !== '');
if (firstChapterWithContent) {
loadChapterContent(firstChapterWithContent.id);
}
} catch (error) {
console.error('加载章节列表失败:', error);
message.error('加载章节列表失败');
} finally {
setLoading(false);
}
};
loadChapters();
}, [projectId]);
// 加载章节内容和标注
const loadChapterContent = async (chapterId: string) => {
try {
setContentLoading(true);
const [chapterResponse, annotationsResponse, navigationResponse] = await Promise.all([
api.get(`/chapters/${chapterId}`),
api.get(`/chapters/${chapterId}/annotations`).catch(() => null),
api.get(`/chapters/${chapterId}/navigation`).catch(() => null),
]);
// 提取 data 属性
setSelectedChapter(chapterResponse.data || chapterResponse);
setAnnotationsData(annotationsResponse ? (annotationsResponse.data || annotationsResponse) : null);
setNavigation(navigationResponse ? (navigationResponse.data || navigationResponse) : null);
} catch (error) {
console.error('加载章节内容失败:', error);
message.error('加载章节内容失败');
} finally {
setContentLoading(false);
}
};
const handleChapterSelect = (chapterId: string) => {
loadChapterContent(chapterId);
};
const handlePreviousChapter = () => {
if (navigation?.previous) {
loadChapterContent(navigation.previous.id);
}
};
const handleNextChapter = () => {
if (navigation?.next) {
loadChapterContent(navigation.next.id);
}
};
const handleAnnotationClick = (annotation: MemoryAnnotation, source: 'content' | 'sidebar' = 'content') => {
setActiveAnnotationId(annotation.id);
if (source === 'content') {
// 从内容区点击,滚动到侧边栏
setScrollToSidebarAnnotation(annotation.id);
// 清除滚动状态
setTimeout(() => setScrollToSidebarAnnotation(undefined), 100);
if (window.innerWidth < 768) {
setSidebarVisible(true);
}
} else {
// 从侧边栏点击,滚动到内容区
setScrollToContentAnnotation(annotation.id);
// 清除滚动状态
setTimeout(() => setScrollToContentAnnotation(undefined), 100);
}
};
const handleReanalyze = async () => {
if (!selectedChapter) return;
let pollInterval: number | null = null;
let timeoutId: number | null = null;
try {
setAnalyzing(true);
setAnalysisProgress(0);
message.loading({ content: '开始分析章节...', key: 'analyze', duration: 0 });
// 触发分析任务
const triggerRes = await api.post(`/chapters/${selectedChapter.id}/analyze`);
const triggerData = triggerRes.data || triggerRes;
const taskId = triggerData.task_id;
console.log('分析任务已创建:', taskId);
// 开始轮询状态
let pollCount = 0;
const maxPolls = 60; // 最多轮询60次(2分钟)
pollInterval = setInterval(async () => {
pollCount++;
if (pollCount > maxPolls) {
if (pollInterval) clearInterval(pollInterval);
if (timeoutId) clearTimeout(timeoutId);
setAnalyzing(false);
message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' });
return;
}
try {
const statusRes = await api.get(`/chapters/${selectedChapter.id}/analysis/status`);
const responseData = statusRes.data || statusRes;
if (!responseData) {
console.warn(`${pollCount}次轮询:响应数据为空`);
return;
}
const { status, progress, error_message } = responseData;
console.log(`${pollCount}次轮询:status=${status}, progress=${progress}`);
setAnalysisProgress(progress || 0);
if (status === 'completed') {
if (pollInterval) clearInterval(pollInterval);
if (timeoutId) clearTimeout(timeoutId);
setAnalyzing(false);
message.success({ content: '分析完成!', key: 'analyze' });
// 重新加载标注数据
try {
const annotationsRes = await api.get(`/chapters/${selectedChapter.id}/annotations`);
setAnnotationsData(annotationsRes.data || annotationsRes);
} catch (annotErr) {
console.error('加载标注数据失败:', annotErr);
message.warning('分析完成,但加载标注数据失败,请刷新页面');
}
} else if (status === 'failed') {
if (pollInterval) clearInterval(pollInterval);
if (timeoutId) clearTimeout(timeoutId);
setAnalyzing(false);
message.error({
content: `分析失败:${error_message || '未知错误'}`,
key: 'analyze'
});
}
// pending 或 running 状态继续轮询
} catch (pollErr) {
console.error(`${pollCount}次轮询失败:`, pollErr);
// 轮询错误不中断,继续下一次轮询
}
}, 2000);
// 设置总超时(2分钟)
timeoutId = setTimeout(() => {
if (pollInterval) clearInterval(pollInterval);
setAnalyzing(false);
message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' });
}, 120000);
} catch (err: any) {
// 清理定时器
if (pollInterval) clearInterval(pollInterval);
if (timeoutId) clearTimeout(timeoutId);
setAnalyzing(false);
const errorMsg = err.response?.data?.detail || err.message || '触发分析失败';
console.error('触发分析失败:', errorMsg, err);
message.error({
content: errorMsg,
key: 'analyze'
});
}
};
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="加载章节中..." />
</div>
);
}
return (
<div style={{ display: 'flex', height: '100%', gap: 16 }}>
{/* 左侧章节列表 */}
<Card
title="章节列表"
style={{ width: 280, height: '100%', overflow: 'hidden' }}
bodyStyle={{ padding: 0, height: 'calc(100% - 57px)', overflow: 'auto' }}
>
{chapters.length === 0 ? (
<Empty description="暂无章节" style={{ marginTop: 60 }} />
) : (
<List
dataSource={chapters}
renderItem={(chapter) => (
<List.Item
key={chapter.id}
onClick={() => handleChapterSelect(chapter.id)}
style={{
cursor: 'pointer',
padding: '12px 16px',
background: selectedChapter?.id === chapter.id ? '#e6f7ff' : 'transparent',
borderLeft: selectedChapter?.id === chapter.id ? '3px solid #1890ff' : '3px solid transparent',
}}
>
<List.Item.Meta
title={
<span style={{ fontSize: 14, fontWeight: selectedChapter?.id === chapter.id ? 600 : 400 }}>
{chapter.chapter_number}: {chapter.title}
</span>
}
description={
<Space size={4}>
<Tag color={chapter.content && chapter.content.trim() !== '' ? 'success' : 'default'}>
{chapter.word_count || 0}
</Tag>
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
{/* 右侧内容区域 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
{!selectedChapter ? (
<Card style={{ height: '100%' }}>
<Empty description="请从左侧选择一个章节查看" style={{ marginTop: 100 }} />
</Card>
) : (
<>
{/* 工具栏 */}
<Card size="small" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{selectedChapter.chapter_number}: {selectedChapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Space>
<Button
icon={<ReloadOutlined />}
onClick={handleReanalyze}
loading={analyzing}
disabled={analyzing || !selectedChapter?.content || selectedChapter.content.trim() === ''}
title={!selectedChapter?.content || selectedChapter.content.trim() === '' ? '章节内容为空,无法分析' : ''}
>
{analyzing ? '分析中...' : '重新分析'}
</Button>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
>
</Button>
</>
)}
</Space>
</div>
{analyzing && (
<div style={{ marginTop: 12 }}>
<Progress percent={analysisProgress} size="small" status="active" />
<span style={{ fontSize: 12, color: '#666', marginLeft: 8 }}>
...
</span>
</div>
)}
{!analyzing && hasAnnotations && annotationsData && (
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
{annotationsData.summary.total_annotations}
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
{annotationsData.summary.foreshadows > 0 &&
` 🌟${annotationsData.summary.foreshadows}个伏笔`}
{annotationsData.summary.plot_points > 0 &&
` 💎${annotationsData.summary.plot_points}个情节点`}
{annotationsData.summary.character_events > 0 &&
` 👤${annotationsData.summary.character_events}个角色事件`}
</div>
)}
</Card>
{/* 内容区域 */}
<div style={{ flex: 1, display: 'flex', gap: 16, overflow: 'hidden' }}>
{/* 章节内容 */}
<Card
style={{ flex: 1, overflow: 'auto' }}
loading={contentLoading}
>
{!contentLoading && (
<>
{!hasAnnotations && (
<Alert
message="暂无分析数据"
description="该章节尚未进行AI分析,无法显示记忆标注。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
)}
{showAnnotations && hasAnnotations && annotationsData ? (
<AnnotatedText
content={selectedChapter.content}
annotations={annotationsData.annotations}
onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'content')}
activeAnnotationId={activeAnnotationId}
scrollToAnnotation={scrollToContentAnnotation}
/>
) : (
<div
style={{
lineHeight: 2,
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{selectedChapter.content}
</div>
)}
</>
)}
</Card>
{/* 右侧记忆侧边栏(桌面端) */}
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
<Card
style={{ width: 400, overflow: 'auto' }}
bodyStyle={{ padding: 0 }}
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => handleAnnotationClick(annotation, 'sidebar')}
scrollToAnnotation={scrollToSidebarAnnotation}
/>
</Card>
)}
</div>
{/* 移动端抽屉 */}
{hasAnnotations && annotationsData && (
<Drawer
title="章节分析"
placement="right"
onClose={() => setSidebarVisible(false)}
open={sidebarVisible}
width="80%"
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => {
handleAnnotationClick(annotation, 'sidebar');
setSidebarVisible(false);
}}
scrollToAnnotation={scrollToSidebarAnnotation}
/>
</Drawer>
)}
</>
)}
</div>
</div>
);
};
export default ChapterAnalysis;
+456
View File
@@ -0,0 +1,456 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Spin, Alert, Button, Space, Switch, Drawer, message, Progress } from 'antd';
import {
ArrowLeftOutlined,
EyeOutlined,
EyeInvisibleOutlined,
MenuOutlined,
ReloadOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import api from '../services/api';
import AnnotatedText, { type MemoryAnnotation } from '../components/AnnotatedText';
import MemorySidebar from '../components/MemorySidebar';
interface ChapterData {
id: string;
chapter_number: number;
title: string;
content: string;
word_count: number;
}
interface AnnotationsData {
chapter_id: string;
chapter_number: number;
title: string;
word_count: number;
annotations: MemoryAnnotation[];
has_analysis: boolean;
summary: {
total_annotations: number;
hooks: number;
foreshadows: number;
plot_points: number;
character_events: number;
};
}
interface NavigationData {
current: {
id: string;
chapter_number: number;
title: string;
};
previous: {
id: string;
chapter_number: number;
title: string;
} | null;
next: {
id: string;
chapter_number: number;
title: string;
} | null;
}
/**
* 章节阅读器页面
* 展示带有记忆标注的章节内容
*/
const ChapterReader: React.FC = () => {
const { chapterId } = useParams<{ chapterId: string }>();
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [chapter, setChapter] = useState<ChapterData | null>(null);
const [annotationsData, setAnnotationsData] = useState<AnnotationsData | null>(null);
const [showAnnotations, setShowAnnotations] = useState(true);
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
const [sidebarVisible, setSidebarVisible] = useState(false);
const [analyzing, setAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [navigation, setNavigation] = useState<NavigationData | null>(null);
useEffect(() => {
if (chapterId) {
loadChapterData();
}
}, [chapterId]);
const loadChapterData = async () => {
try {
setLoading(true);
setError(null);
// 并行加载章节内容、标注数据和导航信息
// 注意:api拦截器已经解析了response.data,所以直接返回数据对象
const [chapterData, annotationsData, navigationData] = await Promise.all([
api.get<unknown, ChapterData>(`/chapters/${chapterId}`).catch(err => {
console.error('加载章节失败:', err);
throw err;
}),
api.get<unknown, AnnotationsData>(`/chapters/${chapterId}/annotations`).catch(err => {
console.warn('加载标注失败:', err);
return null;
}), // 如果没有分析数据也不报错
api.get<unknown, NavigationData>(`/chapters/${chapterId}/navigation`).catch(err => {
console.warn('加载导航信息失败:', err);
return null;
}),
]);
console.log('章节数据:', chapterData);
console.log('标注数据:', annotationsData);
console.log('导航数据:', navigationData);
// 验证数据
if (!chapterData || !chapterData.content) {
throw new Error('章节数据无效:缺少内容');
}
setChapter(chapterData);
setNavigation(navigationData);
// 验证标注数据
if (annotationsData) {
const validAnnotations = annotationsData.annotations.filter(
(a: MemoryAnnotation) => a.position >= 0 && a.position < chapterData.content.length
);
const invalidCount = annotationsData.annotations.length - validAnnotations.length;
if (invalidCount > 0) {
console.warn(`${invalidCount}个标注位置无效,将仅显示${validAnnotations.length}个有效标注`);
}
setAnnotationsData(annotationsData);
} else {
setAnnotationsData(null);
}
} catch (err: any) {
console.error('加载章节数据失败:', err);
setError(err.response?.data?.detail || err.message || '加载失败');
} finally {
setLoading(false);
}
};
const handleAnnotationClick = (annotation: MemoryAnnotation) => {
setActiveAnnotationId(annotation.id);
// 移动端显示侧边栏
if (window.innerWidth < 768) {
setSidebarVisible(true);
}
};
const handleBackClick = () => {
navigate(-1);
};
const handlePreviousChapter = () => {
if (navigation?.previous) {
navigate(`/chapters/${navigation.previous.id}/reader`);
}
};
const handleNextChapter = () => {
if (navigation?.next) {
navigate(`/chapters/${navigation.next.id}/reader`);
}
};
const handleReanalyze = async () => {
if (!chapterId) return;
try {
setAnalyzing(true);
setAnalysisProgress(0);
message.loading({ content: '开始分析章节...', key: 'analyze', duration: 0 });
// 触发分析
await api.post(`/chapters/${chapterId}/analyze`);
// 轮询分析状态
const pollInterval = setInterval(async () => {
try {
const statusRes = await api.get(`/chapters/${chapterId}/analysis/status`);
const { status, progress, error_message } = statusRes.data;
setAnalysisProgress(progress || 0);
if (status === 'completed') {
clearInterval(pollInterval);
setAnalyzing(false);
message.success({ content: '分析完成!', key: 'analyze' });
// 重新加载标注数据
const annotationsRes = await api.get(`/chapters/${chapterId}/annotations`);
setAnnotationsData(annotationsRes.data);
} else if (status === 'failed') {
clearInterval(pollInterval);
setAnalyzing(false);
message.error({
content: `分析失败:${error_message || '未知错误'}`,
key: 'analyze'
});
}
} catch (err) {
console.error('轮询分析状态失败:', err);
}
}, 2000); // 每2秒轮询一次
// 30秒超时
setTimeout(() => {
clearInterval(pollInterval);
if (analyzing) {
setAnalyzing(false);
message.warning({ content: '分析超时,请稍后刷新查看结果', key: 'analyze' });
}
}, 30000);
} catch (err: any) {
setAnalyzing(false);
message.error({
content: err.response?.data?.detail || '触发分析失败',
key: 'analyze'
});
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '100px 0' }}>
<Spin size="large" tip="加载章节中..." />
</div>
);
}
if (error || !chapter) {
return (
<div style={{ padding: 24 }}>
<Alert
message="加载失败"
description={error || '章节不存在'}
type="error"
showIcon
/>
<Button onClick={handleBackClick} style={{ marginTop: 16 }}>
</Button>
</div>
);
}
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
return (
<div style={{ height: '100vh', display: 'flex', flexDirection: 'column' }}>
{/* 顶部工具栏 */}
<Card
size="small"
style={{
borderRadius: 0,
borderLeft: 0,
borderRight: 0,
borderTop: 0,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Space>
<Button icon={<ArrowLeftOutlined />} onClick={handleBackClick}>
</Button>
<Button
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
title={navigation?.previous ? `上一章: ${navigation.previous.title}` : '已是第一章'}
>
</Button>
<span style={{ fontSize: 16, fontWeight: 600 }}>
{chapter.chapter_number}: {chapter.title}
</span>
<Button
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
title={navigation?.next ? `下一章: ${navigation.next.title}` : '已是最后一章'}
>
</Button>
</Space>
<Space>
<Button
icon={<ReloadOutlined />}
onClick={handleReanalyze}
loading={analyzing}
disabled={analyzing}
>
{analyzing ? '分析中...' : '重新分析'}
</Button>
{hasAnnotations && (
<>
<Switch
checked={showAnnotations}
onChange={setShowAnnotations}
checkedChildren={<EyeOutlined />}
unCheckedChildren={<EyeInvisibleOutlined />}
/>
<span style={{ fontSize: 13, color: '#666' }}></span>
<Button
icon={<MenuOutlined />}
onClick={() => setSidebarVisible(true)}
style={{ display: window.innerWidth < 768 ? 'inline-block' : 'none' }}
>
</Button>
</>
)}
</Space>
</div>
{analyzing && (
<div style={{ marginTop: 12 }}>
<Progress percent={analysisProgress} size="small" status="active" />
<span style={{ fontSize: 12, color: '#666', marginLeft: 8 }}>
...
</span>
</div>
)}
{!analyzing && hasAnnotations && annotationsData && (
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
{annotationsData.summary.total_annotations}
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
{annotationsData.summary.foreshadows > 0 &&
` 🌟${annotationsData.summary.foreshadows}个伏笔`}
{annotationsData.summary.plot_points > 0 &&
` 💎${annotationsData.summary.plot_points}个情节点`}
{annotationsData.summary.character_events > 0 &&
` 👤${annotationsData.summary.character_events}个角色事件`}
</div>
)}
</Card>
{/* 主内容区域 */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* 左侧:章节内容 */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '32px 48px',
maxWidth: hasAnnotations ? 'calc(100% - 400px)' : '100%',
}}
>
<Card>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
{!hasAnnotations && (
<Alert
message="暂无分析数据"
description="该章节尚未进行AI分析,无法显示记忆标注。"
type="info"
showIcon
style={{ marginBottom: 24 }}
/>
)}
{showAnnotations && hasAnnotations && annotationsData ? (
<AnnotatedText
content={chapter.content}
annotations={annotationsData.annotations}
onAnnotationClick={handleAnnotationClick}
activeAnnotationId={activeAnnotationId}
/>
) : (
<div
style={{
lineHeight: 2,
fontSize: 16,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{chapter.content}
</div>
)}
{/* 底部翻页按钮 */}
<div style={{ marginTop: 48, paddingTop: 24, borderTop: '1px solid #f0f0f0' }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Button
size="large"
icon={<LeftOutlined />}
onClick={handlePreviousChapter}
disabled={!navigation?.previous}
>
{navigation?.previous
? `上一章: 第${navigation.previous.chapter_number}${navigation.previous.title}`
: '已是第一章'}
</Button>
<Button
size="large"
type="primary"
icon={<RightOutlined />}
onClick={handleNextChapter}
disabled={!navigation?.next}
iconPosition="end"
>
{navigation?.next
? `下一章: 第${navigation.next.chapter_number}${navigation.next.title}`
: '已是最后一章'}
</Button>
</Space>
</div>
</div>
</Card>
</div>
{/* 右侧:记忆侧边栏(桌面端) */}
{hasAnnotations && annotationsData && window.innerWidth >= 768 && (
<div
style={{
width: 400,
borderLeft: '1px solid #f0f0f0',
overflowY: 'auto',
background: '#fafafa',
}}
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={handleAnnotationClick}
/>
</div>
)}
</div>
{/* 移动端抽屉 */}
{hasAnnotations && annotationsData && (
<Drawer
title="章节分析"
placement="right"
onClose={() => setSidebarVisible(false)}
open={sidebarVisible}
width="80%"
>
<MemorySidebar
annotations={annotationsData.annotations}
activeAnnotationId={activeAnnotationId}
onAnnotationClick={(annotation) => {
handleAnnotationClick(annotation);
setSidebarVisible(false);
}}
/>
</Drawer>
)}
</div>
);
};
export default ChapterReader;
+39 -3
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle } from '../types';
import { cardStyles } from '../components/CardStyles';
import ChapterAnalysis from '../components/ChapterAnalysis';
const { TextArea } = Input;
@@ -23,6 +24,8 @@ export default function Chapters() {
const [writingStyles, setWritingStyles] = useState<WritingStyle[]>([]);
const [selectedStyleId, setSelectedStyleId] = useState<number | undefined>();
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
const [analysisVisible, setAnalysisVisible] = useState(false);
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
useEffect(() => {
const handleResize = () => {
@@ -322,6 +325,11 @@ export default function Chapters() {
});
};
const handleShowAnalysis = (chapterId: string) => {
setAnalysisChapterId(chapterId);
setAnalysisVisible(true);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
@@ -372,15 +380,23 @@ export default function Chapters() {
}}
actions={isMobile ? undefined : [
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : ''}>
<Button
icon={<FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!item.content || item.content.trim() === ''}
>
</Button>
</Tooltip>,
<Button
type="text"
icon={<EditOutlined />}
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
>
@@ -425,6 +441,15 @@ export default function Chapters() {
size="small"
title="编辑内容"
/>
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : '查看分析'}>
<Button
type="text"
icon={<FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!item.content || item.content.trim() === ''}
/>
</Tooltip>
<Button
type="text"
icon={<SettingOutlined />}
@@ -654,6 +679,17 @@ export default function Chapters() {
</Form.Item>
</Form>
</Modal>
{analysisChapterId && (
<ChapterAnalysis
chapterId={analysisChapterId}
visible={analysisVisible}
onClose={() => {
setAnalysisVisible(false);
setAnalysisChapterId(null);
}}
/>
)}
</div>
);
}
+11 -2
View File
@@ -13,6 +13,7 @@ import {
ApartmentOutlined,
BankOutlined,
EditOutlined,
FundOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
@@ -122,6 +123,11 @@ export default function ProjectDetail() {
icon: <BookOutlined />,
label: <Link to={`/project/${projectId}/chapters`}></Link>,
},
{
key: 'chapter-analysis',
icon: <FundOutlined />,
label: <Link to={`/project/${projectId}/chapter-analysis`}></Link>,
},
{
key: 'writing-styles',
icon: <EditOutlined />,
@@ -142,6 +148,7 @@ export default function ProjectDetail() {
if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline';
if (path.includes('/characters')) return 'characters';
if (path.includes('/chapter-analysis')) return 'chapter-analysis';
if (path.includes('/chapters')) return 'chapters';
if (path.includes('/writing-styles')) return 'writing-styles';
// if (path.includes('/polish')) return 'polish';
@@ -259,7 +266,8 @@ export default function ProjectDetail() {
)}
{!mobile && (
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', zIndex: 1 }}>
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end' }}>
<Col>
<Card
size="small"
@@ -344,7 +352,8 @@ export default function ProjectDetail() {
/>
</Card>
</Col>
</Row>
</Row>
</div>
)}
</Header>
+607 -42
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import type { ReactNode } from 'react';
@@ -14,6 +15,18 @@ export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [validationResult, setValidationResult] = useState<any>(null);
const [importing, setImporting] = useState(false);
const [validating, setValidating] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [exportOptions, setExportOptions] = useState({
includeWritingStyles: true,
includeGenerationHistory: true,
});
const { refreshProjects, deleteProject } = useProjectSync();
@@ -122,6 +135,160 @@ export default function ProjectList() {
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
const activeProjects = projects.filter(p => p.status === 'writing').length;
// 处理文件选择
const handleFileSelect = async (file: File) => {
setSelectedFile(file);
setValidationResult(null);
// 验证文件
try {
setValidating(true);
const result = await projectApi.validateImportFile(file);
setValidationResult(result);
if (!result.valid) {
message.error('文件验证失败');
}
} catch (error) {
console.error('验证失败:', error);
message.error('文件验证失败');
} finally {
setValidating(false);
}
return false; // 阻止自动上传
};
// 处理导入
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) {
message.warning('请选择有效的导入文件');
return;
}
try {
setImporting(true);
const result = await projectApi.importProject(selectedFile);
if (result.success) {
message.success(`项目导入成功!${result.message}`);
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
// 刷新项目列表
await refreshProjects();
// 跳转到新项目
if (result.project_id) {
navigate(`/project/${result.project_id}`);
}
} else {
message.error(result.message || '导入失败');
}
} catch (error) {
console.error('导入失败:', error);
message.error('导入失败,请重试');
} finally {
setImporting(false);
}
};
// 关闭导入对话框
const handleCloseImportModal = () => {
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
};
// 打开导出对话框
const handleOpenExportModal = () => {
setExportModalVisible(true);
setSelectedProjectIds([]);
};
// 获取可导出的项目(过滤掉向导未完成的项目)
const exportableProjects = projects.filter(p => p.wizard_status === 'completed');
// 关闭导出对话框
const handleCloseExportModal = () => {
setExportModalVisible(false);
setSelectedProjectIds([]);
};
// 切换项目选择
const handleToggleProject = (projectId: string) => {
setSelectedProjectIds(prev =>
prev.includes(projectId)
? prev.filter(id => id !== projectId)
: [...prev, projectId]
);
};
// 全选/取消全选
const handleToggleAll = () => {
if (selectedProjectIds.length === exportableProjects.length) {
setSelectedProjectIds([]);
} else {
setSelectedProjectIds(exportableProjects.map(p => p.id));
}
};
// 执行导出
const handleExport = async () => {
if (selectedProjectIds.length === 0) {
message.warning('请至少选择一个项目');
return;
}
try {
setExporting(true);
if (selectedProjectIds.length === 1) {
// 单个项目导出
const projectId = selectedProjectIds[0];
const project = projects.find(p => p.id === projectId);
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
message.success(`项目 "${project?.title}" 导出成功`);
} else {
// 批量导出
let successCount = 0;
let failCount = 0;
for (const projectId of selectedProjectIds) {
try {
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
successCount++;
// 添加延迟避免浏览器阻止多个下载
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.error(`导出项目 ${projectId} 失败:`, error);
failCount++;
}
}
if (failCount === 0) {
message.success(`成功导出 ${successCount} 个项目`);
} else {
message.warning(`导出完成:成功 ${successCount} 个,失败 ${failCount}`);
}
}
handleCloseExportModal();
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败,请重试');
} finally {
setExporting(false);
}
};
return (
<div style={{
minHeight: '100vh',
@@ -153,46 +320,165 @@ export default function ProjectList() {
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
<Button
type="primary"
size={window.innerWidth <= 768 ? 'middle' : 'large'}
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size={window.innerWidth <= 768 ? 'middle' : 'large'}
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
API设置
</Button>
<UserMenu />
<Col xs={24} sm={12} md={14}>
{window.innerWidth <= 768 ? (
// 移动端:按钮分两行显示
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space size={8} style={{ width: '100%' }}>
<Button
type="primary"
size="middle"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
flex: 1,
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)'
}}
>
API设置
</Button>
<UserMenu />
</Space>
<Space size={8} style={{ width: '100%' }}>
<Button
type="default"
size="middle"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
>
</Button>
</Space>
</Space>
) : (
// PC端:原有布局
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size="large"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button
type="default"
size="large"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = '#f6ffed';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button
type="default"
size="large"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
API设置
</Button>
<UserMenu />
</Space>
)}
</Col>
</Row>
@@ -472,6 +758,285 @@ export default function ProjectList() {
)}
</Spin>
</div>
{/* 导入项目对话框 */}
<Modal
title="导入项目"
open={importModalVisible}
onOk={handleImport}
onCancel={handleCloseImportModal}
confirmLoading={importing}
okText="导入"
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 500}
centered
okButtonProps={{ disabled: !validationResult?.valid }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '60vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<p style={{ marginBottom: '12px', color: '#666', fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
JSON
</p>
<Upload
accept=".json"
beforeUpload={handleFileSelect}
maxCount={1}
onRemove={() => {
setSelectedFile(null);
setValidationResult(null);
}}
fileList={selectedFile ? [{ uid: '-1', name: selectedFile.name, status: 'done' }] as any : []}
>
<Button icon={<UploadOutlined />} block></Button>
</Upload>
</div>
{validating && (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="验证文件中..." />
</div>
)}
{validationResult && (
<Card size="small" style={{ background: validationResult.valid ? '#f6ffed' : '#fff2f0' }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div>
<Text strong style={{
color: validationResult.valid ? '#52c41a' : '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 13 : 14
}}>
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
</Text>
</div>
{validationResult.project_name && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>{validationResult.project_name}</Text>
</div>
)}
{validationResult.statistics && Object.keys(validationResult.statistics).length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<div style={{ marginTop: 8 }}>
<Row gutter={[8, 8]}>
{validationResult.statistics.chapters > 0 && (
<Col span={12}>
<Tag color="blue">: {validationResult.statistics.chapters}</Tag>
</Col>
)}
{validationResult.statistics.characters > 0 && (
<Col span={12}>
<Tag color="green">: {validationResult.statistics.characters}</Tag>
</Col>
)}
{validationResult.statistics.outlines > 0 && (
<Col span={12}>
<Tag color="purple">: {validationResult.statistics.outlines}</Tag>
</Col>
)}
{validationResult.statistics.relationships > 0 && (
<Col span={12}>
<Tag color="orange">: {validationResult.statistics.relationships}</Tag>
</Col>
)}
</Row>
</div>
</div>
)}
{validationResult.errors && validationResult.errors.length > 0 && (
<div>
<Text type="danger" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.errors.map((error: string, index: number) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{validationResult.warnings && validationResult.warnings.length > 0 && (
<div>
<Text type="warning" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#faad14',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.warnings.map((warning: string, index: number) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</Space>
</Card>
)}
</Space>
</Modal>
{/* 导出项目对话框 */}
<Modal
title="导出项目"
open={exportModalVisible}
onOk={handleExport}
onCancel={handleCloseExportModal}
confirmLoading={exporting}
okText={selectedProjectIds.length > 0 ? `导出 (${selectedProjectIds.length})` : '导出'}
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 700}
centered
okButtonProps={{ disabled: selectedProjectIds.length === 0 }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '70vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{/* 导出选项 */}
<Card
size="small"
style={{ background: '#f5f5f5' }}
styles={{ body: { padding: window.innerWidth <= 768 ? 12 : 16 } }}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeWritingStyles}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeWritingStyles: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出项目关联的写作风格数据">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeGenerationHistory}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeGenerationHistory: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出AI生成的历史记录(最多100条)">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
</Space>
</Card>
<Divider style={{ margin: '8px 0' }} />
{/* 项目列表 */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: window.innerWidth <= 768 ? 'wrap' : 'nowrap', gap: 8 }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
{exportableProjects.length > 0 && <Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>({exportableProjects.length})</Text>}
</Text>
<Checkbox
checked={selectedProjectIds.length === exportableProjects.length && exportableProjects.length > 0}
indeterminate={selectedProjectIds.length > 0 && selectedProjectIds.length < exportableProjects.length}
onChange={handleToggleAll}
style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}
>
</Checkbox>
</div>
<div style={{ maxHeight: window.innerWidth <= 768 ? 300 : 400, overflowY: 'auto' }}>
{exportableProjects.length === 0 ? (
<Empty
description="暂无可导出的项目"
style={{ padding: '40px 0' }}
/>
) : (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{exportableProjects.map((project) => (
<Card
key={project.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedProjectIds.includes(project.id) ? '2px solid #1890ff' : '1px solid #d9d9d9',
background: selectedProjectIds.includes(project.id) ? '#e6f7ff' : '#fff'
}}
onClick={() => handleToggleProject(project.id)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox
checked={selectedProjectIds.includes(project.id)}
onChange={() => handleToggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
<BookOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>{project.title}</Text>
{project.genre && (
<Tag color="blue" style={{ margin: 0, fontSize: window.innerWidth <= 768 ? 11 : 12 }}>{project.genre}</Tag>
)}
{getStatusTag(project.status)}
</div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 11 : 12 }}>
{project.current_words || 0}
{project.description && ` · ${project.description.substring(0, window.innerWidth <= 768 ? 30 : 50)}${project.description.length > (window.innerWidth <= 768 ? 30 : 50) ? '...' : ''}`}
</Text>
</div>
{window.innerWidth > 768 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(project.updated_at)}
</Text>
)}
</div>
</Card>
))}
</Space>
)}
</div>
</div>
{selectedProjectIds.length > 0 && (
<Alert
message={`已选择 ${selectedProjectIds.length} 个项目`}
type="info"
showIcon
style={{ marginTop: 8 }}
/>
)}
</Space>
</Modal>
</div>
);
}