1004 lines
38 KiB
TypeScript
1004 lines
38 KiB
TypeScript
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<boolean>(() => 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<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,
|
||
});
|
||
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<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]);
|
||
|
||
const scrollContainerRef = useRef<HTMLDivElement>(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<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 /> },
|
||
};
|
||
const config = statusConfig[status] || statusConfig.planning;
|
||
return (
|
||
<Tag color={config.color} icon={config.icon} style={{ margin: 0, borderRadius: 4, flexShrink: 0 }}>
|
||
{config.text}
|
||
</Tag>
|
||
);
|
||
};
|
||
|
||
// 根据进度获取显示状态(进度达到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: <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 设置',
|
||
},
|
||
];
|
||
|
||
return (
|
||
<div style={{
|
||
height: '100vh',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
background: token.colorBgLayout,
|
||
overflow: 'hidden'
|
||
}}>
|
||
{contextHolder}
|
||
|
||
{!isMobile && (
|
||
<div
|
||
style={{
|
||
width: desktopSiderWidth,
|
||
background: token.colorBgContainer,
|
||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
position: 'fixed',
|
||
left: 0,
|
||
top: 0,
|
||
bottom: 0,
|
||
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
|
||
}}>
|
||
<div style={{
|
||
height: 70,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
padding: collapsed ? 0 : '0 12px',
|
||
background: token.colorPrimary,
|
||
flexShrink: 0,
|
||
justifyContent: collapsed ? 'center' : 'space-between',
|
||
gap: 8
|
||
}}>
|
||
{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,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
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>
|
||
</div>
|
||
<Button
|
||
type="text"
|
||
icon={<MenuFoldOutlined />}
|
||
onClick={() => setCollapsed(true)}
|
||
style={{
|
||
color: token.colorWhite,
|
||
width: 32,
|
||
height: 32,
|
||
padding: 0,
|
||
flexShrink: 0
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
<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}
|
||
/>
|
||
</div>
|
||
|
||
<div style={{
|
||
padding: collapsed ? '12px 8px' : 16,
|
||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||
flexShrink: 0
|
||
}}>
|
||
{collapsed ? (
|
||
<Space direction="vertical" style={{ width: '100%', alignItems: 'center' }} size={10}>
|
||
<Button
|
||
type="text"
|
||
icon={collapsedThemeIcon}
|
||
onClick={cycleThemeMode}
|
||
title={`主题模式:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`}
|
||
style={{
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 20,
|
||
background: alphaColor(token.colorBgContainer, 0.65),
|
||
border: `1px solid ${token.colorBorder}`,
|
||
color: token.colorTextSecondary,
|
||
}}
|
||
/>
|
||
<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>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<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 }}>
|
||
<Button
|
||
type="text"
|
||
icon={<MenuUnfoldOutlined />}
|
||
onClick={() => setDrawerVisible(true)}
|
||
style={{
|
||
fontSize: 18,
|
||
color: token.colorWhite,
|
||
width: 36,
|
||
height: 36
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<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 }}>
|
||
{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 ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`,
|
||
cursor: 'default',
|
||
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
|
||
}}
|
||
onMouseEnter={(e) => {
|
||
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)}`;
|
||
}}
|
||
>
|
||
<span style={{ fontSize: '11px', color: alphaColor(token.colorWhite, 0.9), marginBottom: '2px', lineHeight: 1 }}>
|
||
{item.label}
|
||
</span>
|
||
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorWhite, 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>
|
||
</>
|
||
)}
|
||
</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)'
|
||
}}>
|
||
|
||
{/* 内容显示区 */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
style={{
|
||
flex: 1,
|
||
overflowY: 'auto',
|
||
padding: activeView === 'projects'
|
||
? (isMobile ? '20px 16px 70px' : '24px 24px 70px')
|
||
: 0,
|
||
background: activeView === 'projects'
|
||
? `linear-gradient(180deg, ${alphaColor(token.colorPrimary, 0.04)} 0%, ${token.colorBgLayout} 26%)`
|
||
: token.colorBgLayout,
|
||
}}
|
||
>
|
||
{activeView === 'settings' && <SettingsPage />}
|
||
{activeView === 'mcp' && <MCPPluginsPage />}
|
||
{activeView === 'prompts' && <PromptTemplates />}
|
||
|
||
{activeView === 'book-import' && <BookImport />}
|
||
|
||
{activeView === 'projects' && (
|
||
<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}
|
||
/>
|
||
)}
|
||
|
||
<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: token.colorTextSecondary }}>
|
||
选择之前导出的 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 ? token.colorSuccessBg : token.colorErrorBg }}>
|
||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||
<div>
|
||
<Text strong style={{ color: validationResult.valid ? token.colorSuccess : token.colorError }}>
|
||
{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: token.colorWarning, 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: token.colorError, 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: token.colorFillTertiary }}>
|
||
<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 ${token.colorBorderSecondary}`, 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) ? token.colorPrimaryBg : token.colorBgContainer,
|
||
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: token.colorTextTertiary }}>{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>
|
||
|
||
</div>
|
||
);
|
||
}
|