Files
MuMuAINovel/frontend/src/pages/ProjectDetail.tsx
T
2025-10-31 17:23:25 +08:00

424 lines
13 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
import {
ArrowLeftOutlined,
FileTextOutlined,
TeamOutlined,
BookOutlined,
// ToolOutlined,
GlobalOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ApartmentOutlined,
BankOutlined,
EditOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
import { projectApi } from '../services/api';
const { Header, Sider, Content } = Layout;
// 判断是否为移动端
const isMobile = () => window.innerWidth <= 768;
export default function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [mobile, setMobile] = useState(isMobile());
// 监听窗口大小变化
useEffect(() => {
const handleResize = () => {
setMobile(isMobile());
if (!isMobile()) {
setDrawerVisible(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const {
currentProject,
setCurrentProject,
clearProjectData,
loading,
setLoading,
outlines,
characters,
chapters,
} = useStore();
// 使用同步 hooks
const { refreshCharacters } = useCharacterSync();
const { refreshOutlines } = useOutlineSync();
const { refreshChapters } = useChapterSync();
useEffect(() => {
const loadProjectData = async (id: string) => {
try {
setLoading(true);
// 加载项目基本信息
const project = await projectApi.getProject(id);
setCurrentProject(project);
// 并行加载其他数据
await Promise.all([
refreshOutlines(id),
refreshCharacters(id),
refreshChapters(id),
]);
} catch (error) {
console.error('加载项目数据失败:', error);
} finally {
setLoading(false);
}
};
if (projectId) {
loadProjectData(projectId);
}
return () => {
clearProjectData();
};
}, [projectId, clearProjectData, setLoading, setCurrentProject, refreshOutlines, refreshCharacters, refreshChapters]);
// 移除事件监听,避免无限循环
// Hook 内部已经更新了 store,不需要再次刷新
const menuItems = [
{
key: 'world-setting',
icon: <GlobalOutlined />,
label: <Link to={`/project/${projectId}/world-setting`}></Link>,
},
{
key: 'characters',
icon: <TeamOutlined />,
label: <Link to={`/project/${projectId}/characters`}></Link>,
},
{
key: 'relationships',
icon: <ApartmentOutlined />,
label: <Link to={`/project/${projectId}/relationships`}></Link>,
},
{
key: 'organizations',
icon: <BankOutlined />,
label: <Link to={`/project/${projectId}/organizations`}></Link>,
},
{
key: 'outline',
icon: <FileTextOutlined />,
label: <Link to={`/project/${projectId}/outline`}></Link>,
},
{
key: 'chapters',
icon: <BookOutlined />,
label: <Link to={`/project/${projectId}/chapters`}></Link>,
},
{
key: 'writing-styles',
icon: <EditOutlined />,
label: <Link to={`/project/${projectId}/writing-styles`}></Link>,
},
// {
// key: 'polish',
// icon: <ToolOutlined />,
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
// },
];
// 根据当前路径动态确定选中的菜单项
const selectedKey = useMemo(() => {
const path = location.pathname;
if (path.includes('/world-setting')) return 'world-setting';
if (path.includes('/relationships')) return 'relationships';
if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline';
if (path.includes('/characters')) return 'characters';
if (path.includes('/chapters')) return 'chapters';
if (path.includes('/writing-styles')) return 'writing-styles';
// if (path.includes('/polish')) return 'polish';
return 'world-setting'; // 默认选中世界设定
}, [location.pathname]);
if (loading || !currentProject) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
// 渲染菜单内容
const renderMenu = () => (
<div style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden'
}}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
style={{
borderRight: 0,
paddingTop: '16px'
}}
items={menuItems}
onClick={() => mobile && setDrawerVisible(false)}
/>
</div>
);
return (
<Layout style={{ minHeight: '100vh', height: '100vh', overflow: 'hidden' }}>
<Header style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: mobile ? '0 12px' : '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
height: mobile ? 56 : 70
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', zIndex: 1 }}>
<Button
type="text"
icon={mobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
onClick={() => mobile ? setDrawerVisible(true) : setCollapsed(!collapsed)}
style={{
fontSize: mobile ? '18px' : '20px',
color: '#fff',
width: mobile ? '36px' : '40px',
height: mobile ? '36px' : '40px'
}}
/>
{!mobile && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
fontSize: '16px',
color: '#fff',
height: '40px',
padding: '0 16px'
}}
>
</Button>
)}
</div>
<h2 style={{
margin: 0,
color: '#fff',
fontSize: mobile ? '16px' : '24px',
fontWeight: 600,
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: mobile ? 'static' : 'absolute',
left: mobile ? 'auto' : '50%',
transform: mobile ? 'none' : 'translateX(-50%)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: mobile ? 1 : 'none',
textAlign: mobile ? 'center' : 'left',
paddingLeft: mobile ? '8px' : '0',
paddingRight: mobile ? '8px' : '0'
}}>
{currentProject.title}
</h2>
{mobile && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
fontSize: '14px',
color: '#fff',
height: '36px',
padding: '0 8px',
zIndex: 1
}}
>
</Button>
)}
{!mobile && (
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={outlines.length}
suffix="条"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={characters.length}
suffix="个"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={chapters.length}
suffix="章"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={currentProject.current_words}
suffix="字"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
)}
</Header>
<Layout style={{ marginTop: mobile ? 56 : 70 }}>
{mobile ? (
<Drawer
title="导航菜单"
placement="left"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
width={280}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
>
{renderMenu()}
</Drawer>
) : (
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
width={220}
collapsedWidth={60}
style={{
background: '#fff',
position: 'fixed',
left: 0,
top: 70,
bottom: 0,
overflow: 'hidden',
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
transition: 'all 0.2s',
height: 'calc(100vh - 70px)'
}}
>
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
{renderMenu()}
</div>
</Sider>
)}
<Layout style={{
marginLeft: mobile ? 0 : (collapsed ? 60 : 220),
transition: 'all 0.2s'
}}>
<Content
style={{
background: '#f5f7fa',
padding: mobile ? 12 : 24,
height: mobile ? 'calc(100vh - 56px)' : 'calc(100vh - 70px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
background: '#fff',
padding: mobile ? 12 : 24,
borderRadius: mobile ? '8px' : '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<Outlet />
</div>
</Content>
</Layout>
</Layout>
</Layout>
);
}