From a416e2091f43e105ad538c172c0a47bd2f85da28 Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 26 Jan 2026 23:39:48 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E6=AD=A5=E5=BC=80=E5=8F=91=E5=AE=8C?= =?UTF-8?q?=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/__pycache__/auth.cpython-310.pyc | Bin 1545 -> 1386 bytes backend/app/api/auth.py | 26 +- backend/test.db | Bin 159744 -> 159744 bytes frontend/eslint.config.js | 29 +- frontend/package-lock.json | 21 ++ frontend/package.json | 3 + frontend/src/App.css | 258 ------------- frontend/src/App.jsx | 92 +++-- frontend/src/components/Layout.css | 26 ++ frontend/src/components/Layout.jsx | 18 + frontend/src/components/Login.css | 180 +++++++++ frontend/src/components/Login.jsx | 73 +++- frontend/src/components/ProjectDetail.css | 277 ++++++++++++++ frontend/src/components/ProjectDetail.jsx | 261 +++++++++++++ frontend/src/components/ProjectForm.css | 187 ++++++++++ frontend/src/components/ProjectForm.jsx | 274 ++++++++++++++ frontend/src/components/ProjectManagement.css | 263 +++++++++++++ frontend/src/components/ProjectManagement.jsx | 263 +++++++++++++ frontend/src/components/Sidebar.css | 189 ++++++++++ frontend/src/components/Sidebar.jsx | 97 +++++ frontend/src/components/Statistics.css | 195 ++++++++++ frontend/src/components/Statistics.jsx | 217 +++++++++++ frontend/src/components/UserManagement.css | 260 +++++++++++++ frontend/src/components/UserManagement.jsx | 351 ++++++++++++++++++ frontend/src/services/api.js | 13 +- 25 files changed, 3253 insertions(+), 320 deletions(-) create mode 100644 frontend/src/components/Layout.css create mode 100644 frontend/src/components/Layout.jsx create mode 100644 frontend/src/components/Login.css create mode 100644 frontend/src/components/ProjectDetail.css create mode 100644 frontend/src/components/ProjectDetail.jsx create mode 100644 frontend/src/components/ProjectForm.css create mode 100644 frontend/src/components/ProjectForm.jsx create mode 100644 frontend/src/components/ProjectManagement.css create mode 100644 frontend/src/components/ProjectManagement.jsx create mode 100644 frontend/src/components/Sidebar.css create mode 100644 frontend/src/components/Sidebar.jsx create mode 100644 frontend/src/components/Statistics.css create mode 100644 frontend/src/components/Statistics.jsx create mode 100644 frontend/src/components/UserManagement.css create mode 100644 frontend/src/components/UserManagement.jsx diff --git a/backend/app/api/__pycache__/auth.cpython-310.pyc b/backend/app/api/__pycache__/auth.cpython-310.pyc index 2540428dc4cf9af5cee23a2c0bef3e3076daf180..b4dd7ca403157ef0d1457ae6600227458c8e8f06 100644 GIT binary patch delta 261 zcmeC=dBw$-&&$ij00fK`<(WTOHu8ODT zSW-ArIOi}0GiY*^@&HYKK4rzTj_uE9?tZ#=>f}#M%NThl&tz7ce1lngG6#!}AZw8j zP*IUEkhsNCTAW%m*^fn>Ll($g$xx&*IhVyjP6Ei0VHRL!Vq{@tVq{}v`^&?|#K^_S k_MeNnNNe(4mROcsO!>u=?O9tzwLpexpc0ysZ?YNy02PHjn*aa+ delta 439 zcmZvWJ8K(36oq&0JXTuou3|X}DGVVg5)x$a3xnN6V4RR9jnt$FLU@L-E3qqPMwW{e z20y?qRE<*vT*ysu%2X~){>XGL9D~7?>)?#45XgmdxCg#_xHrL380S$Gcy6xigXEU| zi0@F$T%=CxPU_55;OUO0DI5D7aX5u>>D1%614CS3Iah#uUU+GllMa?O9q6ev<^T4qGEGs+wi| z;tAx+`Xbv(WS&|4S!heU`9MhX5!XoM%FRBWTV=B9RypI$lD-ns`iW{yI_<;u=B7|C z2{O-vGbBXKouU(kMk1>H501*U;A@&>YtEImwWPk$1?gK2B%p3BF W%9rlSjLE^HCAY|he{IpM(fL1W19e{j diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index 56cf5b5..06a389b 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -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( diff --git a/backend/test.db b/backend/test.db index 248dbfd47480989d1a5365177bcf4a44716cad2c..99c317d81af011d2e5815c9129f052a988665162 100644 GIT binary patch delta 611 zcmZp8z}fJCbAmME--$BLjDI&KEY#=c*Johi-Oj+bjnA0BoOk?mgh`%e()zr>W>%sfi$@C*SPfn^A=Lp+(ASd^Vwl9`uIi8Fv+mDR=*tXx2wS@>Tu@c-fe z#{Yr;6)>W2@vE^iGcvNUfJkO$5Xr>E%*@FNQG1<%|2zLf{_FhjfohNQOK>uaGlJuh ag_l{L5$ delta 162 zcmZp8z}fJCbAmME?};+bjK4P~EY#;_U|?Y4pUc31iGS{9L4k?<0>(@n48p?l^2Q7m z#W{vX#*^#pRIPLvIT*yn1;vdS5>s+B^T32>2$(W5Ff!9MFw`|NQ!uc!GPbZXH0A;u z`i+7A5C1oyp|AMGSeY3anV6WFIXNM6?-}^N^S=kmJ?3wIAK(5yo^kv8cqTys0O!Ul Ad;kCd diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4fa125d..09594ec 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -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', + }, + }, + }, ]) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f01c281..b73c022 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 9627657..4952475 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,9 @@ "testEnvironment": "jsdom", "transform": { "^.+\\.(js|jsx)$": "babel-jest" + }, + "moduleNameMapper": { + "\\.(css|less|scss|sass)$": "identity-obj-proxy" } }, "dependencies": { diff --git a/frontend/src/App.css b/frontend/src/App.css index 2cb23da..8094129 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; - } -} \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 683da7b..51ff0a4 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 : ; +} - // 私有路由组件 - const PrivateRoute = ({ children }) => { - return isAuthenticated ? children : ; - }; - +function App() { return ( -
- -
- - } /> - - - - } /> - - - - } /> - } /> - -
-
+ + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + + + + + + } /> + } /> +
); } -export default App; \ No newline at end of file +export default App; diff --git a/frontend/src/components/Layout.css b/frontend/src/components/Layout.css new file mode 100644 index 0000000..d244f09 --- /dev/null +++ b/frontend/src/components/Layout.css @@ -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%; +} diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx new file mode 100644 index 0000000..333c9f4 --- /dev/null +++ b/frontend/src/components/Layout.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import Sidebar from './Sidebar'; +import './Layout.css'; + +function Layout({ children }) { + return ( +
+ +
+
+ {children} +
+
+
+ ); +} + +export default Layout; diff --git a/frontend/src/components/Login.css b/frontend/src/components/Login.css new file mode 100644 index 0000000..192fc46 --- /dev/null +++ b/frontend/src/components/Login.css @@ -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; +} diff --git a/frontend/src/components/Login.jsx b/frontend/src/components/Login.jsx index 614f51a..b253904 100644 --- a/frontend/src/components/Login.jsx +++ b/frontend/src/components/Login.jsx @@ -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 ( -
-
-

登录

- {error &&
{error}
} -
+
+
+
+

登录

+
+ 📊 + 项目管理系统 +
+
+ + {error && ( +
+ ⚠️ + {error} +
+ )} + +
- + setUsername(e.target.value)} + className="form-input" + placeholder="请输入用户名" required + autoFocus />
+
- + setPassword(e.target.value)} + className="form-input" + placeholder="请输入密码" required />
- + +
+
+

测试账户:

+
+
+ 管理员: + admin / 123456 +
+
+ 市场部: + marketing / 123456 +
+
+ 其他部门: + other / 123456 +
+
+
+
); } -export default Login; \ No newline at end of file +export default Login; diff --git a/frontend/src/components/ProjectDetail.css b/frontend/src/components/ProjectDetail.css new file mode 100644 index 0000000..9c8b072 --- /dev/null +++ b/frontend/src/components/ProjectDetail.css @@ -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; +} diff --git a/frontend/src/components/ProjectDetail.jsx b/frontend/src/components/ProjectDetail.jsx new file mode 100644 index 0000000..5cb6d93 --- /dev/null +++ b/frontend/src/components/ProjectDetail.jsx @@ -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 ( +
+
+
+

加载中...

+
+
+ ); + } + + if (error) { + return ( +
+
+
⚠️
+

{error}

+ + 返回项目列表 + +
+
+ ); + } + + if (!project) { + return ( +
+
+

项目不存在

+ + 返回项目列表 + +
+
+ ); + } + + return ( +
+
+
+ + ← 返回列表 + +

{project.name}

+
+
+ + 编辑项目 + +
+
+ +
+
+

基本信息

+
+
+ 项目ID: + {project.id} +
+
+ 项目名称: + {project.name} +
+
+ 所属部门: + {project.department || '-'} +
+
+ 项目状态: + + {getStatusText(project.status)} + +
+
+ 项目预算: + {formatBudget(project.budget)} +
+
+
+ +
+

时间信息

+
+
+ 开始日期: + {formatDate(project.start_date)} +
+
+ 结束日期: + {formatDate(project.end_date)} +
+
+ 创建时间: + {formatDate(project.created_at)} +
+
+ 更新时间: + {formatDate(project.updated_at)} +
+
+
+ +
+

项目描述

+
+
+ {project.description || '暂无描述'} +
+
+
+
+ +
+
+

修改历史

+ +
+ + {showHistory && ( +
+ {history.length === 0 ? ( +
暂无修改历史
+ ) : ( + history.map((record) => ( +
+
+ + {record.changed_by_username || '未知用户'} + + + {formatDate(record.created_at)} + +
+
+
+ 字段: {record.change_field} +
+
+
+ 修改前: + {record.old_value || '-'} +
+
+ 修改后: + {record.new_value || '-'} +
+
+ {record.change_description && ( +
+ 说明: {record.change_description} +
+ )} +
+
+ )) + )} +
+ )} +
+
+ ); +} + +export default ProjectDetail; diff --git a/frontend/src/components/ProjectForm.css b/frontend/src/components/ProjectForm.css new file mode 100644 index 0000000..e0b65a9 --- /dev/null +++ b/frontend/src/components/ProjectForm.css @@ -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; +} diff --git a/frontend/src/components/ProjectForm.jsx b/frontend/src/components/ProjectForm.jsx new file mode 100644 index 0000000..1dd8880 --- /dev/null +++ b/frontend/src/components/ProjectForm.jsx @@ -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 ( +
+
+
+

+ {isEdit ? '编辑项目' : '新建项目'} +

+ +
+ + {fetching && ( +
+
+

加载中...

+
+ )} + + {!fetching && ( + <> + {error && ( +
+ ⚠️ + {error} +
+ )} + +
+
+

基本信息

+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+
+ +
+

时间信息

+ +
+
+ + +
+ +
+ + +
+
+
+ +
+

项目描述

+ +
+ +