212 lines
7.1 KiB
TypeScript
212 lines
7.1 KiB
TypeScript
/**
|
||
* 主布局与路由配置。
|
||
*
|
||
* - Ant Design Layout:Sider + Content + Footer(状态栏)
|
||
* - react-router-dom:6 个功能页面路由 + 登录页路由
|
||
* - 路由守卫:未登录重定向到登录页
|
||
*/
|
||
|
||
import React, { useEffect, useState, useCallback } from "react";
|
||
import { Routes, Route, Navigate, useNavigate, useLocation } from "react-router-dom";
|
||
import { Layout, Menu, Spin, Space, Typography, Tag, Button, Tooltip } from "antd";
|
||
import {
|
||
SettingOutlined,
|
||
UnorderedListOutlined,
|
||
ToolOutlined,
|
||
DatabaseOutlined,
|
||
DashboardOutlined,
|
||
FileTextOutlined,
|
||
LogoutOutlined,
|
||
DesktopOutlined,
|
||
} from "@ant-design/icons";
|
||
import type { MenuProps } from "antd";
|
||
import { useAuthStore } from "./store/authStore";
|
||
import { useBusinessDayStore } from "./store/businessDayStore";
|
||
import { fetchQueue } from "./api/execution";
|
||
import type { QueuedTask } from "./types";
|
||
import Login from "./pages/Login";
|
||
import TaskConfig from "./pages/TaskConfig";
|
||
import TaskManager from "./pages/TaskManager";
|
||
import EnvConfig from "./pages/EnvConfig";
|
||
import DBViewer from "./pages/DBViewer";
|
||
import ETLStatus from "./pages/ETLStatus";
|
||
import LogViewer from "./pages/LogViewer";
|
||
import OpsPanel from "./pages/OpsPanel";
|
||
|
||
const { Sider, Content, Footer } = Layout;
|
||
const { Text } = Typography;
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 侧边栏导航配置 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const NAV_ITEMS: MenuProps["items"] = [
|
||
{ key: "/", icon: <SettingOutlined />, label: "任务配置" },
|
||
{ key: "/task-manager", icon: <UnorderedListOutlined />, label: "任务管理" },
|
||
{ key: "/etl-status", icon: <DashboardOutlined />, label: "ETL 状态" },
|
||
{ key: "/db-viewer", icon: <DatabaseOutlined />, label: "数据库" },
|
||
{ key: "/log-viewer", icon: <FileTextOutlined />, label: "日志" },
|
||
{ key: "/env-config", icon: <ToolOutlined />, label: "环境配置" },
|
||
{ key: "/ops-panel", icon: <DesktopOutlined />, label: "运维面板" },
|
||
];
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 路由守卫 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />;
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 主布局 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const AppLayout: React.FC = () => {
|
||
const navigate = useNavigate();
|
||
const location = useLocation();
|
||
const logout = useAuthStore((s) => s.logout);
|
||
|
||
const [runningTask, setRunningTask] = useState<QueuedTask | null>(null);
|
||
|
||
const pollQueue = useCallback(async () => {
|
||
try {
|
||
const queue = await fetchQueue();
|
||
const running = queue.find((t) => t.status === "running") ?? null;
|
||
setRunningTask(running);
|
||
} catch {
|
||
// 网络异常时不更新状态
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
pollQueue();
|
||
const timer = setInterval(pollQueue, 5_000);
|
||
return () => clearInterval(timer);
|
||
}, [pollQueue]);
|
||
|
||
const onMenuClick: MenuProps["onClick"] = ({ key }) => { navigate(key); };
|
||
|
||
const handleLogout = () => {
|
||
logout();
|
||
navigate("/login", { replace: true });
|
||
};
|
||
|
||
return (
|
||
<Layout style={{ minHeight: "100vh" }}>
|
||
<Sider
|
||
collapsible
|
||
style={{ display: "flex", flexDirection: "column" }}
|
||
>
|
||
<div
|
||
style={{
|
||
height: 48,
|
||
margin: "12px 16px",
|
||
color: "#fff",
|
||
fontWeight: 700,
|
||
fontSize: 18,
|
||
textAlign: "center",
|
||
lineHeight: "48px",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
letterSpacing: 1,
|
||
}}
|
||
>
|
||
NeoZQYY
|
||
</div>
|
||
<Menu
|
||
theme="dark"
|
||
mode="inline"
|
||
selectedKeys={[location.pathname]}
|
||
items={NAV_ITEMS}
|
||
onClick={onMenuClick}
|
||
/>
|
||
<div style={{ flex: 1 }} />
|
||
<div style={{ padding: "12px 16px" }}>
|
||
<Tooltip title="退出登录">
|
||
<Button
|
||
type="text" icon={<LogoutOutlined />}
|
||
onClick={handleLogout}
|
||
style={{ color: "rgba(255,255,255,0.65)", width: "100%" }}
|
||
>
|
||
退出
|
||
</Button>
|
||
</Tooltip>
|
||
</div>
|
||
</Sider>
|
||
<Layout>
|
||
<Content style={{ margin: 16, minHeight: 280 }}>
|
||
<Routes>
|
||
<Route path="/" element={<TaskConfig />} />
|
||
<Route path="/task-manager" element={<TaskManager />} />
|
||
<Route path="/env-config" element={<EnvConfig />} />
|
||
<Route path="/db-viewer" element={<DBViewer />} />
|
||
<Route path="/etl-status" element={<ETLStatus />} />
|
||
<Route path="/log-viewer" element={<LogViewer />} />
|
||
<Route path="/ops-panel" element={<OpsPanel />} />
|
||
</Routes>
|
||
</Content>
|
||
<Footer
|
||
style={{
|
||
textAlign: "center",
|
||
padding: "6px 16px",
|
||
background: "#fafafa",
|
||
borderTop: "1px solid #f0f0f0",
|
||
}}
|
||
>
|
||
{runningTask ? (
|
||
<Space size={8}>
|
||
<Spin size="small" />
|
||
<Text>执行中</Text>
|
||
<Tag color="processing">{runningTask.config.flow}</Tag>
|
||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||
{runningTask.config.tasks.slice(0, 3).join(", ")}
|
||
{runningTask.config.tasks.length > 3 && ` +${runningTask.config.tasks.length - 3}`}
|
||
</Text>
|
||
</Space>
|
||
) : (
|
||
<Text type="secondary" style={{ fontSize: 12 }}>无任务执行中</Text>
|
||
)}
|
||
</Footer>
|
||
</Layout>
|
||
</Layout>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* 根组件 */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
const App: React.FC = () => {
|
||
const hydrate = useAuthStore((s) => s.hydrate);
|
||
const initBusinessDay = useBusinessDayStore((s) => s.init);
|
||
const [hydrated, setHydrated] = useState(false);
|
||
|
||
useEffect(() => {
|
||
hydrate();
|
||
setHydrated(true);
|
||
// 启动时请求一次营业日配置,降级策略在 store 内部处理
|
||
initBusinessDay();
|
||
}, [hydrate, initBusinessDay]);
|
||
|
||
/* hydrate 完成前不渲染路由,避免 PrivateRoute 误判跳转到 /login */
|
||
if (!hydrated) return <Spin style={{ display: "flex", justifyContent: "center", marginTop: 120 }} />;
|
||
|
||
return (
|
||
<Routes>
|
||
<Route path="/login" element={<Login />} />
|
||
<Route
|
||
path="/*"
|
||
element={
|
||
<PrivateRoute>
|
||
<AppLayout />
|
||
</PrivateRoute>
|
||
}
|
||
/>
|
||
</Routes>
|
||
);
|
||
};
|
||
|
||
export default App;
|