import { useEffect, useState, useRef, useCallback } from 'react'; 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'; import { useStore } from '../store'; import { useProjectSync } from '../store/hooks'; import { eventBus, EventNames } from '../store/eventBus'; import type { ReactNode } from 'react'; import { cardStyles, cardHoverHandlers } from '../components/CardStyles'; import UserMenu from '../components/UserMenu'; import ChangelogFloatingButton from '../components/ChangelogFloatingButton'; import SettingsPage from './Settings'; import MCPPluginsPage from './MCPPlugins'; import PromptTemplates from './PromptTemplates'; 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'; } }; 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(null); const [validationResult, setValidationResult] = useState(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([]); const [exportOptions, setExportOptions] = useState({ includeWritingStyles: true, includeGenerationHistory: false, includeCareers: true, includeMemories: false, includePlotAnalysis: false, }); const { refreshProjects, deleteProject } = useProjectSync(); const scrollContainerRef = useRef(null); // 处理切换到 MCP 视图的事件 const handleSwitchToMcp = useCallback(() => { setActiveView('mcp'); }, []); useEffect(() => { refreshProjects(); // 监听切换到 MCP 视图的事件 eventBus.on(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp); return () => { eventBus.off(EventNames.SWITCH_TO_MCP_VIEW, handleSwitchToMcp); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [handleSwitchToMcp]); 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({ 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}`); } }; const getStatusTag = (status: string) => { const statusConfig: Record = { planning: { color: 'blue', text: '规划', icon: }, writing: { color: 'green', text: '创作', icon: }, revising: { color: 'orange', text: '修订', icon: }, completed: { color: 'purple', text: '已完结', icon: }, }; const config = statusConfig[status] || statusConfig.planning; return ( {config.text} ); }; // 根据进度获取显示状态(进度达到100%时显示已完结) const getDisplayStatus = (status: string, progress: number): string => { if (progress >= 100) { return 'completed'; } return status; }; 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)); 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; 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; return (
{contextHolder} {/* 侧边栏 - 仅桌面端显示 - 样式对齐 ProjectDetail */} {!isMobile && (
{/* Logo 区域 - 保持 Primary Color 风格 */}
MuMuAINovel
{/* 侧边栏菜单 - 使用 Menu 组件以保持风格一致 */}
{/* 模拟 Menu 样式 */}
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')} > 我的书架
创作工具
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')} > 提示词管理
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')} > MCP 插件
系统设置
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')} > API 设置
{/* 底部用户信息 */}
)} {/* 主内容区域容器 */}
{/* 移动端顶部导航栏 */} {isMobile && (
{activeView === 'projects' ? '我的书架' : activeView === 'prompts' ? '提示词模板' : activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
)} {/* 移动端侧边栏 Drawer */} {isMobile && (
MuMuAINovel
} closeIcon={null} extra={
{/* 底部用户信息 */}
)} {/* 桌面端顶部标题栏 */} {!isMobile && (

{activeView === 'projects' ? '我的书架' : activeView === 'prompts' ? '提示词模板' : activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}

{activeView === 'projects' && (
{/* 导入导出按钮 */} {/* 统计数据:创作中 已完结 总字数 */} {projects.length > 0 && (
{[ { label: '创作中', value: activeProjects, unit: '本' }, { label: '已完结', value: completedProjects, unit: '本' }, { label: '总字数', value: totalWords, unit: '字' }, ].map((item, index) => (
{ 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)'; }} 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)'; }} > {item.label} {item.label === '总字数' ? formatWordCount(item.value) : item.value} {item.unit && {item.unit}}
))}
)}
)}
)} {/* 内容显示区 */}
{activeView === 'settings' && } {activeView === 'mcp' && } {activeView === 'prompts' && } {activeView === 'projects' && (
{showApiTip && projects.length === 0 && ( 在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。
} type="info" showIcon closable onClose={() => setShowApiTip(false)} style={{ marginBottom: isMobile ? 16 : 24, borderRadius: 12 }} /> )}
{/* 新建/灵感卡片 */}
开始一个新的创作旅程
{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 (
handleEnterProject(project)} > {/* 卡片头部 - 参考图片样式 */}
{/* 标题行:图标 + 标题 */}
{project.title}
{/* 标签行 - 单行不换行 */}
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => ( {tag} )) : ( 未分类 )}
{/* 右上角状态标签 - 带文字和图标 */}
{isWizardIncomplete ? ( } style={{ margin: 0, borderRadius: 4, fontSize: isMobile ? 10 : 12, padding: isMobile ? '0 6px' : '2px 10px', fontWeight: 500 }} > 生成中 ) : ( getStatusTag(getDisplayStatus(project.status, progress)) )}
{/* 描述区域 */}
{project.description || '暂无描述...'}
{/* 进度条区域 */}
完成进度 {progress}%
{/* 字数统计区域 */}
{formatWordCount(project.current_words || 0)}
已写字数
{formatWordCount(project.target_words || 0)}
目标字数
{/* 卡片底部 - 时间和操作 */}
{formatDate(project.updated_at)}
); })}
)}
{/* 导入项目对话框 */}

选择之前导出的 JSON 格式项目文件

{ setSelectedFile(null); setValidationResult(null); }} // eslint-disable-next-line @typescript-eslint/no-explicit-any fileList={selectedFile ? [{ uid: '-1', name: selectedFile.name, status: 'done' }] as any : []} >
{validating && (
)} {validationResult && (
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
{validationResult.project_name && (
项目名称: {validationResult.project_name}
)} {validationResult.statistics && (
数据统计: {validationResult.statistics.chapters > 0 && 章节: {validationResult.statistics.chapters}} {validationResult.statistics.characters > 0 && 角色: {validationResult.statistics.characters}} {validationResult.statistics.outlines > 0 && 大纲: {validationResult.statistics.outlines}} {validationResult.statistics.relationships > 0 && 关系: {validationResult.statistics.relationships}} {validationResult.statistics.organizations > 0 && 组织: {validationResult.statistics.organizations}} {validationResult.statistics.careers > 0 && 职业: {validationResult.statistics.careers}} {validationResult.statistics.character_careers > 0 && 职业关联: {validationResult.statistics.character_careers}} {validationResult.statistics.writing_styles > 0 && 写作风格: {validationResult.statistics.writing_styles}} {validationResult.statistics.story_memories > 0 && 故事记忆: {validationResult.statistics.story_memories}} {validationResult.statistics.plot_analysis > 0 && 剧情分析: {validationResult.statistics.plot_analysis}} {validationResult.statistics.generation_history > 0 && 生成历史: {validationResult.statistics.generation_history}} {validationResult.statistics.has_default_style && 含默认风格}
)} {validationResult.warnings?.length > 0 && (
提示:
    {validationResult.warnings.map((w: string, i: number) =>
  • {w}
  • )}
)} {validationResult.errors?.length > 0 && (
错误:
    {validationResult.errors.map((e: string, i: number) =>
  • {e}
  • )}
)}
)}
{/* 导出项目对话框 */} 0 ? `导出 (${selectedProjectIds.length})` : '导出'} cancelText="取消" width={isMobile ? '90%' : 700} centered okButtonProps={{ disabled: selectedProjectIds.length === 0 }} > 导出选项
setExportOptions(prev => ({...prev, includeWritingStyles: e.target.checked}))}>写作风格 setExportOptions(prev => ({...prev, includeCareers: e.target.checked}))}>职业系统 setExportOptions(prev => ({...prev, includeGenerationHistory: e.target.checked}))}>生成历史 setExportOptions(prev => ({...prev, includeMemories: e.target.checked}))}>故事记忆 setExportOptions(prev => ({...prev, includePlotAnalysis: e.target.checked}))}>剧情分析
选择项目 ({exportableProjects.length}) 0} indeterminate={selectedProjectIds.length > 0 && selectedProjectIds.length < exportableProjects.length} onChange={handleToggleAll} > 全选
{exportableProjects.map(p => (
handleToggleProject(p.id)} >
{p.title}
{formatWordCount(p.current_words || 0)} 字 · {getStatusTag(getDisplayStatus(p.status, getProgress(p.current_words || 0, p.target_words || 0)))}
))}
); }