2026-01-14 14:33:00 +08:00
|
|
|
|
import { useEffect, useState, useRef, useCallback } from 'react';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
import { useNavigate } from 'react-router-dom';
|
2026-01-14 14:33:00 +08:00
|
|
|
|
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';
|
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-01-14 14:33:00 +08:00
|
|
|
|
import { cardStyles, cardHoverHandlers } from '../components/CardStyles';
|
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-01-14 14:33:00 +08:00
|
|
|
|
import SettingsPage from './Settings';
|
|
|
|
|
|
import MCPPluginsPage from './MCPPlugins';
|
|
|
|
|
|
import PromptTemplates from './PromptTemplates';
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
|
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
|
|
|
|
|
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';
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
export default function ProjectList() {
|
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
|
const { projects, loading } = useStore();
|
2026-01-14 14:33:00 +08:00
|
|
|
|
const [activeView, setActiveView] = useState<'projects' | 'settings' | 'mcp' | 'prompts'>('projects');
|
|
|
|
|
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
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-01-14 14:33:00 +08:00
|
|
|
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
|
|
// 处理切换到 MCP 视图的事件
|
|
|
|
|
|
const handleSwitchToMcp = useCallback(() => {
|
|
|
|
|
|
setActiveView('mcp');
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
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
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
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-01-14 14:33:00 +08:00
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
2025-11-26 14:56:13 +08:00
|
|
|
|
const handleEnterProject = async (project: any) => {
|
|
|
|
|
|
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) => {
|
|
|
|
|
|
if (progress >= 80) return '#52c41a';
|
|
|
|
|
|
if (progress >= 50) return '#1890ff';
|
|
|
|
|
|
if (progress >= 20) return '#faad14';
|
|
|
|
|
|
return '#ff4d4f';
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
2025-10-30 11:14:43 +08:00
|
|
|
|
return (
|
|
|
|
|
|
<div style={{
|
2025-12-11 17:01:25 +08:00
|
|
|
|
height: '100vh',
|
|
|
|
|
|
display: 'flex',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
flexDirection: 'row', // 改为行布局,容纳侧边栏
|
|
|
|
|
|
background: 'var(--color-bg-base)',
|
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
|
|
|
|
{/* 侧边栏 - 仅桌面端显示 - 样式对齐 ProjectDetail */}
|
|
|
|
|
|
{!isMobile && (
|
2025-12-11 17:01:25 +08:00
|
|
|
|
<div style={{
|
2026-01-14 14:33:00 +08:00
|
|
|
|
width: 220, // 对齐 ProjectDetail 的宽度
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
borderRight: '1px solid rgba(0,0,0,0.06)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
zIndex: 10,
|
|
|
|
|
|
position: 'fixed', // 固定定位,与 ProjectDetail 一致
|
|
|
|
|
|
left: 0,
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
bottom: 0,
|
|
|
|
|
|
boxShadow: '4px 0 16px rgba(0,0,0,0.02)'
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* Logo 区域 - 保持 Primary Color 风格 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
height: 70,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
padding: '0 24px',
|
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
|
flexShrink: 0
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 32,
|
|
|
|
|
|
height: 32,
|
|
|
|
|
|
background: 'rgba(255, 255, 255, 0.2)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
backdropFilter: 'blur(4px)'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<BookOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Title level={5} style={{ margin: 0, color: '#fff', fontWeight: 600 }}>
|
|
|
|
|
|
MuMuAINovel
|
|
|
|
|
|
</Title>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 侧边栏菜单 - 使用 Menu 组件以保持风格一致 */}
|
|
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto', paddingTop: 16 }}>
|
|
|
|
|
|
{/* 模拟 Menu 样式 */}
|
|
|
|
|
|
<div style={{ padding: '0 12px 12px 12px' }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => setActiveView('projects')}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '10px 16px',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
cursor: 'pointer',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
color: activeView === 'projects' ? 'var(--color-primary)' : 'rgba(0,0,0,0.85)',
|
|
|
|
|
|
background: activeView === 'projects' ? '#e6f7ff' : 'transparent',
|
|
|
|
|
|
marginBottom: 4,
|
|
|
|
|
|
fontWeight: 500,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 10,
|
|
|
|
|
|
transition: 'all 0.3s',
|
|
|
|
|
|
borderRight: activeView === 'projects' ? '3px solid var(--color-primary)' : '3px solid transparent'
|
|
|
|
|
|
}}
|
|
|
|
|
|
onMouseEnter={e => activeView !== 'projects' && (e.currentTarget.style.background = 'rgba(0,0,0,0.04)')}
|
|
|
|
|
|
onMouseLeave={e => activeView !== 'projects' && (e.currentTarget.style.background = 'transparent')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<BookOutlined />
|
|
|
|
|
|
我的书架
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>创作工具</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => 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')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FileSearchOutlined />
|
|
|
|
|
|
提示词管理
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => 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')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ApiOutlined />
|
|
|
|
|
|
MCP 插件
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ padding: '0 12px', fontSize: 12, color: 'rgba(0,0,0,0.45)', marginBottom: 8, marginTop: 16 }}>系统设置</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
onClick={() => 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')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SettingOutlined />
|
|
|
|
|
|
API 设置
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 底部用户信息 */}
|
|
|
|
|
|
<div style={{ padding: 16, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
|
|
|
|
|
<UserMenu />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 主内容区域容器 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
marginLeft: isMobile ? 0 : 220 // 为固定定位的侧边栏留出空间
|
|
|
|
|
|
}}>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 移动端顶部导航栏 */}
|
|
|
|
|
|
{isMobile && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
|
boxShadow: 'var(--shadow-header)',
|
|
|
|
|
|
height: 56,
|
|
|
|
|
|
padding: '0 12px',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
zIndex: 100
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
icon={<MenuUnfoldOutlined />}
|
|
|
|
|
|
onClick={() => setDrawerVisible(true)}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
width: 36,
|
|
|
|
|
|
height: 36
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
paddingRight: 36 // 为了与左侧菜单按钮对称
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{activeView === 'projects' ? '我的书架' :
|
|
|
|
|
|
activeView === 'prompts' ? '提示词模板' :
|
|
|
|
|
|
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 移动端侧边栏 Drawer */}
|
|
|
|
|
|
{isMobile && (
|
|
|
|
|
|
<Drawer
|
|
|
|
|
|
title={
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 32,
|
|
|
|
|
|
height: 32,
|
|
|
|
|
|
background: 'var(--color-primary)',
|
|
|
|
|
|
borderRadius: 8,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<BookOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span style={{ fontWeight: 600, fontSize: 16 }}>MuMuAINovel</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
|
|
|
|
|
closeIcon={null}
|
|
|
|
|
|
extra={
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
icon={<CloseOutlined />}
|
|
|
|
|
|
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' } }}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{ flex: 1, overflowY: 'auto' }}>
|
|
|
|
|
|
<Menu
|
|
|
|
|
|
mode="inline"
|
|
|
|
|
|
selectedKeys={[activeView]}
|
|
|
|
|
|
style={{ borderRight: 0, paddingTop: 8 }}
|
|
|
|
|
|
onClick={({ key }) => {
|
|
|
|
|
|
setActiveView(key as 'projects' | 'settings' | 'mcp' | 'prompts');
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
items={[
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'projects',
|
|
|
|
|
|
icon: <BookOutlined />,
|
|
|
|
|
|
label: '我的书架',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'group',
|
|
|
|
|
|
label: '创作工具',
|
|
|
|
|
|
children: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'prompts',
|
|
|
|
|
|
icon: <FileSearchOutlined />,
|
|
|
|
|
|
label: '提示词管理',
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'mcp',
|
|
|
|
|
|
icon: <ApiOutlined />,
|
|
|
|
|
|
label: 'MCP 插件',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
type: 'group',
|
|
|
|
|
|
label: '系统设置',
|
|
|
|
|
|
children: [
|
|
|
|
|
|
{
|
|
|
|
|
|
key: 'settings',
|
|
|
|
|
|
icon: <SettingOutlined />,
|
|
|
|
|
|
label: 'API 设置',
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
]}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 导入导出操作 */}
|
|
|
|
|
|
<div style={{ padding: '16px', borderTop: '1px solid rgba(0,0,0,0.06)', marginTop: 16 }}>
|
|
|
|
|
|
<Space direction="vertical" style={{ width: '100%' }} size={8}>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<UploadOutlined />}
|
|
|
|
|
|
block
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
setImportModalVisible(true);
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
导入项目
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<DownloadOutlined />}
|
|
|
|
|
|
block
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
handleOpenExportModal();
|
|
|
|
|
|
setDrawerVisible(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
disabled={exportableProjects.length === 0}
|
|
|
|
|
|
>
|
|
|
|
|
|
导出项目
|
|
|
|
|
|
</Button>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</Space>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 底部用户信息 */}
|
|
|
|
|
|
<div style={{ padding: 16, borderTop: '1px solid rgba(0,0,0,0.06)' }}>
|
|
|
|
|
|
<UserMenu showFullInfo />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Drawer>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 桌面端顶部标题栏 */}
|
|
|
|
|
|
{!isMobile && (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
height: 70, // 与 ProjectDetail header 高度一致
|
|
|
|
|
|
padding: '0 24px',
|
|
|
|
|
|
background: 'var(--color-primary)', // 使用主题色背景
|
|
|
|
|
|
boxShadow: 'var(--shadow-header)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
flexShrink: 0,
|
|
|
|
|
|
zIndex: 100
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<h2 style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: '24px',
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: 12
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{activeView === 'projects' ? '我的书架' :
|
|
|
|
|
|
activeView === 'prompts' ? '提示词模板' :
|
|
|
|
|
|
activeView === 'mcp' ? 'MCP 插件' : 'API 设置'}
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
|
|
|
|
|
|
{activeView === 'projects' && (
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
|
|
|
|
|
|
{/* 导入导出按钮 */}
|
|
|
|
|
|
<Space>
|
|
|
|
|
|
<Button ghost icon={<UploadOutlined />} onClick={() => setImportModalVisible(true)} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}>导入</Button>
|
|
|
|
|
|
<Button ghost icon={<DownloadOutlined />} onClick={handleOpenExportModal} disabled={exportableProjects.length === 0} style={{ color: '#fff', borderColor: 'rgba(255,255,255,0.6)' }}>导出</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 统计数据:创作中 已完结 总字数 */}
|
|
|
|
|
|
{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)',
|
|
|
|
|
|
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)',
|
|
|
|
|
|
cursor: 'default',
|
|
|
|
|
|
transition: 'all 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)';
|
|
|
|
|
|
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)';
|
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)';
|
|
|
|
|
|
e.currentTarget.style.boxShadow = 'inset 0 0 15px rgba(255, 255, 255, 0.15), 0 4px 10px rgba(0, 0, 0, 0.1)';
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<span style={{ fontSize: '11px', color: 'rgba(255, 255, 255, 0.9)', marginBottom: '2px', lineHeight: 1 }}>
|
|
|
|
|
|
{item.label}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{ fontSize: '15px', fontWeight: '600', color: '#fff', 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 内容显示区 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={scrollContainerRef}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
flex: 1,
|
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
padding: activeView === 'projects' ? `${isMobile ? 16 : 24}px ${isMobile ? 16 : 32}px` : 0,
|
|
|
|
|
|
background: 'var(--color-bg-base)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{activeView === 'settings' && <SettingsPage />}
|
|
|
|
|
|
{activeView === 'mcp' && <MCPPluginsPage />}
|
|
|
|
|
|
{activeView === 'prompts' && <PromptTemplates />}
|
|
|
|
|
|
|
|
|
|
|
|
{activeView === 'projects' && (
|
|
|
|
|
|
<div style={{ maxWidth: 1600, margin: '0 auto', paddingBottom: 60 }}>
|
|
|
|
|
|
|
2025-12-11 17:01:25 +08:00
|
|
|
|
{showApiTip && projects.length === 0 && (
|
|
|
|
|
|
<Alert
|
2026-01-14 14:33:00 +08:00
|
|
|
|
message="欢迎使用 MuMuAINovel"
|
2025-10-30 11:14:43 +08:00
|
|
|
|
description={
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: isMobile ? 'column' : 'row',
|
|
|
|
|
|
alignItems: isMobile ? 'flex-start' : 'center',
|
|
|
|
|
|
gap: isMobile ? 12 : 16,
|
|
|
|
|
|
justifyContent: 'space-between'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<span style={{ fontSize: isMobile ? 12 : 14 }}>
|
|
|
|
|
|
在开始创作之前,请先配置您的AI接口(支持 OpenAI / Anthropic)。
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={() => setActiveView('settings')}
|
|
|
|
|
|
style={{ flexShrink: 0 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
去配置
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
2025-10-30 11:14:43 +08:00
|
|
|
|
}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
type="info"
|
2026-01-14 14:33:00 +08:00
|
|
|
|
showIcon
|
2025-12-11 17:01:25 +08:00
|
|
|
|
closable
|
|
|
|
|
|
onClose={() => setShowApiTip(false)}
|
|
|
|
|
|
style={{
|
2026-01-14 14:33:00 +08:00
|
|
|
|
marginBottom: isMobile ? 16 : 24,
|
|
|
|
|
|
borderRadius: 12
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
2025-10-30 11:14:43 +08:00
|
|
|
|
/>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<Spin spinning={loading}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
...cardStyles.bookshelf,
|
2026-01-14 19:47:28 +08:00
|
|
|
|
// 移动端显示一列
|
2026-01-14 14:33:00 +08:00
|
|
|
|
...(isMobile && {
|
2026-01-14 19:47:28 +08:00
|
|
|
|
gridTemplateColumns: '1fr',
|
|
|
|
|
|
gap: '16px',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
padding: '16px 0',
|
|
|
|
|
|
})
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{/* 新建/灵感卡片 */}
|
|
|
|
|
|
<div style={{ position: 'relative', width: '100%', minWidth: 0, minHeight: isMobile ? 300 : 330 }}>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
<Card
|
2026-01-14 14:33:00 +08:00
|
|
|
|
hoverable
|
|
|
|
|
|
style={{ ...cardStyles.newProjectBook, minHeight: isMobile ? 300 : 330 }}
|
|
|
|
|
|
styles={{ body: { padding: isMobile ? '16px' : '24px', flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center' } }}
|
|
|
|
|
|
{...cardHoverHandlers}
|
|
|
|
|
|
data-type="new-project"
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
height: '100%',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
flexDirection: 'column',
|
|
|
|
|
|
gap: isMobile ? 10 : 16,
|
|
|
|
|
|
justifyContent: 'center'
|
|
|
|
|
|
}}>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
2026-01-14 14:33:00 +08:00
|
|
|
|
size={isMobile ? 'middle' : 'large'}
|
|
|
|
|
|
icon={<RocketOutlined />}
|
|
|
|
|
|
onClick={() => navigate('/wizard')}
|
|
|
|
|
|
style={{ height: isMobile ? '38px' : '50px', fontSize: isMobile ? '14px' : '16px' }}
|
|
|
|
|
|
block
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
快速开始
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
2026-01-14 14:33:00 +08:00
|
|
|
|
size={isMobile ? 'middle' : 'large'}
|
|
|
|
|
|
icon={<BulbOutlined />}
|
|
|
|
|
|
onClick={() => navigate('/inspiration')}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
style={{
|
2026-01-14 14:33:00 +08:00
|
|
|
|
height: isMobile ? '38px' : '50px',
|
|
|
|
|
|
fontSize: isMobile ? '14px' : '16px',
|
|
|
|
|
|
borderColor: '#faad14',
|
|
|
|
|
|
color: '#faad14',
|
|
|
|
|
|
background: 'rgba(250, 173, 20, 0.1)'
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
block
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
灵感模式
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</Button>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{ textAlign: 'center', color: '#999', fontSize: isMobile ? 11 : 12, marginTop: isMobile ? 4 : 8 }}>
|
|
|
|
|
|
开始一个新的创作旅程
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
2025-10-30 11:14:43 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{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 (
|
|
|
|
|
|
<div key={project.id} style={{ position: 'relative', width: '100%', minWidth: 0 }}>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
<Card
|
|
|
|
|
|
hoverable
|
|
|
|
|
|
style={cardStyles.project}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column' } }}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
{...cardHoverHandlers}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
onClick={() => handleEnterProject(project)}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 卡片头部 - 参考图片样式 */}
|
2025-12-11 17:01:25 +08:00
|
|
|
|
<div style={{
|
2026-01-14 14:33:00 +08:00
|
|
|
|
padding: isMobile ? '14px 14px 10px' : '18px 20px 14px',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'flex-start',
|
|
|
|
|
|
position: 'relative'
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
<div style={{ flex: 1, minWidth: 0, marginRight: isMobile ? 8 : 12 }}>
|
|
|
|
|
|
{/* 标题行:图标 + 标题 */}
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 6 : 8, marginBottom: isMobile ? 6 : 10 }}>
|
|
|
|
|
|
<BookOutlined style={{
|
|
|
|
|
|
fontSize: isMobile ? 14 : 16,
|
|
|
|
|
|
color: 'var(--color-primary)',
|
|
|
|
|
|
flexShrink: 0
|
|
|
|
|
|
}} />
|
|
|
|
|
|
<Tooltip title={project.title}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontSize: isMobile ? 14 : 16,
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
color: 'var(--color-text-primary)',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
textOverflow: 'ellipsis',
|
|
|
|
|
|
lineHeight: 1.3
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{project.title}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Tooltip>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{/* 标签行 - 单行不换行 */}
|
|
|
|
|
|
<div style={{
|
2025-12-11 17:01:25 +08:00
|
|
|
|
display: 'flex',
|
2026-01-14 14:33:00 +08:00
|
|
|
|
gap: isMobile ? 4 : 6,
|
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
|
flexWrap: 'nowrap'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{tags.length > 0 ? tags.slice(0, 3).map((tag: string, idx: number) => (
|
|
|
|
|
|
<Tag key={idx} style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
fontSize: isMobile ? 10 : 11,
|
|
|
|
|
|
lineHeight: isMobile ? '18px' : '20px',
|
|
|
|
|
|
padding: isMobile ? '0 6px' : '0 8px',
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
background: 'rgba(82, 196, 26, 0.1)',
|
|
|
|
|
|
color: '#52c41a',
|
|
|
|
|
|
fontWeight: 500
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{tag}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)) : (
|
|
|
|
|
|
<Tag style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
fontSize: isMobile ? 10 : 11,
|
|
|
|
|
|
lineHeight: isMobile ? '18px' : '20px',
|
|
|
|
|
|
padding: isMobile ? '0 6px' : '0 8px',
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
background: 'rgba(82, 196, 26, 0.1)',
|
|
|
|
|
|
color: '#52c41a',
|
|
|
|
|
|
fontWeight: 500
|
|
|
|
|
|
}}>
|
|
|
|
|
|
未分类
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
)}
|
2025-11-11 19:50:12 +08:00
|
|
|
|
</div>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 右上角状态标签 - 带文字和图标 */}
|
|
|
|
|
|
<div style={{ flexShrink: 0 }}>
|
|
|
|
|
|
{isWizardIncomplete ? (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
color="warning"
|
|
|
|
|
|
icon={<LoadingOutlined />}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
margin: 0,
|
|
|
|
|
|
borderRadius: 4,
|
|
|
|
|
|
fontSize: isMobile ? 10 : 12,
|
|
|
|
|
|
padding: isMobile ? '0 6px' : '2px 10px',
|
|
|
|
|
|
fontWeight: 500
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
生成中
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
getStatusTag(getDisplayStatus(project.status, progress))
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-10-30 11:14:43 +08:00
|
|
|
|
</div>
|
2025-11-04 14:38:59 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 描述区域 */}
|
|
|
|
|
|
<div style={{ padding: isMobile ? '0 14px 10px' : '0 20px 14px' }}>
|
|
|
|
|
|
<Paragraph
|
|
|
|
|
|
ellipsis={{ rows: isMobile ? 2 : 2 }}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: isMobile ? 12 : 13,
|
|
|
|
|
|
color: 'var(--color-text-secondary)',
|
|
|
|
|
|
marginBottom: 0,
|
|
|
|
|
|
lineHeight: 1.6
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{project.description || '暂无描述...'}
|
|
|
|
|
|
</Paragraph>
|
|
|
|
|
|
</div>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 进度条区域 */}
|
|
|
|
|
|
<div style={{ padding: isMobile ? '0 14px 12px' : '0 20px 16px' }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginBottom: isMobile ? 6 : 8
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
fontSize: isMobile ? 11 : 12,
|
|
|
|
|
|
color: 'var(--color-text-tertiary)'
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
完成进度
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
fontSize: isMobile ? 11 : 12,
|
|
|
|
|
|
color: getProgressColor(progress),
|
|
|
|
|
|
fontWeight: 600
|
2025-12-11 17:01:25 +08:00
|
|
|
|
}}>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{progress}%
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={progress}
|
|
|
|
|
|
showInfo={false}
|
|
|
|
|
|
strokeColor={getProgressColor(progress)}
|
|
|
|
|
|
trailColor="rgba(0, 0, 0, 0.04)"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
style={{ marginBottom: 0 }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
|
2026-01-14 14:33:00 +08:00
|
|
|
|
{/* 字数统计区域 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
padding: isMobile ? '12px 14px' : '16px 20px',
|
|
|
|
|
|
background: 'rgba(0, 0, 0, 0.02)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{ textAlign: 'center', flex: 1 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontSize: isMobile ? 18 : 22,
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
color: 'var(--color-text-primary)',
|
|
|
|
|
|
lineHeight: 1.2,
|
|
|
|
|
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
|
|
|
|
|
|
}}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{formatWordCount(project.current_words || 0)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontSize: isMobile ? 10 : 11,
|
|
|
|
|
|
color: 'var(--color-text-tertiary)',
|
|
|
|
|
|
marginTop: 2
|
|
|
|
|
|
}}>
|
|
|
|
|
|
已写字数
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: 1,
|
|
|
|
|
|
height: isMobile ? 28 : 36,
|
|
|
|
|
|
background: 'rgba(0, 0, 0, 0.06)'
|
|
|
|
|
|
}} />
|
|
|
|
|
|
<div style={{ textAlign: 'center', flex: 1 }}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontSize: isMobile ? 18 : 22,
|
|
|
|
|
|
fontWeight: 700,
|
|
|
|
|
|
color: '#52c41a',
|
|
|
|
|
|
lineHeight: 1.2,
|
|
|
|
|
|
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial'
|
|
|
|
|
|
}}>
|
2026-01-14 19:47:28 +08:00
|
|
|
|
{formatWordCount(project.target_words || 0)}
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
fontSize: isMobile ? 10 : 11,
|
|
|
|
|
|
color: 'var(--color-text-tertiary)',
|
|
|
|
|
|
marginTop: 2
|
|
|
|
|
|
}}>
|
|
|
|
|
|
目标字数
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 卡片底部 - 时间和操作 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
padding: isMobile ? '10px 14px' : '12px 20px',
|
|
|
|
|
|
borderTop: '1px solid rgba(0,0,0,0.04)',
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
marginTop: 'auto'
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<Space size={4} style={{ fontSize: isMobile ? 11 : 12, color: 'var(--color-text-tertiary)' }}>
|
|
|
|
|
|
<CalendarOutlined style={{ fontSize: isMobile ? 10 : 12 }} /> {formatDate(project.updated_at)}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
danger
|
|
|
|
|
|
icon={<DeleteOutlined style={{ fontSize: isMobile ? 12 : 14 }} />}
|
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
handleDelete(project.id);
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ padding: isMobile ? '2px 4px' : '4px 8px' }}
|
|
|
|
|
|
/>
|
2025-12-11 17:01:25 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</Card>
|
2026-01-14 14:33:00 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Spin>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
|
<p style={{ marginBottom: '12px', color: '#666' }}>
|
|
|
|
|
|
选择之前导出的 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 && (
|
|
|
|
|
|
<Card size="small" style={{ background: validationResult.valid ? '#f6ffed' : '#fff2f0' }}>
|
|
|
|
|
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<Text strong style={{ color: validationResult.valid ? '#52c41a' : '#ff4d4f' }}>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: '#faad14', fontSize: 12 }}>
|
|
|
|
|
|
{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>
|
|
|
|
|
|
<ul style={{ margin: '4px 0 0 0', paddingLeft: 20, color: '#ff4d4f', fontSize: 13 }}>
|
|
|
|
|
|
{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%' }}>
|
|
|
|
|
|
<Card size="small" style={{ background: '#f5f5f5' }}>
|
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>
|
|
|
|
|
|
<div style={{ maxHeight: 300, overflowY: 'auto', border: '1px solid #f0f0f0', 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) ? '#e6f7ff' : '#fff',
|
|
|
|
|
|
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-01-14 19:47:28 +08:00
|
|
|
|
<div style={{ fontSize: 12, color: '#999' }}>{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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|