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

1064 lines
44 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState } from 'react';
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';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api';
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,
});
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) => {
const project = projects.find(p => p.id === id);
if (project) {
console.log('项目信息:', {
id: project.id,
title: project.title,
wizard_status: project.wizard_status,
wizard_step: project.wizard_step
});
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
console.log('向导未完成,跳转到向导页面');
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
} else {
console.log('向导已完成,进入项目管理界面');
navigate(`/project/${id}`);
}
}
};
const getStatusTag = (status: string) => {
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
};
const config = statusConfig[status] || statusConfig.planning;
return (
<Tag color={config.color} icon={config.icon}>
{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.filter(p => p.wizard_status === 'completed');
// 关闭导出对话框
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);
}
};
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 ? (
// 移动端:按钮分两行显示
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space size={8} style={{ width: '100%' }}>
<Button
type="primary"
size="middle"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
flex: 1,
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="middle"
icon={<SettingOutlined />}
onClick={() => navigate('/settings')}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)'
}}
>
API设置
</Button>
<UserMenu />
</Space>
<Space size={8} style={{ width: '100%' }}>
<Button
type="default"
size="middle"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
>
</Button>
<Button
type="default"
size="middle"
icon={<ApiOutlined />}
onClick={() => navigate('/mcp-plugins')}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#722ed1',
color: '#722ed1',
boxShadow: '0 2px 8px rgba(114, 46, 209, 0.2)'
}}
>
MCP
</Button>
</Space>
</Space>
) : (
// PC端:优化后的布局 - 主要按钮 + 下拉菜单
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<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>
)}
</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'
}}
/>
)}
{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>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
>
</Button>
</Space>
}
style={{ padding: '80px 0' }}
/>
</Card>
) : (
<Row gutter={[16, 16]}>
{projects.map((project) => {
const progress = getProgress(project.current_words, project.target_words || 0);
const isWizardComplete = project.wizard_status === 'completed';
return (
<Col {...gridConfig} key={project.id}>
<Badge.Ribbon
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}></Tag>}
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>
{isWizardComplete ? (
<>
{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>
</div>
<Progress
percent={progress}
strokeColor={getProgressColor(progress)}
showInfo={false}
size={{ height: 8 }}
/>
</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>
</>
) : (
<div style={{
textAlign: 'center',
padding: '24px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</div>
)}
<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>
</div>
);
}