322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|||
|
|
import { useNavigate } from 'react-router-dom';
|
||
|
|
import { Card, Typography, Button, Space, Table, Tag, message, Spin, Modal, Input } from 'antd';
|
||
|
|
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
|
||
|
|
import axios from 'axios';
|
||
|
|
import { useAuthStore } from '../../store/authStore';
|
||
|
|
|
||
|
|
const { Title, Paragraph } = Typography;
|
||
|
|
|
||
|
|
interface Project {
|
||
|
|
id: number
|
||
|
|
project_code: string
|
||
|
|
name: string
|
||
|
|
customer_id: number
|
||
|
|
customer_name?: string
|
||
|
|
status: string
|
||
|
|
budget: string
|
||
|
|
spent: string
|
||
|
|
start_date: string
|
||
|
|
end_date: string
|
||
|
|
description: string
|
||
|
|
manager_name?: string
|
||
|
|
progress?: number
|
||
|
|
}
|
||
|
|
|
||
|
|
const ProjectsPage: React.FC = () => {
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const [isMobile, setIsMobile] = useState(false);
|
||
|
|
const [projects, setProjects] = useState<Project[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||
|
|
const [deleteProjectId, setDeleteProjectId] = useState<number | null>(null);
|
||
|
|
const [deletePassword, setDeletePassword] = useState('');
|
||
|
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||
|
|
|
||
|
|
const { user: currentUser } = useAuthStore();
|
||
|
|
const isAdmin = currentUser?.role === 'admin' || false;
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const checkMobile = () => {
|
||
|
|
setIsMobile(window.innerWidth <= 768);
|
||
|
|
};
|
||
|
|
|
||
|
|
checkMobile();
|
||
|
|
window.addEventListener('resize', checkMobile);
|
||
|
|
return () => window.removeEventListener('resize', checkMobile);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchProjects();
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
const fetchProjects = async () => {
|
||
|
|
try {
|
||
|
|
console.log('开始获取项目列表...');
|
||
|
|
const response = await axios.get('http://localhost:3005/api/projects');
|
||
|
|
console.log('API响应:', response.data);
|
||
|
|
if (response.data.success) {
|
||
|
|
setProjects(response.data.data.map((p: Project) => ({
|
||
|
|
...p,
|
||
|
|
key: p.id.toString(),
|
||
|
|
progress: Math.floor(Math.random() * 100), // 临时模拟进度
|
||
|
|
manager_name: p.manager_name || '未分配'
|
||
|
|
})));
|
||
|
|
} else {
|
||
|
|
console.error('API返回失败:', response.data.message);
|
||
|
|
message.error(`获取项目列表失败: ${response.data.message}`);
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('获取项目列表错误:', error);
|
||
|
|
message.error(`获取项目列表失败: ${error instanceof Error ? error.message : '网络错误'}`);
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 处理删除项目
|
||
|
|
const handleDeleteProject = (projectId: number) => {
|
||
|
|
setDeleteProjectId(projectId);
|
||
|
|
setDeletePassword('');
|
||
|
|
setDeleteModalVisible(true);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 确认删除项目
|
||
|
|
const handleDeleteConfirm = async () => {
|
||
|
|
if (!deleteProjectId) return;
|
||
|
|
|
||
|
|
// 验证密码
|
||
|
|
if (deletePassword !== 'X123c321@') {
|
||
|
|
message.error('密码错误');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setDeleteLoading(true);
|
||
|
|
try {
|
||
|
|
const response = await axios.delete(`/api/projects/${deleteProjectId}`, {
|
||
|
|
headers: {
|
||
|
|
'x-user-role': 'admin'
|
||
|
|
}
|
||
|
|
});
|
||
|
|
if (response.data.success) {
|
||
|
|
message.success('项目删除成功');
|
||
|
|
setDeleteModalVisible(false);
|
||
|
|
fetchProjects();
|
||
|
|
} else {
|
||
|
|
message.error(response.data.message || '删除失败');
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
message.error('删除项目失败');
|
||
|
|
} finally {
|
||
|
|
setDeleteLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// 桌面端表格列
|
||
|
|
const desktopColumns = [
|
||
|
|
{
|
||
|
|
title: '项目名称',
|
||
|
|
dataIndex: 'name',
|
||
|
|
key: 'name',
|
||
|
|
width: 250,
|
||
|
|
ellipsis: true,
|
||
|
|
render: (text: string, record: Project) => (
|
||
|
|
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
|
||
|
|
{text}
|
||
|
|
</a>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '项目经理',
|
||
|
|
dataIndex: 'manager_name',
|
||
|
|
key: 'manager_name',
|
||
|
|
width: 100,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '预算',
|
||
|
|
dataIndex: 'budget',
|
||
|
|
key: 'budget',
|
||
|
|
width: 120,
|
||
|
|
render: (amount: string) => {
|
||
|
|
const val = parseFloat(amount || '0');
|
||
|
|
return val > 0 ? `¥${(val / 10000).toFixed(1)}万` : '-';
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '进度',
|
||
|
|
dataIndex: 'progress',
|
||
|
|
key: 'progress',
|
||
|
|
width: 120,
|
||
|
|
render: (progress: number) => (
|
||
|
|
<div style={{ width: 100 }}>
|
||
|
|
<div style={{ background: '#f0f0f0', borderRadius: 10, height: 8 }}>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
||
|
|
borderRadius: 10,
|
||
|
|
height: 8,
|
||
|
|
width: `${progress}%`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<span style={{ fontSize: 12, color: '#888' }}>{progress}%</span>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '状态',
|
||
|
|
dataIndex: 'status',
|
||
|
|
key: 'status',
|
||
|
|
width: 100,
|
||
|
|
render: (status: string) => {
|
||
|
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||
|
|
planning: { color: 'blue', text: '规划中' },
|
||
|
|
in_progress: { color: 'processing', text: '进行中' },
|
||
|
|
completed: { color: 'success', text: '已完成' },
|
||
|
|
suspended: { color: 'warning', text: '已暂停' },
|
||
|
|
};
|
||
|
|
const config = statusMap[status] || { color: 'default', text: status };
|
||
|
|
return <Tag color={config.color}>{config.text}</Tag>;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '操作',
|
||
|
|
key: 'action',
|
||
|
|
width: 140,
|
||
|
|
render: (_: unknown, record: Project) => (
|
||
|
|
<Space>
|
||
|
|
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||
|
|
{isAdmin && (
|
||
|
|
<Button
|
||
|
|
size="small"
|
||
|
|
danger
|
||
|
|
icon={<DeleteOutlined />}
|
||
|
|
onClick={() => handleDeleteProject(record.id)}
|
||
|
|
>
|
||
|
|
删除
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</Space>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
// 移动端简化表格列
|
||
|
|
const mobileColumns = [
|
||
|
|
{
|
||
|
|
title: '项目',
|
||
|
|
dataIndex: 'name',
|
||
|
|
key: 'name',
|
||
|
|
ellipsis: true,
|
||
|
|
render: (text: string, record: Project) => (
|
||
|
|
<a onClick={() => navigate(`/projects/${record.id}`)} style={{ cursor: 'pointer' }}>
|
||
|
|
{text}
|
||
|
|
</a>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '进度',
|
||
|
|
dataIndex: 'progress',
|
||
|
|
key: 'progress',
|
||
|
|
width: 80,
|
||
|
|
render: (progress: number) => (
|
||
|
|
<div style={{ width: 60 }}>
|
||
|
|
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 6 }}>
|
||
|
|
<div
|
||
|
|
style={{
|
||
|
|
background: progress > 80 ? '#52c41a' : progress > 50 ? '#1890ff' : '#faad14',
|
||
|
|
borderRadius: 4,
|
||
|
|
height: 6,
|
||
|
|
width: `${progress}%`,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<span style={{ fontSize: 10, color: '#888' }}>{progress}%</span>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '状态',
|
||
|
|
dataIndex: 'status',
|
||
|
|
key: 'status',
|
||
|
|
width: 70,
|
||
|
|
render: (status: string) => {
|
||
|
|
const statusMap: Record<string, { color: string; text: string }> = {
|
||
|
|
planning: { color: 'blue', text: '规划' },
|
||
|
|
in_progress: { color: 'processing', text: '进行中' },
|
||
|
|
completed: { color: 'success', text: '完成' },
|
||
|
|
suspended: { color: 'warning', text: '暂停' },
|
||
|
|
};
|
||
|
|
const config = statusMap[status] || { color: 'default', text: status };
|
||
|
|
return <Tag color={config.color} style={{ fontSize: 10 }}>{config.text}</Tag>;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '操作',
|
||
|
|
key: 'action',
|
||
|
|
width: 60,
|
||
|
|
render: (_: unknown, record: Project) => (
|
||
|
|
<Button size="small" onClick={() => navigate(`/projects/${record.id}`)}>查看</Button>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: isMobile ? 8 : 24 }}>
|
||
|
|
<div style={{ marginBottom: isMobile ? 12 : 24 }}>
|
||
|
|
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>项目管理</Title>
|
||
|
|
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||
|
|
项目由预算报价签约后自动创建,管理您的项目信息、进度和预算
|
||
|
|
</Paragraph>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Card
|
||
|
|
title="项目列表"
|
||
|
|
size="small"
|
||
|
|
styles={{ body: { padding: isMobile ? 8 : 24 } }}
|
||
|
|
>
|
||
|
|
{loading ? (
|
||
|
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||
|
|
<Spin />
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Table
|
||
|
|
dataSource={projects}
|
||
|
|
columns={isMobile ? mobileColumns : desktopColumns}
|
||
|
|
pagination={{
|
||
|
|
pageSize: isMobile ? 5 : 10,
|
||
|
|
size: isMobile ? 'small' : 'default'
|
||
|
|
}}
|
||
|
|
scroll={isMobile ? { x: 350 } : undefined}
|
||
|
|
size={isMobile ? 'small' : 'middle'}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 删除确认模态框 */}
|
||
|
|
<Modal
|
||
|
|
title="删除确认"
|
||
|
|
open={deleteModalVisible}
|
||
|
|
onOk={handleDeleteConfirm}
|
||
|
|
onCancel={() => setDeleteModalVisible(false)}
|
||
|
|
confirmLoading={deleteLoading}
|
||
|
|
okText="确认删除"
|
||
|
|
cancelText="取消"
|
||
|
|
>
|
||
|
|
<div style={{ marginBottom: 16 }}>
|
||
|
|
<p>确定要删除这个项目吗?此操作不可恢复。</p>
|
||
|
|
<p>请输入管理员密码确认删除操作:</p>
|
||
|
|
</div>
|
||
|
|
<Input.Password
|
||
|
|
placeholder="请输入管理员密码"
|
||
|
|
value={deletePassword}
|
||
|
|
onChange={(e) => setDeletePassword(e.target.value)}
|
||
|
|
size="large"
|
||
|
|
/>
|
||
|
|
</Modal>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default ProjectsPage;
|