在准备环境前提交次全部更改。
This commit is contained in:
13
apps/admin-web/index.html
Normal file
13
apps/admin-web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NeoZQYY 管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
35
apps/admin-web/package.json
Normal file
35
apps/admin-web/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "admin-web",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.6.1",
|
||||
"antd": "^5.24.7",
|
||||
"axios": "^1.9.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.1",
|
||||
"zustand": "^5.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"jsdom": "^26.1.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^6.3.5",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
2851
apps/admin-web/pnpm-lock.yaml
generated
Normal file
2851
apps/admin-web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
196
apps/admin-web/src/App.tsx
Normal file
196
apps/admin-web/src/App.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 主布局与路由配置。
|
||||
*
|
||||
* - 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,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { useAuthStore } from "./store/authStore";
|
||||
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";
|
||||
|
||||
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: "环境配置" },
|
||||
];
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 路由守卫 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
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 />} />
|
||||
</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.pipeline}</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);
|
||||
|
||||
useEffect(() => { hydrate(); }, [hydrate]);
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/*"
|
||||
element={
|
||||
<PrivateRoute>
|
||||
<AppLayout />
|
||||
</PrivateRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
125
apps/admin-web/src/__tests__/flowLayers.test.ts
Normal file
125
apps/admin-web/src/__tests__/flowLayers.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Flow 层级与任务兼容性测试
|
||||
*
|
||||
* **Validates: Requirements 2.2**
|
||||
*
|
||||
* Property 21: 对任意 Flow 类型和任务定义,当 Flow 包含的层不包含该任务所属层时,
|
||||
* 该任务不应出现在可选列表中;当 Flow 包含该任务所属层时,该任务应出现在可选列表中。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getFlowLayers } from "../pages/TaskConfig";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 预期的 Flow 定义(来自设计文档) */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const EXPECTED_FLOWS: Record<string, string[]> = {
|
||||
api_ods: ["ODS"],
|
||||
api_ods_dwd: ["ODS", "DWD"],
|
||||
api_full: ["ODS", "DWD", "DWS", "INDEX"],
|
||||
ods_dwd: ["DWD"],
|
||||
dwd_dws: ["DWS"],
|
||||
dwd_dws_index: ["DWS", "INDEX"],
|
||||
dwd_index: ["INDEX"],
|
||||
};
|
||||
|
||||
describe("getFlowLayers — Flow 层级与任务兼容性", () => {
|
||||
/* ---- 1. 每个已知 Flow 返回正确的层列表 ---- */
|
||||
it.each(Object.entries(EXPECTED_FLOWS))(
|
||||
"Flow '%s' 应返回 %j",
|
||||
(flowId, expectedLayers) => {
|
||||
expect(getFlowLayers(flowId)).toEqual(expectedLayers);
|
||||
},
|
||||
);
|
||||
|
||||
/* ---- 2. 未知 Flow ID 返回空数组 ---- */
|
||||
it("未知 Flow ID 应返回空数组", () => {
|
||||
expect(getFlowLayers("unknown_flow")).toEqual([]);
|
||||
expect(getFlowLayers("")).toEqual([]);
|
||||
expect(getFlowLayers("API_FULL")).toEqual([]); // 大小写敏感
|
||||
});
|
||||
|
||||
/* ---- 3. 所有 7 种 Flow 都有定义 ---- */
|
||||
it("应定义全部 7 种 Flow", () => {
|
||||
const allFlowIds = Object.keys(EXPECTED_FLOWS);
|
||||
expect(allFlowIds).toHaveLength(7);
|
||||
for (const flowId of allFlowIds) {
|
||||
expect(getFlowLayers(flowId).length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 4. 层级互斥性验证 ---- */
|
||||
describe("层级互斥性", () => {
|
||||
it("api_ods 不包含 DWD / DWS / INDEX", () => {
|
||||
const layers = getFlowLayers("api_ods");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("DWS");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("ods_dwd 只包含 DWD,不包含 ODS / DWS / INDEX", () => {
|
||||
const layers = getFlowLayers("ods_dwd");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWS");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("dwd_dws 只包含 DWS,不包含 ODS / DWD / INDEX", () => {
|
||||
const layers = getFlowLayers("dwd_dws");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("INDEX");
|
||||
});
|
||||
|
||||
it("dwd_index 只包含 INDEX,不包含 ODS / DWD / DWS", () => {
|
||||
const layers = getFlowLayers("dwd_index");
|
||||
expect(layers).not.toContain("ODS");
|
||||
expect(layers).not.toContain("DWD");
|
||||
expect(layers).not.toContain("DWS");
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- 5. 任务兼容性:模拟任务按层过滤 ---- */
|
||||
describe("任务兼容性过滤", () => {
|
||||
// 模拟任务定义
|
||||
const mockTasks = [
|
||||
{ code: "FETCH_ORDERS", layer: "ODS" },
|
||||
{ code: "LOAD_DWD_ORDERS", layer: "DWD" },
|
||||
{ code: "AGG_DAILY_REVENUE", layer: "DWS" },
|
||||
{ code: "CALC_WBI_INDEX", layer: "INDEX" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 根据 Flow 包含的层过滤任务(与 TaskSelector 组件逻辑一致)
|
||||
*/
|
||||
function filterTasksByFlow(flowId: string) {
|
||||
const layers = getFlowLayers(flowId);
|
||||
return mockTasks.filter((t) => layers.includes(t.layer));
|
||||
}
|
||||
|
||||
it("api_ods 只显示 ODS 任务", () => {
|
||||
const visible = filterTasksByFlow("api_ods");
|
||||
expect(visible.map((t) => t.code)).toEqual(["FETCH_ORDERS"]);
|
||||
});
|
||||
|
||||
it("api_full 显示所有层的任务", () => {
|
||||
const visible = filterTasksByFlow("api_full");
|
||||
expect(visible).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("dwd_dws_index 显示 DWS 和 INDEX 任务", () => {
|
||||
const visible = filterTasksByFlow("dwd_dws_index");
|
||||
const codes = visible.map((t) => t.code);
|
||||
expect(codes).toContain("AGG_DAILY_REVENUE");
|
||||
expect(codes).toContain("CALC_WBI_INDEX");
|
||||
expect(codes).not.toContain("FETCH_ORDERS");
|
||||
expect(codes).not.toContain("LOAD_DWD_ORDERS");
|
||||
});
|
||||
|
||||
it("未知 Flow 不显示任何任务", () => {
|
||||
const visible = filterTasksByFlow("nonexistent");
|
||||
expect(visible).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
169
apps/admin-web/src/__tests__/logFilter.test.ts
Normal file
169
apps/admin-web/src/__tests__/logFilter.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 日志过滤正确性测试
|
||||
*
|
||||
* **Validates: Requirements 9.2**
|
||||
*
|
||||
* Property 19: 对任意日志行集合和过滤关键词,过滤后的结果应只包含
|
||||
* 含有该关键词的日志行,且不遗漏任何匹配行。
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { filterLogLines } from "../pages/LogViewer";
|
||||
|
||||
describe("filterLogLines — 日志过滤正确性", () => {
|
||||
/* ---- 1. 空关键词返回所有行 ---- */
|
||||
it("空关键词返回所有行", () => {
|
||||
const lines = ["INFO 启动", "ERROR 失败", "DEBUG 调试"];
|
||||
expect(filterLogLines(lines, "")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 2. 空格关键词返回所有行 ---- */
|
||||
it("空格关键词返回所有行", () => {
|
||||
const lines = ["行1", "行2", "行3"];
|
||||
expect(filterLogLines(lines, " ")).toEqual(lines);
|
||||
expect(filterLogLines(lines, "\t")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 3. 匹配的行被保留 ---- */
|
||||
it("匹配的行被保留", () => {
|
||||
const lines = ["INFO 启动成功", "ERROR 连接失败", "INFO 处理完成"];
|
||||
expect(filterLogLines(lines, "INFO")).toEqual([
|
||||
"INFO 启动成功",
|
||||
"INFO 处理完成",
|
||||
]);
|
||||
});
|
||||
|
||||
/* ---- 4. 不匹配的行被过滤掉 ---- */
|
||||
it("不匹配的行被过滤掉", () => {
|
||||
const lines = ["INFO ok", "ERROR fail", "WARN slow"];
|
||||
const result = filterLogLines(lines, "ERROR");
|
||||
expect(result).not.toContain("INFO ok");
|
||||
expect(result).not.toContain("WARN slow");
|
||||
});
|
||||
|
||||
/* ---- 5. 大小写不敏感匹配 ---- */
|
||||
it("大小写不敏感匹配", () => {
|
||||
const lines = ["Error occurred", "error found", "ERROR critical"];
|
||||
const result = filterLogLines(lines, "error");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 6. 空行数组返回空数组 ---- */
|
||||
it("空行数组返回空数组", () => {
|
||||
expect(filterLogLines([], "anything")).toEqual([]);
|
||||
});
|
||||
|
||||
/* ---- 7. 所有行都匹配时返回全部 ---- */
|
||||
it("所有行都匹配时返回全部", () => {
|
||||
const lines = ["log: a", "log: b", "log: c"];
|
||||
expect(filterLogLines(lines, "log")).toEqual(lines);
|
||||
});
|
||||
|
||||
/* ---- 8. 没有行匹配时返回空数组 ---- */
|
||||
it("没有行匹配时返回空数组", () => {
|
||||
const lines = ["hello", "world", "foo"];
|
||||
expect(filterLogLines(lines, "zzz")).toEqual([]);
|
||||
});
|
||||
|
||||
/* ---- 9. 关键词在行首/行中/行尾都能匹配 ---- */
|
||||
describe("关键词位置匹配", () => {
|
||||
const keyword = "target";
|
||||
|
||||
it("行首匹配", () => {
|
||||
expect(filterLogLines(["target is here"], keyword)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("行中匹配", () => {
|
||||
expect(filterLogLines(["the target found"], keyword)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("行尾匹配", () => {
|
||||
expect(filterLogLines(["found the target"], keyword)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
/* ---- 10. 特殊字符关键词正常工作 ---- */
|
||||
it("特殊字符关键词正常工作", () => {
|
||||
const lines = [
|
||||
"path: /api/v1/users",
|
||||
"regex: [a-z]+",
|
||||
"price: $100.00",
|
||||
"normal line",
|
||||
];
|
||||
// 包含 '/' 的关键词
|
||||
expect(filterLogLines(lines, "/api")).toEqual(["path: /api/v1/users"]);
|
||||
// 包含 '[' 的关键词
|
||||
expect(filterLogLines(lines, "[a-z]")).toEqual(["regex: [a-z]+"]);
|
||||
// 包含 '$' 的关键词
|
||||
expect(filterLogLines(lines, "$100")).toEqual(["price: $100.00"]);
|
||||
});
|
||||
|
||||
/* ---- 11. Property: 过滤结果是原始数组的子集 ---- */
|
||||
it("过滤结果是原始数组的子集", () => {
|
||||
const lines = ["alpha", "beta", "gamma", "delta", "epsilon"];
|
||||
const keywords = ["a", "eta", "xyz", ""];
|
||||
|
||||
for (const kw of keywords) {
|
||||
const result = filterLogLines(lines, kw);
|
||||
// 结果中的每一行都必须存在于原始数组中
|
||||
for (const line of result) {
|
||||
expect(lines).toContain(line);
|
||||
}
|
||||
// 结果长度不超过原始数组
|
||||
expect(result.length).toBeLessThanOrEqual(lines.length);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 12. Property: 过滤结果中每一行都包含关键词 ---- */
|
||||
it("过滤结果中每一行都包含关键词", () => {
|
||||
const lines = [
|
||||
"2024-01-01 INFO 启动",
|
||||
"2024-01-01 ERROR 数据库连接失败",
|
||||
"2024-01-01 WARN 内存不足",
|
||||
"2024-01-01 INFO 处理完成",
|
||||
"2024-01-01 DEBUG SQL: SELECT *",
|
||||
];
|
||||
const keywords = ["INFO", "error", "SQL", "2024", "不存在的关键词"];
|
||||
|
||||
for (const kw of keywords) {
|
||||
const result = filterLogLines(lines, kw);
|
||||
const lower = kw.toLowerCase();
|
||||
for (const line of result) {
|
||||
expect(line.toLowerCase()).toContain(lower);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 13. Property: 原始数组中包含关键词的行都在结果中(不遗漏) ---- */
|
||||
it("原始数组中包含关键词的行都在结果中(不遗漏)", () => {
|
||||
const lines = [
|
||||
"INFO 启动",
|
||||
"ERROR 失败",
|
||||
"INFO 完成",
|
||||
"WARN 超时",
|
||||
"INFO 关闭",
|
||||
];
|
||||
const keyword = "INFO";
|
||||
const result = filterLogLines(lines, keyword);
|
||||
const lower = keyword.toLowerCase();
|
||||
|
||||
// 手动找出所有应匹配的行
|
||||
const expected = lines.filter((l) => l.toLowerCase().includes(lower));
|
||||
expect(result).toEqual(expected);
|
||||
|
||||
// 确认没有遗漏:原始数组中每一行如果包含关键词,就必须在结果中
|
||||
for (const line of lines) {
|
||||
if (line.toLowerCase().includes(lower)) {
|
||||
expect(result).toContain(line);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ---- 补充:保持原始顺序 ---- */
|
||||
it("过滤结果保持原始顺序", () => {
|
||||
const lines = ["c-match", "a-match", "b-no", "d-match"];
|
||||
const result = filterLogLines(lines, "match");
|
||||
expect(result).toEqual(["c-match", "a-match", "d-match"]);
|
||||
});
|
||||
});
|
||||
159
apps/admin-web/src/api/client.ts
Normal file
159
apps/admin-web/src/api/client.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* axios 实例 & JWT 拦截器。
|
||||
*
|
||||
* - 请求拦截器:自动从 localStorage 读取 access_token 并附加 Authorization header
|
||||
* - 响应拦截器:遇到 401 时尝试用 refresh_token 刷新,刷新失败则清除令牌并跳转 /login
|
||||
* - 并发刷新保护:多个请求同时 401 时只触发一次 refresh,其余排队等待
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosError,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* axios 实例 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: "/api",
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 请求拦截器 — 附加 JWT */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 响应拦截器 — 401 自动刷新 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 是否正在刷新中 */
|
||||
let isRefreshing = false;
|
||||
|
||||
/** 等待刷新完成的请求队列 */
|
||||
let pendingQueue: {
|
||||
resolve: (token: string) => void;
|
||||
reject: (err: unknown) => void;
|
||||
}[] = [];
|
||||
|
||||
/** 刷新完成后,依次重放排队的请求 */
|
||||
function processPendingQueue(token: string | null, error: unknown) {
|
||||
pendingQueue.forEach(({ resolve, reject }) => {
|
||||
if (token) {
|
||||
resolve(token);
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
pendingQueue = [];
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & {
|
||||
_retried?: boolean;
|
||||
};
|
||||
|
||||
// 非 401、无原始请求、或已重试过 → 直接抛出
|
||||
if (
|
||||
error.response?.status !== 401 ||
|
||||
!originalRequest ||
|
||||
originalRequest._retried
|
||||
) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 刷新端点本身 401 → 不再递归刷新
|
||||
if (originalRequest.url === "/auth/refresh") {
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// 已有刷新请求在飞 → 排队等待
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingQueue.push({ resolve, reject });
|
||||
}).then((newToken) => {
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
};
|
||||
originalRequest._retried = true;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
// 发起刷新
|
||||
isRefreshing = true;
|
||||
originalRequest._retried = true;
|
||||
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
isRefreshing = false;
|
||||
processPendingQueue(null, error);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
try {
|
||||
// 用独立 axios 调用避免被自身拦截器干扰
|
||||
const { data } = await axios.post<{
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
}>("/api/auth/refresh", { refresh_token: refreshToken });
|
||||
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, data.access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, data.refresh_token);
|
||||
|
||||
processPendingQueue(data.access_token, null);
|
||||
|
||||
// 重放原始请求
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${data.access_token}`,
|
||||
};
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processPendingQueue(null, refreshError);
|
||||
clearTokensAndRedirect();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function clearTokensAndRedirect() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
// 派发自定义事件,让 authStore 监听并重置状态
|
||||
// 避免直接 import authStore 导致循环依赖
|
||||
window.dispatchEvent(new Event("auth:force-logout"));
|
||||
|
||||
// 避免在登录页反复跳转
|
||||
if (window.location.pathname !== "/login") {
|
||||
window.location.href = "/login";
|
||||
}
|
||||
}
|
||||
59
apps/admin-web/src/api/dbViewer.ts
Normal file
59
apps/admin-web/src/api/dbViewer.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 数据库查看器相关 API 调用。
|
||||
*
|
||||
* - fetchSchemas:获取 Schema 列表
|
||||
* - fetchTables:获取指定 Schema 下的表列表(含行数)
|
||||
* - fetchColumns:获取指定表的列定义
|
||||
* - executeQuery:执行只读 SQL 查询
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** 表信息 */
|
||||
export interface TableInfo {
|
||||
name: string;
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
/** 列定义 */
|
||||
export interface ColumnInfo {
|
||||
name: string;
|
||||
data_type: string;
|
||||
is_nullable: boolean;
|
||||
default_value: string | null;
|
||||
}
|
||||
|
||||
/** 查询结果 */
|
||||
export interface QueryResult {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
row_count: number;
|
||||
}
|
||||
|
||||
/** 获取所有 Schema */
|
||||
export async function fetchSchemas(): Promise<string[]> {
|
||||
const { data } = await apiClient.get<{ schemas: string[] }>('/db/schemas');
|
||||
return data.schemas;
|
||||
}
|
||||
|
||||
/** 获取指定 Schema 下的表列表 */
|
||||
export async function fetchTables(schema: string): Promise<TableInfo[]> {
|
||||
const { data } = await apiClient.get<{ tables: TableInfo[] }>(
|
||||
`/db/schemas/${encodeURIComponent(schema)}/tables`,
|
||||
);
|
||||
return data.tables;
|
||||
}
|
||||
|
||||
/** 获取指定表的列定义 */
|
||||
export async function fetchColumns(schema: string, table: string): Promise<ColumnInfo[]> {
|
||||
const { data } = await apiClient.get<{ columns: ColumnInfo[] }>(
|
||||
`/db/tables/${encodeURIComponent(schema)}/${encodeURIComponent(table)}/columns`,
|
||||
);
|
||||
return data.columns;
|
||||
}
|
||||
|
||||
/** 执行只读 SQL 查询 */
|
||||
export async function executeQuery(sql: string): Promise<QueryResult> {
|
||||
const { data } = await apiClient.post<QueryResult>('/db/query', { sql });
|
||||
return data;
|
||||
}
|
||||
44
apps/admin-web/src/api/envConfig.ts
Normal file
44
apps/admin-web/src/api/envConfig.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 环境配置相关 API 调用。
|
||||
*
|
||||
* - fetchEnvConfig:获取键值对列表(敏感值已掩码)
|
||||
* - updateEnvConfig:批量更新键值对
|
||||
* - exportEnvConfig:导出去敏感值的配置文件(浏览器下载)
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { EnvConfigItem } from '../types';
|
||||
|
||||
/** 获取环境配置列表 */
|
||||
export async function fetchEnvConfig(): Promise<EnvConfigItem[]> {
|
||||
const { data } = await apiClient.get<{ items: EnvConfigItem[] }>('/env-config');
|
||||
return data.items;
|
||||
}
|
||||
|
||||
/** 批量更新环境配置 */
|
||||
export async function updateEnvConfig(items: EnvConfigItem[]): Promise<void> {
|
||||
await apiClient.put('/env-config', { items });
|
||||
}
|
||||
|
||||
/** 导出配置文件(去敏感值),触发浏览器下载 */
|
||||
export async function exportEnvConfig(): Promise<void> {
|
||||
const response = await apiClient.get('/env-config/export', {
|
||||
responseType: 'blob',
|
||||
});
|
||||
// 从响应头提取文件名,回退默认值
|
||||
const disposition = response.headers['content-disposition'] as string | undefined;
|
||||
let filename = 'env-config.txt';
|
||||
if (disposition) {
|
||||
const match = disposition.match(/filename="?([^";\s]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
// 创建临时链接触发下载
|
||||
const url = URL.createObjectURL(response.data as Blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
38
apps/admin-web/src/api/etlStatus.ts
Normal file
38
apps/admin-web/src/api/etlStatus.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* ETL 状态监控 API 调用。
|
||||
*
|
||||
* - fetchCursors:获取各任务的数据游标(最后抓取时间、记录数)
|
||||
* - fetchRecentRuns:获取最近执行记录
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
|
||||
/** ETL 游标信息 */
|
||||
export interface CursorInfo {
|
||||
task_code: string;
|
||||
last_fetch_time: string | null;
|
||||
record_count: number | null;
|
||||
}
|
||||
|
||||
/** 最近执行记录 */
|
||||
export interface RecentRun {
|
||||
id: string;
|
||||
task_codes: string[];
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
duration_ms: number | null;
|
||||
exit_code: number | null;
|
||||
}
|
||||
|
||||
/** 获取各任务的数据游标 */
|
||||
export async function fetchCursors(): Promise<CursorInfo[]> {
|
||||
const { data } = await apiClient.get<CursorInfo[]>('/etl-status/cursors');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取最近执行记录 */
|
||||
export async function fetchRecentRuns(): Promise<RecentRun[]> {
|
||||
const { data } = await apiClient.get<RecentRun[]>('/etl-status/recent-runs');
|
||||
return data;
|
||||
}
|
||||
47
apps/admin-web/src/api/execution.ts
Normal file
47
apps/admin-web/src/api/execution.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 任务执行相关 API 调用。
|
||||
*
|
||||
* - submitToQueue:提交任务配置到执行队列
|
||||
* - executeDirectly:直接执行任务
|
||||
* - fetchQueue:获取当前队列
|
||||
* - fetchHistory:获取执行历史
|
||||
* - deleteFromQueue:从队列删除任务
|
||||
* - cancelExecution:取消执行中的任务
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { TaskConfig, QueuedTask, ExecutionLog } from '../types';
|
||||
|
||||
/** 提交任务配置到执行队列 */
|
||||
export async function submitToQueue(config: TaskConfig): Promise<{ id: string }> {
|
||||
const { data } = await apiClient.post<{ id: string }>('/execution/queue', config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 直接执行任务(不经过队列) */
|
||||
export async function executeDirectly(config: TaskConfig): Promise<{ execution_id: string }> {
|
||||
const { data } = await apiClient.post<{ execution_id: string }>('/execution/run', config);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取当前任务队列 */
|
||||
export async function fetchQueue(): Promise<QueuedTask[]> {
|
||||
const { data } = await apiClient.get<QueuedTask[]>('/execution/queue');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 获取执行历史记录 */
|
||||
export async function fetchHistory(limit = 50): Promise<ExecutionLog[]> {
|
||||
const { data } = await apiClient.get<ExecutionLog[]>('/execution/history', { params: { limit } });
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 从队列中删除待执行任务 */
|
||||
export async function deleteFromQueue(id: string): Promise<void> {
|
||||
await apiClient.delete(`/execution/queue/${id}`);
|
||||
}
|
||||
|
||||
/** 取消执行中的任务 */
|
||||
export async function cancelExecution(id: string): Promise<void> {
|
||||
await apiClient.post(`/execution/${id}/cancel`);
|
||||
}
|
||||
48
apps/admin-web/src/api/schedules.ts
Normal file
48
apps/admin-web/src/api/schedules.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 调度任务相关 API 调用。
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { ScheduledTask, ScheduleConfig, TaskConfig } from '../types';
|
||||
|
||||
/** 获取调度任务列表 */
|
||||
export async function fetchSchedules(): Promise<ScheduledTask[]> {
|
||||
const { data } = await apiClient.get<ScheduledTask[]>('/schedules');
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 创建调度任务 */
|
||||
export async function createSchedule(payload: {
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
}): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.post<ScheduledTask>('/schedules', payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 更新调度任务 */
|
||||
export async function updateSchedule(
|
||||
id: string,
|
||||
payload: Partial<{
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
}>,
|
||||
): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.put<ScheduledTask>(`/schedules/${id}`, payload);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 删除调度任务 */
|
||||
export async function deleteSchedule(id: string): Promise<void> {
|
||||
await apiClient.delete(`/schedules/${id}`);
|
||||
}
|
||||
|
||||
/** 启用/禁用调度任务 */
|
||||
export async function toggleSchedule(id: string): Promise<ScheduledTask> {
|
||||
const { data } = await apiClient.patch<ScheduledTask>(`/schedules/${id}/toggle`);
|
||||
return data;
|
||||
}
|
||||
32
apps/admin-web/src/api/tasks.ts
Normal file
32
apps/admin-web/src/api/tasks.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 任务相关 API 调用。
|
||||
*
|
||||
* - fetchTaskRegistry:获取按业务域分组的任务注册表
|
||||
*/
|
||||
|
||||
import { apiClient } from './client';
|
||||
import type { TaskConfig, TaskDefinition } from '../types';
|
||||
|
||||
/** 获取按业务域分组的任务注册表 */
|
||||
export async function fetchTaskRegistry(): Promise<Record<string, TaskDefinition[]>> {
|
||||
// 后端返回 { groups: { 域名: [TaskItem] } },需要解包
|
||||
const { data } = await apiClient.get<{ groups: Record<string, TaskDefinition[]> }>('/tasks/registry');
|
||||
return data.groups;
|
||||
}
|
||||
|
||||
/** 获取按业务域分组的 DWD 表定义 */
|
||||
export async function fetchDwdTables(): Promise<Record<string, string[]>> {
|
||||
// 后端返回 { groups: { 域名: [DwdTableItem] } },需要解包并提取 table_name
|
||||
const { data } = await apiClient.get<{ groups: Record<string, { table_name: string }[]> }>('/tasks/dwd-tables');
|
||||
const result: Record<string, string[]> = {};
|
||||
for (const [domain, items] of Object.entries(data.groups)) {
|
||||
result[domain] = items.map((item) => item.table_name);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 验证任务配置并返回生成的 CLI 命令预览 */
|
||||
export async function validateTaskConfig(config: TaskConfig): Promise<{ command: string }> {
|
||||
const { data } = await apiClient.post<{ command: string }>('/tasks/validate', { config });
|
||||
return data;
|
||||
}
|
||||
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal file
187
apps/admin-web/src/components/DwdTableSelector.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 按业务域分组的 DWD 表选择器。
|
||||
*
|
||||
* 从 /api/tasks/dwd-tables 获取 DWD 表定义,按业务域折叠展示,
|
||||
* 支持全选/反选。仅在 Flow 包含 DWD 层时由父组件渲染。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
} from "antd";
|
||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||
import { fetchDwdTables } from "../api/tasks";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface DwdTableSelectorProps {
|
||||
/** 已选中的 DWD 表名列表 */
|
||||
selectedTables: string[];
|
||||
/** 选中表变化回调 */
|
||||
onTablesChange: (tables: string[]) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const DwdTableSelector: React.FC<DwdTableSelectorProps> = ({
|
||||
selectedTables,
|
||||
onTablesChange,
|
||||
}) => {
|
||||
/** 按业务域分组的 DWD 表 */
|
||||
const [tableGroups, setTableGroups] = useState<Record<string, string[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/* ---------- 加载 DWD 表定义 ---------- */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
fetchDwdTables()
|
||||
.then((data) => {
|
||||
if (!cancelled) setTableGroups(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err?.message ?? "获取 DWD 表列表失败");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
/** 所有表名的扁平列表 */
|
||||
const allTableNames = useMemo(
|
||||
() => Object.values(tableGroups).flat(),
|
||||
[tableGroups],
|
||||
);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
|
||||
/** 单个业务域的勾选变化 */
|
||||
const handleDomainChange = useCallback(
|
||||
(domain: string, checkedTables: string[]) => {
|
||||
const domainTables = new Set(tableGroups[domain] ?? []);
|
||||
const otherSelected = selectedTables.filter((t) => !domainTables.has(t));
|
||||
onTablesChange([...otherSelected, ...checkedTables]);
|
||||
},
|
||||
[selectedTables, tableGroups, onTablesChange],
|
||||
);
|
||||
|
||||
/** 全选 */
|
||||
const handleSelectAll = useCallback(() => {
|
||||
onTablesChange(allTableNames);
|
||||
}, [allTableNames, onTablesChange]);
|
||||
|
||||
/** 反选 */
|
||||
const handleInvertSelection = useCallback(() => {
|
||||
const currentSet = new Set(selectedTables);
|
||||
const inverted = allTableNames.filter((t) => !currentSet.has(t));
|
||||
onTablesChange(inverted);
|
||||
}, [allTableNames, selectedTables, onTablesChange]);
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
if (loading) {
|
||||
return <Spin tip="加载 DWD 表列表…" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Alert type="error" message="加载失败" description={error} />;
|
||||
}
|
||||
|
||||
const domainEntries = Object.entries(tableGroups);
|
||||
|
||||
if (domainEntries.length === 0) {
|
||||
return <Text type="secondary">无可选 DWD 表</Text>;
|
||||
}
|
||||
|
||||
const selectedCount = selectedTables.filter((t) =>
|
||||
allTableNames.includes(t),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 全选 / 反选 */}
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button size="small" onClick={handleSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" onClick={handleInvertSelection}>
|
||||
反选
|
||||
</Button>
|
||||
<Text type="secondary">
|
||||
已选 {selectedCount} / {allTableNames.length}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={domainEntries.map(([d]) => d)}
|
||||
items={domainEntries.map(([domain, tables]) => {
|
||||
const domainSelected = selectedTables.filter((t) =>
|
||||
tables.includes(t),
|
||||
);
|
||||
|
||||
const allChecked = domainSelected.length === tables.length;
|
||||
const indeterminate = domainSelected.length > 0 && !allChecked;
|
||||
|
||||
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
|
||||
handleDomainChange(domain, e.target.checked ? tables : []);
|
||||
};
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={indeterminate}
|
||||
checked={allChecked}
|
||||
onChange={handleDomainCheckAll}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4 }}>
|
||||
({domainSelected.length}/{tables.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) =>
|
||||
handleDomainChange(domain, checked as string[])
|
||||
}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
{tables.map((table) => (
|
||||
<Checkbox key={table} value={table}>
|
||||
{table}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DwdTableSelector;
|
||||
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal file
68
apps/admin-web/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 全局错误边界 — 捕获 React 渲染异常,显示错误信息而非白屏。
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Result, Button, Typography } from "antd";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends React.Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info.componentStack);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div style={{ padding: 48 }}>
|
||||
<Result
|
||||
status="error"
|
||||
title="页面渲染出错"
|
||||
subTitle="请尝试刷新页面,如果问题持续请联系管理员。"
|
||||
extra={
|
||||
<Button type="primary" onClick={this.handleReload}>
|
||||
刷新页面
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{this.state.error && (
|
||||
<Paragraph>
|
||||
<Text type="danger" code style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{this.state.error.message}
|
||||
</Text>
|
||||
</Paragraph>
|
||||
)}
|
||||
</Result>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
79
apps/admin-web/src/components/LogStream.tsx
Normal file
79
apps/admin-web/src/components/LogStream.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 日志流展示组件。
|
||||
*
|
||||
* - 等宽字体展示日志行
|
||||
* - 自动滚动到底部(useRef + scrollIntoView)
|
||||
* - 提供"暂停自动滚动"按钮(toggle)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "antd";
|
||||
import { PauseCircleOutlined, PlayCircleOutlined } from "@ant-design/icons";
|
||||
|
||||
export interface LogStreamProps {
|
||||
/** 可选的执行 ID,用于标题展示 */
|
||||
executionId?: string;
|
||||
/** 日志行数组 */
|
||||
lines: string[];
|
||||
}
|
||||
|
||||
const LogStream: React.FC<LogStreamProps> = ({ lines }) => {
|
||||
const [autoscroll, setAutoscroll] = useState(true);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoscroll && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
}, [lines, autoscroll]);
|
||||
|
||||
const handleToggle = () => {
|
||||
const next = !autoscroll;
|
||||
setAutoscroll(next);
|
||||
// 恢复时立即滚动到底部
|
||||
if (next && bottomRef.current) {
|
||||
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ marginBottom: 8, textAlign: "right" }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={autoscroll ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
||||
onClick={handleToggle}
|
||||
>
|
||||
{autoscroll ? "暂停滚动" : "恢复滚动"}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', 'Consolas', monospace",
|
||||
fontSize: 13,
|
||||
lineHeight: 1.6,
|
||||
padding: 12,
|
||||
borderRadius: 4,
|
||||
minHeight: 300,
|
||||
}}
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<div style={{ color: "#888" }}>暂无日志</div>
|
||||
) : (
|
||||
lines.map((line, i) => (
|
||||
<div key={i} style={{ whiteSpace: "pre-wrap", wordBreak: "break-all" }}>
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogStream;
|
||||
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal file
407
apps/admin-web/src/components/ScheduleTab.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 调度管理 Tab 组件。
|
||||
*
|
||||
* 功能:
|
||||
* - 调度任务列表(名称、调度类型、启用 Switch、下次执行、执行次数、最近状态、操作)
|
||||
* - 创建/编辑调度任务 Modal(名称 + 调度配置)
|
||||
* - 删除确认
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Table, Tag, Button, Switch, Popconfirm, Space, Modal, Form,
|
||||
Input, Select, InputNumber, TimePicker, Checkbox, message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import type { ScheduledTask, ScheduleConfig } from '../types';
|
||||
import {
|
||||
fetchSchedules,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
toggleSchedule,
|
||||
} from '../api/schedules';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 & 工具 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
running: 'processing',
|
||||
cancelled: 'warning',
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_LABEL: Record<string, string> = {
|
||||
once: '一次性',
|
||||
interval: '固定间隔',
|
||||
daily: '每日',
|
||||
weekly: '每周',
|
||||
cron: 'Cron',
|
||||
};
|
||||
|
||||
const INTERVAL_UNIT_LABEL: Record<string, string> = {
|
||||
minutes: '分钟',
|
||||
hours: '小时',
|
||||
days: '天',
|
||||
};
|
||||
|
||||
const WEEKDAY_OPTIONS = [
|
||||
{ label: '周一', value: 1 },
|
||||
{ label: '周二', value: 2 },
|
||||
{ label: '周三', value: 3 },
|
||||
{ label: '周四', value: 4 },
|
||||
{ label: '周五', value: 5 },
|
||||
{ label: '周六', value: 6 },
|
||||
{ label: '周日', value: 0 },
|
||||
];
|
||||
|
||||
/** 格式化时间 */
|
||||
function fmtTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/** 根据调度配置生成可读描述 */
|
||||
function describeSchedule(cfg: ScheduleConfig): string {
|
||||
switch (cfg.schedule_type) {
|
||||
case 'once':
|
||||
return '一次性';
|
||||
case 'interval':
|
||||
return `每 ${cfg.interval_value} ${INTERVAL_UNIT_LABEL[cfg.interval_unit] ?? cfg.interval_unit}`;
|
||||
case 'daily':
|
||||
return `每日 ${cfg.daily_time}`;
|
||||
case 'weekly': {
|
||||
const days = (cfg.weekly_days ?? [])
|
||||
.map((d) => WEEKDAY_OPTIONS.find((o) => o.value === d)?.label ?? `${d}`)
|
||||
.join('、');
|
||||
return `每周 ${days} ${cfg.weekly_time}`;
|
||||
}
|
||||
case 'cron':
|
||||
return `Cron: ${cfg.cron_expression}`;
|
||||
default:
|
||||
return cfg.schedule_type;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 调度配置表单子组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 根据调度类型动态渲染配置项 */
|
||||
const ScheduleConfigFields: React.FC<{ scheduleType: string }> = ({ scheduleType }) => {
|
||||
switch (scheduleType) {
|
||||
case 'interval':
|
||||
return (
|
||||
<Space>
|
||||
<Form.Item name={['schedule_config', 'interval_value']} noStyle rules={[{ required: true }]}>
|
||||
<InputNumber min={1} placeholder="间隔值" />
|
||||
</Form.Item>
|
||||
<Form.Item name={['schedule_config', 'interval_unit']} noStyle rules={[{ required: true }]}>
|
||||
<Select style={{ width: 100 }} options={[
|
||||
{ label: '分钟', value: 'minutes' },
|
||||
{ label: '小时', value: 'hours' },
|
||||
{ label: '天', value: 'days' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
);
|
||||
case 'daily':
|
||||
return (
|
||||
<Form.Item name={['schedule_config', 'daily_time']} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
);
|
||||
case 'weekly':
|
||||
return (
|
||||
<>
|
||||
<Form.Item name={['schedule_config', 'weekly_days']} label="星期" rules={[{ required: true }]}>
|
||||
<Checkbox.Group options={WEEKDAY_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['schedule_config', 'weekly_time']} label="执行时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
case 'cron':
|
||||
return (
|
||||
<Form.Item name={['schedule_config', 'cron_expression']} label="Cron 表达式" rules={[{ required: true }]}>
|
||||
<Input placeholder="0 4 * * *" />
|
||||
</Form.Item>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ScheduleTab: React.FC = () => {
|
||||
const [data, setData] = useState<ScheduledTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [scheduleType, setScheduleType] = useState<string>('daily');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
/* 加载列表 */
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(await fetchSchedules());
|
||||
} catch {
|
||||
message.error('加载调度任务失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
/* 打开创建 Modal */
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
schedule_config: {
|
||||
schedule_type: 'daily',
|
||||
interval_value: 1,
|
||||
interval_unit: 'hours',
|
||||
daily_time: dayjs('04:00', 'HH:mm'),
|
||||
weekly_days: [1],
|
||||
weekly_time: dayjs('04:00', 'HH:mm'),
|
||||
cron_expression: '0 4 * * *',
|
||||
},
|
||||
});
|
||||
setScheduleType('daily');
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 打开编辑 Modal */
|
||||
const openEdit = (record: ScheduledTask) => {
|
||||
setEditing(record);
|
||||
const cfg = record.schedule_config;
|
||||
form.setFieldsValue({
|
||||
name: record.name,
|
||||
schedule_config: {
|
||||
...cfg,
|
||||
daily_time: cfg.daily_time ? dayjs(cfg.daily_time, 'HH:mm') : undefined,
|
||||
weekly_time: cfg.weekly_time ? dayjs(cfg.weekly_time, 'HH:mm') : undefined,
|
||||
},
|
||||
});
|
||||
setScheduleType(cfg.schedule_type);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
/* 提交创建/编辑 */
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
|
||||
// 将 dayjs 对象转为字符串
|
||||
const cfg = { ...values.schedule_config };
|
||||
if (cfg.daily_time && typeof cfg.daily_time !== 'string') {
|
||||
cfg.daily_time = cfg.daily_time.format('HH:mm');
|
||||
}
|
||||
if (cfg.weekly_time && typeof cfg.weekly_time !== 'string') {
|
||||
cfg.weekly_time = cfg.weekly_time.format('HH:mm');
|
||||
}
|
||||
|
||||
const scheduleConfig: ScheduleConfig = {
|
||||
schedule_type: cfg.schedule_type ?? 'daily',
|
||||
interval_value: cfg.interval_value ?? 1,
|
||||
interval_unit: cfg.interval_unit ?? 'hours',
|
||||
daily_time: cfg.daily_time ?? '04:00',
|
||||
weekly_days: cfg.weekly_days ?? [1],
|
||||
weekly_time: cfg.weekly_time ?? '04:00',
|
||||
cron_expression: cfg.cron_expression ?? '0 4 * * *',
|
||||
enabled: true,
|
||||
start_date: null,
|
||||
end_date: null,
|
||||
};
|
||||
|
||||
if (editing) {
|
||||
await updateSchedule(editing.id, {
|
||||
name: values.name,
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已更新');
|
||||
} else {
|
||||
// 创建时使用默认 task_config(简化实现)
|
||||
await createSchedule({
|
||||
name: values.name,
|
||||
task_codes: [],
|
||||
task_config: {
|
||||
tasks: [],
|
||||
pipeline: 'api_full',
|
||||
processing_mode: 'increment_only',
|
||||
pipeline_flow: 'FULL',
|
||||
dry_run: false,
|
||||
window_mode: 'lookback',
|
||||
window_start: null,
|
||||
window_end: null,
|
||||
window_split: null,
|
||||
window_split_days: null,
|
||||
lookback_hours: 24,
|
||||
overlap_seconds: 600,
|
||||
fetch_before_verify: false,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: false,
|
||||
store_id: null,
|
||||
dwd_only_tables: null,
|
||||
force_full: false,
|
||||
extra_args: {},
|
||||
},
|
||||
schedule_config: scheduleConfig,
|
||||
});
|
||||
message.success('调度任务已创建');
|
||||
}
|
||||
|
||||
setModalOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
// 表单验证失败,不做额外处理
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* 删除 */
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteSchedule(id);
|
||||
message.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
message.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 启用/禁用 */
|
||||
const handleToggle = async (id: string) => {
|
||||
try {
|
||||
await toggleSchedule(id);
|
||||
load();
|
||||
} catch {
|
||||
message.error('切换状态失败');
|
||||
}
|
||||
};
|
||||
|
||||
/* 表格列定义 */
|
||||
const columns: ColumnsType<ScheduledTask> = [
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: '调度类型',
|
||||
key: 'schedule_type',
|
||||
render: (_: unknown, record: ScheduledTask) => describeSchedule(record.schedule_config),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
key: 'enabled',
|
||||
width: 80,
|
||||
render: (enabled: boolean, record: ScheduledTask) => (
|
||||
<Switch checked={enabled} onChange={() => handleToggle(record.id)} size="small" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '下次执行',
|
||||
dataIndex: 'next_run_at',
|
||||
key: 'next_run_at',
|
||||
render: fmtTime,
|
||||
},
|
||||
{
|
||||
title: '执行次数',
|
||||
dataIndex: 'run_count',
|
||||
key: 'run_count',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: '最近状态',
|
||||
dataIndex: 'last_status',
|
||||
key: 'last_status',
|
||||
width: 100,
|
||||
render: (s: string | null) =>
|
||||
s ? <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag> : '—',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 140,
|
||||
render: (_: unknown, record: ScheduledTask) => (
|
||||
<Space size="small">
|
||||
<Button type="link" icon={<EditOutlined />} size="small" onClick={() => openEdit(record)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确认删除该调度任务?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
|
||||
新建调度
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Table<ScheduledTask>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
size="middle"
|
||||
/>
|
||||
|
||||
{/* 创建/编辑 Modal */}
|
||||
<Modal
|
||||
title={editing ? '编辑调度任务' : '新建调度任务'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={submitting}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入调度任务名称' }]}>
|
||||
<Input placeholder="例如:每日全量同步" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name={['schedule_config', 'schedule_type']} label="调度类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(SCHEDULE_TYPE_LABEL).map(([value, label]) => ({ value, label }))}
|
||||
onChange={(v: string) => setScheduleType(v)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<ScheduleConfigFields scheduleType={scheduleType} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScheduleTab;
|
||||
309
apps/admin-web/src/components/TaskSelector.tsx
Normal file
309
apps/admin-web/src/components/TaskSelector.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 按业务域分组的任务选择器。
|
||||
*
|
||||
* 从 /api/tasks/registry 获取任务注册表,按业务域折叠展示,
|
||||
* 支持全选/反选和按 Flow 层级过滤。
|
||||
* 当 Flow 包含 DWD 层时,在 DWD 任务下方内嵌表过滤子选项。
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback } from "react";
|
||||
import {
|
||||
Collapse,
|
||||
Checkbox,
|
||||
Spin,
|
||||
Alert,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Tag,
|
||||
Divider,
|
||||
} from "antd";
|
||||
import type { CheckboxChangeEvent } from "antd/es/checkbox";
|
||||
import { fetchTaskRegistry, fetchDwdTables } from "../api/tasks";
|
||||
import type { TaskDefinition } from "../types";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Props */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export interface TaskSelectorProps {
|
||||
/** 当前 Flow 包含的层(如 ["ODS", "DWD"]) */
|
||||
layers: string[];
|
||||
/** 已选中的任务编码列表 */
|
||||
selectedTasks: string[];
|
||||
/** 选中任务变化回调 */
|
||||
onTasksChange: (tasks: string[]) => void;
|
||||
/** DWD 表过滤:已选中的表名列表 */
|
||||
selectedDwdTables?: string[];
|
||||
/** DWD 表过滤变化回调 */
|
||||
onDwdTablesChange?: (tables: string[]) => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 过滤逻辑 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function filterTasksByLayers(
|
||||
tasks: TaskDefinition[],
|
||||
layers: string[],
|
||||
): TaskDefinition[] {
|
||||
if (layers.length === 0) return [];
|
||||
return tasks;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskSelector: React.FC<TaskSelectorProps> = ({
|
||||
layers,
|
||||
selectedTasks,
|
||||
onTasksChange,
|
||||
selectedDwdTables = [],
|
||||
onDwdTablesChange,
|
||||
}) => {
|
||||
const [registry, setRegistry] = useState<Record<string, TaskDefinition[]>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// DWD 表定义(按域分组)
|
||||
const [dwdTableGroups, setDwdTableGroups] = useState<Record<string, string[]>>({});
|
||||
const showDwdFilter = layers.includes("DWD") && !!onDwdTablesChange;
|
||||
|
||||
/* ---------- 加载任务注册表 ---------- */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const promises: Promise<void>[] = [
|
||||
fetchTaskRegistry()
|
||||
.then((data) => { if (!cancelled) setRegistry(data); })
|
||||
.catch((err) => { if (!cancelled) setError(err?.message ?? "获取任务列表失败"); }),
|
||||
];
|
||||
// 如果包含 DWD 层,同时加载 DWD 表定义
|
||||
if (layers.includes("DWD")) {
|
||||
promises.push(
|
||||
fetchDwdTables()
|
||||
.then((data) => { if (!cancelled) setDwdTableGroups(data); })
|
||||
.catch(() => { /* DWD 表加载失败不阻塞任务列表 */ }),
|
||||
);
|
||||
}
|
||||
|
||||
Promise.all(promises).finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [layers]);
|
||||
|
||||
/* ---------- 按 layers 过滤后的分组 ---------- */
|
||||
const filteredGroups = useMemo(() => {
|
||||
const result: Record<string, TaskDefinition[]> = {};
|
||||
for (const [domain, tasks] of Object.entries(registry)) {
|
||||
const visible = filterTasksByLayers(tasks, layers);
|
||||
if (visible.length > 0) {
|
||||
result[domain] = [...visible].sort((a, b) => {
|
||||
if (a.is_common === b.is_common) return 0;
|
||||
return a.is_common ? -1 : 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [registry, layers]);
|
||||
|
||||
const allVisibleCodes = useMemo(
|
||||
() => Object.values(filteredGroups).flatMap((t) => t.map((d) => d.code)),
|
||||
[filteredGroups],
|
||||
);
|
||||
|
||||
// DWD 表扁平列表
|
||||
const allDwdTableNames = useMemo(
|
||||
() => Object.values(dwdTableGroups).flat(),
|
||||
[dwdTableGroups],
|
||||
);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
|
||||
const handleDomainChange = useCallback(
|
||||
(domain: string, checkedCodes: string[]) => {
|
||||
const otherDomainCodes = selectedTasks.filter(
|
||||
(code) => !filteredGroups[domain]?.some((t) => t.code === code),
|
||||
);
|
||||
onTasksChange([...otherDomainCodes, ...checkedCodes]);
|
||||
},
|
||||
[selectedTasks, filteredGroups, onTasksChange],
|
||||
);
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
onTasksChange(allVisibleCodes);
|
||||
}, [allVisibleCodes, onTasksChange]);
|
||||
|
||||
const handleInvertSelection = useCallback(() => {
|
||||
const currentSet = new Set(selectedTasks);
|
||||
const inverted = allVisibleCodes.filter((code) => !currentSet.has(code));
|
||||
onTasksChange(inverted);
|
||||
}, [allVisibleCodes, selectedTasks, onTasksChange]);
|
||||
|
||||
/* ---------- DWD 表过滤事件 ---------- */
|
||||
|
||||
const handleDwdDomainTableChange = useCallback(
|
||||
(domain: string, checked: string[]) => {
|
||||
if (!onDwdTablesChange) return;
|
||||
const domainTables = new Set(dwdTableGroups[domain] ?? []);
|
||||
const otherSelected = selectedDwdTables.filter((t) => !domainTables.has(t));
|
||||
onDwdTablesChange([...otherSelected, ...checked]);
|
||||
},
|
||||
[selectedDwdTables, dwdTableGroups, onDwdTablesChange],
|
||||
);
|
||||
|
||||
const handleDwdSelectAll = useCallback(() => {
|
||||
onDwdTablesChange?.(allDwdTableNames);
|
||||
}, [allDwdTableNames, onDwdTablesChange]);
|
||||
|
||||
const handleDwdClearAll = useCallback(() => {
|
||||
onDwdTablesChange?.([]);
|
||||
}, [onDwdTablesChange]);
|
||||
|
||||
/* ---------- 渲染 ---------- */
|
||||
|
||||
if (loading) return <Spin tip="加载任务列表…" />;
|
||||
if (error) return <Alert type="error" message="加载失败" description={error} />;
|
||||
|
||||
const domainEntries = Object.entries(filteredGroups);
|
||||
if (domainEntries.length === 0) return <Text type="secondary">当前 Flow 无可选任务</Text>;
|
||||
|
||||
const selectedCount = selectedTasks.filter((c) => allVisibleCodes.includes(c)).length;
|
||||
// DWD 装载任务是否被选中
|
||||
const dwdLoadSelected = selectedTasks.includes("DWD_LOAD_FROM_ODS");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Button size="small" onClick={handleSelectAll}>全选</Button>
|
||||
<Button size="small" onClick={handleInvertSelection}>反选</Button>
|
||||
<Text type="secondary">已选 {selectedCount} / {allVisibleCodes.length}</Text>
|
||||
</Space>
|
||||
|
||||
<Collapse
|
||||
defaultActiveKey={domainEntries.map(([d]) => d)}
|
||||
items={domainEntries.map(([domain, tasks]) => {
|
||||
const domainCodes = tasks.map((t) => t.code);
|
||||
const domainSelected = selectedTasks.filter((c) => domainCodes.includes(c));
|
||||
const allChecked = domainSelected.length === domainCodes.length;
|
||||
const indeterminate = domainSelected.length > 0 && !allChecked;
|
||||
|
||||
const handleDomainCheckAll = (e: CheckboxChangeEvent) => {
|
||||
handleDomainChange(domain, e.target.checked ? domainCodes : []);
|
||||
};
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={indeterminate}
|
||||
checked={allChecked}
|
||||
onChange={handleDomainCheckAll}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4 }}>
|
||||
({domainSelected.length}/{domainCodes.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) => handleDomainChange(domain, checked as string[])}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{tasks.map((t) => (
|
||||
<Checkbox key={t.code} value={t.code}>
|
||||
<Text strong style={t.is_common === false ? { color: "#999" } : undefined}>{t.code}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>{t.name}</Text>
|
||||
{t.is_common === false && (
|
||||
<Tag color="default" style={{ marginLeft: 6, fontSize: 11 }}>不常用</Tag>
|
||||
)}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* DWD 表过滤:仅在 DWD 层且 DWD_LOAD_FROM_ODS 被选中时显示 */}
|
||||
{showDwdFilter && dwdLoadSelected && allDwdTableNames.length > 0 && (
|
||||
<>
|
||||
<Divider style={{ margin: "12px 0 8px" }} />
|
||||
<div style={{ padding: "0 4px" }}>
|
||||
<Space style={{ marginBottom: 6 }}>
|
||||
<Text strong style={{ fontSize: 13 }}>DWD 表过滤</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{selectedDwdTables.length === 0
|
||||
? "(未选择 = 全部装载)"
|
||||
: `已选 ${selectedDwdTables.length} / ${allDwdTableNames.length}`}
|
||||
</Text>
|
||||
</Space>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<Space size={4}>
|
||||
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdSelectAll}>
|
||||
全选
|
||||
</Button>
|
||||
<Button size="small" type="link" style={{ padding: 0, fontSize: 12 }} onClick={handleDwdClearAll}>
|
||||
清空(全部装载)
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
<Collapse
|
||||
size="small"
|
||||
items={Object.entries(dwdTableGroups).map(([domain, tables]) => {
|
||||
const domainSelected = selectedDwdTables.filter((t) => tables.includes(t));
|
||||
const allDomainChecked = domainSelected.length === tables.length;
|
||||
const domainIndeterminate = domainSelected.length > 0 && !allDomainChecked;
|
||||
|
||||
return {
|
||||
key: domain,
|
||||
label: (
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
indeterminate={domainIndeterminate}
|
||||
checked={allDomainChecked}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
handleDwdDomainTableChange(domain, e.target.checked ? tables : [])
|
||||
}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
{domain}
|
||||
<Text type="secondary" style={{ marginLeft: 4, fontSize: 12 }}>
|
||||
({domainSelected.length}/{tables.length})
|
||||
</Text>
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<Checkbox.Group
|
||||
value={domainSelected}
|
||||
onChange={(checked) => handleDwdDomainTableChange(domain, checked as string[])}
|
||||
>
|
||||
<Space direction="vertical">
|
||||
{tables.map((table) => (
|
||||
<Checkbox key={table} value={table}>
|
||||
<Text style={{ fontSize: 12 }}>{table}</Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Checkbox.Group>
|
||||
),
|
||||
};
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSelector;
|
||||
22
apps/admin-web/src/main.tsx
Normal file
22
apps/admin-web/src/main.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { ConfigProvider } from "antd";
|
||||
import zhCN from "antd/locale/zh_CN";
|
||||
import App from "./App";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
|
||||
/**
|
||||
* 入口:ErrorBoundary + BrowserRouter + antd 中文 locale + App 根组件。
|
||||
*/
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<ErrorBoundary>
|
||||
<BrowserRouter>
|
||||
<ConfigProvider locale={zhCN}>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</BrowserRouter>
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
235
apps/admin-web/src/pages/DBViewer.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* 数据库查看器页面。
|
||||
*
|
||||
* - 左侧:Schema → Table 层级树,异步加载
|
||||
* - 右侧上方:SQL 编辑器 + 执行按钮
|
||||
* - 右侧下方:列定义 / 查询结果 Table
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Tree, Input, Button, Table, Space, message, Spin, Tag, Card, Typography, Tooltip } from 'antd';
|
||||
import {
|
||||
PlayCircleOutlined, ReloadOutlined, TableOutlined,
|
||||
DatabaseOutlined, CopyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode, EventDataNode } from 'antd/es/tree';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchSchemas, fetchTables, fetchColumns, executeQuery,
|
||||
type ColumnInfo, type QueryResult,
|
||||
} from '../api/dbViewer';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const schemaKey = (schema: string) => `s::${schema}`;
|
||||
const tableKey = (schema: string, table: string) => `t::${schema}::${table}`;
|
||||
|
||||
function parseTableKey(key: string): { schema: string; table: string } | null {
|
||||
if (!key.startsWith('t::')) return null;
|
||||
const parts = key.slice(3).split('::');
|
||||
if (parts.length !== 2) return null;
|
||||
return { schema: parts[0], table: parts[1] };
|
||||
}
|
||||
|
||||
const DBViewer: React.FC = () => {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [loadingTree, setLoadingTree] = useState(false);
|
||||
const [expandedKeys, setExpandedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedTable, setSelectedTable] = useState<{ schema: string; table: string } | null>(null);
|
||||
const [columnData, setColumnData] = useState<ColumnInfo[]>([]);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [sql, setSql] = useState('');
|
||||
const [queryResult, setQueryResult] = useState<QueryResult | null>(null);
|
||||
const [loadingQuery, setLoadingQuery] = useState(false);
|
||||
|
||||
const loadSchemas = useCallback(async () => {
|
||||
setLoadingTree(true);
|
||||
try {
|
||||
const schemas = await fetchSchemas();
|
||||
setTreeData(
|
||||
schemas.map((s) => ({
|
||||
title: s, key: schemaKey(s), icon: <DatabaseOutlined />, isLeaf: false,
|
||||
})),
|
||||
);
|
||||
} catch { message.error('加载 Schema 列表失败'); }
|
||||
finally { setLoadingTree(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadSchemas(); }, [loadSchemas]);
|
||||
|
||||
const onLoadData = async (node: EventDataNode<DataNode>) => {
|
||||
const key = node.key as string;
|
||||
if (!key.startsWith('s::')) return;
|
||||
if (node.children && node.children.length > 0) return;
|
||||
const schema = key.slice(3);
|
||||
try {
|
||||
const tables = await fetchTables(schema);
|
||||
const children: DataNode[] = tables.map((t) => ({
|
||||
title: (
|
||||
<Space size={4}>
|
||||
<span>{t.name}</span>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>({t.row_count.toLocaleString()})</Text>
|
||||
</Space>
|
||||
),
|
||||
key: tableKey(schema, t.name), icon: <TableOutlined />, isLeaf: true,
|
||||
}));
|
||||
setTreeData((prev) => prev.map((n) => n.key === key ? { ...n, children } : n));
|
||||
} catch { message.error(`加载 ${schema} 的表列表失败`); }
|
||||
};
|
||||
|
||||
const onSelectNode = async (_: React.Key[], info: { node: DataNode }) => {
|
||||
const key = info.node.key as string;
|
||||
const parsed = parseTableKey(key);
|
||||
if (!parsed) return;
|
||||
setSelectedTable(parsed);
|
||||
setLoadingColumns(true);
|
||||
setQueryResult(null);
|
||||
try {
|
||||
const cols = await fetchColumns(parsed.schema, parsed.table);
|
||||
setColumnData(cols);
|
||||
setSql(`SELECT * FROM ${parsed.schema}.${parsed.table} LIMIT 100;`);
|
||||
} catch { message.error('加载列定义失败'); setColumnData([]); }
|
||||
finally { setLoadingColumns(false); }
|
||||
};
|
||||
|
||||
const handleExecute = async () => {
|
||||
const trimmed = sql.trim();
|
||||
if (!trimmed) { message.warning('请输入 SQL 语句'); return; }
|
||||
setLoadingQuery(true);
|
||||
try {
|
||||
const result = await executeQuery(trimmed);
|
||||
setQueryResult(result);
|
||||
} catch (err: unknown) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string } } };
|
||||
const msg = axiosErr.response?.data?.detail ?? (err instanceof Error ? err.message : '查询执行失败');
|
||||
message.error(msg);
|
||||
setQueryResult(null);
|
||||
} finally { setLoadingQuery(false); }
|
||||
};
|
||||
|
||||
const handleCopySql = () => {
|
||||
navigator.clipboard.writeText(sql).then(() => message.success('已复制'));
|
||||
};
|
||||
|
||||
const columnDefColumns: ColumnsType<ColumnInfo> = [
|
||||
{ title: '列名', dataIndex: 'name', key: 'name', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '数据类型', dataIndex: 'data_type', key: 'data_type' },
|
||||
{
|
||||
title: '可空', dataIndex: 'is_nullable', key: 'is_nullable', width: 70, align: 'center',
|
||||
render: (v: boolean) => v ? <Tag color="orange">YES</Tag> : <Tag color="blue">NO</Tag>,
|
||||
},
|
||||
{
|
||||
title: '默认值', dataIndex: 'default_value', key: 'default_value',
|
||||
render: (v: string | null) => v != null ? <code style={{ fontSize: 12 }}>{v}</code> : <Text type="secondary">—</Text>,
|
||||
},
|
||||
];
|
||||
|
||||
const resultColumns: ColumnsType<Record<string, unknown>> = queryResult
|
||||
? queryResult.columns.map((col, idx) => ({
|
||||
title: col, dataIndex: String(idx), key: col, ellipsis: true,
|
||||
render: (v: unknown) => {
|
||||
if (v === null || v === undefined) return <Text type="secondary">NULL</Text>;
|
||||
return String(v);
|
||||
},
|
||||
}))
|
||||
: [];
|
||||
|
||||
const resultDataSource: Record<string, unknown>[] = queryResult
|
||||
? queryResult.rows.map((row, rowIdx) => {
|
||||
const obj: Record<string, unknown> = { _key: rowIdx };
|
||||
row.forEach((cell, colIdx) => { obj[String(colIdx)] = cell; });
|
||||
return obj;
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 120px)' }}>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DatabaseOutlined style={{ marginRight: 8 }} />
|
||||
数据库查看器
|
||||
</Title>
|
||||
{selectedTable && (
|
||||
<Tag color="blue">{selectedTable.schema}.{selectedTable.table}</Tag>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, gap: 12, minHeight: 0 }}>
|
||||
{/* 左侧树 */}
|
||||
<Card
|
||||
size="small"
|
||||
title="Schema / 表"
|
||||
extra={<Button size="small" icon={<ReloadOutlined />} onClick={loadSchemas} loading={loadingTree} />}
|
||||
style={{ width: 260, minWidth: 260, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||||
>
|
||||
<Spin spinning={loadingTree}>
|
||||
<Tree
|
||||
showIcon treeData={treeData} loadData={onLoadData}
|
||||
expandedKeys={expandedKeys}
|
||||
onExpand={(keys) => setExpandedKeys(keys)}
|
||||
onSelect={onSelectNode}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* 右侧 */}
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
{/* SQL 编辑器 */}
|
||||
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 12px' } }}>
|
||||
<TextArea
|
||||
rows={5} value={sql} onChange={(e) => setSql(e.target.value)}
|
||||
placeholder="输入 SQL 查询语句…"
|
||||
style={{ fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace", fontSize: 13, marginBottom: 8 }}
|
||||
onKeyDown={(e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); handleExecute(); } }}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary" icon={<PlayCircleOutlined />} onClick={handleExecute} loading={loadingQuery}>
|
||||
执行 <Text type="secondary" style={{ fontSize: 11, marginLeft: 4 }}>(Ctrl+Enter)</Text>
|
||||
</Button>
|
||||
<Tooltip title="复制 SQL">
|
||||
<Button icon={<CopyOutlined />} onClick={handleCopySql} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 结果区域 */}
|
||||
<Card size="small" style={{ flex: 1, display: 'flex', flexDirection: 'column' }}
|
||||
styles={{ body: { flex: 1, overflow: 'auto', padding: '8px 12px' } }}
|
||||
>
|
||||
{queryResult ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary">查询返回 {queryResult.row_count} 行</Text>
|
||||
</div>
|
||||
<Table<Record<string, unknown>>
|
||||
rowKey="_key" columns={resultColumns} dataSource={resultDataSource}
|
||||
pagination={{ pageSize: 50, showSizeChanger: false, showTotal: (t) => `共 ${t} 行` }}
|
||||
size="small" scroll={{ x: 'max-content' }} bordered
|
||||
/>
|
||||
</>
|
||||
) : selectedTable ? (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text strong>{selectedTable.schema}.{selectedTable.table}</Text>
|
||||
<Text type="secondary" style={{ marginLeft: 8 }}>列定义</Text>
|
||||
</div>
|
||||
<Table<ColumnInfo>
|
||||
rowKey="name" columns={columnDefColumns} dataSource={columnData}
|
||||
loading={loadingColumns} pagination={false} size="small" bordered
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ color: '#bbb', textAlign: 'center', marginTop: 60 }}>
|
||||
在左侧选择一张表查看列定义,或输入 SQL 执行查询
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DBViewer;
|
||||
137
apps/admin-web/src/pages/ETLStatus.tsx
Normal file
137
apps/admin-web/src/pages/ETLStatus.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* ETL 状态监控页面。
|
||||
*
|
||||
* - 游标状态 Table
|
||||
* - 最近执行记录 Table
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { Table, Tag, Button, message, Typography, Card, Row, Col, Statistic } from 'antd';
|
||||
import { ReloadOutlined, DashboardOutlined, DatabaseOutlined, PlayCircleOutlined } from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
fetchCursors, fetchRecentRuns,
|
||||
type CursorInfo, type RecentRun,
|
||||
} from '../api/etlStatus';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
success: 'green', failed: 'red', running: 'blue', cancelled: 'orange',
|
||||
};
|
||||
|
||||
function formatTime(raw: string | null): string {
|
||||
if (!raw) return '—';
|
||||
const d = new Date(raw);
|
||||
return Number.isNaN(d.getTime()) ? raw : d.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainSec = seconds % 60;
|
||||
return `${minutes}m ${remainSec}s`;
|
||||
}
|
||||
|
||||
const cursorColumns: ColumnsType<CursorInfo> = [
|
||||
{ title: '任务编码', dataIndex: 'task_code', key: 'task_code', render: (v: string) => <code>{v}</code> },
|
||||
{ title: '最后抓取时间', dataIndex: 'last_fetch_time', key: 'last_fetch_time', render: (v: string | null) => formatTime(v) },
|
||||
{
|
||||
title: '记录数', dataIndex: 'record_count', key: 'record_count', align: 'right',
|
||||
render: (v: number | null) => (v != null ? <Text strong>{v.toLocaleString()}</Text> : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
const runColumns: ColumnsType<RecentRun> = [
|
||||
{ title: '任务名称', dataIndex: 'task_codes', key: 'task_codes', render: (codes: string[]) => codes.join(', ') || '—' },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (status: string) => <Tag color={STATUS_COLOR[status] ?? 'default'}>{status}</Tag>,
|
||||
},
|
||||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: (v: string) => formatTime(v) },
|
||||
{ title: '执行时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 100, render: (v: number | null) => formatDuration(v) },
|
||||
];
|
||||
|
||||
const ETLStatus: React.FC = () => {
|
||||
const [cursors, setCursors] = useState<CursorInfo[]>([]);
|
||||
const [runs, setRuns] = useState<RecentRun[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [c, r] = await Promise.all([fetchCursors(), fetchRecentRuns()]);
|
||||
setCursors(c);
|
||||
setRuns(r);
|
||||
} catch { message.error('加载 ETL 状态失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
// 统计
|
||||
const successCount = runs.filter((r) => r.status === 'success').length;
|
||||
const failedCount = runs.filter((r) => r.status === 'failed').length;
|
||||
const runningCount = runs.filter((r) => r.status === 'running').length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ marginRight: 8 }} />
|
||||
ETL 状态监控
|
||||
</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
|
||||
{/* 统计卡片 */}
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="游标数" value={cursors.length} prefix={<DatabaseOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="最近执行" value={runs.length} prefix={<PlayCircleOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="成功" value={successCount} valueStyle={{ color: '#52c41a' }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Card size="small">
|
||||
<Statistic
|
||||
title="失败 / 运行中"
|
||||
value={failedCount}
|
||||
suffix={runningCount > 0 ? ` / ${runningCount}` : ''}
|
||||
valueStyle={{ color: failedCount > 0 ? '#ff4d4f' : undefined }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="游标状态" style={{ marginBottom: 12 }}>
|
||||
<Table<CursorInfo>
|
||||
rowKey="task_code" columns={cursorColumns} dataSource={cursors}
|
||||
loading={loading} pagination={false} size="small"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="最近执行记录">
|
||||
<Table<RecentRun>
|
||||
rowKey="id" columns={runColumns} dataSource={runs}
|
||||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ETLStatus;
|
||||
164
apps/admin-web/src/pages/EnvConfig.tsx
Normal file
164
apps/admin-web/src/pages/EnvConfig.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 环境配置页面。
|
||||
*
|
||||
* - Ant Design Table 展示键值对,支持 inline 编辑
|
||||
* - 敏感值显示为 ****,编辑时可输入新值
|
||||
* - 顶部按钮栏:刷新、保存、导出
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Table, Button, Input, Tag, Space, message, Card, Typography, Badge } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import {
|
||||
ReloadOutlined, SaveOutlined, DownloadOutlined, ToolOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { EnvConfigItem } from '../types';
|
||||
import { fetchEnvConfig, updateEnvConfig, exportEnvConfig } from '../api/envConfig';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const MASK = '****';
|
||||
|
||||
const EnvConfig: React.FC = () => {
|
||||
const [items, setItems] = useState<EnvConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [dirtyMap, setDirtyMap] = useState<Record<string, string>>({});
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await fetchEnvConfig();
|
||||
setItems(data);
|
||||
setDirtyMap({});
|
||||
setEditingKey(null);
|
||||
} catch { message.error('加载环境配置失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const startEdit = (key: string, currentValue: string, isSensitive: boolean) => {
|
||||
setEditingKey(key);
|
||||
setEditValue(isSensitive ? '' : (dirtyMap[key] ?? currentValue));
|
||||
setTimeout(() => { inputRef.current?.focus(); }, 0);
|
||||
};
|
||||
|
||||
const confirmEdit = (key: string, originalValue: string, isSensitive: boolean) => {
|
||||
const trimmed = editValue.trim();
|
||||
if (isSensitive && trimmed === '') {
|
||||
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
|
||||
} else if (!isSensitive && trimmed === originalValue) {
|
||||
setDirtyMap((prev) => { const next = { ...prev }; delete next[key]; return next; });
|
||||
} else {
|
||||
setDirtyMap((prev) => ({ ...prev, [key]: trimmed }));
|
||||
}
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const cancelEdit = () => { setEditingKey(null); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (Object.keys(dirtyMap).length === 0) { message.info('没有需要保存的修改'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = items.map((item) => ({
|
||||
key: item.key,
|
||||
value: dirtyMap[item.key] ?? item.value,
|
||||
is_sensitive: item.is_sensitive,
|
||||
}));
|
||||
await updateEnvConfig(payload);
|
||||
message.success('保存成功');
|
||||
await load();
|
||||
} catch { message.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try { await exportEnvConfig(); message.success('导出成功'); }
|
||||
catch { message.error('导出失败'); }
|
||||
finally { setExporting(false); }
|
||||
};
|
||||
|
||||
const columns: ColumnsType<EnvConfigItem> = [
|
||||
{
|
||||
title: '键名', dataIndex: 'key', key: 'key', width: '35%',
|
||||
render: (text: string) => <code style={{ fontSize: 12 }}>{text}</code>,
|
||||
},
|
||||
{
|
||||
title: '值', dataIndex: 'value', key: 'value', width: '50%',
|
||||
render: (_: string, record: EnvConfigItem) => {
|
||||
if (editingKey === record.key) {
|
||||
return (
|
||||
<Input
|
||||
ref={inputRef} value={editValue} size="small"
|
||||
placeholder={record.is_sensitive ? '输入新值(留空则不修改)' : undefined}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onPressEnter={() => confirmEdit(record.key, record.value, record.is_sensitive)}
|
||||
onBlur={() => confirmEdit(record.key, record.value, record.is_sensitive)}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') cancelEdit(); }}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isDirty = record.key in dirtyMap;
|
||||
const displayValue = record.is_sensitive
|
||||
? (isDirty ? MASK + ' (已修改)' : MASK)
|
||||
: (isDirty ? dirtyMap[record.key] : record.value);
|
||||
return (
|
||||
<span
|
||||
style={{ cursor: 'pointer', color: isDirty ? '#1677ff' : undefined, fontFamily: 'monospace', fontSize: 12 }}
|
||||
onClick={() => startEdit(record.key, record.value, record.is_sensitive)}
|
||||
title="点击编辑"
|
||||
>
|
||||
{displayValue || <Text type="secondary">(空)</Text>}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '类型', dataIndex: 'is_sensitive', key: 'is_sensitive', width: '15%', align: 'center',
|
||||
render: (v: boolean) => v ? <Tag color="red">敏感</Tag> : <Tag color="green">普通</Tag>,
|
||||
},
|
||||
];
|
||||
|
||||
const hasDirty = Object.keys(dirtyMap).length > 0;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<ToolOutlined style={{ marginRight: 8 }} />
|
||||
环境配置
|
||||
</Title>
|
||||
<Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
<Badge count={hasDirty ? Object.keys(dirtyMap).length : 0} size="small">
|
||||
<Button
|
||||
type="primary" icon={<SaveOutlined />}
|
||||
onClick={handleSave} loading={saving} disabled={!hasDirty}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Badge>
|
||||
<Button icon={<DownloadOutlined />} onClick={handleExport} loading={exporting}>导出</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Table<EnvConfigItem>
|
||||
rowKey="key" columns={columns} dataSource={items}
|
||||
loading={loading} pagination={false} size="small"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvConfig;
|
||||
138
apps/admin-web/src/pages/LogViewer.tsx
Normal file
138
apps/admin-web/src/pages/LogViewer.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 日志查看器页面。
|
||||
*
|
||||
* - 输入执行 ID,通过 WebSocket 实时接收日志
|
||||
* - 支持加载历史日志
|
||||
* - 关键词过滤(大小写不敏感)
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { Input, Button, Space, message, Card, Typography, Tag, Badge } from "antd";
|
||||
import {
|
||||
LinkOutlined, DisconnectOutlined, HistoryOutlined,
|
||||
FileTextOutlined, SearchOutlined, ClearOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { apiClient } from "../api/client";
|
||||
import LogStream from "../components/LogStream";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 纯函数:日志过滤 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export function filterLogLines(lines: string[], keyword: string): string[] {
|
||||
if (!keyword.trim()) return lines;
|
||||
const lower = keyword.toLowerCase();
|
||||
return lines.filter((line) => line.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const LogViewer: React.FC = () => {
|
||||
const [executionId, setExecutionId] = useState("");
|
||||
const [lines, setLines] = useState<string[]>([]);
|
||||
const [filterKeyword, setFilterKeyword] = useState("");
|
||||
const [connected, setConnected] = useState(false);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { wsRef.current?.close(); };
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback(() => {
|
||||
const id = executionId.trim();
|
||||
if (!id) { message.warning("请输入执行 ID"); return; }
|
||||
wsRef.current?.close();
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host;
|
||||
const ws = new WebSocket(`${protocol}//${host}/ws/logs/${id}`);
|
||||
wsRef.current = ws;
|
||||
ws.onopen = () => { setConnected(true); message.success("WebSocket 已连接"); };
|
||||
ws.onmessage = (event) => { setLines((prev) => [...prev, event.data]); };
|
||||
ws.onclose = () => { setConnected(false); };
|
||||
ws.onerror = () => { message.error("WebSocket 连接失败"); setConnected(false); };
|
||||
}, [executionId]);
|
||||
|
||||
const handleDisconnect = useCallback(() => {
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
}, []);
|
||||
|
||||
const handleLoadHistory = useCallback(async () => {
|
||||
const id = executionId.trim();
|
||||
if (!id) { message.warning("请输入执行 ID"); return; }
|
||||
try {
|
||||
const { data } = await apiClient.get<{ execution_id: string; output_log: string | null; error_log: string | null }>(
|
||||
`/execution/${id}/logs`
|
||||
);
|
||||
const parts: string[] = [];
|
||||
if (data.output_log) parts.push(data.output_log);
|
||||
if (data.error_log) parts.push(data.error_log);
|
||||
const historyLines = parts.join("\n").split("\n");
|
||||
setLines(historyLines);
|
||||
message.success("历史日志加载完成");
|
||||
} catch { message.error("加载历史日志失败"); }
|
||||
}, [executionId]);
|
||||
|
||||
const handleClear = useCallback(() => { setLines([]); }, []);
|
||||
|
||||
const filteredLines = filterLogLines(lines, filterKeyword);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<div style={{ marginBottom: 12, display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<FileTextOutlined style={{ marginRight: 8 }} />
|
||||
日志查看器
|
||||
</Title>
|
||||
<Space>
|
||||
{connected && <Badge status="processing" text={<Text type="success">已连接</Text>} />}
|
||||
<Tag>{lines.length} 行</Tag>
|
||||
{filterKeyword && <Tag color="blue">{filteredLines.length} 条匹配</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* 操作栏 */}
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap style={{ width: "100%", justifyContent: "space-between" }}>
|
||||
<Space>
|
||||
<Input
|
||||
placeholder="执行 ID"
|
||||
value={executionId}
|
||||
onChange={(e) => setExecutionId(e.target.value)}
|
||||
style={{ width: 280, fontFamily: "monospace" }}
|
||||
onPressEnter={handleConnect}
|
||||
allowClear
|
||||
/>
|
||||
{connected ? (
|
||||
<Button icon={<DisconnectOutlined />} danger onClick={handleDisconnect}>断开</Button>
|
||||
) : (
|
||||
<Button type="primary" icon={<LinkOutlined />} onClick={handleConnect}>连接</Button>
|
||||
)}
|
||||
<Button icon={<HistoryOutlined />} onClick={handleLoadHistory}>加载历史</Button>
|
||||
<Button icon={<ClearOutlined />} onClick={handleClear} disabled={lines.length === 0}>清空</Button>
|
||||
</Space>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="过滤关键词..."
|
||||
value={filterKeyword}
|
||||
onChange={(e) => setFilterKeyword(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 日志流 */}
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<LogStream executionId={executionId} lines={filteredLines} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogViewer;
|
||||
92
apps/admin-web/src/pages/Login.tsx
Normal file
92
apps/admin-web/src/pages/Login.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* 登录页面 — Ant Design Form + Zustand authStore。
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { Button, Card, Form, Input, message, Typography, Space } from "antd";
|
||||
import { LockOutlined, UserOutlined } from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
interface LoginFormValues {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(values.username, values.password);
|
||||
message.success("登录成功");
|
||||
navigate("/", { replace: true });
|
||||
} catch (err: unknown) {
|
||||
const detail =
|
||||
(err as { response?: { data?: { detail?: string } } })?.response?.data
|
||||
?.detail ?? "登录失败,请检查用户名和密码";
|
||||
message.error(detail);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
minHeight: "100vh",
|
||||
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
style={{
|
||||
width: 400,
|
||||
borderRadius: 12,
|
||||
boxShadow: "0 8px 32px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%", textAlign: "center", marginBottom: 24 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>NeoZQYY</Title>
|
||||
<Text type="secondary">管理后台</Text>
|
||||
</Space>
|
||||
|
||||
<Form<LoginFormValues>
|
||||
name="login"
|
||||
onFinish={onFinish}
|
||||
autoComplete="off"
|
||||
size="large"
|
||||
>
|
||||
<Form.Item
|
||||
name="username"
|
||||
rules={[{ required: true, message: "请输入用户名" }]}
|
||||
>
|
||||
<Input prefix={<UserOutlined />} placeholder="用户名" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="password"
|
||||
rules={[{ required: true, message: "请输入密码" }]}
|
||||
>
|
||||
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
573
apps/admin-web/src/pages/TaskConfig.tsx
Normal file
573
apps/admin-web/src/pages/TaskConfig.tsx
Normal file
@@ -0,0 +1,573 @@
|
||||
/**
|
||||
* ETL 任务配置页面。
|
||||
*
|
||||
* 提供 Flow 选择、处理模式、时间窗口、高级选项等配置区域,
|
||||
* 以及连接器/Store 选择、任务选择、DWD 表选择、CLI 命令预览和任务提交功能。
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Card,
|
||||
Radio,
|
||||
Checkbox,
|
||||
InputNumber,
|
||||
DatePicker,
|
||||
Button,
|
||||
Space,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
Row,
|
||||
Col,
|
||||
Badge,
|
||||
Alert,
|
||||
TreeSelect,
|
||||
Tooltip,
|
||||
Segmented,
|
||||
} from "antd";
|
||||
import {
|
||||
SendOutlined,
|
||||
ThunderboltOutlined,
|
||||
CodeOutlined,
|
||||
SettingOutlined,
|
||||
ClockCircleOutlined,
|
||||
SyncOutlined,
|
||||
ShopOutlined,
|
||||
ApiOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import TaskSelector from "../components/TaskSelector";
|
||||
import { validateTaskConfig } from "../api/tasks";
|
||||
import { submitToQueue, executeDirectly } from "../api/execution";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
import type { RadioChangeEvent } from "antd";
|
||||
import type { Dayjs } from "dayjs";
|
||||
import type { TaskConfig as TaskConfigType } from "../types";
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Flow 定义 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const FLOW_DEFINITIONS: Record<string, { name: string; layers: string[]; desc: string }> = {
|
||||
api_ods: { name: "API → ODS", layers: ["ODS"], desc: "仅抓取原始数据" },
|
||||
api_ods_dwd: { name: "API → ODS → DWD", layers: ["ODS", "DWD"], desc: "抓取并清洗装载" },
|
||||
api_full: { name: "API → ODS → DWD → DWS → INDEX", layers: ["ODS", "DWD", "DWS", "INDEX"], desc: "全链路执行" },
|
||||
ods_dwd: { name: "ODS → DWD", layers: ["DWD"], desc: "仅清洗装载" },
|
||||
dwd_dws: { name: "DWD → DWS汇总", layers: ["DWS"], desc: "仅汇总计算" },
|
||||
dwd_dws_index: { name: "DWD → DWS → INDEX", layers: ["DWS", "INDEX"], desc: "汇总+指数" },
|
||||
dwd_index: { name: "DWD → INDEX", layers: ["INDEX"], desc: "仅指数计算" },
|
||||
};
|
||||
|
||||
export function getFlowLayers(flowId: string): string[] {
|
||||
return FLOW_DEFINITIONS[flowId]?.layers ?? [];
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 处理模式 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const PROCESSING_MODES = [
|
||||
{ value: "increment_only", label: "仅增量", desc: "按游标增量抓取和装载" },
|
||||
{ value: "verify_only", label: "校验并修复", desc: "对比源和目标,修复差异" },
|
||||
{ value: "increment_verify", label: "增量+校验", desc: "先增量再校验" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 时间窗口 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type WindowMode = "lookback" | "custom";
|
||||
|
||||
const WINDOW_SPLIT_OPTIONS = [
|
||||
{ value: 0, label: "不切分" },
|
||||
{ value: 1, label: "1天" },
|
||||
{ value: 10, label: "10天" },
|
||||
{ value: 30, label: "30天" },
|
||||
] as const;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 连接器 → 门店 树形数据结构 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 连接器定义:每个连接器下挂载门店列表 */
|
||||
interface ConnectorDef {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const CONNECTOR_DEFS: ConnectorDef[] = [
|
||||
{ id: "feiqiu", label: "飞球", icon: <ApiOutlined /> },
|
||||
];
|
||||
|
||||
/** 构建 TreeSelect 的 treeData,连接器为父节点,门店为子节点 */
|
||||
function buildConnectorStoreTree(
|
||||
connectors: ConnectorDef[],
|
||||
siteId: number | null,
|
||||
): { treeData: { title: React.ReactNode; value: string; key: string; children?: { title: React.ReactNode; value: string; key: string }[] }[]; allValues: string[] } {
|
||||
const allValues: string[] = [];
|
||||
const treeData = connectors.map((c) => {
|
||||
// 每个连接器下挂载当前用户的门店(未来可扩展为多门店)
|
||||
const stores = siteId
|
||||
? [{ title: (<Space size={4}><ShopOutlined /><span>门店 {siteId}</span></Space>), value: `${c.id}::${siteId}`, key: `${c.id}::${siteId}` }]
|
||||
: [];
|
||||
stores.forEach((s) => allValues.push(s.value));
|
||||
return {
|
||||
title: (<Space size={4}>{c.icon}<span>{c.label}</span></Space>),
|
||||
value: c.id,
|
||||
key: c.id,
|
||||
children: stores,
|
||||
};
|
||||
});
|
||||
return { treeData, allValues };
|
||||
}
|
||||
|
||||
/** 从选中值中解析出 store_id 列表 */
|
||||
function parseSelectedStoreIds(selected: string[]): number[] {
|
||||
const ids: number[] = [];
|
||||
for (const v of selected) {
|
||||
// 格式: "connector::storeId"
|
||||
const parts = v.split("::");
|
||||
if (parts.length === 2) {
|
||||
const num = Number(parts[1]);
|
||||
if (!isNaN(num)) ids.push(num);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 页面组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskConfig: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
/* ---------- 连接器 & Store 树形选择 ---------- */
|
||||
const { treeData: connectorTreeData, allValues: allConnectorStoreValues } = useMemo(
|
||||
() => buildConnectorStoreTree(CONNECTOR_DEFS, user?.site_id ?? null),
|
||||
[user?.site_id],
|
||||
);
|
||||
// 默认全选
|
||||
const [selectedConnectorStores, setSelectedConnectorStores] = useState<string[]>([]);
|
||||
|
||||
// 初始化时默认全选
|
||||
useEffect(() => {
|
||||
if (selectedConnectorStores.length === 0 && allConnectorStoreValues.length > 0) {
|
||||
setSelectedConnectorStores(allConnectorStoreValues);
|
||||
}
|
||||
}, [allConnectorStoreValues]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 从选中值解析 store_id(取第一个,当前单门店场景)
|
||||
const selectedStoreIds = useMemo(() => parseSelectedStoreIds(selectedConnectorStores), [selectedConnectorStores]);
|
||||
const effectiveStoreId = selectedStoreIds.length === 1 ? selectedStoreIds[0] : null;
|
||||
|
||||
/* ---------- Flow ---------- */
|
||||
const [flow, setFlow] = useState<string>("api_ods_dwd");
|
||||
|
||||
/* ---------- 处理模式 ---------- */
|
||||
const [processingMode, setProcessingMode] = useState<string>("increment_only");
|
||||
const [fetchBeforeVerify, setFetchBeforeVerify] = useState(false);
|
||||
|
||||
/* ---------- 时间窗口 ---------- */
|
||||
const [windowMode, setWindowMode] = useState<WindowMode>("lookback");
|
||||
const [lookbackHours, setLookbackHours] = useState<number>(24);
|
||||
const [overlapSeconds, setOverlapSeconds] = useState<number>(600);
|
||||
const [windowStart, setWindowStart] = useState<Dayjs | null>(null);
|
||||
const [windowEnd, setWindowEnd] = useState<Dayjs | null>(null);
|
||||
const [windowSplitDays, setWindowSplitDays] = useState<number>(0);
|
||||
|
||||
/* ---------- 任务选择 ---------- */
|
||||
const [selectedTasks, setSelectedTasks] = useState<string[]>([]);
|
||||
const [selectedDwdTables, setSelectedDwdTables] = useState<string[]>([]);
|
||||
|
||||
/* ---------- 高级选项 ---------- */
|
||||
const [dryRun, setDryRun] = useState(false);
|
||||
const [forceFull, setForceFull] = useState(false);
|
||||
const [useLocalJson, setUseLocalJson] = useState(false);
|
||||
|
||||
/* ---------- CLI 预览 ---------- */
|
||||
const [cliCommand, setCliCommand] = useState<string>("");
|
||||
const [cliEdited, setCliEdited] = useState(false);
|
||||
const [cliLoading, setCliLoading] = useState(false);
|
||||
|
||||
/* ---------- 提交状态 ---------- */
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
/* ---------- 派生状态 ---------- */
|
||||
const layers = getFlowLayers(flow);
|
||||
const showVerifyOption = processingMode === "verify_only";
|
||||
|
||||
/* ---------- 构建 TaskConfig 对象 ---------- */
|
||||
const buildTaskConfig = (): TaskConfigType => ({
|
||||
tasks: selectedTasks,
|
||||
pipeline: flow,
|
||||
processing_mode: processingMode,
|
||||
pipeline_flow: "FULL",
|
||||
dry_run: dryRun,
|
||||
window_mode: windowMode,
|
||||
window_start: windowMode === "custom" && windowStart ? windowStart.format("YYYY-MM-DD") : null,
|
||||
window_end: windowMode === "custom" && windowEnd ? windowEnd.format("YYYY-MM-DD") : null,
|
||||
window_split: windowSplitDays > 0 ? "day" : null,
|
||||
window_split_days: windowSplitDays > 0 ? windowSplitDays : null,
|
||||
lookback_hours: lookbackHours,
|
||||
overlap_seconds: overlapSeconds,
|
||||
fetch_before_verify: fetchBeforeVerify,
|
||||
skip_ods_when_fetch_before_verify: false,
|
||||
ods_use_local_json: useLocalJson,
|
||||
store_id: effectiveStoreId,
|
||||
dwd_only_tables: selectedDwdTables.length > 0 ? selectedDwdTables : null,
|
||||
force_full: forceFull,
|
||||
extra_args: {},
|
||||
});
|
||||
|
||||
/* ---------- 自动刷新 CLI 预览 ---------- */
|
||||
const refreshCli = async () => {
|
||||
setCliLoading(true);
|
||||
try {
|
||||
const { command } = await validateTaskConfig(buildTaskConfig());
|
||||
setCliCommand(command);
|
||||
setCliEdited(false);
|
||||
} catch {
|
||||
// 静默失败,保留上次命令
|
||||
} finally {
|
||||
setCliLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置变化时自动刷新 CLI(防抖)
|
||||
useEffect(() => {
|
||||
if (cliEdited) return; // 用户手动编辑过则不自动刷新
|
||||
const timer = setTimeout(refreshCli, 500);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [flow, processingMode, fetchBeforeVerify, windowMode, lookbackHours, overlapSeconds,
|
||||
windowStart, windowEnd, windowSplitDays, selectedTasks, selectedDwdTables,
|
||||
dryRun, forceFull, useLocalJson, selectedConnectorStores]);
|
||||
|
||||
/* ---------- 事件处理 ---------- */
|
||||
const handleFlowChange = (e: RadioChangeEvent) => setFlow(e.target.value);
|
||||
|
||||
const handleSubmitToQueue = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await submitToQueue(buildTaskConfig());
|
||||
message.success("已提交到执行队列");
|
||||
navigate("/task-manager");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "提交失败";
|
||||
message.error(`提交到队列失败:${msg}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteDirectly = async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await executeDirectly(buildTaskConfig());
|
||||
message.success("任务已开始执行");
|
||||
navigate("/task-manager");
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : "执行失败";
|
||||
message.error(`直接执行失败:${msg}`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 样式常量 ---------- */
|
||||
const cardStyle = { marginBottom: 12 };
|
||||
const sectionTitleStyle: React.CSSProperties = {
|
||||
fontSize: 13, fontWeight: 500, color: "#666", marginBottom: 8, display: "block",
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
{/* ---- 页面标题 ---- */}
|
||||
<div style={{ marginBottom: 16, display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
<SettingOutlined style={{ marginRight: 8 }} />
|
||||
任务配置
|
||||
</Title>
|
||||
<Space>
|
||||
<Badge count={selectedTasks.length} size="small" offset={[-4, 0]}>
|
||||
<Text type="secondary">已选任务</Text>
|
||||
</Badge>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* ---- 第一行:连接器/门店 + Flow ---- */}
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title={<Space size={4}><ApiOutlined />连接器 / 门店</Space>} style={cardStyle}>
|
||||
<TreeSelect
|
||||
treeData={connectorTreeData}
|
||||
value={selectedConnectorStores}
|
||||
onChange={setSelectedConnectorStores}
|
||||
treeCheckable
|
||||
treeDefaultExpandAll
|
||||
showCheckedStrategy={TreeSelect.SHOW_CHILD}
|
||||
placeholder="选择连接器和门店"
|
||||
style={{ width: "100%" }}
|
||||
maxTagCount={3}
|
||||
maxTagPlaceholder={(omitted) => `+${omitted.length} 项`}
|
||||
treeCheckStrictly={false}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 11, marginTop: 6, display: "block" }}>
|
||||
{selectedStoreIds.length === 0
|
||||
? "未选择门店,将使用 JWT 默认值"
|
||||
: `已选 ${selectedStoreIds.length} 个门店`}
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card size="small" title="执行流程 (Flow)" style={cardStyle}>
|
||||
<Radio.Group value={flow} onChange={handleFlowChange} style={{ width: "100%" }}>
|
||||
<Row gutter={[0, 4]}>
|
||||
{Object.entries(FLOW_DEFINITIONS).map(([id, def]) => (
|
||||
<Col span={12} key={id}>
|
||||
<Tooltip title={def.desc}>
|
||||
<Radio value={id}>
|
||||
<Text strong style={{ fontSize: 12 }}>{id}</Text>
|
||||
</Radio>
|
||||
</Tooltip>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Radio.Group>
|
||||
<div style={{ marginTop: 6, padding: "4px 8px", background: "#f6f8fa", borderRadius: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{layers.join(" → ") || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ---- 第二行:处理模式 + 时间窗口 ---- */}
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Card size="small" title="处理模式" style={cardStyle}>
|
||||
<Radio.Group
|
||||
value={processingMode}
|
||||
onChange={(e) => {
|
||||
setProcessingMode(e.target.value);
|
||||
if (e.target.value === "increment_only") setFetchBeforeVerify(false);
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" style={{ width: "100%" }}>
|
||||
{PROCESSING_MODES.map((m) => (
|
||||
<Radio key={m.value} value={m.value}>
|
||||
<Text strong>{m.label}</Text>
|
||||
<br />
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{m.desc}</Text>
|
||||
</Radio>
|
||||
))}
|
||||
</Space>
|
||||
</Radio.Group>
|
||||
{showVerifyOption && (
|
||||
<Checkbox
|
||||
checked={fetchBeforeVerify}
|
||||
onChange={(e) => setFetchBeforeVerify(e.target.checked)}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
校验前从 API 获取
|
||||
</Checkbox>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<><ClockCircleOutlined style={{ marginRight: 6 }} />时间窗口</>}
|
||||
style={cardStyle}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={24}>
|
||||
<Segmented
|
||||
value={windowMode}
|
||||
onChange={(v) => setWindowMode(v as WindowMode)}
|
||||
options={[
|
||||
{ value: "lookback", label: "回溯模式" },
|
||||
{ value: "custom", label: "自定义范围" },
|
||||
]}
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{windowMode === "lookback" ? (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>回溯小时数</Text>
|
||||
<InputNumber
|
||||
min={1} max={720} value={lookbackHours}
|
||||
onChange={(v) => setLookbackHours(v ?? 24)}
|
||||
style={{ width: "100%" }}
|
||||
addonAfter="小时"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>冗余秒数</Text>
|
||||
<InputNumber
|
||||
min={0} max={7200} value={overlapSeconds}
|
||||
onChange={(v) => setOverlapSeconds(v ?? 600)}
|
||||
style={{ width: "100%" }}
|
||||
addonAfter="秒"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
) : (
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>开始日期</Text>
|
||||
<DatePicker
|
||||
value={windowStart} onChange={setWindowStart}
|
||||
placeholder="选择开始日期" style={{ width: "100%" }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Text style={sectionTitleStyle}>结束日期</Text>
|
||||
<DatePicker
|
||||
value={windowEnd} onChange={setWindowEnd}
|
||||
placeholder="选择结束日期" style={{ width: "100%" }}
|
||||
status={windowStart && windowEnd && windowEnd.isBefore(windowStart) ? "error" : undefined}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Text style={sectionTitleStyle}>窗口切分</Text>
|
||||
<Radio.Group
|
||||
value={windowSplitDays}
|
||||
onChange={(e) => setWindowSplitDays(e.target.value)}
|
||||
>
|
||||
{WINDOW_SPLIT_OPTIONS.map((opt) => (
|
||||
<Radio.Button key={opt.value} value={opt.value}>{opt.label}</Radio.Button>
|
||||
))}
|
||||
</Radio.Group>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* ---- 高级选项(带描述) ---- */}
|
||||
<Card size="small" title="高级选项" style={cardStyle}>
|
||||
<Row gutter={[24, 8]}>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={dryRun} onChange={(e) => setDryRun(e.target.checked)}>
|
||||
<Text strong>dry-run</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>模拟执行,走完整流程但不写入数据库</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={forceFull} onChange={(e) => setForceFull(e.target.checked)}>
|
||||
<Text strong>force-full</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>强制全量,跳过 hash 去重和变更对比</Text>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Checkbox checked={useLocalJson} onChange={(e) => setUseLocalJson(e.target.checked)}>
|
||||
<Text strong>本地 JSON</Text>
|
||||
</Checkbox>
|
||||
<div style={{ marginLeft: 24 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>离线模式,从本地 JSON 回放(等同 --data-source offline)</Text>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ---- 任务选择(含 DWD 表过滤) ---- */}
|
||||
<Card size="small" title="任务选择" style={cardStyle}>
|
||||
<TaskSelector
|
||||
layers={layers}
|
||||
selectedTasks={selectedTasks}
|
||||
onTasksChange={setSelectedTasks}
|
||||
selectedDwdTables={selectedDwdTables}
|
||||
onDwdTablesChange={setSelectedDwdTables}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* ---- CLI 命令预览(内嵌可编辑) ---- */}
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space>
|
||||
<CodeOutlined />
|
||||
<span>CLI 命令预览</span>
|
||||
{cliEdited && <Text type="warning" style={{ fontSize: 12 }}>(已手动编辑)</Text>}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={cliLoading} />}
|
||||
onClick={() => { setCliEdited(false); refreshCli(); }}
|
||||
>
|
||||
重新生成
|
||||
</Button>
|
||||
}
|
||||
style={cardStyle}
|
||||
>
|
||||
<TextArea
|
||||
value={cliCommand}
|
||||
onChange={(e) => { setCliCommand(e.target.value); setCliEdited(true); }}
|
||||
autoSize={{ minRows: 2, maxRows: 6 }}
|
||||
style={{
|
||||
fontFamily: "'Cascadia Code', 'Fira Code', Consolas, monospace",
|
||||
fontSize: 13,
|
||||
background: "#1e1e1e",
|
||||
color: "#d4d4d4",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
}}
|
||||
placeholder="配置变更后自动生成 CLI 命令..."
|
||||
/>
|
||||
{cliEdited && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="已手动编辑命令,配置变更不会自动覆盖。点击「重新生成」恢复自动模式。"
|
||||
style={{ marginTop: 8 }}
|
||||
banner
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ---- 操作按钮 ---- */}
|
||||
<Card size="small" style={{ marginBottom: 24 }}>
|
||||
<Space size="middle">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
icon={<SendOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleSubmitToQueue}
|
||||
>
|
||||
提交到队列
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={submitting}
|
||||
onClick={handleExecuteDirectly}
|
||||
>
|
||||
直接执行
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskConfig;
|
||||
255
apps/admin-web/src/pages/TaskManager.tsx
Normal file
255
apps/admin-web/src/pages/TaskManager.tsx
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 任务管理页面。
|
||||
*
|
||||
* 三个 Tab:队列、调度、历史
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Tabs, Table, Tag, Button, Popconfirm, Space, message, Drawer,
|
||||
Typography, Descriptions, Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
ReloadOutlined, DeleteOutlined, StopOutlined,
|
||||
UnorderedListOutlined, ClockCircleOutlined, HistoryOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { QueuedTask, ExecutionLog } from '../types';
|
||||
import {
|
||||
fetchQueue, fetchHistory, deleteFromQueue, cancelExecution,
|
||||
} from '../api/execution';
|
||||
import ScheduleTab from '../components/ScheduleTab';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 状态颜色映射 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'default',
|
||||
running: 'processing',
|
||||
success: 'success',
|
||||
failed: 'error',
|
||||
cancelled: 'warning',
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 工具函数 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function fmtTime(iso: string | null | undefined): string {
|
||||
if (!iso) return '—';
|
||||
return new Date(iso).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number | null | undefined): string {
|
||||
if (ms == null) return '—';
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const sec = ms / 1000;
|
||||
if (sec < 60) return `${sec.toFixed(1)}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
const remainSec = Math.round(sec % 60);
|
||||
return `${min}m${remainSec}s`;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 队列 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const QueueTab: React.FC = () => {
|
||||
const [data, setData] = useState<QueuedTask[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchQueue()); }
|
||||
catch { message.error('加载队列失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try { await deleteFromQueue(id); message.success('已删除'); load(); }
|
||||
catch { message.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
try { await cancelExecution(id); message.success('已取消'); load(); }
|
||||
catch { message.error('取消失败'); }
|
||||
};
|
||||
|
||||
const columns: ColumnsType<QueuedTask> = [
|
||||
{
|
||||
title: '任务', dataIndex: ['config', 'tasks'], key: 'tasks',
|
||||
render: (tasks: string[]) => (
|
||||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: tasks?.join(', ') }}>
|
||||
{tasks?.join(', ') ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Flow', dataIndex: ['config', 'pipeline'], key: 'pipeline', width: 120,
|
||||
render: (v: string) => <Tag>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '位置', dataIndex: 'position', key: 'position', width: 60, align: 'center' },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170, render: fmtTime },
|
||||
{
|
||||
title: '操作', key: 'action', width: 100, align: 'center',
|
||||
render: (_: unknown, record: QueuedTask) => {
|
||||
if (record.status === 'pending') {
|
||||
return (
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Button type="link" danger icon={<DeleteOutlined />} size="small">删除</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
if (record.status === 'running') {
|
||||
return (
|
||||
<Popconfirm title="确认取消执行?" onConfirm={() => handleCancel(record.id)}>
|
||||
<Button type="link" danger icon={<StopOutlined />} size="small">取消</Button>
|
||||
</Popconfirm>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">共 {data.length} 个任务</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
<Table<QueuedTask>
|
||||
rowKey="id" columns={columns} dataSource={data}
|
||||
loading={loading} pagination={false} size="small"
|
||||
locale={{ emptyText: <Empty description="队列为空" /> }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 历史 Tab */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const HistoryTab: React.FC = () => {
|
||||
const [data, setData] = useState<ExecutionLog[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<ExecutionLog | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setData(await fetchHistory()); }
|
||||
catch { message.error('加载历史记录失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const columns: ColumnsType<ExecutionLog> = [
|
||||
{
|
||||
title: '任务', dataIndex: 'task_codes', key: 'task_codes',
|
||||
render: (codes: string[]) => (
|
||||
<Text style={{ maxWidth: 300 }} ellipsis={{ tooltip: codes?.join(', ') }}>
|
||||
{codes?.join(', ') ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态', dataIndex: 'status', key: 'status', width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s] ?? 'default'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '开始时间', dataIndex: 'started_at', key: 'started_at', width: 170, render: fmtTime },
|
||||
{ title: '时长', dataIndex: 'duration_ms', key: 'duration_ms', width: 90, render: fmtDuration },
|
||||
{
|
||||
title: '退出码', dataIndex: 'exit_code', key: 'exit_code', width: 70, align: 'center',
|
||||
render: (v: number | null) => v != null ? (
|
||||
<Tag color={v === 0 ? 'success' : 'error'}>{v}</Tag>
|
||||
) : '—',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">最近 {data.length} 条记录</Text>
|
||||
<Button icon={<ReloadOutlined />} onClick={load} loading={loading}>刷新</Button>
|
||||
</div>
|
||||
<Table<ExecutionLog>
|
||||
rowKey="id" columns={columns} dataSource={data}
|
||||
loading={loading} pagination={{ pageSize: 20, showTotal: (t) => `共 ${t} 条` }}
|
||||
size="small"
|
||||
onRow={(record) => ({ onClick: () => setDetail(record), style: { cursor: 'pointer' } })}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="执行详情" open={!!detail} onClose={() => setDetail(null)}
|
||||
width={520}
|
||||
>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="任务">{detail.task_codes?.join(', ')}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLOR[detail.status] ?? 'default'}>{detail.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开始时间">{fmtTime(detail.started_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="结束时间">{fmtTime(detail.finished_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">{fmtDuration(detail.duration_ms)}</Descriptions.Item>
|
||||
<Descriptions.Item label="退出码">
|
||||
{detail.exit_code != null ? (
|
||||
<Tag color={detail.exit_code === 0 ? 'success' : 'error'}>{detail.exit_code}</Tag>
|
||||
) : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="命令">
|
||||
<code style={{ wordBreak: 'break-all', fontSize: 12 }}>{detail.command || '—'}</code>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 主组件 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const TaskManager: React.FC = () => {
|
||||
const items = [
|
||||
{
|
||||
key: 'queue',
|
||||
label: <Space><UnorderedListOutlined />队列</Space>,
|
||||
children: <QueueTab />,
|
||||
},
|
||||
{
|
||||
key: 'schedule',
|
||||
label: <Space><ClockCircleOutlined />调度</Space>,
|
||||
children: <ScheduleTab />,
|
||||
},
|
||||
{
|
||||
key: 'history',
|
||||
label: <Space><HistoryOutlined />历史</Space>,
|
||||
children: <HistoryTab />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4} style={{ marginBottom: 16 }}>
|
||||
<UnorderedListOutlined style={{ marginRight: 8 }} />
|
||||
任务管理
|
||||
</Title>
|
||||
<Tabs defaultActiveKey="queue" items={items} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskManager;
|
||||
137
apps/admin-web/src/store/authStore.ts
Normal file
137
apps/admin-web/src/store/authStore.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 认证状态管理 — Zustand store。
|
||||
*
|
||||
* - 存储 JWT 令牌和用户信息
|
||||
* - login / logout / hydrate 三个核心方法
|
||||
* - 令牌同步到 localStorage,与 client.ts 拦截器共用同一 key
|
||||
*/
|
||||
|
||||
import { create } from "zustand";
|
||||
import { apiClient } from "../api/client";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 类型 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
/** 当前登录用户信息(从 JWT payload 解码或登录响应中获取) */
|
||||
export interface AuthUser {
|
||||
user_id: number;
|
||||
username: string;
|
||||
display_name: string;
|
||||
site_id: number;
|
||||
}
|
||||
|
||||
/** 后端 /api/auth/login 响应体 */
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
/** 用户名密码登录,成功后存储令牌到 state 和 localStorage */
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
/** 登出,清除 state 和 localStorage */
|
||||
logout: () => void;
|
||||
/** 从 localStorage 恢复状态(应用启动时调用) */
|
||||
hydrate: () => void;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 常量 — 与 client.ts 保持一致 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const ACCESS_TOKEN_KEY = "access_token";
|
||||
const REFRESH_TOKEN_KEY = "refresh_token";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* 辅助:从 JWT payload 解析用户信息 */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function parseJwtPayload(token: string): AuthUser | null {
|
||||
try {
|
||||
const base64 = token.split(".")[1];
|
||||
if (!base64) return null;
|
||||
const json = atob(base64);
|
||||
const payload = JSON.parse(json) as Record<string, unknown>;
|
||||
return {
|
||||
user_id: payload.user_id as number,
|
||||
username: payload.username as string,
|
||||
display_name: (payload.display_name as string) ?? "",
|
||||
site_id: payload.site_id as number,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Store */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, _get) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const { data } = await apiClient.post<LoginResponse>("/auth/login", {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = data;
|
||||
|
||||
// 持久化到 localStorage
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, access_token);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token);
|
||||
|
||||
const user = parseJwtPayload(access_token);
|
||||
|
||||
set({
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
},
|
||||
|
||||
logout() {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
set({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
});
|
||||
},
|
||||
|
||||
hydrate() {
|
||||
const accessToken = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
if (accessToken) {
|
||||
const user = parseJwtPayload(accessToken);
|
||||
set({
|
||||
accessToken,
|
||||
refreshToken,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// 监听 axios 拦截器的强制登出事件,同步清除 Zustand 状态
|
||||
// 避免 localStorage 已清空但 isAuthenticated 仍为 true 导致白屏
|
||||
window.addEventListener("auth:force-logout", () => {
|
||||
useAuthStore.getState().logout();
|
||||
});
|
||||
133
apps/admin-web/src/types/index.ts
Normal file
133
apps/admin-web/src/types/index.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 前后端共享的 TypeScript 类型定义。
|
||||
* 与设计文档中的 Pydantic 模型和数据库表结构对应。
|
||||
*/
|
||||
|
||||
/** ETL 任务执行配置 */
|
||||
export interface TaskConfig {
|
||||
tasks: string[];
|
||||
/** 执行流程 Flow ID(对应 CLI --pipeline) */
|
||||
pipeline: string;
|
||||
/** 处理模式 */
|
||||
processing_mode: string;
|
||||
/** 传统模式兼容(已弃用) */
|
||||
pipeline_flow: string;
|
||||
dry_run: boolean;
|
||||
/** lookback / custom */
|
||||
window_mode: string;
|
||||
window_start: string | null;
|
||||
window_end: string | null;
|
||||
/** none / day */
|
||||
window_split: string | null;
|
||||
/** 1 / 10 / 30 */
|
||||
window_split_days: number | null;
|
||||
lookback_hours: number;
|
||||
overlap_seconds: number;
|
||||
fetch_before_verify: boolean;
|
||||
skip_ods_when_fetch_before_verify: boolean;
|
||||
ods_use_local_json: boolean;
|
||||
/** 门店 ID(由后端从 JWT 注入) */
|
||||
store_id: number | null;
|
||||
/** DWD 表级选择 */
|
||||
dwd_only_tables: string[] | null;
|
||||
/** 强制全量处理(跳过 hash 去重和变更对比) */
|
||||
force_full: boolean;
|
||||
extra_args: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 执行流程(Flow)定义 */
|
||||
export interface PipelineDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
/** 包含的层:ODS / DWD / DWS / INDEX */
|
||||
layers: string[];
|
||||
}
|
||||
|
||||
/** 处理模式定义 */
|
||||
export interface ProcessingModeDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** 任务注册表中的任务定义 */
|
||||
export interface TaskDefinition {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string;
|
||||
/** 业务域(会员、结算、助教等) */
|
||||
domain: string;
|
||||
requires_window: boolean;
|
||||
is_ods: boolean;
|
||||
is_dimension: boolean;
|
||||
default_enabled: boolean;
|
||||
/** 常用任务标记,false 表示工具类/手动类任务 */
|
||||
is_common: boolean;
|
||||
}
|
||||
|
||||
/** 调度配置 */
|
||||
export interface ScheduleConfig {
|
||||
schedule_type: "once" | "interval" | "daily" | "weekly" | "cron";
|
||||
interval_value: number;
|
||||
interval_unit: "minutes" | "hours" | "days";
|
||||
daily_time: string;
|
||||
weekly_days: number[];
|
||||
weekly_time: string;
|
||||
cron_expression: string;
|
||||
enabled: boolean;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
}
|
||||
|
||||
/** 队列中的任务 */
|
||||
export interface QueuedTask {
|
||||
id: string;
|
||||
site_id: number;
|
||||
config: TaskConfig;
|
||||
status: "pending" | "running" | "success" | "failed" | "cancelled";
|
||||
position: number;
|
||||
created_at: string;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
exit_code: number | null;
|
||||
error_message: string | null;
|
||||
}
|
||||
|
||||
/** 执行历史记录 */
|
||||
export interface ExecutionLog {
|
||||
id: string;
|
||||
site_id: number;
|
||||
task_codes: string[];
|
||||
status: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
exit_code: number | null;
|
||||
duration_ms: number | null;
|
||||
command: string;
|
||||
summary: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/** 调度任务 */
|
||||
export interface ScheduledTask {
|
||||
id: string;
|
||||
site_id: number;
|
||||
name: string;
|
||||
task_codes: string[];
|
||||
task_config: TaskConfig;
|
||||
schedule_config: ScheduleConfig;
|
||||
enabled: boolean;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
run_count: number;
|
||||
last_status: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** 环境配置项 */
|
||||
export interface EnvConfigItem {
|
||||
key: string;
|
||||
value: string;
|
||||
is_sensitive: boolean;
|
||||
}
|
||||
|
||||
1
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
1
apps/admin-web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
apps/admin-web/tsconfig.json
Normal file
26
apps/admin-web/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* 打包 */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* 类型检查 */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
25
apps/admin-web/tsconfig.node.json
Normal file
25
apps/admin-web/tsconfig.node.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
"composite": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
1
apps/admin-web/tsconfig.node.tsbuildinfo
Normal file
1
apps/admin-web/tsconfig.node.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
1
apps/admin-web/tsconfig.tsbuildinfo
Normal file
1
apps/admin-web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/__tests__/flowlayers.test.ts","./src/__tests__/logfilter.test.ts","./src/api/client.ts","./src/api/dbviewer.ts","./src/api/envconfig.ts","./src/api/etlstatus.ts","./src/api/execution.ts","./src/api/schedules.ts","./src/api/tasks.ts","./src/components/dwdtableselector.tsx","./src/components/errorboundary.tsx","./src/components/logstream.tsx","./src/components/scheduletab.tsx","./src/components/taskselector.tsx","./src/pages/dbviewer.tsx","./src/pages/etlstatus.tsx","./src/pages/envconfig.tsx","./src/pages/logviewer.tsx","./src/pages/login.tsx","./src/pages/taskconfig.tsx","./src/pages/taskmanager.tsx","./src/store/authstore.ts","./src/types/index.ts"],"version":"5.8.3"}
|
||||
3
apps/admin-web/vite.config.d.ts
vendored
Normal file
3
apps/admin-web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
//# sourceMappingURL=vite.config.d.ts.map
|
||||
1
apps/admin-web/vite.config.d.ts.map
Normal file
1
apps/admin-web/vite.config.d.ts.map
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":";AAIA,wBAqBG"}
|
||||
26
apps/admin-web/vite.config.ts
Normal file
26
apps/admin-web/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
// API 代理到后端
|
||||
"/api": {
|
||||
target: "http://localhost:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
// WebSocket 代理
|
||||
"/ws": {
|
||||
target: "ws://localhost:8000",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: [],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user