初步开发完成

This commit is contained in:
Your Name
2026-01-26 23:39:48 +08:00
parent f90367ddcb
commit a416e2091f
25 changed files with 3253 additions and 320 deletions
Binary file not shown.
+7 -19
View File
@@ -17,25 +17,13 @@ def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depend
"""用户登录"""
user = db.query(User).filter(User.username == form_data.username).first()
# 检查是否为测试环境
is_test = os.environ.get("TESTING", "False").lower() == "true"
if is_test:
# 测试环境:检查用户名和密码
if not user or form_data.password != "test_password":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
else:
# 生产环境:正常验证密码
if not user or not verify_password(form_data.password, user.password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
# 密码验证:比较用户存储的密码
if not user or form_data.password != user.password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
access_token = create_access_token(
BIN
View File
Binary file not shown.
+27 -2
View File
@@ -2,10 +2,12 @@ import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
import { defineConfig } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
ignores: ['dist', '**/__tests__/**', '**/*.test.{js,jsx}'],
},
{
files: ['**/*.{js,jsx}'],
extends: [
@@ -26,4 +28,27 @@ export default defineConfig([
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
{
files: ['**/__tests__/**', '**/*.test.{js,jsx}', 'src/setupTests.js'],
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.node,
...globals.jest,
describe: 'readonly',
test: 'readonly',
expect: 'readonly',
beforeEach: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
afterAll: 'readonly',
jest: 'readonly',
},
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
},
])
+21
View File
@@ -29,6 +29,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-environment-jsdom": "^30.2.0",
"vite": "^7.2.4"
@@ -5921,6 +5922,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/harmony-reflect": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
"integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
"dev": true,
"license": "(Apache-2.0 OR MPL-1.1)"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -6058,6 +6066,19 @@
"node": ">=0.10.0"
}
},
"node_modules/identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
"integrity": "sha512-00n6YnVHKrinT9t0d9+5yZC6UBNJANpYEQvL2LlX6Ab9lnmxzIRcEmTPuyGScvl1+jKuCICX1Z0Ab1pPKKdikA==",
"dev": true,
"license": "MIT",
"dependencies": {
"harmony-reflect": "^1.4.6"
},
"engines": {
"node": ">=4"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+3
View File
@@ -17,6 +17,9 @@
"testEnvironment": "jsdom",
"transform": {
"^.+\\.(js|jsx)$": "babel-jest"
},
"moduleNameMapper": {
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
}
},
"dependencies": {
-258
View File
@@ -20,261 +20,3 @@ body {
display: flex;
flex-direction: column;
}
/* 导航栏 */
.navbar {
background-color: #333;
color: white;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.navbar-brand a {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.navbar-links {
display: flex;
gap: 1.5rem;
align-items: center;
}
.navbar-links a {
color: white;
text-decoration: none;
font-size: 1rem;
}
.navbar-links a:hover {
text-decoration: underline;
}
.logout-button {
background-color: #f44336;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.logout-button:hover {
background-color: #d32f2f;
}
/* 主内容区域 */
.main-content {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
/* 登录页面 */
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 80vh;
}
.login-form {
background-color: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-form h2 {
margin-bottom: 1.5rem;
text-align: center;
color: #333;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
color: #555;
}
.form-group input {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.login-button {
width: 100%;
padding: 0.75rem;
background-color: #4caf50;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.login-button:hover {
background-color: #45a049;
}
.login-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.error-message {
background-color: #ffebee;
color: #c62828;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
/* 项目列表 */
.project-list {
margin-top: 2rem;
}
.project-list h2 {
margin-bottom: 1.5rem;
color: #333;
}
.projects-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1.5rem;
}
.project-card {
background-color: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.project-card h3 {
margin-bottom: 0.75rem;
color: #333;
}
.project-card p {
margin-bottom: 1rem;
color: #666;
line-height: 1.4;
}
.project-details {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-details span {
font-size: 0.9rem;
color: #555;
}
.project-actions {
display: flex;
gap: 0.75rem;
margin-top: 1rem;
}
.edit-button {
padding: 0.5rem 1rem;
background-color: #2196f3;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.edit-button:hover {
background-color: #0b7dda;
}
.delete-button {
padding: 0.5rem 1rem;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
}
.delete-button:hover {
background-color: #d32f2f;
}
.empty-message {
grid-column: 1 / -1;
text-align: center;
padding: 3rem;
color: #666;
background-color: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
/* 加载和错误状态 */
.loading {
text-align: center;
padding: 3rem;
color: #666;
}
.error {
text-align: center;
padding: 3rem;
color: #c62828;
background-color: #ffebee;
border-radius: 8px;
margin: 2rem 0;
}
/* 响应式设计 */
@media (max-width: 768px) {
.navbar {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.navbar-links {
flex-direction: column;
gap: 1rem;
}
.main-content {
padding: 1rem;
}
.projects-container {
grid-template-columns: 1fr;
}
.login-form {
padding: 1.5rem;
}
}
+63 -27
View File
@@ -1,39 +1,75 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Navbar from './components/Navbar';
import Layout from './components/Layout';
import Login from './components/Login';
import ProjectList from './components/ProjectList';
import ProjectManagement from './components/ProjectManagement';
import ProjectDetail from './components/ProjectDetail';
import ProjectForm from './components/ProjectForm';
import UserManagement from './components/UserManagement';
import Statistics from './components/Statistics';
import './App.css';
function App() {
function PrivateRoute({ children }) {
const isAuthenticated = !!localStorage.getItem('token');
return isAuthenticated ? children : <Navigate to="/login" />;
}
// 私有路由组件
const PrivateRoute = ({ children }) => {
return isAuthenticated ? children : <Navigate to="/login" />;
};
function App() {
return (
<Router>
<div className="app">
<Navbar />
<div className="main-content">
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<PrivateRoute>
<ProjectList />
</PrivateRoute>
} />
<Route path="/projects" element={
<PrivateRoute>
<ProjectList />
</PrivateRoute>
} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</div>
</div>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={
<PrivateRoute>
<Layout>
<ProjectManagement />
</Layout>
</PrivateRoute>
} />
<Route path="/projects" element={
<PrivateRoute>
<Layout>
<ProjectManagement />
</Layout>
</PrivateRoute>
} />
<Route path="/projects/create" element={
<PrivateRoute>
<Layout>
<ProjectForm />
</Layout>
</PrivateRoute>
} />
<Route path="/projects/:id" element={
<PrivateRoute>
<Layout>
<ProjectDetail />
</Layout>
</PrivateRoute>
} />
<Route path="/projects/:id/edit" element={
<PrivateRoute>
<Layout>
<ProjectForm isEdit={true} />
</Layout>
</PrivateRoute>
} />
<Route path="/users" element={
<PrivateRoute>
<Layout>
<UserManagement />
</Layout>
</PrivateRoute>
} />
<Route path="/statistics" element={
<PrivateRoute>
<Layout>
<Statistics />
</Layout>
</PrivateRoute>
} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Router>
);
}
+26
View File
@@ -0,0 +1,26 @@
.layout {
display: flex;
min-height: 100vh;
width: 100%;
}
.main-content {
flex: 1;
margin-left: 260px;
background: #f5f7fa;
min-height: 100vh;
transition: margin-left 0.3s ease;
width: calc(100% - 260px);
}
.layout.collapsed .main-content {
margin-left: 80px;
width: calc(100% - 80px);
}
.content-wrapper {
padding: 30px;
max-width: 1600px;
margin: 0 auto;
width: 100%;
}
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
import Sidebar from './Sidebar';
import './Layout.css';
function Layout({ children }) {
return (
<div className="layout">
<Sidebar />
<div className="main-content">
<div className="content-wrapper">
{children}
</div>
</div>
</div>
);
}
export default Layout;
+180
View File
@@ -0,0 +1,180 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-container {
background: white;
border-radius: 16px;
padding: 48px;
width: 100%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: slideUp 0.5s ease;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 40px;
}
.login-title {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin: 0 0 20px 0;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
}
.logo-icon {
font-size: 48px;
}
.logo-text {
font-size: 28px;
font-weight: 700;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.error-message {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
margin-bottom: 24px;
color: #dc2626;
}
.error-icon {
font-size: 20px;
}
.login-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.form-input {
width: 100%;
padding: 14px 18px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 15px;
transition: all 0.2s ease;
outline: none;
}
.form-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.login-button {
width: 100%;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.login-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.login-footer {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #e5e7eb;
}
.test-accounts {
background: #f9fafb;
padding: 20px;
border-radius: 8px;
}
.test-title {
font-size: 14px;
font-weight: 600;
color: #374151;
margin: 0 0 16px 0;
}
.account-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.account-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
}
.account-label {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.account-value {
font-size: 13px;
color: #1f2937;
font-weight: 600;
font-family: 'Courier New', monospace;
}
+62 -9
View File
@@ -1,7 +1,10 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { authApi } from '../services/api';
import './Login.css';
function Login() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -15,7 +18,7 @@ function Login() {
try {
const response = await authApi.login(username, password);
localStorage.setItem('token', response.access_token);
window.location.href = '/';
navigate('/');
} catch (err) {
setError('登录失败,请检查用户名和密码');
console.error('Login error:', err);
@@ -25,35 +28,85 @@ function Login() {
};
return (
<div className="login-container">
<div className="login-form">
<h2>登录</h2>
{error && <div className="error-message">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="login-page">
<div className="login-container">
<div className="login-header">
<h2 className="login-title">登录</h2>
<div className="logo">
<span className="logo-icon">📊</span>
<span className="logo-text">项目管理系统</span>
</div>
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">用户名</label>
<label className="form-label" htmlFor="username">
用户名
</label>
<input
type="text"
id="username"
name="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="form-input"
placeholder="请输入用户名"
required
autoFocus
/>
</div>
<div className="form-group">
<label htmlFor="password">密码</label>
<label className="form-label" htmlFor="password">
密码
</label>
<input
type="password"
id="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="请输入密码"
required
/>
</div>
<button type="submit" className="login-button" disabled={loading}>
<button
type="submit"
className="login-button"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="login-footer">
<div className="test-accounts">
<p className="test-title">测试账户</p>
<div className="account-list">
<div className="account-item">
<span className="account-label">管理员:</span>
<span className="account-value">admin / 123456</span>
</div>
<div className="account-item">
<span className="account-label">市场部:</span>
<span className="account-value">marketing / 123456</span>
</div>
<div className="account-item">
<span className="account-label">其他部门:</span>
<span className="account-value">other / 123456</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
+277
View File
@@ -0,0 +1,277 @@
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e5e7eb;
}
.header-left {
display: flex;
flex-direction: column;
gap: 12px;
}
.back-link {
display: inline-flex;
align-items: center;
color: #6b7280;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: color 0.2s ease;
}
.back-link:hover {
color: #667eea;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.header-actions {
display: flex;
gap: 12px;
}
.edit-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.edit-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.project-detail-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-bottom: 30px;
}
.detail-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.detail-card.full-width {
grid-column: 1 / -1;
}
.card-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 16px;
}
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.detail-row:last-child {
border-bottom: none;
}
.detail-label {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.detail-value {
font-size: 14px;
color: #1f2937;
font-weight: 600;
}
.status-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.description-text {
font-size: 14px;
color: #374151;
line-height: 1.8;
white-space: pre-wrap;
word-wrap: break-word;
}
.history-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
.toggle-history-button {
padding: 8px 16px;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.toggle-history-button:hover {
background: #e5e7eb;
}
.history-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.history-item {
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.history-operator {
font-size: 14px;
font-weight: 600;
color: #1f2937;
}
.history-time {
font-size: 12px;
color: #6b7280;
}
.history-details {
display: flex;
flex-direction: column;
gap: 12px;
}
.history-field {
font-size: 13px;
color: #374151;
margin-bottom: 8px;
}
.history-change {
display: flex;
gap: 20px;
padding: 12px;
background: white;
border-radius: 6px;
}
.change-before,
.change-after {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.change-label {
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
font-weight: 600;
}
.change-value {
font-size: 13px;
color: #374151;
padding: 8px;
background: #f9fafb;
border-radius: 4px;
word-break: break-all;
}
.history-description {
font-size: 13px;
color: #374151;
padding: 12px;
background: #fef3c7;
border-radius: 6px;
border-left: 3px solid #f59e0b;
}
.empty-history {
text-align: center;
padding: 40px;
color: #6b7280;
font-size: 14px;
}
.back-button {
display: inline-block;
padding: 12px 24px;
background: #6b7280;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s ease;
}
.back-button:hover {
background: #4b5563;
}
+261
View File
@@ -0,0 +1,261 @@
import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { projectApi } from '../services/api';
import './ProjectDetail.css';
function ProjectDetail() {
const { id } = useParams();
const [project, setProject] = useState(null);
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showHistory, setShowHistory] = useState(false);
useEffect(() => {
const fetchProjectDetail = async () => {
setLoading(true);
setError('');
try {
const data = await projectApi.getProject(id);
setProject(data);
} catch (err) {
setError('获取项目详情失败');
console.error('Error fetching project detail:', err);
} finally {
setLoading(false);
}
};
const fetchProjectHistory = async () => {
try {
const data = await projectApi.getProjectHistory(id);
setHistory(data);
} catch (err) {
console.error('Error fetching project history:', err);
}
};
fetchProjectDetail();
fetchProjectHistory();
}, [id]);
const getStatusColor = (status) => {
const colors = {
'pending': '#f59e0b',
'in_progress': '#3b82f6',
'completed': '#10b981',
'paused': '#f59e0b',
'cancelled': '#ef4444'
};
return colors[status] || '#6b7280';
};
const getStatusText = (status) => {
const texts = {
'pending': '未开始',
'in_progress': '进行中',
'completed': '已完成',
'paused': '已暂停',
'cancelled': '已取消'
};
return texts[status] || status;
};
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const formatBudget = (budget) => {
if (!budget) return '-';
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(budget);
};
if (loading) {
return (
<div className="page-container">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="page-container">
<div className="error-container">
<div className="error-icon"></div>
<p>{error}</p>
<Link to="/projects" className="back-button">
返回项目列表
</Link>
</div>
</div>
);
}
if (!project) {
return (
<div className="page-container">
<div className="error-container">
<p>项目不存在</p>
<Link to="/projects" className="back-button">
返回项目列表
</Link>
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header">
<div className="header-left">
<Link to="/projects" className="back-link">
返回列表
</Link>
<h1 className="page-title">{project.name}</h1>
</div>
<div className="header-actions">
<Link
to={`/projects/${project.id}/edit`}
className="edit-button"
>
编辑项目
</Link>
</div>
</div>
<div className="project-detail-grid">
<div className="detail-card">
<h2 className="card-title">基本信息</h2>
<div className="detail-content">
<div className="detail-row">
<span className="detail-label">项目ID:</span>
<span className="detail-value">{project.id}</span>
</div>
<div className="detail-row">
<span className="detail-label">项目名称:</span>
<span className="detail-value">{project.name}</span>
</div>
<div className="detail-row">
<span className="detail-label">所属部门:</span>
<span className="detail-value">{project.department || '-'}</span>
</div>
<div className="detail-row">
<span className="detail-label">项目状态:</span>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(project.status) }}
>
{getStatusText(project.status)}
</span>
</div>
<div className="detail-row">
<span className="detail-label">项目预算:</span>
<span className="detail-value">{formatBudget(project.budget)}</span>
</div>
</div>
</div>
<div className="detail-card">
<h2 className="card-title">时间信息</h2>
<div className="detail-content">
<div className="detail-row">
<span className="detail-label">开始日期:</span>
<span className="detail-value">{formatDate(project.start_date)}</span>
</div>
<div className="detail-row">
<span className="detail-label">结束日期:</span>
<span className="detail-value">{formatDate(project.end_date)}</span>
</div>
<div className="detail-row">
<span className="detail-label">创建时间:</span>
<span className="detail-value">{formatDate(project.created_at)}</span>
</div>
<div className="detail-row">
<span className="detail-label">更新时间:</span>
<span className="detail-value">{formatDate(project.updated_at)}</span>
</div>
</div>
</div>
<div className="detail-card full-width">
<h2 className="card-title">项目描述</h2>
<div className="detail-content">
<div className="description-text">
{project.description || '暂无描述'}
</div>
</div>
</div>
</div>
<div className="history-section">
<div className="history-header">
<h2 className="card-title">修改历史</h2>
<button
className="toggle-history-button"
onClick={() => setShowHistory(!showHistory)}
>
{showHistory ? '隐藏' : '显示'}历史
</button>
</div>
{showHistory && (
<div className="history-list">
{history.length === 0 ? (
<div className="empty-history">暂无修改历史</div>
) : (
history.map((record) => (
<div key={record.id} className="history-item">
<div className="history-header">
<span className="history-operator">
{record.changed_by_username || '未知用户'}
</span>
<span className="history-time">
{formatDate(record.created_at)}
</span>
</div>
<div className="history-details">
<div className="history-field">
<strong>字段:</strong> {record.change_field}
</div>
<div className="history-change">
<div className="change-before">
<span className="change-label">修改前:</span>
<span className="change-value">{record.old_value || '-'}</span>
</div>
<div className="change-after">
<span className="change-label">修改后:</span>
<span className="change-value">{record.new_value || '-'}</span>
</div>
</div>
{record.change_description && (
<div className="history-description">
<strong>说明:</strong> {record.change_description}
</div>
)}
</div>
</div>
))
)}
</div>
)}
</div>
</div>
);
}
export default ProjectDetail;
+187
View File
@@ -0,0 +1,187 @@
.form-container {
max-width: 900px;
margin: 0 auto;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e5e7eb;
}
.form-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.cancel-button {
padding: 10px 20px;
background: #6b7280;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s ease;
}
.cancel-button:hover {
background: #4b5563;
}
.error-message {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
margin-bottom: 24px;
color: #dc2626;
}
.error-icon {
font-size: 20px;
}
.project-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-section {
background: white;
padding: 24px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #e5e7eb;
}
.form-row {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.required {
color: #ef4444;
margin-left: 4px;
}
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
outline: none;
font-family: inherit;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
background: #f9fafb;
cursor: not-allowed;
}
.form-textarea {
resize: vertical;
min-height: 150px;
line-height: 1.6;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.submit-button {
padding: 14px 32px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.cancel-button {
padding: 14px 32px;
background: #6b7280;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
}
.cancel-button:hover:not(:disabled) {
background: #4b5563;
}
.cancel-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+274
View File
@@ -0,0 +1,274 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { projectApi } from '../services/api';
import './ProjectForm.css';
function ProjectForm({ isEdit = false }) {
const { id } = useParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [fetching, setFetching] = useState(false);
const [error, setError] = useState('');
const [formData, setFormData] = useState({
name: '',
description: '',
department: '客户工程',
budget: '',
start_date: '',
end_date: '',
status: 'pending'
});
useEffect(() => {
if (isEdit && id) {
const fetchProjectData = async () => {
setFetching(true);
setError('');
try {
const data = await projectApi.getProject(id);
setFormData({
name: data.name || '',
description: data.description || '',
department: data.department || '客户工程',
budget: data.budget || '',
start_date: data.start_date ? data.start_date.split('T')[0] : '',
end_date: data.end_date ? data.end_date.split('T')[0] : '',
status: data.status || 'pending'
});
} catch (err) {
setError('获取项目数据失败');
console.error('Error fetching project:', err);
} finally {
setFetching(false);
}
};
fetchProjectData();
}
}, [isEdit, id]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const submitData = {
...formData,
budget: formData.budget ? parseFloat(formData.budget) : null
};
if (isEdit && id) {
await projectApi.updateProject(id, submitData);
} else {
await projectApi.createProject(submitData);
}
navigate('/projects');
} catch (err) {
setError(isEdit ? '更新项目失败' : '创建项目失败');
console.error('Error saving project:', err);
} finally {
setLoading(false);
}
};
const handleCancel = () => {
navigate('/projects');
};
return (
<div className="page-container">
<div className="form-container">
<div className="form-header">
<h1 className="form-title">
{isEdit ? '编辑项目' : '新建项目'}
</h1>
<button className="cancel-button" onClick={handleCancel}>
取消
</button>
</div>
{fetching && (
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
)}
{!fetching && (
<>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="project-form">
<div className="form-section">
<h2 className="section-title">基本信息</h2>
<div className="form-row">
<div className="form-group">
<label className="form-label">
项目名称 <span className="required">*</span>
</label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
className="form-input"
placeholder="请输入项目名称"
required
/>
</div>
<div className="form-group">
<label className="form-label">
所属部门 <span className="required">*</span>
</label>
<select
name="department"
value={formData.department}
onChange={handleChange}
className="form-select"
required
>
<option value="">请选择部门</option>
<option value="客户工程">客户工程</option>
<option value="基建">基建</option>
<option value="业扩">业扩</option>
<option value="营销">营销</option>
<option value="带电作业">带电作业</option>
<option value="检修">检修</option>
<option value="技改">技改</option>
<option value="应急抢修">应急抢修</option>
</select>
</div>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">
项目预算
</label>
<input
type="number"
name="budget"
value={formData.budget}
onChange={handleChange}
className="form-input"
placeholder="请输入项目预算"
min="0"
step="0.01"
/>
</div>
<div className="form-group">
<label className="form-label">
项目状态 <span className="required">*</span>
</label>
<select
name="status"
value={formData.status}
onChange={handleChange}
className="form-select"
required
>
<option value="pending">未开始</option>
<option value="in_progress">进行中</option>
<option value="completed">已完成</option>
<option value="paused">已暂停</option>
<option value="cancelled">已取消</option>
</select>
</div>
</div>
</div>
<div className="form-section">
<h2 className="section-title">时间信息</h2>
<div className="form-row">
<div className="form-group">
<label className="form-label">
开始日期
</label>
<input
type="date"
name="start_date"
value={formData.start_date}
onChange={handleChange}
className="form-input"
/>
</div>
<div className="form-group">
<label className="form-label">
结束日期
</label>
<input
type="date"
name="end_date"
value={formData.end_date}
onChange={handleChange}
className="form-input"
/>
</div>
</div>
</div>
<div className="form-section">
<h2 className="section-title">项目描述</h2>
<div className="form-group full-width">
<label className="form-label">
项目描述
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
className="form-textarea"
placeholder="请输入项目描述"
rows="6"
/>
</div>
</div>
<div className="form-actions">
<button
type="submit"
className="submit-button"
disabled={loading}
>
{loading ? '保存中...' : (isEdit ? '更新项目' : '创建项目')}
</button>
<button
type="button"
className="cancel-button"
onClick={handleCancel}
disabled={loading}
>
取消
</button>
</div>
</form>
</>
)}
</div>
</div>
);
}
export default ProjectForm;
@@ -0,0 +1,263 @@
.page-container {
padding: 0;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.create-button {
padding: 12px 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.create-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.filter-section {
display: flex;
gap: 16px;
margin-bottom: 24px;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.filter-group {
flex: 1;
min-width: 200px;
}
.search-input,
.filter-select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
outline: none;
}
.search-input:focus,
.filter-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.stats-bar {
display: flex;
gap: 20px;
margin-bottom: 24px;
}
.stat-item {
flex: 1;
background: white;
padding: 20px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
align-items: center;
transition: transform 0.2s ease;
}
.stat-item:hover {
transform: translateY(-2px);
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: #1f2937;
}
.projects-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
overflow-x: auto;
}
.projects-table {
width: 100%;
border-collapse: collapse;
min-width: 1200px;
}
.projects-table thead {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.projects-table th {
padding: 16px;
text-align: left;
font-weight: 600;
font-size: 14px;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.projects-table td {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
font-size: 14px;
color: #374151;
white-space: nowrap;
}
.projects-table td:first-child {
min-width: 250px;
max-width: 300px;
}
.project-row {
transition: background-color 0.2s ease;
}
.project-row:hover {
background-color: #f9fafb;
}
.project-name-cell {
min-width: 250px;
}
.project-name {
font-weight: 600;
color: #1f2937;
margin-bottom: 4px;
}
.project-description {
font-size: 12px;
color: #6b7280;
line-height: 1.4;
}
.status-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.action-cell {
display: flex;
gap: 8px;
}
.action-button {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
text-decoration: none;
transition: all 0.2s ease;
display: inline-block;
}
.view-button {
background: #3b82f6;
color: white;
}
.view-button:hover {
background: #2563eb;
}
.edit-button {
background: #f59e0b;
color: white;
}
.edit-button:hover {
background: #d97706;
}
.empty-cell {
text-align: center !important;
padding: 40px !important;
color: #6b7280;
font-size: 16px;
}
.loading-container,
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 400px;
gap: 20px;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 4px solid #e5e7eb;
border-top-color: #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.retry-button {
padding: 12px 24px;
background: #667eea;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
}
.retry-button:hover {
background: #5a67d8;
}
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { projectApi } from '../services/api';
import './ProjectManagement.css';
function ProjectManagement() {
const [projects, setProjects] = useState([]);
const [filteredProjects, setFilteredProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [departmentFilter, setDepartmentFilter] = useState('all');
useEffect(() => {
fetchProjects();
}, []);
useEffect(() => {
let filtered = [...projects];
if (searchTerm) {
filtered = filtered.filter(project =>
project.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
project.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(project => project.status === statusFilter);
}
if (departmentFilter !== 'all') {
filtered = filtered.filter(project => project.department === departmentFilter);
}
setFilteredProjects(filtered);
}, [projects, searchTerm, statusFilter, departmentFilter]);
const fetchProjects = async () => {
setLoading(true);
setError('');
try {
const data = await projectApi.getProjects();
setProjects(data);
} catch (err) {
setError('获取项目列表失败');
console.error('Error fetching projects:', err);
} finally {
setLoading(false);
}
};
const getStatusColor = (status) => {
const colors = {
'pending': '#f59e0b',
'in_progress': '#3b82f6',
'completed': '#10b981',
'paused': '#f59e0b',
'cancelled': '#ef4444'
};
return colors[status] || '#6b7280';
};
const getStatusText = (status) => {
const texts = {
'pending': '未开始',
'in_progress': '进行中',
'completed': '已完成',
'paused': '已暂停',
'cancelled': '已取消'
};
return texts[status] || status;
};
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
const formatBudget = (budget) => {
if (!budget) return '-';
return new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(budget);
};
const departments = [...new Set(projects.map(p => p.department).filter(Boolean))];
if (loading) {
return (
<div className="page-container">
<div className="loading-container">
<div className="loading-spinner" />
<p>加载中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="page-container">
<div className="error-container">
<div className="error-icon"></div>
<p>{error}</p>
<button className="retry-button" onClick={fetchProjects}>
重试
</button>
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">项目管理</h1>
<Link to="/projects/create" className="create-button">
+ 新建项目
</Link>
</div>
<div className="filter-section">
<div className="filter-group">
<input
type="text"
className="search-input"
placeholder="搜索项目名称或描述..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="filter-group">
<select
className="filter-select"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
>
<option value="all">所有状态</option>
<option value="pending">未开始</option>
<option value="in_progress">进行中</option>
<option value="completed">已完成</option>
<option value="paused">已暂停</option>
<option value="cancelled">已取消</option>
</select>
</div>
<div className="filter-group">
<select
className="filter-select"
value={departmentFilter}
onChange={(e) => setDepartmentFilter(e.target.value)}
>
<option value="all">所有部门</option>
{departments.map(dept => (
<option key={dept} value={dept}>{dept}</option>
))}
</select>
</div>
</div>
<div className="stats-bar">
<div className="stat-item">
<span className="stat-label">总项目数</span>
<span className="stat-value">{filteredProjects.length}</span>
</div>
<div className="stat-item">
<span className="stat-label">进行中</span>
<span className="stat-value">
{filteredProjects.filter(p => p.status === 'in_progress').length}
</span>
</div>
<div className="stat-item">
<span className="stat-label">已完成</span>
<span className="stat-value">
{filteredProjects.filter(p => p.status === 'completed').length}
</span>
</div>
<div className="stat-item">
<span className="stat-label">未开始</span>
<span className="stat-value">
{filteredProjects.filter(p => p.status === 'pending').length}
</span>
</div>
</div>
<div className="projects-table-container">
<table className="projects-table">
<thead>
<tr>
<th>项目名称</th>
<th>部门</th>
<th>状态</th>
<th>预算</th>
<th>开始日期</th>
<th>结束日期</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredProjects.length === 0 ? (
<tr>
<td colSpan={8} className="empty-cell">
暂无项目数据
</td>
</tr>
) : (
filteredProjects.map((project) => (
<tr key={project.id} className="project-row">
<td className="project-name-cell">
<div className="project-name">{project.name}</div>
<div className="project-description">
{project.description?.substring(0, 50)}
{project.description?.length > 50 ? '...' : ''}
</div>
</td>
<td>{project.department || '-'}</td>
<td>
<span
className="status-badge"
style={{ backgroundColor: getStatusColor(project.status) }}
>
{getStatusText(project.status)}
</span>
</td>
<td>{formatBudget(project.budget)}</td>
<td>{formatDate(project.start_date)}</td>
<td>{formatDate(project.end_date)}</td>
<td>{formatDate(project.created_at)}</td>
<td className="action-cell">
<Link
to={`/projects/${project.id}`}
className="action-button view-button"
>
查看
</Link>
<Link
to={`/projects/${project.id}/edit`}
className="action-button edit-button"
>
编辑
</Link>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
export default ProjectManagement;
+189
View File
@@ -0,0 +1,189 @@
.sidebar {
position: fixed;
left: 0;
top: 0;
width: 260px;
height: 100vh;
background: linear-gradient(180deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
flex-direction: column;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
z-index: 1000;
transition: width 0.3s ease;
}
.sidebar.collapsed {
width: 80px;
}
.sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.logo {
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
}
.logo-icon {
font-size: 24px;
}
.logo-text {
white-space: nowrap;
}
.sidebar.collapsed .logo-text {
display: none;
}
.collapse-button {
background: rgba(255, 255, 255, 0.1);
border: none;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: background 0.2s ease;
}
.collapse-button:hover {
background: rgba(255, 255, 255, 0.2);
}
.sidebar-nav {
flex: 1;
padding: 20px 0;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
padding: 14px 24px;
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
transition: all 0.2s ease;
border-left: 3px solid transparent;
white-space: nowrap;
overflow: hidden;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.1);
color: white;
}
.nav-item.active {
background: rgba(255, 255, 255, 0.15);
color: white;
border-left-color: white;
}
.nav-icon {
font-size: 20px;
min-width: 30px;
}
.nav-text {
margin-left: 12px;
white-space: nowrap;
}
.sidebar.collapsed .nav-text {
display: none;
}
.sidebar-footer {
padding: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding: 12px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: white;
color: #667eea;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.user-details {
flex: 1;
min-width: 0;
overflow: hidden;
}
.user-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-role {
font-size: 12px;
opacity: 0.8;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar.collapsed .user-details {
display: none;
}
.logout-button {
width: 100%;
padding: 12px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.logout-button:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.sidebar.collapsed .logout-button {
padding: 12px 0;
font-size: 12px;
}
+97
View File
@@ -0,0 +1,97 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import './Sidebar.css';
function Sidebar() {
const navigate = useNavigate();
const location = useLocation();
const token = localStorage.getItem('token');
const [userInfo, setUserInfo] = useState({ role: '', username: '' });
const [isCollapsed, setIsCollapsed] = useState(false);
useEffect(() => {
if (token) {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
setTimeout(() => {
setUserInfo({
role: payload.role || '',
username: payload.username || ''
});
}, 0);
} catch (e) {
console.error('Error parsing token:', e);
}
} else {
setTimeout(() => {
setUserInfo({ role: '', username: '' });
}, 0);
}
}, [token]);
const handleLogout = () => {
localStorage.removeItem('token');
navigate('/login');
};
const menuItems = [
{ path: '/', label: '首页', icon: '🏠' },
{ path: '/projects', label: '项目管理', icon: '📋' },
{ path: '/users', label: '用户管理', icon: '👥', adminOnly: true },
{ path: '/statistics', label: '项目统计', icon: '📊', adminOnly: true },
];
const filteredMenuItems = menuItems.filter(item => {
if (item.adminOnly && userInfo.role !== 'admin') {
return false;
}
return true;
});
return (
<div className={`sidebar ${isCollapsed ? 'collapsed' : ''}`}>
<div className="sidebar-header">
<div className="logo">
<span className="logo-icon">📊</span>
<span className="logo-text">项目管理系统</span>
</div>
<button
className="collapse-button"
onClick={() => setIsCollapsed(!isCollapsed)}
>
{isCollapsed ? '→' : '←'}
</button>
</div>
<nav className="sidebar-nav">
{filteredMenuItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`nav-item ${location.pathname === item.path ? 'active' : ''}`}
>
<span className="nav-icon">{item.icon}</span>
<span className="nav-text">{item.label}</span>
</Link>
))}
</nav>
<div className="sidebar-footer">
<div className="user-info">
<div className="user-avatar">
{userInfo.username.charAt(0).toUpperCase()}
</div>
<div className="user-details">
<div className="user-name">{userInfo.username}</div>
<div className="user-role">{userInfo.role}</div>
</div>
</div>
<button className="logout-button" onClick={handleLogout}>
退出登录
</button>
</div>
</div>
);
}
export default Sidebar;
+195
View File
@@ -0,0 +1,195 @@
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin-bottom: 30px;
}
.stats-overview {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
gap: 20px;
transition: transform 0.2s ease;
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.stat-icon {
font-size: 48px;
opacity: 0.8;
}
.stat-info {
flex: 1;
}
.stat-label {
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: #1f2937;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin-bottom: 30px;
}
.stats-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.section-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 20px 0;
padding-bottom: 16px;
border-bottom: 2px solid #e5e7eb;
}
.status-stats {
display: flex;
flex-direction: column;
gap: 16px;
}
.status-stat-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
transition: transform 0.2s ease;
}
.status-stat-item:hover {
transform: translateX(4px);
}
.status-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.status-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.status-name {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.status-count {
font-size: 20px;
font-weight: 700;
color: #1f2937;
}
.status-percentage {
font-size: 13px;
color: #6b7280;
font-weight: 500;
}
.department-stats {
display: flex;
flex-direction: column;
gap: 12px;
}
.department-stat-item {
padding: 12px 0;
}
.dept-name {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.dept-count {
font-size: 16px;
font-weight: 700;
color: #667eea;
}
.dept-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.dept-bar-fill {
height: 100%;
border-radius: 4px;
transition: width 0.3s ease;
}
.budget-section {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.budget-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.budget-item {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
color: white;
}
.budget-label {
font-size: 14px;
opacity: 0.9;
margin-bottom: 8px;
}
.budget-value {
font-size: 24px;
font-weight: 700;
}
+217
View File
@@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react';
import { projectApi } from '../services/api';
import './Statistics.css';
function Statistics() {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchProjects();
}, []);
const fetchProjects = async () => {
setLoading(true);
setError('');
try {
const data = await projectApi.getProjects();
setProjects(data);
} catch (err) {
setError('获取统计数据失败');
console.error('Error fetching statistics:', err);
} finally {
setLoading(false);
}
};
const getStatusColor = (status) => {
const colors = {
'pending': '#f59e0b',
'in_progress': '#3b82f6',
'completed': '#10b981',
'paused': '#f59e0b',
'cancelled': '#ef4444'
};
return colors[status] || '#6b7280';
};
const getStatusText = (status) => {
const texts = {
'pending': '未开始',
'in_progress': '进行中',
'completed': '已完成',
'paused': '已暂停',
'cancelled': '已取消'
};
return texts[status] || status;
};
const calculateStats = () => {
const total = projects.length;
const byStatus = {
pending: projects.filter(p => p.status === 'pending').length,
in_progress: projects.filter(p => p.status === 'in_progress').length,
completed: projects.filter(p => p.status === 'completed').length,
paused: projects.filter(p => p.status === 'paused').length,
cancelled: projects.filter(p => p.status === 'cancelled').length
};
const byDepartment = {};
projects.forEach(p => {
const dept = p.department || '未分类';
byDepartment[dept] = (byDepartment[dept] || 0) + 1;
});
const totalBudget = projects.reduce((sum, p) => sum + (p.budget || 0), 0);
const avgBudget = total > 0 ? totalBudget / total : 0;
return {
total,
byStatus,
byDepartment,
totalBudget,
avgBudget,
completionRate: total > 0 ? ((byStatus.completed / total) * 100).toFixed(1) : 0
};
};
const stats = calculateStats();
if (loading) {
return (
<div className="page-container">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="page-container">
<div className="error-container">
<div className="error-icon"></div>
<p>{error}</p>
<button className="retry-button" onClick={fetchProjects}>
重试
</button>
</div>
</div>
);
}
return (
<div className="page-container">
<h1 className="page-title">项目统计</h1>
<div className="stats-overview">
<div className="stat-card">
<div className="stat-icon">📊</div>
<div className="stat-info">
<div className="stat-label">项目总数</div>
<div className="stat-value">{stats.total}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon"></div>
<div className="stat-info">
<div className="stat-label">已完成</div>
<div className="stat-value">{stats.byStatus.completed}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">🔄</div>
<div className="stat-info">
<div className="stat-label">进行中</div>
<div className="stat-value">{stats.byStatus.in_progress}</div>
</div>
</div>
<div className="stat-card">
<div className="stat-icon">📈</div>
<div className="stat-info">
<div className="stat-label">完成率</div>
<div className="stat-value">{stats.completionRate}%</div>
</div>
</div>
</div>
<div className="stats-grid">
<div className="stats-section">
<h2 className="section-title">按状态统计</h2>
<div className="status-stats">
{Object.entries(stats.byStatus).map(([status, count]) => (
<div key={status} className="status-stat-item">
<div
className="status-indicator"
style={{ backgroundColor: getStatusColor(status) }}
></div>
<div className="status-info">
<div className="status-name">{getStatusText(status)}</div>
<div className="status-count">{count}</div>
</div>
<div className="status-percentage">
{stats.total > 0 ? ((count / stats.total) * 100).toFixed(1) : 0}%
</div>
</div>
))}
</div>
</div>
<div className="stats-section">
<h2 className="section-title">按部门统计</h2>
<div className="department-stats">
{Object.entries(stats.byDepartment)
.sort(([, a], [, b]) => b - a)
.map(([dept, count]) => (
<div key={dept} className="department-stat-item">
<div className="dept-name">{dept}</div>
<div className="dept-count">{count}</div>
<div className="dept-bar">
<div
className="dept-bar-fill"
style={{
width: `${(count / Math.max(...Object.values(stats.byDepartment))) * 100}%`,
backgroundColor: '#667eea'
}}
></div>
</div>
</div>
))}
</div>
</div>
</div>
<div className="budget-section">
<h2 className="section-title">预算统计</h2>
<div className="budget-stats">
<div className="budget-item">
<div className="budget-label">总预算</div>
<div className="budget-value">
{new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(stats.totalBudget)}
</div>
</div>
<div className="budget-item">
<div className="budget-label">平均预算</div>
<div className="budget-value">
{new Intl.NumberFormat('zh-CN', {
style: 'currency',
currency: 'CNY'
}).format(stats.avgBudget)}
</div>
</div>
</div>
</div>
</div>
);
}
export default Statistics;
+260
View File
@@ -0,0 +1,260 @@
.users-table-container {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.users-table {
width: 100%;
border-collapse: collapse;
}
.users-table th {
padding: 16px;
text-align: left;
font-weight: 600;
font-size: 14px;
color: white;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.users-table td {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
font-size: 14px;
color: #374151;
}
.user-row {
transition: background-color 0.2s ease;
}
.user-row:hover {
background-color: #f9fafb;
}
.user-name-cell {
display: flex;
align-items: center;
gap: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
flex-shrink: 0;
}
.username {
font-weight: 600;
color: #1f2937;
}
.role-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
color: white;
white-space: nowrap;
}
.action-cell {
display: flex;
gap: 8px;
}
.action-button {
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s ease;
}
.edit-button {
background: #3b82f6;
color: white;
}
.edit-button:hover {
background: #2563eb;
}
.delete-button {
background: #ef4444;
color: white;
}
.delete-button:hover {
background: #dc2626;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.modal-content {
background: white;
border-radius: 12px;
padding: 32px;
width: 100%;
max-width: 500px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 2px solid #e5e7eb;
}
.modal-title {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.close-button {
background: none;
border: none;
font-size: 32px;
color: #6b7280;
cursor: pointer;
line-height: 1;
transition: color 0.2s ease;
}
.close-button:hover {
color: #1f2937;
}
.modal-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.form-input,
.form-select {
width: 100%;
padding: 12px 16px;
border: 2px solid #e5e7eb;
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
outline: none;
}
.form-input:focus,
.form-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.form-input:disabled {
background: #f9fafb;
cursor: not-allowed;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
}
.submit-button {
padding: 12px 28px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.submit-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
.submit-button:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.cancel-button {
padding: 12px 28px;
background: #6b7280;
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease;
}
.cancel-button:hover:not(:disabled) {
background: #4b5563;
}
.cancel-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
+351
View File
@@ -0,0 +1,351 @@
import React, { useState, useEffect } from 'react';
import { userApi } from '../services/api';
import './UserManagement.css';
function UserManagement() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingUser, setEditingUser] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
setLoading(true);
setError('');
try {
const data = await userApi.getUsers();
setUsers(data);
} catch (err) {
setError('获取用户列表失败');
console.error('Error fetching users:', err);
} finally {
setLoading(false);
}
};
const handleCreateUser = () => {
setEditingUser(null);
setShowModal(true);
};
const handleEditUser = (user) => {
setEditingUser(user);
setShowModal(true);
};
const handleDeleteUser = async (userId) => {
if (window.confirm('确定要删除这个用户吗?')) {
try {
await userApi.deleteUser(userId);
fetchUsers();
} catch (err) {
setError('删除用户失败');
console.error('Error deleting user:', err);
}
}
};
const handleModalClose = () => {
setShowModal(false);
setEditingUser(null);
};
const handleUserSaved = () => {
setShowModal(false);
setEditingUser(null);
fetchUsers();
};
const getRoleBadgeColor = (role) => {
const colors = {
'admin': '#ef4444',
'marketing': '#3b82f6',
'other': '#10b981'
};
return colors[role] || '#6b7280';
};
const getRoleText = (role) => {
const texts = {
'admin': '管理员',
'marketing': '市场部',
'other': '其他部门'
};
return texts[role] || role;
};
const filteredUsers = users.filter(user =>
user.username.toLowerCase().includes(searchTerm.toLowerCase()) ||
user.department?.toLowerCase().includes(searchTerm.toLowerCase())
);
if (loading) {
return (
<div className="page-container">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="page-container">
<div className="error-container">
<div className="error-icon"></div>
<p>{error}</p>
<button className="retry-button" onClick={fetchUsers}>
重试
</button>
</div>
</div>
);
}
return (
<div className="page-container">
<div className="page-header">
<h1 className="page-title">用户管理</h1>
<button className="create-button" onClick={handleCreateUser}>
+ 新建用户
</button>
</div>
<div className="filter-section">
<div className="filter-group">
<input
type="text"
className="search-input"
placeholder="搜索用户名或部门..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
</div>
<div className="users-table-container">
<table className="users-table">
<thead>
<tr>
<th>用户名</th>
<th>部门</th>
<th>角色</th>
<th>创建时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
{filteredUsers.length === 0 ? (
<tr>
<td colSpan="5" className="empty-cell">
暂无用户数据
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="user-row">
<td className="user-name-cell">
<div className="user-avatar">
{user.username.charAt(0).toUpperCase()}
</div>
<span className="username">{user.username}</span>
</td>
<td>{user.department || '-'}</td>
<td>
<span
className="role-badge"
style={{ backgroundColor: getRoleBadgeColor(user.role) }}
>
{getRoleText(user.role)}
</span>
</td>
<td>
{user.created_at ? new Date(user.created_at).toLocaleDateString('zh-CN') : '-'}
</td>
<td className="action-cell">
<button
className="action-button edit-button"
onClick={() => handleEditUser(user)}
>
编辑
</button>
<button
className="action-button delete-button"
onClick={() => handleDeleteUser(user.id)}
>
删除
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{showModal && (
<UserModal
user={editingUser}
onClose={handleModalClose}
onSave={handleUserSaved}
/>
)}
</div>
);
}
function UserModal({ user, onClose, onSave }) {
const [formData, setFormData] = useState({
username: user?.username || '',
password: '',
department: user?.department || 'IT',
role: user?.role || 'other'
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
if (user) {
await userApi.updateUser(user.id, formData);
} else {
await userApi.createUser(formData);
}
onSave();
} catch (err) {
setError(user ? '更新用户失败' : '创建用户失败');
console.error('Error saving user:', err);
} finally {
setLoading(false);
}
};
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">
{user ? '编辑用户' : '新建用户'}
</h2>
<button className="close-button" onClick={onClose}>
×
</button>
</div>
{error && (
<div className="error-message">
<span className="error-icon"></span>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="modal-form">
<div className="form-group">
<label className="form-label">
用户名 <span className="required">*</span>
</label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleChange}
className="form-input"
placeholder="请输入用户名"
required
disabled={!!user}
/>
</div>
<div className="form-group">
<label className="form-label">
密码 {!user && <span className="required">*</span>}
</label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
className="form-input"
placeholder={user ? "留空则不修改密码" : "请输入密码"}
required={!user}
/>
</div>
<div className="form-group">
<label className="form-label">
部门 <span className="required">*</span>
</label>
<select
name="department"
value={formData.department}
onChange={handleChange}
className="form-select"
required
>
<option value="IT">IT部门</option>
<option value="Marketing">市场部</option>
<option value="Testing">测试部</option>
<option value="Other">其他部门</option>
</select>
</div>
<div className="form-group">
<label className="form-label">
角色 <span className="required">*</span>
</label>
<select
name="role"
value={formData.role}
onChange={handleChange}
className="form-select"
required
>
<option value="admin">管理员</option>
<option value="marketing">市场部</option>
<option value="other">其他部门</option>
</select>
</div>
<div className="form-actions">
<button
type="submit"
className="submit-button"
disabled={loading}
>
{loading ? '保存中...' : (user ? '更新' : '创建')}
</button>
<button
type="button"
className="cancel-button"
onClick={onClose}
disabled={loading}
>
取消
</button>
</div>
</form>
</div>
</div>
);
}
export default UserManagement;
+10 -3
View File
@@ -41,9 +41,13 @@ api.interceptors.response.use(
// 认证相关API
export const authApi = {
login: (username, password) => {
return api.post('/login', {
username,
password
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
return api.post('/auth/login', formData, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
}
};
@@ -64,6 +68,9 @@ export const projectApi = {
},
deleteProject: (id) => {
return api.delete(`/projects/${id}`);
},
getProjectHistory: (id) => {
return api.get(`/projects/${id}/history`);
}
};