Compare commits
4 Commits
28ca0da7ed
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08980077aa | ||
|
|
d50b08e33e | ||
|
|
863fb26040 | ||
|
|
88e8523c9d |
@@ -1,4 +1,4 @@
|
|||||||
# 机型信息管理系统 - 前端
|
# 通信技术部 - 前端
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
- React 18
|
- React 18
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "department-web",
|
"name": "department-web",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "机型信息管理系统前端",
|
"description": "通信技术部前端",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta name="description" content="机型信息管理系统" />
|
<meta name="description" content="通信技术部" />
|
||||||
<title>机型信息管理系统</title>
|
<title>通信技术部</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
|
<noscript>您需要启用JavaScript才能运行此应用程序。</noscript>
|
||||||
|
|||||||
17
src/App.js
17
src/App.js
@@ -2,8 +2,9 @@ import React from 'react';
|
|||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||||
import { AuthProvider } from './context/AuthContext';
|
import { AuthProvider } from './context/AuthContext';
|
||||||
import Login from './pages/Login';
|
import Login from './pages/Login';
|
||||||
import AircraftList from './pages/AircraftList';
|
import ProductDetail from './pages/ProductDetail';
|
||||||
import AircraftDetail from './pages/AircraftDetail';
|
import DevGuide from './pages/DevGuide';
|
||||||
|
import Training from './pages/Training';
|
||||||
import PrivateRoute from './components/PrivateRoute';
|
import PrivateRoute from './components/PrivateRoute';
|
||||||
import Navbar from './components/Navbar';
|
import Navbar from './components/Navbar';
|
||||||
import Home from './pages/Home';
|
import Home from './pages/Home';
|
||||||
@@ -13,29 +14,29 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Router>
|
<Router>
|
||||||
<div className="App">
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
|
|
||||||
|
<Route path="/product/:series/:sub/:idx" element={<ProductDetail />} />
|
||||||
<Route
|
<Route
|
||||||
path="/aircraft"
|
path="/dev"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AircraftList />
|
<DevGuide />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/aircraft/:id"
|
path="/training"
|
||||||
element={
|
element={
|
||||||
<PrivateRoute>
|
<PrivateRoute>
|
||||||
<AircraftDetail />
|
<Training />
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route path="/" element={<Home />} />
|
<Route path="/" element={<Home />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
|
||||||
</Router>
|
</Router>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
.root: {}
|
||||||
|
:root {
|
||||||
|
--navbar-height: 60px;
|
||||||
|
}
|
||||||
.navbar {
|
.navbar {
|
||||||
background: white;
|
background: white;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
min-height: var(--navbar-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-inner {
|
.navbar-inner {
|
||||||
@@ -13,13 +18,18 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1002;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
margin-right: 16px;
|
margin-right: 16px;
|
||||||
color: #1890ff;
|
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-weight: 600;
|
}
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
height: 28px;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-left {
|
.navbar-left {
|
||||||
@@ -35,10 +45,17 @@
|
|||||||
|
|
||||||
.nav-label {
|
.nav-label {
|
||||||
color: #333;
|
color: #333;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover .dropdown {
|
.nav-item:hover .dropdown,
|
||||||
|
.nav-item:hover .dropdown.mega {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.nav-item.open .dropdown {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +90,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
|
height: 36px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
border: 1px solid #d9d9d9;
|
border: 1px solid #d9d9d9;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -88,3 +107,120 @@
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-right .btn {
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dropdown.mega {
|
||||||
|
display: none;
|
||||||
|
padding: 28px;
|
||||||
|
width: min(95vw, 1400px);
|
||||||
|
border-radius: 10px;
|
||||||
|
position: fixed;
|
||||||
|
top: var(--navbar-height);
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 1001;
|
||||||
|
gap: 20px;
|
||||||
|
background: #fff;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
.nav-item.open .dropdown.mega {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.selected .nav-label::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -2px;
|
||||||
|
height: 2px;
|
||||||
|
background: #1890ff;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-col {
|
||||||
|
width: 320px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
.mega-col:first-child {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-col.mega-wide {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 800px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-products {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-product {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-product img {
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-product-info .name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-product-info .desc {
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mega-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-item {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-item:hover {
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mega-item.active {
|
||||||
|
background: #e6f7ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,135 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { useNavigate, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import './Navbar.css';
|
import './Navbar.css';
|
||||||
|
|
||||||
|
const productMenu = [
|
||||||
|
{
|
||||||
|
key: 'smart',
|
||||||
|
label: '智能系列',
|
||||||
|
subs: [
|
||||||
|
{
|
||||||
|
key: 'N96',
|
||||||
|
label: 'N96',
|
||||||
|
products: [
|
||||||
|
{ name: 'N96', desc: 'Android11,高性能,适配复杂场景', image: '/uploads/menu/menu_n96.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N96P',
|
||||||
|
label: 'N96P',
|
||||||
|
products: [
|
||||||
|
{ name: 'N96P', desc: 'Android14,高性能,适配复杂场景', image: '/uploads/menu/menu_n96p.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N92',
|
||||||
|
label: 'N92',
|
||||||
|
products: [
|
||||||
|
{ name: 'N92', desc: 'Android13,高性能,适配复杂场景', image: '/uploads/menu/menu_n92.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N86P',
|
||||||
|
label: 'N86P',
|
||||||
|
products: [
|
||||||
|
{ name: 'N86P', desc: 'Android13,高性能,适配复杂场景', image: '/uploads/menu/menu_n86p.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N86',
|
||||||
|
label: 'N86',
|
||||||
|
products: [
|
||||||
|
{ name: 'N86', desc: 'Android9,高性能,适配复杂场景', image: '/uploads/menu/menu_n86.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N82',
|
||||||
|
label: 'N82',
|
||||||
|
products: [
|
||||||
|
{ name: 'N82', desc: 'Android10,高性能,适配复杂场景', image: '/uploads/menu/menu_n82.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N80',
|
||||||
|
label: 'N80',
|
||||||
|
products: [
|
||||||
|
{ name: 'N80', desc: 'Android13,高性能,适配复杂场景', image: '/uploads/menu/menu_n80.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N62',
|
||||||
|
label: 'N62',
|
||||||
|
products: [
|
||||||
|
{ name: 'N62', desc: 'Android11,高性能,适配复杂场景', image: '/uploads/menu/menu_n62.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N6P',
|
||||||
|
label: 'N6P',
|
||||||
|
products: [
|
||||||
|
{ name: 'N6P', desc: 'Android13,高性能,适配复杂场景', image: '/uploads/menu/menu_n6p.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'N6',
|
||||||
|
label: 'N6',
|
||||||
|
products: [
|
||||||
|
{ name: 'N6', desc: 'Android7,高性能,适配复杂场景', image: '/uploads/menu/menu_n6.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'traditional',
|
||||||
|
label: '传统系列',
|
||||||
|
subs: [
|
||||||
|
{
|
||||||
|
key: 'k300',
|
||||||
|
label: 'K300',
|
||||||
|
products: [
|
||||||
|
{ name: 'K300', desc: '标准版,覆盖主流需求', image: '/uploads/menu/menu_k300.png' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'basic',
|
||||||
|
label: '基础系列',
|
||||||
|
subs: [
|
||||||
|
{
|
||||||
|
key: 'kd69',
|
||||||
|
label: 'KD69',
|
||||||
|
products: [
|
||||||
|
{ name: 'KD69', desc: '专业场景,扩展性强', image: '/uploads/menu/menu_kd69.png' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [activeSeries, setActiveSeries] = useState(productMenu[0].key);
|
||||||
|
const [activeSub, setActiveSub] = useState(productMenu[0].subs[0].key);
|
||||||
|
const productsRef = useRef(null);
|
||||||
|
const [productsOpen, setProductsOpen] = useState(false);
|
||||||
|
const [productsSelected, setProductsSelected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (productsRef.current && !productsRef.current.contains(e.target)) {
|
||||||
|
setProductsOpen(false);
|
||||||
|
setProductsSelected(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLoginClick = () => {
|
const handleLoginClick = () => {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -19,11 +142,7 @@ const Navbar = () => {
|
|||||||
|
|
||||||
const handleSearchKeyDown = (e) => {
|
const handleSearchKeyDown = (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
if (user) {
|
navigate('/');
|
||||||
navigate('/aircraft', { state: { searchTerm: search } });
|
|
||||||
} else {
|
|
||||||
navigate('/login');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -31,34 +150,98 @@ const Navbar = () => {
|
|||||||
<nav className="navbar">
|
<nav className="navbar">
|
||||||
<div className="navbar-inner">
|
<div className="navbar-inner">
|
||||||
<div className="navbar-left">
|
<div className="navbar-left">
|
||||||
<Link to="/" className="logo">主页</Link>
|
<Link to="/" className="logo">
|
||||||
|
<img
|
||||||
|
src="/uploads/menu/logo.svg"
|
||||||
|
alt="Logo"
|
||||||
|
className="logo-img"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="nav-item">
|
<div className={`nav-item ${productsOpen ? 'open' : ''} ${productsSelected ? 'selected' : ''}`} ref={productsRef}>
|
||||||
<span className="nav-label">产品</span>
|
<span
|
||||||
<div className="dropdown">
|
className="nav-label"
|
||||||
<Link to="/" className="dropdown-item">产品一</Link>
|
onClick={() => {
|
||||||
<Link to="/" className="dropdown-item">产品二</Link>
|
setProductsOpen((prev) => {
|
||||||
<Link to="/" className="dropdown-item">产品三</Link>
|
const next = !prev;
|
||||||
|
setProductsSelected(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>产品</span>
|
||||||
|
<div className="dropdown mega">
|
||||||
|
<div className="mega-col">
|
||||||
|
<div className="mega-title">系列</div>
|
||||||
|
<div className="mega-list">
|
||||||
|
{productMenu.map((series) => (
|
||||||
|
<button
|
||||||
|
key={series.key}
|
||||||
|
className={`mega-item ${series.key === activeSeries ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveSeries(series.key);
|
||||||
|
setActiveSub(series.subs[0]?.key || '');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{series.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mega-col">
|
||||||
|
<div className="mega-title">型号</div>
|
||||||
|
<div className="mega-list">
|
||||||
|
{productMenu.find(s => s.key === activeSeries)?.subs.map((sub) => (
|
||||||
|
<button
|
||||||
|
key={sub.key}
|
||||||
|
className={`mega-item ${sub.key === activeSub ? 'active' : ''}`}
|
||||||
|
onClick={() => setActiveSub(sub.key)}
|
||||||
|
>
|
||||||
|
{sub.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mega-col mega-wide">
|
||||||
|
<div className="mega-title">介绍</div>
|
||||||
|
<div className="mega-products">
|
||||||
|
{productMenu
|
||||||
|
.find(s => s.key === activeSeries)
|
||||||
|
?.subs.find(sub => sub.key === activeSub)
|
||||||
|
?.products.map((p, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="mega-product"
|
||||||
|
onClick={() => {
|
||||||
|
setProductsOpen(false);
|
||||||
|
navigate(`/product/${activeSeries}/${activeSub}/${idx}`, {
|
||||||
|
state: { product: p, series: activeSeries, sub: activeSub }
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
<img src={p.image} alt={p.name} />
|
||||||
|
<div className="mega-product-info">
|
||||||
|
<div className="name">{p.name}</div>
|
||||||
|
<div className="desc">{p.desc}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{user && (
|
||||||
<div className="nav-item">
|
<div className="nav-item">
|
||||||
<span className="nav-label">解决方案</span>
|
<span className="nav-label" onClick={() => navigate('/dev')}>开发指南</span>
|
||||||
<div className="dropdown">
|
|
||||||
<Link to="/" className="dropdown-item">解决方案A</Link>
|
|
||||||
<Link to="/" className="dropdown-item">解决方案B</Link>
|
|
||||||
<Link to="/" className="dropdown-item">解决方案C</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{user && (
|
||||||
<div className="nav-item">
|
<div className="nav-item">
|
||||||
<span className="nav-label">行业</span>
|
<span className="nav-label" onClick={() => navigate('/training')}>培训资料</span>
|
||||||
<div className="dropdown">
|
|
||||||
<Link to="/" className="dropdown-item">民航</Link>
|
|
||||||
<Link to="/" className="dropdown-item">通航</Link>
|
|
||||||
<Link to="/" className="dropdown-item">研发制造</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="navbar-right">
|
<div className="navbar-right">
|
||||||
|
|||||||
@@ -1,221 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } 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('');
|
|
||||||
|
|
||||||
const fetchAircraftDetail = useCallback(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);
|
|
||||||
}
|
|
||||||
}, [id, logout, navigate]);
|
|
||||||
|
|
||||||
const fetchPdfMaterials = useCallback(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);
|
|
||||||
}
|
|
||||||
}, [id, searchTerm]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAircraftDetail();
|
|
||||||
fetchPdfMaterials();
|
|
||||||
}, [fetchAircraftDetail, fetchPdfMaterials]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) {
|
|
||||||
fetchPdfMaterials();
|
|
||||||
}
|
|
||||||
}, [id, searchTerm, fetchPdfMaterials]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div className="container">
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
|
||||||
<button onClick={() => navigate('/aircraft')} className="btn btn-secondary">← 返回列表</button>
|
|
||||||
<h1 style={{ margin: 0, color: '#333' }}>机型详情</h1>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
<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;
|
|
||||||
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useNavigate, useLocation } 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 location = useLocation();
|
|
||||||
const initialSearch = location.state?.searchTerm || '';
|
|
||||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
|
||||||
const { logout } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const fetchAircrafts = useCallback(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);
|
|
||||||
}
|
|
||||||
}, [searchTerm, logout, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAircrafts();
|
|
||||||
}, [fetchAircrafts]);
|
|
||||||
|
|
||||||
const handleAircraftClick = (id) => {
|
|
||||||
navigate(`/aircraft/${id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="aircraft-list-page">
|
|
||||||
<div className="container">
|
|
||||||
<h1 style={{ margin: '0 0 16px', color: '#333' }}>机型信息管理系统</h1>
|
|
||||||
<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;
|
|
||||||
|
|
||||||
12
src/pages/DevGuide.js
Normal file
12
src/pages/DevGuide.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const DevGuide = () => {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '20px' }}>
|
||||||
|
<h1 style={{ margin: 0, padding: '12px 0', color: '#111' }}>开发指南</h1>
|
||||||
|
<p style={{ color: '#333' }}>API、SDK、示例代码等开发资源的统一入口。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DevGuide;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
--home-carousel-height: 720px;
|
--home-carousel-height: 860px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-page {
|
.home-page {
|
||||||
|
|||||||
@@ -5,26 +5,26 @@ const slidesData = [
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: '行业资讯:新机型发布',
|
title: '行业资讯:新机型发布',
|
||||||
description: '最新机型亮相航空展,性能全面升级',
|
description: '最新机型亮相展览,性能全面升级',
|
||||||
image: 'http://localhost:3001/uploads/home_1.png'
|
image: '/uploads/menu/home_1.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: '解决方案:资料管理优化',
|
title: '解决方案:资料管理优化',
|
||||||
description: '一体化资料管理平台上线,提效30%',
|
description: '一体化资料管理平台上线,提效30%',
|
||||||
image: 'http://localhost:3001/uploads/home_2.jpg'
|
image: '/uploads/menu/home_2.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 3,
|
id: 3,
|
||||||
title: '产品更新:PDF在线预览',
|
title: '产品更新:PDF在线预览',
|
||||||
description: '新增在线预览与多端适配功能',
|
description: '新增在线预览与多端适配功能',
|
||||||
image: 'http://localhost:3001/uploads/home_3.jpg'
|
image: '/uploads/menu/home_3.jpg'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 4,
|
id: 4,
|
||||||
title: '客户案例:数字化转型',
|
title: '客户案例:数字化转型',
|
||||||
description: '多行业落地实践,推动数据驱动决策',
|
description: '多行业落地实践,推动数据驱动决策',
|
||||||
image: 'http://localhost:3001/uploads/home_4.jpg'
|
image: '/uploads/menu/home_4.jpg'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const Home = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setActive((prev) => (prev + 1) % slidesData.length);
|
setActive((prev) => (prev + 1) % slidesData.length);
|
||||||
}, 4000);
|
}, 5000);
|
||||||
return () => clearInterval(timer);
|
return () => clearInterval(timer);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const Login = () => {
|
|||||||
const result = await login(username, password);
|
const result = await login(username, password);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
navigate('/aircraft');
|
navigate('/');
|
||||||
} else {
|
} else {
|
||||||
setError(result.error);
|
setError(result.error);
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@ const Login = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-card">
|
<div className="login-card">
|
||||||
<h1 className="login-title">机型信息管理系统</h1>
|
<h1 className="login-title">通信技术部</h1>
|
||||||
<form onSubmit={handleSubmit} className="login-form">
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="username">用户名</label>
|
<label htmlFor="username">用户名</label>
|
||||||
@@ -66,7 +66,7 @@ const Login = () => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<div className="login-hint">
|
<div className="login-hint">
|
||||||
<p>默认账号:admin / admin123</p>
|
<p>测试账号:admin / admin123</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
74
src/pages/ProductDetail.css
Normal file
74
src/pages/ProductDetail.css
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
.product-detail-page { min-height: 100vh; background: #f5f5f5; }
|
||||||
|
.product-detail-page .container { max-width: 100%; padding: 0; }
|
||||||
|
.section-narrow { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||||
|
.carousel-full { position: relative; background: #000; overflow: hidden; width: 100%; min-height: 560px; border-radius: 0; }
|
||||||
|
.slides { display: grid; grid-template-columns: 100%; transform: translateX(0); }
|
||||||
|
.slide { opacity: 0; transition: opacity 0.4s ease; display: none; }
|
||||||
|
.slide.active { opacity: 1; display: block; }
|
||||||
|
.slide-img { width: 100%; height: 720px; object-fit: cover; display: block; }
|
||||||
|
.arrow { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.85); border: 1px solid #e8e8e8; border-radius: 50%; width: 36px; height: 36px; cursor: pointer; }
|
||||||
|
.arrow.left { left: 10px; }
|
||||||
|
.arrow.right { right: 10px; }
|
||||||
|
.indicators { display: flex; justify-content: center; gap: 8px; margin-top: 12px; }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 50%; background: #d9d9d9; cursor: pointer; }
|
||||||
|
.dot.active { background: #1890ff; }
|
||||||
|
.product-title { margin: 0; color: #333; font-size: 22px; font-weight: 600; }
|
||||||
|
.product-desc { color: #666; }
|
||||||
|
.specs-section { margin-top: 24px; }
|
||||||
|
.specs-body { margin-top: 0; background: #fff; border: none; border-radius: 0; padding: 0; }
|
||||||
|
.specs-group { margin: 0; }
|
||||||
|
.specs-group-header { width: 100%; text-align: left; padding: 18px 0; border: none; background: transparent; cursor: pointer; font-weight: 700; color: #111; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e8e8e8; }
|
||||||
|
.specs-summary { padding: 12px 0; border-bottom: 1px solid #e8e8e8; }
|
||||||
|
.specs-table { width: 100%; border-collapse: collapse; border: none; background: #fff; }
|
||||||
|
.specs-table tr { border-bottom: 1px solid #e8e8e8; }
|
||||||
|
.specs-table tr:last-child { border-bottom: none; }
|
||||||
|
.specs-table td { padding: 12px 14px; border: none; }
|
||||||
|
.specs-key { width: 220px; color: #666; text-align: left; }
|
||||||
|
.specs-val { color: #1890ff; text-align: center; }
|
||||||
|
.error-state { text-align: center; padding: 60px 20px; color: #999; }
|
||||||
|
.docs-section { margin-top: 16px; }
|
||||||
|
.docs-list { list-style: none; padding: 0 8px 12px; margin: 0; }
|
||||||
|
.docs-item { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
|
||||||
|
.docs-link { color: #1890ff; text-decoration: none; }
|
||||||
|
.docs-link:hover { text-decoration: underline; }
|
||||||
|
.docs-size { color: #999; font-size: 12px; margin-left: auto; }
|
||||||
|
.docs-download { color: #1890ff; text-decoration: none; }
|
||||||
|
|
||||||
|
.section-tag { display: inline-block; padding: 6px 10px; border-radius: 4px; font-size: 13px; font-weight: 600; margin: 12px 14px; }
|
||||||
|
.tag-images { background: #e6f7ff; color: #1890ff; }
|
||||||
|
.tag-specs { background: #f6ffed; color: #52c41a; }
|
||||||
|
.tag-docs { background: #fff7e6; color: #fa8c16; }
|
||||||
|
.carousel-full .section-tag { position: absolute; top: 12px; left: 12px; margin: 0; z-index: 2; }
|
||||||
|
.docs-item:hover { background: #fafafa; }
|
||||||
|
|
||||||
|
.docs-body { background: #f5f5f5; border: none; border-radius: 0; padding: 8px 8px; }
|
||||||
|
|
||||||
|
.pd-title { text-align: center; font-size: 24px; font-weight: 700; color: #111; padding: 16px 0; }
|
||||||
|
.pd-panel { margin: 12px 0; }
|
||||||
|
.pd-panel { border: none; border-radius: 0; background: #fff; }
|
||||||
|
.pd-panel-header { width: 100%; text-align: left; padding: 12px 0; background: transparent; cursor: pointer; font-weight: 700; color: #111; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #e8e8e8; }
|
||||||
|
.pd-panel-body { background: #fff; padding: 12px 0; }
|
||||||
|
.pd-panel.expanded { border: 1px dashed #bfbfbf; border-radius: 6px; }
|
||||||
|
.pd-panel.expanded .pd-panel-header { padding: 12px 14px; border-bottom: none; }
|
||||||
|
.image-row { display: flex; justify-content: center; align-items: center; gap: 40px; padding: 12px; background: #f5f5f5; }
|
||||||
|
.image-row img { height: 420px; width: auto; object-fit: contain; display: block; }
|
||||||
|
.size-carousel { position: relative; background: #f5f5f5; overflow: hidden; width: 100%; min-height: 420px; border-radius: 0; }
|
||||||
|
.size-slides { display: grid; grid-template-columns: 100%; }
|
||||||
|
.size-slide { opacity: 0; transition: opacity 0.3s ease; display: none; }
|
||||||
|
.size-slide.active { opacity: 1; display: block; }
|
||||||
|
.size-slide-img { width: 100%; height: 420px; object-fit: contain; display: block; }
|
||||||
|
.size-arrow { position: absolute; top: 50%; transform: translateY(-50%); background: rgba(255,255,255,0.85); border: 1px solid #e8e8e8; border-radius: 50%; width: 32px; height: 32px; cursor: pointer; }
|
||||||
|
.size-arrow.left { left: 8px; }
|
||||||
|
.size-arrow.right { right: 8px; }
|
||||||
|
.size-indicators { display: flex; justify-content: center; gap: 8px; margin-top: 8px; }
|
||||||
|
.size-dot { width: 8px; height: 8px; border-radius: 50%; background: #d9d9d9; cursor: pointer; }
|
||||||
|
.size-dot.active { background: #1890ff; }
|
||||||
|
.device-figure { position: relative; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.device-title { position: absolute; top: -26px; left: 50%; transform: translateX(-50%); font-weight: 700; color: #111; }
|
||||||
|
.diag-line { position: absolute; left: 10%; top: 10%; width: 80%; height: 0; border-top: 2px solid rgba(255,255,255,0.9); transform: rotate(-35deg); }
|
||||||
|
.diag-label { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); color: #fff; font-weight: 700; text-shadow: 0 1px 2px rgba(0,0,0,0.6); }
|
||||||
|
.device-figure.stylus .diag-line, .device-figure.stylus .diag-label, .device-figure.stylus .device-title { display: none; }
|
||||||
|
.pd-list { margin-top: 24px; }
|
||||||
|
.pd-list-item { width: 100%; text-align: left; background: transparent; border: none; border-bottom: 1px solid #e8e8e8; padding: 18px 0; font-weight: 700; color: #111; display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.pd-list-body { padding: 12px 0; }
|
||||||
|
.specs-sublist { padding-left: 28px; }
|
||||||
315
src/pages/ProductDetail.js
Normal file
315
src/pages/ProductDetail.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useParams, useLocation } from 'react-router-dom';
|
||||||
|
import axios from 'axios';
|
||||||
|
import './ProductDetail.css';
|
||||||
|
|
||||||
|
const ProductDetail = () => {
|
||||||
|
const { series, sub, idx } = useParams();
|
||||||
|
const location = useLocation();
|
||||||
|
const product = location.state?.product;
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const [specs, setSpecs] = useState([]);
|
||||||
|
const [sections, setSections] = useState([]);
|
||||||
|
const [expanded, setExpanded] = useState({});
|
||||||
|
const [specsLoading, setSpecsLoading] = useState(false);
|
||||||
|
const [imgOpen, setImgOpen] = useState(true);
|
||||||
|
const [highlightsOpen, setHighlightsOpen] = useState(false);
|
||||||
|
const [compareOpen, setCompareOpen] = useState(false);
|
||||||
|
const [promoOpen, setPromoOpen] = useState(false);
|
||||||
|
const [faqOpen, setFaqOpen] = useState(false);
|
||||||
|
const [reviewOpen, setReviewOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const srcs = product
|
||||||
|
? (Array.isArray(product.images) && product.images.length
|
||||||
|
? product.images
|
||||||
|
: (product.image ? [product.image] : []))
|
||||||
|
: [];
|
||||||
|
setImages(srcs);
|
||||||
|
setActive(0);
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadImages = async () => {
|
||||||
|
try {
|
||||||
|
const qs = new URLSearchParams({ series, sub }).toString();
|
||||||
|
const res = await fetch(`/api/docs/images?${qs}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && Array.isArray(data.data) && data.data.length) {
|
||||||
|
setImages(data.data.map((d) => `${d.url}`));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
loadImages();
|
||||||
|
}, [series, sub]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSpecs = async () => {
|
||||||
|
try {
|
||||||
|
setSpecsLoading(true);
|
||||||
|
const qs = new URLSearchParams({ series, sub }).toString();
|
||||||
|
const res = await fetch(`/api/docs/specs?${qs}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && Array.isArray(data.data)) {
|
||||||
|
if (data.data.length && typeof data.data[0]?.title === 'string' && Array.isArray(data.data[0]?.rows)) {
|
||||||
|
setSections(data.data);
|
||||||
|
setSpecs([]);
|
||||||
|
setExpanded(Object.fromEntries(data.data.map((_, i) => [i, false])));
|
||||||
|
} else {
|
||||||
|
setSections([{ title: '参数', rows: data.data }]);
|
||||||
|
setSpecs(data.data);
|
||||||
|
setExpanded({ 0: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
finally {
|
||||||
|
setSpecsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSpecs();
|
||||||
|
}, [series, sub]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (images.length <= 1) return;
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setActive((prev) => (prev + 1) % images.length);
|
||||||
|
}, 5000);
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [images.length]);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<div className="product-detail-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="error-state">未找到产品信息</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const prevSlide = () => setActive((prev) => (prev - 1 + images.length) % images.length);
|
||||||
|
const nextSlide = () => setActive((prev) => (prev + 1) % images.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="product-detail-page">
|
||||||
|
<div className="container">
|
||||||
|
<div className="pd-title">{product.name}</div>
|
||||||
|
<div className="section-narrow">
|
||||||
|
<div className="pd-list">
|
||||||
|
<button className="pd-list-item" onClick={() => setImgOpen(!imgOpen)}>
|
||||||
|
<span>产品图片</span>
|
||||||
|
<span>{imgOpen ? 'v' : '›'}</span>
|
||||||
|
</button>
|
||||||
|
{imgOpen && (
|
||||||
|
<div className="pd-list-body">
|
||||||
|
{images.length > 1 ? (
|
||||||
|
<div className="size-carousel">
|
||||||
|
<div className="size-slides">
|
||||||
|
{images.map((src, idx) => (
|
||||||
|
<div key={idx} className={`size-slide ${idx === active ? 'active' : ''}`}>
|
||||||
|
<img className="size-slide-img" src={src} alt={`${product.name}-${idx+1}`} loading="lazy" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="size-arrow left" onClick={prevSlide}>‹</button>
|
||||||
|
<button className="size-arrow right" onClick={nextSlide}>›</button>
|
||||||
|
<div className="size-indicators">
|
||||||
|
{images.map((_, idx) => (
|
||||||
|
<span
|
||||||
|
key={idx}
|
||||||
|
className={`size-dot ${idx === active ? 'active' : ''}`}
|
||||||
|
onClick={() => setActive(idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="image-row">
|
||||||
|
{(images.length ? images : (product?.image ? [product.image] : [])).map((src, idx) => (
|
||||||
|
<img key={idx} src={src} alt={`${product.name}-${idx+1}`} loading="lazy" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button className="pd-list-item" onClick={() => setHighlightsOpen(!highlightsOpen)}>
|
||||||
|
<span>通信功能</span>
|
||||||
|
<span>{highlightsOpen ? 'v' : '›'}</span>
|
||||||
|
</button>
|
||||||
|
{highlightsOpen && <div className="pd-list-body"></div>}
|
||||||
|
<button className="pd-list-item" onClick={() => setCompareOpen(!compareOpen)}>
|
||||||
|
<span>详细参数</span>
|
||||||
|
<span>{compareOpen ? 'v' : '›'}</span>
|
||||||
|
</button>
|
||||||
|
{compareOpen && (
|
||||||
|
<div className="pd-list-body">
|
||||||
|
<div className="specs-sublist">
|
||||||
|
{specsLoading ? (
|
||||||
|
<div className="specs-body"><table className="specs-table"><tbody><tr><td colSpan="2" className="specs-val">加载中...</td></tr></tbody></table></div>
|
||||||
|
) : sections.length ? (
|
||||||
|
sections.map((sec, i) => {
|
||||||
|
const isOpen = !!expanded[i];
|
||||||
|
const toggle = () => setExpanded((prev) => ({ ...prev, [i]: !prev[i] }));
|
||||||
|
return (
|
||||||
|
<div key={sec.title + i} className="specs-group">
|
||||||
|
<button className="specs-group-header" onClick={toggle}>
|
||||||
|
{sec.title} {isOpen ? 'v' : '›'}
|
||||||
|
</button>
|
||||||
|
{isOpen && (
|
||||||
|
<div className="specs-body">
|
||||||
|
<table className="specs-table">
|
||||||
|
<tbody>
|
||||||
|
{sec.rows.length ? (
|
||||||
|
sec.rows.map((row) => {
|
||||||
|
const k = String(row.key || '');
|
||||||
|
const v = String(row.value || '');
|
||||||
|
const kb = /^\*\*.*\*\*$/.test(k) || k.startsWith('!');
|
||||||
|
const vb = /^\*\*.*\*\*$/.test(v) || v.startsWith('!');
|
||||||
|
const bold = kb || vb;
|
||||||
|
const cleanKey = k.replace(/^!/, '').replace(/^\*\*/, '').replace(/\*\*$/, '');
|
||||||
|
const cleanVal = v.replace(/^!/, '').replace(/^\*\*/, '').replace(/\*\*$/, '');
|
||||||
|
return (
|
||||||
|
<tr key={row.key} className={bold ? 'specs-row-bold' : ''}>
|
||||||
|
<td className={`specs-key ${bold ? 'specs-bold' : ''}`}>{cleanKey}</td>
|
||||||
|
<td className={`specs-val ${bold ? 'specs-bold' : ''}`}>{cleanVal}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr><td className="specs-val" colSpan="2">暂无参数</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="specs-body"><table className="specs-table"><tbody><tr><td colSpan="2" className="specs-val">暂无参数</td></tr></tbody></table></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* <button className="pd-list-item" onClick={() => setPromoOpen(!promoOpen)}>
|
||||||
|
<span>活动说明</span>
|
||||||
|
<span>{promoOpen ? 'v' : '›'}</span>
|
||||||
|
</button> */}
|
||||||
|
{promoOpen && <div className="pd-list-body"></div>}
|
||||||
|
<button className="pd-list-item" onClick={() => setFaqOpen(!faqOpen)}>
|
||||||
|
<span>常见问题</span>
|
||||||
|
<span>{faqOpen ? 'v' : '›'}</span>
|
||||||
|
</button>
|
||||||
|
{faqOpen && <div className="pd-list-body"></div>}
|
||||||
|
<button className="pd-list-item" onClick={() => setReviewOpen(!reviewOpen)}>
|
||||||
|
<span>资料下载</span>
|
||||||
|
<span>{reviewOpen ? 'v' : '›'}</span>
|
||||||
|
</button>
|
||||||
|
{reviewOpen && (
|
||||||
|
<div className="pd-list-body">
|
||||||
|
<div className="docs-body">
|
||||||
|
<DocsList series={series} sub={sub} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DocsList = ({ series, sub }) => {
|
||||||
|
const [docs, setDocs] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleDownload = async (d) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(d.url, { responseType: 'blob' });
|
||||||
|
const blob = new Blob([res.data], { type: res.data.type || 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = d.name || (String(d.url).split('/').pop() || '下载文件');
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError('下载失败或文件不存在');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpen = async (d) => {
|
||||||
|
try {
|
||||||
|
const res = await axios.get(d.url, { responseType: 'blob' });
|
||||||
|
const blob = new Blob([res.data], { type: 'application/pdf' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
window.open(url, '_blank');
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
|
} catch (e) {
|
||||||
|
window.open(d.url, '_blank');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const fetchDocs = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
const qs = new URLSearchParams({ series, sub }).toString();
|
||||||
|
const res = await fetch(`/api/docs/list?${qs}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) {
|
||||||
|
setDocs(data.data || []);
|
||||||
|
} else {
|
||||||
|
setError('获取资料失败');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setError('网络错误');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchDocs();
|
||||||
|
}, [series, sub]);
|
||||||
|
|
||||||
|
if (loading) return <div className="docs-loading">加载中...</div>;
|
||||||
|
if (error) return <div className="docs-error">{error}</div>;
|
||||||
|
|
||||||
|
if (!docs.length) return <div className="docs-empty">暂无资料</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="docs-list">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<li key={d.url} className="docs-item">
|
||||||
|
{String(d.url || '').toLowerCase().endsWith('.pdf') ? (
|
||||||
|
<a
|
||||||
|
href={`${d.url}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="docs-link"
|
||||||
|
onClick={(e) => { e.preventDefault(); handleOpen(d); }}
|
||||||
|
>
|
||||||
|
{d.name}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a href={`${d.url}`} target="_blank" rel="noreferrer" className="docs-link">
|
||||||
|
{d.name}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<span className="docs-size">{(d.size / 1024 / 1024).toFixed(2)}MB</span>
|
||||||
|
<button type="button" className="docs-download" onClick={() => handleDownload(d)}>下载</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ProductDetail;
|
||||||
12
src/pages/Training.js
Normal file
12
src/pages/Training.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Training = () => {
|
||||||
|
return (
|
||||||
|
<div style={{ maxWidth: 1200, margin: '0 auto', padding: '20px' }}>
|
||||||
|
<h1 style={{ margin: 0, padding: '12px 0', color: '#111' }}>培训资料</h1>
|
||||||
|
<p style={{ color: '#333' }}>课程、教材、视频等培训资源的统一入口。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Training;
|
||||||
Reference in New Issue
Block a user