Files
MuMuAINovel/frontend/src/pages/ProjectList.tsx
T

1092 lines
44 KiB
TypeScript
Raw Normal View History

import { useEffect, useState } from 'react';
2025-10-30 11:14:43 +08:00
import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown } from 'antd';
2025-11-14 17:16:24 +08:00
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined, BulbOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
2025-10-30 11:14:43 +08:00
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import type { ReactNode } from 'react';
import { cardStyles, cardHoverHandlers, gridConfig } from '../components/CardStyles';
import UserMenu from '../components/UserMenu';
const { Title, Text, Paragraph } = Typography;
export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const [showApiTip, setShowApiTip] = useState(true);
const [importModalVisible, setImportModalVisible] = useState(false);
const [exportModalVisible, setExportModalVisible] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [validationResult, setValidationResult] = useState<any>(null);
const [importing, setImporting] = useState(false);
const [validating, setValidating] = useState(false);
const [exporting, setExporting] = useState(false);
const [selectedProjectIds, setSelectedProjectIds] = useState<string[]>([]);
const [exportOptions, setExportOptions] = useState({
includeWritingStyles: true,
includeGenerationHistory: true,
});
2025-10-30 11:14:43 +08:00
const { refreshProjects, deleteProject } = useProjectSync();
useEffect(() => {
refreshProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
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;
Modal.confirm({
title: '确认删除',
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
centered: true,
...(isMobile && {
style: { top: 'auto' }
}),
onOk: async () => {
try {
await deleteProject(id);
message.success('项目删除成功');
} catch {
message.error('删除项目失败');
}
},
});
};
const handleEnterProject = (id: string) => {
// 简化后直接进入项目,不再检查向导状态
navigate(`/project/${id}`);
2025-10-30 11:14:43 +08:00
};
const getStatusTag = (status: string) => {
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
};
const config = statusConfig[status] || statusConfig.planning;
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
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));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
if (days < 30) return `${Math.floor(days / 7)}周前`;
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;
// 处理文件选择
const handleFileSelect = async (file: File) => {
setSelectedFile(file);
setValidationResult(null);
// 验证文件
try {
setValidating(true);
const result = await projectApi.validateImportFile(file);
setValidationResult(result);
if (!result.valid) {
message.error('文件验证失败');
}
} catch (error) {
console.error('验证失败:', error);
message.error('文件验证失败');
} finally {
setValidating(false);
}
return false; // 阻止自动上传
};
// 处理导入
const handleImport = async () => {
if (!selectedFile || !validationResult?.valid) {
message.warning('请选择有效的导入文件');
return;
}
try {
setImporting(true);
const result = await projectApi.importProject(selectedFile);
if (result.success) {
message.success(`项目导入成功!${result.message}`);
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
// 刷新项目列表
await refreshProjects();
// 跳转到新项目
if (result.project_id) {
navigate(`/project/${result.project_id}`);
}
} else {
message.error(result.message || '导入失败');
}
} catch (error) {
console.error('导入失败:', error);
message.error('导入失败,请重试');
} finally {
setImporting(false);
}
};
// 关闭导入对话框
const handleCloseImportModal = () => {
setImportModalVisible(false);
setSelectedFile(null);
setValidationResult(null);
};
// 打开导出对话框
const handleOpenExportModal = () => {
setExportModalVisible(true);
setSelectedProjectIds([]);
};
// 获取所有可导出的项目
const exportableProjects = projects;
// 关闭导出对话框
const handleCloseExportModal = () => {
setExportModalVisible(false);
setSelectedProjectIds([]);
};
// 切换项目选择
const handleToggleProject = (projectId: string) => {
setSelectedProjectIds(prev =>
prev.includes(projectId)
? prev.filter(id => id !== projectId)
: [...prev, projectId]
);
};
// 全选/取消全选
const handleToggleAll = () => {
if (selectedProjectIds.length === exportableProjects.length) {
setSelectedProjectIds([]);
} else {
setSelectedProjectIds(exportableProjects.map(p => p.id));
}
};
// 执行导出
const handleExport = async () => {
if (selectedProjectIds.length === 0) {
message.warning('请至少选择一个项目');
return;
}
try {
setExporting(true);
if (selectedProjectIds.length === 1) {
// 单个项目导出
const projectId = selectedProjectIds[0];
const project = projects.find(p => p.id === projectId);
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
message.success(`项目 "${project?.title}" 导出成功`);
} else {
// 批量导出
let successCount = 0;
let failCount = 0;
for (const projectId of selectedProjectIds) {
try {
await projectApi.exportProjectData(projectId, {
include_generation_history: exportOptions.includeGenerationHistory,
include_writing_styles: exportOptions.includeWritingStyles
});
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-10-30 11:14:43 +08:00
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
}}>
<div style={{
maxWidth: 1400,
margin: '0 auto',
marginBottom: window.innerWidth <= 768 ? 20 : 40
}}>
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: window.innerWidth <= 768 ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col xs={24} sm={12} md={10}>
<Space direction="vertical" size={4}>
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
<FireOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
</Title>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={14}>
{window.innerWidth <= 768 ? (
2025-11-14 19:28:49 +08:00
// 移动端:优化布局
<Space direction="vertical" size={8} style={{ width: '100%' }}>
2025-11-14 19:28:49 +08:00
{/* 第一行:主要创建按钮 */}
<Row gutter={8}>
<Col span={12}>
<Button
type="primary"
size="middle"
icon={<BulbOutlined />}
onClick={() => navigate('/inspiration')}
block
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(255, 215, 0, 0.4)',
color: '#fff',
height: 40
}}
>
</Button>
</Col>
<Col span={12}>
<Button
type="primary"
size="middle"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
block
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)',
height: 40
}}
>
</Button>
</Col>
</Row>
{/* 第二行:功能按钮 */}
<Row gutter={8}>
<Col span={8}>
<Button
type="default"
size="middle"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
block
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
height: 36,
padding: '0 8px'
}}
>
</Button>
</Col>
<Col span={8}>
<Dropdown
menu={{
items: [
{
key: 'export',
label: '导出项目',
icon: <DownloadOutlined />,
onClick: handleOpenExportModal,
disabled: exportableProjects.length === 0
},
{
key: 'import',
label: '导入项目',
icon: <UploadOutlined />,
onClick: () => setImportModalVisible(true)
},
{
type: 'divider'
},
{
key: 'mcp',
label: 'MCP插件',
icon: <ApiOutlined />,
onClick: () => navigate('/mcp-plugins')
}
]
}}
placement="bottomRight"
trigger={['click']}
>
<Button
size="middle"
icon={<MoreOutlined />}
block
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
height: 36
}}
>
</Button>
</Dropdown>
</Col>
<Col span={8}>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<UserMenu />
</div>
</Col>
</Row>
</Space>
) : (
// PC端:优化后的布局 - 主要按钮 + 下拉菜单
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
2025-11-14 17:16:24 +08:00
<Button
type="primary"
size="large"
icon={<BulbOutlined />}
onClick={() => navigate('/inspiration')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(255, 215, 0, 0.4)',
color: '#fff'
}}
>
</Button>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)'
}}
>
</Button>
<Button
type="default"
size="large"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#667eea';
e.currentTarget.style.color = '#667eea';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(102, 126, 234, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
API设置
</Button>
<Dropdown
menu={{
items: [
{
key: 'export',
label: '导出项目',
icon: <DownloadOutlined />,
onClick: handleOpenExportModal,
disabled: exportableProjects.length === 0
},
{
key: 'import',
label: '导入项目',
icon: <UploadOutlined />,
onClick: () => setImportModalVisible(true)
},
{
type: 'divider'
},
{
key: 'mcp',
label: 'MCP插件',
icon: <ApiOutlined />,
onClick: () => navigate('/mcp-plugins')
}
]
}}
placement="bottomRight"
>
<Button
size="large"
icon={<MoreOutlined />}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(24, 144, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
</Button>
</Dropdown>
<UserMenu />
</Space>
)}
2025-10-30 11:14:43 +08:00
</Col>
</Row>
{showApiTip && projects.length === 0 && (
<Alert
message={
<Space align="center" style={{ width: '100%' }}>
<InfoCircleOutlined style={{ fontSize: 16, color: '#1890ff' }} />
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
使
</Text>
</Space>
}
description={
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text style={{ fontSize: window.innerWidth <= 768 ? 12 : 13 }}>
AI接口OpenAI和Anthropic两种接口
</Text>
<Space size={8}>
<Button
type="primary"
size="small"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
borderRadius: 6,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
</Button>
<Button
size="small"
onClick={() => setShowApiTip(false)}
style={{ borderRadius: 6 }}
>
</Button>
</Space>
</Space>
}
type="info"
showIcon={false}
closable
closeIcon={<CloseOutlined style={{ fontSize: 12 }} />}
onClose={() => setShowApiTip(false)}
style={{
marginTop: window.innerWidth <= 768 ? 16 : 24,
borderRadius: 12,
background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%)',
border: '1px solid #91d5ff'
}}
/>
)}
2025-10-30 11:14:43 +08:00
{projects.length > 0 && (
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#f0f5ff', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={projects.length}
prefix={<BookOutlined style={{ color: '#1890ff' }} />}
suffix="个"
valueStyle={{ color: '#1890ff', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#f6ffed', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={activeProjects}
prefix={<EditOutlined style={{ color: '#52c41a' }} />}
suffix="个"
valueStyle={{ color: '#52c41a', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#fff7e6', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={totalWords}
prefix={<FileTextOutlined style={{ color: '#faad14' }} />}
suffix="字"
valueStyle={{ color: '#faad14', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
</Row>
)}
</Card>
</div>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Spin spinning={loading}>
{!Array.isArray(projects) || projects.length === 0 ? (
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Empty
description={
<Space direction="vertical" size={16}>
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
</Text>
2025-11-14 17:16:24 +08:00
<Space size={12}>
<Button
type="primary"
size="large"
icon={<BulbOutlined />}
onClick={() => navigate('/inspiration')}
style={{
background: 'linear-gradient(135deg, #ffd700 0%, #ff8c00 100%)',
border: 'none',
color: '#fff'
}}
>
</Button>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
>
</Button>
</Space>
2025-10-30 11:14:43 +08:00
</Space>
}
style={{ padding: '80px 0' }}
/>
</Card>
) : (
<Row gutter={[16, 16]}>
{projects.map((project) => {
const progress = getProgress(project.current_words, project.target_words || 0);
return (
<Col {...gridConfig} key={project.id}>
<Badge.Ribbon
text={getStatusTag(project.status)}
2025-10-30 11:14:43 +08:00
color="transparent"
style={{ top: 12, right: 12 }}
>
<Card
hoverable
variant="borderless"
onClick={() => handleEnterProject(project.id)}
style={cardStyles.project}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
{...cardHoverHandlers}
>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: window.innerWidth <= 768 ? '16px' : '24px',
position: 'relative'
}}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: window.innerWidth <= 768 ? 8 : 12 }}>
<BookOutlined style={{ fontSize: window.innerWidth <= 768 ? 20 : 28, color: '#fff' }} />
<Title level={window.innerWidth <= 768 ? 5 : 4} style={{ margin: 0, color: '#fff', flex: 1 }} ellipsis>
{project.title}
</Title>
</div>
{project.genre && (
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
{project.genre}
</Tag>
)}
</Space>
</div>
<div style={{ padding: window.innerWidth <= 768 ? '16px' : '20px' }}>
<Paragraph
ellipsis={{ rows: 2 }}
style={{
color: 'rgba(0,0,0,0.65)',
minHeight: 44,
marginBottom: 16
}}
>
{project.description || '暂无描述'}
</Paragraph>
{project.target_words && project.target_words > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
2025-10-30 11:14:43 +08:00
</div>
<Progress
percent={progress}
strokeColor={getProgressColor(progress)}
showInfo={false}
size={{ height: 8 }}
/>
2025-10-30 11:14:43 +08:00
</div>
)}
<Row gutter={12}>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{(project.current_words / 1000).toFixed(1)}K
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
</Row>
2025-10-30 11:14:43 +08:00
<div style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Text type="secondary" style={{ fontSize: 12 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{formatDate(project.updated_at)}
</Text>
<Space size={8}>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id);
}}
/>
</Tooltip>
</Space>
</div>
</div>
</Card>
</Badge.Ribbon>
</Col>
);
})}
</Row>
)}
</Spin>
</div>
{/* 导入项目对话框 */}
<Modal
title="导入项目"
open={importModalVisible}
onOk={handleImport}
onCancel={handleCloseImportModal}
confirmLoading={importing}
okText="导入"
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 500}
centered
okButtonProps={{ disabled: !validationResult?.valid }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '60vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<div>
<p style={{ marginBottom: '12px', color: '#666', fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
JSON
</p>
<Upload
accept=".json"
beforeUpload={handleFileSelect}
maxCount={1}
onRemove={() => {
setSelectedFile(null);
setValidationResult(null);
}}
fileList={selectedFile ? [{ uid: '-1', name: selectedFile.name, status: 'done' }] as any : []}
>
<Button icon={<UploadOutlined />} block></Button>
</Upload>
</div>
{validating && (
<div style={{ textAlign: 'center', padding: '20px' }}>
<Spin tip="验证文件中..." />
</div>
)}
{validationResult && (
<Card size="small" style={{ background: validationResult.valid ? '#f6ffed' : '#fff2f0' }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div>
<Text strong style={{
color: validationResult.valid ? '#52c41a' : '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 13 : 14
}}>
{validationResult.valid ? '✓ 文件验证通过' : '✗ 文件验证失败'}
</Text>
</div>
{validationResult.project_name && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>{validationResult.project_name}</Text>
</div>
)}
{validationResult.statistics && Object.keys(validationResult.statistics).length > 0 && (
<div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<div style={{ marginTop: 8 }}>
<Row gutter={[8, 8]}>
{validationResult.statistics.chapters > 0 && (
<Col span={12}>
<Tag color="blue">: {validationResult.statistics.chapters}</Tag>
</Col>
)}
{validationResult.statistics.characters > 0 && (
<Col span={12}>
<Tag color="green">: {validationResult.statistics.characters}</Tag>
</Col>
)}
{validationResult.statistics.outlines > 0 && (
<Col span={12}>
<Tag color="purple">: {validationResult.statistics.outlines}</Tag>
</Col>
)}
{validationResult.statistics.relationships > 0 && (
<Col span={12}>
<Tag color="orange">: {validationResult.statistics.relationships}</Tag>
</Col>
)}
</Row>
</div>
</div>
)}
{validationResult.errors && validationResult.errors.length > 0 && (
<div>
<Text type="danger" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#ff4d4f',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.errors.map((error: string, index: number) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
)}
{validationResult.warnings && validationResult.warnings.length > 0 && (
<div>
<Text type="warning" strong style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}></Text>
<ul style={{
margin: '4px 0 0 0',
paddingLeft: '20px',
color: '#faad14',
fontSize: window.innerWidth <= 768 ? 12 : 13
}}>
{validationResult.warnings.map((warning: string, index: number) => (
<li key={index}>{warning}</li>
))}
</ul>
</div>
)}
</Space>
</Card>
)}
</Space>
</Modal>
{/* 导出项目对话框 */}
<Modal
title="导出项目"
open={exportModalVisible}
onOk={handleExport}
onCancel={handleCloseExportModal}
confirmLoading={exporting}
okText={selectedProjectIds.length > 0 ? `导出 (${selectedProjectIds.length})` : '导出'}
cancelText="取消"
width={window.innerWidth <= 768 ? '90%' : 700}
centered
okButtonProps={{ disabled: selectedProjectIds.length === 0 }}
styles={{
body: {
maxHeight: window.innerWidth <= 768 ? '70vh' : 'auto',
overflowY: 'auto',
padding: window.innerWidth <= 768 ? '16px' : '24px'
}
}}
>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{/* 导出选项 */}
<Card
size="small"
style={{ background: '#f5f5f5' }}
styles={{ body: { padding: window.innerWidth <= 768 ? 12 : 16 } }}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeWritingStyles}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeWritingStyles: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出项目关联的写作风格数据">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Switch
size={window.innerWidth <= 768 ? 'small' : 'default'}
checked={exportOptions.includeGenerationHistory}
onChange={(checked) => setExportOptions(prev => ({ ...prev, includeGenerationHistory: checked }))}
style={{
flexShrink: 0,
height: window.innerWidth <= 768 ? 16 : 22,
minHeight: window.innerWidth <= 768 ? 16 : 22,
lineHeight: window.innerWidth <= 768 ? '16px' : '22px'
}}
/>
<Text style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}></Text>
<Tooltip title="导出AI生成的历史记录(最多100条)">
<InfoCircleOutlined style={{ color: '#999', fontSize: window.innerWidth <= 768 ? 12 : 14 }} />
</Tooltip>
</div>
</Space>
</Card>
<Divider style={{ margin: '8px 0' }} />
{/* 项目列表 */}
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, flexWrap: window.innerWidth <= 768 ? 'wrap' : 'nowrap', gap: 8 }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
{exportableProjects.length > 0 && <Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>({exportableProjects.length})</Text>}
</Text>
<Checkbox
checked={selectedProjectIds.length === exportableProjects.length && exportableProjects.length > 0}
indeterminate={selectedProjectIds.length > 0 && selectedProjectIds.length < exportableProjects.length}
onChange={handleToggleAll}
style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}
>
</Checkbox>
</div>
<div style={{ maxHeight: window.innerWidth <= 768 ? 300 : 400, overflowY: 'auto' }}>
{exportableProjects.length === 0 ? (
<Empty
description="暂无可导出的项目"
style={{ padding: '40px 0' }}
/>
) : (
<Space direction="vertical" size={8} style={{ width: '100%' }}>
{exportableProjects.map((project) => (
<Card
key={project.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedProjectIds.includes(project.id) ? '2px solid #1890ff' : '1px solid #d9d9d9',
background: selectedProjectIds.includes(project.id) ? '#e6f7ff' : '#fff'
}}
onClick={() => handleToggleProject(project.id)}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Checkbox
checked={selectedProjectIds.includes(project.id)}
onChange={() => handleToggleProject(project.id)}
onClick={(e) => e.stopPropagation()}
/>
<BookOutlined style={{ fontSize: 20, color: '#1890ff' }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 4, flexWrap: 'wrap' }}>
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>{project.title}</Text>
{project.genre && (
<Tag color="blue" style={{ margin: 0, fontSize: window.innerWidth <= 768 ? 11 : 12 }}>{project.genre}</Tag>
)}
{getStatusTag(project.status)}
</div>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 11 : 12 }}>
{project.current_words || 0}
{project.description && ` · ${project.description.substring(0, window.innerWidth <= 768 ? 30 : 50)}${project.description.length > (window.innerWidth <= 768 ? 30 : 50) ? '...' : ''}`}
</Text>
</div>
{window.innerWidth > 768 && (
<Text type="secondary" style={{ fontSize: 12 }}>
{formatDate(project.updated_at)}
</Text>
)}
</div>
</Card>
))}
</Space>
)}
</div>
</div>
{selectedProjectIds.length > 0 && (
<Alert
message={`已选择 ${selectedProjectIds.length} 个项目`}
type="info"
showIcon
style={{ marginTop: 8 }}
/>
)}
</Space>
</Modal>
2025-11-14 17:16:24 +08:00
2025-10-30 11:14:43 +08:00
</div>
);
}