update:1.更新导入导出功能 2.实现RAG记忆功能,引入剧情分析功能
This commit is contained in:
@@ -10,6 +10,8 @@ import Characters from './pages/Characters';
|
||||
import Relationships from './pages/Relationships';
|
||||
import Organizations from './pages/Organizations';
|
||||
import Chapters from './pages/Chapters';
|
||||
import ChapterReader from './pages/ChapterReader';
|
||||
import ChapterAnalysis from './pages/ChapterAnalysis';
|
||||
import WritingStyles from './pages/WritingStyles';
|
||||
import Settings from './pages/Settings';
|
||||
// import Polish from './pages/Polish';
|
||||
@@ -34,6 +36,7 @@ function App() {
|
||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
<Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} />
|
||||
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
<Route path="world-setting" element={<WorldSetting />} />
|
||||
@@ -42,6 +45,7 @@ function App() {
|
||||
<Route path="relationships" element={<Relationships />} />
|
||||
<Route path="organizations" element={<Organizations />} />
|
||||
<Route path="chapters" element={<Chapters />} />
|
||||
<Route path="chapter-analysis" element={<ChapterAnalysis />} />
|
||||
<Route path="writing-styles" element={<WritingStyles />} />
|
||||
{/* <Route path="polish" element={<Polish />} /> */}
|
||||
</Route>
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { Tooltip } from 'antd';
|
||||
|
||||
// 标注数据类型
|
||||
export interface MemoryAnnotation {
|
||||
id: string;
|
||||
type: 'hook' | 'foreshadow' | 'plot_point' | 'character_event';
|
||||
title: string;
|
||||
content: string;
|
||||
importance: number;
|
||||
position: number;
|
||||
length: number;
|
||||
tags: string[];
|
||||
metadata: {
|
||||
strength?: number;
|
||||
foreshadowType?: 'planted' | 'resolved';
|
||||
relatedCharacters?: string[];
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
// 文本片段类型
|
||||
interface TextSegment {
|
||||
type: 'text' | 'annotated';
|
||||
content: string;
|
||||
annotation?: MemoryAnnotation;
|
||||
}
|
||||
|
||||
interface AnnotatedTextProps {
|
||||
content: string;
|
||||
annotations: MemoryAnnotation[];
|
||||
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
|
||||
activeAnnotationId?: string;
|
||||
scrollToAnnotation?: string;
|
||||
}
|
||||
|
||||
// 类型颜色映射
|
||||
const TYPE_COLORS = {
|
||||
hook: '#ff6b6b',
|
||||
foreshadow: '#6b7bff',
|
||||
plot_point: '#51cf66',
|
||||
character_event: '#ffd93d',
|
||||
};
|
||||
|
||||
// 类型图标映射
|
||||
const TYPE_ICONS = {
|
||||
hook: '🎣',
|
||||
foreshadow: '🌟',
|
||||
plot_point: '💎',
|
||||
character_event: '👤',
|
||||
};
|
||||
|
||||
/**
|
||||
* 带标注的文本组件
|
||||
* 将记忆标注可视化地展示在章节文本中
|
||||
*/
|
||||
const AnnotatedText: React.FC<AnnotatedTextProps> = ({
|
||||
content,
|
||||
annotations,
|
||||
onAnnotationClick,
|
||||
activeAnnotationId,
|
||||
scrollToAnnotation,
|
||||
}) => {
|
||||
const annotationRefs = useRef<Record<string, HTMLSpanElement | null>>({});
|
||||
|
||||
// 当需要滚动到特定标注时
|
||||
useEffect(() => {
|
||||
if (scrollToAnnotation && annotationRefs.current[scrollToAnnotation]) {
|
||||
const element = annotationRefs.current[scrollToAnnotation];
|
||||
element?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, [scrollToAnnotation]);
|
||||
// 处理标注重叠和排序
|
||||
const processedAnnotations = useMemo(() => {
|
||||
if (!annotations || annotations.length === 0) {
|
||||
console.log('AnnotatedText: 没有标注数据');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`AnnotatedText: 收到${annotations.length}个标注,内容长度${content.length}`);
|
||||
|
||||
// 过滤掉无效位置的标注
|
||||
const validAnnotations = annotations.filter(
|
||||
(a) => a.position >= 0 && a.position < content.length
|
||||
);
|
||||
|
||||
const invalidCount = annotations.length - validAnnotations.length;
|
||||
if (invalidCount > 0) {
|
||||
console.warn(`AnnotatedText: ${invalidCount}个标注位置无效,有效标注${validAnnotations.length}个`);
|
||||
console.log('无效标注:', annotations.filter(a => a.position < 0 || a.position >= content.length));
|
||||
}
|
||||
|
||||
// 按位置排序
|
||||
return validAnnotations.sort((a, b) => a.position - b.position);
|
||||
}, [annotations, content]);
|
||||
|
||||
// 将文本分割为带标注的片段
|
||||
const segments = useMemo(() => {
|
||||
if (processedAnnotations.length === 0) {
|
||||
return [{ type: 'text' as const, content }];
|
||||
}
|
||||
|
||||
const result: TextSegment[] = [];
|
||||
let lastPos = 0;
|
||||
|
||||
for (const annotation of processedAnnotations) {
|
||||
const { position, length } = annotation;
|
||||
|
||||
// 添加普通文本片段
|
||||
if (position > lastPos) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
content: content.slice(lastPos, position),
|
||||
});
|
||||
}
|
||||
|
||||
// 添加标注片段
|
||||
const annotatedContent = content.slice(
|
||||
position,
|
||||
position + (length > 0 ? length : 30) // 如果没有长度,默认30字符
|
||||
);
|
||||
|
||||
result.push({
|
||||
type: 'annotated',
|
||||
content: annotatedContent,
|
||||
annotation,
|
||||
});
|
||||
|
||||
lastPos = position + (length > 0 ? length : 30);
|
||||
}
|
||||
|
||||
// 添加剩余文本
|
||||
if (lastPos < content.length) {
|
||||
result.push({
|
||||
type: 'text',
|
||||
content: content.slice(lastPos),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [content, processedAnnotations]);
|
||||
|
||||
// 渲染标注片段
|
||||
const renderAnnotatedSegment = (segment: TextSegment, index: number) => {
|
||||
if (segment.type === 'text') {
|
||||
return <span key={index}>{segment.content}</span>;
|
||||
}
|
||||
|
||||
const { annotation } = segment;
|
||||
if (!annotation) return null;
|
||||
|
||||
const color = TYPE_COLORS[annotation.type];
|
||||
const icon = TYPE_ICONS[annotation.type];
|
||||
const isActive = activeAnnotationId === annotation.id;
|
||||
|
||||
// 工具提示内容
|
||||
const tooltipContent = (
|
||||
<div style={{ maxWidth: 300 }}>
|
||||
<div style={{ fontWeight: 'bold', marginBottom: 4 }}>
|
||||
{icon} {annotation.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.9 }}>
|
||||
{annotation.content.slice(0, 100)}
|
||||
{annotation.content.length > 100 ? '...' : ''}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 11, opacity: 0.7 }}>
|
||||
重要性: {(annotation.importance * 10).toFixed(1)}/10
|
||||
</div>
|
||||
{annotation.tags && annotation.tags.length > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: 11 }}>
|
||||
{annotation.tags.map((tag, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
background: 'rgba(255,255,255,0.2)',
|
||||
padding: '2px 6px',
|
||||
borderRadius: 3,
|
||||
marginRight: 4,
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Tooltip key={index} title={tooltipContent} placement="top">
|
||||
<span
|
||||
ref={(el) => {
|
||||
if (annotation) {
|
||||
annotationRefs.current[annotation.id] = el;
|
||||
}
|
||||
}}
|
||||
data-annotation-id={annotation?.id}
|
||||
className={`annotated-text ${isActive ? 'active' : ''}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
borderBottom: `2px solid ${color}`,
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isActive ? `${color}22` : 'transparent',
|
||||
transition: 'all 0.2s',
|
||||
padding: '2px 0',
|
||||
}}
|
||||
onClick={() => onAnnotationClick?.(annotation)}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.backgroundColor = `${color}33`;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.backgroundColor = isActive
|
||||
? `${color}22`
|
||||
: 'transparent';
|
||||
}}
|
||||
>
|
||||
{segment.content}
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -20,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
fontSize: 14,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
lineHeight: 2,
|
||||
fontSize: 16,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{segments.map((segment, index) => renderAnnotatedSegment(segment, index))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnotatedText;
|
||||
@@ -0,0 +1,533 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Progress, Spin, Alert, Tabs, Card, Tag, List, Empty, Statistic, Row, Col, Button } from 'antd';
|
||||
import {
|
||||
ThunderboltOutlined,
|
||||
BulbOutlined,
|
||||
FireOutlined,
|
||||
HeartOutlined,
|
||||
TeamOutlined,
|
||||
TrophyOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
ReloadOutlined
|
||||
} from '@ant-design/icons';
|
||||
import type { AnalysisTask, ChapterAnalysisResponse } from '../types';
|
||||
|
||||
interface ChapterAnalysisProps {
|
||||
chapterId: string;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ChapterAnalysis({ chapterId, visible, onClose }: ChapterAnalysisProps) {
|
||||
const [task, setTask] = useState<AnalysisTask | null>(null);
|
||||
const [analysis, setAnalysis] = useState<ChapterAnalysisResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && chapterId) {
|
||||
fetchAnalysisStatus();
|
||||
}
|
||||
}, [visible, chapterId]);
|
||||
|
||||
const fetchAnalysisStatus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
|
||||
|
||||
if (response.status === 404) {
|
||||
setTask(null);
|
||||
setError('该章节还未进行分析');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('获取分析状态失败');
|
||||
}
|
||||
|
||||
const taskData: AnalysisTask = await response.json();
|
||||
setTask(taskData);
|
||||
|
||||
if (taskData.status === 'completed') {
|
||||
await fetchAnalysisResult();
|
||||
} else if (taskData.status === 'running' || taskData.status === 'pending') {
|
||||
// 开始轮询
|
||||
startPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAnalysisResult = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${chapterId}/analysis`);
|
||||
if (!response.ok) {
|
||||
throw new Error('获取分析结果失败');
|
||||
}
|
||||
const data: ChapterAnalysisResponse = await response.json();
|
||||
setAnalysis(data);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const startPolling = () => {
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
|
||||
if (!response.ok) return;
|
||||
|
||||
const taskData: AnalysisTask = await response.json();
|
||||
setTask(taskData);
|
||||
|
||||
if (taskData.status === 'completed') {
|
||||
clearInterval(pollInterval);
|
||||
await fetchAnalysisResult();
|
||||
} else if (taskData.status === 'failed') {
|
||||
clearInterval(pollInterval);
|
||||
setError(taskData.error_message || '分析失败');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('轮询错误:', err);
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
// 5分钟超时
|
||||
setTimeout(() => clearInterval(pollInterval), 300000);
|
||||
};
|
||||
|
||||
const triggerAnalysis = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await fetch(`/api/chapters/${chapterId}/analyze`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.detail || '触发分析失败');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
setTask({
|
||||
task_id: result.task_id,
|
||||
chapter_id: chapterId,
|
||||
status: 'pending',
|
||||
progress: 0
|
||||
});
|
||||
|
||||
// 开始轮询
|
||||
startPolling();
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderStatusIcon = () => {
|
||||
if (!task) return null;
|
||||
|
||||
switch (task.status) {
|
||||
case 'pending':
|
||||
return <ClockCircleOutlined style={{ color: '#faad14' }} />;
|
||||
case 'running':
|
||||
return <Spin />;
|
||||
case 'completed':
|
||||
return <CheckCircleOutlined style={{ color: '#52c41a' }} />;
|
||||
case 'failed':
|
||||
return <CloseCircleOutlined style={{ color: '#ff4d4f' }} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const renderProgress = () => {
|
||||
if (!task || task.status === 'completed') return null;
|
||||
|
||||
return (
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 16 }}>
|
||||
{renderStatusIcon()}
|
||||
<span style={{ marginLeft: 8, fontSize: 16 }}>
|
||||
{task.status === 'pending' && '等待分析...'}
|
||||
{task.status === 'running' && 'AI正在分析中...'}
|
||||
{task.status === 'failed' && '分析失败'}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
percent={task.progress}
|
||||
status={task.status === 'failed' ? 'exception' : 'active'}
|
||||
/>
|
||||
{task.status === 'failed' && task.error_message && (
|
||||
<Alert
|
||||
message="分析失败"
|
||||
description={task.error_message}
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginTop: 16 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAnalysisResult = () => {
|
||||
if (!analysis) return null;
|
||||
|
||||
const { analysis: analysis_data, memories } = analysis;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultActiveKey="overview"
|
||||
style={{ height: '100%' }}
|
||||
items={[
|
||||
{
|
||||
key: 'overview',
|
||||
label: '概览',
|
||||
icon: <TrophyOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card title="整体评分" style={{ marginBottom: 16 }}>
|
||||
<Row gutter={16}>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="整体质量"
|
||||
value={analysis_data.overall_quality_score || 0}
|
||||
suffix="/ 10"
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="节奏把控"
|
||||
value={analysis_data.pacing_score || 0}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="吸引力"
|
||||
value={analysis_data.engagement_score || 0}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="连贯性"
|
||||
value={analysis_data.coherence_score || 0}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{analysis_data.analysis_report && (
|
||||
<Card title="分析摘要" style={{ marginBottom: 16 }}>
|
||||
<pre style={{ whiteSpace: 'pre-wrap', fontFamily: 'inherit' }}>
|
||||
{analysis_data.analysis_report}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{analysis_data.suggestions && analysis_data.suggestions.length > 0 && (
|
||||
<Card title={<><BulbOutlined /> 改进建议</>}>
|
||||
<List
|
||||
dataSource={analysis_data.suggestions}
|
||||
renderItem={(item, index) => (
|
||||
<List.Item>
|
||||
<span>{index + 1}. {item}</span>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'hooks',
|
||||
label: `钩子 (${analysis_data.hooks?.length || 0})`,
|
||||
icon: <ThunderboltOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
{analysis_data.hooks && analysis_data.hooks.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.hooks}
|
||||
renderItem={(hook) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div>
|
||||
<Tag color="blue">{hook.type}</Tag>
|
||||
<Tag color="orange">{hook.position}</Tag>
|
||||
<Tag color="red">强度: {hook.strength}/10</Tag>
|
||||
</div>
|
||||
}
|
||||
description={hook.content}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无钩子" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'foreshadows',
|
||||
label: `伏笔 (${analysis_data.foreshadows?.length || 0})`,
|
||||
icon: <FireOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
{analysis_data.foreshadows && analysis_data.foreshadows.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.foreshadows}
|
||||
renderItem={(foreshadow) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div>
|
||||
<Tag color={foreshadow.type === 'planted' ? 'green' : 'purple'}>
|
||||
{foreshadow.type === 'planted' ? '已埋下' : '已回收'}
|
||||
</Tag>
|
||||
<Tag>强度: {foreshadow.strength}/10</Tag>
|
||||
<Tag>隐藏度: {foreshadow.subtlety}/10</Tag>
|
||||
{foreshadow.reference_chapter && (
|
||||
<Tag color="cyan">呼应第{foreshadow.reference_chapter}章</Tag>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={foreshadow.content}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无伏笔" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'emotion',
|
||||
label: '情感曲线',
|
||||
icon: <HeartOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
{analysis_data.emotional_tone ? (
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="主导情绪"
|
||||
value={analysis_data.emotional_tone}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="情感强度"
|
||||
value={(analysis_data.emotional_intensity * 10).toFixed(1)}
|
||||
suffix="/ 10"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Card type="inner" title="剧情阶段" size="small">
|
||||
<p><strong>阶段:</strong>{analysis_data.plot_stage}</p>
|
||||
<p><strong>冲突等级:</strong>{analysis_data.conflict_level} / 10</p>
|
||||
{analysis_data.conflict_types && analysis_data.conflict_types.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<strong>冲突类型:</strong>
|
||||
{analysis_data.conflict_types.map((type, idx) => (
|
||||
<Tag key={idx} color="red" style={{ margin: 4 }}>
|
||||
{type}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<Empty description="暂无情感分析" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'characters',
|
||||
label: `角色 (${analysis_data.character_states?.length || 0})`,
|
||||
icon: <TeamOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
{analysis_data.character_states && analysis_data.character_states.length > 0 ? (
|
||||
<List
|
||||
dataSource={analysis_data.character_states}
|
||||
renderItem={(char) => (
|
||||
<List.Item>
|
||||
<Card
|
||||
type="inner"
|
||||
title={char.character_name}
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<p><strong>状态变化:</strong>{char.state_before} → {char.state_after}</p>
|
||||
<p><strong>心理变化:</strong>{char.psychological_change}</p>
|
||||
<p><strong>关键事件:</strong>{char.key_event}</p>
|
||||
{char.relationship_changes && Object.keys(char.relationship_changes).length > 0 && (
|
||||
<div>
|
||||
<strong>关系变化:</strong>
|
||||
{Object.entries(char.relationship_changes).map(([name, change]) => (
|
||||
<Tag key={name} color="blue" style={{ margin: 4 }}>
|
||||
与{name}: {change}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无角色分析" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'memories',
|
||||
label: `记忆 (${memories?.length || 0})`,
|
||||
icon: <FireOutlined />,
|
||||
children: (
|
||||
<div style={{ height: 'calc(90vh - 220px)', overflowY: 'auto', paddingRight: '8px' }}>
|
||||
<Card>
|
||||
{memories && memories.length > 0 ? (
|
||||
<List
|
||||
dataSource={memories}
|
||||
renderItem={(memory) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<div>
|
||||
<Tag color="blue">{memory.type}</Tag>
|
||||
<Tag color="orange">重要性: {memory.importance.toFixed(1)}</Tag>
|
||||
{memory.is_foreshadow === 1 && <Tag color="green">已埋下伏笔</Tag>}
|
||||
{memory.is_foreshadow === 2 && <Tag color="purple">已回收伏笔</Tag>}
|
||||
<span style={{ marginLeft: 8 }}>{memory.title}</span>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
<div>
|
||||
<p>{memory.content}</p>
|
||||
<div>
|
||||
{memory.tags.map((tag, idx) => (
|
||||
<Tag key={idx} style={{ margin: 2 }}>{tag}</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Empty description="暂无记忆片段" />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="章节分析"
|
||||
open={visible}
|
||||
onCancel={onClose}
|
||||
width="90%"
|
||||
centered
|
||||
style={{
|
||||
maxWidth: '1400px',
|
||||
paddingBottom: 0
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
padding: '24px',
|
||||
paddingBottom: 0
|
||||
}
|
||||
}}
|
||||
footer={[
|
||||
<Button key="close" onClick={onClose}>
|
||||
关闭
|
||||
</Button>,
|
||||
!task && (
|
||||
<Button
|
||||
key="analyze"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={triggerAnalysis}
|
||||
loading={loading}
|
||||
>
|
||||
开始分析
|
||||
</Button>
|
||||
),
|
||||
task && (task.status === 'failed' || task.status === 'completed') && (
|
||||
<Button
|
||||
key="reanalyze"
|
||||
type="primary"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={triggerAnalysis}
|
||||
loading={loading}
|
||||
danger={task.status === 'failed'}
|
||||
>
|
||||
重新分析
|
||||
</Button>
|
||||
)
|
||||
]}
|
||||
>
|
||||
{loading && !task && (
|
||||
<div style={{ textAlign: 'center', padding: '48px' }}>
|
||||
<Spin size="large" />
|
||||
<p style={{ marginTop: 16 }}>加载中...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert
|
||||
message="错误"
|
||||
description={error}
|
||||
type="error"
|
||||
showIcon
|
||||
action={
|
||||
<Button size="small" danger onClick={triggerAnalysis}>
|
||||
开始分析
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{task && task.status !== 'completed' && renderProgress()}
|
||||
{task && task.status === 'completed' && analysis && renderAnalysisResult()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useMemo, useEffect, useRef } from 'react';
|
||||
import { Card, Tag, Badge, Empty, Collapse, Divider } from 'antd';
|
||||
import {
|
||||
FireOutlined,
|
||||
StarOutlined,
|
||||
ThunderboltOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { MemoryAnnotation } from './AnnotatedText';
|
||||
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface MemorySidebarProps {
|
||||
annotations: MemoryAnnotation[];
|
||||
activeAnnotationId?: string;
|
||||
onAnnotationClick?: (annotation: MemoryAnnotation) => void;
|
||||
scrollToAnnotation?: string;
|
||||
}
|
||||
|
||||
// 类型配置
|
||||
const TYPE_CONFIG = {
|
||||
hook: {
|
||||
label: '钩子',
|
||||
icon: <FireOutlined />,
|
||||
color: '#ff6b6b',
|
||||
},
|
||||
foreshadow: {
|
||||
label: '伏笔',
|
||||
icon: <StarOutlined />,
|
||||
color: '#6b7bff',
|
||||
},
|
||||
plot_point: {
|
||||
label: '情节点',
|
||||
icon: <ThunderboltOutlined />,
|
||||
color: '#51cf66',
|
||||
},
|
||||
character_event: {
|
||||
label: '角色事件',
|
||||
icon: <UserOutlined />,
|
||||
color: '#ffd93d',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 记忆侧边栏组件
|
||||
* 展示章节的所有记忆标注
|
||||
*/
|
||||
const MemorySidebar: React.FC<MemorySidebarProps> = ({
|
||||
annotations,
|
||||
activeAnnotationId,
|
||||
onAnnotationClick,
|
||||
scrollToAnnotation,
|
||||
}) => {
|
||||
const cardRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// 当需要滚动到特定标注卡片时
|
||||
useEffect(() => {
|
||||
if (scrollToAnnotation && cardRefs.current[scrollToAnnotation]) {
|
||||
const element = cardRefs.current[scrollToAnnotation];
|
||||
element?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}, [scrollToAnnotation]);
|
||||
// 按类型分组
|
||||
const groupedAnnotations = useMemo(() => {
|
||||
const groups: Record<string, MemoryAnnotation[]> = {
|
||||
hook: [],
|
||||
foreshadow: [],
|
||||
plot_point: [],
|
||||
character_event: [],
|
||||
};
|
||||
|
||||
annotations.forEach((annotation) => {
|
||||
if (groups[annotation.type]) {
|
||||
groups[annotation.type].push(annotation);
|
||||
}
|
||||
});
|
||||
|
||||
// 每组按重要性排序
|
||||
Object.keys(groups).forEach((type) => {
|
||||
groups[type].sort((a, b) => b.importance - a.importance);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [annotations]);
|
||||
|
||||
// 统计信息
|
||||
const stats = useMemo(() => {
|
||||
return {
|
||||
total: annotations.length,
|
||||
hooks: groupedAnnotations.hook.length,
|
||||
foreshadows: groupedAnnotations.foreshadow.length,
|
||||
plotPoints: groupedAnnotations.plot_point.length,
|
||||
characterEvents: groupedAnnotations.character_event.length,
|
||||
};
|
||||
}, [annotations, groupedAnnotations]);
|
||||
|
||||
// 渲染单个记忆卡片
|
||||
const renderMemoryCard = (annotation: MemoryAnnotation) => {
|
||||
const config = TYPE_CONFIG[annotation.type];
|
||||
const isActive = activeAnnotationId === annotation.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={annotation.id}
|
||||
ref={(el) => {
|
||||
cardRefs.current[annotation.id] = el;
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => onAnnotationClick?.(annotation)}
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
borderLeft: `4px solid ${config.color}`,
|
||||
backgroundColor: isActive ? `${config.color}11` : 'transparent',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
}}
|
||||
bodyStyle={{ padding: 12 }}
|
||||
>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Badge
|
||||
count={`${(annotation.importance * 10).toFixed(1)}`}
|
||||
style={{
|
||||
backgroundColor: config.color,
|
||||
float: 'right',
|
||||
}}
|
||||
/>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, paddingRight: 50 }}>
|
||||
{config.icon} {annotation.title}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
lineHeight: 1.6,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{annotation.content.length > 100
|
||||
? `${annotation.content.slice(0, 100)}...`
|
||||
: annotation.content}
|
||||
</div>
|
||||
|
||||
{annotation.tags && annotation.tags.length > 0 && (
|
||||
<div>
|
||||
{annotation.tags.map((tag, index) => (
|
||||
<Tag key={index} style={{ fontSize: 11, margin: '2px 4px 2px 0' }}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 特殊元数据 */}
|
||||
{annotation.metadata.strength && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: '#999' }}>
|
||||
强度: {annotation.metadata.strength}/10
|
||||
</div>
|
||||
)}
|
||||
{annotation.metadata.foreshadowType && (
|
||||
<Tag
|
||||
color={annotation.metadata.foreshadowType === 'planted' ? 'blue' : 'green'}
|
||||
style={{ marginTop: 4 }}
|
||||
>
|
||||
{annotation.metadata.foreshadowType === 'planted' ? '已埋下' : '已回收'}
|
||||
</Tag>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (annotations.length === 0) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Empty description="暂无分析数据" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', overflowY: 'auto', padding: '16px' }}>
|
||||
{/* 统计概览 */}
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>📊 分析概览</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>钩子</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.hook.color }}>
|
||||
{stats.hooks}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>伏笔</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.foreshadow.color }}>
|
||||
{stats.foreshadows}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>情节点</div>
|
||||
<div style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.plot_point.color }}>
|
||||
{stats.plotPoints}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: '#999' }}>角色事件</div>
|
||||
<div
|
||||
style={{ fontSize: 20, fontWeight: 600, color: TYPE_CONFIG.character_event.color }}
|
||||
>
|
||||
{stats.characterEvents}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Divider style={{ margin: '16px 0' }} />
|
||||
|
||||
{/* 分类展示 */}
|
||||
<Collapse defaultActiveKey={['hook', 'foreshadow', 'plot_point']} ghost>
|
||||
{Object.entries(groupedAnnotations).map(([type, items]) => {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const config = TYPE_CONFIG[type as keyof typeof TYPE_CONFIG];
|
||||
|
||||
return (
|
||||
<Panel
|
||||
key={type}
|
||||
header={
|
||||
<span style={{ fontWeight: 600 }}>
|
||||
{config.icon} {config.label} ({items.length})
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{items.map((annotation) => renderMemoryCard(annotation))}
|
||||
</Panel>
|
||||
);
|
||||
})}
|
||||
</Collapse>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemorySidebar;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,71 @@ export const projectApi = {
|
||||
exportProject: (id: string) => {
|
||||
window.open(`/api/projects/${id}/export`, '_blank');
|
||||
},
|
||||
|
||||
// 导出项目数据为JSON
|
||||
exportProjectData: async (id: string, options: { include_generation_history?: boolean; include_writing_styles?: boolean }) => {
|
||||
const response = await axios.post(
|
||||
`/api/projects/${id}/export-data`,
|
||||
options,
|
||||
{
|
||||
responseType: 'blob',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 从响应头获取文件名
|
||||
const contentDisposition = response.headers['content-disposition'];
|
||||
let filename = 'project_export.json';
|
||||
if (contentDisposition) {
|
||||
const matches = /filename\*=UTF-8''(.+)/.exec(contentDisposition);
|
||||
if (matches && matches[1]) {
|
||||
filename = decodeURIComponent(matches[1]);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建下载链接
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
},
|
||||
|
||||
// 验证导入文件
|
||||
validateImportFile: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post<unknown, {
|
||||
valid: boolean;
|
||||
version: string;
|
||||
project_name?: string;
|
||||
statistics: Record<string, number>;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}>('/projects/validate-import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
|
||||
// 导入项目
|
||||
importProject: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post<unknown, {
|
||||
success: boolean;
|
||||
project_id?: string;
|
||||
message: string;
|
||||
statistics: Record<string, number>;
|
||||
warnings: string[];
|
||||
}>('/projects/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const outlineApi = {
|
||||
|
||||
@@ -376,4 +376,140 @@ export interface ApiError {
|
||||
};
|
||||
};
|
||||
message?: string;
|
||||
}
|
||||
|
||||
// 章节分析任务相关类型
|
||||
export interface AnalysisTask {
|
||||
task_id: string;
|
||||
chapter_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress: number;
|
||||
error_message?: string;
|
||||
created_at?: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
// 分析结果 - 钩子
|
||||
export interface AnalysisHook {
|
||||
type: string;
|
||||
content: string;
|
||||
strength: number;
|
||||
position: string;
|
||||
}
|
||||
|
||||
// 分析结果 - 伏笔
|
||||
export interface AnalysisForeshadow {
|
||||
content: string;
|
||||
type: 'planted' | 'resolved';
|
||||
strength: number;
|
||||
subtlety: number;
|
||||
reference_chapter?: number;
|
||||
}
|
||||
|
||||
// 分析结果 - 冲突
|
||||
export interface AnalysisConflict {
|
||||
types: string[];
|
||||
parties: string[];
|
||||
level: number;
|
||||
description: string;
|
||||
resolution_progress: number;
|
||||
}
|
||||
|
||||
// 分析结果 - 情感曲线
|
||||
export interface AnalysisEmotionalArc {
|
||||
primary_emotion: string;
|
||||
intensity: number;
|
||||
curve: string;
|
||||
secondary_emotions: string[];
|
||||
}
|
||||
|
||||
// 分析结果 - 角色状态
|
||||
export interface AnalysisCharacterState {
|
||||
character_name: string;
|
||||
state_before: string;
|
||||
state_after: string;
|
||||
psychological_change: string;
|
||||
key_event: string;
|
||||
relationship_changes: Record<string, string>;
|
||||
}
|
||||
|
||||
// 分析结果 - 情节点
|
||||
export interface AnalysisPlotPoint {
|
||||
content: string;
|
||||
type: 'revelation' | 'conflict' | 'resolution' | 'transition';
|
||||
importance: number;
|
||||
impact: string;
|
||||
}
|
||||
|
||||
// 分析结果 - 场景
|
||||
export interface AnalysisScene {
|
||||
location: string;
|
||||
atmosphere: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
// 分析结果 - 评分
|
||||
export interface AnalysisScores {
|
||||
pacing: number;
|
||||
engagement: number;
|
||||
coherence: number;
|
||||
overall: number;
|
||||
}
|
||||
|
||||
// 完整分析数据 - 匹配后端PlotAnalysis模型
|
||||
export interface AnalysisData {
|
||||
id: string;
|
||||
chapter_id: string;
|
||||
plot_stage: string;
|
||||
conflict_level: number;
|
||||
conflict_types: string[];
|
||||
emotional_tone: string;
|
||||
emotional_intensity: number;
|
||||
hooks: AnalysisHook[];
|
||||
hooks_count: number;
|
||||
foreshadows: AnalysisForeshadow[];
|
||||
foreshadows_planted: number;
|
||||
foreshadows_resolved: number;
|
||||
plot_points: AnalysisPlotPoint[];
|
||||
plot_points_count: number;
|
||||
character_states: AnalysisCharacterState[];
|
||||
scenes?: AnalysisScene[];
|
||||
pacing: string;
|
||||
overall_quality_score: number;
|
||||
pacing_score: number;
|
||||
engagement_score: number;
|
||||
coherence_score: number;
|
||||
analysis_report: string;
|
||||
suggestions: string[];
|
||||
dialogue_ratio: number;
|
||||
description_ratio: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 记忆片段
|
||||
export interface StoryMemory {
|
||||
id: string;
|
||||
type: 'hook' | 'foreshadow' | 'plot_point' | 'character_event';
|
||||
title: string;
|
||||
content: string;
|
||||
importance: number;
|
||||
tags: string[];
|
||||
is_foreshadow: 0 | 1 | 2; // 0=普通, 1=已埋下, 2=已回收
|
||||
}
|
||||
|
||||
// 章节分析结果响应 - 匹配后端API返回
|
||||
export interface ChapterAnalysisResponse {
|
||||
chapter_id: string;
|
||||
analysis: AnalysisData; // 注意:后端返回的是analysis而不是analysis_data
|
||||
memories: StoryMemory[];
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// 手动触发分析响应
|
||||
export interface TriggerAnalysisResponse {
|
||||
task_id: string;
|
||||
chapter_id: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
Reference in New Issue
Block a user