init commit

This commit is contained in:
huanglinhuan
2025-12-03 22:21:49 +08:00
commit 72bce2dbc8
16 changed files with 1150 additions and 0 deletions

24
.gitignore vendored Executable file
View File

@@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

34
README.md Executable file
View File

@@ -0,0 +1,34 @@
# 机型信息管理系统 - 前端
## 技术栈
- React 18
- React Router
- Axios
- CSS3
## 安装依赖
```bash
npm install
```
## 运行项目
```bash
npm start
```
项目将在 http://localhost:3000 启动
## 功能特性
- 用户登录认证
- 机型信息展示
- 机型切换浏览
- PDF资料查询和查看
## 默认登录信息
- 用户名: `admin`
- 密码: `admin123`
## 注意事项
- 确保后端API服务已启动默认端口3001
- 前端已配置代理API请求会自动转发到后端

38
package.json Executable file
View File

@@ -0,0 +1,38 @@
{
"name": "department-web",
"version": "1.0.0",
"description": "机型信息管理系统前端",
"private": true,
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://localhost:3001"
}

15
public/index.html Executable file
View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="机型信息管理系统" />
<title>机型信息管理系统</title>
</head>
<body>
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
<div id="root"></div>
</body>
</html>

71
src/App.css Executable file
View File

@@ -0,0 +1,71 @@
.App {
min-height: 100vh;
background-color: #f5f5f5;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.card {
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 24px;
margin-bottom: 20px;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.btn-primary {
background-color: #1890ff;
color: white;
}
.btn-primary:hover {
background-color: #40a9ff;
}
.btn-secondary {
background-color: #f0f0f0;
color: #333;
}
.btn-secondary:hover {
background-color: #d9d9d9;
}
.input {
width: 100%;
padding: 10px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
}
.input:focus {
outline: none;
border-color: #1890ff;
}
.error-message {
color: #ff4d4f;
margin-top: 8px;
font-size: 14px;
}
.success-message {
color: #52c41a;
margin-top: 8px;
font-size: 14px;
}

42
src/App.js Executable file
View File

@@ -0,0 +1,42 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './context/AuthContext';
import Login from './pages/Login';
import AircraftList from './pages/AircraftList';
import AircraftDetail from './pages/AircraftDetail';
import PrivateRoute from './components/PrivateRoute';
import './App.css';
function App() {
return (
<AuthProvider>
<Router>
<div className="App">
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/aircraft"
element={
<PrivateRoute>
<AircraftList />
</PrivateRoute>
}
/>
<Route
path="/aircraft/:id"
element={
<PrivateRoute>
<AircraftDetail />
</PrivateRoute>
}
/>
<Route path="/" element={<Navigate to="/aircraft" replace />} />
</Routes>
</div>
</Router>
</AuthProvider>
);
}
export default App;

16
src/components/PrivateRoute.js Executable file
View File

@@ -0,0 +1,16 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
const PrivateRoute = ({ children }) => {
const { user, loading } = useAuth();
if (loading) {
return <div style={{ padding: '20px', textAlign: 'center' }}>加载中...</div>;
}
return user ? children : <Navigate to="/login" replace />;
};
export default PrivateRoute;

70
src/context/AuthContext.js Executable file
View File

@@ -0,0 +1,70 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth必须在AuthProvider内使用');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// 从localStorage恢复用户信息
useEffect(() => {
const token = localStorage.getItem('token');
const userInfo = localStorage.getItem('user');
if (token && userInfo) {
setUser(JSON.parse(userInfo));
// 设置axios默认header
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
setLoading(false);
}, []);
const login = async (username, password) => {
try {
const response = await axios.post('/api/auth/login', {
username,
password
});
if (response.data.success) {
const { token, user } = response.data;
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
setUser(user);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
return { success: true };
}
} catch (error) {
return {
success: false,
error: error.response?.data?.error || '登录失败,请检查用户名和密码'
};
}
};
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
setUser(null);
delete axios.defaults.headers.common['Authorization'];
};
const value = {
user,
login,
logout,
loading
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

21
src/index.css Executable file
View File

@@ -0,0 +1,21 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #f5f5f5;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

12
src/index.js Executable file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

221
src/pages/AircraftDetail.css Executable file
View File

@@ -0,0 +1,221 @@
.aircraft-detail-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.page-header {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
margin: 0;
color: #333;
font-size: 24px;
}
.aircraft-detail-card {
background: white;
border-radius: 8px;
padding: 32px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.aircraft-header {
border-bottom: 2px solid #f0f0f0;
padding-bottom: 24px;
margin-bottom: 24px;
}
.aircraft-code-large {
display: inline-block;
background: #1890ff;
color: white;
padding: 6px 16px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
}
.aircraft-name-large {
margin: 0 0 16px 0;
color: #333;
font-size: 32px;
font-weight: 600;
}
.aircraft-meta {
display: flex;
gap: 12px;
}
.manufacturer-badge,
.type-badge {
padding: 6px 12px;
border-radius: 4px;
font-size: 14px;
}
.manufacturer-badge {
background: #e6f7ff;
color: #1890ff;
}
.type-badge {
background: #f6ffed;
color: #52c41a;
}
.aircraft-description {
margin-bottom: 32px;
}
.aircraft-description h3,
.aircraft-specifications h3 {
margin: 0 0 16px 0;
color: #333;
font-size: 20px;
font-weight: 600;
}
.aircraft-description p {
color: #666;
line-height: 1.8;
font-size: 15px;
}
.aircraft-specifications {
margin-top: 32px;
}
.specs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.spec-item {
display: flex;
flex-direction: column;
padding: 16px;
background: #fafafa;
border-radius: 6px;
}
.spec-label {
color: #999;
font-size: 12px;
margin-bottom: 8px;
text-transform: uppercase;
}
.spec-value {
color: #333;
font-size: 18px;
font-weight: 600;
}
.pdf-materials-section {
background: white;
border-radius: 8px;
padding: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
gap: 16px;
}
.section-header h3 {
margin: 0;
color: #333;
font-size: 20px;
font-weight: 600;
}
.search-input-small {
padding: 8px 12px;
border: 1px solid #d9d9d9;
border-radius: 4px;
font-size: 14px;
min-width: 200px;
}
.search-input-small:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.pdf-materials-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.pdf-material-card {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
background: #fafafa;
border-radius: 6px;
border: 1px solid #e8e8e8;
transition: all 0.3s;
}
.pdf-material-card:hover {
background: #f0f0f0;
border-color: #1890ff;
}
.pdf-material-info {
flex: 1;
}
.pdf-material-title {
margin: 0 0 8px 0;
color: #333;
font-size: 16px;
font-weight: 600;
}
.pdf-material-description {
margin: 0 0 12px 0;
color: #666;
font-size: 14px;
}
.pdf-material-meta {
display: flex;
gap: 20px;
color: #999;
font-size: 12px;
}
.loading,
.error-state,
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
font-size: 16px;
}

189
src/pages/AircraftDetail.js Executable file
View File

@@ -0,0 +1,189 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import axios from 'axios';
import './AircraftDetail.css';
const AircraftDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const { logout } = useAuth();
const [aircraft, setAircraft] = useState(null);
const [pdfMaterials, setPdfMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
useEffect(() => {
fetchAircraftDetail();
fetchPdfMaterials();
}, [id]);
useEffect(() => {
if (id) {
fetchPdfMaterials();
}
}, [searchTerm, id]);
const fetchAircraftDetail = async () => {
try {
setLoading(true);
const response = await axios.get(`/api/aircraft/${id}`);
if (response.data.success) {
setAircraft(response.data.data);
}
} catch (error) {
console.error('获取机型详情失败:', error);
if (error.response?.status === 401) {
logout();
navigate('/login');
}
} finally {
setLoading(false);
}
};
const fetchPdfMaterials = async () => {
try {
const response = await axios.get(`/api/pdf/aircraft/${id}`, {
params: { search: searchTerm }
});
if (response.data.success) {
setPdfMaterials(response.data.data);
}
} catch (error) {
console.error('获取PDF资料失败:', error);
}
};
const handleViewPdf = async (materialId) => {
try {
const response = await axios.get(`/api/pdf/file/${materialId}`);
if (response.data.success) {
const fileUrl = `http://localhost:3001${response.data.data.fileUrl}`;
window.open(fileUrl, '_blank');
}
} catch (error) {
console.error('打开PDF失败:', error);
alert('无法打开PDF文件请确保文件存在');
}
};
if (loading) {
return (
<div className="aircraft-detail-page">
<div className="container">
<div className="loading">加载中...</div>
</div>
</div>
);
}
if (!aircraft) {
return (
<div className="aircraft-detail-page">
<div className="container">
<div className="error-state">机型信息不存在</div>
</div>
</div>
);
}
return (
<div className="aircraft-detail-page">
<header className="page-header">
<div className="header-content">
<button onClick={() => navigate('/aircraft')} className="btn btn-secondary">
返回列表
</button>
<h1>机型详情</h1>
<div></div>
</div>
</header>
<div className="container">
<div className="aircraft-detail-card">
<div className="aircraft-header">
<div className="aircraft-code-large">{aircraft.code}</div>
<h2 className="aircraft-name-large">{aircraft.name}</h2>
<div className="aircraft-meta">
<span className="manufacturer-badge">{aircraft.manufacturer}</span>
<span className="type-badge">{aircraft.type}</span>
</div>
</div>
<div className="aircraft-description">
<h3>机型简介</h3>
<p>{aircraft.description}</p>
</div>
<div className="aircraft-specifications">
<h3>技术规格</h3>
<div className="specs-grid">
<div className="spec-item">
<span className="spec-label">最大载客量</span>
<span className="spec-value">{aircraft.specifications.maxPassengers} </span>
</div>
<div className="spec-item">
<span className="spec-label">最大航程</span>
<span className="spec-value">{aircraft.specifications.maxRange}</span>
</div>
<div className="spec-item">
<span className="spec-label">巡航速度</span>
<span className="spec-value">{aircraft.specifications.cruiseSpeed}</span>
</div>
<div className="spec-item">
<span className="spec-label">机长</span>
<span className="spec-value">{aircraft.specifications.length}</span>
</div>
<div className="spec-item">
<span className="spec-label">翼展</span>
<span className="spec-value">{aircraft.specifications.wingspan}</span>
</div>
</div>
</div>
</div>
<div className="pdf-materials-section">
<div className="section-header">
<h3>相关资料</h3>
<input
type="text"
placeholder="搜索资料..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input-small"
/>
</div>
{pdfMaterials.length === 0 ? (
<div className="empty-state">暂无相关资料</div>
) : (
<div className="pdf-materials-list">
{pdfMaterials.map((material) => (
<div key={material.id} className="pdf-material-card">
<div className="pdf-material-info">
<h4 className="pdf-material-title">{material.title}</h4>
<p className="pdf-material-description">{material.description}</p>
<div className="pdf-material-meta">
<span>上传日期: {material.uploadDate}</span>
<span>文件大小: {material.fileSize}</span>
</div>
</div>
<button
onClick={() => handleViewPdf(material.id)}
className="btn btn-primary"
>
查看PDF
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
export default AircraftDetail;

142
src/pages/AircraftList.css Executable file
View File

@@ -0,0 +1,142 @@
.aircraft-list-page {
min-height: 100vh;
background-color: #f5f5f5;
}
.page-header {
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
margin: 0;
color: #333;
font-size: 24px;
}
.header-actions {
display: flex;
align-items: center;
gap: 15px;
}
.user-info {
color: #666;
font-size: 14px;
}
.search-section {
margin-bottom: 24px;
}
.search-input {
width: 100%;
max-width: 600px;
padding: 12px 16px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.search-input:focus {
outline: none;
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
.loading {
text-align: center;
padding: 40px;
color: #999;
}
.aircraft-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
}
.aircraft-card {
background: white;
border-radius: 8px;
padding: 24px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
position: relative;
border: 2px solid transparent;
}
.aircraft-card:hover {
transform: translateY(-4px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
border-color: #1890ff;
}
.aircraft-code {
display: inline-block;
background: #1890ff;
color: white;
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
margin-bottom: 12px;
}
.aircraft-name {
margin: 0 0 12px 0;
color: #333;
font-size: 20px;
font-weight: 600;
}
.aircraft-info {
display: flex;
flex-direction: column;
gap: 6px;
color: #666;
font-size: 14px;
}
.manufacturer {
font-weight: 500;
}
.type {
color: #999;
}
.card-arrow {
position: absolute;
right: 24px;
top: 50%;
transform: translateY(-50%);
font-size: 24px;
color: #d9d9d9;
transition: all 0.3s;
}
.aircraft-card:hover .card-arrow {
color: #1890ff;
transform: translateY(-50%) translateX(4px);
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: #999;
font-size: 16px;
}

98
src/pages/AircraftList.js Executable file
View File

@@ -0,0 +1,98 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import axios from 'axios';
import './AircraftList.css';
const AircraftList = () => {
const [aircrafts, setAircrafts] = useState([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const { user, logout } = useAuth();
const navigate = useNavigate();
useEffect(() => {
fetchAircrafts();
}, [searchTerm]);
const fetchAircrafts = async () => {
try {
setLoading(true);
const response = await axios.get('/api/aircraft/list', {
params: { search: searchTerm }
});
if (response.data.success) {
setAircrafts(response.data.data);
}
} catch (error) {
console.error('获取机型列表失败:', error);
if (error.response?.status === 401) {
logout();
navigate('/login');
}
} finally {
setLoading(false);
}
};
const handleAircraftClick = (id) => {
navigate(`/aircraft/${id}`);
};
return (
<div className="aircraft-list-page">
<header className="page-header">
<div className="header-content">
<h1>机型信息管理系统</h1>
<div className="header-actions">
<span className="user-info">欢迎{user?.name || user?.username}</span>
<button onClick={logout} className="btn btn-secondary">
退出登录
</button>
</div>
</div>
</header>
<div className="container">
<div className="search-section">
<input
type="text"
placeholder="搜索机型(名称、代码、制造商)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="search-input"
/>
</div>
{loading ? (
<div className="loading">加载中...</div>
) : (
<div className="aircraft-grid">
{aircrafts.length === 0 ? (
<div className="empty-state">未找到机型信息</div>
) : (
aircrafts.map((aircraft) => (
<div
key={aircraft.id}
className="aircraft-card"
onClick={() => handleAircraftClick(aircraft.id)}
>
<div className="aircraft-code">{aircraft.code}</div>
<h3 className="aircraft-name">{aircraft.name}</h3>
<div className="aircraft-info">
<span className="manufacturer">{aircraft.manufacturer}</span>
<span className="type">{aircraft.type}</span>
</div>
<div className="card-arrow"></div>
</div>
))
)}
</div>
)}
</div>
</div>
);
};
export default AircraftList;

80
src/pages/Login.css Executable file
View File

@@ -0,0 +1,80 @@
.login-container {
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
padding: 40px;
width: 100%;
max-width: 400px;
}
.login-title {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 28px;
font-weight: 600;
}
.login-form {
display: flex;
flex-direction: column;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: #333;
font-weight: 500;
}
.form-input {
width: 100%;
padding: 12px;
border: 1px solid #d9d9d9;
border-radius: 6px;
font-size: 14px;
transition: all 0.3s;
}
.form-input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.login-button {
width: 100%;
padding: 12px;
margin-top: 10px;
font-size: 16px;
font-weight: 500;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-hint {
margin-top: 20px;
text-align: center;
color: #999;
font-size: 12px;
}
.login-hint p {
margin: 0;
}

77
src/pages/Login.js Executable file
View File

@@ -0,0 +1,77 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext';
import './Login.css';
const Login = () => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const { login } = useAuth();
const navigate = useNavigate();
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
const result = await login(username, password);
if (result.success) {
navigate('/aircraft');
} else {
setError(result.error);
}
setLoading(false);
};
return (
<div className="login-container">
<div className="login-card">
<h1 className="login-title">机型信息管理系统</h1>
<form onSubmit={handleSubmit} className="login-form">
<div className="form-group">
<label htmlFor="username">用户名</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="form-input"
placeholder="请输入用户名"
required
/>
</div>
<div className="form-group">
<label htmlFor="password">密码</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="form-input"
placeholder="请输入密码"
required
/>
</div>
{error && <div className="error-message">{error}</div>}
<button
type="submit"
className="btn btn-primary login-button"
disabled={loading}
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<div className="login-hint">
<p>默认账号admin / admin123</p>
</div>
</div>
</div>
);
};
export default Login;