save code
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './contexts/AuthContext';
|
||||
import Layout from './components/Layout/Layout';
|
||||
import Login from './pages/Login/Login';
|
||||
import Dashboard from './pages/Dashboard/Dashboard';
|
||||
import ProjectList from './pages/Projects/ProjectList';
|
||||
import UserList from './pages/Users/UserList';
|
||||
import Statistics from './pages/Statistics/Statistics';
|
||||
|
||||
const PrivateRoute = ({ children, requiresRole }) => {
|
||||
const { isAuthenticated, user, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (requiresRole && !requiresRole.includes(user?.role)) {
|
||||
return <div>您没有访问该页面的权限</div>;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Layout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route
|
||||
path="/projects"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<ProjectList />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/create"
|
||||
element={
|
||||
<PrivateRoute requiresRole={['admin', 'market']}>
|
||||
<ProjectList mode="create" />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<PrivateRoute requiresRole={['admin']}>
|
||||
<UserList />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/statistics"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<Statistics />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const Header = () => {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (window.confirm('确定要退出登录吗?')) {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="layout-header">
|
||||
<div>
|
||||
<h1 style={{ color: '#fff', fontSize: '20px', margin: 0 }}>
|
||||
海洋项目管理系统
|
||||
</h1>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
|
||||
<span style={{ color: '#fff', fontSize: '14px' }}>
|
||||
{user?.real_name || ''}
|
||||
</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.65)', fontSize: '14px' }}>
|
||||
({user?.department || ''})
|
||||
</span>
|
||||
<button className="btn btn-default" onClick={handleLogout}>
|
||||
登出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileTextOutlined,
|
||||
UserOutlined,
|
||||
BarChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Header from './Header';
|
||||
|
||||
const Sidebar = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { user } = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '仪表盘',
|
||||
},
|
||||
{
|
||||
key: '/projects',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '项目管理',
|
||||
},
|
||||
...(user?.role === 'admin' ? [
|
||||
{
|
||||
key: '/users',
|
||||
icon: <UserOutlined />,
|
||||
label: '用户管理',
|
||||
},
|
||||
] : []),
|
||||
{
|
||||
key: '/statistics',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '项目统计',
|
||||
},
|
||||
];
|
||||
|
||||
const handleMenuClick = ({ key }) => {
|
||||
navigate(key);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<div className="layout-sider">
|
||||
<div style={{ padding: '16px' }}>
|
||||
<h3 style={{ margin: 0, fontSize: '16px', color: '#001529' }}>
|
||||
�单
|
||||
</h3>
|
||||
</div>
|
||||
{menuItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={`menu-item ${
|
||||
location.pathname === item.key ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => handleMenuClick(item)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
color: location.pathname === item.key ? '#1890ff' : '#666',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#e6f7ff';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="layout-content">
|
||||
<Outlet />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
@@ -0,0 +1,146 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
const [token, setToken] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authState, setAuthState] = useState({
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
try {
|
||||
const savedToken = localStorage.getItem('token');
|
||||
const savedUser = localStorage.getItem('user');
|
||||
|
||||
if (savedToken && savedUser) {
|
||||
setToken(savedToken);
|
||||
setUser(JSON.parse(savedUser));
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
} else {
|
||||
clearAuth();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
clearAuth();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (username, password) => {
|
||||
try {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
const { token: newToken, user: userData } = data.data;
|
||||
|
||||
localStorage.setItem('token', newToken);
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
setToken(newToken);
|
||||
setUser(userData);
|
||||
setAuthState({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
return userData;
|
||||
} else {
|
||||
throw new Error(data.message || '登录失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setAuthState({
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasRole = useCallback((roles) => {
|
||||
if (!user) return false;
|
||||
if (Array.isArray(roles)) {
|
||||
return roles.includes(user.role);
|
||||
}
|
||||
return user.role === roles;
|
||||
}, [user]);
|
||||
|
||||
const canEdit = useCallback((createdBy) => {
|
||||
if (!user) return false;
|
||||
if (user.role === 'admin') return true;
|
||||
if (user.role === 'market' && createdBy === user.id) return true;
|
||||
if (user.role === 'other') return 'partial';
|
||||
return false;
|
||||
}, [user]);
|
||||
|
||||
const value = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
authState,
|
||||
login,
|
||||
logout,
|
||||
checkAuth,
|
||||
hasRole,
|
||||
canEdit,
|
||||
isAuthenticated: authState.isAuthenticated,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default AuthContext;
|
||||
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export const useApi = (apiFunc) => {
|
||||
const [data, setData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const execute = useCallback(
|
||||
async (...args) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const result = await apiFunc(...args);
|
||||
setData(result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiFunc]
|
||||
);
|
||||
|
||||
return { data, loading, error, execute };
|
||||
};
|
||||
|
||||
export const useList = (apiFunc, defaultParams = {}) => {
|
||||
const [list, setList] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
|
||||
const fetchList = useCallback(
|
||||
async (params = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const mergedParams = { ...defaultParams, ...params };
|
||||
const result = await apiFunc(mergedParams);
|
||||
|
||||
setList(result.items || []);
|
||||
setTotal(result.total || 0);
|
||||
setPagination({
|
||||
current: result.page || 1,
|
||||
pageSize: result.page_size || 10,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Fetch list error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiFunc, defaultParams]
|
||||
);
|
||||
|
||||
const handleTableChange = useCallback((newPagination, filters, sorter) => {
|
||||
setPagination(newPagination);
|
||||
|
||||
const sortParams = sorter.field
|
||||
? {
|
||||
sort_by: sorter.field,
|
||||
sort_order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
}
|
||||
: {};
|
||||
|
||||
fetchList({
|
||||
page: newPagination.current,
|
||||
page_size: newPagination.pageSize,
|
||||
...sortParams,
|
||||
});
|
||||
}, [fetchList]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
fetchList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
});
|
||||
}, [fetchList, pagination.current, pagination.pageSize]);
|
||||
|
||||
return {
|
||||
list,
|
||||
total,
|
||||
loading,
|
||||
pagination,
|
||||
fetchList,
|
||||
handleTableChange,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCreate = (apiFunc, onSuccess) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const create = useCallback(
|
||||
async (data) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiFunc(data);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Create error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiFunc, onSuccess]
|
||||
);
|
||||
|
||||
return { create, loading };
|
||||
};
|
||||
|
||||
export const useUpdate = (apiFunc, onSuccess) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const update = useCallback(
|
||||
async (id, data) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiFunc(id, data);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Update error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[apiFunc, onSuccess]
|
||||
);
|
||||
|
||||
return { update, loading };
|
||||
};
|
||||
|
||||
export const useDelete = (apiFunc, onSuccess) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (id, options = {}) => {
|
||||
const { title = '确认删除', content = '确定要删除吗?此操作不可恢复。' } = options;
|
||||
|
||||
if (window.confirm(`${title}\n${content}`)) {
|
||||
try {
|
||||
setLoading(true);
|
||||
await apiFunc(id);
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Delete error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[apiFunc, onSuccess]
|
||||
);
|
||||
|
||||
return { handleDelete, loading };
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export const useDebounce = (value, delay = 300) => {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
};
|
||||
|
||||
export const useLocalStorage = (key, initialValue) => {
|
||||
const [storedValue, setStoredValue] = useState(() => {
|
||||
try {
|
||||
const item = window.localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(`Error reading localStorage key "${key}":`, error);
|
||||
return initialValue;
|
||||
}
|
||||
});
|
||||
|
||||
const setValue = (value) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error(`Error setting localStorage key "${key}":`, error);
|
||||
}
|
||||
};
|
||||
|
||||
return [storedValue, setValue];
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import App from './App';
|
||||
import './styles/index.css';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</ConfigProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { authAPI } from '../../services/auth';
|
||||
import { projectAPI } from '../../services/project';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [statistics, setStatistics] = useState(null);
|
||||
const [recentProjects, setRecentProjects] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [statsResponse, projectsResponse] = await Promise.all([
|
||||
authAPI.getStatistics(),
|
||||
projectAPI.getList({ page: 1, page_size: 5 }),
|
||||
]);
|
||||
|
||||
setStatistics(statsResponse);
|
||||
setRecentProjects(projectsResponse.items || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '24px' }}>
|
||||
仪表盘
|
||||
</h1>
|
||||
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">项目总数</div>
|
||||
<div className="stat-value">{statistics?.total_count || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">进行中</div>
|
||||
<div className="stat-value">
|
||||
{(statistics?.total_count || 0) -
|
||||
(statistics?.completed_count || 0) -
|
||||
(statistics?.cancelled_count || 0) || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">已完成</div>
|
||||
<div className="stat-value">
|
||||
{statistics?.completed_count || 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">总合同金额(万元)</div>
|
||||
<div className="stat-value">
|
||||
{(statistics?.total_contract_amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
最近项目
|
||||
</div>
|
||||
{recentProjects.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无项目
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{recentProjects.map((project) => (
|
||||
<div
|
||||
key={project.id}
|
||||
style={{
|
||||
padding: '16px 0',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#fafafa';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
onClick={() => window.location.href = `/projects/${project.id}`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500 }}>
|
||||
{project.name}
|
||||
</div>
|
||||
<div
|
||||
style={{ fontSize: '12px', color: '#999' }}
|
||||
>
|
||||
{project.created_at?.split('T')[0] || ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
合同金额:{project.contract_amount?.toLocaleString('zh-CN')} 万元
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
@@ -0,0 +1,176 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const { login, loading } = useAuth();
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleChange = (e) => {
|
||||
setForm({ ...form, [e.target.name]: e.target.value });
|
||||
if (error) setError('');
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!form.username) {
|
||||
setError('请输入用户名');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.username.length < 3) {
|
||||
setError('用户名至少3个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!form.password) {
|
||||
setError('请输入密码');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.password.length < 6) {
|
||||
setError('密码至少6个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await login(form.username, form.password);
|
||||
navigate('/dashboard');
|
||||
} catch (err) {
|
||||
setError(err.message || '登录失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #001529 0%, #1890ff 100%)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '20px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
padding: '40px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
width: '400px',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#1890ff',
|
||||
fontSize: '24px',
|
||||
fontWeight: 600,
|
||||
marginBottom: '32px',
|
||||
marginTop: 0,
|
||||
}}
|
||||
>
|
||||
海洋项目管理系统
|
||||
</h1>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={form.username}
|
||||
onChange={handleChange}
|
||||
placeholder="请输入用户名"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '40px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500',
|
||||
}}
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
placeholder="请输入密码"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '40px',
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
color: '#ff4d4f',
|
||||
fontSize: '14px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '40px',
|
||||
background: '#1890ff',
|
||||
color: '#ffffff',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 500',
|
||||
cursor: loading ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.3s',
|
||||
}}
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
@@ -0,0 +1,611 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { projectAPI } from '../../services/project';
|
||||
|
||||
const ProjectList = ({ mode = 'list' }) => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [list, setList] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [filters, setFilters] = useState({});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
project_no: '',
|
||||
name: '',
|
||||
engineering_type: '',
|
||||
contract_amount: '',
|
||||
signing_date: '',
|
||||
start_date: '',
|
||||
planned_end_date: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'list') {
|
||||
fetchProjects();
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
const fetchProjects = async (params = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await projectAPI.getList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
...filters,
|
||||
...params,
|
||||
});
|
||||
setList(result.items || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch projects:', error);
|
||||
alert('获取项目列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setPagination({ ...pagination, current: page });
|
||||
fetchProjects({ page });
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
project_no: '',
|
||||
name: '',
|
||||
engineering_type: '',
|
||||
contract_amount: '',
|
||||
signing_date: '',
|
||||
start_date: '',
|
||||
planned_end_date: '',
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (id) => {
|
||||
try {
|
||||
const project = await projectAPI.getDetail(id);
|
||||
setForm(project);
|
||||
setEditingId(id);
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch project detail:', error);
|
||||
alert('获取项目详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除该项目吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await projectAPI.delete(id);
|
||||
alert('项目删除成功');
|
||||
fetchProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete project:', error);
|
||||
alert('删除项目失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleView = (id) => {
|
||||
navigate(`/projects/${id}`);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const requiredFields = ['project_no', 'name', 'engineering_type', 'contract_amount'];
|
||||
const missingFields = requiredFields.filter(field => !form[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
alert(`请填写必填字段: ${missingFields.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await projectAPI.update(editingId, form);
|
||||
alert('项目更新成功');
|
||||
} else {
|
||||
await projectAPI.create(form);
|
||||
alert('项目创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchProjects();
|
||||
} catch (error) {
|
||||
console.error('Failed to save project:', error);
|
||||
alert('保存项目失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalVisible(false);
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
project_no: '',
|
||||
name: '',
|
||||
engineering_type: '',
|
||||
contract_amount: '',
|
||||
signing_date: '',
|
||||
start_date: '',
|
||||
planned_end_date: '',
|
||||
});
|
||||
};
|
||||
|
||||
const canEdit = (createdBy) => {
|
||||
return user.role === 'admin' || (user.role === 'market' && createdBy === user?.id);
|
||||
};
|
||||
|
||||
const canDelete = (createdBy) => {
|
||||
return user.role === 'admin' || (user.role === 'market' && createdBy === user?.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '24px' }}>
|
||||
{mode === 'create' ? '新建项目' : '项目列表'}
|
||||
</h1>
|
||||
|
||||
<div style={{ marginBottom: '16px', display: 'flex', gap: '12px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索项目名称、合同编号..."
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
width: '300px',
|
||||
}}
|
||||
/>
|
||||
<select
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">工程类别</option>
|
||||
<option value="基建">基建</option>
|
||||
<option value="业扩">业扩</option>
|
||||
<option value="客户">客户</option>
|
||||
<option value="营销">营销</option>
|
||||
<option value="检修">检修</option>
|
||||
</select>
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新建项目
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无项目
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600, borderBottom: '1px solid #e8e8e8' }}>
|
||||
合同编号
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
项目名称
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
工程类别
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'right', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
合同金额(万元)
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((project) => (
|
||||
<tr
|
||||
key={project.id}
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#fafafa';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
}}
|
||||
onClick={() => handleView(project.id)}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}>
|
||||
{project.project_no}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#333' }}>
|
||||
{project.name}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}>
|
||||
{project.engineering_type}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666', textAlign: 'right' }}>
|
||||
{project.contract_amount?.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', textAlign: 'center' }}>
|
||||
<button
|
||||
className="btn btn-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleView(project.id);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</button>
|
||||
{canEdit(project.created_by) && (
|
||||
<button
|
||||
className="btn btn-link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(project.id);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
)}
|
||||
{canDelete(project.created_by) && (
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(project.id);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{total > pagination.pageSize && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={pagination.current === 1}
|
||||
onClick={() => handlePageChange(pagination.current - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{pagination.current} / {Math.ceil(total / pagination.pageSize)}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={pagination.current >= Math.ceil(total / pagination.pageSize)}
|
||||
onClick={() => handlePageChange(pagination.current + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{modalVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.45)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
width: '800px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editingId ? '编辑项目' : '新建项目'}
|
||||
<button
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
合同编号 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="project_no"
|
||||
value={form.project_no}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
项目名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
工程类别 *
|
||||
</label>
|
||||
<select
|
||||
name="engineering_type"
|
||||
value={form.engineering_type}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">请选择</option>
|
||||
<option value="基建">基建</option>
|
||||
<option value="业扩">业扩</option>
|
||||
<option value="客户">客户</option>
|
||||
<option value="营销">营销</option>
|
||||
<option value="检修">检修</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
合同金额(万元) *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="contract_amount"
|
||||
value={form.contract_amount}
|
||||
onChange={handleChange}
|
||||
required
|
||||
min="0"
|
||||
step="0.01"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
签订日期 *
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="signing_date"
|
||||
value={form.signing_date}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
开工日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="start_date"
|
||||
value={form.start_date}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
计划竣工日期
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
name="planned_end_date"
|
||||
value={form.planned_end_date}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #e8e8e8',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
onClick={() => {}}
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { authAPI } from '../../services/auth';
|
||||
import { projectAPI } from '../../services/project';
|
||||
|
||||
const Statistics = () => {
|
||||
const [statistics, setStatistics] = useState(null);
|
||||
const [groupStatistics, setGroupStatistics] = useState([]);
|
||||
const [timelineStatistics, setTimelineStatistics] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filters, setFilters] = useState({
|
||||
group_by: 'engineering_type',
|
||||
time_field: 'signing_date',
|
||||
group_by_time: 'month',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const [statsResponse, groupResponse, timelineResponse] = await Promise.all([
|
||||
projectAPI.getStatistics(filters),
|
||||
projectAPI.getGroupStatistics(filters),
|
||||
projectAPI.getTimelineStatistics(filters),
|
||||
]);
|
||||
|
||||
setStatistics(statsResponse);
|
||||
setGroupStatistics(groupResponse || []);
|
||||
setTimelineStatistics(timelineResponse || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch statistics data:', error);
|
||||
alert('获取统计数据失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (field, value) => {
|
||||
setFilters({ ...filters, [field]: value });
|
||||
fetchData();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div>加载中...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '24px' }}>
|
||||
项目统计
|
||||
</h1>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginBottom: '24px',
|
||||
padding: '20px',
|
||||
background: '#ffffff',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
marginBottom: '16px',
|
||||
color: '#333',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
筛选条件
|
||||
</h3>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '16px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
分组方式:
|
||||
</label>
|
||||
<select
|
||||
value={filters.group_by}
|
||||
onChange={(e) => handleFilterChange('group_by', e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="engineering_type">按工程类别</option>
|
||||
<option value="project_department">按项目部</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
时间字段:
|
||||
</label>
|
||||
<select
|
||||
value={filters.time_field}
|
||||
onChange={(e) => handleFilterChange('time_field', e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="signing_date">签订日期</option>
|
||||
<option value="start_date">开工日期</option>
|
||||
<option value="planned_end_date">计划竣工日期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
marginRight: '8px',
|
||||
fontSize: '14px',
|
||||
color: '#333',
|
||||
}}
|
||||
>
|
||||
时间粒度:
|
||||
</label>
|
||||
<select
|
||||
value={filters.group_by_time}
|
||||
onChange={(e) => handleFilterChange('group_by_time', e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="day">按天</option>
|
||||
<option value="month">按月</option>
|
||||
<option value="year">按年</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">项目总数</div>
|
||||
<div className="stat-value">{statistics?.total_count || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">总投资金额(万元)</div>
|
||||
<div className="stat-value">
|
||||
{(statistics?.total_investment || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">总合同金额(万元)</div>
|
||||
<div className="stat-value">
|
||||
{(statistics?.total_contract_amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stat-card">
|
||||
<div className="stat-label">总收款金额(万元)</div>
|
||||
<div className="stat-value">
|
||||
{(statistics?.total_receipt_amount || 0).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
分组统计
|
||||
</div>
|
||||
{groupStatistics.length > 0 ? (
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600,
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
{filters.group_by === 'engineering_type' ? '工程类别' : '项目部'}
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'right',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
项目数
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'right',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
合同金额(万元)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{groupStatistics.map((item, index) => (
|
||||
<tr
|
||||
key={`${filters.group_by}-${index}`}
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}
|
||||
>
|
||||
{item[filters.group_by]}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px,
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{item.count?.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{item.total_contract_amount?.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div
|
||||
style={{ textAlign: 'center', padding: '40px', color: '#999' }}
|
||||
>
|
||||
暂无统计数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
时间维度统计
|
||||
</div>
|
||||
{timelineStatistics.length > 0 ? (
|
||||
<table
|
||||
style={{ width: '100%', borderCollapse: 'collapse' }}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'left',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
时间
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'right',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
项目数
|
||||
</th>
|
||||
<th
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
textAlign: 'right',
|
||||
fontSize: '14px',
|
||||
fontWeight: 600',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
}}
|
||||
>
|
||||
合同金额(万元)
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{timelineStatistics.map((item, index) => ({
|
||||
key={`${filters.time_field}-${index}`}
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<td
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
{item.month}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{item.count?.toLocaleString('zh-CN')}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
fontSize: '14px',
|
||||
color: '#666',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{item.total_contract_amount?.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div
|
||||
style={{ textAlign: 'center', padding: '40px', color: '#999' }}
|
||||
>
|
||||
暂无时间统计数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statistics;
|
||||
@@ -0,0 +1,580 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { userAPI } from '../../services/user';
|
||||
|
||||
const UserList = () => {
|
||||
const { user } = useAuth();
|
||||
const [list, setList] = useState([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
});
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [editingId, setEditingId] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
username: '',
|
||||
password: '',
|
||||
real_name: '',
|
||||
department: '',
|
||||
role: 'other',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async (params = {}) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await userAPI.getList({
|
||||
page: pagination.current,
|
||||
page_size: pagination.pageSize,
|
||||
...params,
|
||||
});
|
||||
setList(result.items || []);
|
||||
setTotal(result.total || 0);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
alert('获取用户列表失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (page) => {
|
||||
setPagination({ ...pagination, current: page });
|
||||
fetchUsers({ page });
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
username: '',
|
||||
password: '',
|
||||
real_name: '',
|
||||
department: '',
|
||||
role: 'other',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
setModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = async (id) => {
|
||||
try {
|
||||
const user = await userAPI.getDetail(id);
|
||||
setForm({ ...user, password: '' });
|
||||
setEditingId(id);
|
||||
setModalVisible(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user detail:', error);
|
||||
alert('获取用户详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('确定要删除该用户吗?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userAPI.delete(id);
|
||||
alert('用户删除成功');
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user:', error);
|
||||
alert('删除用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async (id) => {
|
||||
const newPassword = window.prompt('请输入新密码:');
|
||||
if (!newPassword) return;
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
alert('密码至少6个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await userAPI.resetPassword(id, newPassword);
|
||||
alert('密码重置成功');
|
||||
} catch (error) {
|
||||
console.error('Failed to reset password:', error);
|
||||
alert('密码重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const requiredFields = ['username', 'password', 'real_name', 'department', 'role'];
|
||||
const missingFields = requiredFields.filter(field => !form[field]);
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
alert(`请填写必填字段: ${missingFields.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.username.length < 3) {
|
||||
alert('用户名至少3个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.password.length < 6) {
|
||||
alert('密码至少6个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId) {
|
||||
await userAPI.update(editingId, form);
|
||||
alert('用户更新成功');
|
||||
} else {
|
||||
await userAPI.create(form);
|
||||
alert('用户创建成功');
|
||||
}
|
||||
|
||||
setModalVisible(false);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to save user:', error);
|
||||
alert('保存用户失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setModalVisible(false);
|
||||
setEditingId(null);
|
||||
setForm({
|
||||
username: '',
|
||||
password: '',
|
||||
real_name: '',
|
||||
department: '',
|
||||
role: 'other',
|
||||
email: '',
|
||||
phone: '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontSize: '24px', marginBottom: '24px' }}>
|
||||
用户管理
|
||||
</h1>
|
||||
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户名、真实姓名、邮箱..."
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #d9d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
width: '300px',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
新建用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px' }}>加载中...</div>
|
||||
) : list.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
|
||||
暂无用户
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600, borderBottom: '1px solid #e8e8e8' }}>
|
||||
用户名
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
真实姓名
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
部门
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'left', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
角色
|
||||
</th>
|
||||
<th style={{ padding: '12px 16px', textAlign: 'center', fontSize: '14px', fontWeight: 600', borderBottom: '1px solid #e8e8e8' }}>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((user) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
style={{
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}>
|
||||
{user.username}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#333' }}>
|
||||
{user.real_name}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}>
|
||||
{user.department}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', color: '#666' }}>
|
||||
{user.role === 'admin' ? '管理员' : user.role === 'market' ? '市场部' : '其他'}
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontSize: '14px', textAlign: 'center' }}>
|
||||
<button className="btn btn-link" onClick={() => handleEdit(user.id)}>
|
||||
编辑
|
||||
</button>
|
||||
<button className="btn btn-danger" onClick={() => handleDelete(user.id)}>
|
||||
删除
|
||||
</button>
|
||||
<button className="btn btn-link" onClick={() => handleResetPassword(user.id)}>
|
||||
重置密码
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
{total > pagination.pageSize && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={pagination.current === 1}
|
||||
onClick={() => handlePageChange(pagination.current - 1)}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span>
|
||||
{pagination.current} / {Math.ceil(total / pagination.pageSize)}
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
disabled={pagination.current >= Math.ceil(total / pagination.pageSize)}
|
||||
onClick={() => handlePageChange(pagination.current + 1)}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{modalVisible && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0, 0, 0, 0.45)',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#ffffff',
|
||||
width: '800px',
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '16px 24px',
|
||||
borderBottom: '1px solid #e8e8e8',
|
||||
fontSize: '16px',
|
||||
fontWeight: 600',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{editingId ? '编辑用户' : '新建用户'}
|
||||
<button
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
color: '#999',
|
||||
}}
|
||||
onClick={handleCancel}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ padding: '24px' }}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
用户名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={form.username}
|
||||
onChange={handleChange}
|
||||
required
|
||||
disabled={!!editingId}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
密码 *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={form.password}
|
||||
onChange={handleChange}
|
||||
required={!editingId}
|
||||
placeholder={editingId ? '留空不修改' : '请输入密码'}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
真实姓名 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="real_name"
|
||||
value={form.real_name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
部门 *
|
||||
</label>
|
||||
<select
|
||||
name="department"
|
||||
value={form.department}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">请选择部门</option>
|
||||
<option value="管理部">管理部</option>
|
||||
<option value="市场部">市场部</option>
|
||||
<option value="技术部">技术部</option>
|
||||
<option value="财务部">财务部</option>
|
||||
<option value="项目部">项目部</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
角色 *
|
||||
</label>
|
||||
<select
|
||||
name="role"
|
||||
value={form.role}
|
||||
onChange={handleChange}
|
||||
required
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
<option value="">请选择角色</option>
|
||||
<option value="admin">管理员</option>
|
||||
<option value="market">市场部</option>
|
||||
<option value="other">其他</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
邮箱
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="example@example.com"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
color: '#333',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
>
|
||||
电话
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
name="phone"
|
||||
value={form.phone}
|
||||
onChange={handleChange}
|
||||
placeholder="请输入手机号"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
padding: '4px 12px',
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: '24px',
|
||||
padding: '16px 24px',
|
||||
borderTop: '1px solid #e8e8e8',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="btn btn-default"
|
||||
onClick={handleCancel}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
>
|
||||
保存
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserList;
|
||||
@@ -0,0 +1,61 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1',
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response.data;
|
||||
},
|
||||
(error) => {
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
switch (status) {
|
||||
case 400:
|
||||
console.error('参数错误:', data?.message);
|
||||
break;
|
||||
case 401:
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
window.location.href = '/login';
|
||||
break;
|
||||
case 403:
|
||||
console.error('权限不足:', data?.message);
|
||||
break;
|
||||
case 404:
|
||||
console.error('资源不存在:', data?.message);
|
||||
break;
|
||||
case 500:
|
||||
console.error('服务器错误:', data?.message);
|
||||
break;
|
||||
default:
|
||||
console.error('请求失败:', data?.message);
|
||||
}
|
||||
} else if (error.request) {
|
||||
console.error('网络错误');
|
||||
} else {
|
||||
console.error('请求失败:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
@@ -0,0 +1,34 @@
|
||||
export const authAPI = {
|
||||
login: async (username, password) => {
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
getCurrentUser: async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
const token = localStorage.getItem('token');
|
||||
const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import api from './api';
|
||||
|
||||
export const projectAPI = {
|
||||
getList: (params) => api.get('/projects', { params }),
|
||||
|
||||
getDetail: (id) => api.get(`/projects/${id}`),
|
||||
|
||||
create: (data) => api.post('/projects', data),
|
||||
|
||||
update: (id, data) => api.put(`/projects/${id}`, data),
|
||||
|
||||
delete: (id) => api.delete(`/projects/${id}`),
|
||||
|
||||
getStatistics: (params) => api.get('/projects/statistics', { params }),
|
||||
|
||||
getGroupStatistics: (params) => api.get('/projects/statistics/group', { params }),
|
||||
|
||||
getTimelineStatistics: (params) => api.get('/projects/statistics/timeline', { params }),
|
||||
|
||||
export: (params) => api.get('/projects/export', { params, responseType: 'blob' }),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import api from './api';
|
||||
|
||||
export const userAPI = {
|
||||
getList: (params) => api.get('/users', { params }),
|
||||
|
||||
getDetail: (id) => api.get(`/users/${id}`),
|
||||
|
||||
create: (data) => api.post('/users', data),
|
||||
|
||||
update: (id, data) => api.put(`/users/${id}`, data),
|
||||
|
||||
delete: (id) => api.delete(`/users/${id}`),
|
||||
|
||||
resetPassword: (id, newPassword) => api.post(`/users/${id}/reset-password`, { new_password: newPassword }),
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
height: 64px;
|
||||
background: #001529;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
margin-top: 64px;
|
||||
margin-left: 256px;
|
||||
padding: 24px;
|
||||
background: #f0f2f5;
|
||||
min-height: calc(100vh - 64px);
|
||||
}
|
||||
|
||||
.layout-sider {
|
||||
width: 256px;
|
||||
background: #ffffff;
|
||||
border-right: 1px solid #e8e8e8;
|
||||
position: fixed;
|
||||
left: 64px;
|
||||
top: 64px;
|
||||
bottom: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: #ffffff;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: 600;
|
||||
color: #1890ff;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
float: right;
|
||||
font-size: 48px;
|
||||
color: #1890ff;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 2px 8px (0, 0, 0, 0.08);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 16px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #1890ff;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #40a9ff;
|
||||
}
|
||||
|
||||
.btn-default {
|
||||
background: #ffffff;
|
||||
color: #666666;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
.btn-default:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ffffff;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
color: #ff7875;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
color: #1890ff;
|
||||
border: none;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #40a9ff;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export const formatMoney = (amount) => {
|
||||
if (amount === null || amount === undefined) return '-';
|
||||
return Number(amount).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
};
|
||||
|
||||
export const formatPercentage = (value) => {
|
||||
if (value === null || value === undefined) return '-';
|
||||
return `${(Number(value) * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
export const formatDate = (date) => {
|
||||
if (!date) return '-';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
export const formatDateTime = (date) => {
|
||||
if (!date) return '-';
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
export const validateEmail = (email) => {
|
||||
if (!email) return true;
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
};
|
||||
|
||||
export const validatePhone = (phone) => {
|
||||
if (!phone) return true;
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
return phoneRegex.test(phone);
|
||||
};
|
||||
|
||||
export const validateProjectNo = (projectNo) => {
|
||||
if (!projectNo) return true;
|
||||
const regex = /^[A-Z0-9]+$/;
|
||||
return regex.test(projectNo);
|
||||
};
|
||||
Reference in New Issue
Block a user