Compare commits

...

10 Commits

Author SHA1 Message Date
李如威 c865132e1a feat: 接口逻辑 2025-09-10 10:45:56 +08:00
李如威 8b043a58a1 feat: 创建路由 2025-09-09 23:25:14 +08:00
李如威 70fcc0f410 feat: install 2025-08-28 11:18:02 +08:00
李如威 a054441885 feat: nvm version 2025-08-28 11:16:24 +08:00
李如威 b1492434a0 feat: 登录验证 2025-08-28 11:12:25 +08:00
李如威 fcc5d11804 feat: layout 2025-08-28 11:12:03 +08:00
李如威 8fc61a98c6 feat: 创建路由 2025-08-28 10:03:32 +08:00
李如威 7bb611ba17 feat: frontend 2025-08-28 00:00:52 +08:00
李如威 a66dd72594 feat: git node 2025-08-25 14:57:26 +08:00
李如威 bd7a0e44bd feat: cmd client 2025-08-23 17:24:50 +08:00
37 changed files with 5436 additions and 49 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,8 +1,12 @@
import asyncio
from hashlib import md5
import json
import uvicorn import uvicorn
import uuid import uuid
import time import time
import models.db as DB import models.db as DB
import nodes as F import nodes as F
from tortoise.transactions import in_transaction
from fastapi import FastAPI, Depends, HTTPException from fastapi import FastAPI, Depends, HTTPException
from models.schemas.system import InitStatusResponse, InitConfigRequest, BaseResponse from models.schemas.system import InitStatusResponse, InitConfigRequest, BaseResponse
from models.schemas.user import LoginRequest, LoginResponse from models.schemas.user import LoginRequest, LoginResponse
@ -21,7 +25,7 @@ scheduler = AsyncIOScheduler()
# 定时清理过期session # 定时清理过期session
def clear_session(): def clear_session():
TTL = 60 * 60 * 24 # 1天有效期 TTL = 60 * 60 * 24 # 1天有效期
now = time.time() 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: for token in expired_keys:
@ -31,7 +35,7 @@ def clear_session():
logger.info(f"清理过期 now: {now} session: {expired_keys}") logger.info(f"清理过期 now: {now} session: {expired_keys}")
def get_current_user(token:str = Depends(oauth2_scheme)) -> DB.User | None: def get_current_user(token: str = Depends(oauth2_scheme)) -> DB.User | None:
if not token or token not in sessions: if not token or token not in sessions:
raise HTTPException(status_code=401, detail="用户未登陆") raise HTTPException(status_code=401, detail="用户未登陆")
_, user = sessions[token] _, user = sessions[token]
@ -45,20 +49,140 @@ async def is_init():
return init_status return init_status
async def _create_demo_records(app: FastAPI):
"""创建测试用的"""
if await DB.Work.exists():
return
async with in_transaction():
work = DB.Work(
uuid=uuid.uuid4().hex,
creator_id=1,
work_title="pai-beta",
work_desc="大白智护beta环境",
)
await work.save()
work_content = DB.WorkContent(
user_id=work.creator_id,
work_id=work.id,
content=[
{
"type": "git",
"title": "同步前端仓库",
"description": "clone 分支 pai-beta-build",
"uuid": uuid.uuid4().hex,
"detail": {
"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": "pai-beta-build",
"git_clone_type": "http/https",
"git_clone_depth": 1,
},
},
{
"type": "git",
"title": "同步后端仓库",
"description": "clone 分支 v3.4.2",
"uuid": uuid.uuid4().hex,
"detail": {
"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": 1,
},
},
{
"type": "git",
"title": "同步项目集合仓库",
"description": "clone 分支 v3.4.2",
"uuid": uuid.uuid4().hex,
"detail": {
"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,
},
},
{
"type": "cmd",
"title": "更新项目集合仓库",
"description": "更新 server/ html/ 内容",
"uuid": uuid.uuid4().hex,
"detail": {
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"cmd": ("cd /data/wrok-flow/dbpai-jenkins" " && rm -fr ./server" " && cp -r ../dbpai-server ./server" " && rm -fr ./html" " && cp -r ../dbpai-web/dist ./html" " && git add ." ' && git commit -m "build"' " && git push"),
},
},
{
"type": "cmd",
"title": "部署",
"description": "重新运行 pai-beta 分支 v3.4.2",
"uuid": uuid.uuid4().hex,
"detail": {
"host": "58.214.239.10",
"user": "dgx07",
"password": "f=bHt/.7kRo:KIXq",
"port": 6378,
"sudo": True,
"cmd": ("cd /data/PAI/dbpai-beta/dbpai-jenkins;" " git stash;" " git pull origin v3.4.2;" " git stash pop;" " docker-compose restart;"),
},
},
],
)
await work_content.save()
logger.debug("创建")
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
tasks = {}
task_nodes = {}
app.state.tasks = tasks
app.state.task_nodes = task_nodes
# 数据库 # 数据库
if await is_init(): if await is_init():
init_config = await load_init_config() 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("初始化数据库") logger.info("初始化数据库")
# 定时器 # 定时器
scheduler.add_job(clear_session, "interval", seconds=5) scheduler.add_job(clear_session, "interval", seconds=30, misfire_grace_time=10)
scheduler.start() scheduler.start()
logger.info("初始化定时器") logger.info("初始化定时器")
await _create_demo_records(app)
yield yield
for _, v in tasks.items():
v.cancel()
scheduler.shutdown() scheduler.shutdown()
logger.info("关闭定时器") logger.info("关闭定时器")
@ -74,7 +198,7 @@ def create_app():
return {"message": "hello"} return {"message": "hello"}
@app.get( @app.get(
"/check_init", "/api/check_init",
tags=["System"], tags=["System"],
description="系统是否初始化", description="系统是否初始化",
response_model=InitStatusResponse, response_model=InitStatusResponse,
@ -83,7 +207,7 @@ def create_app():
return InitStatusResponse(data=await is_init()) return InitStatusResponse(data=await is_init())
@app.post( @app.post(
"/init_system", "/api/init_system",
tags=["System"], tags=["System"],
description="系统初始化", description="系统初始化",
response_model=InitStatusResponse, response_model=InitStatusResponse,
@ -92,7 +216,7 @@ def create_app():
logger.info("init_system") logger.info("init_system")
try: try:
init_config = await load_init_config() init_config = await load_init_config()
if init_config.get('is_init'): if init_config.get("is_init"):
return InitStatusResponse(data=True) return InitStatusResponse(data=True)
# 保存配置 # 保存配置
init_config["db_type"] = request.db_type init_config["db_type"] = request.db_type
@ -114,36 +238,135 @@ def create_app():
except Exception as e: except Exception as e:
return InitStatusResponse(success=False, message=str(e)) return InitStatusResponse(success=False, message=str(e))
@app.post("/login", tags=["User"], description="用户登录", response_model=LoginResponse) @app.post(
"/api/login",
tags=["User"],
description="用户登录",
response_model=LoginResponse,
)
async def login(login_info: LoginRequest): 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: if user:
token = uuid.uuid4().hex token = uuid.uuid4().hex
sessions[token] = time.time(), user sessions[token] = time.time(), user
logger.info(sessions) logger.info(sessions)
return LoginResponse(data={ return LoginResponse(data={"token": token, "user": user.json(del_columns=["password"])})
"token": token,
"user": user.json(del_columns=["password"])
})
return LoginResponse(success=False, message="帐号或密码错误") return LoginResponse(success=False, message="帐号或密码错误")
@app.post("/logout", tags=["User"], description="退出登录", response_model=BaseResponse) @app.post(
async def logout(token:str): "/api/logout",
tags=["User"],
description="退出登录",
response_model=BaseResponse,
)
async def logout(token: str):
sessions.pop(token, None) sessions.pop(token, None)
logger.info(sessions) logger.info(sessions)
return BaseResponse() return BaseResponse()
@app.post("/run", tags=["Flow"], description="执行命令", response_model=BaseResponse) @app.get(
async def flow_run(id: int): "/api/current_user",
cmd_node = F.CMDNode() tags=["User"],
cmd_node.set_params({"cmd": "ls"}) description="当前用户信息",
response_model=LoginResponse,
)
async def current_user(user: DB.User = Depends(get_current_user)):
return LoginResponse(data=user.json(del_columns=["password"]))
flow = F.AsyncFlow(cmd_node) @app.post(
flow.set_params({"cmd": """ls && pwd"""}) "/api/run",
await flow.run_async({}) tags=["Flow"],
description="执行命令",
response_model=BaseResponse,
)
async def flow_run(work_uuid: str):
work = await DB.Work.filter(uuid=work_uuid).first()
if not work:
return BaseResponse(success=False, message="任务不存在")
task_id = work_uuid
if task_id in app.state.tasks:
return BaseResponse(success=False, message="已有任务正在执行...")
async def _update_node_status(task_id: str, node_id: str, status: str):
task_nodes = app.state.task_nodes
logger.info(f"task:{task_id} node:{node_id} status:{status}")
if task_id in task_nodes and node_id in task_nodes[task_id]:
task_nodes[task_id][node_id] = status
async def _task(task_id: str, work: DB.Work):
try:
work_content = await DB.WorkContent.filter(work_id=work.id).first()
if not work_content:
logger.warning(f"没有找到对应的内容: {work.uuid}")
return
nodes = []
params = {}
status = {}
for x in work_content.content:
node_id = x["uuid"]
node_type = x["type"]
node_info = x["detail"]
params[node_id] = node_info
status[node_id] = "wait"
if node_type == "git":
node = F.GitNode()
node.set_info(node_id, lambda k, v: _update_node_status(task_id, k, v))
nodes.append(node)
elif node_type == "cmd":
node = F.CmdNode()
node.set_info(node_id, lambda k, v: _update_node_status(task_id, k, v))
nodes.append(node)
nodes.pop()
for i in range(len(nodes) - 1):
nodes[i] - "true" >> nodes[i + 1]
flow = F.AsyncFlow(nodes[0])
flow.set_params(params)
shared = {}
app.state.task_nodes[task_id] = status
await flow.run_async(shared)
app.state.tasks.pop(task_id, None)
app.state.task_nodes.pop(task_id, None)
except asyncio.CancelledError:
logger.debug(f"任务被取消: {task_id}")
app.state.tasks.pop(task_id, None)
app.state.tasks[task_id] = asyncio.create_task(_task(task_id, work))
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)
app.state.task_nodes.pop(task_id, None)
if task:
task.cancel()
return BaseResponse() return BaseResponse()
@app.get(
"/api/tasks",
tags=["Flow"],
description="任务列表",
response_model=BaseResponse,
)
async def task_list():
global_tasks = app.state.tasks
global_status = app.state.task_nodes
work_list = await DB.Work.filter(is_deleted=0).order_by('-id')
work_content_list = await DB.WorkContent.filter(work_id__in=[x.id for x in work_list]).all()
res = []
for work in work_list:
obj = work.json()
content = next((x for x in work_content_list if x.work_id == work.id), None)
if content:
obj["content"] = [{k: v for k, v in _c.items() if k != "detail"} for _c in (content.content or [])]
obj["is_running"] = work.uuid in global_tasks
obj["status"] = global_status[work.uuid] if work.uuid in global_status else {}
res.append(obj)
return BaseResponse(data=res)
return app return app

View File

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

View File

@ -8,6 +8,12 @@ class BaseModel(Model):
def json(self, del_columns=None): def json(self, del_columns=None):
user_json = dict(self) user_json = dict(self)
user_json["create_time"] = (
self.create_time.strftime("%Y-%m-%d %H:%M:%S") if self.create_time else ""
)
user_json["update_time"] = (
self.update_time.strftime("%Y-%m-%d %H:%M:%S") if self.update_time else ""
)
if del_columns is not None: if del_columns is not None:
for column in del_columns: for column in del_columns:
try: try:

View File

@ -5,6 +5,7 @@ class User(BaseModel):
username = fields.CharField(max_length=50, null=True) username = fields.CharField(max_length=50, null=True)
password = fields.CharField(max_length=128, null=True) password = fields.CharField(max_length=128, null=True)
level = fields.IntField(default=1, null=True, description="0:超级用户 1:普通用户") level = fields.IntField(default=1, null=True, description="0:超级用户 1:普通用户")
is_deleted = fields.IntField(default=0, null=True)
class Meta: class Meta:
table = "tb_users" 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 pydantic import BaseModel
from typing import Any
class BaseResponse(BaseModel): class BaseResponse(BaseModel):
data: Any | None = None
success: bool = True success: bool = True
message: str | None = None message: str | None = None
class InitConfigRequest(BaseModel): class InitConfigRequest(BaseModel):
db_type: str = "sqlite" db_type: str = "sqlite"
admin_password: str = "admin" admin_password: str = "admin"

View File

@ -10,4 +10,4 @@ class LoginRequest(BaseModel):
return hashlib.md5(self.password.encode()).hexdigest() return hashlib.md5(self.password.encode()).hexdigest()
class LoginResponse(BaseResponse): class LoginResponse(BaseResponse):
data: dict | None data: dict | None = None

View File

@ -1,2 +1,3 @@
from core.pocket_flow import AsyncFlow from core.pocket_flow import AsyncFlow
from .cmd_node import * from .cmd_node import *
from .git_node import *

View File

@ -1,3 +1,4 @@
from fastapi import FastAPI
from core.pocket_flow import AsyncNode from core.pocket_flow import AsyncNode
from core.logger import logger from core.logger import logger
import asyncio import asyncio
@ -5,45 +6,157 @@ import asyncssh
async def async_run_cmd( async def async_run_cmd(
cmd: str, host: str = None, user: str = None, password: str = None cmd: str,
host: str = None,
user: str = None,
password: str = None,
port: int = None,
sudo: bool = False,
): ):
""" """
异步执行命令 异步执行命令
- 如果 host=None则在本地执行 - 如果 host=None则在本地执行
- 如果 host 不为空则通过 SSH 执行远程命令 - 如果 host 不为空则通过 SSH 执行远程命令
""" """
if host is None:
async def read_stream(stream, stream_type):
async for line in stream:
text = line
if not isinstance(text, str):
text = line.decode()
yield stream_type, text.rstrip()
if not host:
# 本地执行 # 本地执行
proc = await asyncio.create_subprocess_shell( proc = await asyncio.create_subprocess_shell(
cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
) )
stdout, stderr = await proc.communicate()
return proc.returncode, stdout.decode(), stderr.decode() tasks = [
read_stream(proc.stdout, "stdout"),
read_stream(proc.stderr, "stderr"),
]
for t in tasks:
async for stream_type, line in t:
yield stream_type, line
await proc.wait()
else: else:
# 远程执行 try:
conn = await asyncssh.connect( conn = await asyncssh.connect(
host, host,
username=user, username=user,
password=password, password=password,
known_hosts=None, # 不检查 known_hosts port=port,
) known_hosts=None,
result = await conn.run(cmd, check=False) )
await conn.close() except (asyncssh.PermissionDenied, asyncssh.DisconnectError) as e:
return result.exit_status, result.stdout, result.stderr # 认证失败 / 连接断开
yield "stderr", f"SSH connection failed: {str(e)}"
return
except Exception as e:
# 其它异常
yield "stderr", f"SSH error: {str(e)}"
return
try:
# 执行脚本
remote_cmd = cmd
if sudo:
remote_cmd = f"echo {password!r} | sudo -S -p '' bash -c '{cmd}'"
# 执行
proc = await conn.create_process(remote_cmd, term_type="xterm")
# 分别创建 stdout/stderr 的异步生成器
stdout_gen = read_stream(proc.stdout, "stdout")
stderr_gen = read_stream(proc.stderr, "stderr")
# 用 task 管理两个流
stdout_task = asyncio.create_task(stdout_gen.__anext__())
stderr_task = asyncio.create_task(stderr_gen.__anext__())
while True:
if stdout_task is None and stderr_task is None:
break
# 等待任意一个流先返回
done, _ = await asyncio.wait(
[t for t in (stdout_task, stderr_task) if t is not None],
return_when=asyncio.FIRST_COMPLETED,
)
for fut in done:
try:
stream_type, line = fut.result()
yield stream_type, line
# 继续拉取该流的下一行
if stream_type == "stdout":
stdout_task = asyncio.create_task(stdout_gen.__anext__())
else:
stderr_task = asyncio.create_task(stderr_gen.__anext__())
except StopAsyncIteration:
# 该流已结束
if fut == stdout_task:
stdout_task = None
elif fut == stderr_task:
stderr_task = None
await proc.wait()
finally:
conn.close()
await conn.wait_closed()
class CMDNode(AsyncNode): class CmdNode(AsyncNode):
def set_info(self, id, callback):
self.id = id
self.callback = callback
async def prep_async(self, shared): async def prep_async(self, shared):
pass if self.callback:
await self.callback(self.id, "exec")
return {
"cmd": self.params[self.id].get("cmd"),
"host": self.params[self.id].get("host"),
"user": self.params[self.id].get("user"),
"password": self.params[self.id].get("password"),
"port": self.params[self.id].get("port"),
"sudo": self.params[self.id].get("sudo", False),
}
async def exec_async(self, prep_res): async def exec_async(self, prep_res):
cmd = self.params.get("cmd") if self.callback:
_, out, err = await async_run_cmd(cmd) await self.callback(self.id, "exec")
if out:
logger.info(out) err = []
if err: out = []
logger.error(err)
return 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)
# 补充一下密码失效问题
if "Sorry, try again." in out_str and not err_str:
err_str = "SSH connection failed no password was provided"
return {"err": err_str, "out": out_str}
async def post_async(self, shared, prep_res, exec_res): async def post_async(self, shared, prep_res, exec_res):
pass shared[self.id] = exec_res
result = "true"
if exec_res.get("err"):
result = "false"
if self.callback:
await self.callback(self.id, "post")
return result

View File

@ -0,0 +1,106 @@
from fastapi import FastAPI
from core.pocket_flow import AsyncNode
from core.logger import logger
from .cmd_node import async_run_cmd
from pathlib import Path
class GitNode(AsyncNode):
def set_info(self, id, callback):
self.id = id
self.callback = callback
async def prep_async(self, shared):
if self.callback:
await self.callback(self.id, "prep")
return {
"client": {
"host": self.params[self.id].get("host"),
"user": self.params[self.id].get("user"),
"password": self.params[self.id].get("password"),
"port": self.params[self.id].get("port"),
"sudo": self.params[self.id].get("sudo", False),
},
"git": {
"url": self.params[self.id].get("git_url"),
"user": self.params[self.id].get("git_user"),
"password": self.params[self.id].get("git_password"),
"branch": self.params[self.id].get("git_branch"),
"path": self.params[self.id].get("git_path"),
"clone_type": self.params[self.id].get("git_clone_type", "http/https"),
"depth": self.params[self.id].get("git_clone_depth", 1)
},
}
async def exec_async(self, prep_res):
if self.callback:
await self.callback(self.id, "exec")
client:dict = prep_res.get("client")
git:dict = prep_res.get("git")
path = git.get("path")
git_path = str(Path(path) / ".git")
parent_path = str(Path(path).parent)
clone_url:str = git.get('url')
clone_type = git.get("clone_type")
clone_branch = git.get("branch")
clone_depth = git.get("depth")
if clone_type == "http/https":
_info = clone_url.split("://")
user = git.get("user")
password = git.get("password")
clone_url = f"{_info[0]}://{user}:{password}@{_info[-1]}"
cmd = (
f"mkdir -p {parent_path} \n"
f"cd {parent_path} \n"
f"[ ! -d '{git_path}' ] && git clone -b {clone_branch} --single-branch --depth=1 --quiet {clone_url} \n"
f"cd {path} \n"
"git stash \n"
)
if clone_depth == 0:
cmd = (
f"{cmd} \n"
f"git fetch origin {clone_branch} --quiet \n"
f"git switch -C {clone_branch} FETCH_HEAD \n"
f"[ -e .git/shallow ] && git fetch --unshallow --quiet"
)
else:
cmd = (
f"{cmd} \n"
f"git fetch --depth={clone_depth} origin {clone_branch} --quiet \n"
f"git switch -C {clone_branch} FETCH_HEAD"
)
err = []
out = []
ignore_err = [f"Reset branch '{clone_branch}'"]
async for stream_type, line in async_run_cmd(**client, cmd=cmd):
if stream_type == "stdout" or line in ignore_err:
out.append(line)
else:
err.append(line)
err_str = "\n".join(err)
out_str = "\n".join(out)
# 补充一下密码失效问题
if "Sorry, try again." in out_str and not err_str:
err_str = "SSH connection failed no password was provided"
return {"err": err_str, "out": out_str}
async def post_async(self, shared, prep_res, exec_res):
shared[self.id] = exec_res
result = "true"
if exec_res.get("err"):
result = "false"
if self.callback:
await self.callback(self.id, "post")
return result

24
frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

1
frontend/.nvmrc Normal file
View File

@ -0,0 +1 @@
22.18.0

12
frontend/README.md Normal file
View File

@ -0,0 +1,12 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<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>工作流</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4217
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/pro-components": "^2.8.10",
"antd": "^5.27.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"vite": "^7.1.2"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

14
frontend/src/App.css Normal file
View File

@ -0,0 +1,14 @@
html,
body,
#root {
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';
}
div.no-select {
user-select: none;
}

7
frontend/src/App.jsx Normal file
View File

@ -0,0 +1,7 @@
import { useRoutes } from "react-router-dom";
import routes from "./routes";
export default function App() {
const element = useRoutes(routes);
return element;
}

26
frontend/src/api/base.js Normal file
View File

@ -0,0 +1,26 @@
export async function request(url, method, data) {
const options = {
method,
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + localStorage.getItem('_token') || ''
},
};
if (data) {
options.body = JSON.stringify(data);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return await response.json();
} catch (error) {
console.error("Request failed:", error);
throw error;
}
}

12
frontend/src/api/user.js Normal file
View File

@ -0,0 +1,12 @@
import { request } from "./base";
export const login = async (username, password) => {
return request("/api/login", "POST", { username, password })
}
export const currentUser = async () => {
return request("/api/current_user", "GET")
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,57 @@
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({
token: token,
...res?.data
})
}
} catch (error) {
navigate("/login")
}
setLoading(false);
}
const login = async (username, password) => {
const res = await loginToServer(username, password);
if (res?.success) {
localStorage.setItem('_token', res.data?.token);
setUser({
token: res?.data?.token,
...res?.data?.user,
})
}
return res;
};
const logout = () => {
localStorage.removeItem('_token')
setUser(null);
};
useEffect(() => {
token && getUserInfoFromToken();
}, [token])
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

56
frontend/src/index.css Normal file
View File

@ -0,0 +1,56 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

21
frontend/src/main.jsx Normal file
View File

@ -0,0 +1,21 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import { AuthProvider } from "./context/AuthContext";
import zhCN from "antd/locale/zh_CN";
import { ConfigProvider } from "antd";
import { ProConfigProvider } from "@ant-design/pro-components";
createRoot(document.getElementById('root')).render(
<BrowserRouter>
<AuthProvider>
<ConfigProvider locale={zhCN}>
<ProConfigProvider>
<App />
</ProConfigProvider>
</ConfigProvider>
</AuthProvider>
</BrowserRouter>
)

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

@ -0,0 +1,7 @@
import { Button, Card } from "antd"
export default () => {
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

@ -0,0 +1,108 @@
import React, { useState } from "react";
import { Outlet, useNavigate, useLocation } from "react-router-dom";
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, user } = useAuth();
const [collapsed, setCollapsed] = useState(true);
const navigate = useNavigate();
const location = useLocation();
const menuItems = [
{ key: "1", icon: <NodeExpandOutlined />, label: "工作台", path: "/flow" },
];
const selectedKey = menuItems.find(item => location.pathname.startsWith(item.path))?.key || "";
return (
<Layout className="page-layout">
<Sider trigger={null} collapsible collapsed={collapsed} width={200}>
<Logo collapsed={collapsed} />
<Menu
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
items={menuItems}
onClick={({ key }) => {
const item = menuItems.find(i => i.key === key);
if (item) navigate(item.path);
}}
/>
</Sider>
<Layout className="page-content">
<Header style={{ padding: 0, backgroundColor: 'white', width: "100%" }}>
<Flex align="center" justify="space-between" style={{
height: '100%',
padding: '0 8px',
boxSizing: 'border-box'
}}>
<Button
style={{ border: 'none'}}
type='text'
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
/>
<HeaderRight logout={logout} navigate={navigate} user={user} />
</Flex>
</Header>
<Content
style={{
margin: "24px 16px",
minHeight: 280,
}}
>
<Outlet /> {/* 渲染子路由 */}
</Content>
</Layout>
</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

@ -0,0 +1,16 @@
import { useEffect } from "react";
import { useAuth } from "../context/AuthContext";
import { useNavigate } from 'react-router-dom';
export default () => {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
user && navigate("/flow")
!user && !loading && navigate("/login")
}, [user])
return <></>
}

View File

@ -0,0 +1,30 @@
import { LoginFormPage, ProForm, ProFormText } from '@ant-design/pro-components';
import { message } from 'antd';
import { useAuth } from '../context/AuthContext';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
const REQUIRED = [{required: true}]
export default () => {
const navigate = useNavigate();
const { login } = useAuth();
return <div style={{ width: '100vw', height: '100vh'}}>
<LoginFormPage
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title="工作流"
onFinish={async (values) => {
const res = await login(values?.username, values?.password);
if (res?.success) {
navigate('/')
} else {
message.error(res?.message);
}
}}
>
<ProFormText name="username" label="帐号" rules={REQUIRED} />
<ProFormText.Password name="password" label="密码" rules={REQUIRED}/>
</LoginFormPage>
</div>
}

View File

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

View File

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

40
frontend/src/routes.jsx Normal file
View File

@ -0,0 +1,40 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuth } from "./context/AuthContext";
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";
//
function PrivateRoute() {
const { user, loading } = useAuth();
return (user || loading) ? <Outlet /> : <Navigate to="/login" replace />;
}
const routes = [
// 访
{ path: "/", element: <Loading /> },
{ path: "/login", element: <Login /> },
// 访
{
element: <PrivateRoute />,
children: [
{
element: <LayoutPage />,
children: [
{ path: "/flow", element: <Flow /> },
{ path: "/user", element: <User /> },
{ path: "/user/detail", element: <Profile /> }
]
}
],
},
];
export default routes;

16
frontend/vite.config.js Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": {
target: "http://0.0.0.0:8080", // 你的后端地址
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
})