diff --git a/frontend/src/pages/BookshelfPage.tsx b/frontend/src/pages/BookshelfPage.tsx
new file mode 100644
index 0000000..585376a
--- /dev/null
+++ b/frontend/src/pages/BookshelfPage.tsx
@@ -0,0 +1,590 @@
+import { Card, Button, Spin, Space, Tag, Typography, Alert, Tooltip, theme } from 'antd';
+import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined } from '@ant-design/icons';
+import type { ReactNode } from 'react';
+import type { Project } from '../types';
+import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
+import { useThemeMode } from '../theme/useThemeMode';
+
+const { Paragraph } = Typography;
+
+interface BookshelfPageProps {
+ isMobile: boolean;
+ loading: boolean;
+ projects: Project[];
+ showApiTip: boolean;
+ setShowApiTip: (show: boolean) => void;
+ exportableProjectsCount: number;
+ onOpenImportModal: () => void;
+ onOpenExportModal: () => void;
+ onGoSettings: () => void;
+ onStartWizard: () => void;
+ onOpenInspiration: () => void;
+ onEnterProject: (project: Project) => void;
+ onDeleteProject: (projectId: string) => void;
+ formatWordCount: (count: number) => string;
+ getProgress: (current: number, target: number) => number;
+ getProgressColor: (progress: number) => string;
+ getDisplayStatus: (status: string, progress: number) => string;
+ getStatusTag: (status: string) => ReactNode;
+ formatDate: (dateString: string) => string;
+}
+
+export default function BookshelfPage({
+ isMobile,
+ loading,
+ projects,
+ showApiTip,
+ setShowApiTip,
+ exportableProjectsCount,
+ onOpenImportModal,
+ onOpenExportModal,
+ onGoSettings,
+ onStartWizard,
+ onOpenInspiration,
+ onEnterProject,
+ onDeleteProject,
+ formatWordCount,
+ getProgress,
+ getProgressColor,
+ getDisplayStatus,
+ formatDate,
+}: BookshelfPageProps) {
+ const { token } = theme.useToken();
+ const { resolvedMode } = useThemeMode();
+ const isDark = resolvedMode === 'dark';
+ const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
+ const mobileBookHeight = 460;
+ const desktopBookHeight = 430;
+ const mobileSpineWidth = 32;
+
+ const serialBookPalettes = [
+ {
+ spine: `linear-gradient(180deg, color-mix(in srgb, ${token.colorSuccess} ${isDark ? 58 : 42}%, ${token.colorBgContainer} ${isDark ? 42 : 58}%) 0%, color-mix(in srgb, ${token.colorSuccess} ${isDark ? 74 : 56}%, ${token.colorText} ${isDark ? 26 : 44}%) 100%)`,
+ spineBorder: `color-mix(in srgb, ${token.colorSuccess} ${isDark ? 66 : 52}%, ${token.colorBorder} ${isDark ? 34 : 48}%)`,
+ ribbon: `color-mix(in srgb, ${token.colorSuccess} ${isDark ? 70 : 82}%, ${token.colorPrimary} ${isDark ? 30 : 18}%)`,
+ },
+ {
+ spine: `linear-gradient(180deg, color-mix(in srgb, ${token.colorWarning} ${isDark ? 64 : 52}%, ${token.colorBgContainer} ${isDark ? 36 : 48}%) 0%, color-mix(in srgb, ${token.colorWarning} ${isDark ? 80 : 66}%, ${token.colorText} ${isDark ? 20 : 34}%) 100%)`,
+ spineBorder: `color-mix(in srgb, ${token.colorWarning} ${isDark ? 70 : 56}%, ${token.colorBorder} ${isDark ? 30 : 44}%)`,
+ ribbon: `color-mix(in srgb, ${token.colorWarning} ${isDark ? 72 : 82}%, ${token.colorPrimary} ${isDark ? 28 : 18}%)`,
+ },
+ {
+ spine: `linear-gradient(180deg, color-mix(in srgb, ${token.colorInfo} ${isDark ? 46 : 30}%, ${token.colorText} ${isDark ? 54 : 70}%) 0%, color-mix(in srgb, ${token.colorText} ${isDark ? 66 : 52}%, ${token.colorBgContainer} ${isDark ? 34 : 48}%) 100%)`,
+ spineBorder: `color-mix(in srgb, ${token.colorText} ${isDark ? 74 : 64}%, ${token.colorBorder} ${isDark ? 26 : 36}%)`,
+ ribbon: `color-mix(in srgb, ${token.colorInfo} ${isDark ? 68 : 76}%, ${token.colorPrimary} ${isDark ? 32 : 24}%)`,
+ },
+ {
+ spine: `linear-gradient(180deg, color-mix(in srgb, ${token.colorPrimary} ${isDark ? 62 : 50}%, ${token.colorBgContainer} ${isDark ? 38 : 50}%) 0%, color-mix(in srgb, ${token.colorPrimary} ${isDark ? 78 : 62}%, ${token.colorText} ${isDark ? 22 : 38}%) 100%)`,
+ spineBorder: `color-mix(in srgb, ${token.colorPrimary} ${isDark ? 70 : 58}%, ${token.colorBorder} ${isDark ? 30 : 42}%)`,
+ ribbon: `color-mix(in srgb, ${token.colorPrimary} ${isDark ? 74 : 86}%, ${token.colorInfo} ${isDark ? 26 : 14}%)`,
+ },
+ ];
+
+ const completedBookPalette = {
+ spine: `linear-gradient(180deg, color-mix(in srgb, ${token.colorPrimary} ${isDark ? 68 : 52}%, ${token.colorSuccess} ${isDark ? 32 : 48}%) 0%, color-mix(in srgb, ${token.colorPrimary} ${isDark ? 82 : 66}%, ${token.colorText} ${isDark ? 18 : 34}%) 100%)`,
+ spineBorder: `color-mix(in srgb, ${token.colorPrimary} ${isDark ? 76 : 62}%, ${token.colorBorder} ${isDark ? 24 : 38}%)`,
+ ribbon: `color-mix(in srgb, ${token.colorPrimary} ${isDark ? 62 : 48}%, ${token.colorError} ${isDark ? 38 : 52}%)`,
+ };
+
+ const getRibbonStatusIcon = (displayStatus: string, isWizardIncomplete: boolean, isCompleted: boolean) => {
+ const commonStyle = { color: token.colorWhite, fontSize: isMobile ? 12 : 14 };
+
+ if (isWizardIncomplete) {
+ return ;
+ }
+ if (isCompleted) {
+ return ;
+ }
+ if (displayStatus.includes('暂停') || displayStatus.includes('搁置')) {
+ return ;
+ }
+ if (displayStatus.includes('筹备') || displayStatus.includes('准备') || displayStatus.includes('大纲')) {
+ return ;
+ }
+
+ return ;
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ 我的书架
+
+
+ 点击书本即可进入项目,统一查看进度、字数与状态。
+
+
+
+
+ }
+ onClick={onOpenImportModal}
+ style={{ borderRadius: 10 }}
+ >
+ 导入项目
+
+ }
+ onClick={onOpenExportModal}
+ disabled={exportableProjectsCount === 0}
+ style={{ borderRadius: 10 }}
+ >
+ 导出项目
+
+
+
+
+
+ {showApiTip && projects.length === 0 && (
+
+
+ 在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。
+
+
+
+ }
+ type="info"
+ showIcon
+ closable
+ onClose={() => setShowApiTip(false)}
+ style={{
+ marginBottom: isMobile ? 16 : 24,
+ borderRadius: 12
+ }}
+ />
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ onClick={onStartWizard}
+ style={{
+ height: isMobile ? 42 : 52,
+ fontSize: isMobile ? 14 : 16,
+ borderRadius: 10,
+ boxShadow: `0 10px 18px ${alphaColor(token.colorPrimary, isDark ? 0.14 : 0.22)}`,
+ }}
+ block
+ >
+ 快速开始
+
+ }
+ onClick={onOpenInspiration}
+ style={{
+ height: isMobile ? 42 : 52,
+ fontSize: isMobile ? '14px' : '16px',
+ borderRadius: 10,
+ borderColor: alphaColor(token.colorWarning, isDark ? 0.34 : 0.5),
+ color: `color-mix(in srgb, ${token.colorWarning} ${isDark ? 78 : 72}%, ${token.colorText} ${isDark ? 22 : 28}%)`,
+ background: `linear-gradient(180deg, ${alphaColor(token.colorWarning, isDark ? 0.12 : 0.12)} 0%, ${alphaColor(token.colorWarning, isDark ? 0.2 : 0.2)} 100%)`,
+ }}
+ block
+ >
+ 灵感模式
+
+
+
+
+ 开始一个新的创作旅程
+
+
+
+
+
+ {Array.isArray(projects) && projects.map((project, index) => {
+ const progress = getProgress(project.current_words || 0, project.target_words || 0);
+ const progressColor = getProgressColor(progress);
+ const isWizardIncomplete = project.wizard_status === 'incomplete';
+ const displayStatus = getDisplayStatus(project.status, progress);
+ const isCompleted = progress >= 100 || displayStatus.includes('完结');
+ const palette = isCompleted ? completedBookPalette : serialBookPalettes[index % serialBookPalettes.length];
+ const tags = project.genre ? project.genre.split(/[,、,]/).map((t: string) => t.trim()).filter((t: string) => t) : [];
+
+ const ribbonStatusIcon = getRibbonStatusIcon(displayStatus, isWizardIncomplete, isCompleted);
+
+ return (
+
+
onEnterProject(project)}
+ data-card-style="bookshelf-book"
+ data-book-kind="project"
+ >
+
+
+ {ribbonStatusIcon}
+
+
+
+
+
+
+
+
+ {project.title}
+
+
+
+
+
+ {tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
+
+ {tag}
+
+ )) : (
+
+ 未分类
+
+ )}
+
+
+
+
+ {project.description || '暂无描述...'}
+
+
+
+
+ 完成进度
+ {progress}%
+
+
+
+
+
+
+
+
+ {formatWordCount(project.current_words || 0)}
+
+
+ 已写字数
+
+
+
+
+
= 100 ? token.colorSuccess : progressColor,
+ lineHeight: 1.1,
+ fontFamily: 'Georgia, "Times New Roman", serif',
+ }}>
+ {formatWordCount(project.target_words || 0)}
+
+
+ 目标字数
+
+
+
+
+
+
+
+
+ {formatDate(project.updated_at)}
+
+
+ }
+ onClick={(e) => {
+ e.stopPropagation();
+ onDeleteProject(project.id);
+ }}
+ style={{
+ padding: isMobile ? '2px 4px' : '4px 8px',
+ borderRadius: 8,
+ }}
+ />
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/ProjectList.tsx b/frontend/src/pages/ProjectList.tsx
index 26a2fac..a022cfe 100644
--- a/frontend/src/pages/ProjectList.tsx
+++ b/frontend/src/pages/ProjectList.tsx
@@ -1,21 +1,25 @@
-import { useEffect, useState, useRef, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { Card, Button, Modal, message, Spin, Space, Tag, Progress, Typography, Alert, Upload, Checkbox, Tooltip, Drawer, Menu } from 'antd';
-import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, SettingOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, BulbOutlined, LoadingOutlined, FileSearchOutlined, MenuUnfoldOutlined, CloseOutlined } from '@ant-design/icons';
+import { 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 { cardStyles, cardHoverHandlers } from '../components/CardStyles';
+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 { Title, Text, Paragraph } = Typography;
+const { Text } = Typography;
/**
* 格式化字数显示
@@ -37,11 +41,22 @@ const formatWordCount = (count: number): string => {
}
};
+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 [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts' | 'book-import'>('projects');
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);
@@ -60,13 +75,41 @@ export default function ProjectList() {
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(() => {
- setActiveView('mcp');
- }, []);
+ changeView('mcp');
+ }, [changeView]);
useEffect(() => {
refreshProjects();
@@ -94,6 +137,10 @@ export default function ProjectList() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ useEffect(() => {
+ setStoredSidebarCollapsed(collapsed);
+ }, [collapsed]);
+
const handleDelete = (id: string) => {
const isMobile = window.innerWidth <= 768;
modal.confirm({
@@ -117,8 +164,7 @@ export default function ProjectList() {
});
};
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const handleEnterProject = async (project: any) => {
+ const handleEnterProject = async (project: Project) => {
if (project.wizard_status === 'incomplete') {
navigate(`/wizard?project_id=${project.id}`);
} else {
@@ -155,10 +201,10 @@ export default function ProjectList() {
};
const getProgressColor = (progress: number) => {
- if (progress >= 80) return '#52c41a';
- if (progress >= 50) return '#1890ff';
- if (progress >= 20) return '#faad14';
- return '#ff4d4f';
+ 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) => {
@@ -315,410 +361,313 @@ export default function ProjectList() {
};
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}
- {/* 侧边栏 - 仅桌面端显示 - 样式对齐 ProjectDetail */}
{!isMobile && (
-
- {/* Logo 区域 - 保持 Primary Color 风格 */}
+ {collapsed ? (
+
}
+ onClick={() => setCollapsed(false)}
+ style={{
+ color: token.colorWhite,
+ width: '100%',
+ height: '100%',
+ padding: 0,
+ borderRadius: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center'
+ }}
+ />
+ ) : (
+ <>
+
+
+
+
+
+ MuMuAINovel
+
+
+
}
+ onClick={() => setCollapsed(true)}
+ style={{
+ color: token.colorWhite,
+ width: 32,
+ height: 32,
+ padding: 0,
+ flexShrink: 0
+ }}
+ />
+ >
+ )}
+
+
+
+
+
+
-
-
-
-
-
- MuMuAINovel
-
-
-
-
- {/* 侧边栏菜单 - 使用 Menu 组件以保持风格一致 */}
-
- {/* 模拟 Menu 样式 */}
-
-
setActiveView('projects')}
+ {collapsed ? (
+
+
+ ) : (
+
+
+ 主题模式
+ {resolvedMode === 'dark' ? '深色' : '浅色'}
-
- 创作工具
- setActiveView('book-import')}
- style={{
- padding: '10px 16px',
- fontSize: 14,
- cursor: 'pointer',
- borderRadius: 4,
- color: activeView === 'book-import' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
- background: activeView === 'book-import' ? '#e6f7ff' : 'transparent',
- fontWeight: 500,
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- transition: 'all 0.3s',
- marginBottom: 4,
- borderRight: activeView === 'book-import' ? '3px solid var(--color-primary)' : '3px solid transparent'
- }}
- onMouseEnter={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
- onMouseLeave={e => activeView !== 'book-import' && (e.currentTarget.style.background = 'transparent')}
- >
-
- 拆书导入
-
- setActiveView('mcp')}
- style={{
- padding: '10px 16px',
- fontSize: 14,
- cursor: 'pointer',
- borderRadius: 4,
- color: activeView === 'mcp' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
- background: activeView === 'mcp' ? '#e6f7ff' : 'transparent',
- fontWeight: 500,
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- transition: 'all 0.3s',
- marginBottom: 4,
- borderRight: activeView === 'mcp' ? '3px solid var(--color-primary)' : '3px solid transparent'
- }}
- onMouseEnter={e => activeView !== 'mcp' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
- onMouseLeave={e => activeView !== 'mcp' && (e.currentTarget.style.background = 'transparent')}
- >
-
- MCP 插件
-
- setActiveView('prompts')}
- style={{
- padding: '10px 16px',
- fontSize: 14,
- cursor: 'pointer',
- borderRadius: 4,
- color: activeView === 'prompts' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
- background: activeView === 'prompts' ? '#e6f7ff' : 'transparent',
- fontWeight: 500,
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- transition: 'all 0.3s',
- marginBottom: 4,
- borderRight: activeView === 'prompts' ? '3px solid var(--color-primary)' : '3px solid transparent'
- }}
- onMouseEnter={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
- onMouseLeave={e => activeView !== 'prompts' && (e.currentTarget.style.background = 'transparent')}
- >
-
- 提示词管理
-
-
- 系统设置
- setActiveView('settings')}
- style={{
- padding: '10px 16px',
- fontSize: 14,
- cursor: 'pointer',
- borderRadius: 4,
- color: activeView === 'settings' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
- background: activeView === 'settings' ? '#e6f7ff' : 'transparent',
- fontWeight: 500,
- display: 'flex',
- alignItems: 'center',
- gap: 10,
- transition: 'all 0.3s',
- marginBottom: 4,
- borderRight: activeView === 'settings' ? '3px solid var(--color-primary)' : '3px solid transparent'
- }}
- onMouseEnter={e => activeView !== 'settings' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
- onMouseLeave={e => activeView !== 'settings' && (e.currentTarget.style.background = 'transparent')}
- >
-
- API 设置
-
-
-
-
- {/* 底部用户信息 */}
-
-
+
+
+
+ )}
)}
- {/* 主内容区域容器 */}
-
- {/* 移动端顶部导航栏 */}
- {isMobile && (
-
-
- }
- onClick={() => setDrawerVisible(true)}
- style={{
- fontSize: 18,
- color: '#fff',
- width: 36,
- height: 36
- }}
- />
-
-
-
- {activeView === 'projects' ? '我的书架' :
- activeView === 'prompts' ? '提示词模板' :
- activeView === 'book-import' ? '拆书导入' :
- activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
-
-
- )}
-
- {/* 移动端侧边栏 Drawer */}
- {isMobile && (
-
-
-
-
- MuMuAINovel
-
- }
- closeIcon={null}
- extra={
+ {isMobile ? (
+ <>
+
}
- onClick={() => setDrawerVisible(false)}
- style={{ fontSize: 16, color: 'rgba(0,0,0,0.45)' }}
- />
- }
- placement="left"
- onClose={() => setDrawerVisible(false)}
- open={drawerVisible}
- width="60%"
- styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
- >
-
-
-
- {/* 底部用户信息 */}
-
-
-
-
- )}
- {/* 桌面端顶部标题栏 */}
- {!isMobile && (
-
-
- {activeView === 'projects' ? '我的书架' :
- activeView === 'prompts' ? '提示词模板' :
- activeView === 'book-import' ? '拆书导入' :
- activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
-
-
- {activeView === 'projects' && (
-
- {/* 导入导出按钮 */}
-
- } onClick={() => setImportModalVisible(true)} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}>导入
- } onClick={handleOpenExportModal} disabled={exportableProjects.length === 0} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}>导出
-
-
- {/* 统计数据:创作中 已完结 总字数 */}
+
+ {currentViewTitle}
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ {currentViewTitle}
+
+
+
+ {activeView === 'projects' && (
+
{projects.length > 0 && (
{[
@@ -734,28 +683,28 @@ export default function ProjectList() {
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
- borderRadius: '28px', // 圆角风格
+ borderRadius: '28px',
minWidth: '56px',
height: '56px',
padding: '0 12px',
- boxShadow: 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)',
+ boxShadow: `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`,
cursor: 'default',
- transition: 'all 0.3s ease',
+ 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 rgba(255, 255, 255, 0.25), 0 8px 16px rgba(0, 0, 0, 0.15)';
- e.currentTarget.style.border = '1px solid rgba(255, 255, 255, 0.1)';
+ 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 rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.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}}
@@ -763,10 +712,76 @@ export default function ProjectList() {
))}
)}
-
- )}
-
+
+ )}
+
+ >
)}
+
+
+ {isMobile && (
+
+
+
+
+ MuMuAINovel
+
+ }
+ placement="left"
+ onClose={() => setDrawerVisible(false)}
+ open={drawerVisible}
+ width={280}
+ styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
+ >
+
+
+
+
+
+
+ 主题模式
+ {resolvedMode === 'dark' ? '深色' : '浅色'}
+
+
+
+
+
+
+ )}
+
+
{/* 内容显示区 */}
{activeView === 'settings' &&
}
{activeView === 'mcp' &&
}
{activeView === 'prompts' &&
}
- {activeView === 'book-import' && (
-
-
-
- )}
+ {activeView === 'book-import' &&
}
{activeView === 'projects' && (
-
-
- {showApiTip && projects.length === 0 && (
-
-
- 在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。
-
-
-
- }
- type="info"
- showIcon
- closable
- onClose={() => setShowApiTip(false)}
- style={{
- marginBottom: isMobile ? 16 : 24,
- borderRadius: 12
- }}
- />
- )}
-
-
-
- {/* 新建/灵感卡片 */}
-
-
-
-
}
- onClick={() => navigate('/wizard')}
- style={{ height: isMobile ? '38px' : '50px', fontSize: isMobile ? '14px' : '16px' }}
- block
- >
- 快速开始
-
-
}
- onClick={() => navigate('/inspiration')}
- style={{
- height: isMobile ? '38px' : '50px',
- fontSize: isMobile ? '14px' : '16px',
- borderColor: '#faad14',
- color: '#faad14',
- background: 'rgba(250, 173, 20, 0.1)'
- }}
- block
- >
- 灵感模式
-
-
- 开始一个新的创作旅程
-
-
-
-
-
- {Array.isArray(projects) && projects.map((project) => {
- const progress = getProgress(project.current_words, project.target_words || 0);
- const isWizardIncomplete = project.wizard_status === 'incomplete';
- // 解析标签(假设存储在 genre 字段,用逗号或顿号分隔)
- const tags = project.genre ? project.genre.split(/[,、,]/).map((t: string) => t.trim()).filter((t: string) => t) : [];
-
- return (
-
-
handleEnterProject(project)}
- >
- {/* 卡片头部 - 参考图片样式 */}
-
-
- {/* 标题行:图标 + 标题 */}
-
-
-
-
- {project.title}
-
-
-
- {/* 标签行 - 单行不换行 */}
-
- {tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
-
- {tag}
-
- )) : (
-
- 未分类
-
- )}
-
-
-
- {/* 右上角状态标签 - 带文字和图标 */}
-
- {isWizardIncomplete ? (
- }
- style={{
- margin: 0,
- borderRadius: 4,
- fontSize: isMobile ? 10 : 12,
- padding: isMobile ? '0 6px' : '2px 10px',
- fontWeight: 500
- }}
- >
- 生成中
-
- ) : (
- getStatusTag(getDisplayStatus(project.status, progress))
- )}
-
-
-
- {/* 描述区域 */}
-
-
- {project.description || '暂无描述...'}
-
-
-
- {/* 进度条区域 */}
-
-
-
- 完成进度
-
-
- {progress}%
-
-
-
-
-
- {/* 字数统计区域 */}
-
-
-
- {formatWordCount(project.current_words || 0)}
-
-
- 已写字数
-
-
-
-
-
- {formatWordCount(project.target_words || 0)}
-
-
- 目标字数
-
-
-
-
- {/* 卡片底部 - 时间和操作 */}
-
-
- {formatDate(project.updated_at)}
-
-
- }
- onClick={(e) => {
- e.stopPropagation();
- handleDelete(project.id);
- }}
- style={{ padding: isMobile ? '2px 4px' : '4px 8px' }}
- />
-
-
-
- );
- })}
-
-
-
+
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}
+ />
)}
@@ -1144,7 +846,7 @@ export default function ProjectList() {
>
-
+
选择之前导出的 JSON 格式项目文件
+
-
+
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
@@ -1204,7 +906,7 @@ export default function ProjectList() {
{validationResult.warnings?.length > 0 && (
提示:
-
+
{validationResult.warnings.map((w: string, i: number) => - {w}
)}
@@ -1212,7 +914,7 @@ export default function ProjectList() {
{validationResult.errors?.length > 0 && (
错误:
-
+
{validationResult.errors.map((e: string, i: number) => - {e}
)}
@@ -1237,7 +939,7 @@ export default function ProjectList() {
okButtonProps={{ disabled: selectedProjectIds.length === 0 }}
>
-
+
导出选项
@@ -1267,14 +969,14 @@ export default function ProjectList() {
全选
-
+
{exportableProjects.map(p => (
{p.title}
-
{formatWordCount(p.current_words || 0)} 字 · {getStatusTag(getDisplayStatus(p.status, getProgress(p.current_words || 0, p.target_words || 0)))}
+
{formatWordCount(p.current_words || 0)} 字 · {getStatusTag(getDisplayStatus(p.status, getProgress(p.current_words || 0, p.target_words || 0)))}
))}
@@ -1298,4 +1000,4 @@ export default function ProjectList() {
);
-}
\ No newline at end of file
+}
diff --git a/frontend/src/utils/sidebarState.ts b/frontend/src/utils/sidebarState.ts
new file mode 100644
index 0000000..b5dcc46
--- /dev/null
+++ b/frontend/src/utils/sidebarState.ts
@@ -0,0 +1,28 @@
+const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mumu_sidebar_collapsed';
+
+export const getStoredSidebarCollapsed = (): boolean => {
+ if (typeof window === 'undefined') {
+ return false;
+ }
+
+ try {
+ return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1';
+ } catch (error) {
+ console.warn('读取侧边栏状态失败:', error);
+ return false;
+ }
+};
+
+export const setStoredSidebarCollapsed = (collapsed: boolean): void => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+
+ try {
+ localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, collapsed ? '1' : '0');
+ } catch (error) {
+ console.warn('保存侧边栏状态失败:', error);
+ }
+};
+
+export const getSidebarCollapsedStorageKey = (): string => SIDEBAR_COLLAPSED_STORAGE_KEY;