初步开发完成
This commit is contained in:
@@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+21
@@ -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",
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
"testEnvironment": "jsdom",
|
||||
"transform": {
|
||||
"^.+\\.(js|jsx)$": "babel-jest"
|
||||
},
|
||||
"moduleNameMapper": {
|
||||
"\\.(css|less|scss|sass)$": "identity-obj-proxy"
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+64
-28
@@ -1,41 +1,77 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,38 +28,88 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user