update:1.小说项目创建支持双模式生成,大纲-章节(一对一&一对多) 2.新增章节管理-编辑章节规划功能 3.修复灵感模式可重复点击选项问题,刷新对话内容丢失问题
This commit is contained in:
@@ -41,20 +41,21 @@ export default function AuthCallback() {
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
// 检查今天是否已经显示过公告
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
// 检查是否永久隐藏公告或今日已隐藏
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
// 延迟一下再显示公告,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
} else {
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
// 延迟一下再跳转,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
}, 1000);
|
||||
} else {
|
||||
// 延迟一下再显示公告,让用户看到成功提示
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
@@ -117,10 +118,14 @@ export default function AuthCallback() {
|
||||
};
|
||||
|
||||
const handleDoNotShowToday = () => {
|
||||
// 设置到今天23:59:59不再显示
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(23, 59, 59, 999);
|
||||
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
|
||||
// 设置今日不再显示
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('announcement_hide_today', today);
|
||||
};
|
||||
|
||||
const handleNeverShow = () => {
|
||||
// 设置永久不再显示
|
||||
localStorage.setItem('announcement_hide_forever', 'true');
|
||||
};
|
||||
|
||||
const handleSetPassword = async () => {
|
||||
@@ -147,16 +152,17 @@ export default function AuthCallback() {
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -173,16 +179,17 @@ export default function AuthCallback() {
|
||||
const redirect = sessionStorage.getItem('login_redirect') || '/';
|
||||
sessionStorage.removeItem('login_redirect');
|
||||
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
navigate(redirect);
|
||||
}, 500);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
navigate(redirect);
|
||||
setShowAnnouncement(true);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
@@ -193,6 +200,7 @@ export default function AuthCallback() {
|
||||
visible={showAnnouncement}
|
||||
onClose={handleAnnouncementClose}
|
||||
onDoNotShowToday={handleDoNotShowToday}
|
||||
onNeverShow={handleNeverShow}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
+307
-50
@@ -1,11 +1,12 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
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 { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined } 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, AnalysisTask, ExpansionPlanData } from '../types';
|
||||
import ChapterAnalysis from '../components/ChapterAnalysis';
|
||||
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
|
||||
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
|
||||
import { SSEProgressModal } from '../components/SSEProgressModal';
|
||||
import FloatingIndexPanel from '../components/FloatingIndexPanel';
|
||||
@@ -33,6 +34,10 @@ export default function Chapters() {
|
||||
const pollingIntervalsRef = useRef<Record<string, number>>({});
|
||||
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
|
||||
|
||||
// 规划编辑状态
|
||||
const [planEditorVisible, setPlanEditorVisible] = useState(false);
|
||||
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
|
||||
|
||||
// 单章节生成进度状态
|
||||
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
|
||||
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
|
||||
@@ -559,6 +564,7 @@ export default function Chapters() {
|
||||
|
||||
try {
|
||||
setBatchGenerating(true);
|
||||
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
|
||||
|
||||
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, {
|
||||
method: 'POST',
|
||||
@@ -978,12 +984,63 @@ export default function Chapters() {
|
||||
const updatedProject = await projectApi.getProject(currentProject.id);
|
||||
setCurrentProject(updatedProject);
|
||||
}
|
||||
|
||||
|
||||
message.success('章节删除成功');
|
||||
} catch (error: any) {
|
||||
message.error('删除章节失败:' + (error.message || '未知错误'));
|
||||
}
|
||||
};
|
||||
|
||||
// 打开规划编辑器
|
||||
const handleOpenPlanEditor = (chapter: Chapter) => {
|
||||
// 检查是否有规划数据
|
||||
if (!chapter.expansion_plan) {
|
||||
message.warning('该章节暂无规划信息');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 尝试解析JSON,验证数据有效性
|
||||
JSON.parse(chapter.expansion_plan);
|
||||
setEditingPlanChapter(chapter);
|
||||
setPlanEditorVisible(true);
|
||||
} catch (error) {
|
||||
console.error('规划数据格式错误:', error);
|
||||
message.error('规划数据格式错误,无法编辑');
|
||||
}
|
||||
};
|
||||
|
||||
// 保存规划信息
|
||||
const handleSavePlan = async (planData: ExpansionPlanData) => {
|
||||
if (!editingPlanChapter) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/chapters/${editingPlanChapter.id}/expansion-plan`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(planData),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || '更新失败');
|
||||
}
|
||||
|
||||
// 刷新章节列表
|
||||
await refreshChapters();
|
||||
|
||||
message.success('规划信息更新成功');
|
||||
|
||||
// 关闭编辑器
|
||||
setPlanEditorVisible(false);
|
||||
setEditingPlanChapter(null);
|
||||
} catch (error: any) {
|
||||
message.error('保存规划失败:' + (error.message || '未知错误'));
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleChapterSelect = (chapterId: string) => {
|
||||
const element = document.getElementById(`chapter-item-${chapterId}`);
|
||||
@@ -1037,14 +1094,165 @@ export default function Chapters() {
|
||||
>
|
||||
导出为TXT
|
||||
</Button>
|
||||
{!isMobile && <Tag color="blue">章节由大纲管理,请在大纲页面添加/删除</Tag>}
|
||||
{!isMobile && (
|
||||
<Tag color="blue">
|
||||
{currentProject.outline_mode === 'one-to-one'
|
||||
? '传统模式:章节由大纲一对一管理,请在大纲页面操作'
|
||||
: '细化模式:章节可在大纲页面展开'}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||
{chapters.length === 0 ? (
|
||||
<Empty description="还没有章节,开始创作吧!" />
|
||||
) : currentProject.outline_mode === 'one-to-one' ? (
|
||||
// one-to-one 模式:直接显示扁平列表
|
||||
<List
|
||||
dataSource={sortedChapters}
|
||||
renderItem={(item) => (
|
||||
<List.Item
|
||||
id={`chapter-item-${item.id}`}
|
||||
style={{
|
||||
padding: '16px',
|
||||
marginBottom: 16,
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
border: '1px solid #f0f0f0',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
>
|
||||
编辑内容
|
||||
</Button>,
|
||||
(() => {
|
||||
const task = analysisTasksMap[item.id];
|
||||
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
|
||||
const hasContent = item.content && item.content.trim() !== '';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
!hasContent ? '请先生成章节内容' :
|
||||
isAnalyzing ? '分析进行中,请稍候...' :
|
||||
''
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
|
||||
onClick={() => handleShowAnalysis(item.id)}
|
||||
disabled={!hasContent || isAnalyzing}
|
||||
loading={isAnalyzing}
|
||||
>
|
||||
{isAnalyzing ? '分析中' : '查看分析'}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
})(),
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
>
|
||||
修改信息
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<List.Item.Meta
|
||||
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
|
||||
title={
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
flexDirection: isMobile ? 'column' : 'row',
|
||||
alignItems: isMobile ? 'flex-start' : 'center',
|
||||
gap: isMobile ? 6 : 12,
|
||||
width: '100%'
|
||||
}}>
|
||||
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500, flexShrink: 0 }}>
|
||||
第{item.chapter_number}章:{item.title}
|
||||
</span>
|
||||
<Space wrap size={isMobile ? 4 : 8}>
|
||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||
{renderAnalysisStatus(item.id)}
|
||||
{!canGenerateChapter(item) && (
|
||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||
<Tag icon={<LockOutlined />} color="warning">
|
||||
需前置章节
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
description={
|
||||
item.content ? (
|
||||
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
|
||||
{item.content.substring(0, isMobile ? 80 : 150)}
|
||||
{item.content.length > (isMobile ? 80 : 150) && '...'}
|
||||
</div>
|
||||
) : (
|
||||
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}>暂无内容</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{isMobile && (
|
||||
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
size="small"
|
||||
title="编辑内容"
|
||||
/>
|
||||
{(() => {
|
||||
const task = analysisTasksMap[item.id];
|
||||
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
|
||||
const hasContent = item.content && item.content.trim() !== '';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
!hasContent ? '请先生成章节内容' :
|
||||
isAnalyzing ? '分析中' :
|
||||
'查看分析'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
|
||||
onClick={() => handleShowAnalysis(item.id)}
|
||||
size="small"
|
||||
disabled={!hasContent || isAnalyzing}
|
||||
loading={isAnalyzing}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})()}
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
onClick={() => handleOpenModal(item.id)}
|
||||
size="small"
|
||||
title="修改信息"
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
// one-to-many 模式:按大纲分组显示
|
||||
<Collapse
|
||||
bordered={false}
|
||||
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
|
||||
@@ -1093,6 +1301,7 @@ export default function Chapters() {
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleOpenEditor(item.id)}
|
||||
>
|
||||
@@ -1112,6 +1321,7 @@ export default function Chapters() {
|
||||
}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
|
||||
onClick={() => handleShowAnalysis(item.id)}
|
||||
disabled={!hasContent || isAnalyzing}
|
||||
@@ -1129,22 +1339,25 @@ export default function Chapters() {
|
||||
>
|
||||
修改信息
|
||||
</Button>,
|
||||
<Popconfirm
|
||||
title="确定删除这个章节吗?"
|
||||
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
|
||||
onConfirm={() => handleDeleteChapter(item.id)}
|
||||
okText="确定删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
// 只在 one-to-many 模式下显示删除按钮
|
||||
...(currentProject.outline_mode === 'one-to-many' ? [
|
||||
<Popconfirm
|
||||
title="确定删除这个章节吗?"
|
||||
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
|
||||
onConfirm={() => handleDeleteChapter(item.id)}
|
||||
okText="确定删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>,
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
] : []),
|
||||
]}
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
@@ -1165,13 +1378,6 @@ export default function Chapters() {
|
||||
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
|
||||
<Badge count={`${item.word_count || 0}字`} style={{ backgroundColor: '#52c41a' }} />
|
||||
{renderAnalysisStatus(item.id)}
|
||||
{item.expansion_plan && (
|
||||
<Tooltip title="已有展开规划,点击信息图标查看详情">
|
||||
<Tag icon={<CheckCircleOutlined />} color="blue">
|
||||
已展开
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!canGenerateChapter(item) && (
|
||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||
<Tag icon={<LockOutlined />} color="warning">
|
||||
@@ -1180,15 +1386,26 @@ export default function Chapters() {
|
||||
</Tooltip>
|
||||
)}
|
||||
{item.expansion_plan && (
|
||||
<Tooltip title="查看展开规划详情">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showExpansionPlanModal(item);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Space size={4}>
|
||||
<Tooltip title="查看展开详情">
|
||||
<InfoCircleOutlined
|
||||
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showExpansionPlanModal(item);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title="编辑规划信息">
|
||||
<FormOutlined
|
||||
style={{ color: '#52c41a', cursor: 'pointer', fontSize: 16 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleOpenPlanEditor(item);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
@@ -1245,22 +1462,25 @@ export default function Chapters() {
|
||||
size="small"
|
||||
title="修改信息"
|
||||
/>
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => handleDeleteChapter(item.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
title="删除章节"
|
||||
/>
|
||||
</Popconfirm>
|
||||
{/* 只在 one-to-many 模式下显示删除按钮 */}
|
||||
{currentProject.outline_mode === 'one-to-many' && (
|
||||
<Popconfirm
|
||||
title="确定删除?"
|
||||
description="删除后无法恢复"
|
||||
onConfirm={() => handleDeleteChapter(item.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
okButtonProps={{ danger: true }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
title="删除章节"
|
||||
/>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
@@ -1781,6 +2001,18 @@ export default function Chapters() {
|
||||
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
|
||||
}
|
||||
title="批量生成章节"
|
||||
onCancel={() => {
|
||||
Modal.confirm({
|
||||
title: '确认取消',
|
||||
content: '确定要取消批量生成吗?已生成的章节将保留。',
|
||||
okText: '确定取消',
|
||||
cancelText: '继续生成',
|
||||
okButtonProps: { danger: true },
|
||||
centered: true,
|
||||
onOk: handleCancelBatchGenerate,
|
||||
});
|
||||
}}
|
||||
cancelButtonText="取消任务"
|
||||
/>
|
||||
|
||||
<FloatButton
|
||||
@@ -1797,6 +2029,31 @@ export default function Chapters() {
|
||||
groupedChapters={groupedChapters}
|
||||
onChapterSelect={handleChapterSelect}
|
||||
/>
|
||||
|
||||
{/* 规划编辑器 */}
|
||||
{editingPlanChapter && currentProject && (() => {
|
||||
let parsedPlanData = null;
|
||||
try {
|
||||
if (editingPlanChapter.expansion_plan) {
|
||||
parsedPlanData = JSON.parse(editingPlanChapter.expansion_plan);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析规划数据失败:', error);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpansionPlanEditor
|
||||
visible={planEditorVisible}
|
||||
planData={parsedPlanData}
|
||||
projectId={currentProject.id}
|
||||
onSave={handleSavePlan}
|
||||
onCancel={() => {
|
||||
setPlanEditorVisible(false);
|
||||
setEditingPlanChapter(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,13 +8,14 @@ import { AIProjectGenerator, type GenerationConfig } from '../components/AIProje
|
||||
const { Title, Text, Paragraph } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'confirm' | 'generating' | 'complete';
|
||||
type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'outline_mode' | 'confirm' | 'generating' | 'complete';
|
||||
|
||||
interface Message {
|
||||
type: 'ai' | 'user';
|
||||
content: string;
|
||||
options?: string[];
|
||||
isMultiSelect?: boolean;
|
||||
optionsDisabled?: boolean; // 标记选项是否已禁用
|
||||
}
|
||||
|
||||
interface WizardData {
|
||||
@@ -23,8 +24,24 @@ interface WizardData {
|
||||
theme: string;
|
||||
genre: string[];
|
||||
narrative_perspective: string;
|
||||
outline_mode: 'one-to-one' | 'one-to-many';
|
||||
}
|
||||
|
||||
// 缓存数据接口
|
||||
interface CacheData {
|
||||
messages: Message[];
|
||||
currentStep: Step;
|
||||
wizardData: Partial<WizardData>;
|
||||
initialIdea: string;
|
||||
selectedOptions: string[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
// 缓存键
|
||||
const CACHE_KEY = 'inspiration_conversation_cache';
|
||||
// 缓存有效期:24小时
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
const Inspiration: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [currentStep, setCurrentStep] = useState<Step>('idea');
|
||||
@@ -56,6 +73,112 @@ const Inspiration: React.FC = () => {
|
||||
context: Partial<WizardData>;
|
||||
} | null>(null);
|
||||
|
||||
// 标记是否已经加载缓存
|
||||
const [cacheLoaded, setCacheLoaded] = useState(false);
|
||||
|
||||
// ==================== 缓存管理函数 ====================
|
||||
|
||||
// 保存到缓存
|
||||
const saveToCache = () => {
|
||||
try {
|
||||
// 只在对话阶段保存,生成阶段不保存
|
||||
if (currentStep === 'generating' || currentStep === 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有用户有输入时才保存(至少两条消息:AI问候+用户回复)
|
||||
if (messages.length <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData: CacheData = {
|
||||
messages,
|
||||
currentStep,
|
||||
wizardData,
|
||||
initialIdea,
|
||||
selectedOptions,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
|
||||
console.log('💾 对话已自动保存');
|
||||
} catch (error) {
|
||||
console.error('保存缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 从缓存恢复
|
||||
const restoreFromCache = (): boolean => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
if (!cached) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cacheData: CacheData = JSON.parse(cached);
|
||||
const age = Date.now() - cacheData.timestamp;
|
||||
|
||||
// 检查缓存是否过期
|
||||
if (age > CACHE_EXPIRY) {
|
||||
console.log('⏰ 缓存已过期,清除');
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 必须有有效的对话数据
|
||||
if (!cacheData.messages || cacheData.messages.length <= 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 恢复所有状态
|
||||
setMessages(cacheData.messages);
|
||||
setCurrentStep(cacheData.currentStep);
|
||||
setWizardData(cacheData.wizardData);
|
||||
setInitialIdea(cacheData.initialIdea);
|
||||
setSelectedOptions(cacheData.selectedOptions);
|
||||
|
||||
console.log('✅ 已恢复上次的对话进度');
|
||||
message.success('已恢复上次的对话进度', 2);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('恢复缓存失败:', error);
|
||||
clearCache();
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 清除缓存
|
||||
const clearCache = () => {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
console.log('🗑️ 缓存已清除');
|
||||
} catch (error) {
|
||||
console.error('清除缓存失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 组件挂载时恢复缓存 ====================
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheLoaded) {
|
||||
restoreFromCache();
|
||||
setCacheLoaded(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ==================== 自动保存:状态变化时保存 ====================
|
||||
|
||||
useEffect(() => {
|
||||
// 防抖保存
|
||||
const timer = setTimeout(() => {
|
||||
if (cacheLoaded) {
|
||||
saveToCache();
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [messages, currentStep, wizardData, initialIdea, selectedOptions, cacheLoaded]);
|
||||
|
||||
// 自动滚动到底部
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
@@ -116,7 +239,7 @@ const Inspiration: React.FC = () => {
|
||||
};
|
||||
|
||||
// 步骤顺序
|
||||
const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'confirm'];
|
||||
const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'outline_mode', 'confirm'];
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!inputValue.trim()) {
|
||||
@@ -191,6 +314,7 @@ const Inspiration: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 对于多选类型,不立即禁用选项
|
||||
if (currentStep === 'genre') {
|
||||
const newSelected = selectedOptions.includes(option)
|
||||
? selectedOptions.filter(o => o !== option)
|
||||
@@ -199,6 +323,19 @@ const Inspiration: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 立即禁用当前消息的选项(单选场景)
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop();
|
||||
if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) {
|
||||
newMessages[lastAiMessageIndex] = {
|
||||
...newMessages[lastAiMessageIndex],
|
||||
optionsDisabled: true
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
if (currentStep === 'perspective') {
|
||||
const userMessage: Message = {
|
||||
type: 'user',
|
||||
@@ -206,9 +343,46 @@ const Inspiration: React.FC = () => {
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData;
|
||||
const updatedData = { ...wizardData, narrative_perspective: option };
|
||||
setWizardData(updatedData);
|
||||
|
||||
// 询问大纲模式
|
||||
const aiMessage: Message = {
|
||||
type: 'ai',
|
||||
content: `很好!现在请选择你想要的大纲模式:
|
||||
|
||||
📋 **一对一模式**:传统模式,一个大纲对应一个章节,适合结构清晰、章节独立的小说。
|
||||
|
||||
📚 **一对多模式**:细化模式,一个大纲可以展开成多个章节,适合需要详细展开情节的小说。
|
||||
|
||||
请选择:`,
|
||||
options: ['📋 一对一模式', '📚 一对多模式']
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
setCurrentStep('outline_mode');
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentStep === 'outline_mode') {
|
||||
const userMessage: Message = {
|
||||
type: 'user',
|
||||
content: option,
|
||||
};
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
|
||||
// 将选项转换为实际的模式值
|
||||
const modeValue: 'one-to-one' | 'one-to-many' =
|
||||
option === '📋 一对一模式' ? 'one-to-one' : 'one-to-many';
|
||||
|
||||
const updatedData = {
|
||||
...wizardData,
|
||||
outline_mode: modeValue,
|
||||
genre: wizardData.genre || []
|
||||
} as WizardData;
|
||||
setWizardData(updatedData);
|
||||
|
||||
// 显示摘要
|
||||
const modeText = modeValue === 'one-to-one' ? '一对一模式' : '一对多模式';
|
||||
const summary = `
|
||||
太棒了!你的小说设定已完成,请确认:
|
||||
|
||||
@@ -217,6 +391,7 @@ const Inspiration: React.FC = () => {
|
||||
🎯 主题:${updatedData.theme}
|
||||
🏷️ 类型:${updatedData.genre.join('、')}
|
||||
👁️ 视角:${updatedData.narrative_perspective}
|
||||
📋 大纲模式:${modeText}
|
||||
|
||||
请选择下一步操作:
|
||||
`.trim();
|
||||
@@ -245,6 +420,9 @@ const Inspiration: React.FC = () => {
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
|
||||
// 清除缓存(对话完成,进入生成阶段)
|
||||
clearCache();
|
||||
|
||||
// 开始生成项目
|
||||
const data = wizardData as WizardData;
|
||||
const config: GenerationConfig = {
|
||||
@@ -256,6 +434,7 @@ const Inspiration: React.FC = () => {
|
||||
target_words: 100000,
|
||||
chapter_count: 3,
|
||||
character_count: 5,
|
||||
outline_mode: data.outline_mode,
|
||||
};
|
||||
setGenerationConfig(config);
|
||||
setCurrentStep('generating');
|
||||
@@ -308,6 +487,11 @@ const Inspiration: React.FC = () => {
|
||||
updatedData.genre = [input];
|
||||
} else if (currentStep === 'perspective') {
|
||||
updatedData.narrative_perspective = input;
|
||||
} else if (currentStep === 'outline_mode') {
|
||||
// 大纲模式不支持自定义输入
|
||||
message.warning('请从选项中选择一个大纲模式');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setWizardData(updatedData);
|
||||
@@ -326,6 +510,19 @@ const Inspiration: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 禁用类型选择的选项
|
||||
setMessages(prev => {
|
||||
const newMessages = [...prev];
|
||||
const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop();
|
||||
if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) {
|
||||
newMessages[lastAiMessageIndex] = {
|
||||
...newMessages[lastAiMessageIndex],
|
||||
optionsDisabled: true
|
||||
};
|
||||
}
|
||||
return newMessages;
|
||||
});
|
||||
|
||||
const userMessage: Message = {
|
||||
type: 'user',
|
||||
content: selectedOptions.join('、'),
|
||||
@@ -340,7 +537,7 @@ const Inspiration: React.FC = () => {
|
||||
try {
|
||||
const aiMessage: Message = {
|
||||
type: 'ai',
|
||||
content: '很好!最后一步,请选择小说的叙事视角:',
|
||||
content: '很好!接下来,请选择小说的叙事视角:',
|
||||
options: ['第一人称', '第三人称', '全知视角']
|
||||
};
|
||||
setMessages(prev => [...prev, aiMessage]);
|
||||
@@ -458,6 +655,9 @@ const Inspiration: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleRestart = () => {
|
||||
// 清除缓存
|
||||
clearCache();
|
||||
|
||||
setCurrentStep('idea');
|
||||
setMessages([
|
||||
{
|
||||
@@ -478,11 +678,14 @@ const Inspiration: React.FC = () => {
|
||||
// 生成完成回调
|
||||
const handleComplete = (projectId: string) => {
|
||||
console.log('灵感模式项目创建完成:', projectId);
|
||||
// 确保清除缓存
|
||||
clearCache();
|
||||
setCurrentStep('complete');
|
||||
};
|
||||
|
||||
// 返回对话界面
|
||||
const handleBackToChat = () => {
|
||||
clearCache();
|
||||
setCurrentStep('idea');
|
||||
setGenerationConfig(null);
|
||||
handleRestart();
|
||||
@@ -543,29 +746,36 @@ const Inspiration: React.FC = () => {
|
||||
{msg.options.map((option, optIndex) => (
|
||||
<Card
|
||||
key={optIndex}
|
||||
hoverable
|
||||
hoverable={!msg.optionsDisabled}
|
||||
size="small"
|
||||
onClick={() => handleSelectOption(option)}
|
||||
onClick={() => !msg.optionsDisabled && handleSelectOption(option)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
cursor: msg.optionsDisabled ? 'not-allowed' : 'pointer',
|
||||
border: msg.isMultiSelect && selectedOptions.includes(option)
|
||||
? '2px solid #1890ff'
|
||||
: '1px solid #d9d9d9',
|
||||
background: msg.isMultiSelect && selectedOptions.includes(option)
|
||||
background: msg.optionsDisabled
|
||||
? '#f5f5f5'
|
||||
: msg.isMultiSelect && selectedOptions.includes(option)
|
||||
? '#e6f7ff'
|
||||
: '#fff',
|
||||
opacity: msg.optionsDisabled ? 0.6 : 1,
|
||||
animation: 'floatIn 0.6s ease-out',
|
||||
animationDelay: `${optIndex * 0.1}s`,
|
||||
animationFillMode: 'both',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)';
|
||||
if (!msg.optionsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
if (!msg.optionsDisabled) {
|
||||
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
@@ -733,7 +943,7 @@ const Inspiration: React.FC = () => {
|
||||
|
||||
{(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' ||
|
||||
currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' ||
|
||||
currentStep === 'confirm') && renderChat()}
|
||||
currentStep === 'outline_mode' || currentStep === 'confirm') && renderChat()}
|
||||
{(currentStep === 'generating' || currentStep === 'complete') && generationConfig && (
|
||||
<AIProjectGenerator
|
||||
config={generationConfig}
|
||||
|
||||
@@ -50,15 +50,17 @@ export default function Login() {
|
||||
if (response.success) {
|
||||
message.success('登录成功!');
|
||||
|
||||
// 检查今天是否已经显示过公告
|
||||
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
|
||||
const now = new Date().getTime();
|
||||
// 检查是否永久隐藏公告
|
||||
const hideForever = localStorage.getItem('announcement_hide_forever');
|
||||
const hideToday = localStorage.getItem('announcement_hide_today');
|
||||
const today = new Date().toDateString();
|
||||
|
||||
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
|
||||
setShowAnnouncement(true);
|
||||
} else {
|
||||
// 如果永久隐藏或今日已隐藏,则不显示公告
|
||||
if (hideForever === 'true' || hideToday === today) {
|
||||
const redirect = searchParams.get('redirect') || '/';
|
||||
navigate(redirect);
|
||||
} else {
|
||||
setShowAnnouncement(true);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -203,10 +205,14 @@ export default function Login() {
|
||||
};
|
||||
|
||||
const handleDoNotShowToday = () => {
|
||||
// 设置到今天23:59:59不再显示
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setHours(23, 59, 59, 999);
|
||||
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
|
||||
// 设置今日不再显示
|
||||
const today = new Date().toDateString();
|
||||
localStorage.setItem('announcement_hide_today', today);
|
||||
};
|
||||
|
||||
const handleNeverShow = () => {
|
||||
// 设置永久不再显示
|
||||
localStorage.setItem('announcement_hide_forever', 'true');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -215,6 +221,7 @@ export default function Login() {
|
||||
visible={showAnnouncement}
|
||||
onClose={handleAnnouncementClose}
|
||||
onDoNotShowToday={handleDoNotShowToday}
|
||||
onNeverShow={handleNeverShow}
|
||||
/>
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -1377,7 +1377,14 @@ export default function Outline() {
|
||||
justifyContent: 'space-between',
|
||||
alignItems: isMobile ? 'stretch' : 'center'
|
||||
}}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}>故事大纲</h2>
|
||||
{currentProject?.outline_mode && (
|
||||
<Tag color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'} style={{ width: 'fit-content' }}>
|
||||
{currentProject.outline_mode === 'one-to-one' ? '传统模式 (1→1)' : '细化模式 (1→N)'}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<Space size="small" wrap={isMobile}>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -1388,7 +1395,7 @@ export default function Outline() {
|
||||
>
|
||||
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
|
||||
</Button>
|
||||
{outlines.length > 0 && (
|
||||
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
|
||||
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
|
||||
<Button
|
||||
icon={<AppstoreAddOutlined />}
|
||||
@@ -1421,16 +1428,18 @@ export default function Outline() {
|
||||
alignItems: isMobile ? 'flex-start' : 'center'
|
||||
}}
|
||||
actions={isMobile ? undefined : [
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
>
|
||||
展开
|
||||
</Button>
|
||||
</Tooltip>,
|
||||
...(currentProject?.outline_mode === 'one-to-many' ? [
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
>
|
||||
展开
|
||||
</Button>
|
||||
</Tooltip>
|
||||
] : []), // 一对一模式:不显示任何展开/创建按钮
|
||||
<Button
|
||||
type="text"
|
||||
icon={<EditOutlined />}
|
||||
@@ -1458,11 +1467,13 @@ export default function Outline() {
|
||||
第{item.order_index || '?'}卷
|
||||
</span>
|
||||
<span>{item.title}</span>
|
||||
{/* ✅ 新增:展开状态标识 */}
|
||||
{outlineExpandStatus[item.id] ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>已展开</Tag>
|
||||
) : (
|
||||
<Tag color="default">未展开</Tag>
|
||||
{/* ✅ 新增:展开状态标识 - 仅在一对多模式显示 */}
|
||||
{currentProject?.outline_mode === 'one-to-many' && (
|
||||
outlineExpandStatus[item.id] ? (
|
||||
<Tag color="success" icon={<CheckCircleOutlined />}>已展开</Tag>
|
||||
) : (
|
||||
<Tag color="default">未展开</Tag>
|
||||
)
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
@@ -1482,15 +1493,19 @@ export default function Outline() {
|
||||
onClick={() => handleOpenEditModal(item.id)}
|
||||
size="small"
|
||||
/>
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
{/* 一对多模式:显示展开按钮 */}
|
||||
{currentProject?.outline_mode === 'one-to-many' && (
|
||||
<Tooltip title="展开为多章">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<BranchesOutlined />}
|
||||
onClick={() => handleExpandOutline(item.id, item.title)}
|
||||
loading={isExpanding}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* 一对一模式:不显示任何展开/创建按钮 */}
|
||||
<Popconfirm
|
||||
title="确定删除这条大纲吗?"
|
||||
onConfirm={() => handleDeleteOutline(item.id)}
|
||||
|
||||
@@ -801,7 +801,12 @@ export default function ProjectList() {
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
|
||||
{(project.current_words / 1000).toFixed(1)}K
|
||||
{project.current_words >= 1000000
|
||||
? (project.current_words / 1000000).toFixed(1) + 'M'
|
||||
: project.current_words >= 1000
|
||||
? (project.current_words / 1000).toFixed(1) + 'K'
|
||||
: project.current_words
|
||||
}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>已写字数</Text>
|
||||
</div>
|
||||
@@ -814,7 +819,14 @@ export default function ProjectList() {
|
||||
borderRadius: 8
|
||||
}}>
|
||||
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
|
||||
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
|
||||
{project.target_words
|
||||
? (project.target_words >= 1000000
|
||||
? (project.target_words / 1000000).toFixed(1) + 'M'
|
||||
: project.target_words >= 1000
|
||||
? (project.target_words / 1000).toFixed(1) + 'K'
|
||||
: project.target_words)
|
||||
: '--'
|
||||
}
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>目标字数</Text>
|
||||
</div>
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Form, Input, InputNumber, Select, Button, Card,
|
||||
Row, Col, Typography, Space, message
|
||||
Row, Col, Typography, Space, message, Radio
|
||||
} from 'antd';
|
||||
import {
|
||||
RocketOutlined, ArrowLeftOutlined
|
||||
RocketOutlined, ArrowLeftOutlined, CheckCircleOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator';
|
||||
import type { WizardBasicInfo } from '../types';
|
||||
@@ -83,6 +83,7 @@ export default function ProjectWizardNew() {
|
||||
target_words: values.target_words || 100000,
|
||||
chapter_count: 3, // 默认生成3章大纲
|
||||
character_count: values.character_count || 5,
|
||||
outline_mode: values.outline_mode || 'one-to-many', // 添加大纲模式
|
||||
};
|
||||
|
||||
setGenerationConfig(config);
|
||||
@@ -120,6 +121,7 @@ export default function ProjectWizardNew() {
|
||||
narrative_perspective: '第三人称',
|
||||
character_count: 5,
|
||||
target_words: 100000,
|
||||
outline_mode: 'one-to-many', // 默认为细化模式
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
@@ -181,6 +183,71 @@ export default function ProjectWizardNew() {
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="大纲章节模式"
|
||||
name="outline_mode"
|
||||
rules={[{ required: true, message: '请选择大纲章节模式' }]}
|
||||
tooltip="创建后不可更改,请根据创作习惯选择"
|
||||
>
|
||||
<Radio.Group size="large">
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
borderColor: form.getFieldValue('outline_mode') === 'one-to-one' ? '#1890ff' : '#d9d9d9',
|
||||
borderWidth: 2,
|
||||
height: '100%',
|
||||
}}
|
||||
onClick={() => form.setFieldValue('outline_mode', 'one-to-one')}
|
||||
>
|
||||
<Radio value="one-to-one" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
|
||||
<CheckCircleOutlined style={{ marginRight: 8, color: '#52c41a' }} />
|
||||
传统模式 (1→1)
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
一个大纲对应一个章节,简单直接
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
💡 适合:简单剧情、快速创作、短篇小说
|
||||
</div>
|
||||
</Space>
|
||||
</Radio>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12}>
|
||||
<Card
|
||||
hoverable
|
||||
style={{
|
||||
borderColor: form.getFieldValue('outline_mode') === 'one-to-many' ? '#1890ff' : '#d9d9d9',
|
||||
borderWidth: 2,
|
||||
height: '100%',
|
||||
}}
|
||||
onClick={() => form.setFieldValue('outline_mode', 'one-to-many')}
|
||||
>
|
||||
<Radio value="one-to-many" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
|
||||
<CheckCircleOutlined style={{ marginRight: 8, color: '#52c41a' }} />
|
||||
细化模式 (1→N) 推荐
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: '#666' }}>
|
||||
一个大纲可展开为多个章节,灵活控制
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
💡 适合:复杂剧情、长篇创作、需要细化控制
|
||||
</div>
|
||||
</Space>
|
||||
</Radio>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Form.Item
|
||||
|
||||
Reference in New Issue
Block a user