new branch
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -10,6 +10,7 @@ from app.api.v1 import (
|
|||||||
alerts,
|
alerts,
|
||||||
settings,
|
settings,
|
||||||
collected_data,
|
collected_data,
|
||||||
|
visualization,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -25,3 +26,4 @@ api_router.include_router(tasks.router, prefix="/tasks", tags=["tasks"])
|
|||||||
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
api_router.include_router(dashboard.router, prefix="/dashboard", tags=["dashboard"])
|
||||||
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
api_router.include_router(alerts.router, prefix="/alerts", tags=["alerts"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
|
api_router.include_router(visualization.router, prefix="/visualization", tags=["visualization"])
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
81
backend/app/api/v1/visualization.py
Normal file
81
backend/app/api/v1/visualization.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Visualization API - GeoJSON endpoints for 3D Earth display"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CABLE_DATA_URL = "https://services.arcgis.com/6DIQcwlPy8knb6sg/arcgis/rest/services/SubmarineCables/FeatureServer/2/query?where=1%3D1&outFields=*&returnGeometry=true&f=geojson"
|
||||||
|
LANDING_POINT_CSV_URL = "https://data.apps.fao.org/catalog/dataset/1b75ff21-92f2-4b96-9b7b-98e8aa65ad5d/resource/b6071077-d1d4-4e97-aa00-42e902847c87/download/landing-point-geo.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geo/cables")
|
||||||
|
async def get_cables_geojson():
|
||||||
|
"""获取海底电缆 GeoJSON 数据 (LineString)"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.get(CABLE_DATA_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Failed to fetch cable data: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geo/landing-points")
|
||||||
|
async def get_landing_points_geojson():
|
||||||
|
"""获取登陆点 GeoJSON 数据 (Point)"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||||
|
response = await client.get(LANDING_POINT_CSV_URL)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
lines = response.text.strip().split("\n")
|
||||||
|
if not lines:
|
||||||
|
raise HTTPException(status_code=500, detail="Empty CSV data")
|
||||||
|
|
||||||
|
features = []
|
||||||
|
for line in lines[1:]:
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
parts = line.split(",")
|
||||||
|
if len(parts) >= 4:
|
||||||
|
try:
|
||||||
|
lon = float(parts[0])
|
||||||
|
lat = float(parts[1])
|
||||||
|
feature_id = parts[2]
|
||||||
|
name = parts[3].strip('"')
|
||||||
|
is_tbd = parts[4].strip() == "true" if len(parts) > 4 else False
|
||||||
|
|
||||||
|
features.append(
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {"type": "Point", "coordinates": [lon, lat]},
|
||||||
|
"properties": {"id": feature_id, "name": name, "is_tbd": is_tbd},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
continue
|
||||||
|
|
||||||
|
return {"type": "FeatureCollection", "features": features}
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
raise HTTPException(status_code=502, detail=f"Failed to fetch landing point data: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Internal error: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/geo/all")
|
||||||
|
async def get_all_geojson():
|
||||||
|
"""获取所有可视化数据 (电缆 + 登陆点)"""
|
||||||
|
cables = await get_cables_geojson()
|
||||||
|
points = await get_landing_points_geojson()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cables": cables,
|
||||||
|
"landing_points": points,
|
||||||
|
"stats": {
|
||||||
|
"cable_count": len(cables.get("features", [])) if cables else 0,
|
||||||
|
"landing_point_count": len(points.get("features", [])) if points else 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -19,7 +19,7 @@ class Settings(BaseSettings):
|
|||||||
POSTGRES_USER: str = "postgres"
|
POSTGRES_USER: str = "postgres"
|
||||||
POSTGRES_PASSWORD: str = "postgres"
|
POSTGRES_PASSWORD: str = "postgres"
|
||||||
POSTGRES_DB: str = "planet_db"
|
POSTGRES_DB: str = "planet_db"
|
||||||
DATABASE_URL: str = f"postgresql+asyncpg://postgres:postgres@postgres:5432/planet_db"
|
DATABASE_URL: str = f"postgresql+asyncpg://postgres:postgres@localhost:5432/planet_db"
|
||||||
|
|
||||||
REDIS_SERVER: str = "localhost"
|
REDIS_SERVER: str = "localhost"
|
||||||
REDIS_PORT: int = 6379
|
REDIS_PORT: int = 6379
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
39
backend/scripts/create_admin.py
Normal file
39
backend/scripts/create_admin.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, ".")
|
||||||
|
|
||||||
|
from app.db.session import async_session_factory
|
||||||
|
from app.models.user import User
|
||||||
|
from app.core.security import get_password_hash
|
||||||
|
|
||||||
|
|
||||||
|
async def create_admin():
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
# 检查用户是否已存在
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await session.execute(select(User).where(User.username == "linkong"))
|
||||||
|
existing_user = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_user:
|
||||||
|
print(f"用户 linkong 已存在,更新密码...")
|
||||||
|
existing_user.set_password("LK12345678")
|
||||||
|
existing_user.role = "super_admin"
|
||||||
|
else:
|
||||||
|
print("创建管理员用户...")
|
||||||
|
user = User(
|
||||||
|
username="linkong",
|
||||||
|
email="linkong@example.com",
|
||||||
|
password_hash=get_password_hash("LK12345678"),
|
||||||
|
role="super_admin",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
print("完成!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(create_admin())
|
||||||
1912
earth/3dearthmult.html
Normal file
1912
earth/3dearthmult.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
earth/8k_earth_daymap.jpg
Normal file
BIN
earth/8k_earth_daymap.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
BIN
earth/gebco_08_rev_elev_21600x10800.png
Normal file
BIN
earth/gebco_08_rev_elev_21600x10800.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 MiB |
1
earth/geo.json
Normal file
1
earth/geo.json
Normal file
File diff suppressed because one or more lines are too long
1
earth/landing-point-geo.geojson
Normal file
1
earth/landing-point-geo.geojson
Normal file
File diff suppressed because one or more lines are too long
64
frontend/package-lock.json
generated
64
frontend/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-resizable": "^3.1.3",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
@@ -1653,6 +1654,15 @@
|
|||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -2145,6 +2155,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/object-assign": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -2181,6 +2200,23 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prop-types": {
|
||||||
|
"version": "15.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
|
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"loose-envify": "^1.4.0",
|
||||||
|
"object-assign": "^4.1.1",
|
||||||
|
"react-is": "^16.13.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/prop-types/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@@ -2818,6 +2854,20 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-draggable": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-VC+HBLEZ0XJxnOxVAZsdRi8rD04Iz3SiiKOoYzamjylUcju/hP9np/aZdLHf/7WOD268WMoNJMvYfB5yAK45cw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"prop-types": "^15.8.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3.0",
|
||||||
|
"react-dom": ">= 16.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -2834,6 +2884,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-resizable": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-resizable/-/react-resizable-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-liJBNayhX7qA4tBJiBD321FDhJxgGTJ07uzH5zSORXoE8h7PyEZ8mLqmosST7ppf6C4zUsbd2gzDMmBCfFp9Lw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prop-types": "15.x",
|
||||||
|
"react-draggable": "^4.5.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.3",
|
||||||
|
"react-dom": ">= 16.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "6.30.3",
|
"version": "6.30.3",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ant-design/icons": "^5.2.6",
|
||||||
|
"antd": "^5.12.5",
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-resizable": "^3.1.3",
|
||||||
"react-router-dom": "^6.21.0",
|
"react-router-dom": "^6.21.0",
|
||||||
"axios": "^1.6.2",
|
|
||||||
"antd": "^5.12.5",
|
|
||||||
"@ant-design/icons": "^5.2.6",
|
|
||||||
"dayjs": "^1.11.10",
|
|
||||||
"socket.io-client": "^4.7.2",
|
"socket.io-client": "^4.7.2",
|
||||||
"zustand": "^4.4.7"
|
"zustand": "^4.4.7"
|
||||||
},
|
},
|
||||||
|
|||||||
1911
frontend/public/earth/3dearthmult.html
Normal file
1911
frontend/public/earth/3dearthmult.html
Normal file
File diff suppressed because it is too large
Load Diff
BIN
frontend/public/earth/8k_earth_daymap.jpg
Normal file
BIN
frontend/public/earth/8k_earth_daymap.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.4 MiB |
BIN
frontend/public/earth/gebco_08_rev_elev_21600x10800.png
Normal file
BIN
frontend/public/earth/gebco_08_rev_elev_21600x10800.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 MiB |
1
frontend/public/earth/geo.json
Normal file
1
frontend/public/earth/geo.json
Normal file
File diff suppressed because one or more lines are too long
1
frontend/public/earth/landing-point-geo.geojson
Normal file
1
frontend/public/earth/landing-point-geo.geojson
Normal file
File diff suppressed because one or more lines are too long
@@ -5,17 +5,21 @@ import Dashboard from './pages/Dashboard/Dashboard'
|
|||||||
import Users from './pages/Users/Users'
|
import Users from './pages/Users/Users'
|
||||||
import DataSources from './pages/DataSources/DataSources'
|
import DataSources from './pages/DataSources/DataSources'
|
||||||
import DataList from './pages/DataList/DataList'
|
import DataList from './pages/DataList/DataList'
|
||||||
|
import Earth from './pages/Earth/Earth'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { token } = useAuthStore()
|
const { token } = useAuthStore()
|
||||||
|
const isEarthPage = window.location.pathname === '/earth'
|
||||||
|
|
||||||
if (!token) {
|
if (!token && !isEarthPage) {
|
||||||
return <Login />
|
return <Login />
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/admin" element={<Dashboard />} />
|
||||||
|
<Route path="/earth" element={<Earth />} />
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
<Route path="/datasources" element={<DataSources />} />
|
<Route path="/datasources" element={<DataSources />} />
|
||||||
<Route path="/data" element={<DataList />} />
|
<Route path="/data" element={<DataList />} />
|
||||||
|
|||||||
99
frontend/src/components/AppLayout/AppLayout.tsx
Normal file
99
frontend/src/components/AppLayout/AppLayout.tsx
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import { Layout, Menu, Typography, Button } from 'antd'
|
||||||
|
import {
|
||||||
|
DashboardOutlined,
|
||||||
|
DatabaseOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
} from '@ant-design/icons'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
|
const { Header, Sider, Content } = Layout
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface AppLayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function AppLayout({ children }: AppLayoutProps) {
|
||||||
|
const location = useLocation()
|
||||||
|
const { user, logout } = useAuthStore()
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/', icon: <DashboardOutlined />, label: <Link to="/">仪表盘</Link> },
|
||||||
|
{ key: '/datasources', icon: <DatabaseOutlined />, label: <Link to="/datasources">数据源</Link> },
|
||||||
|
{ key: '/data', icon: <BarChartOutlined />, label: <Link to="/data">采集数据</Link> },
|
||||||
|
{ key: '/users', icon: <UserOutlined />, label: <Link to="/users">用户管理</Link> },
|
||||||
|
{ key: '/settings', icon: <SettingOutlined />, label: <Link to="/settings">系统配置</Link> },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout className="dashboard-layout">
|
||||||
|
<Sider
|
||||||
|
width={240}
|
||||||
|
collapsible
|
||||||
|
collapsed={false}
|
||||||
|
onCollapse={(collapsed) => {
|
||||||
|
const sider = document.querySelector('.dashboard-sider') as HTMLElement
|
||||||
|
if (sider) {
|
||||||
|
sider.style.width = collapsed ? '80px' : '240px'
|
||||||
|
sider.style.minWidth = collapsed ? '80px' : '240px'
|
||||||
|
sider.style.maxWidth = collapsed ? '80px' : '240px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="dashboard-sider"
|
||||||
|
trigger={null}
|
||||||
|
breakpoint="lg"
|
||||||
|
onBreakpoint={(broken) => {
|
||||||
|
const sider = document.querySelector('.dashboard-sider') as HTMLElement
|
||||||
|
if (sider) {
|
||||||
|
sider.style.width = broken ? '80px' : '240px'
|
||||||
|
sider.style.minWidth = broken ? '80px' : '240px'
|
||||||
|
sider.style.maxWidth = broken ? '80px' : '240px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<Text strong style={{ color: 'white', fontSize: 18 }}>智能星球</Text>
|
||||||
|
</div>
|
||||||
|
<Menu
|
||||||
|
theme="dark"
|
||||||
|
mode="inline"
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
items={menuItems}
|
||||||
|
/>
|
||||||
|
</Sider>
|
||||||
|
<Layout>
|
||||||
|
<Header className="dashboard-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '0 16px' }}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<MenuUnfoldOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const sider = document.querySelector('.ant-layout-sider') as HTMLElement
|
||||||
|
if (sider) {
|
||||||
|
const currentWidth = sider.style.width || '240px'
|
||||||
|
const isCollapsed = currentWidth === '80px'
|
||||||
|
sider.style.width = isCollapsed ? '240px' : '80px'
|
||||||
|
sider.style.minWidth = isCollapsed ? '240px' : '80px'
|
||||||
|
sider.style.maxWidth = isCollapsed ? '240px' : '80px'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{ fontSize: 16 }}
|
||||||
|
/>
|
||||||
|
<Text strong>欢迎, {user?.username}</Text>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
|
<Button type="link" danger onClick={logout}>退出登录</Button>
|
||||||
|
</div>
|
||||||
|
</Header>
|
||||||
|
<Content className="dashboard-content" style={{ padding: 24, minHeight: '100%', overflow: 'auto' }}>
|
||||||
|
{children}
|
||||||
|
</Content>
|
||||||
|
</Layout>
|
||||||
|
</Layout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLayout
|
||||||
@@ -77,3 +77,42 @@ body {
|
|||||||
.stat-card .trend.down {
|
.stat-card .trend.down {
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Table column resize */
|
||||||
|
.ant-table-wrapper .ant-table-thead > tr > th {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 6px;
|
||||||
|
cursor: col-resize;
|
||||||
|
background: transparent;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle::before {
|
||||||
|
content: '';
|
||||||
|
width: 2px;
|
||||||
|
height: 20px;
|
||||||
|
background: #d9d9d9;
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resize-handle:hover::before {
|
||||||
|
background: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table cell fixed width */
|
||||||
|
.ant-table-wrapper .ant-table-tbody > tr > td {
|
||||||
|
max-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
|
import { Table, Tag, Card, Row, Col, Statistic, Button, Modal, Space, Descriptions } from 'antd'
|
||||||
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
import { AlertOutlined, InfoCircleOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
|
||||||
interface Alert {
|
interface Alert {
|
||||||
id: number
|
id: number
|
||||||
@@ -140,7 +141,7 @@ function Alerts() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppLayout>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -173,7 +174,7 @@ function Alerts() {
|
|||||||
title="告警列表"
|
title="告警列表"
|
||||||
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
extra={<Button icon={<ReloadOutlined />} onClick={fetchAlerts}>刷新</Button>}
|
||||||
>
|
>
|
||||||
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} />
|
<Table columns={columns} dataSource={alerts} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -212,7 +213,7 @@ function Alerts() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import {
|
import {
|
||||||
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
|
Table, Tag, Space, Card, Row, Col, Select, Input, Button,
|
||||||
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
|
Statistic, Modal, Descriptions, Spin, Empty, Tooltip
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
import {
|
import {
|
||||||
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
DatabaseOutlined, GlobalOutlined, CloudServerOutlined,
|
||||||
AppstoreOutlined, EyeOutlined, SearchOutlined
|
AppstoreOutlined, EyeOutlined, SearchOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
|
||||||
interface CollectedData {
|
interface CollectedData {
|
||||||
id: number
|
id: number
|
||||||
@@ -153,17 +155,77 @@ function DataList() {
|
|||||||
return colors[type] || 'default'
|
return colors[type] || 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
const columns = [
|
const [columnsWidth, setColumnsWidth] = useState<Record<string, number>>({
|
||||||
|
id: 60,
|
||||||
|
name: 300,
|
||||||
|
source: 150,
|
||||||
|
data_type: 100,
|
||||||
|
country: 100,
|
||||||
|
value: 100,
|
||||||
|
collected_at: 160,
|
||||||
|
action: 80,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resizeRef = useRef<{ startX: number; startWidth: number; key: string } | null>(null)
|
||||||
|
|
||||||
|
const handleResizeStart = (key: string) => (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
resizeRef.current = {
|
||||||
|
startX: e.clientX,
|
||||||
|
startWidth: columnsWidth[key],
|
||||||
|
key,
|
||||||
|
}
|
||||||
|
document.addEventListener('mousemove', handleResizeMove)
|
||||||
|
document.addEventListener('mouseup', handleResizeEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeMove = (e: MouseEvent) => {
|
||||||
|
if (!resizeRef.current) return
|
||||||
|
const diff = e.clientX - resizeRef.current.startX
|
||||||
|
const newWidth = Math.max(50, resizeRef.current.startWidth + diff)
|
||||||
|
setColumnsWidth((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[resizeRef.current!.key]: newWidth,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeEnd = () => {
|
||||||
|
resizeRef.current = null
|
||||||
|
document.removeEventListener('mousemove', handleResizeMove)
|
||||||
|
document.removeEventListener('mouseup', handleResizeEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<CollectedData> = [
|
||||||
{
|
{
|
||||||
title: 'ID',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>ID</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('id')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'id',
|
dataIndex: 'id',
|
||||||
key: 'id',
|
key: 'id',
|
||||||
width: 80,
|
width: columnsWidth.id,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '名称',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>名称</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('name')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
key: 'name',
|
key: 'name',
|
||||||
|
width: columnsWidth.name,
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (name: string, record: CollectedData) => (
|
render: (name: string, record: CollectedData) => (
|
||||||
<Tooltip title={name}>
|
<Tooltip title={name}>
|
||||||
@@ -174,49 +236,95 @@ function DataList() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数据源',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>数据源</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('source')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'source',
|
dataIndex: 'source',
|
||||||
key: 'source',
|
key: 'source',
|
||||||
width: 150,
|
width: columnsWidth.source,
|
||||||
render: (source: string) => (
|
render: (source: string) => (
|
||||||
<Tag icon={getSourceIcon(source)}>{source}</Tag>
|
<Tag icon={getSourceIcon(source)}>{source}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '类型',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>类型</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('data_type')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'data_type',
|
dataIndex: 'data_type',
|
||||||
key: 'data_type',
|
key: 'data_type',
|
||||||
width: 120,
|
width: columnsWidth.data_type,
|
||||||
render: (type: string) => (
|
render: (type: string) => (
|
||||||
<Tag color={getTypeColor(type)}>{type}</Tag>
|
<Tag color={getTypeColor(type)}>{type}</Tag>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '国家/地区',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>国家/地区</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('country')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'country',
|
dataIndex: 'country',
|
||||||
key: 'country',
|
key: 'country',
|
||||||
width: 120,
|
width: columnsWidth.country,
|
||||||
|
ellipsis: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '数值',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>数值</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('value')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'value',
|
dataIndex: 'value',
|
||||||
key: 'value',
|
key: 'value',
|
||||||
width: 120,
|
width: columnsWidth.value,
|
||||||
render: (value: string | null, record: CollectedData) => (
|
render: (value: string | null, record: CollectedData) => (
|
||||||
value ? `${value} ${record.unit || ''}` : '-'
|
value ? `${value} ${record.unit || ''}` : '-'
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '采集时间',
|
title: () => (
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', position: 'relative' }}>
|
||||||
|
<span>采集时间</span>
|
||||||
|
<div
|
||||||
|
className="resize-handle"
|
||||||
|
onMouseDown={handleResizeStart('collected_at')}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
dataIndex: 'collected_at',
|
dataIndex: 'collected_at',
|
||||||
key: 'collected_at',
|
key: 'collected_at',
|
||||||
width: 180,
|
width: columnsWidth.collected_at,
|
||||||
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
render: (time: string) => new Date(time).toLocaleString('zh-CN'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
key: 'action',
|
key: 'action',
|
||||||
width: 80,
|
width: columnsWidth.action,
|
||||||
render: (_: unknown, record: CollectedData) => (
|
render: (_: unknown, record: CollectedData) => (
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
@@ -230,7 +338,7 @@ function DataList() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppLayout>
|
||||||
<h2>采集数据管理</h2>
|
<h2>采集数据管理</h2>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Summary Cards */}
|
||||||
@@ -305,6 +413,8 @@ function DataList() {
|
|||||||
dataSource={data}
|
dataSource={data}
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
tableLayout="fixed"
|
||||||
pagination={{
|
pagination={{
|
||||||
current: page,
|
current: page,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -361,7 +471,7 @@ function DataList() {
|
|||||||
<Empty description="暂无数据" />
|
<Empty description="暂无数据" />
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
SyncOutlined
|
SyncOutlined
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
|
||||||
interface BuiltInDataSource {
|
interface BuiltInDataSource {
|
||||||
id: number
|
id: number
|
||||||
@@ -326,6 +327,8 @@ function DataSources() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -352,6 +355,8 @@ function DataSources() {
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
|
scroll={{ x: 'max-content' }}
|
||||||
|
tableLayout="fixed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -360,7 +365,7 @@ function DataSources() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppLayout>
|
||||||
<h2>数据源管理</h2>
|
<h2>数据源管理</h2>
|
||||||
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
<Tabs activeKey={activeTab} onChange={setActiveTab} items={tabItems} />
|
||||||
|
|
||||||
@@ -665,7 +670,7 @@ function DataSources() {
|
|||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</div>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
15
frontend/src/pages/Earth/Earth.tsx
Normal file
15
frontend/src/pages/Earth/Earth.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
function Earth() {
|
||||||
|
return (
|
||||||
|
<iframe
|
||||||
|
src="/earth/3dearthmult.html"
|
||||||
|
style={{
|
||||||
|
width: '100vw',
|
||||||
|
height: '100vh',
|
||||||
|
border: 'none',
|
||||||
|
}}
|
||||||
|
title="3D Earth"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Earth
|
||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
import { Table, Tag, Card, Row, Col, Statistic, Button } from 'antd'
|
||||||
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
import { ReloadOutlined, CheckCircleOutlined, CloseCircleOutlined, SyncOutlined } from '@ant-design/icons'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
|
||||||
interface Task {
|
interface Task {
|
||||||
id: number
|
id: number
|
||||||
@@ -107,7 +108,7 @@ function Tasks() {
|
|||||||
const successRate = tasks.length > 0 ? (statusCounts.success / tasks.length) * 100 : 0
|
const successRate = tasks.length > 0 ? (statusCounts.success / tasks.length) * 100 : 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppLayout>
|
||||||
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
|
||||||
<Col span={6}>
|
<Col span={6}>
|
||||||
<Card>
|
<Card>
|
||||||
@@ -144,9 +145,9 @@ function Tasks() {
|
|||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} />
|
<Table columns={columns} dataSource={tasks} rowKey="id" loading={loading} pagination={{ pageSize: 10 }} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
import { Table, Button, Tag, Space, message, Modal, Form, Input, Select } from 'antd'
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import AppLayout from '../../components/AppLayout/AppLayout'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: number
|
id: number
|
||||||
@@ -113,12 +114,12 @@ function Users() {
|
|||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<AppLayout>
|
||||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<h2>用户管理</h2>
|
<h2>用户管理</h2>
|
||||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
<Button type="primary" icon={<PlusOutlined />} onClick={handleAdd}>添加用户</Button>
|
||||||
</div>
|
</div>
|
||||||
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} />
|
<Table columns={columns} dataSource={users} rowKey="id" loading={loading} scroll={{ x: 'max-content' }} tableLayout="fixed" />
|
||||||
<Modal
|
<Modal
|
||||||
title={editingUser ? '编辑用户' : '添加用户'}
|
title={editingUser ? '编辑用户' : '添加用户'}
|
||||||
open={modalVisible}
|
open={modalVisible}
|
||||||
@@ -150,7 +151,7 @@ function Users() {
|
|||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
</Modal>
|
</Modal>
|
||||||
</div>
|
</AppLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user