Files
MuMuAINovel/frontend/src/pages/ProjectList.tsx
T

1262 lines
54 KiB
TypeScript
Raw Normal View History

import { useEffect, useState, useRef, useCallback } from 'react';
2025-10-30 11:14:43 +08:00
import { useNavigate } from 'react-router-dom';
import { Card, Button, Modal, message, Spin, Space, Tag, Progress, Typography, Alert, Upload, Checkbox, Tooltip, Drawer, Menu } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, BulbOutlined, LoadingOutlined, FileSearchOutlined, MenuUnfoldOutlined, CloseOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
2025-10-30 11:14:43 +08:00
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import { eventBus, EventNames } from '../store/eventBus';
2025-10-30 11:14:43 +08:00
import type { ReactNode } from 'react';
import { cardStyles, cardHoverHandlers } from '../components/CardStyles';
2025-10-30 11:14:43 +08:00
import UserMenu from '../components/UserMenu';
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import SettingsPage from './Settings';
import MCPPluginsPage from './MCPPlugins';
import PromptTemplates from './PromptTemplates';
2025-10-30 11:14:43 +08:00
const { Title, Text, Paragraph } = Typography;
/**
* 格式化字数显示
* @param count 字数
* @returns 格式化后的字符串,如 "1.2K", "3.5W", "1.2M"
*/
const formatWordCount = (count: number): string => {
if (count < 1000) {
return count.toString();
} else if (count < 10000) {
// 1K - 9.9K
return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
} else if (count < 1000000) {
// 1W - 99.9W (万)
return (count / 10000).toFixed(1).replace(/\.0$/, '') + 'W';
} else {
// 1M+ (百万)
return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M';
}
};
2025-10-30 11:14:43 +08:00
export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts'>('projects');
const [drawerVisible, setDrawerVisible] = useState(false);
const [modal, contextHolder] = Modal.useModal();
const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [validationResult, setValidationResult] = useState<any>(null); // eslint-disable-line @typescript-eslint/no-explicit-any
const [importing, setImporting] = useState(false);
const [validating, setValidating] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [exportOptions, setExportOptions] = useState({
includeWritingStyles: true,
includeGenerationHistory: false,
includeCareers: true,
includeMemories: false,
includePlotAnalysis: false,
});
2025-10-30 11:14:43 +08:00
const { refreshProjects, deleteProject } = useProjectSync();
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 处理切换到 MCP 视图的事件
const handleSwitchToMcp = useCallback(() => {
setActiveView('mcp');
}, []);
2025-10-30 11:14:43 +08:00
useEffect(() => {
refreshProjects();
// 监听切换到 MCP 视图的事件
eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp);
return () => {
eventBus.off(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp);
};
2025-10-30 11:14:43 +08:00
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [handleSwitchToMcp]);
2025-10-30 11:14:43 +08:00
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshProjects();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDelete = (id: string) => {
const isMobile = window.innerWidth <= 768;
modal.confirm({
2025-10-30 11:14:43 +08:00
title: '确认删除',
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
centered: true,
...(isMobile && {
style: { top: 'auto' }
}),
onOk: async () => {
try {
await deleteProject(id);
message.success('项目删除成功');
} catch {
message.error('删除项目失败');
}
},
});
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const handleEnterProject = async (project: any) => {
if (project.wizard_status === 'incomplete') {
navigate(`/wizard?project_id=${project.id}`);
} else {
navigate(`/project/${project.id}`);
}
2025-10-30 11:14:43 +08:00
};
const getStatusTag = (status: string) => {
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
planning: { color: 'blue', text: '规划', icon: <CalendarOutlined /> },
writing: { color: 'green', text: '创作', icon: <EditOutlined /> },
revising: { color: 'orange', text: '修订', icon: <FileTextOutlined /> },
completed: { color: 'purple', text: '已完结', icon: <TrophyOutlined /> },
2025-10-30 11:14:43 +08:00
};
const config = statusConfig[status] || statusConfig.planning;
return (
<Tag color={config.color} icon={config.icon} style={{ margin: 0, borderRadius: 4, flexShrink: 0 }}>
2025-10-30 11:14:43 +08:00
{config.text}
</Tag>
);
};
// 根据进度获取显示状态(进度达到100%时显示已完结)
const getDisplayStatus = (status: string, progress: number): string => {
if (progress >= 100) {
return 'completed';
}
return status;
};
2025-10-30 11:14:43 +08:00
const getProgress = (current: number, target: number) => {
if (!target) return 0;
return Math.min(Math.round((current / target) * 100), 100);
};
const getProgressColor = (progress: number) => {
if (progress >= 80) return '#52c41a';
if (progress >= 50) return '#1890ff';
if (progress >= 20) return '#faad14';
return '#ff4d4f';
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
2025-10-30 11:14:43 +08:00
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN');
};
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
const activeProjects = projects.filter(p => p.status === 'writing').length;
// 计算已完结项目数(进度>=100%或状态为completed
const completedProjects = projects.filter(p => {
const progress = getProgress(p.current_words || 0, p.target_words || 0);
return progress >= 100 || p.status === 'completed';
}).length;
2025-10-30 11:14:43 +08:00
const handleFileSelect = async (file: File) => {
setSelectedFile(file);
setValidationResult(null);
try {
setValidating(true);
const result = await projectApi.validateImportFile(file);
setValidationResult(result);
if (!result.valid) {
message.error('文件验证失败');
}
} catch (error) {
console.error('验证失败:', error);
message.error('文件验证失败');
} finally {
setValidating(false);
}
return false;
};
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) {
message.warning('请选择有效的导入文件');
return;
}
try {
setImporting(true);
const result = await projectApi.importProject(selectedFile);
if (result.success) {
message.success(`项目导入成功!${result.message}`);
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
await refreshProjects();
if (result.project_id) {
navigate(`/project/${result.project_id}`);
}
} else {
message.error(result.message || '导入失败');
}
} catch (error) {
console.error('导入失败:', error);
message.error('导入失败,请重试');
} finally {
setImporting(false);
}
};
const handleCloseImportModal = () => {
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
};
const handleOpenExportModal = () => {
setExportModalVisible(true);
setSelectedProjectIds([]);
};
const exportableProjects = projects;
const handleCloseExportModal = () => {
setExportModalVisible(false);
setSelectedProjectIds([]);
};
const handleToggleProject = (projectId: string) => {
setSelectedProjectIds(prev =>
prev.includes(projectId)
? prev.filter(id => id !== projectId)
: [...prev, projectId]
);
};
const handleToggleAll = () => {
if (selectedProjectIds.length === exportableProjects.length) {
setSelectedProjectIds([]);
} else {
setSelectedProjectIds(exportableProjects.map(p => p.id));
}
};
const handleExport = async () => {
if (selectedProjectIds.length === 0) {
message.warning('请至少选择一个项目');
return;
}
try {
setExporting(true);
if (selectedProjectIds.length === 1) {
const projectId = selectedProjectIds[0];
const project = projects.find(p => p.id === projectId);
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles,
include_careers: exportOptions.includeCareers,
include_memories: exportOptions.includeMemories,
include_plot_analysis: exportOptions.includePlotAnalysis
});
message.success(`项目 "${project?.title}" 导出成功`);
} else {
let successCount = 0;
let failCount = 0;
for (const projectId of selectedProjectIds) {
try {
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles,
include_careers: exportOptions.includeCareers,
include_memories: exportOptions.includeMemories,
include_plot_analysis: exportOptions.includePlotAnalysis
});
successCount++;
await new Promise(resolve => setTimeout(resolve, 500));
} catch (error) {
console.error(`导出项目 ${projectId} 失败:`, error);
failCount++;
}
}
if (failCount === 0) {
message.success(`成功导出 ${successCount} 个项目`);
} else {
message.warning(`导出完成:成功 ${successCount} 个,失败 ${failCount}`);
}
}
handleCloseExportModal();
} catch (error) {
console.error('导出失败:', error);
message.error('导出失败,请重试');
} finally {
setExporting(false);
}
};
const isMobile = window.innerWidth <= 768;
2025-10-30 11:14:43 +08:00
return (
<div style={{
height: '100vh',
display: 'flex',
flexDirection: 'row', // 改为行布局,容纳侧边栏
background: 'var(--color-bg-base)',
overflow: 'hidden'
2025-10-30 11:14:43 +08:00
}}>
{contextHolder}
{/* 侧边栏 - 仅桌面端显示 - 样式对齐 ProjectDetail */}
{!isMobile && (
<div style={{
width: 220, // 对齐 ProjectDetail 的宽度
background: '#fff',
borderRight: '1px solid rgba(0,0,0,0.06)',
display: 'flex',
flexDirection: 'column',
zIndex: 10,
position: 'fixed', // 固定定位,与 ProjectDetail 一致
left: 0,
top: 0,
bottom: 0,
boxShadow: '4px 0 16px rgba(0,0,0,0.02)'
}}>
{/* Logo 区域 - 保持 Primary Color 风格 */}
<div style={{
height: 70,
display: 'flex',
alignItems: 'center',
padding: '0 24px',
background: 'var(--color-primary)',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32,
height: 32,
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 18,
backdropFilter: 'blur(4px)'
}}>
<BookOutlined />
</div>
<Title level={5} style={{ margin: 0, color: '#fff', fontWeight: 600 }}>
MuMuAINovel
</Title>
</div>
</div>
{/* 侧边栏菜单 - 使用 Menu 组件以保持风格一致 */}
<div style={{ flex: 1, overflowY: 'auto', paddingTop: 16 }}>
{/* 模拟 Menu 样式 */}
<div style={{ padding: '0 12px 12px 12px' }}>
<div
onClick={() => setActiveView('projects')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'projects' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'projects' ? '#e6f7ff' : 'transparent',
marginBottom: 4,
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
borderRight: activeView === 'projects' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'projects' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'projects' && (e.currentTarget.style.background = 'transparent')}
>
<BookOutlined />
</div>
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}></div>
<div
onClick={() => setActiveView('prompts')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
marginBottom: 4,
borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
>
<FileSearchOutlined />
</div>
<div
onClick={() => setActiveView('mcp')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'mcp' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'mcp' ? '#e6f7ff' : 'transparent',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
marginBottom: 4,
borderRight: activeView === 'mcp' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'mcp' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'mcp' && (e.currentTarget.style.background = 'transparent')}
>
<ApiOutlined />
MCP
</div>
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}></div>
<div
onClick={() => setActiveView('settings')}
style={{
padding: '10px 16px',
fontSize: 14,
cursor: 'pointer',
borderRadius: 4,
color: activeView === 'settings' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
background: activeView === 'settings' ? '#e6f7ff' : 'transparent',
fontWeight: 500,
display: 'flex',
alignItems: 'center',
gap: 10,
transition: 'all 0.3s',
marginBottom: 4,
borderRight: activeView === 'settings' ? '3px solid var(--color-primary)' : '3px solid transparent'
}}
onMouseEnter={e => activeView !== 'settings' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
onMouseLeave={e => activeView !== 'settings' && (e.currentTarget.style.background = 'transparent')}
>
<SettingOutlined />
API
</div>
</div>
</div>
{/* 底部用户信息 */}
<div style={{ padding: 16, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<UserMenu />
</div>
</div>
)}
{/* 主内容区域容器 */}
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden',
marginLeft: isMobile ? 0 : 220 // 为固定定位的侧边栏留出空间
}}>
{/* 移动端顶部导航栏 */}
{isMobile && (
<div style={{
flexShrink: 0,
background: 'var(--color-primary)',
boxShadow: 'var(--shadow-header)',
height: 56,
padding: '0 12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
zIndex: 100
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Button
type="text"
icon={<MenuUnfoldOutlined />}
onClick={() => setDrawerVisible(true)}
style={{
fontSize: 18,
color: '#fff',
width: 36,
height: 36
}}
/>
</div>
<span style={{
color: '#fff',
fontWeight: 600,
fontSize: 16,
flex: 1,
textAlign: 'center',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
paddingRight: 36 // 为了与左侧菜单按钮对称
}}>
{activeView === 'projects' ? '我的书架' :
activeView === 'prompts' ? '提示词模板' :
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
</span>
</div>
)}
{/* 移动端侧边栏 Drawer */}
{isMobile && (
<Drawer
title={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 32,
height: 32,
background: 'var(--color-primary)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 16,
}}>
<BookOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 16 }}>MuMuAINovel</span>
</div>
}
closeIcon={null}
extra={
<Button
type="text"
icon={<CloseOutlined />}
onClick={() => setDrawerVisible(false)}
style={{ fontSize: 16, color: 'rgba(0,0,0,0.45)' }}
/>
}
placement="left"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
width="60%"
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
>
<div style={{ flex: 1, overflowY: 'auto' }}>
<Menu
mode="inline"
selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 8 }}
onClick={({ key }) => {
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts');
setDrawerVisible(false);
}}
items={[
{
key: 'projects',
icon: <BookOutlined />,
label: '我的书架',
},
{
type: 'group',
label: '创作工具',
children: [
{
key: 'prompts',
icon: <FileSearchOutlined />,
label: '提示词管理',
},
{
key: 'mcp',
icon: <ApiOutlined />,
label: 'MCP 插件',
},
],
},
{
type: 'group',
label: '系统设置',
children: [
{
key: 'settings',
icon: <SettingOutlined />,
label: 'API 设置',
},
],
},
]}
/>
{/* 导入导出操作 */}
<div style={{ padding: '16px', borderTop: '1px solid rgba(0,0,0,0.06)', marginTop: 16 }}>
<Space direction="vertical" style={{ width: '100%' }} size={8}>
<Button
icon={<UploadOutlined />}
block
onClick={() => {
setImportModalVisible(true);
setDrawerVisible(false);
}}
>
</Button>
<Button
icon={<DownloadOutlined />}
block
onClick={() => {
handleOpenExportModal();
setDrawerVisible(false);
}}
disabled={exportableProjects.length === 0}
>
</Button>
</Space>
</div>
</div>
{/* 底部用户信息 */}
<div style={{ padding: 16, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
<UserMenu showFullInfo />
</div>
</Drawer>
)}
{/* 桌面端顶部标题栏 */}
{!isMobile && (
<div style={{
height: 70, // 与 ProjectDetail header 高度一致
padding: '0 24px',
background: 'var(--color-primary)', // 使用主题色背景
boxShadow: 'var(--shadow-header)',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
zIndex: 100
}}>
<h2 style={{
margin: 0,
color: '#fff',
fontSize: '24px',
fontWeight: 600,
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
display: 'flex',
alignItems: 'center',
gap: 12
}}>
{activeView === 'projects' ? '我的书架' :
activeView === 'prompts' ? '提示词模板' :
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
</h2>
{activeView === 'projects' && (
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
{/* 导入导出按钮 */}
<Space>
<Button ghost icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}></Button>
<Button ghost icon={<DownloadOutlined />} onClick={handleOpenExportModal} disabled={exportableProjects.length === 0} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}></Button>
</Space>
{/* 统计数据:创作中 已完结 总字数 */}
{projects.length > 0 && (
<div style={{ display: 'flex', gap: '16px' }}>
{[
{ label: '创作中', value: activeProjects, unit: '本' },
{ label: '已完结', value: completedProjects, unit: '本' },
{ label: '总字数', value: totalWords, unit: '字' },
].map((item, index) => (
<div
key={index}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
borderRadius: '28px', // 圆角风格
minWidth: '56px',
height: '56px',
padding: '0 12px',
boxShadow: 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)',
cursor: 'default',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)';
e.currentTarget.style.boxShadow = 'inset 0 0 20px rgba(255, 255, 255, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15)';
e.currentTarget.style.border = '1px solid rgba(255, 255, 255, 0.1)';
2025-11-14 19:28:49 +08:00
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)';
}}
>
<span style={{ fontSize: '11px', color: 'rgba(255, 255, 255, 0.9)', marginBottom: '2px', lineHeight: 1 }}>
{item.label}
</span>
<span style={{ fontSize: '15px', fontWeight: '600', color: '#fff', lineHeight: 1, fontFamily: 'Monaco, monospace' }}>
{item.label === '总字数' ? formatWordCount(item.value) : item.value}
{item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
)}
2025-10-30 11:14:43 +08:00
{/* 内容显示区 */}
<div
ref={scrollContainerRef}
style={{
flex: 1,
overflowY: 'auto',
padding: activeView === 'projects' ? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px` : 0,
background: 'var(--color-bg-base)',
}}
>
{activeView === 'settings' && <SettingsPage />}
{activeView === 'mcp' && <MCPPluginsPage />}
{activeView === 'prompts' && <PromptTemplates />}
{activeView === 'projects' && (
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>
{showApiTip && projects.length === 0 && (
<Alert
message="欢迎使用 MuMuAINovel"
2025-10-30 11:14:43 +08:00
description={
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 12 : 16,
justifyContent: 'space-between'
}}>
<span style={{ fontSize: isMobile ? 12 : 14 }}>
AI接口 OpenAI / Anthropic
</span>
<Button
size="small"
type="primary"
onClick={() => setActiveView('settings')}
style={{ flexShrink: 0 }}
>
</Button>
</div>
2025-10-30 11:14:43 +08:00
}
type="info"
showIcon
closable
onClose={() => setShowApiTip(false)}
style={{
marginBottom: isMobile ? 16 : 24,
borderRadius: 12
}}
2025-10-30 11:14:43 +08:00
/>
)}
<Spin spinning={loading}>
<div style={{
...cardStyles.bookshelf,
// 移动端显示一列
...(isMobile && {
gridTemplateColumns: '1fr',
gap: '16px',
padding: '16px 0',
})
}}>
{/* 新建/灵感卡片 */}
<div style={{ position: 'relative', width: '100%', minWidth: 0, minHeight: isMobile ? 300 : 330 }}>
<Card
hoverable
style={{ ...cardStyles.newProjectBook, minHeight: isMobile ? 300 : 330 }}
styles={{ body: { padding: isMobile ? '16px' : '24px', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' } }}
{...cardHoverHandlers}
data-type="new-project"
>
<div style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
gap: isMobile ? 10 : 16,
justifyContent: 'center'
}}>
<Button
type="primary"
size={isMobile ? 'middle' : 'large'}
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{ height: isMobile ? '38px' : '50px', fontSize: isMobile ? '14px' : '16px' }}
block
>
</Button>
<Button
size={isMobile ? 'middle' : 'large'}
icon={<BulbOutlined />}
onClick={() => navigate('/inspiration')}
style={{
height: isMobile ? '38px' : '50px',
fontSize: isMobile ? '14px' : '16px',
borderColor: '#faad14',
color: '#faad14',
background: 'rgba(250, 173, 20, 0.1)'
}}
block
>
</Button>
<div style={{ textAlign: 'center', color: '#999', fontSize: isMobile ? 11 : 12, marginTop: isMobile ? 4 : 8 }}>
</div>
</div>
</Card>
</div>
2025-10-30 11:14:43 +08:00
{Array.isArray(projects) && projects.map((project) => {
const progress = getProgress(project.current_words, project.target_words || 0);
const isWizardIncomplete = project.wizard_status === 'incomplete';
// 解析标签(假设存储在 genre 字段,用逗号或顿号分隔)
const tags = project.genre ? project.genre.split(/[,、,]/).map((t: string) => t.trim()).filter((t: string) => t) : [];
return (
<div key={project.id} style={{ position: 'relative', width: '100%', minWidth: 0 }}>
<Card
hoverable
style={cardStyles.project}
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
{...cardHoverHandlers}
onClick={() => handleEnterProject(project)}
>
{/* 卡片头部 - 参考图片样式 */}
<div style={{
padding: isMobile ? '14px 14px 10px' : '18px 20px 14px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
position: 'relative'
}}>
<div style={{ flex: 1, minWidth: 0, marginRight: isMobile ? 8 : 12 }}>
{/* 标题行:图标 + 标题 */}
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 6 : 8, marginBottom: isMobile ? 6 : 10 }}>
<BookOutlined style={{
fontSize: isMobile ? 14 : 16,
color: 'var(--color-primary)',
flexShrink: 0
}} />
<Tooltip title={project.title}>
<div style={{
fontSize: isMobile ? 14 : 16,
fontWeight: 600,
color: 'var(--color-text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineHeight: 1.3
}}>
{project.title}
</div>
</Tooltip>
</div>
{/* 标签行 - 单行不换行 */}
<div style={{
display: 'flex',
gap: isMobile ? 4 : 6,
overflow: 'hidden',
flexWrap: 'nowrap'
}}>
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
<Tag key={idx} style={{
margin: 0,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
padding: isMobile ? '0 6px' : '0 8px',
border: 'none',
borderRadius: 4,
background: 'rgba(82, 196, 26, 0.1)',
color: '#52c41a',
fontWeight: 500
}}>
{tag}
</Tag>
)) : (
<Tag style={{
margin: 0,
fontSize: isMobile ? 10 : 11,
lineHeight: isMobile ? '18px' : '20px',
padding: isMobile ? '0 6px' : '0 8px',
border: 'none',
borderRadius: 4,
background: 'rgba(82, 196, 26, 0.1)',
color: '#52c41a',
fontWeight: 500
}}>
</Tag>
)}
</div>
</div>
{/* 右上角状态标签 - 带文字和图标 */}
<div style={{ flexShrink: 0 }}>
{isWizardIncomplete ? (
<Tag
color="warning"
icon={<LoadingOutlined />}
style={{
margin: 0,
borderRadius: 4,
fontSize: isMobile ? 10 : 12,
padding: isMobile ? '0 6px' : '2px 10px',
fontWeight: 500
}}
>
</Tag>
) : (
getStatusTag(getDisplayStatus(project.status, progress))
)}
</div>
2025-10-30 11:14:43 +08:00
</div>
{/* 描述区域 */}
<div style={{ padding: isMobile ? '0 14px 10px' : '0 20px 14px' }}>
<Paragraph
ellipsis={{ rows: isMobile ? 2 : 2 }}
style={{
fontSize: isMobile ? 12 : 13,
color: 'var(--color-text-secondary)',
marginBottom: 0,
lineHeight: 1.6
}}
>
{project.description || '暂无描述...'}
</Paragraph>
</div>
{/* 进度条区域 */}
<div style={{ padding: isMobile ? '0 14px 12px' : '0 20px 16px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? 6 : 8
}}>
<span style={{
fontSize: isMobile ? 11 : 12,
color: 'var(--color-text-tertiary)'
}}>
</span>
<span style={{
fontSize: isMobile ? 11 : 12,
color: getProgressColor(progress),
fontWeight: 600
}}>
{progress}%
</span>
</div>
<Progress
percent={progress}
showInfo={false}
strokeColor={getProgressColor(progress)}
trailColor="rgba(0, 0, 0, 0.04)"
size="small"
style={{ marginBottom: 0 }}
/>
</div>
{/* 字数统计区域 */}
<div style={{
padding: isMobile ? '12px 14px' : '16px 20px',
background: 'rgba(0, 0, 0, 0.02)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ textAlign: 'center', flex: 1 }}>
<div style={{
fontSize: isMobile ? 18 : 22,
fontWeight: 700,
color: 'var(--color-text-primary)',
lineHeight: 1.2,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
}}>
{formatWordCount(project.current_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: 'var(--color-text-tertiary)',
marginTop: 2
}}>
</div>
</div>
<div style={{
width: 1,
height: isMobile ? 28 : 36,
background: 'rgba(0, 0, 0, 0.06)'
}} />
<div style={{ textAlign: 'center', flex: 1 }}>
<div style={{
fontSize: isMobile ? 18 : 22,
fontWeight: 700,
color: '#52c41a',
lineHeight: 1.2,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
}}>
{formatWordCount(project.target_words || 0)}
</div>
<div style={{
fontSize: isMobile ? 10 : 11,
color: 'var(--color-text-tertiary)',
marginTop: 2
}}>
</div>
</div>
</div>
{/* 卡片底部 - 时间和操作 */}
<div style={{
padding: isMobile ? '10px 14px' : '12px 20px',
borderTop: '1px solid rgba(0,0,0,0.04)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: 'auto'
}}>
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: 'var(--color-text-tertiary)' }}>
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} /> {formatDate(project.updated_at)}
</Space>
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id);
}}
style={{ padding: isMobile ? '2px 4px' : '4px 8px' }}
/>
</div>
</Card>
</div>
);
})}
</div>
</Spin>
</div>
)}
<ChangelogFloatingButton />
</div>
</div>
{/* 导入项目对话框 */}
<Modal
title="导入项目"
open={importModalVisible}
onOk={handleImport}
onCancel={handleCloseImportModal}
confirmLoading={importing}
okText="导入"
cancelText="取消"
width={isMobile ? '90%' : 500}
centered
okButtonProps={{ disabled: !validationResult?.valid }}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<p style={{ marginBottom: '12px', color: '#666' }}>
JSON
</p>
<Upload
accept=".json"
beforeUpload={handleFileSelect}
maxCount={1}
onRemove={() => {
setSelectedFile(null);
setValidationResult(null);
}}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
fileList={selectedFile ? [{ uid: '-1', name: selectedFile.name, status: 'done' }] as any : []}
>
<Button icon={<UploadOutlined />} block></Button>
</Upload>
</div>
{validating && (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="验证文件中..." />
</div>
)}
{validationResult && (
<Card size="small" style={{ background: validationResult.valid ? '#f6ffed' : '#fff2f0' }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div>
<Text strong style={{ color: validationResult.valid ? '#52c41a' : '#ff4d4f' }}>
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
</Text>
</div>
{validationResult.project_name && (
<div>
<Text type="secondary"></Text>
<Text strong>{validationResult.project_name}</Text>
</div>
)}
{validationResult.statistics && (
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}></Text>
<Space size={[6, 6]} wrap>
{validationResult.statistics.chapters > 0 && <Tag color="blue">: {validationResult.statistics.chapters}</Tag>}
{validationResult.statistics.characters > 0 && <Tag color="green">: {validationResult.statistics.characters}</Tag>}
{validationResult.statistics.outlines > 0 && <Tag color="cyan">: {validationResult.statistics.outlines}</Tag>}
{validationResult.statistics.relationships > 0 && <Tag color="purple">: {validationResult.statistics.relationships}</Tag>}
{validationResult.statistics.organizations > 0 && <Tag color="orange">: {validationResult.statistics.organizations}</Tag>}
{validationResult.statistics.careers > 0 && <Tag color="magenta">: {validationResult.statistics.careers}</Tag>}
{validationResult.statistics.character_careers > 0 && <Tag color="geekblue">: {validationResult.statistics.character_careers}</Tag>}
{validationResult.statistics.writing_styles > 0 && <Tag color="lime">: {validationResult.statistics.writing_styles}</Tag>}
{validationResult.statistics.story_memories > 0 && <Tag color="gold">: {validationResult.statistics.story_memories}</Tag>}
{validationResult.statistics.plot_analysis > 0 && <Tag color="volcano">: {validationResult.statistics.plot_analysis}</Tag>}
{validationResult.statistics.generation_history > 0 && <Tag>: {validationResult.statistics.generation_history}</Tag>}
{validationResult.statistics.has_default_style && <Tag color="success"></Tag>}
</Space>
</div>
)}
{validationResult.warnings?.length > 0 && (
<div style={{ marginTop: 8 }}>
<Text type="warning" strong style={{ fontSize: 12 }}></Text>
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: '#faad14', fontSize: 12 }}>
{validationResult.warnings.map((w: string, i: number) => <li key={i}>{w}</li>)}
</ul>
</div>
)}
{validationResult.errors?.length > 0 && (
<div>
<Text type="danger" strong></Text>
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: '#ff4d4f', fontSize: 13 }}>
{validationResult.errors.map((e: string, i: number) => <li key={i}>{e}</li>)}
</ul>
</div>
)}
</Space>
</Card>
)}
</Space>
</Modal>
{/* 导出项目对话框 */}
<Modal
title="导出项目"
open={exportModalVisible}
onOk={handleExport}
onCancel={handleCloseExportModal}
confirmLoading={exporting}
okText={selectedProjectIds.length > 0 ? `导出 (${selectedProjectIds.length})` : '导出'}
cancelText="取消"
width={isMobile ? '90%' : 700}
centered
okButtonProps={{ disabled: selectedProjectIds.length === 0 }}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small" style={{ background: '#f5f5f5' }}>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text strong></Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px 24px' }}>
<Checkbox checked={exportOptions.includeWritingStyles} onChange={e => setExportOptions(prev => ({...prev, includeWritingStyles: e.target.checked}))}></Checkbox>
<Checkbox checked={exportOptions.includeCareers} onChange={e => setExportOptions(prev => ({...prev, includeCareers: e.target.checked}))}></Checkbox>
<Tooltip title="包含生成历史记录,文件可能较大">
<Checkbox checked={exportOptions.includeGenerationHistory} onChange={e => setExportOptions(prev => ({...prev, includeGenerationHistory: e.target.checked}))}></Checkbox>
</Tooltip>
<Tooltip title="包含故事记忆数据,文件可能较大">
<Checkbox checked={exportOptions.includeMemories} onChange={e => setExportOptions(prev => ({...prev, includeMemories: e.target.checked}))}></Checkbox>
</Tooltip>
<Tooltip title="包含AI剧情分析数据">
<Checkbox checked={exportOptions.includePlotAnalysis} onChange={e => setExportOptions(prev => ({...prev, includePlotAnalysis: e.target.checked}))}></Checkbox>
</Tooltip>
</div>
</Space>
</Card>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text> ({exportableProjects.length})</Text>
<Checkbox
checked={selectedProjectIds.length === exportableProjects.length && exportableProjects.length > 0}
indeterminate={selectedProjectIds.length > 0 && selectedProjectIds.length < exportableProjects.length}
onChange={handleToggleAll}
>
</Checkbox>
</div>
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #f0f0f0', borderRadius: 8, padding: 8 }}>
<Space direction="vertical" style={{ width: '100%' }}>
{exportableProjects.map(p => (
<div
key={p.id}
style={{
padding: '8px 12px',
background: selectedProjectIds.includes(p.id) ? '#e6f7ff' : '#fff',
borderRadius: 6,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12
}}
onClick={() => handleToggleProject(p.id)}
>
<Checkbox checked={selectedProjectIds.includes(p.id)} />
<div style={{ flex: 1 }}>
<div>{p.title}</div>
<div style={{ fontSize: 12, color: '#999' }}>{formatWordCount(p.current_words || 0)} · {getStatusTag(getDisplayStatus(p.status, getProgress(p.current_words || 0, p.target_words || 0)))}</div>
</div>
</div>
))}
</Space>
</div>
</div>
</Space>
</Modal>
2025-10-30 11:14:43 +08:00
</div>
);
}