feat: 创建路由

This commit is contained in:
李如威 2025-09-09 23:25:14 +08:00
parent 70fcc0f410
commit 8b043a58a1
20 changed files with 407 additions and 176 deletions

View File

@ -1,2 +1,11 @@
## 环境准备
```shell
# python
nvm local 3.13.0
python -m venv venv
source venv/bin/activate
# node
nvm use v20.18.0
```

View File

@ -1,3 +1,4 @@
import asyncio
import uvicorn
import uuid
import time
@ -23,9 +24,7 @@ scheduler = AsyncIOScheduler()
def clear_session():
TTL = 60 * 60 * 24 # 1天有效期
now = time.time()
expired_keys = [
token for token, (expires_at, _) in sessions.items() if expires_at + TTL < now
]
expired_keys = [token for token, (expires_at, _) in sessions.items() if expires_at + TTL < now]
for token in expired_keys:
sessions.pop(token, None)
@ -49,12 +48,12 @@ async def is_init():
@asynccontextmanager
async def lifespan(app: FastAPI):
tasks = {}
app.state.tasks = tasks
# 数据库
if await is_init():
init_config = await load_init_config()
await init_tortoise(
init_config["db_type"], init_config, init_config.get("admin_password")
)
await init_tortoise(init_config["db_type"], init_config, init_config.get("admin_password"))
logger.info("初始化数据库")
# 定时器
scheduler.add_job(clear_session, "interval", seconds=30, misfire_grace_time=10)
@ -63,6 +62,9 @@ async def lifespan(app: FastAPI):
yield
for _, v in tasks.items():
v.cancel()
scheduler.shutdown()
logger.info("关闭定时器")
@ -108,9 +110,7 @@ def create_app():
init_config["admin_password"] = request.admin_password
init_config["is_init"] = True
init_success = await init_tortoise(
request.db_type, init_config, request.admin_password
)
init_success = await init_tortoise(request.db_type, init_config, request.admin_password)
if init_success:
await save_init_config(init_config)
@ -127,16 +127,12 @@ def create_app():
response_model=LoginResponse,
)
async def login(login_info: LoginRequest):
user = await DB.User.filter(
username=login_info.username, password=login_info.get_md5_password()
).first()
user = await DB.User.filter(username=login_info.username, password=login_info.get_md5_password(), is_deleted=0).first()
if user:
token = uuid.uuid4().hex
sessions[token] = time.time(), user
logger.info(sessions)
return LoginResponse(
data={"token": token, "user": user.json(del_columns=["password"])}
)
return LoginResponse(data={"token": token, "user": user.json(del_columns=["password"])})
return LoginResponse(success=False, message="帐号或密码错误")
@app.post(
@ -160,79 +156,112 @@ def create_app():
return LoginResponse(data=user.json(del_columns=["password"]))
@app.post(
"/api/run", tags=["Flow"], description="执行命令", response_model=BaseResponse
"/api/run",
tags=["Flow"],
description="执行命令",
response_model=BaseResponse,
)
async def flow_run(id: int):
task_id = uuid.uuid4().hex
async def _task(task_id):
try:
nodes = []
node_0 = F.GitNode()
node_0.set_id("01K3JHSEGBHHAVQ92RAH8M6GPX")
nodes.append(node_0)
node_1 = F.GitNode()
node_1.set_id("01K3JHSHKESMWYEGSY4VNCJY4D")
nodes.append(node_1)
node_2 = F.GitNode()
node_2.set_id("01K3T4GTWZJF3RT5FFH25D3T9C")
nodes.append(node_2)
node_3 = F.CmdNode()
node_3.set_id("01K3T4S2HN97T1B0TPFBCFSB00")
nodes.append(node_3)
git_node_0 = F.GitNode()
git_node_0.set_id("01K3JHSEGBHHAVQ92RAH8M6GPX")
nodes.append(git_node_0)
git_node_1 = F.GitNode()
git_node_1.set_id("01K3JHSHKESMWYEGSY4VNCJY4D")
nodes.append(git_node_1)
cmd_node_2 = F.CmdNode()
cmd_node_2.set_id("01K3JHSMYM584C63QB74G3Q6X4")
nodes.append(cmd_node_2)
cmd_node_3 = F.CmdNode()
cmd_node_3.set_id("01K3JHS5HTY78Z4EYWRFW5TA77")
nodes.append(cmd_node_3)
logger.debug(app.state.tasks)
# 设置依赖关系: node0 - "true" >> node1 - "true" >> node2
for i in range(len(nodes) - 1):
nodes[i] - "true" >> nodes[i + 1]
flow = F.AsyncFlow(git_node_0)
flow = F.AsyncFlow(node_0)
flow.set_params(
{
"01K3JHSEGBHHAVQ92RAH8M6GPX": {
"git_path": "/Users/liruwei/Documents/code/project/dabai/dbpai-web",
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"git_path": "/data/wrok-flow/dbpai-web",
"git_user": "liruwei",
"git_password": "Wsad.8246",
"git_url": "https://tencentgit.dabby.com.cn/dbpai/dbpai-web.git",
"git_branch": "v3.4.2",
"git_branch": "pai-beta-build",
"git_clone_type": "http/https",
"git_clone_depth": 0,
"git_clone_depth": 1,
},
"01K3JHSHKESMWYEGSY4VNCJY4D": {
"git_path": "/Users/liruwei/Documents/code/project/dabai/dbpai-server",
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"git_path": "/data/wrok-flow/dbpai-server",
"git_user": "liruwei",
"git_password": "Wsad.8246",
"git_url": "https://tencentgit.dabby.com.cn/dbpai/dbpai-server.git",
"git_branch": "v3.4.2",
"git_clone_type": "http/https",
"git_clone_depth": 0,
"git_clone_depth": 1,
},
"01K3JHSMYM584C63QB74G3Q6X4": {
"01K3T4GTWZJF3RT5FFH25D3T9C": {
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"git_path": "/data/wrok-flow/dbpai-jenkins",
"git_user": "liruwei",
"git_password": "Wsad.8246",
"git_url": "https://tencentgit.dabby.com.cn/dbpai/dbpai-jenkins.git",
"git_branch": "v3.4.2",
"git_clone_type": "http/https",
"git_clone_depth": 1,
},
"01K3T4S2HN97T1B0TPFBCFSB00": {
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"cmd": "\n".join(
[
"cd /Users/liruwei/Documents/code/project/dabai",
"cp ./dbpai-web/public/dbpai-chat-widget.min.js ./dbpai-server/src/static",
"echo '开始打包'",
"sh build-dbpai-jenkisn.sh all 2> /dev/null",
"echo '打包结束'",
"pwd",
"cd ./dbpai-jenkins",
"git add .",
"git commit -m 'feat: build'",
"git push",
"cd /data/wrok-flow/dbpai-jenkins",
"git log",
]
)
},
"01K3JHS5HTY78Z4EYWRFW5TA77": {
"cmd": "\n".join(["pwd"]),
),
},
}
)
shared = {}
await flow.run_async(shared)
app.state.tasks.pop(task_id, None)
except asyncio.CancelledError:
logger.debug(f"任务被取消: {task_id}")
app.state.tasks.pop(task_id, None)
logger.info(shared)
app.state.tasks[task_id] = asyncio.create_task(_task(task_id))
return BaseResponse(data=task_id)
@app.post("/api/stop", tags=["Flow"], description="停止执行指令", response_model=BaseResponse)
async def stop_run(task_id: str):
task = app.state.tasks.pop(task_id, None)
if task:
task.cancel()
return BaseResponse()
return app

View File

@ -1,2 +1,3 @@
from .system import *
from .user import *
from .work import *

View File

@ -5,6 +5,7 @@ class User(BaseModel):
username = fields.CharField(max_length=50, null=True)
password = fields.CharField(max_length=128, null=True)
level = fields.IntField(default=1, null=True, description="0:超级用户 1:普通用户")
is_deleted = fields.IntField(default=0, null=True)
class Meta:
table = "tb_users"

View File

@ -0,0 +1,49 @@
from tortoise import fields
from .system import BaseModel
from enum import Enum
class DBAnalysisFileStatus(str, Enum):
PROCESSING = "PROCESSING" # 处理中
ERROR = "ERROR" # 处理失败
DONE = "DONE" # 处理完成
class Work(BaseModel):
uuid = fields.CharField(max_length=128, index=True)
creator_id = fields.IntField(null=True)
work_title = fields.CharField(max_length=128, null=True)
work_desc = fields.CharField(max_length=255, null=True)
is_deleted = fields.IntField(default=0, null=True)
class Meta:
table = "tb_work"
class WorkLogsStatus(str, Enum):
DEFAULT = "DEFAULT"
RUNNING = "RUNNING"
SUCCESS = "SUCCESS"
FAILED = "FAILED"
class WorkLogs(BaseModel):
user_id = fields.IntField(null=True)
work_id = fields.IntField(index=True)
status = fields.CharField(max_length=128, null=True)
result = fields.JSONField(default={})
work_title = fields.CharField(max_length=128, null=True)
work_desc = fields.CharField(max_length=255, null=True)
is_deleted = fields.IntField(default=0)
class Meta:
table = "tb_work_logs"
class WorkContent(BaseModel):
user_id = fields.IntField(null=True)
work_id = fields.IntField(index=True)
content = fields.JSONField(default={})
is_deleted = fields.IntField(default=0)
class Meta:
table = "tb_work_content"

View File

@ -1,11 +1,11 @@
from pydantic import BaseModel
from typing import Any
class BaseResponse(BaseModel):
data: Any | None = None
success: bool = True
message: str | None = None
class InitConfigRequest(BaseModel):
db_type: str = "sqlite"
admin_password: str = "admin"

View File

@ -1,4 +1,5 @@
from core.pocket_flow import AsyncNode
from core.logger import logger
import asyncio
import asyncssh
@ -22,7 +23,6 @@ async def async_run_cmd(
text = line
if not isinstance(text, str):
text = line.decode()
print(text)
yield stream_type, text.rstrip()
if not host:
@ -127,8 +127,10 @@ class CmdNode(AsyncNode):
async for stream_type, line in async_run_cmd(**prep_res):
if stream_type == "stderr":
err.append(str(line))
logger.debug(f'err: {line}')
else:
out.append(str(line))
logger.debug(f"out: {line}")
err_str = "\n".join(err)
out_str = "\n".join(out)

View File

@ -4,7 +4,7 @@
<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>Vite + React</title>
<title>工作流</title>
</head>
<body>
<div id="root"></div>

View File

@ -1,42 +1,14 @@
html,
body,
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
'Noto Color Emoji';
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
div.no-select {
user-select: none;
}

View File

@ -1,14 +1,16 @@
import { createContext, useContext, useEffect, useState } from "react";
import { login as loginToServer, currentUser } from "../api/user";
const AuthContext = createContext(null);
import { useNavigate } from 'react-router-dom';
export function AuthProvider({ children }) {
const token = localStorage.getItem('_token')
const [user, setUser] = useState(null);
const navigate = useNavigate();
const [loading, setLoading] = useState(token?.length > 0);
const getUserInfoFromToken = async () => {
try {
const res = await currentUser();
if (res?.success) {
setUser({
@ -16,6 +18,9 @@ export function AuthProvider({ children }) {
...res?.data
})
}
} catch (error) {
navigate("/login")
}
setLoading(false);
}

View File

@ -13,14 +13,6 @@
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
@ -49,10 +41,6 @@ button {
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {

View File

@ -11,13 +11,7 @@ import { ProConfigProvider } from "@ant-design/pro-components";
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<AuthProvider>
<ConfigProvider locale={zhCN} theme={{
components: {
Button: {
algorithm: true
}
}
}}>
<ConfigProvider locale={zhCN}>
<ProConfigProvider>
<App />
</ProConfigProvider>

View File

@ -0,0 +1,107 @@
import { ProTable } from "@ant-design/pro-components"
import { Button, Card, Modal, Popconfirm, Space, Steps } from "antd"
import { useAuth } from "../../context/AuthContext";
import { LoadingOutlined } from "@ant-design/icons";
import { useState } from "react";
export default () => {
const { user } = useAuth();
const run = async record => {
}
const columns = [
{
dataIndex: 'id',
title: "序号",
},
{
dataIndex: 'title',
title: "工作流",
},
{
dataIndex: 'status',
title: "状态",
render: (_, record) => {
return <RunningStatus record={record} />
}
},
{
dataIndex: 'user',
title: '最后执行用户',
},
{
dataIndex: 'time',
title: '最后执行时间',
},
{
dataIndex: 'result',
title: '最后执行结果',
},
{
title: '操作',
valueType: 'option',
key: 'option',
width: user.level === 0 ? 120 : 60,
fixed: 'right',
render: (_, record) => [
<Popconfirm key='1' title="运行工作" onConfirm={() => run(record)}>
<Button type="link" style={{ padding: '0 0', border: 'none' }}>运行</Button>
</Popconfirm>,
user?.level === 0 && <Button key='2' type="link" style={{ padding: '0 0', border: 'none' }}>编辑</Button>,
user?.level === 0 && <Button key='3' type="link" danger style={{ padding: '0 0', border: 'none' }}>删除</Button>,
]
}
];
return (
<Card title="工作台">
<ProTable
rowKey='id'
search={false}
scroll={{ x: 'max-content' }}
options={false}
columns={columns}
dataSource={[{ id: 1 }]}
/>
</Card>
)
}
const RunningStatus = ({record}) => {
const [open, setOpen] = useState(false);
return <>
<a onClick={() => setOpen(true)}>
<Space>
<LoadingOutlined />
执行中
</Space>
</a>
<Modal
title={record?.title || `#${record?.id} 执行状态`}
closable={false}
maskClosable={false}
open={open}
onCancel={() => setOpen(false)}
footer={[<Button key="cancel" onClick={() => setOpen(false)}>关闭</Button>]}
>
<Steps
progressDot
current={1}
direction="vertical"
items={[
{
title: 'Finished',
description: 'This is a description.',
},
{
title: <Space>In Progress<LoadingOutlined /></Space>,
description: 'This is a description.',
},
{
title: 'Waiting',
description: 'This is a description.',
},
]}
/>
</Modal>
</>
}

View File

@ -1,5 +1,7 @@
import { Button, Card } from "antd"
export default () => {
return <>
<h1>Home</h1>
</>
return <Card>
<Button type='text'>ABC</Button>
</Card>
}

View File

@ -0,0 +1,23 @@
.page-layout {
min-width: 100vw;
min-height: 100vh;
.logo {
height: 40px;
width: calc(100% - 16px);
background-color: rgba(255, 255, 255, 0.1);
margin: 8px 8px;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.5);
font-size: 30px;
font-weight: bold;
overflow: hidden;
white-space: nowrap;
}
}
.page-content {
background-color: #f2f3f5;
}

View File

@ -1,28 +1,28 @@
import React, { useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
import { Layout, Menu, Button, theme, Flex } from "antd";
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, VideoCameraOutlined, UploadOutlined, LoginOutlined } from "@ant-design/icons";
import { Layout, Menu, Button, Flex, Dropdown, Steps } from "antd";
import { MenuFoldOutlined, MenuUnfoldOutlined, UserOutlined, LoginOutlined, ProfileOutlined, NodeExpandOutlined } from "@ant-design/icons";
import { useAuth } from "../context/AuthContext";
import './Layout.css';
const { Header, Sider, Content } = Layout;
export default function LayoutPage() {
const { logout } = useAuth();
const [collapsed, setCollapsed] = useState(false);
const { logout, user } = useAuth();
const [collapsed, setCollapsed] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{ key: "1", icon: <UserOutlined />, label: "首页", path: "/welcome" },
{ key: "2", icon: <VideoCameraOutlined />, label: "用户", path: "/user" },
{ key: "1", icon: <NodeExpandOutlined />, label: "工作台", path: "/flow" },
];
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.path))?.key || "1";
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.path))?.key || "";
return (
<Layout style={{ height: '100vh', width: '100vw' }}>
<Layout className="page-layout">
<Sider trigger={null} collapsible collapsed={collapsed} width={200}>
<div style={{ height: 40, width: 'calc(100% - 16px)', backgroundColor: 'lightgray', margin: '8px 8px' }} />
<Logo collapsed={collapsed} />
<Menu
theme="dark"
mode="inline"
@ -34,7 +34,7 @@ export default function LayoutPage() {
}}
/>
</Sider>
<Layout style={{ backgroundColor: '#f2f3f5' }}>
<Layout className="page-content">
<Header style={{ padding: 0, backgroundColor: 'white', width: "100%" }}>
<Flex align="center" justify="space-between" style={{
height: '100%',
@ -42,15 +42,12 @@ export default function LayoutPage() {
boxSizing: 'border-box'
}}>
<Button
style={{ border: 'none'}}
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
/>
<Button type="text"
icon={<LoginOutlined />}
onClick={() => logout()}
/>
<HeaderRight logout={logout} navigate={navigate} user={user} />
</Flex>
</Header>
<Content
@ -65,3 +62,47 @@ export default function LayoutPage() {
</Layout>
);
}
const Logo = ({ collapsed }) => {
return <div className="logo">
{collapsed ? "F" : "FLOW"}
</div>
}
const HeaderRight = ({ logout, navigate, user }) => {
const items = [
{
label: '用户信息',
key: '2',
icon: <ProfileOutlined />
},
{
type: 'divider',
},
{
label: '退出登录',
key: '1',
icon: <LoginOutlined/>
},
];
const onClick = ({ key }) => {
if (key === '1') {
logout();
}
if (key === '2') {
navigate('/user/detail')
}
}
return (
<Dropdown menu={{ items, onClick }} arrow>
<a onClick={e => e.preventDefault()}>
<Button type="text"
style={{ border: 'none' }}
icon={<UserOutlined />}
>
{user.username}
</Button>
</a>
</Dropdown>
)
}

View File

@ -8,7 +8,7 @@ export default () => {
const navigate = useNavigate();
useEffect(() => {
user && navigate("/welcome")
user && navigate("/flow")
!user && !loading && navigate("/login")
}, [user])

View File

@ -0,0 +1,5 @@
export default () => {
return <>
<h1>Profile</h1>
</>
}

View File

@ -4,6 +4,8 @@ import Loading from './pages/Loading';
import Home from "./pages/Home";
import Login from "./pages/Login";
import User from "./pages/User";
import Profile from "./pages/User/Profile";
import Flow from "./pages/Flow";
import LayoutPage from "./pages/Layout";
@ -15,7 +17,7 @@ function PrivateRoute() {
const routes = [
// 访
{ path: "/", element: <Loading />},
{ path: "/", element: <Loading /> },
{ path: "/login", element: <Login /> },
// 访
@ -25,8 +27,9 @@ const routes = [
{
element: <LayoutPage />,
children: [
{ path: "/welcome", element: <Home /> },
{ path: "/User", element: <User /> },
{ path: "/flow", element: <Flow /> },
{ path: "/user", element: <User /> },
{ path: "/user/detail", element: <Profile /> }
]
}
],