update:1.修复大纲展开功能bug,按顺序展开 2.优化大纲细化UI展示,大纲设置为卷 3.实现角色关系修改功能 4.优化提示词避免出现过多特殊符号 5.优化向导页面的AI生产进度页面和灵感模式保持统一,支持重试 6.优化项目生成过长中断添加自动恢复逻辑

This commit is contained in:
xiamuceer
2025-11-26 14:56:13 +08:00
parent 42fdad71aa
commit 8121c04af9
18 changed files with 2094 additions and 1307 deletions
@@ -0,0 +1,922 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Space, Typography, message, Progress } from 'antd';
import { CheckCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { wizardStreamApi } from '../services/api';
import type { ApiError } from '../types';
const { Title, Paragraph, Text } = Typography;
export interface GenerationConfig {
title: string;
description: string;
theme: string;
genre: string | string[];
narrative_perspective: string;
target_words: number;
chapter_count: number;
character_count: number;
}
interface AIProjectGeneratorProps {
config: GenerationConfig;
storagePrefix: 'wizard' | 'inspiration';
onComplete: (projectId: string) => void;
onBack?: () => void;
isMobile?: boolean;
resumeProjectId?: string;
}
type GenerationStep = 'pending' | 'processing' | 'completed' | 'error';
interface GenerationSteps {
worldBuilding: GenerationStep;
characters: GenerationStep;
outline: GenerationStep;
}
export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
config,
storagePrefix,
onComplete,
isMobile = false,
resumeProjectId
}) => {
const navigate = useNavigate();
// 状态管理
const [loading, setLoading] = useState(false);
const [projectId, setProjectId] = useState<string>('');
// SSE流式进度状态
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [errorDetails, setErrorDetails] = useState<string>('');
const [generationSteps, setGenerationSteps] = useState<GenerationSteps>({
worldBuilding: 'pending',
characters: 'pending',
outline: 'pending'
});
// 保存生成数据,用于重试
const [generationData, setGenerationData] = useState<GenerationConfig | null>(null);
// 保存世界观生成结果,用于后续步骤
const [worldBuildingResult, setWorldBuildingResult] = useState<any>(null);
// LocalStorage 键名
const storageKeys = {
projectId: `${storagePrefix}_project_id`,
generationData: `${storagePrefix}_generation_data`,
currentStep: `${storagePrefix}_current_step`
};
// 保存进度到localStorage
const saveProgress = (projectId: string, data: GenerationConfig, step: string) => {
try {
localStorage.setItem(storageKeys.projectId, projectId);
localStorage.setItem(storageKeys.generationData, JSON.stringify(data));
localStorage.setItem(storageKeys.currentStep, step);
} catch (error) {
console.error('保存进度失败:', error);
}
};
// 清理localStorage
const clearStorage = () => {
localStorage.removeItem(storageKeys.projectId);
localStorage.removeItem(storageKeys.generationData);
localStorage.removeItem(storageKeys.currentStep);
};
// 开始自动化生成流程
useEffect(() => {
if (config) {
if (resumeProjectId) {
// 恢复生成模式
handleResumeGenerate(config, resumeProjectId);
} else {
// 新建项目模式
handleAutoGenerate(config);
}
}
}, [config, resumeProjectId]);
// 恢复未完成项目的生成
const handleResumeGenerate = async (data: GenerationConfig, projectIdParam: string) => {
try {
setLoading(true);
setProgress(0);
setProgressMessage('检查项目状态...');
setErrorDetails('');
setGenerationData(data);
setProjectId(projectIdParam);
// 获取项目信息,判断当前完成到哪一步
const response = await fetch(`/api/projects/${projectIdParam}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('获取项目信息失败');
}
const project = await response.json();
const wizardStep = project.wizard_step || 0;
// 根据wizard_step判断从哪里继续
if (wizardStep === 0) {
// 从世界观开始
message.info('从世界观步骤开始生成...');
setGenerationSteps({ worldBuilding: 'processing', characters: 'pending', outline: 'pending' });
await resumeFromWorldBuilding(data);
} else if (wizardStep === 1) {
// 世界观已完成,从角色开始
message.info('世界观已完成,从角色步骤继续...');
setGenerationSteps({ worldBuilding: 'completed', characters: 'processing', outline: 'pending' });
// 获取世界观数据
const worldResult = {
project_id: projectIdParam,
time_period: project.world_time_period || '',
location: project.world_location || '',
atmosphere: project.world_atmosphere || '',
rules: project.world_rules || ''
};
setWorldBuildingResult(worldResult);
setProgress(33);
await resumeFromCharacters(data, worldResult);
} else if (wizardStep === 2) {
// 世界观和角色已完成,从大纲开始
message.info('世界观和角色已完成,从大纲步骤继续...');
setGenerationSteps({ worldBuilding: 'completed', characters: 'completed', outline: 'processing' });
setProgress(66);
await resumeFromOutline(data, projectIdParam);
} else {
// 已全部完成
message.success('项目已完成,正在跳转...');
setProgress(100);
onComplete(projectIdParam);
setTimeout(() => {
navigate(`/project/${projectIdParam}`);
}, 1000);
}
} catch (error) {
const apiError = error as ApiError;
const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误';
console.error('恢复生成失败:', errorMsg);
setErrorDetails(errorMsg);
message.error('恢复生成失败:' + errorMsg);
setLoading(false);
}
};
// 恢复:从世界观步骤开始
const resumeFromWorldBuilding = async (data: GenerationConfig) => {
const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre;
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: data.title,
description: data.description,
theme: data.theme,
genre: genreString,
narrative_perspective: data.narrative_perspective,
target_words: data.target_words,
chapter_count: data.chapter_count,
character_count: data.character_count,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
console.error('世界观生成失败:', error);
setErrorDetails(`世界观生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('世界观生成完成');
}
}
);
await resumeFromCharacters(data, worldResult);
};
// 恢复:从角色步骤继续
const resumeFromCharacters = async (data: GenerationConfig, worldResult: any) => {
const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre;
const pid = projectId || worldResult.project_id;
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: pid,
count: data.character_count,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: data.theme,
genre: genreString,
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
await resumeFromOutline(data, pid);
};
// 恢复:从大纲步骤继续
const resumeFromOutline = async (data: GenerationConfig, pid: string) => {
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: pid,
chapter_count: data.chapter_count,
narrative_perspective: data.narrative_perspective,
target_words: data.target_words,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
// 全部完成
setProgress(100);
setProgressMessage('项目创建完成!正在跳转...');
message.success('项目创建成功!正在进入项目...');
clearStorage();
setLoading(false);
onComplete(pid);
setTimeout(() => {
navigate(`/project/${pid}`);
}, 1000);
};
// 自动化生成流程
const handleAutoGenerate = async (data: GenerationConfig) => {
try {
setLoading(true);
setProgress(0);
setProgressMessage('开始创建项目...');
setErrorDetails('');
setGenerationData(data);
saveProgress('', data, 'generating');
const genreString = Array.isArray(data.genre) ? data.genre.join('、') : data.genre;
// 步骤1: 生成世界观并创建项目
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' }));
setProgressMessage('正在生成世界观...');
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: data.title,
description: data.description,
theme: data.theme,
genre: genreString,
narrative_perspective: data.narrative_perspective,
target_words: data.target_words,
chapter_count: data.chapter_count,
character_count: data.character_count,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
setProjectId(result.project_id);
setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
console.error('世界观生成失败:', error);
setErrorDetails(`世界观生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('世界观生成完成');
}
}
);
if (!worldResult?.project_id) {
throw new Error('项目创建失败:未获取到项目ID');
}
const createdProjectId = worldResult.project_id;
setProjectId(createdProjectId);
setWorldBuildingResult(worldResult);
saveProgress(createdProjectId, data, 'generating');
// 步骤2: 生成角色
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: createdProjectId,
count: data.character_count,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: data.theme,
genre: genreString,
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
// 步骤3: 生成大纲
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: createdProjectId,
chapter_count: data.chapter_count,
narrative_perspective: data.narrative_perspective,
target_words: data.target_words,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
// 全部完成 - 自动跳转到项目详情页
setProgress(100);
setProgressMessage('项目创建完成!正在跳转...');
message.success('项目创建成功!正在进入项目...');
clearStorage();
// 调用完成回调
onComplete(createdProjectId);
// 延迟1秒后自动跳转到项目详情页
setTimeout(() => {
navigate(`/project/${createdProjectId}`);
}, 1000);
} catch (error) {
const apiError = error as ApiError;
const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误';
console.error('创建项目失败:', errorMsg);
setErrorDetails(errorMsg);
message.error('创建项目失败:' + errorMsg);
setLoading(false);
}
};
// 智能重试:从失败的步骤继续生成
const handleSmartRetry = async () => {
if (!generationData) {
message.warning('缺少生成数据');
return;
}
setLoading(true);
setErrorDetails('');
try {
if (generationSteps.worldBuilding === 'error') {
message.info('从世界观步骤开始重新生成...');
await retryFromWorldBuilding();
} else if (generationSteps.characters === 'error') {
message.info('从角色步骤继续生成...');
await retryFromCharacters();
} else if (generationSteps.outline === 'error') {
message.info('从大纲步骤继续生成...');
await retryFromOutline();
}
} catch (error: any) {
console.error('智能重试失败:', error);
message.error('重试失败:' + (error.message || '未知错误'));
setLoading(false);
}
};
// 从世界观步骤重新开始
const retryFromWorldBuilding = async () => {
if (!generationData) return;
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' }));
setProgressMessage('重新生成世界观...');
const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre;
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: generationData.title,
description: generationData.description,
theme: generationData.theme,
genre: genreString,
narrative_perspective: generationData.narrative_perspective,
target_words: generationData.target_words,
chapter_count: generationData.chapter_count,
character_count: generationData.character_count,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
setProjectId(result.project_id);
setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
console.error('世界观生成失败:', error);
setErrorDetails(`世界观生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('世界观重新生成完成');
}
}
);
if (!worldResult?.project_id) {
throw new Error('项目创建失败:未获取到项目ID');
}
await continueFromCharacters(worldResult);
};
// 从角色步骤继续
const retryFromCharacters = async () => {
if (!generationData || !projectId || !worldBuildingResult) {
message.warning('缺少必要数据,无法从角色步骤继续');
setLoading(false);
return;
}
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('重新生成角色...');
const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre;
await wizardStreamApi.generateCharactersStream(
{
project_id: projectId,
count: generationData.character_count,
world_context: {
time_period: worldBuildingResult.time_period || '',
location: worldBuildingResult.location || '',
atmosphere: worldBuildingResult.atmosphere || '',
rules: worldBuildingResult.rules || '',
},
theme: generationData.theme,
genre: genreString,
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色重新生成完成');
}
}
);
await continueFromOutline();
};
// 从大纲步骤继续
const retryFromOutline = async () => {
if (!generationData || !projectId) {
message.warning('缺少必要数据,无法从大纲步骤继续');
setLoading(false);
return;
}
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('重新生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: projectId,
chapter_count: generationData.chapter_count,
narrative_perspective: generationData.narrative_perspective,
target_words: generationData.target_words,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲重新生成完成');
}
}
);
setProgress(100);
setProgressMessage('项目创建完成!正在跳转...');
message.success('项目创建成功!正在进入项目...');
setLoading(false);
// 调用完成回调
if (projectId) {
onComplete(projectId);
// 延迟1秒后自动跳转到项目详情页
setTimeout(() => {
navigate(`/project/${projectId}`);
}, 1000);
}
};
// 从角色步骤开始的完整流程
const continueFromCharacters = async (worldResult: any) => {
if (!generationData || !worldResult?.project_id) return;
const genreString = Array.isArray(generationData.genre) ? generationData.genre.join('、') : generationData.genre;
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: worldResult.project_id,
count: generationData.character_count,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: generationData.theme,
genre: genreString,
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
await continueFromOutline();
};
// 从大纲步骤开始的完整流程
const continueFromOutline = async () => {
if (!generationData || !projectId) return;
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: projectId,
chapter_count: generationData.chapter_count,
narrative_perspective: generationData.narrative_perspective,
target_words: generationData.target_words,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
setProgress(100);
setProgressMessage('项目创建完成!正在跳转...');
message.success('项目创建成功!正在进入项目...');
setLoading(false);
// 调用完成回调
if (projectId) {
onComplete(projectId);
// 延迟1秒后自动跳转到项目详情页
setTimeout(() => {
navigate(`/project/${projectId}`);
}, 1000);
}
};
// 获取步骤状态图标和颜色
const getStepStatus = (step: GenerationStep) => {
if (step === 'completed') return { icon: <CheckCircleOutlined />, color: '#52c41a' };
if (step === 'processing') return { icon: <LoadingOutlined />, color: '#1890ff' };
if (step === 'error') return { icon: '✗', color: '#ff4d4f' };
return { icon: '○', color: '#d9d9d9' };
};
const hasError = generationSteps.worldBuilding === 'error' ||
generationSteps.characters === 'error' ||
generationSteps.outline === 'error';
// 渲染生成进度页面
const renderGenerating = () => (
<div style={{
textAlign: 'center',
padding: isMobile ? '32px 16px' : '40px 20px',
maxWidth: '100%',
overflow: 'hidden'
}}>
<Title
level={isMobile ? 4 : 3}
style={{
marginBottom: 32,
color: '#fff',
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}
>
{config.title}
</Title>
<Card style={{ marginBottom: 24, maxWidth: '100%' }}>
<Progress
percent={progress}
status={hasError ? 'exception' : (progress === 100 ? 'success' : 'active')}
strokeColor={{
'0%': '#667eea',
'100%': '#764ba2',
}}
style={{ marginBottom: 24 }}
/>
<Paragraph
style={{
fontSize: isMobile ? 14 : 16,
marginBottom: 32,
color: hasError ? '#ff4d4f' : '#666',
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}
>
{progressMessage}
</Paragraph>
{errorDetails && (
<Card
size="small"
style={{
marginBottom: 24,
background: '#fff2f0',
borderColor: '#ffccc7',
textAlign: 'left',
maxWidth: '100%',
overflow: 'hidden'
}}
>
<Text strong style={{ color: '#ff4d4f' }}></Text>
<br />
<Text
style={{
color: '#666',
fontSize: 14,
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word',
display: 'block'
}}
>
{errorDetails}
</Text>
</Card>
)}
<Space
direction="vertical"
size={16}
style={{
width: '100%',
maxWidth: isMobile ? '100%' : 400,
margin: '0 auto'
}}
>
{[
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
{ key: 'characters', label: '生成角色', step: generationSteps.characters },
{ key: 'outline', label: '生成大纲', step: generationSteps.outline },
].map(({ key, label, step }) => {
const status = getStepStatus(step);
return (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: isMobile ? '10px 12px' : '12px 20px',
background: step === 'processing' ? '#f0f5ff' : (step === 'error' ? '#fff2f0' : '#fafafa'),
borderRadius: 8,
border: `1px solid ${step === 'processing' ? '#d6e4ff' : (step === 'error' ? '#ffccc7' : '#f0f0f0')}`,
gap: '8px',
maxWidth: '100%',
overflow: 'hidden'
}}
>
<Text
style={{
fontSize: isMobile ? 14 : 16,
fontWeight: step === 'processing' ? 600 : 400,
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word',
flex: 1,
textAlign: 'left'
}}
>
{label}
</Text>
<span
style={{
fontSize: 20,
color: status.color,
flexShrink: 0
}}
>
{status.icon}
</span>
</div>
);
})}
</Space>
</Card>
<Paragraph
type="secondary"
style={{
color: '#fff',
opacity: 0.9,
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word',
fontSize: isMobile ? 14 : 16
}}
>
{hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'}
</Paragraph>
{hasError && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
size="large"
onClick={handleSmartRetry}
loading={loading}
disabled={loading}
>
</Button>
</Space>
)}
</div>
);
return renderGenerating();
};
@@ -9,7 +9,6 @@ import {
Space,
Alert,
Divider,
Progress,
Tag,
message,
Collapse,
@@ -22,6 +21,7 @@ import {
CloseCircleOutlined
} from '@ant-design/icons';
import { ssePost } from '../utils/sseClient';
import { SSEProgressModal } from './SSEProgressModal';
const { TextArea } = Input;
const { Panel } = Collapse;
@@ -242,22 +242,6 @@ const ChapterRegenerationModal: React.FC<ChapterRegenerationModalProps> = ({
)
}
>
{status === 'generating' && (
<Alert
message="正在重新生成中..."
description={
<div>
<Progress percent={progress} status="active" />
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
{wordCount}
</div>
</div>
}
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
)}
{status === 'success' && (
<Alert
@@ -395,6 +379,13 @@ const ChapterRegenerationModal: React.FC<ChapterRegenerationModalProps> = ({
</Collapse>
</Form>
{/* 使用统一的进度显示组件 */}
<SSEProgressModal
visible={status === 'generating'}
progress={progress}
message={`正在重新生成中... (已生成 ${wordCount} 字)`}
title="重新生成章节"
/>
</Modal>
);
};
@@ -0,0 +1,127 @@
import React from 'react';
import { Modal, Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
interface SSEProgressModalProps {
visible: boolean;
progress: number;
message: string;
title?: string;
showPercentage?: boolean;
showIcon?: boolean;
}
/**
* 统一的SSE进度显示Modal组件
* 用于在Modal中显示AI生成进度,样式与SSELoadingOverlay保持一致
*/
export const SSEProgressModal: React.FC<SSEProgressModalProps> = ({
visible,
progress,
message,
title = 'AI生成中...',
showPercentage = true,
showIcon = true,
}) => {
if (!visible) return null;
return (
<Modal
title={null}
open={visible}
footer={null}
closable={false}
centered
width={500}
maskClosable={false}
keyboard={false}
styles={{
body: {
padding: '40px 40px 32px',
}
}}
>
<div>
{/* 标题和图标 */}
{showIcon && (
<div style={{
textAlign: 'center',
marginBottom: 24
}}>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{
fontSize: 20,
fontWeight: 'bold',
marginTop: 16,
color: '#262626'
}}>
{title}
</div>
</div>
)}
{/* 进度条 */}
<div style={{
marginBottom: showPercentage ? 16 : 24
}}>
<div style={{
height: 12,
background: '#f0f0f0',
borderRadius: 6,
overflow: 'hidden',
marginBottom: showPercentage ? 12 : 0
}}>
<div style={{
height: '100%',
background: progress === 100
? 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
width: `${progress}%`,
transition: 'all 0.3s ease',
borderRadius: 6,
boxShadow: progress > 0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none'
}} />
</div>
{/* 进度百分比 */}
{showPercentage && (
<div style={{
textAlign: 'center',
fontSize: 32,
fontWeight: 'bold',
color: progress === 100 ? '#52c41a' : '#1890ff',
marginBottom: 8
}}>
{progress}%
</div>
)}
</div>
{/* 状态消息 */}
<div style={{
textAlign: 'center',
fontSize: 16,
color: '#595959',
minHeight: 24,
padding: '0 20px',
marginBottom: 16
}}>
{message || '准备生成...'}
</div>
{/* 提示文字 */}
<div style={{
textAlign: 'center',
fontSize: 13,
color: '#8c8c8c'
}}>
</div>
</div>
</Modal>
);
};
export default SSEProgressModal;
+171 -57
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
@@ -7,6 +7,7 @@ import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel';
const { TextArea } = Input;
@@ -748,43 +749,129 @@ export default function Chapters() {
Modal.info({
title: (
<Space>
<Space style={{ flexWrap: 'wrap' }}>
<InfoCircleOutlined style={{ color: '#1890ff' }} />
<span>{chapter.chapter_number}</span>
<span style={{ wordBreak: 'break-word' }}>{chapter.chapter_number}</span>
</Space>
),
width: 800,
width: isMobile ? '95%' : 800,
centered: true,
style: isMobile ? {
top: 20,
maxWidth: 'calc(100vw - 16px)',
margin: '0 8px'
} : undefined,
styles: {
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
},
content: (
<div style={{ marginTop: 16 }}>
<Descriptions column={1} size="small" bordered>
<Descriptions
column={1}
size="small"
bordered
labelStyle={{
whiteSpace: 'normal',
wordBreak: 'break-word',
width: isMobile ? '80px' : '100px'
}}
contentStyle={{
whiteSpace: 'normal',
wordBreak: 'break-word',
overflowWrap: 'break-word'
}}
>
<Descriptions.Item label="章节标题">
<strong>{chapter.title}</strong>
<strong style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{chapter.title}
</strong>
</Descriptions.Item>
<Descriptions.Item label="情感基调">
<Tag color="blue">{planData.emotional_tone}</Tag>
<Tag
color="blue"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{planData.emotional_tone}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="冲突类型">
<Tag color="orange">{planData.conflict_type}</Tag>
<Tag
color="orange"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{planData.conflict_type}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="预估字数">
<Tag color="green">{planData.estimated_words}</Tag>
</Descriptions.Item>
<Descriptions.Item label="叙事目标">
{planData.narrative_goal}
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{planData.narrative_goal}
</span>
</Descriptions.Item>
<Descriptions.Item label="关键事件">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.key_events.map((event, idx) => (
<div key={idx} style={{ padding: '4px 0' }}>
<Tag color="purple">{idx + 1}</Tag> {event}
<div
key={idx}
style={{
padding: '4px 0',
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}
>
<Tag color="purple" style={{ flexShrink: 0 }}>{idx + 1}</Tag>{' '}
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{event}
</span>
</div>
))}
</Space>
</Descriptions.Item>
<Descriptions.Item label="涉及角色">
<Space wrap>
<Space wrap style={{ maxWidth: '100%' }}>
{planData.character_focus.map((char, idx) => (
<Tag key={idx} color="cyan">{char}</Tag>
<Tag
key={idx}
color="cyan"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5'
}}
>
{char}
</Tag>
))}
</Space>
</Descriptions.Item>
@@ -792,20 +879,68 @@ export default function Chapters() {
<Descriptions.Item label="场景规划">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.scenes.map((scene, idx) => (
<Card key={idx} size="small" style={{ backgroundColor: '#fafafa' }}>
<div style={{ marginBottom: 4 }}>
<strong>📍 </strong>{scene.location}
<Card
key={idx}
size="small"
style={{
backgroundColor: '#fafafa',
maxWidth: '100%',
overflow: 'hidden'
}}
>
<div style={{
marginBottom: 4,
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong>📍 </strong>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{scene.location}
</span>
</div>
<div style={{ marginBottom: 4 }}>
<strong>👥 </strong>
<Space size="small" wrap style={{ marginLeft: 8 }}>
<Space
size="small"
wrap
style={{
marginLeft: isMobile ? 0 : 8,
marginTop: isMobile ? 4 : 0,
display: isMobile ? 'flex' : 'inline-flex'
}}
>
{scene.characters.map((char, charIdx) => (
<Tag key={charIdx}>{char}</Tag>
<Tag
key={charIdx}
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto'
}}
>
{char}
</Tag>
))}
</Space>
</div>
<div>
<strong>🎯 </strong>{scene.purpose}
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong>🎯 </strong>
<span style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{scene.purpose}
</span>
</div>
</Card>
))}
@@ -1590,42 +1725,6 @@ export default function Chapters() {
</Form>
) : (
<div>
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span></span>
<span>
<strong style={{ color: '#1890ff', fontSize: 18 }}>
{batchProgress?.completed || 0} / {batchProgress?.total || 0}
</strong>
</span>
</div>
<Progress
percent={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
status={batchProgress?.status === 'failed' ? 'exception' : 'active'}
strokeColor={{
'0%': '#722ed1',
'100%': '#1890ff',
}}
/>
</div>
{batchProgress?.current_chapter_number && (
<Alert
message={`正在生成第 ${batchProgress.current_chapter_number} 章...`}
type="info"
showIcon
icon={<SyncOutlined spin />}
style={{ marginBottom: 16 }}
/>
)}
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
<div style={{ marginBottom: 16, color: '#666', fontSize: 13 }}>
{batchProgress.estimated_time_minutes}
</div>
)}
<Alert
message="温馨提示"
description={
@@ -1633,9 +1732,12 @@ export default function Chapters() {
<li></li>
<li></li>
<li>"取消任务"</li>
{batchProgress?.estimated_time_minutes && batchProgress.completed === 0 && (
<li> {batchProgress.estimated_time_minutes} </li>
)}
</ul>
}
type="warning"
type="info"
showIcon
style={{ marginBottom: 16 }}
/>
@@ -1669,6 +1771,18 @@ export default function Chapters() {
message={singleChapterProgressMessage}
/>
{/* 批量生成进度显示 - 使用统一的进度组件 */}
<SSEProgressModal
visible={batchGenerating}
progress={batchProgress ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}
message={
batchProgress?.current_chapter_number
? `正在生成第 ${batchProgress.current_chapter_number} 章... (${batchProgress.completed}/${batchProgress.total})`
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
}
title="批量生成章节"
/>
<FloatButton
icon={<BookOutlined />}
type="primary"
+41 -612
View File
@@ -1,9 +1,9 @@
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Input, Button, Space, Typography, message, Spin, Progress } from 'antd';
import { SendOutlined, ArrowLeftOutlined, CheckCircleOutlined, LoadingOutlined, RocketOutlined } from '@ant-design/icons';
import { inspirationApi, wizardStreamApi } from '../services/api';
import type { ApiError } from '../types';
import { Card, Input, Button, Space, Typography, message, Spin } from 'antd';
import { SendOutlined, ArrowLeftOutlined } from '@ant-design/icons';
import { inspirationApi } from '../services/api';
import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator';
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
@@ -43,26 +43,8 @@ const Inspiration: React.FC = () => {
// 保存用户的原始想法,用于保持上下文一致性
const [initialIdea, setInitialIdea] = useState<string>('');
// 项目生成状态
const [projectId, setProjectId] = useState<string>('');
const [projectTitle, setProjectTitle] = useState<string>('');
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [errorDetails, setErrorDetails] = useState<string>(''); // 新增:错误详情
const [generationSteps, setGenerationSteps] = useState<{
worldBuilding: 'pending' | 'processing' | 'completed' | 'error';
characters: 'pending' | 'processing' | 'completed' | 'error';
outline: 'pending' | 'processing' | 'completed' | 'error';
}>({
worldBuilding: 'pending',
characters: 'pending',
outline: 'pending'
});
// 新增:保存生成数据,用于重试
const [generationData, setGenerationData] = useState<WizardData | null>(null);
// 保存世界观生成结果,用于后续步骤
const [worldBuildingResult, setWorldBuildingResult] = useState<any>(null);
// 生成配置
const [generationConfig, setGenerationConfig] = useState<GenerationConfig | null>(null);
// 滚动容器引用
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -74,9 +56,8 @@ const Inspiration: React.FC = () => {
context: Partial<WizardData>;
} | null>(null);
// 自动滚动到底部 - 使用更丝滑的方式
// 自动滚动到底部
const scrollToBottom = () => {
// 使用 setTimeout 确保 DOM 已更新
setTimeout(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTo({
@@ -108,7 +89,6 @@ const Inspiration: React.FC = () => {
return;
}
// 移除失败消息,添加成功的AI消息
setMessages(prev => {
const newMessages = [...prev];
if (newMessages[newMessages.length - 1].type === 'ai' &&
@@ -156,7 +136,6 @@ const Inspiration: React.FC = () => {
try {
if (currentStep === 'idea') {
// 保存用户的原始想法
setInitialIdea(userInput);
const requestData = {
@@ -169,7 +148,6 @@ const Inspiration: React.FC = () => {
const response = await inspirationApi.generateOptions(requestData);
// 前端格式校验:检查是否有错误或选项数量不足
if (response.error || !response.options || response.options.length < 3) {
const errorMessage: Message = {
type: 'ai',
@@ -222,7 +200,6 @@ const Inspiration: React.FC = () => {
}
if (currentStep === 'perspective') {
// 叙事视角是单选
const userMessage: Message = {
type: 'user',
content: option,
@@ -232,7 +209,6 @@ const Inspiration: React.FC = () => {
const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData;
setWizardData(updatedData);
// 显示预览和确认选项
const summary = `
太棒了!你的小说设定已完成,请确认:
@@ -270,7 +246,19 @@ const Inspiration: React.FC = () => {
setMessages(prev => [...prev, aiMessage]);
// 开始生成项目
await handleAutoGenerate(wizardData as WizardData);
const data = wizardData as WizardData;
const config: GenerationConfig = {
title: data.title,
description: data.description,
theme: data.theme,
genre: data.genre,
narrative_perspective: data.narrative_perspective,
target_words: 100000,
chapter_count: 3,
character_count: 5,
};
setGenerationConfig(config);
setCurrentStep('generating');
return;
} else if (option === '🔄 重新开始') {
handleRestart();
@@ -332,439 +320,6 @@ const Inspiration: React.FC = () => {
}
};
// 自动化生成项目流程
const handleAutoGenerate = async (data: WizardData) => {
try {
setLoading(true);
setCurrentStep('generating');
setProjectTitle(data.title);
setProgress(0);
setProgressMessage('开始创建项目...');
setErrorDetails(''); // 清空错误详情
setGenerationData(data); // 保存数据用于重试
// 步骤1: 生成世界观并创建项目
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' }));
setProgressMessage('正在生成世界观...');
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: data.title,
description: data.description,
theme: data.theme,
genre: data.genre.join('、'),
narrative_perspective: data.narrative_perspective,
target_words: 100000,
chapter_count: 5,
character_count: 5,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
setProjectId(result.project_id);
setWorldBuildingResult(result); // 保存世界观结果
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
console.error('世界观生成失败:', error);
setErrorDetails(`世界观生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
setLoading(false); // 确保错误时解除加载状态
throw new Error(error);
},
onComplete: () => {
console.log('世界观生成完成');
}
}
);
if (!worldResult?.project_id) {
throw new Error('项目创建失败:未获取到项目ID');
}
const createdProjectId = worldResult.project_id;
setProjectId(createdProjectId);
setWorldBuildingResult(worldResult); // 保存世界观结果
// 步骤2: 生成角色
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: createdProjectId,
count: 5,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: data.theme,
genre: data.genre.join('、'),
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false); // 确保错误时解除加载状态
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
// 步骤3: 生成大纲
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: createdProjectId,
chapter_count: 3,
narrative_perspective: data.narrative_perspective,
target_words: 100000,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false); // 确保错误时解除加载状态
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
// 全部完成
setProgress(100);
setProgressMessage('项目创建完成!');
setCurrentStep('complete');
message.success('项目创建成功!');
} catch (error) {
const apiError = error as ApiError;
const errorMsg = apiError.response?.data?.detail || apiError.message || '未知错误';
console.error('创建项目失败:', errorMsg);
setErrorDetails(errorMsg);
message.error('创建项目失败:' + errorMsg);
// 不重置步骤,保持在generating状态以显示重试按钮
setLoading(false); // 确保在错误时也设置loading为false
}
};
// 智能重试:从失败的步骤继续生成
const handleSmartRetry = async () => {
if (!generationData) {
message.warning('缺少生成数据');
return;
}
setLoading(true);
setErrorDetails('');
try {
// 判断从哪个步骤开始重试
if (generationSteps.worldBuilding === 'error') {
// 世界观失败,从世界观开始重新生成
message.info('从世界观步骤开始重新生成...');
await retryFromWorldBuilding();
} else if (generationSteps.characters === 'error') {
// 角色失败,从角色开始生成
message.info('从角色步骤继续生成...');
await retryFromCharacters();
} else if (generationSteps.outline === 'error') {
// 大纲失败,从大纲开始生成
message.info('从大纲步骤继续生成...');
await retryFromOutline();
}
} catch (error: any) {
console.error('智能重试失败:', error);
message.error('重试失败:' + (error.message || '未知错误'));
setLoading(false);
}
};
// 从世界观步骤重新开始
const retryFromWorldBuilding = async () => {
if (!generationData) return;
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' }));
setProgressMessage('重新生成世界观...');
try {
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: generationData.title,
description: generationData.description,
theme: generationData.theme,
genre: generationData.genre.join('、'),
narrative_perspective: generationData.narrative_perspective,
target_words: 100000,
chapter_count: 5,
character_count: 5,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
setProjectId(result.project_id);
setWorldBuildingResult(result);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
console.error('世界观生成失败:', error);
setErrorDetails(`世界观生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('世界观重新生成完成');
}
}
);
if (!worldResult?.project_id) {
throw new Error('项目创建失败:未获取到项目ID');
}
// 继续生成角色和大纲
await continueFromCharacters(worldResult);
} catch (error: any) {
throw error;
}
};
// 从角色步骤继续
const retryFromCharacters = async () => {
if (!generationData || !projectId || !worldBuildingResult) {
message.warning('缺少必要数据,无法从角色步骤继续');
setLoading(false);
return;
}
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('重新生成角色...');
try {
await wizardStreamApi.generateCharactersStream(
{
project_id: projectId,
count: 5,
world_context: {
time_period: worldBuildingResult.time_period || '',
location: worldBuildingResult.location || '',
atmosphere: worldBuildingResult.atmosphere || '',
rules: worldBuildingResult.rules || '',
},
theme: generationData.theme,
genre: generationData.genre.join('、'),
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色重新生成完成');
}
}
);
// 继续生成大纲
await continueFromOutline();
} catch (error: any) {
throw error;
}
};
// 从大纲步骤继续
const retryFromOutline = async () => {
if (!generationData || !projectId) {
message.warning('缺少必要数据,无法从大纲步骤继续');
setLoading(false);
return;
}
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('重新生成大纲...');
try {
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: projectId,
chapter_count: 5,
narrative_perspective: generationData.narrative_perspective,
target_words: 100000,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲重新生成完成');
}
}
);
// 全部完成
setProgress(100);
setProgressMessage('项目创建完成!');
setCurrentStep('complete');
message.success('项目创建成功!');
setLoading(false);
} catch (error: any) {
throw error;
}
};
// 从角色步骤开始的完整流程(世界观成功后调用)
const continueFromCharacters = async (worldResult: any) => {
if (!generationData || !worldResult?.project_id) return;
try {
// 生成角色
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: worldResult.project_id,
count: 5,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: generationData.theme,
genre: generationData.genre.join('、'),
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: (result) => {
console.log(`成功生成${result.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
console.error('角色生成失败:', error);
setErrorDetails(`角色生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
// 生成大纲
await continueFromOutline();
} catch (error: any) {
console.error('继续生成失败:', error);
throw error;
}
};
// 从大纲步骤开始的完整流程(角色成功后调用)
const continueFromOutline = async () => {
if (!generationData || !projectId) return;
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: projectId,
chapter_count: 5,
narrative_perspective: generationData.narrative_perspective,
target_words: 100000,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3));
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
console.error('大纲生成失败:', error);
setErrorDetails(`大纲生成失败: ${error}`);
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
setLoading(false);
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
// 全部完成
setProgress(100);
setProgressMessage('项目创建完成!');
setCurrentStep('complete');
message.success('项目创建成功!');
setLoading(false);
};
const handleConfirmGenres = async () => {
if (selectedOptions.length === 0) {
message.warning('请至少选择一个类型');
@@ -781,7 +336,6 @@ const Inspiration: React.FC = () => {
setWizardData(updatedData);
setSelectedOptions([]);
// 进入叙事视角选择
setLoading(true);
try {
const aiMessage: Message = {
@@ -810,7 +364,6 @@ const Inspiration: React.FC = () => {
};
const response = await inspirationApi.generateOptions(requestData);
// 前端格式校验
if (response.error || !response.options || response.options.length < 3) {
const errorMessage: Message = {
type: 'ai',
@@ -844,7 +397,6 @@ const Inspiration: React.FC = () => {
};
const response = await inspirationApi.generateOptions(requestData);
// 前端格式校验
if (response.error || !response.options || response.options.length < 3) {
const errorMessage: Message = {
type: 'ai',
@@ -879,7 +431,6 @@ const Inspiration: React.FC = () => {
};
const response = await inspirationApi.generateOptions(requestData);
// 前端格式校验
if (response.error || !response.options || response.options.length < 3) {
const errorMessage: Message = {
type: 'ai',
@@ -915,7 +466,7 @@ const Inspiration: React.FC = () => {
}
]);
setWizardData({});
setInitialIdea(''); // 重置原始想法
setInitialIdea('');
setSelectedOptions([]);
setLoading(false);
};
@@ -924,145 +475,22 @@ const Inspiration: React.FC = () => {
navigate('/projects');
};
// 渲染生成进度页面
const renderGenerating = () => {
const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => {
if (step === 'completed') return { icon: <CheckCircleOutlined />, color: '#52c41a' };
if (step === 'processing') return { icon: <LoadingOutlined />, color: '#1890ff' };
if (step === 'error') return { icon: '✗', color: '#ff4d4f' };
return { icon: '○', color: '#d9d9d9' };
};
const hasError = generationSteps.worldBuilding === 'error' ||
generationSteps.characters === 'error' ||
generationSteps.outline === 'error';
return (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<Title level={3} style={{ marginBottom: 32, color: '#fff' }}>
{projectTitle}
</Title>
<Card style={{ marginBottom: 24 }}>
<Progress
percent={progress}
status={hasError ? 'exception' : (progress === 100 ? 'success' : 'active')}
strokeColor={{
'0%': '#667eea',
'100%': '#764ba2',
}}
style={{ marginBottom: 24 }}
/>
<Paragraph style={{ fontSize: 16, marginBottom: 32, color: hasError ? '#ff4d4f' : '#666' }}>
{progressMessage}
</Paragraph>
{/* 错误详情显示 */}
{errorDetails && (
<Card
size="small"
style={{
marginBottom: 24,
background: '#fff2f0',
borderColor: '#ffccc7',
textAlign: 'left'
}}
>
<Text strong style={{ color: '#ff4d4f' }}></Text>
<br />
<Text style={{ color: '#666', fontSize: 14 }}>{errorDetails}</Text>
</Card>
)}
<Space direction="vertical" size={16} style={{ width: '100%', maxWidth: 400, margin: '0 auto' }}>
{[
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
{ key: 'characters', label: '生成角色', step: generationSteps.characters },
{ key: 'outline', label: '生成大纲', step: generationSteps.outline },
].map(({ key, label, step }) => {
const status = getStepStatus(step);
return (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
background: step === 'processing' ? '#f0f5ff' : (step === 'error' ? '#fff2f0' : '#fafafa'),
borderRadius: 8,
border: `1px solid ${step === 'processing' ? '#d6e4ff' : (step === 'error' ? '#ffccc7' : '#f0f0f0')}`,
}}
>
<Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}>
{label}
</Text>
<span style={{ fontSize: 20, color: status.color }}>
{status.icon}
</span>
</div>
);
})}
</Space>
</Card>
<Paragraph type="secondary" style={{ color: '#fff', opacity: 0.9 }}>
{hasError ? '生成过程中出现错误,请点击重试按钮重新生成' : '请耐心等待,AI正在为您精心创作...'}
</Paragraph>
{hasError && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
size="large"
onClick={handleSmartRetry}
loading={loading}
disabled={loading}
>
</Button>
</Space>
)}
</div>
);
// 生成完成回调
const handleComplete = (projectId: string) => {
console.log('灵感模式项目创建完成:', projectId);
setCurrentStep('complete');
};
// 渲染完成页
const renderComplete = () => (
<div style={{ textAlign: 'center', padding: '40px 20px' }}>
<Card>
<div style={{ fontSize: 72, color: '#52c41a', marginBottom: 24 }}>
</div>
<Title level={2} style={{ color: '#52c41a', marginBottom: 16 }}>
</Title>
<Paragraph style={{ fontSize: 16, marginTop: 24, marginBottom: 48 }}>
{projectTitle}
</Paragraph>
<Space size={16}>
<Button size="large" onClick={() => navigate('/')}>
</Button>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate(`/project/${projectId}`)}
>
</Button>
</Space>
</Card>
</div>
);
// 返回对话界
const handleBackToChat = () => {
setCurrentStep('idea');
setGenerationConfig(null);
handleRestart();
};
// 渲染对话界面
const renderChat = () => (
<>
{/* 对话区域 */}
<Card
ref={chatContainerRef}
style={{
@@ -1106,7 +534,6 @@ const Inspiration: React.FC = () => {
{msg.content}
</Paragraph>
{/* 选项卡片 */}
{msg.options && msg.options.length > 0 && (
<Space
direction="vertical"
@@ -1145,7 +572,6 @@ const Inspiration: React.FC = () => {
</Card>
))}
{/* 多选确认按钮 */}
{msg.isMultiSelect && (
<Button
type="primary"
@@ -1172,12 +598,10 @@ const Inspiration: React.FC = () => {
</div>
)}
{/* 滚动锚点 */}
<div ref={messagesEndRef} />
</Space>
</Card>
{/* 输入区域 */}
<Card
style={{ boxShadow: '0 4px 12px rgba(0,0,0,0.1)' }}
styles={{ body: { padding: 12 } }}
@@ -1261,7 +685,6 @@ const Inspiration: React.FC = () => {
`}
</style>
<div style={{ maxWidth: 800, margin: '0 auto' }}>
{/* 头部 */}
<div style={{
marginBottom: window.innerWidth <= 768 ? 12 : 24,
position: 'relative'
@@ -1308,16 +731,22 @@ const Inspiration: React.FC = () => {
</div>
</div>
{/* 根据当前步骤渲染不同内容 */}
{(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' ||
currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' ||
currentStep === 'confirm') && renderChat()}
{currentStep === 'generating' && renderGenerating()}
{currentStep === 'complete' && renderComplete()}
{(currentStep === 'generating' || currentStep === 'complete') && generationConfig && (
<AIProjectGenerator
config={generationConfig}
storagePrefix="inspiration"
onComplete={handleComplete}
onBack={handleBackToChat}
isMobile={window.innerWidth <= 768}
/>
)}
</div>
</div>
);
};
export default Inspiration;
+233 -56
View File
@@ -1,10 +1,11 @@
import { useState, useEffect } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress, InputNumber, Tooltip, Tabs } from 'antd';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tooltip, Tabs } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useOutlineSync } from '../store/hooks';
import { cardStyles } from '../components/CardStyles';
import { SSEPostClient } from '../utils/sseClient';
import { SSEProgressModal } from '../components/SSEProgressModal';
import { outlineApi, chapterApi } from '../services/api';
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse } from '../types';
@@ -20,6 +21,9 @@ export default function Outline() {
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const [isExpanding, setIsExpanding] = useState(false);
// ✅ 新增:记录每个大纲的展开状态
const [outlineExpandStatus, setOutlineExpandStatus] = useState<Record<string, boolean>>({});
// 缓存批量展开的规划数据,避免重复AI调用
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(null);
@@ -58,6 +62,27 @@ export default function Outline() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
// ✅ 新增:加载所有大纲的展开状态
useEffect(() => {
const loadExpandStatus = async () => {
if (outlines.length === 0) return;
const statusMap: Record<string, boolean> = {};
for (const outline of outlines) {
try {
const chapters = await outlineApi.getOutlineChapters(outline.id);
statusMap[outline.id] = chapters.has_chapters;
} catch (error) {
console.error(`加载大纲 ${outline.id} 状态失败:`, error);
statusMap[outline.id] = false;
}
}
setOutlineExpandStatus(statusMap);
};
loadExpandStatus();
}, [outlines]);
// 移除事件监听,避免无限循环
// Hook 内部已经更新了 store,不需要再次刷新
@@ -207,7 +232,7 @@ export default function Outline() {
title: hasOutlines ? (
<Space>
<span>AI生成/</span>
<Tag color="blue"> {outlines.length} </Tag>
<Tag color="blue"> {outlines.length} </Tag>
</Space>
) : 'AI生成大纲',
width: 700,
@@ -351,6 +376,59 @@ export default function Outline() {
try {
setIsExpanding(true);
// ✅ 新增:检查是否需要按顺序展开
const currentOutline = sortedOutlines.find(o => o.id === outlineId);
if (currentOutline) {
// 获取所有在当前大纲之前的大纲
const previousOutlines = sortedOutlines.filter(
o => o.order_index < currentOutline.order_index
);
// 检查前面的大纲是否都已展开
for (const prevOutline of previousOutlines) {
try {
const prevChapters = await outlineApi.getOutlineChapters(prevOutline.id);
if (!prevChapters.has_chapters) {
// 如果前面有未展开的大纲,显示提示并阻止操作
setIsExpanding(false);
Modal.warning({
title: '请按顺序展开大纲',
width: 600,
centered: true,
content: (
<div>
<p style={{ marginBottom: 12 }}>
</p>
<div style={{
padding: 12,
background: '#fff7e6',
borderRadius: 4,
border: '1px solid #ffd591'
}}>
<div style={{ fontWeight: 500, marginBottom: 8, color: '#fa8c16' }}>
</div>
<div style={{ color: '#666' }}>
{prevOutline.order_index}{prevOutline.title}
</div>
</div>
<p style={{ marginTop: 12, color: '#666', fontSize: 13 }}>
💡 使
</p>
</div>
),
okText: '我知道了'
});
return;
}
} catch (error) {
console.error(`检查大纲 ${prevOutline.id} 失败:`, error);
// 如果检查失败,继续处理(避免因网络问题阻塞)
}
}
}
// 第一步:检查是否已有展开的章节
const existingChapters = await outlineApi.getOutlineChapters(outlineId);
@@ -521,16 +599,27 @@ export default function Outline() {
) => {
const modal = Modal.info({
title: (
<Space>
<Space style={{ flexWrap: 'wrap' }}>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<span></span>
</Space>
),
width: 900,
width: isMobile ? '95%' : 900,
centered: true,
okText: '关闭',
style: isMobile ? {
top: 20,
maxWidth: 'calc(100vw - 16px)',
margin: '0 8px'
} : undefined,
styles: {
body: {
maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)',
overflowY: 'auto'
}
},
footer: (_, { OkBtn }) => (
<Space>
<Space wrap style={{ width: '100%', justifyContent: isMobile ? 'center' : 'flex-end' }}>
<Button
danger
icon={<DeleteOutlined />}
@@ -556,6 +645,8 @@ export default function Outline() {
onOk: () => handleDeleteExpandedChapters(outlineTitle, data.chapters || []),
});
}}
block={isMobile}
size={isMobile ? 'middle' : undefined}
>
({data.chapter_count})
</Button>
@@ -565,9 +656,22 @@ export default function Outline() {
content: (
<div>
<div style={{ marginBottom: 16 }}>
<Tag color="blue">: {outlineTitle}</Tag>
<Tag color="green">: {data.chapter_count}</Tag>
<Tag color="orange"></Tag>
<Space wrap style={{ maxWidth: '100%' }}>
<Tag
color="blue"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
: {outlineTitle}
</Tag>
<Tag color="green">: {data.chapter_count}</Tag>
<Tag color="orange"></Tag>
</Space>
</div>
<Tabs
defaultActiveKey="0"
@@ -575,41 +679,104 @@ export default function Outline() {
items={data.expansion_plans?.map((plan, idx) => ({
key: idx.toString(),
label: (
<Space size="small">
<span style={{ fontWeight: 500 }}>{plan.sub_index}. {plan.title}</span>
<Space size="small" style={{ maxWidth: isMobile ? '150px' : 'none' }}>
<span
style={{
fontWeight: 500,
whiteSpace: isMobile ? 'normal' : 'nowrap',
wordBreak: isMobile ? 'break-word' : 'normal',
fontSize: isMobile ? 12 : 14
}}
>
{plan.sub_index}. {plan.title}
</span>
</Space>
),
children: (
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '8px 0' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="基本信息">
<Space wrap>
<Tag color="blue">{plan.emotional_tone}</Tag>
<Tag color="orange">{plan.conflict_type}</Tag>
<Space wrap style={{ maxWidth: '100%' }}>
<Tag
color="blue"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{plan.emotional_tone}
</Tag>
<Tag
color="orange"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5',
padding: '4px 8px'
}}
>
{plan.conflict_type}
</Tag>
<Tag color="green">{plan.estimated_words}</Tag>
</Space>
</Card>
<Card size="small" title="情节概要">
{plan.plot_summary}
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{plan.plot_summary}
</div>
</Card>
<Card size="small" title="叙事目标">
{plan.narrative_goal}
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
{plan.narrative_goal}
</div>
</Card>
<Card size="small" title="关键事件">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{plan.key_events.map((event, eventIdx) => (
<div key={eventIdx}> {event}</div>
<div
key={eventIdx}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}
>
{event}
</div>
))}
</Space>
</Card>
<Card size="small" title="涉及角色">
<Space wrap>
<Space wrap style={{ maxWidth: '100%' }}>
{plan.character_focus.map((char, charIdx) => (
<Tag key={charIdx} color="purple">{char}</Tag>
<Tag
key={charIdx}
color="purple"
style={{
whiteSpace: 'normal',
wordBreak: 'break-word',
height: 'auto',
lineHeight: '1.5'
}}
>
{char}
</Tag>
))}
</Space>
</Card>
@@ -618,10 +785,36 @@ export default function Outline() {
<Card size="small" title="场景">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{plan.scenes.map((scene, sceneIdx) => (
<Card key={sceneIdx} size="small" style={{ backgroundColor: '#fafafa' }}>
<div><strong></strong>{scene.location}</div>
<div><strong></strong>{scene.characters.join('、')}</div>
<div><strong></strong>{scene.purpose}</div>
<Card
key={sceneIdx}
size="small"
style={{
backgroundColor: '#fafafa',
maxWidth: '100%',
overflow: 'hidden'
}}
>
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong></strong>{scene.location}
</div>
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong></strong>{scene.characters.join('、')}
</div>
<div style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
overflowWrap: 'break-word'
}}>
<strong></strong>{scene.purpose}
</div>
</Card>
))}
</Space>
@@ -1160,35 +1353,13 @@ export default function Outline() {
{renderBatchPreviewContent()}
</Modal>
{/* SSE进度Modal */}
<Modal
title="生成大纲中"
open={sseModalVisible}
footer={null}
closable={false}
centered
width={500}
>
<div style={{ padding: '20px 0' }}>
<Progress
percent={sseProgress}
status={sseProgress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': '#108ee9',
'100%': '#87d068',
}}
/>
<div style={{
marginTop: 16,
color: '#666',
fontSize: 14,
minHeight: 40,
lineHeight: '20px'
}}>
{sseMessage}
</div>
</div>
</Modal>
{/* SSE进度Modal - 使用统一组件 */}
<SSEProgressModal
visible={sseModalVisible}
progress={sseProgress}
message={sseMessage}
title="AI生成中..."
/>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */}
@@ -1282,12 +1453,18 @@ export default function Outline() {
<div style={{ width: '100%' }}>
<List.Item.Meta
title={
<span style={{ fontSize: isMobile ? 14 : 16 }}>
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
{item.order_index || '?'}
<Space size="small" style={{ fontSize: isMobile ? 14 : 16, flexWrap: 'wrap' }}>
<span style={{ color: '#1890ff', fontWeight: 'bold' }}>
{item.order_index || '?'}
</span>
{item.title}
</span>
<span>{item.title}</span>
{/* ✅ 新增:展开状态标识 */}
{outlineExpandStatus[item.id] ? (
<Tag color="success" icon={<CheckCircleOutlined />}></Tag>
) : (
<Tag color="default"></Tag>
)}
</Space>
}
description={
<div style={{ fontSize: isMobile ? 12 : 14 }}>
+14 -6
View File
@@ -1,7 +1,7 @@
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, Upload, Checkbox, Divider, Switch, Dropdown, Form, Input, InputNumber } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined } from '@ant-design/icons';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined, LoadingOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
@@ -115,9 +115,15 @@ export default function ProjectList() {
}
};
const handleEnterProject = (id: string) => {
// 简化后直接进入项目,不再检查向导状态
navigate(`/project/${id}`);
const handleEnterProject = async (project: any) => {
// 检查项目是否未完成生成(wizard_status为incomplete)
if (project.wizard_status === 'incomplete') {
// 未完成的项目跳转到生成页面继续生成
navigate(`/wizard?project_id=${project.id}`);
} else {
// 已完成的项目进入项目详情页
navigate(`/project/${project.id}`);
}
};
const getStatusTag = (status: string) => {
@@ -725,14 +731,16 @@ export default function ProjectList() {
return (
<Col {...gridConfig} key={project.id}>
<Badge.Ribbon
text={getStatusTag(project.status)}
text={project.wizard_status === 'incomplete' ? (
<Tag color="orange" icon={<LoadingOutlined spin />}></Tag>
) : getStatusTag(project.status)}
color="transparent"
style={{ top: 12, right: 12 }}
>
<Card
hoverable
variant="borderless"
onClick={() => handleEnterProject(project.id)}
onClick={() => handleEnterProject(project)}
style={cardStyles.project}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
{...cardHoverHandlers}
+89 -310
View File
@@ -1,43 +1,28 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Form, Input, InputNumber, Select, Button, message, Card,
Row, Col, Typography, Space, Progress
Form, Input, InputNumber, Select, Button, Card,
Row, Col, Typography, Space, message
} from 'antd';
import {
RocketOutlined, ArrowLeftOutlined, CheckCircleOutlined,
LoadingOutlined
RocketOutlined, ArrowLeftOutlined
} from '@ant-design/icons';
import { wizardStreamApi } from '../services/api';
import type { WizardBasicInfo, ApiError } from '../types';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator';
import type { WizardBasicInfo } from '../types';
const { TextArea } = Input;
const { Title, Paragraph, Text } = Typography;
const { Title, Paragraph } = Typography;
export default function ProjectWizardNew() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [form] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
// 状态管理
const [loading, setLoading] = useState(false);
const [currentStep, setCurrentStep] = useState<'form' | 'generating' | 'complete'>('form');
const [projectId, setProjectId] = useState<string>('');
const [projectTitle, setProjectTitle] = useState<string>('');
// SSE流式进度状态
const [progress, setProgress] = useState(0);
const [progressMessage, setProgressMessage] = useState('');
const [generationSteps, setGenerationSteps] = useState<{
worldBuilding: 'pending' | 'processing' | 'completed' | 'error';
characters: 'pending' | 'processing' | 'completed' | 'error';
outline: 'pending' | 'processing' | 'completed' | 'error';
}>({
worldBuilding: 'pending',
characters: 'pending',
outline: 'pending'
});
const [currentStep, setCurrentStep] = useState<'form' | 'generating'>('form');
const [generationConfig, setGenerationConfig] = useState<GenerationConfig | null>(null);
const [resumeProjectId, setResumeProjectId] = useState<string | null>(null);
useEffect(() => {
const handleResize = () => {
@@ -47,140 +32,72 @@ export default function ProjectWizardNew() {
return () => window.removeEventListener('resize', handleResize);
}, []);
// 自动化生成流程
const handleAutoGenerate = async (values: WizardBasicInfo) => {
try {
setLoading(true);
setCurrentStep('generating');
setProjectTitle(values.title);
setProgress(0);
setProgressMessage('开始创建项目...');
// 步骤1: 生成世界观并创建项目
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'processing' }));
setProgressMessage('正在生成世界观...');
const worldResult = await wizardStreamApi.generateWorldBuildingStream(
{
title: values.title,
description: values.description,
theme: values.theme,
genre: Array.isArray(values.genre) ? values.genre.join('、') : values.genre,
narrative_perspective: values.narrative_perspective,
target_words: values.target_words,
chapter_count: values.chapter_count || 30,
character_count: values.character_count || 5,
},
{
onProgress: (msg, prog) => {
setProgress(Math.floor(prog / 3)); // 0-33%
setProgressMessage(msg);
},
onResult: (data) => {
setProjectId(data.project_id);
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'completed' }));
},
onError: (error) => {
setGenerationSteps(prev => ({ ...prev, worldBuilding: 'error' }));
throw new Error(error);
},
onComplete: () => {
console.log('世界观生成完成');
}
}
);
if (!worldResult?.project_id) {
throw new Error('项目创建失败');
}
const createdProjectId = worldResult.project_id;
setProjectId(createdProjectId);
// 步骤2: 生成角色
setGenerationSteps(prev => ({ ...prev, characters: 'processing' }));
setProgressMessage('正在生成角色...');
await wizardStreamApi.generateCharactersStream(
{
project_id: createdProjectId,
count: values.character_count || 5,
world_context: {
time_period: worldResult.time_period || '',
location: worldResult.location || '',
atmosphere: worldResult.atmosphere || '',
rules: worldResult.rules || '',
},
theme: values.theme,
genre: Array.isArray(values.genre) ? values.genre.join('、') : values.genre,
},
{
onProgress: (msg, prog) => {
setProgress(33 + Math.floor(prog / 3)); // 33-66%
setProgressMessage(msg);
},
onResult: (data) => {
console.log(`成功生成${data.characters?.length || 0}个角色`);
setGenerationSteps(prev => ({ ...prev, characters: 'completed' }));
},
onError: (error) => {
setGenerationSteps(prev => ({ ...prev, characters: 'error' }));
throw new Error(error);
},
onComplete: () => {
console.log('角色生成完成');
}
}
);
// 步骤3: 生成大纲
setGenerationSteps(prev => ({ ...prev, outline: 'processing' }));
setProgressMessage('正在生成大纲...');
await wizardStreamApi.generateCompleteOutlineStream(
{
project_id: createdProjectId,
chapter_count: 3, // 生成3个大纲节点(不展开)
narrative_perspective: values.narrative_perspective,
target_words: values.target_words,
},
{
onProgress: (msg, prog) => {
setProgress(66 + Math.floor(prog / 3)); // 66-99%
setProgressMessage(msg);
},
onResult: () => {
console.log('大纲生成完成');
setGenerationSteps(prev => ({ ...prev, outline: 'completed' }));
},
onError: (error) => {
setGenerationSteps(prev => ({ ...prev, outline: 'error' }));
throw new Error(error);
},
onComplete: () => {
console.log('大纲生成完成');
}
}
);
// 全部完成
setProgress(100);
setProgressMessage('项目创建完成!');
setCurrentStep('complete');
message.success('项目创建成功!');
} catch (error) {
const apiError = error as ApiError;
message.error('创建项目失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
setCurrentStep('form');
setGenerationSteps({
worldBuilding: 'pending',
characters: 'pending',
outline: 'pending'
});
} finally {
setLoading(false);
// 检查URL参数,如果有project_id则恢复生成
useEffect(() => {
const projectId = searchParams.get('project_id');
if (projectId) {
setResumeProjectId(projectId);
handleResumeGeneration(projectId);
}
}, [searchParams]);
// 恢复未完成项目的生成
const handleResumeGeneration = async (projectId: string) => {
try {
const response = await fetch(`/api/projects/${projectId}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error('获取项目信息失败');
}
const project = await response.json();
const config: GenerationConfig = {
title: project.title,
description: project.description || '',
theme: project.theme || '',
genre: project.genre || '',
narrative_perspective: project.narrative_perspective || '第三人称',
target_words: project.target_words || 100000,
chapter_count: 3,
character_count: project.character_count || 5,
};
setGenerationConfig(config);
setCurrentStep('generating');
} catch (error) {
console.error('恢复生成失败:', error);
message.error('恢复生成失败,请重试');
navigate('/');
}
};
// 开始生成流程
const handleAutoGenerate = async (values: WizardBasicInfo) => {
const config: GenerationConfig = {
title: values.title,
description: values.description,
theme: values.theme,
genre: values.genre,
narrative_perspective: values.narrative_perspective,
target_words: values.target_words || 100000,
chapter_count: 3, // 默认生成3章大纲
character_count: values.character_count || 5,
};
setGenerationConfig(config);
setCurrentStep('generating');
};
// 生成完成回调
const handleComplete = (projectId: string) => {
console.log('项目创建完成:', projectId);
};
// 返回表单页面
const handleBack = () => {
setCurrentStep('form');
setGenerationConfig(null);
};
// 渲染表单页面
@@ -317,7 +234,6 @@ export default function ProjectWizardNew() {
htmlType="submit"
size="large"
block
loading={loading}
icon={<RocketOutlined />}
>
@@ -335,150 +251,12 @@ export default function ProjectWizardNew() {
</Card>
);
// 渲染生成进度页面
const renderGenerating = () => {
const getStepStatus = (step: 'pending' | 'processing' | 'completed' | 'error') => {
if (step === 'completed') return { icon: <CheckCircleOutlined />, color: '#52c41a' };
if (step === 'processing') return { icon: <LoadingOutlined />, color: '#1890ff' };
if (step === 'error') return { icon: '✗', color: '#ff4d4f' };
return { icon: '○', color: '#d9d9d9' };
};
return (
<Card>
<div style={{ textAlign: 'center', padding: isMobile ? '32px 16px' : '40px 0' }}>
<Title level={isMobile ? 4 : 3} style={{ marginBottom: 32 }}>
{projectTitle}
</Title>
<Progress
percent={progress}
status={progress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': '#667eea',
'100%': '#764ba2',
}}
style={{ marginBottom: 32 }}
/>
<Paragraph style={{ fontSize: 16, marginBottom: 48, color: '#666' }}>
{progressMessage}
</Paragraph>
<Space direction="vertical" size={24} style={{ width: '100%', maxWidth: 400, margin: '0 auto' }}>
{[
{ key: 'worldBuilding', label: '生成世界观', step: generationSteps.worldBuilding },
{ key: 'characters', label: '生成角色', step: generationSteps.characters },
{ key: 'outline', label: '生成大纲', step: generationSteps.outline },
].map(({ key, label, step }) => {
const status = getStepStatus(step);
return (
<div
key={key}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
background: step === 'processing' ? '#f0f5ff' : '#fafafa',
borderRadius: 8,
border: `1px solid ${step === 'processing' ? '#d6e4ff' : '#f0f0f0'}`,
}}
>
<Text style={{ fontSize: 16, fontWeight: step === 'processing' ? 600 : 400 }}>
{label}
</Text>
<span style={{ fontSize: 20, color: status.color }}>
{status.icon}
</span>
</div>
);
})}
</Space>
<Paragraph type="secondary" style={{ marginTop: 48 }}>
AI正在为您精心创作...
</Paragraph>
</div>
</Card>
);
};
// 渲染完成页面
const renderComplete = () => (
<Card>
<div style={{
textAlign: 'center',
padding: isMobile ? '32px 16px' : '40px 0'
}}>
<div style={{
fontSize: isMobile ? 56 : 72,
color: '#52c41a',
marginBottom: isMobile ? 16 : 24
}}>
</div>
<Title
level={isMobile ? 3 : 2}
style={{
color: '#52c41a',
marginBottom: isMobile ? 8 : 16
}}
>
</Title>
<Paragraph style={{
fontSize: isMobile ? 14 : 16,
marginTop: isMobile ? 16 : 24,
marginBottom: isMobile ? 32 : 48,
}}>
{projectTitle}
</Paragraph>
<Paragraph type="secondary" style={{
fontSize: isMobile ? 12 : 14,
marginTop: 8,
}}>
💡 "大纲"
</Paragraph>
<Space
size={isMobile ? 12 : 16}
direction={isMobile ? 'vertical' : 'horizontal'}
style={{ width: isMobile ? '100%' : 'auto' }}
>
<Button
size="large"
onClick={() => navigate('/')}
block={isMobile}
style={{
minWidth: 120,
height: isMobile ? 44 : 40
}}
>
</Button>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate(`/project/${projectId}`)}
block={isMobile}
style={{
minWidth: 120,
height: isMobile ? 44 : 40
}}
>
</Button>
</Space>
</div>
</Card>
);
return (
<div style={{
minHeight: '100vh',
background: '#f5f7fa',
background: currentStep === 'generating'
? 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
: '#f5f7fa',
}}>
{/* 顶部标题栏 - 固定不滚动 */}
<div style={{
@@ -529,16 +307,17 @@ export default function ProjectWizardNew() {
padding: isMobile ? '16px 12px' : '24px 24px',
}}>
{currentStep === 'form' && renderForm()}
{currentStep === 'generating' && renderGenerating()}
{currentStep === 'complete' && renderComplete()}
{currentStep === 'generating' && generationConfig && (
<AIProjectGenerator
config={generationConfig}
storagePrefix="wizard"
onComplete={handleComplete}
onBack={handleBack}
isMobile={isMobile}
resumeProjectId={resumeProjectId || undefined}
/>
)}
</div>
{/* SSE加载覆盖层 */}
<SSELoadingOverlay
loading={loading}
progress={progress}
message={progressMessage}
/>
</div>
);
}
+78 -14
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs, AutoComplete } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
@@ -40,6 +40,8 @@ export default function Relationships() {
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
const [editingRelationship, setEditingRelationship] = useState<Relationship | null>(null);
const [form] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const [pageSize, setPageSize] = useState(10);
@@ -103,6 +105,49 @@ export default function Relationships() {
}
};
const handleEditRelationship = (record: Relationship) => {
setEditingRelationship(record);
setIsEditMode(true);
form.setFieldsValue({
character_from_id: record.character_from_id,
character_to_id: record.character_to_id,
relationship_name: record.relationship_name,
intimacy_level: record.intimacy_level,
status: record.status,
description: record.description,
});
setIsModalOpen(true);
};
const handleUpdateRelationship = async (values: {
character_from_id: string;
character_to_id: string;
relationship_name: string;
intimacy_level: number;
status: string;
description?: string;
}) => {
if (!editingRelationship) return;
try {
await axios.put(`/api/relationships/${editingRelationship.id}`, {
relationship_name: values.relationship_name,
intimacy_level: values.intimacy_level,
status: values.status,
description: values.description,
});
message.success('关系更新成功');
setIsModalOpen(false);
setIsEditMode(false);
setEditingRelationship(null);
form.resetFields();
loadData();
} catch (error) {
message.error('更新关系失败');
console.error(error);
}
};
const handleDeleteRelationship = async (id: string) => {
Modal.confirm({
title: '确认删除',
@@ -218,16 +263,26 @@ export default function Relationships() {
title: '操作',
key: 'action',
render: (_: unknown, record: Relationship) => (
<Button
type="link"
danger
size="small"
onClick={() => handleDeleteRelationship(record.id)}
>
</Button>
<Space size="small">
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEditRelationship(record)}
>
</Button>
<Button
type="link"
danger
size="small"
onClick={() => handleDeleteRelationship(record.id)}
>
</Button>
</Space>
),
width: 80,
width: 140,
fixed: isMobile ? ('right' as const) : undefined,
},
];
@@ -345,10 +400,12 @@ export default function Relationships() {
</Card>
<Modal
title="添加关系"
title={isEditMode ? '编辑关系' : '添加关系'}
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
setIsEditMode(false);
setEditingRelationship(null);
form.resetFields();
}}
footer={null}
@@ -360,7 +417,7 @@ export default function Relationships() {
<Form
form={form}
layout="vertical"
onFinish={handleCreateRelationship}
onFinish={isEditMode ? handleUpdateRelationship : handleCreateRelationship}
>
<Form.Item
name="character_from_id"
@@ -370,6 +427,7 @@ export default function Relationships() {
<Select
placeholder="选择角色"
showSearch
disabled={isEditMode}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
@@ -404,6 +462,7 @@ export default function Relationships() {
<Select
placeholder="选择角色"
showSearch
disabled={isEditMode}
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
@@ -450,9 +509,14 @@ export default function Relationships() {
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button onClick={() => {
setIsModalOpen(false);
setIsEditMode(false);
setEditingRelationship(null);
form.resetFields();
}}></Button>
<Button type="primary" htmlType="submit">
{isEditMode ? '更新' : '创建'}
</Button>
</Space>
</Form.Item>
+2
View File
@@ -109,6 +109,7 @@ export default function UserManagement() {
</div>
),
width: 500,
centered: true,
});
}
@@ -191,6 +192,7 @@ export default function UserManagement() {
</div>
),
width: 500,
centered: true,
});
setResetPasswordModalVisible(false);