From fc03fe958f7491a42b5fffe27865d83094f7eeed Mon Sep 17 00:00:00 2001 From: xiamuceer-j Date: Fri, 6 Mar 2026 14:13:15 +0800 Subject: [PATCH] =?UTF-8?q?feature:=20=E6=96=B0=E5=A2=9E=E4=B9=A6=E6=9E=B6?= =?UTF-8?q?=E9=A1=B5=E7=BB=84=E4=BB=B6=E4=B8=8E=E9=A1=B9=E7=9B=AE=E9=A1=B5?= =?UTF-8?q?=E6=96=B0=E6=9E=B6=E6=9E=84=EF=BC=88=E4=B9=A6=E6=9E=B6=E8=A7=86?= =?UTF-8?q?=E5=9B=BE=E3=80=81=E4=BE=A7=E8=BE=B9=E6=A0=8F=E6=8A=98=E5=8F=A0?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E3=80=81URL=20=E8=A7=86=E5=9B=BE=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E5=88=87=E6=8D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/BookshelfPage.tsx | 590 +++++++++++++ frontend/src/pages/ProjectList.tsx | 1186 ++++++++++---------------- frontend/src/utils/sidebarState.ts | 28 + 3 files changed, 1062 insertions(+), 742 deletions(-) create mode 100644 frontend/src/pages/BookshelfPage.tsx create mode 100644 frontend/src/utils/sidebarState.ts 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 ( +
+ +
+
+ +
+
+
+ + 我的书架 +
+
+ 点击书本即可进入项目,统一查看进度、字数与状态。 +
+
+ + + + + +
+ + + {showApiTip && projects.length === 0 && ( + + + 在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。 + + +
+ } + type="info" + showIcon + closable + onClose={() => setShowApiTip(false)} + style={{ + marginBottom: isMobile ? 16 : 24, + borderRadius: 12 + }} + /> + )} + + +
+
+ +
+
+
+ +
+ +
+ + +
+ +
+ 开始一个新的创作旅程 +
+
+ +
+ + {Array.isArray(projects) && projects.map((project, 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)} + + +
+
+ +
+ ); + })} +
+ +
+ ); +} 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 ? ( +
+ +
+ { + changeView(key as ProjectListView); + }} + items={collapsed ? sideMenuItemsCollapsed : sideMenuItems} + /> +
+ +
-
-
- -
- - MuMuAINovel - -
-
- - {/* 侧边栏菜单 - 使用 Menu 组件以保持风格一致 */} -
- {/* 模拟 Menu 样式 */} -
-
setActiveView('projects')} + {collapsed ? ( + +
-
- - {/* 底部用户信息 */} -
- + + + + )}
)} - {/* 主内容区域容器 */}
- - {/* 移动端顶部导航栏 */} - {isMobile && ( -
-
-
- - - {activeView === 'projects' ? '我的书架' : - activeView === 'prompts' ? '提示词模板' : - activeView === 'book-import' ? '拆书导入' : - activeView === 'mcp' ? 'MCP 插件' : 'API 设置'} - -
- )} - - {/* 移动端侧边栏 Drawer */} - {isMobile && ( - -
- -
- MuMuAINovel -
- } - closeIcon={null} - extra={ + {isMobile ? ( + <> +
- - -
- - {/* 底部用户信息 */} -
- -
- - )} - {/* 桌面端顶部标题栏 */} - {!isMobile && ( -
-

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

- - {activeView === 'projects' && ( -
- {/* 导入导出按钮 */} - - - - - - {/* 统计数据:创作中 已完结 总字数 */} +

+ {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' } }} + > +
+ { + changeView(key as ProjectListView); + setDrawerVisible(false); + }} + items={sideMenuItems} + /> + +
+ +
+ +
+ 主题模式 + {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 - }} - /> - )} - - -
- {/* 新建/灵感卡片 */} -
- -
- - -
- 开始一个新的创作旅程 -
-
-
-
- - {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)} - - -
- -
- ); - })} -
- -
+ 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;