import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { Card, Button, Modal, message, Spin, Space, Tag, Typography, Upload, Checkbox, Tooltip, Drawer, Menu, theme } from 'antd'; import { EditOutlined, BookOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, ApiOutlined, FileSearchOutlined, MenuUnfoldOutlined, MenuFoldOutlined, BulbOutlined, MoonOutlined, DesktopOutlined } 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 type { Project } from '../types'; import UserMenu from '../components/UserMenu'; import ChangelogFloatingButton from '../components/ChangelogFloatingButton'; import ThemeSwitch from '../components/ThemeSwitch'; import { useThemeMode } from '../theme/useThemeMode'; import SettingsPage from './Settings'; import MCPPluginsPage from './MCPPlugins'; import PromptTemplates from './PromptTemplates'; import BookImport from './BookImport'; import BookshelfPage from './BookshelfPage'; import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState'; const { Text } = 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'; } }; type ProjectListView = 'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'; const parseViewFromSearch = (search: string): ProjectListView => { const view = new URLSearchParams(search).get('view'); if (view === 'settings' || view === 'mcp' || view === 'prompts' || view === 'book-import' || view === 'projects') { return view; } return 'projects'; }; export default function ProjectList() { const navigate = useNavigate(); const location = useLocation(); const { projects, loading } = useStore(); const [drawerVisible, setDrawerVisible] = useState(false); const [collapsed, setCollapsed] = useState(() => getStoredSidebarCollapsed()); 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 { mode, resolvedMode, setMode } = useThemeMode(); const { token } = theme.useToken(); const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`; const activeView = useMemo(() => parseViewFromSearch(location.search), [location.search]); const cycleThemeMode = () => { const nextMode = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'; setMode(nextMode); }; const collapsedThemeIcon = mode === 'light' ? : mode === 'dark' ? : ; const changeView = useCallback((view: ProjectListView) => { const searchParams = new URLSearchParams(location.search); if (view === 'projects') { searchParams.delete('view'); } else { searchParams.set('view', view); } const search = searchParams.toString(); navigate( { pathname: location.pathname, search: search ? `?${search}` : '', }, { replace: false } ); }, [location.pathname, location.search, navigate]); const scrollContainerRef = useRef(null); // 处理切换到 MCP 视图的事件 const handleSwitchToMcp = useCallback(() => { changeView('mcp'); }, [changeView]); 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 }, []); useEffect(() => { setStoredSidebarCollapsed(collapsed); }, [collapsed]); 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('删除项目失败'); } }, }); }; const handleEnterProject = async (project: Project) => { 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 token.colorSuccess; if (progress >= 50) return token.colorPrimary; if (progress >= 20) return token.colorWarning; return token.colorError; }; 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; const headerHeight = isMobile ? 56 : 70; const expandedSiderWidth = 220; const collapsedSiderWidth = 60; const desktopSiderWidth = collapsed ? collapsedSiderWidth : expandedSiderWidth; const currentViewTitle = activeView === 'projects' ? '我的书架' : activeView === 'prompts' ? '提示词模板' : activeView === 'book-import' ? '拆书导入' : activeView === 'mcp' ? 'MCP 插件' : 'API 设置'; const sideMenuItems = [ { key: 'projects', icon: , label: '我的书架', }, { type: 'group' as const, label: '创作工具', children: [ { key: 'book-import', icon: , label: '拆书导入', }, { key: 'mcp', icon: , label: 'MCP 插件', }, { key: 'prompts', icon: , label: '提示词管理', }, ], }, { type: 'group' as const, label: '系统设置', children: [ { key: 'settings', icon: , label: 'API 设置', }, ], }, ]; const sideMenuItemsCollapsed = [ { key: 'projects', icon: , label: '我的书架', }, { key: 'book-import', icon: , label: '拆书导入', }, { key: 'mcp', icon: , label: 'MCP 插件', }, { key: 'prompts', icon: , label: '提示词管理', }, { key: 'settings', icon: , label: 'API 设置', }, ]; return (
{contextHolder} {!isMobile && (
{collapsed ? (
{ changeView(key as ProjectListView); }} items={collapsed ? sideMenuItemsCollapsed : sideMenuItems} />
{collapsed ? (
)}
{isMobile ? ( <>

{currentViewTitle}

) : ( <>

{currentViewTitle}

{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 ${alphaColor(token.colorWhite, 0.25)}, 0 8px 16px ${alphaColor(token.colorText, 0.15)}`; e.currentTarget.style.border = `1px solid ${alphaColor(token.colorWhite, 0.1)}`; }} onMouseLeave={(e) => { e.currentTarget.style.transform = 'translateY(0) scale(1)'; e.currentTarget.style.boxShadow = `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`; }} > {item.label} {item.label === '总字数' ? formatWordCount(item.value) : item.value} {item.unit && {item.unit}}
))}
)}
)}
)}
{isMobile && (
MuMuAINovel
} placement="left" onClose={() => setDrawerVisible(false)} open={drawerVisible} width={280} styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }} >
{ changeView(key as ProjectListView); setDrawerVisible(false); }} items={sideMenuItems} />
主题模式 {resolvedMode === 'dark' ? '深色' : '浅色'}
)}
{/* 内容显示区 */}
{activeView === 'settings' && } {activeView === 'mcp' && } {activeView === 'prompts' && } {activeView === 'book-import' && } {activeView === 'projects' && ( setImportModalVisible(true)} onOpenExportModal={handleOpenExportModal} onGoSettings={() => changeView('settings')} onStartWizard={() => navigate('/wizard')} onOpenInspiration={() => navigate('/inspiration')} onEnterProject={handleEnterProject} onDeleteProject={handleDelete} formatWordCount={formatWordCount} getProgress={getProgress} getProgressColor={getProgressColor} getDisplayStatus={getDisplayStatus} getStatusTag={getStatusTag} formatDate={formatDate} /> )}
{/* 导入项目对话框 */}

选择之前导出的 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)))}
))}
); }