save code

This commit is contained in:
xsl
2026-01-26 08:04:53 +08:00
parent 0895398138
commit e4b59c5ee4
1238 changed files with 95107 additions and 1068 deletions
+80
View File
@@ -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;
+35
View File
@@ -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;
+90
View File
@@ -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;
+146
View File
@@ -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;
+173
View File
@@ -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 };
};
+40
View File
@@ -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];
};
+22
View File
@@ -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>
);
+127
View File
@@ -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;
+176
View File
@@ -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;
+611
View File
@@ -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;
+580
View File
@@ -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;
+61
View File
@@ -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;
+34
View File
@@ -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();
},
};
+21
View File
@@ -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' }),
};
+15
View File
@@ -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 }),
};
+157
View File
@@ -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;
}
+34
View File
@@ -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',
});
};
+17
View File
@@ -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);
};