2026-03-06 14:13:15 +08:00
|
|
|
|
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';
|
2025-11-04 14:38:59 +08:00
|
|
|
|
import { projectApi } from '../services/api';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
import { useStore } from '../store';
|
|
|
|
|
|
import { useProjectSync } from '../store/hooks';
|
2026-01-14 14:33:00 +08:00
|
|
|
|
import { eventBus, EventNames } from '../store/eventBus';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
import type { ReactNode } from 'react';
|
2026-03-06 14:13:15 +08:00
|
|
|
|
import type { Project } from '../types';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
import UserMenu from '../components/UserMenu';
|
2025-12-06 14:15:59 +08:00
|
|
|
|
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
|
2026-03-06 14:13:15 +08:00
|
|
|
|
import ThemeSwitch from '../components/ThemeSwitch';
|
|
|
|
|
|
import { useThemeMode } from '../theme/useThemeMode';
|
2026-01-14 14:33:00 +08:00
|
|
|
|
import SettingsPage from './Settings';
|
|
|
|
|
|
import MCPPluginsPage from './MCPPlugins';
|
|
|
|
|
|
import PromptTemplates from './PromptTemplates';
|
2026-03-04 16:28:16 +08:00
|
|
|
|
import BookImport from './BookImport';
|
2026-03-06 14:13:15 +08:00
|
|
|
|
import BookshelfPage from './BookshelfPage';
|
|
|
|
|
|
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
const { Text } = Typography;
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-01-14 19:47:28 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 格式化字数显示
|
|
|
|
|
|
* @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';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
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';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
export default function ProjectList() {
|
|
|
|
|
|
const navigate = useNavigate();
|
2026-03-06 14:13:15 +08:00
|
|
|
|
const location = useLocation();
|
2025-10-30 11:14:43 +08:00
|
|
|
|
const { projects, loading } = useStore();
|
2026-01-14 14:33:00 +08:00
|
|
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
2026-03-06 14:13:15 +08:00
|
|
|
|
const [collapsed, setCollapsed] = useState<boolean>(() => getStoredSidebarCollapsed());
|
2025-12-11 17:01:25 +08:00
|
|
|
|
const [modal, contextHolder] = Modal.useModal();
|
2025-10-30 22:01:10 +08:00
|
|
|
|
const [showApiTip, setShowApiTip] = useState(true);
|
2025-11-04 14:38:59 +08:00
|
|
|
|
const [importModalVisible, setImportModalVisible] = useState(false);
|
|
|
|
|
|
const [exportModalVisible, setExportModalVisible] = useState(false);
|
|
|
|
|
|
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
2026-01-14 14:33:00 +08:00
|
|
|
|
const [validationResult, setValidationResult] = useState<any>(null); // eslint-disable-line @typescript-eslint/no-explicit-any
|
2025-11-04 14:38:59 +08:00
|
|
|
|
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,
|
2026-01-14 19:47:28 +08:00
|
|
|
|
includeGenerationHistory: false,
|
|
|
|
|
|
includeCareers: true,
|
|
|
|
|
|
includeMemories: false,
|
|
|
|
|
|
includePlotAnalysis: false,
|
2025-11-04 14:38:59 +08:00
|
|
|
|
});
|
2025-10-30 11:14:43 +08:00
|
|
|
|
const { refreshProjects, deleteProject } = useProjectSync();
|
2026-03-06 14:13:15 +08:00
|
|
|
|
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<ProjectListView>(() => parseViewFromSearch(location.search), [location.search]);
|
|
|
|
|
|
const cycleThemeMode = () => {
|
|
|
|
|
|
const nextMode = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light';
|
|
|
|
|
|
setMode(nextMode);
|
|
|
|
|
|
};
|
|
|
|
|
|
const collapsedThemeIcon = mode === 'light' ? <BulbOutlined /> : mode === 'dark' ? <MoonOutlined /> : <DesktopOutlined />;
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理切换到 MCP 视图的事件
|
|
|
|
|
|
const handleSwitchToMcp = useCallback(() => {
|
2026-03-06 14:13:15 +08:00
|
|
|
|
changeView('mcp');
|
|
|
|
|
|
}, [changeView]);
|
2026-01-14 14:33:00 +08:00
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshProjects();
|
2026-01-14 14:33:00 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听切换到 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
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}, [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
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
setStoredSidebarCollapsed(collapsed);
|
|
|
|
|
|
}, [collapsed]);
|
|
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
const handleDelete = (id: string) => {
|
|
|
|
|
|
const isMobile = window.innerWidth <= 768;
|
2025-12-11 17:01:25 +08:00
|
|
|
|
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('删除项目失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
const handleEnterProject = async (project: Project) => {
|
2025-11-26 14:56:13 +08:00
|
|
|
|
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 }> = {
|
2026-01-14 14:33:00 +08:00
|
|
|
|
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 (
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
// 根据进度获取显示状态(进度达到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) => {
|
2026-03-06 14:13:15 +08:00
|
|
|
|
if (progress >= 80) return token.colorSuccess;
|
|
|
|
|
|
if (progress >= 50) return token.colorPrimary;
|
|
|
|
|
|
if (progress >= 20) return token.colorWarning;
|
|
|
|
|
|
return token.colorError;
|
2025-10-30 11:14:43 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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-12-11 17:01:25 +08:00
|
|
|
|
|
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;
|
2026-01-14 14:33:00 +08:00
|
|
|
|
// 计算已完结项目数(进度>=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
|
|
|
|
|
2025-11-04 14:38:59 +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);
|
|
|
|
|
|
}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
return false;
|
2025-11-04 14:38:59 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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([]);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-11 19:50:12 +08:00
|
|
|
|
const exportableProjects = projects;
|
2025-11-04 14:38:59 +08:00
|
|
|
|
|
|
|
|
|
|
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,
|
2026-01-14 19:47:28 +08:00
|
|
|
|
include_writing_styles: exportOptions.includeWritingStyles,
|
|
|
|
|
|
include_careers: exportOptions.includeCareers,
|
|
|
|
|
|
include_memories: exportOptions.includeMemories,
|
|
|
|
|
|
include_plot_analysis: exportOptions.includePlotAnalysis
|
2025-11-04 14:38:59 +08:00
|
|
|
|
});
|
|
|
|
|
|
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,
|
2026-01-14 19:47:28 +08:00
|
|
|
|
include_writing_styles: exportOptions.includeWritingStyles,
|
|
|
|
|
|
include_careers: exportOptions.includeCareers,
|
|
|
|
|
|
include_memories: exportOptions.includeMemories,
|
|
|
|
|
|
include_plot_analysis: exportOptions.includePlotAnalysis
|
2025-11-04 14:38:59 +08:00
|
|
|
|
});
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-11 17:01:25 +08:00
|
|
|
|
const isMobile = window.innerWidth <= 768;
|
2026-03-06 14:13:15 +08:00
|
|
|
|
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: <BookOutlined />,
|
|
|
|
|
|
label: '我的书架',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'group' as const,
|
|
|
|
|
|
label: '创作工具',
|
|
|
|
|
|
children: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'book-import',
|
|
|
|
|
|
icon: <UploadOutlined />,
|
|
|
|
|
|
label: '拆书导入',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'mcp',
|
|
|
|
|
|
icon: <ApiOutlined />,
|
|
|
|
|
|
label: 'MCP 插件',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'prompts',
|
|
|
|
|
|
icon: <FileSearchOutlined />,
|
|
|
|
|
|
label: '提示词管理',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'group' as const,
|
|
|
|
|
|
label: '系统设置',
|
|
|
|
|
|
children: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'settings',
|
|
|
|
|
|
icon: <SettingOutlined />,
|
|
|
|
|
|
label: 'API 设置',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const sideMenuItemsCollapsed = [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'projects',
|
|
|
|
|
|
icon: <BookOutlined />,
|
|
|
|
|
|
label: '我的书架',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'book-import',
|
|
|
|
|
|
icon: <UploadOutlined />,
|
|
|
|
|
|
label: '拆书导入',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'mcp',
|
|
|
|
|
|
icon: <ApiOutlined />,
|
|
|
|
|
|
label: 'MCP 插件',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'prompts',
|
|
|
|
|
|
icon: <FileSearchOutlined />,
|
|
|
|
|
|
label: '提示词管理',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'settings',
|
|
|
|
|
|
icon: <SettingOutlined />,
|
|
|
|
|
|
label: 'API 设置',
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
2025-12-11 17:01:25 +08:00
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div style={{
|
2025-12-11 17:01:25 +08:00
|
|
|
|
height: '100vh',
|
|
|
|
|
|
display: 'flex',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
background: token.colorBgLayout,
|
2025-12-11 17:01:25 +08:00
|
|
|
|
overflow: 'hidden'
|
2025-10-30 11:14:43 +08:00
|
|
|
|
}}>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
{contextHolder}
|
|
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{!isMobile && (
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: desktopSiderWidth,
|
|
|
|
|
|
background: token.colorBgContainer,
|
|
|
|
|
|
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
2026-01-14 14:33:00 +08:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
position: 'fixed',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
left: 0,
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
bottom: 0,
|
2026-03-06 14:13:15 +08:00
|
|
|
|
height: '100vh',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
|
|
|
|
boxShadow: `4px 0 16px ${alphaColor(token.colorText, 0.06)}`,
|
|
|
|
|
|
zIndex: 1000
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
height: 70,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
padding: collapsed ? 0 : '0 12px',
|
|
|
|
|
|
background: token.colorPrimary,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
justifyContent: collapsed ? 'center' : 'space-between',
|
|
|
|
|
|
gap: 8
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
{collapsed ? (
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
icon={<MenuUnfoldOutlined />}
|
|
|
|
|
|
onClick={() => setCollapsed(false)}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
padding: 0,
|
|
|
|
|
|
borderRadius: 0,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center'
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, overflow: 'hidden' }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 30,
|
|
|
|
|
|
height: 30,
|
|
|
|
|
|
background: alphaColor(token.colorWhite, 0.2),
|
|
|
|
|
|
borderRadius: 8,
|
2026-03-04 16:28:16 +08:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
backdropFilter: 'blur(4px)'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<BookOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
fontFamily: token.fontFamily,
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
MuMuAINovel
|
|
|
|
|
|
</span>
|
2026-03-04 16:28:16 +08:00
|
|
|
|
</div>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
icon={<MenuFoldOutlined />}
|
|
|
|
|
|
onClick={() => setCollapsed(true)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
style={{
|
2026-03-06 14:13:15 +08:00
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
width: 32,
|
|
|
|
|
|
height: 32,
|
|
|
|
|
|
padding: 0,
|
|
|
|
|
|
flexShrink: 0
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}
|
2026-03-06 14:13:15 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
inlineCollapsed={collapsed}
|
|
|
|
|
|
selectedKeys={[activeView]}
|
|
|
|
|
|
style={{ borderRight: 0, paddingTop: 12, width: '100%' }}
|
|
|
|
|
|
onClick={({ key }) => {
|
|
|
|
|
|
changeView(key as ProjectListView);
|
|
|
|
|
|
}}
|
|
|
|
|
|
items={collapsed ? sideMenuItemsCollapsed : sideMenuItems}
|
|
|
|
|
|
/>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{
|
2026-03-06 14:13:15 +08:00
|
|
|
|
padding: collapsed ? '12px 8px' : 16,
|
|
|
|
|
|
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
|
|
|
|
|
flexShrink: 0
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
{collapsed ? (
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%', alignItems: 'center' }} size={10}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
2026-03-06 14:13:15 +08:00
|
|
|
|
icon={collapsedThemeIcon}
|
|
|
|
|
|
onClick={cycleThemeMode}
|
|
|
|
|
|
title={`主题模式:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
style={{
|
2026-03-06 14:13:15 +08:00
|
|
|
|
width: 40,
|
|
|
|
|
|
height: 40,
|
|
|
|
|
|
borderRadius: 20,
|
|
|
|
|
|
background: alphaColor(token.colorBgContainer, 0.65),
|
|
|
|
|
|
border: `1px solid ${token.colorBorder}`,
|
|
|
|
|
|
color: token.colorTextSecondary,
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<UserMenu compact />
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: token.colorTextTertiary }}>
|
|
|
|
|
|
<span>主题模式</span>
|
|
|
|
|
|
<span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ThemeSwitch block />
|
|
|
|
|
|
<UserMenu />
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
background: token.colorPrimary,
|
|
|
|
|
|
padding: isMobile ? '0 12px' : '0 24px',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
position: 'fixed',
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
left: isMobile ? 0 : desktopSiderWidth,
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
zIndex: 1000,
|
|
|
|
|
|
boxShadow: `0 2px 10px ${alphaColor(token.colorText, 0.16)}`,
|
|
|
|
|
|
height: headerHeight,
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
transition: 'left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
|
|
|
|
overflow: 'hidden'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{isMobile ? (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
2026-03-06 14:13:15 +08:00
|
|
|
|
icon={<MenuUnfoldOutlined />}
|
|
|
|
|
|
onClick={() => setDrawerVisible(true)}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
width: 36,
|
|
|
|
|
|
height: 36
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<h2 style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
|
paddingRight: 36
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{currentViewTitle}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ width: 36, height: 36 }} />
|
|
|
|
|
|
</>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div style={{ width: 40, zIndex: 1 }} />
|
|
|
|
|
|
|
|
|
|
|
|
<h2 style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
fontSize: '24px',
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: '50%',
|
|
|
|
|
|
transform: 'translateX(-50%)',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
|
maxWidth: '45%'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{currentViewTitle}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 16, zIndex: 1 }}>
|
|
|
|
|
|
{activeView === 'projects' && (
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{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}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
style={{
|
2026-01-14 14:33:00 +08:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
backdropFilter: 'blur(4px)',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
borderRadius: '28px',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
minWidth: '56px',
|
|
|
|
|
|
height: '56px',
|
|
|
|
|
|
padding: '0 12px',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
boxShadow: `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`,
|
2026-01-14 14:33:00 +08:00
|
|
|
|
cursor: 'default',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
onMouseEnter={(e) => {
|
|
|
|
|
|
e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)';
|
2026-03-06 14:13:15 +08:00
|
|
|
|
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)}`;
|
2025-11-14 19:28:49 +08:00
|
|
|
|
}}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
onMouseLeave={(e) => {
|
|
|
|
|
|
e.currentTarget.style.transform = 'translateY(0) scale(1)';
|
2026-03-06 14:13:15 +08:00
|
|
|
|
e.currentTarget.style.boxShadow = `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`;
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<span style={{ fontSize: '11px', color: alphaColor(token.colorWhite, 0.9), marginBottom: '2px', lineHeight: 1 }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{item.label}
|
|
|
|
|
|
</span>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorWhite, lineHeight: 1, fontFamily: 'Monaco, monospace' }}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{item.label === '总字数' ? formatWordCount(item.value) : item.value}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>}
|
|
|
|
|
|
</span>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-06 14:13:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
)}
|
2026-03-06 14:13:15 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{isMobile && (
|
|
|
|
|
|
<Drawer
|
|
|
|
|
|
title={
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 30,
|
|
|
|
|
|
height: 30,
|
|
|
|
|
|
background: token.colorPrimary,
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: token.colorWhite,
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<BookOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: 16, fontFamily: token.fontFamily }}>MuMuAINovel</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
placement="left"
|
|
|
|
|
|
onClose={() => setDrawerVisible(false)}
|
|
|
|
|
|
open={drawerVisible}
|
|
|
|
|
|
width={280}
|
|
|
|
|
|
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 }) => {
|
|
|
|
|
|
changeView(key as ProjectListView);
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
items={sideMenuItems}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: 16, borderTop: `1px solid ${token.colorBorderSecondary}` }}>
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={12}>
|
|
|
|
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: token.colorTextTertiary }}>
|
|
|
|
|
|
<span>主题模式</span>
|
|
|
|
|
|
<span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<ThemeSwitch block />
|
|
|
|
|
|
<UserMenu showFullInfo />
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
marginLeft: isMobile ? 0 : desktopSiderWidth,
|
|
|
|
|
|
marginTop: headerHeight,
|
|
|
|
|
|
transition: 'margin-left 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
|
|
|
|
|
|
}}>
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 内容显示区 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={scrollContainerRef}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
overflowY: 'auto',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
padding: activeView === 'projects'
|
|
|
|
|
|
? (isMobile ? '20px 16px 70px' : '24px 24px 70px')
|
2026-03-04 16:28:16 +08:00
|
|
|
|
: 0,
|
2026-03-06 14:13:15 +08:00
|
|
|
|
background: activeView === 'projects'
|
|
|
|
|
|
? `linear-gradient(180deg, ${alphaColor(token.colorPrimary, 0.04)} 0%, ${token.colorBgLayout} 26%)`
|
|
|
|
|
|
: token.colorBgLayout,
|
2026-01-14 14:33:00 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{activeView === 'settings' && <SettingsPage />}
|
|
|
|
|
|
{activeView === 'mcp' && <MCPPluginsPage />}
|
|
|
|
|
|
{activeView === 'prompts' && <PromptTemplates />}
|
|
|
|
|
|
|
2026-03-06 14:13:15 +08:00
|
|
|
|
{activeView === 'book-import' && <BookImport />}
|
2026-03-04 16:28:16 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{activeView === 'projects' && (
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<BookshelfPage
|
|
|
|
|
|
isMobile={isMobile}
|
|
|
|
|
|
loading={loading}
|
|
|
|
|
|
projects={projects}
|
|
|
|
|
|
showApiTip={showApiTip}
|
|
|
|
|
|
setShowApiTip={setShowApiTip}
|
|
|
|
|
|
exportableProjectsCount={exportableProjects.length}
|
|
|
|
|
|
onOpenImportModal={() => 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}
|
|
|
|
|
|
/>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<ChangelogFloatingButton />
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 导入项目对话框 */}
|
|
|
|
|
|
<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>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<p style={{ marginBottom: '12px', color: token.colorTextSecondary }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
选择之前导出的 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>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validating && (
|
|
|
|
|
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
|
|
|
|
|
<Spin tip="验证文件中..." />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validationResult && (
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<Card size="small" style={{ background: validationResult.valid ? token.colorSuccessBg : token.colorErrorBg }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
|
|
|
|
<div>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<Text strong style={{ color: validationResult.valid ? token.colorSuccess : token.colorError }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{validationResult.project_name && (
|
2025-11-04 14:38:59 +08:00
|
|
|
|
<div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Text type="secondary">项目名称:</Text>
|
|
|
|
|
|
<Text strong>{validationResult.project_name}</Text>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
)}
|
|
|
|
|
|
{validationResult.statistics && (
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 6 }}>数据统计:</Text>
|
|
|
|
|
|
<Space size={[6, 6]} wrap>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validationResult.statistics.chapters > 0 && <Tag color="blue">章节: {validationResult.statistics.chapters}</Tag>}
|
|
|
|
|
|
{validationResult.statistics.characters > 0 && <Tag color="green">角色: {validationResult.statistics.characters}</Tag>}
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{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>}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{validationResult.warnings?.length > 0 && (
|
|
|
|
|
|
<div style={{ marginTop: 8 }}>
|
|
|
|
|
|
<Text type="warning" strong style={{ fontSize: 12 }}>提示:</Text>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: token.colorWarning, fontSize: 12 }}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{validationResult.warnings.map((w: string, i: number) => <li key={i}>{w}</li>)}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validationResult.errors?.length > 0 && (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Text type="danger" strong>错误:</Text>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: token.colorError, fontSize: 13 }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{validationResult.errors.map((e: string, i: number) => <li key={i}>{e}</li>)}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Modal>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 导出项目对话框 */}
|
|
|
|
|
|
<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%' }}>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<Card size="small" style={{ background: token.colorFillTertiary }}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Text strong>导出选项</Text>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
<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>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<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>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<div style={{ maxHeight: 300, overflowY: 'auto', border: `1px solid ${token.colorBorderSecondary}`, borderRadius: 8, padding: 8 }}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }}>
|
|
|
|
|
|
{exportableProjects.map(p => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={p.id}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '8px 12px',
|
2026-03-06 14:13:15 +08:00
|
|
|
|
background: selectedProjectIds.includes(p.id) ? token.colorPrimaryBg : token.colorBgContainer,
|
2026-01-14 14:33:00 +08:00
|
|
|
|
borderRadius: 6,
|
2025-12-11 17:01:25 +08:00
|
|
|
|
cursor: 'pointer',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 12
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
onClick={() => handleToggleProject(p.id)}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Checkbox checked={selectedProjectIds.includes(p.id)} />
|
|
|
|
|
|
<div style={{ flex: 1 }}>
|
|
|
|
|
|
<div>{p.title}</div>
|
2026-03-06 14:13:15 +08:00
|
|
|
|
<div style={{ fontSize: 12, color: token.colorTextTertiary }}>{formatWordCount(p.current_words || 0)} 字 · {getStatusTag(getDisplayStatus(p.status, getProgress(p.current_words || 0, p.target_words || 0)))}</div>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
))}
|
|
|
|
|
|
</Space>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Modal>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
2026-03-06 14:13:15 +08:00
|
|
|
|
}
|