fix: 修复多项后端缺陷并加固安全与限流

- 修正 AI 会话表初始化及异步数据库 URI 处理
- 新增全局 API 与 WS 聊天限流,健康检查豁免
- 修复忘记密码接口防用户枚举与参数校验
- 修复 SSE 推送数据重复编码问题
- 修复 Cron 表达式 Quartz 问号兼容
- 修复存储模块权限标识前缀
- 新增过期日志清理与任务错误中文提示
This commit is contained in:
zhangtao
2026-09-07 00:08:00 +08:00
parent 30dfc7097b
commit a8d29d4ab4
59 changed files with 989 additions and 891 deletions
+2
View File
@@ -54,3 +54,5 @@ LICENSE
# 数据库迁移(构建镜像时不包含,启动时动态执行) # 数据库迁移(构建镜像时不包含,启动时动态执行)
# 取消注释以加速构建:backend/alembic/versions # 取消注释以加速构建:backend/alembic/versions
# 用户上传文件(本地存储源根目录),不进镜像
backend/static
+3
View File
@@ -22,12 +22,15 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[Any, Any]:
from app.core.ap_scheduler import SchedulerUtil from app.core.ap_scheduler import SchedulerUtil
from app.core.database import async_engine from app.core.database import async_engine
from app.core.redis_crud import redis_connect from app.core.redis_crud import redis_connect
from app.modules.ai.chat.crud import init_agno_tables
from app.modules.system.dict.service import DictDataService from app.modules.system.dict.service import DictDataService
from app.modules.system.params.service import ParamsService from app.modules.system.params.service import ParamsService
from app.scripts.initialize import InitializeData from app.scripts.initialize import InitializeData
await InitializeData().init_db() await InitializeData().init_db()
logger.info(f"✅ {settings.DATABASE_TYPE} 连接初始化完成") logger.info(f"✅ {settings.DATABASE_TYPE} 连接初始化完成")
await init_agno_tables()
logger.info("✅ AI 会话表 初始化完成")
await redis_connect(app, status=True) await redis_connect(app, status=True)
logger.info("✅ Redis 连接初始化完成") logger.info("✅ Redis 连接初始化完成")
await ParamsService.init_cache(redis=app.state.redis) await ParamsService.init_cache(redis=app.state.redis)
+3 -2
View File
@@ -1,7 +1,8 @@
"""v1 路由总表""" """v1 路由总表"""
from fastapi import APIRouter from fastapi import APIRouter, Depends
from app.core.rate_limiter import api_rate_limiter
from app.modules.ai.chat.controller import ChatRouter from app.modules.ai.chat.controller import ChatRouter
from app.modules.common.file.controller import FileRouter from app.modules.common.file.controller import FileRouter
from app.modules.generator.gencode.controller import GenRouter from app.modules.generator.gencode.controller import GenRouter
@@ -63,7 +64,7 @@ DOMAIN_CONTROLLERS: dict[str, list[APIRouter]] = {
"/common": [FileRouter], "/common": [FileRouter],
} }
api_v1 = APIRouter() api_v1 = APIRouter(dependencies=[Depends(api_rate_limiter)])
for prefix, controllers in DOMAIN_CONTROLLERS.items(): for prefix, controllers in DOMAIN_CONTROLLERS.items():
router = APIRouter(prefix=prefix) router = APIRouter(prefix=prefix)
for controller in controllers: for controller in controllers:
+4
View File
@@ -71,6 +71,10 @@ class Settings(BaseSettings):
SESSION_MAX_LIFETIME_SECONDS: int = 60 * 60 * 24 * 7 # 会话绝对存活上限(秒),滑动续期不得超过该上限 SESSION_MAX_LIFETIME_SECONDS: int = 60 * 60 * 24 * 7 # 会话绝对存活上限(秒),滑动续期不得超过该上限
LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = 60 # 登录限流窗口(秒) LOGIN_RATE_LIMIT_WINDOW_SECONDS: int = 60 # 登录限流窗口(秒)
LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 10 # 限流窗口内单 IP 最大登录尝试次数 LOGIN_RATE_LIMIT_MAX_ATTEMPTS: int = 10 # 限流窗口内单 IP 最大登录尝试次数
API_RATE_LIMIT_TIMES: int = 60 # 全局 API 限流:窗口内单 IP 单路由最大请求数
API_RATE_LIMIT_INTERVAL_MS: int = 60 * 1000 # 全局 API 限流窗口(pyrate-limiter 以毫秒计)
WS_RATE_LIMIT_TIMES: int = 1 # WS 聊天限流:窗口内最大对话消息数
WS_RATE_LIMIT_INTERVAL_MS: int = 5 * 1000 # WS 聊天限流窗口(毫秒)
# ================================================= # # ================================================= #
# ****************** 数据加密配置 ***************** # # ****************** 数据加密配置 ***************** #
+22 -1
View File
@@ -35,6 +35,27 @@ from app.core.logger import logger
JOB_STATUS_SUCCESS = 2 JOB_STATUS_SUCCESS = 2
JOB_STATUS_FAILED = 3 JOB_STATUS_FAILED = 3
# 常见执行失败模式的中文结论(匹配原始异常文本;任意第三方异常无法机翻,保留原文供诊断)
_JOB_ERROR_HINTS: list[tuple[str, str]] = [
("maximum number of running instances", "任务已达最大并发实例数,本次执行被跳过(可调大节点的 max_instances)"),
("was missed by", "任务错过计划执行时间(misfire,通常因进程繁忙或重启空窗)"),
("Insufficient Balance", "AI 模型账户余额不足"),
("Payment Required", "AI 模型服务计费失败(HTTP 402)"),
("Unauthorized", "上游认证失败(HTTP 401),请检查访问令牌"),
("rate limit", "触发上游限流(HTTP 429),请稍后重试"),
("Connection", "网络连接异常,请检查目标地址与网络"),
("Timeout", "请求超时"),
]
def _humanize_job_error(detail: Any) -> str:
"""把执行异常转成"中文结论|原始信息"回显;未命中已知模式时原样返回。"""
text = str(detail)[:4000]
for pattern, hint in _JOB_ERROR_HINTS:
if pattern in text:
return f"{hint}|原始信息: {text}"[:4000]
return text
# 多 worker 下单实例调度锁:所有进程共享同一个 RedisJobStore,若每个进程都自行 # 多 worker 下单实例调度锁:所有进程共享同一个 RedisJobStore,若每个进程都自行
# start,同一任务会被重复调度执行。仅持有锁的进程运行调度器并周期续期,其余进程 # start,同一任务会被重复调度执行。仅持有锁的进程运行调度器并周期续期,其余进程
# 周期争抢——持有者崩溃(锁过期)后自动接管。 # 周期争抢——持有者崩溃(锁过期)后自动接管。
@@ -396,7 +417,7 @@ class SchedulerUtil:
"job_name": job.name if job else None, "job_name": job.name if job else None,
"trigger_type": "manual" if is_temp else (SchedulerUtil._get_trigger_type(job_id) if job else "manual"), "trigger_type": "manual" if is_temp else (SchedulerUtil._get_trigger_type(job_id) if job else "manual"),
"status": status, "status": status,
"error": str(detail)[:4000] if status == JOB_STATUS_FAILED else None, "error": _humanize_job_error(detail) if status == JOB_STATUS_FAILED else None,
"result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None), "result": None if status == JOB_STATUS_FAILED else (str(detail)[:4000] if detail is not None else None),
"next_run_time": str(job.next_run_time) if job and job.next_run_time else None, "next_run_time": str(job.next_run_time) if job and job.next_run_time else None,
"job_state": SchedulerUtil._get_job_state(job) if job else None, "job_state": SchedulerUtil._get_job_state(job) if job else None,
+1
View File
@@ -86,6 +86,7 @@ def setup_logger() -> None:
"alembic.autogenerate", "alembic.autogenerate",
"alembic.runtime.plugins", "alembic.runtime.plugins",
"asyncio", "asyncio",
"websockets",
"sqlalchemy", "sqlalchemy",
"aiomysql", "aiomysql",
"tzlocal", "tzlocal",
+53
View File
@@ -0,0 +1,53 @@
"""全局 API 限流(fastapi-limiter 0.2.0 + pyrate-limiter)。
0.2.0 为破坏性重写:无 FastAPILimiter.init,直接构造 pyrate-limiter 的
Limiter(进程内内存桶,多 worker 时配额按进程独立);默认按 IP+路由路径
计数,超限抛 HTTP 429。单路由可用 @skip_limiter 豁免(如监控探针)。
ApiRateLimiter 兼容修复:0.2.0 的 RateLimiter.__call__ 遍历 app.routes
并直接访问 route.path,而 FastAPI 0.138 include_router 的惰性挂载产物
_IncludedRouter 没有 path 属性,会抛 AttributeError——改为直接使用请求
scope 中的路径与端点,语义等价(IP + 完整路径计数、支持 _skip_limiter)。
"""
import json
from fastapi import Request, Response
from fastapi_limiter.depends import RateLimiter, WebSocketRateLimiter
from pyrate_limiter import Limiter, Rate
from starlette.websockets import WebSocket
from app.config.setting import settings
limiter = Limiter(Rate(settings.API_RATE_LIMIT_TIMES, settings.API_RATE_LIMIT_INTERVAL_MS))
class ApiRateLimiter(RateLimiter):
"""兼容 FastAPI 0.138 惰性路由挂载的限流依赖。"""
async def __call__(self, request: Request, response: Response):
endpoint = request.scope.get("endpoint")
if endpoint is not None and getattr(endpoint, "_skip_limiter", False):
return
rate_key = await self.identifier(request)
key = f"{rate_key}:{request.scope['path']}"
success = await self.limiter.try_acquire_async(key, blocking=self.blocking)
if not success:
return await self.callback(request, response)
# 挂载点:api_v1 全局 dependencies(见 app/api/v1/routers.py)
api_rate_limiter = ApiRateLimiter(limiter=limiter)
async def ws_rate_limit_callback(ws: WebSocket) -> None:
"""WS 超限回调:WebSocket 中不能抛 HTTPException,改为下发提示并断开(1008 策略违规)。"""
await ws.send_text(json.dumps({"type": "rate_limited", "message": "发送过于频繁,请稍后再试"}))
await ws.close(code=1008, reason="rate limited")
# WS 聊天限流:每条对话消息在接收处调用一次(stop 等控制指令不限流)
ws_rate_limiter = WebSocketRateLimiter(
limiter=Limiter(Rate(settings.WS_RATE_LIMIT_TIMES, settings.WS_RATE_LIMIT_INTERVAL_MS)),
callback=ws_rate_limit_callback,
)
@@ -11,6 +11,7 @@ from app.core.base_schema import AuthSchema, PageResultSchema, PaginationQueryPa
from app.core.dependencies import AuthPermission, redis_getter, websocket_authenticate from app.core.dependencies import AuthPermission, redis_getter, websocket_authenticate
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
from app.core.logger import logger from app.core.logger import logger
from app.core.rate_limiter import ws_rate_limiter
from app.core.router_class import OperationLogRoute from app.core.router_class import OperationLogRoute
from .schema import ( from .schema import (
@@ -259,6 +260,8 @@ async def websocket_chat_controller(websocket: WebSocket) -> None:
continue continue
# 对话指令 # 对话指令
# 对话消息限流(stop 等控制指令不受限):超限时回调下发提示并断开连接
await ws_rate_limiter(websocket)
logger.info("收到聊天查询: session_id={}", query.session_id) logger.info("收到聊天查询: session_id={}", query.session_id)
is_generating.set() is_generating.set()
+18 -2
View File
@@ -32,18 +32,34 @@ def _get_agno_db() -> Any:
with _db_lock: with _db_lock:
if _db_instance is None: if _db_instance is None:
db_type = settings.DATABASE_TYPE db_type = settings.DATABASE_TYPE
db_uri = settings.DB_URI # agno 异步 DB 内部走 SQLAlchemy asyncio 扩展(create_async_engine),
# 必须用异步驱动 URI;传同步的 DB_URI(pymysql/psycopg)会报 InvalidRequestError
db_uri = settings.ASYNC_DB_URI
if db_type == "mysql": if db_type == "mysql":
_db_instance = AsyncMySQLDb(db_url=db_uri, db_schema=settings.DATABASE_NAME, create_schema=False) _db_instance = AsyncMySQLDb(db_url=db_uri, db_schema=settings.DATABASE_NAME, create_schema=False)
elif db_type == "postgres": elif db_type == "postgres":
_db_instance = AsyncPostgresDb(db_url=db_uri, db_schema="public", create_schema=False) _db_instance = AsyncPostgresDb(db_url=db_uri, db_schema="public", create_schema=False)
elif db_type == "sqlite": elif db_type == "sqlite":
_db_instance = AsyncSqliteDb(db_file=db_uri.replace("sqlite:///", "")) _db_instance = AsyncSqliteDb(db_file=db_uri.replace("sqlite+aiosqlite:///", ""))
else: else:
raise CustomException(msg=f"不支持的数据库类型: {db_type}") raise CustomException(msg=f"不支持的数据库类型: {db_type}")
return _db_instance return _db_instance
async def init_agno_tables() -> None:
"""应用启动时确保 agno 表结构存在(幂等,表已存在则跳过)。
agno 的 create_schema 仅控制是否自动建库,建表需调用方显式触发;
不初始化则首次读写会话时报 "Table has an invalid schema"。
"""
db = _get_agno_db()
try:
await db._create_all_tables()
except Exception as e:
# 多 worker 并发首启时可能同时建表,"已存在"类冲突可安全忽略
logger.warning(f"agno 表初始化告警(多为并发建表冲突,可忽略): {e}")
def _row_session_name(row: dict[str, Any]) -> str: def _row_session_name(row: dict[str, Any]) -> str:
"""从 deserialize=False 的原始会话行中提取会话标题(session_data 可能是 JSON 字符串或字典)""" """从 deserialize=False 的原始会话行中提取会话标题(session_data 可能是 JSON 字符串或字典)"""
session_data = row.get("session_data") session_data = row.get("session_data")
@@ -5,9 +5,11 @@ from typing import Annotated, Any
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi.sse import EventSourceResponse, ServerSentEvent from fastapi.sse import EventSourceResponse, ServerSentEvent
from fastapi_limiter.decorators import skip_limiter
from app.common.response import ResponseSchema, SuccessResponse from app.common.response import ResponseSchema, SuccessResponse
from app.config.setting import Settings, get_settings from app.config.setting import Settings, get_settings
from app.core.logger import logger
from app.core.router_class import OperationLogRoute from app.core.router_class import OperationLogRoute
from .schema import ServiceInfoOut from .schema import ServiceInfoOut
@@ -17,6 +19,7 @@ HealthRouter = APIRouter(route_class=OperationLogRoute, prefix="/health", tags=[
@HealthRouter.get("/check", summary="健康检查", response_model=ResponseSchema[ServiceInfoOut]) @HealthRouter.get("/check", summary="健康检查", response_model=ResponseSchema[ServiceInfoOut])
@skip_limiter # 监控探针高频调用,豁免全局限流
async def health_check(request: Request, settings: Annotated[Settings, Depends(get_settings)]) -> JSONResponse: async def health_check(request: Request, settings: Annotated[Settings, Depends(get_settings)]) -> JSONResponse:
"""健康检查:实时探测 DB / Redis 网络连通状态。""" """健康检查:实时探测 DB / Redis 网络连通状态。"""
redis: Any | None = getattr(request.app.state, "redis", None) redis: Any | None = getattr(request.app.state, "redis", None)
@@ -27,6 +30,7 @@ async def health_check(request: Request, settings: Annotated[Settings, Depends(g
@HealthRouter.get("/stream", summary="健康检查实时流(SSE)", response_class=EventSourceResponse) @HealthRouter.get("/stream", summary="健康检查实时流(SSE)", response_class=EventSourceResponse)
@skip_limiter
async def health_stream_controller( async def health_stream_controller(
request: Request, request: Request,
) -> AsyncGenerator[ServerSentEvent, None]: ) -> AsyncGenerator[ServerSentEvent, None]:
@@ -34,6 +38,11 @@ async def health_stream_controller(
redis: Any | None = getattr(request.app.state, "redis", None) redis: Any | None = getattr(request.app.state, "redis", None)
while True: while True:
info = await HealthService.collect(redis) info = await HealthService.collect(redis)
yield ServerSentEvent(data=info.model_dump_json()) # raw_data 原样发送;用 data= 会被 fastapi.sse 再 JSON 编码一次,前端需二次解析
logger.debug(
"健康检查 SSE 推送: DB={} Redis={}",
"正常" if info.db_status == 1 else "异常",
"正常" if info.redis_status == 1 else "异常",
)
yield ServerSentEvent(raw_data=info.model_dump_json())
await asyncio.sleep(30) # SSE 推送间隔(秒) await asyncio.sleep(30) # SSE 推送间隔(秒)
+38 -1
View File
@@ -1,13 +1,19 @@
from typing import Any from datetime import datetime, timedelta
from typing import Any, cast
from sqlalchemy import delete
from sqlalchemy.engine import CursorResult
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.base_schema import AuthSchema, PageResultSchema from app.core.base_schema import AuthSchema, PageResultSchema
from app.core.database import async_db_session
from app.core.exceptions import CustomException from app.core.exceptions import CustomException
from app.core.logger import logger
from app.utils.common_util import search_to_dict from app.utils.common_util import search_to_dict
from app.utils.excel_util import ExcelUtil from app.utils.excel_util import ExcelUtil
from .crud import LoginLogCRUD, OperationLogCRUD from .crud import LoginLogCRUD, OperationLogCRUD
from .model import LoginLogModel, OperationLogModel
from .schema import ( from .schema import (
LoginLogDetailOutSchema, LoginLogDetailOutSchema,
LoginLogOutSchema, LoginLogOutSchema,
@@ -123,3 +129,34 @@ class OperationLogService:
"created_id": "操作用户ID", "created_id": "操作用户ID",
} }
return await ExcelUtil.aexport_list2excel(list_data=operation_log_list, mapping_dict=mapping_dict) return await ExcelUtil.aexport_list2excel(list_data=operation_log_list, mapping_dict=mapping_dict)
async def cleanup_expired_logs(days: int = 90) -> dict:
"""按保留天数物理清理过期日志(供定时任务调用:无请求上下文,自建会话)。
参数:
- days (int): 日志保留天数,默认 90 天。
返回:
- dict: 各表清理条数统计。
"""
if days <= 0:
raise ValueError("日志保留天数必须大于 0")
cutoff = datetime.now() - timedelta(days=days)
async with async_db_session() as session:
# DML 语句运行时返回 CursorResult(含 rowcount),静态类型是 Result,需 cast 收窄
op_result = cast(CursorResult, await session.execute(delete(OperationLogModel).where(OperationLogModel.created_time < cutoff)))
login_result = cast(CursorResult, await session.execute(delete(LoginLogModel).where(LoginLogModel.created_time < cutoff)))
await session.commit()
# rowcount 类型为 int | None,未命中行时驱动可能返回 None,兜底为 0
stats = {
"days": days,
"operation_deleted": op_result.rowcount or 0,
"login_deleted": login_result.rowcount or 0,
}
logger.info(
f"日志保留清理完成(保留 {days} 天): 操作日志 {stats['operation_deleted']} 条,登录日志 {stats['login_deleted']} 条"
)
return stats
@@ -71,15 +71,14 @@ async def reset_password_controller(
return SuccessResponse(data=result_dict, msg="重置密码成功") return SuccessResponse(data=result_dict, msg="重置密码成功")
@UserRouter.post("/password/forget", summary="忘记密码", response_model=ResponseSchema[UserOutSchema]) @UserRouter.post("/password/forget", summary="忘记密码", response_model=ResponseSchema[None])
async def forget_password_controller( async def forget_password_controller(
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[UserForgetPasswordSchema, Body(description="忘记密码参数")], data: Annotated[UserForgetPasswordSchema, Body(description="忘记密码参数")],
) -> JSONResponse: ) -> JSONResponse:
auth = AuthSchema() auth = AuthSchema()
user_forget_password_result: UserOutSchema = await UserService(auth, db).forget_password(data=data) await UserService(auth, db).forget_password(data=data)
logger.info(f"{data.username} 重置密码成功") return SuccessResponse(msg="如果该账号存在,请联系管理员重置密码")
return SuccessResponse(data=user_forget_password_result, msg="重置密码成功")
@UserRouter.post("/register", summary="用户注册", response_model=ResponseSchema[UserOutSchema]) @UserRouter.post("/register", summary="用户注册", response_model=ResponseSchema[UserOutSchema])
+3 -8
View File
@@ -74,10 +74,11 @@ class CurrentUserUpdateSchema(BaseModel):
class UserForgetPasswordSchema(BaseModel): class UserForgetPasswordSchema(BaseModel):
"""忘记密码""" """忘记密码申请。仅接受用户名,不得携带新密码(改密走管理员 reset_password)。"""
model_config = ConfigDict(extra="ignore")
username: str = Field(..., min_length=3, max_length=32, description="用户名") username: str = Field(..., min_length=3, max_length=32, description="用户名")
new_password: str = Field(..., description=PASSWORD_FIELD_DESC)
@field_validator("username") @field_validator("username")
@classmethod @classmethod
@@ -91,12 +92,6 @@ class UserForgetPasswordSchema(BaseModel):
raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -") raise ValueError("账号需以字母开头,3-32 位,仅允许字母、数字、_ . -")
return v return v
@field_validator("new_password")
@classmethod
def validate_new_password(cls, value: str):
"""校验新密码:长度与复杂度"""
return password_validator(value)
class UserChangePasswordSchema(BaseModel): class UserChangePasswordSchema(BaseModel):
"""修改密码""" """修改密码"""
+9 -9
View File
@@ -294,16 +294,16 @@ class UserService:
await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash) await UserCRUD(self.auth, self.db).change_password(id=data.id, password_hash=new_password_hash)
return await self.detail(id=data.id) return await self.detail(id=data.id)
async def forget_password(self, data: UserForgetPasswordSchema) -> UserOutSchema: async def forget_password(self, data: UserForgetPasswordSchema) -> None:
user = await UserCRUD(self.auth, self.db).get_or_404(username=data.username) """接受忘记密码申请,但不根据用户名直接改密。
if user.status == 1:
raise CustomException(msg="用户已停用")
if user.is_superuser:
raise CustomException(msg="超级管理员密码不能重置")
new_password_hash = await PwdUtil.ahash_password(password=data.new_password) 未登录调用方不得设置新密码。管理员请使用 reset_password。
await UserCRUD(self.auth, self.db).change_password(id=user.id, password_hash=new_password_hash) 无论账号是否存在、是否停用、是否超管,均不返回可区分信息(防用户枚举)。
return await self.detail(id=user.id) """
user = await UserCRUD(self.auth, self.db).get(username=data.username)
if user is not None and user.status == 0 and not user.is_superuser:
logger.info("password reset requested for username={}", data.username)
return None
async def register(self, data: UserRegisterSchema) -> UserOutSchema: async def register(self, data: UserRegisterSchema) -> UserOutSchema:
"""用户注册""" """用户注册"""
@@ -207,11 +207,12 @@ def _build_job_trigger(
fields = cron_expr.split() fields = cron_expr.split()
if len(fields) not in (6, 7): if len(fields) not in (6, 7):
raise ValueError("无效的 Cron 表达式,格式: 秒 分 时 日 月 周 [年]") raise ValueError("无效的 Cron 表达式,格式: 秒 分 时 日 月 周 [年]")
# Quartz 风格的 ? 仅表示"该位无具体值",croniter 不支持,先统一规范化为 * 再校验
parsed = [field if field != "?" else "*" for field in fields]
try: try:
croniter(cron_expr) croniter(" ".join(parsed))
except (KeyError, ValueError): except (KeyError, ValueError):
raise ValueError(f"Cron表达式不正确: {cron_expr}") raise ValueError(f"Cron表达式不正确: {cron_expr}")
parsed = [field if field != "?" else "*" for field in fields]
if len(fields) == 6: if len(fields) == 6:
parsed.append("*") parsed.append("*")
second, minute, hour, day, month, day_of_week, year = parsed second, minute, hour, day, month, day_of_week, year = parsed
@@ -27,7 +27,7 @@ def _delete_temp_file(path: str) -> None:
@StorageBrowseRouter.post("/upload", summary="上传文件到存储源", response_model=ResponseSchema[StorageUploadResultSchema]) @StorageBrowseRouter.post("/upload", summary="上传文件到存储源", response_model=ResponseSchema[StorageUploadResultSchema])
async def upload_storage_file_controller( async def upload_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:upload"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:upload"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
file: Annotated[UploadFile, File(description="上传文件")], file: Annotated[UploadFile, File(description="上传文件")],
source_id: Annotated[int | None, Form(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Form(description="存储源ID(不传使用默认存储源)")] = None,
@@ -42,7 +42,7 @@ async def upload_storage_file_controller(
@StorageBrowseRouter.post("/download", summary="下载存储源文件", response_model=None) @StorageBrowseRouter.post("/download", summary="下载存储源文件", response_model=None)
async def download_storage_file_controller( async def download_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:download"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:download"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
remote_path: Annotated[str, Body(description="远端文件路径")], remote_path: Annotated[str, Body(description="远端文件路径")],
@@ -58,7 +58,7 @@ async def download_storage_file_controller(
@StorageBrowseRouter.post("/download_dir", summary="下载存储源目录(递归打包ZIP)", response_model=None) @StorageBrowseRouter.post("/download_dir", summary="下载存储源目录(递归打包ZIP)", response_model=None)
async def download_dir_storage_file_controller( async def download_dir_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:download"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:download"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
remote_path: Annotated[str, Body(description="远端目录路径")], remote_path: Annotated[str, Body(description="远端目录路径")],
@@ -74,7 +74,7 @@ async def download_dir_storage_file_controller(
@StorageBrowseRouter.delete("/delete", summary="删除存储源文件", response_model=ResponseSchema[None]) @StorageBrowseRouter.delete("/delete", summary="删除存储源文件", response_model=ResponseSchema[None])
async def delete_storage_file_controller( async def delete_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:delete"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
remote_path: Annotated[str, Body(description="远端文件路径")], remote_path: Annotated[str, Body(description="远端文件路径")],
source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None,
@@ -86,7 +86,7 @@ async def delete_storage_file_controller(
@StorageBrowseRouter.get("/list", summary="查询存储源文件列表", response_model=None) @StorageBrowseRouter.get("/list", summary="查询存储源文件列表", response_model=None)
async def list_storage_file_controller( async def list_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
source_id: Annotated[int | None, Query(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Query(description="存储源ID(不传使用默认存储源)")] = None,
prefix: Annotated[str | None, Query(description="目录前缀(可选)")] = None, prefix: Annotated[str | None, Query(description="目录前缀(可选)")] = None,
@@ -102,7 +102,7 @@ async def list_storage_file_controller(
@StorageBrowseRouter.get("/buckets", summary="查询存储源桶列表", response_model=ResponseSchema[list[str]]) @StorageBrowseRouter.get("/buckets", summary="查询存储源桶列表", response_model=ResponseSchema[list[str]])
async def list_storage_buckets_controller( async def list_storage_buckets_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
source_id: Annotated[int | None, Query(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Query(description="存储源ID(不传使用默认存储源)")] = None,
) -> JSONResponse: ) -> JSONResponse:
@@ -112,7 +112,7 @@ async def list_storage_buckets_controller(
@StorageBrowseRouter.post("/copy", summary="复制/移动文件", response_model=ResponseSchema[StoragePathResultSchema]) @StorageBrowseRouter.post("/copy", summary="复制/移动文件", response_model=ResponseSchema[StoragePathResultSchema])
async def copy_or_move_storage_file_controller( async def copy_or_move_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
source_id: Annotated[int | None, Body(description="源存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Body(description="源存储源ID(不传使用默认存储源)")] = None,
source_path: Annotated[str, Body(description="源文件路径")] = "", source_path: Annotated[str, Body(description="源文件路径")] = "",
@@ -134,7 +134,7 @@ async def copy_or_move_storage_file_controller(
@StorageBrowseRouter.put("/rename", summary="重命名/移动文件", response_model=ResponseSchema[StoragePathResultSchema]) @StorageBrowseRouter.put("/rename", summary="重命名/移动文件", response_model=ResponseSchema[StoragePathResultSchema])
async def rename_storage_file_controller( async def rename_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None,
source_path: Annotated[str, Body(description="原路径")] = "", source_path: Annotated[str, Body(description="原路径")] = "",
@@ -149,7 +149,7 @@ async def rename_storage_file_controller(
@StorageBrowseRouter.post("/mkdir", summary="新建目录", response_model=ResponseSchema[StoragePathCreateSchema]) @StorageBrowseRouter.post("/mkdir", summary="新建目录", response_model=ResponseSchema[StoragePathCreateSchema])
async def mkdir_storage_file_controller( async def mkdir_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None,
remote_dir: Annotated[str, Body(description="目录路径")] = "", remote_dir: Annotated[str, Body(description="目录路径")] = "",
@@ -161,7 +161,7 @@ async def mkdir_storage_file_controller(
@StorageBrowseRouter.post("/share", summary="生成分享链接", response_model=ResponseSchema[str | None]) @StorageBrowseRouter.post("/share", summary="生成分享链接", response_model=ResponseSchema[str | None])
async def share_storage_file_controller( async def share_storage_file_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:browse:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:browse:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
remote_path: Annotated[str, Body(description="远端文件路径")] = "", remote_path: Annotated[str, Body(description="远端文件路径")] = "",
source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None, source_id: Annotated[int | None, Body(description="存储源ID(不传使用默认存储源)")] = None,
@@ -24,7 +24,7 @@ StorageNodeRouter = APIRouter(route_class=OperationLogRoute, prefix="/storage/no
@StorageNodeRouter.get("/protocols", summary="查询支持的存储协议", response_model=ResponseSchema[list[StorageProtocolDefSchema]]) @StorageNodeRouter.get("/protocols", summary="查询支持的存储协议", response_model=ResponseSchema[list[StorageProtocolDefSchema]])
async def get_storage_protocols_controller( async def get_storage_protocols_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
) -> JSONResponse: ) -> JSONResponse:
result: list[StorageProtocolDefSchema] = [ result: list[StorageProtocolDefSchema] = [
StorageProtocolDefSchema(protocol=p.value, name=p.name, default_port=DEFAULT_PORTS[p]) StorageProtocolDefSchema(protocol=p.value, name=p.name, default_port=DEFAULT_PORTS[p])
@@ -35,7 +35,7 @@ async def get_storage_protocols_controller(
@StorageNodeRouter.get("/advanced-fields", summary="查询存储 SDK 高级配置字段定义", response_model=ResponseSchema[dict[str, list[AdvancedFieldDefSchema]]]) @StorageNodeRouter.get("/advanced-fields", summary="查询存储 SDK 高级配置字段定义", response_model=ResponseSchema[dict[str, list[AdvancedFieldDefSchema]]])
async def get_advanced_fields_controller( async def get_advanced_fields_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
) -> JSONResponse: ) -> JSONResponse:
"""返回按协议分组的 SDK 高级配置字段元数据,供前端「高级设置」面板按协议动态渲染。""" """返回按协议分组的 SDK 高级配置字段元数据,供前端「高级设置」面板按协议动态渲染。"""
return SuccessResponse(data=ADVANCED_FIELD_DEFS, msg="查询高级配置字段成功") return SuccessResponse(data=ADVANCED_FIELD_DEFS, msg="查询高级配置字段成功")
@@ -43,7 +43,7 @@ async def get_advanced_fields_controller(
@StorageNodeRouter.get("/page", summary="分页查询存储源", response_model=ResponseSchema[PageResultSchema[StorageNodeOutSchema]]) @StorageNodeRouter.get("/page", summary="分页查询存储源", response_model=ResponseSchema[PageResultSchema[StorageNodeOutSchema]])
async def get_storage_source_page_controller( async def get_storage_source_page_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()], page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[StorageNodeQueryParam, Query()], search: Annotated[StorageNodeQueryParam, Query()],
@@ -59,7 +59,7 @@ async def get_storage_source_page_controller(
@StorageNodeRouter.get("/list", summary="查询存储源列表", response_model=ResponseSchema[list[StorageNodeOutSchema]]) @StorageNodeRouter.get("/list", summary="查询存储源列表", response_model=ResponseSchema[list[StorageNodeOutSchema]])
async def get_storage_source_list_controller( async def get_storage_source_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[StorageNodeQueryParam, Query()], search: Annotated[StorageNodeQueryParam, Query()],
) -> JSONResponse: ) -> JSONResponse:
@@ -69,7 +69,7 @@ async def get_storage_source_list_controller(
@StorageNodeRouter.get("/detail/{id}", summary="查询存储源详情", response_model=ResponseSchema[StorageNodeOutSchema]) @StorageNodeRouter.get("/detail/{id}", summary="查询存储源详情", response_model=ResponseSchema[StorageNodeOutSchema])
async def get_storage_source_detail_controller( async def get_storage_source_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="存储源ID", ge=1)], id: Annotated[int, Path(description="存储源ID", ge=1)],
) -> JSONResponse: ) -> JSONResponse:
@@ -79,7 +79,7 @@ async def get_storage_source_detail_controller(
@StorageNodeRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建存储源", response_model=ResponseSchema[StorageNodeOutSchema]) @StorageNodeRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建存储源", response_model=ResponseSchema[StorageNodeOutSchema])
async def create_storage_source_controller( async def create_storage_source_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:create"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[StorageNodeCreateSchema, Body(description="存储源创建参数")], data: Annotated[StorageNodeCreateSchema, Body(description="存储源创建参数")],
) -> JSONResponse: ) -> JSONResponse:
@@ -89,7 +89,7 @@ async def create_storage_source_controller(
@StorageNodeRouter.put("/update/{id}", summary="修改存储源", response_model=ResponseSchema[StorageNodeOutSchema]) @StorageNodeRouter.put("/update/{id}", summary="修改存储源", response_model=ResponseSchema[StorageNodeOutSchema])
async def update_storage_source_controller( async def update_storage_source_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="存储源ID", ge=1)], id: Annotated[int, Path(description="存储源ID", ge=1)],
data: Annotated[StorageNodeUpdateSchema, Body(description="存储源修改参数")], data: Annotated[StorageNodeUpdateSchema, Body(description="存储源修改参数")],
@@ -100,7 +100,7 @@ async def update_storage_source_controller(
@StorageNodeRouter.delete("/delete", summary="删除存储源", response_model=ResponseSchema[None]) @StorageNodeRouter.delete("/delete", summary="删除存储源", response_model=ResponseSchema[None])
async def delete_storage_source_controller( async def delete_storage_source_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:delete"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="存储源ID列表")], ids: Annotated[list[int], Body(description="存储源ID列表")],
) -> JSONResponse: ) -> JSONResponse:
@@ -110,7 +110,7 @@ async def delete_storage_source_controller(
@StorageNodeRouter.post("/test/{id}", summary="测试存储源连接", response_model=ResponseSchema[bool]) @StorageNodeRouter.post("/test/{id}", summary="测试存储源连接", response_model=ResponseSchema[bool])
async def test_storage_source_controller( async def test_storage_source_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="存储源ID", ge=1)], id: Annotated[int, Path(description="存储源ID", ge=1)],
) -> JSONResponse: ) -> JSONResponse:
@@ -120,7 +120,7 @@ async def test_storage_source_controller(
@StorageNodeRouter.post("/test", summary="测试存储源连接(配置)", response_model=ResponseSchema[bool]) @StorageNodeRouter.post("/test", summary="测试存储源连接(配置)", response_model=ResponseSchema[bool])
async def test_storage_source_config_controller( async def test_storage_source_config_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:node:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:node:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[StorageNodeTestSchema, Body(description="存储源连接配置")], data: Annotated[StorageNodeTestSchema, Body(description="存储源连接配置")],
) -> JSONResponse: ) -> JSONResponse:
@@ -30,7 +30,7 @@ StorageTransferRouter = APIRouter(route_class=OperationLogRoute, prefix="/storag
@StorageTransferRouter.post("/task", summary="创建传输任务(远端源)", response_model=ResponseSchema[TransferTaskCreateResultSchema]) @StorageTransferRouter.post("/task", summary="创建传输任务(远端源)", response_model=ResponseSchema[TransferTaskCreateResultSchema])
async def create_transfer_task_controller( async def create_transfer_task_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:create"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[TransferTaskCreateSchema, Body(description="任务参数(远端源)")], data: Annotated[TransferTaskCreateSchema, Body(description="任务参数(远端源)")],
) -> JSONResponse: ) -> JSONResponse:
@@ -40,7 +40,7 @@ async def create_transfer_task_controller(
@StorageTransferRouter.post("/task/upload", summary="创建传输任务(本地上传源)", response_model=ResponseSchema[TransferTaskCreateResultSchema]) @StorageTransferRouter.post("/task/upload", summary="创建传输任务(本地上传源)", response_model=ResponseSchema[TransferTaskCreateResultSchema])
async def create_local_transfer_task_controller( async def create_local_transfer_task_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:create"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
file: Annotated[UploadFile, File(description="本地源文件")], file: Annotated[UploadFile, File(description="本地源文件")],
name: Annotated[str, Form(description="任务名称")], name: Annotated[str, Form(description="任务名称")],
@@ -58,7 +58,7 @@ async def create_local_transfer_task_controller(
@StorageTransferRouter.get("/task/page", summary="分页查询传输任务", response_model=ResponseSchema[PageResultSchema[TransferTaskOutSchema]]) @StorageTransferRouter.get("/task/page", summary="分页查询传输任务", response_model=ResponseSchema[PageResultSchema[TransferTaskOutSchema]])
async def get_transfer_task_page_controller( async def get_transfer_task_page_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()], page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[TransferTaskQueryParam, Query()], search: Annotated[TransferTaskQueryParam, Query()],
@@ -74,7 +74,7 @@ async def get_transfer_task_page_controller(
@StorageTransferRouter.get("/task/{id}", summary="查询传输任务详情", response_model=ResponseSchema[TransferTaskOutSchema]) @StorageTransferRouter.get("/task/{id}", summary="查询传输任务详情", response_model=ResponseSchema[TransferTaskOutSchema])
async def get_transfer_task_detail_controller( async def get_transfer_task_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="任务ID", ge=1)], id: Annotated[int, Path(description="任务ID", ge=1)],
) -> JSONResponse: ) -> JSONResponse:
@@ -84,7 +84,7 @@ async def get_transfer_task_detail_controller(
@StorageTransferRouter.post("/task/{id}/cancel", summary="取消传输任务", response_model=ResponseSchema[None]) @StorageTransferRouter.post("/task/{id}/cancel", summary="取消传输任务", response_model=ResponseSchema[None])
async def cancel_transfer_task_controller( async def cancel_transfer_task_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="任务ID", ge=1)], id: Annotated[int, Path(description="任务ID", ge=1)],
) -> JSONResponse: ) -> JSONResponse:
@@ -94,7 +94,7 @@ async def cancel_transfer_task_controller(
@StorageTransferRouter.delete("/task", summary="删除传输任务", response_model=ResponseSchema[None]) @StorageTransferRouter.delete("/task", summary="删除传输任务", response_model=ResponseSchema[None])
async def delete_transfer_task_controller( async def delete_transfer_task_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:delete"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="任务ID列表")], ids: Annotated[list[int], Body(description="任务ID列表")],
) -> JSONResponse: ) -> JSONResponse:
@@ -16,7 +16,7 @@ StorageWorkflowRouter = APIRouter(route_class=OperationLogRoute, prefix="/storag
@StorageWorkflowRouter.get("/page", summary="分页查询传输流程", response_model=ResponseSchema[PageResultSchema[WorkflowOutSchema]]) @StorageWorkflowRouter.get("/page", summary="分页查询传输流程", response_model=ResponseSchema[PageResultSchema[WorkflowOutSchema]])
async def get_flow_page_controller( async def get_flow_page_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
page: Annotated[PaginationQueryParam, Depends()], page: Annotated[PaginationQueryParam, Depends()],
search: Annotated[WorkflowQueryParam, Query()], search: Annotated[WorkflowQueryParam, Query()],
@@ -32,7 +32,7 @@ async def get_flow_page_controller(
@StorageWorkflowRouter.get("/list", summary="查询传输流程列表", response_model=ResponseSchema[list[WorkflowOutSchema]]) @StorageWorkflowRouter.get("/list", summary="查询传输流程列表", response_model=ResponseSchema[list[WorkflowOutSchema]])
async def get_flow_list_controller( async def get_flow_list_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
search: Annotated[WorkflowQueryParam, Query()], search: Annotated[WorkflowQueryParam, Query()],
) -> JSONResponse: ) -> JSONResponse:
@@ -42,7 +42,7 @@ async def get_flow_list_controller(
@StorageWorkflowRouter.get("/detail/{id}", summary="查询传输流程详情", response_model=ResponseSchema[WorkflowOutSchema]) @StorageWorkflowRouter.get("/detail/{id}", summary="查询传输流程详情", response_model=ResponseSchema[WorkflowOutSchema])
async def get_flow_detail_controller( async def get_flow_detail_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:query"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:query"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="流程ID", ge=1)], id: Annotated[int, Path(description="流程ID", ge=1)],
) -> JSONResponse: ) -> JSONResponse:
@@ -52,7 +52,7 @@ async def get_flow_detail_controller(
@StorageWorkflowRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建传输流程", response_model=ResponseSchema[WorkflowOutSchema]) @StorageWorkflowRouter.post("/create", status_code=status.HTTP_201_CREATED, summary="创建传输流程", response_model=ResponseSchema[WorkflowOutSchema])
async def create_flow_controller( async def create_flow_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:create"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
data: Annotated[WorkflowCreateSchema, Body(description="流程创建参数")], data: Annotated[WorkflowCreateSchema, Body(description="流程创建参数")],
) -> JSONResponse: ) -> JSONResponse:
@@ -62,7 +62,7 @@ async def create_flow_controller(
@StorageWorkflowRouter.put("/update/{id}", summary="修改传输流程", response_model=ResponseSchema[WorkflowOutSchema]) @StorageWorkflowRouter.put("/update/{id}", summary="修改传输流程", response_model=ResponseSchema[WorkflowOutSchema])
async def update_flow_controller( async def update_flow_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:update"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:update"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="流程ID", ge=1)], id: Annotated[int, Path(description="流程ID", ge=1)],
data: Annotated[WorkflowUpdateSchema, Body(description="流程修改参数")], data: Annotated[WorkflowUpdateSchema, Body(description="流程修改参数")],
@@ -73,7 +73,7 @@ async def update_flow_controller(
@StorageWorkflowRouter.delete("/delete", summary="删除传输流程", response_model=ResponseSchema[None]) @StorageWorkflowRouter.delete("/delete", summary="删除传输流程", response_model=ResponseSchema[None])
async def delete_flow_controller( async def delete_flow_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:flow:delete"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:flow:delete"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
ids: Annotated[list[int], Body(description="流程ID列表")], ids: Annotated[list[int], Body(description="流程ID列表")],
) -> JSONResponse: ) -> JSONResponse:
@@ -83,7 +83,7 @@ async def delete_flow_controller(
@StorageWorkflowRouter.post("/execute/{id}", summary="执行传输流程", response_model=ResponseSchema[list[int]]) @StorageWorkflowRouter.post("/execute/{id}", summary="执行传输流程", response_model=ResponseSchema[list[int]])
async def execute_flow_controller( async def execute_flow_controller(
auth: Annotated[AuthSchema, Security(AuthPermission(["module_storage:workflow:transfer:create"]))], auth: Annotated[AuthSchema, Security(AuthPermission(["module_task:storage:transfer:create"]))],
db: Annotated[AsyncSession, Depends(db_getter)], db: Annotated[AsyncSession, Depends(db_getter)],
id: Annotated[int, Path(description="流程ID", ge=1)], id: Annotated[int, Path(description="流程ID", ge=1)],
data: Annotated[WorkflowExecuteSchema | None, Body(description="执行参数(源文件/目录路径映射,可选)")] = None, data: Annotated[WorkflowExecuteSchema | None, Body(description="执行参数(源文件/目录路径映射,可选)")] = None,
+8 -1
View File
@@ -6,7 +6,7 @@ from sqlalchemy import func, inspect, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.common.enums import EnvironmentEnum from app.common.enums import EnvironmentEnum
from app.config.path_conf import ALEMBIC_VERSION_DIR, BASE_DIR, SCRIPT_DIR from app.config.path_conf import ALEMBIC_VERSION_DIR, BASE_DIR, SCRIPT_DIR, STATIC_DIR
from app.config.setting import settings from app.config.setting import settings
from app.core.base_model import MappedBase from app.core.base_model import MappedBase
from app.core.database import async_db_session, async_engine, create_tables from app.core.database import async_db_session, async_engine, create_tables
@@ -18,6 +18,8 @@ from app.modules.system.params.model import ParamsModel
from app.modules.system.role.model import RoleModel from app.modules.system.role.model import RoleModel
from app.modules.system.user.model import UserModel, UserRolesModel from app.modules.system.user.model import UserModel, UserRolesModel
from app.modules.system.versions.model import VersionModel from app.modules.system.versions.model import VersionModel
from app.modules.task.cronjob.node.model import NodeModel
from app.modules.task.storage.node.model import StorageNodeModel
from app.utils.import_util import ImportUtil from app.utils.import_util import ImportUtil
# 导入全部模型:与 alembic env.py 保持一致,确保全局 MapperRegistry 的 FK 引用可完整解析 # 导入全部模型:与 alembic env.py 保持一致,确保全局 MapperRegistry 的 FK 引用可完整解析
@@ -38,6 +40,8 @@ class InitializeData:
UserModel, UserModel,
UserRolesModel, UserRolesModel,
VersionModel, VersionModel,
NodeModel,
StorageNodeModel,
] ]
# 树形模型:JSON 含嵌套 children,需递归创建对象 # 树形模型:JSON 含嵌套 children,需递归创建对象
@@ -50,6 +54,9 @@ class InitializeData:
async with async_db_session() as session, session.begin(): async with async_db_session() as session, session.begin():
await self.__init_data(session) await self.__init_data(session)
# 内置本地存储源的根目录(种子节点 host 指向 static/upload),保证开箱可浏览
(STATIC_DIR / "upload").mkdir(parents=True, exist_ok=True)
@staticmethod @staticmethod
async def __apply_migrations() -> None: async def __apply_migrations() -> None:
"""将数据库 schema 带到模型定义的最新的版本(开源项目要求任意方言开箱即用)。""" """将数据库 schema 带到模型定义的最新的版本(开源项目要求任意方言开箱即用)。"""
+1
View File
@@ -44,6 +44,7 @@ dependencies = [
"esdk-obs-python==3.26.6", # 华为云 OBS 协议 "esdk-obs-python==3.26.6", # 华为云 OBS 协议
"alibabacloud-oss-v2==1.3.2", # 阿里云 OSS 协议 "alibabacloud-oss-v2==1.3.2", # 阿里云 OSS 协议
"cos-python-sdk-v5==1.9.44", # 腾讯云 COS 协议 "cos-python-sdk-v5==1.9.44", # 腾讯云 COS 协议
"fastapi-limiter==0.2.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
+1
View File
@@ -11,6 +11,7 @@ bleach==6.4.0 # 过滤 HTML 标签
tinycss2==1.5.1 # bleach CSS 清洗依赖 tinycss2==1.5.1 # bleach CSS 清洗依赖
click==8.1.7 # 命令行参数解析 click==8.1.7 # 命令行参数解析
fastapi==0.138.2 # fastapi 框架 fastapi==0.138.2 # fastapi 框架
fastapi-limiter==0.2.0 # 限频器
greenlet==3.5.2 # 协程核心依赖(SQLAlchemy 异步) greenlet==3.5.2 # 协程核心依赖(SQLAlchemy 异步)
httpx==0.28.1 # HTTP 客户端 httpx==0.28.1 # HTTP 客户端
jinja2==3.1.6 # 模板引擎 jinja2==3.1.6 # 模板引擎
+417 -444
View File
@@ -3332,7 +3332,7 @@
"title": "存储管理", "title": "存储管理",
"params": null, "params": null,
"affix": false, "affix": false,
"redirect": "/task/storage/workflow/flow", "redirect": "/task/storage/flow",
"description": "存储源管理、文件浏览、传输任务与传输流程编排", "description": "存储源管理、文件浏览、传输任务与传输流程编排",
"link": null, "link": null,
"is_iframe": false, "is_iframe": false,
@@ -3342,144 +3342,42 @@
"show_text_badge": null, "show_text_badge": null,
"children": [ "children": [
{ {
"name": "工作流", "name": "流程管理",
"type": 1, "type": 2,
"icon": "ri:tools-line", "icon": "ri:git-branch-line",
"order": 2, "order": 1,
"permission": null, "permission": "module_task:storage:flow:query",
"route_name": "WorkflowMgr", "route_name": "WorkflowFlow",
"route_path": "workflow", "route_path": "flow",
"component_path": null, "component_path": "module_task/storage/workflow/index",
"status": 0, "status": 0,
"keep_alive": true, "keep_alive": true,
"hidden": false, "hidden": false,
"always_show": true, "always_show": false,
"title": "工作流", "title": "流程管理",
"params": null, "params": null,
"affix": false, "affix": false,
"redirect": "/task/storage/workflow/flow", "redirect": null,
"description": "传输流程与节点管理", "description": "传输流程定义(源节点 + 多目标)",
"children": [ "children": [
{ {
"name": "流程管理", "name": "新增",
"type": 2, "type": 3,
"icon": "ri:git-branch-line", "icon": null,
"order": 1, "order": 1,
"permission": "module_storage:workflow:flow:query", "permission": "module_task:storage:flow:create",
"route_name": "WorkflowFlow", "route_name": null,
"route_path": "flow", "route_path": null,
"component_path": "module_storage/workflow/index", "component_path": null,
"status": 0, "status": 0,
"keep_alive": true, "keep_alive": true,
"hidden": false, "hidden": false,
"always_show": false, "always_show": false,
"title": "流程管理", "title": "新增",
"params": null, "params": null,
"affix": false, "affix": false,
"redirect": null, "redirect": null,
"description": "传输流程定义(源节点 + 多目标)", "description": "新增",
"children": [
{
"name": "新增",
"type": 3,
"icon": null,
"order": 1,
"permission": "module_storage:workflow:flow:create",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "新增",
"params": null,
"affix": false,
"redirect": null,
"description": "新增",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "编辑",
"type": 3,
"icon": null,
"order": 3,
"permission": "module_storage:workflow:flow:update",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "编辑",
"params": null,
"affix": false,
"redirect": null,
"description": "编辑",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "删除流程",
"type": 3,
"icon": null,
"order": 4,
"permission": "module_storage:workflow:flow:delete",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "删除流程",
"params": null,
"affix": false,
"redirect": null,
"description": "删除流程",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "查询",
"type": 3,
"icon": null,
"order": 6,
"permission": "module_storage:workflow:flow:query",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "查询",
"params": null,
"affix": false,
"redirect": null,
"description": "查询",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
}
],
"link": null, "link": null,
"is_iframe": false, "is_iframe": false,
"is_hide_tab": false, "is_hide_tab": false,
@@ -3488,247 +3386,23 @@
"show_text_badge": null "show_text_badge": null
}, },
{ {
"name": "节点管理", "name": "编辑",
"type": 2, "type": 3,
"icon": "ri:hard-drive-3-line", "icon": null,
"order": 2,
"permission": "module_storage:workflow:node:query",
"route_name": "WorkflowNode",
"route_path": "node",
"component_path": "module_storage/node/index",
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "节点管理",
"params": null,
"affix": false,
"redirect": null,
"description": "节点即传输地址(存储源)",
"children": [
{
"name": "新增",
"type": 3,
"icon": null,
"order": 1,
"permission": "module_storage:workflow:node:create",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "新增",
"params": null,
"affix": false,
"redirect": null,
"description": "新增",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "编辑",
"type": 3,
"icon": null,
"order": 2,
"permission": "module_storage:workflow:node:update",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "编辑",
"params": null,
"affix": false,
"redirect": null,
"description": "编辑",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "删除",
"type": 3,
"icon": null,
"order": 3,
"permission": "module_storage:workflow:node:delete",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "删除",
"params": null,
"affix": false,
"redirect": null,
"description": "删除",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "查询",
"type": 3,
"icon": null,
"order": 5,
"permission": "module_storage:workflow:node:query",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "查询",
"params": null,
"affix": false,
"redirect": null,
"description": "查询",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
}
],
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "文件浏览",
"type": 2,
"icon": "ri:folder-open-line",
"order": 3, "order": 3,
"permission": "module_storage:browse:query", "permission": "module_task:storage:flow:update",
"route_name": "WorkflowBrowse", "route_name": null,
"route_path": "browse", "route_path": null,
"component_path": "module_storage/browse/index", "component_path": null,
"status": 0, "status": 0,
"keep_alive": true, "keep_alive": true,
"hidden": false, "hidden": false,
"always_show": false, "always_show": false,
"title": "文件浏览", "title": "编辑",
"params": null, "params": null,
"affix": false, "affix": false,
"redirect": null, "redirect": null,
"description": "查看各节点内部文件", "description": "编辑",
"children": [
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "查询",
"order": 1,
"permission": "module_storage:browse:query",
"title": "查询",
"description": "查询"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "上传",
"order": 2,
"permission": "module_storage:browse:upload",
"title": "上传",
"description": "上传"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "下载",
"order": 3,
"permission": "module_storage:browse:download",
"title": "下载",
"description": "下载"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "删除",
"order": 4,
"permission": "module_storage:browse:delete",
"title": "删除",
"description": "删除"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "修改",
"order": 5,
"permission": "module_storage:browse:update",
"title": "修改",
"description": "修改"
}
],
"link": null, "link": null,
"is_iframe": false, "is_iframe": false,
"is_hide_tab": false, "is_hide_tab": false,
@@ -3737,107 +3411,406 @@
"show_text_badge": null "show_text_badge": null
}, },
{ {
"name": "传输任务", "name": "删除流程",
"type": 2, "type": 3,
"icon": "ri:swap-box-line", "icon": null,
"order": 4, "order": 4,
"permission": "module_storage:workflow:transfer:query", "permission": "module_task:storage:flow:delete",
"route_name": "WorkflowTransfer", "route_name": null,
"route_path": "transfer", "route_path": null,
"component_path": "module_storage/transfer/index", "component_path": null,
"status": 0, "status": 0,
"keep_alive": true, "keep_alive": true,
"hidden": false, "hidden": false,
"always_show": false, "always_show": false,
"title": "传输任务", "title": "删除流程",
"params": null, "params": null,
"affix": false, "affix": false,
"redirect": null, "redirect": null,
"description": "文件传输与流程执行", "description": "删除流程",
"children": [
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "查询",
"order": 1,
"permission": "module_storage:workflow:transfer:query",
"title": "查询",
"description": "查询"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "新增",
"order": 2,
"permission": "module_storage:workflow:transfer:create",
"title": "新增",
"description": "新增"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "修改",
"order": 3,
"permission": "module_storage:workflow:transfer:update",
"title": "修改",
"description": "修改"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "删除",
"order": 4,
"permission": "module_storage:workflow:transfer:delete",
"title": "删除",
"description": "删除"
}
],
"link": null, "link": null,
"is_iframe": false, "is_iframe": false,
"is_hide_tab": false, "is_hide_tab": false,
"active_path": null, "active_path": null,
"show_badge": false, "show_badge": false,
"show_text_badge": null "show_text_badge": null
},
{
"name": "查询",
"type": 3,
"icon": null,
"order": 6,
"permission": "module_task:storage:flow:query",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "查询",
"params": null,
"affix": false,
"redirect": null,
"description": "查询",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
}
],
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "节点管理",
"type": 2,
"icon": "ri:hard-drive-3-line",
"order": 2,
"permission": "module_task:storage:node:query",
"route_name": "WorkflowNode",
"route_path": "node",
"component_path": "module_task/storage/node/index",
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "节点管理",
"params": null,
"affix": false,
"redirect": null,
"description": "节点即传输地址(存储源)",
"children": [
{
"name": "新增",
"type": 3,
"icon": null,
"order": 1,
"permission": "module_task:storage:node:create",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "新增",
"params": null,
"affix": false,
"redirect": null,
"description": "新增",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "编辑",
"type": 3,
"icon": null,
"order": 2,
"permission": "module_task:storage:node:update",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "编辑",
"params": null,
"affix": false,
"redirect": null,
"description": "编辑",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "删除",
"type": 3,
"icon": null,
"order": 3,
"permission": "module_task:storage:node:delete",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "删除",
"params": null,
"affix": false,
"redirect": null,
"description": "删除",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "查询",
"type": 3,
"icon": null,
"order": 5,
"permission": "module_task:storage:node:query",
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "查询",
"params": null,
"affix": false,
"redirect": null,
"description": "查询",
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
}
],
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "文件浏览",
"type": 2,
"icon": "ri:folder-open-line",
"order": 3,
"permission": "module_task:storage:browse:query",
"route_name": "WorkflowBrowse",
"route_path": "browse",
"component_path": "module_task/storage/browse/index",
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "文件浏览",
"params": null,
"affix": false,
"redirect": null,
"description": "查看各节点内部文件",
"children": [
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "查询",
"order": 1,
"permission": "module_task:storage:browse:query",
"title": "查询",
"description": "查询"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "上传",
"order": 2,
"permission": "module_task:storage:browse:upload",
"title": "上传",
"description": "上传"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "下载",
"order": 3,
"permission": "module_task:storage:browse:download",
"title": "下载",
"description": "下载"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "删除",
"order": 4,
"permission": "module_task:storage:browse:delete",
"title": "删除",
"description": "删除"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "修改",
"order": 5,
"permission": "module_task:storage:browse:update",
"title": "修改",
"description": "修改"
}
],
"link": null,
"is_iframe": false,
"is_hide_tab": false,
"active_path": null,
"show_badge": false,
"show_text_badge": null
},
{
"name": "传输任务",
"type": 2,
"icon": "ri:swap-box-line",
"order": 4,
"permission": "module_task:storage:transfer:query",
"route_name": "WorkflowTransfer",
"route_path": "transfer",
"component_path": "module_task/storage/transfer/index",
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"title": "传输任务",
"params": null,
"affix": false,
"redirect": null,
"description": "文件传输与流程执行",
"children": [
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "查询",
"order": 1,
"permission": "module_task:storage:transfer:query",
"title": "查询",
"description": "查询"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "新增",
"order": 2,
"permission": "module_task:storage:transfer:create",
"title": "新增",
"description": "新增"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "修改",
"order": 3,
"permission": "module_task:storage:transfer:update",
"title": "修改",
"description": "修改"
},
{
"type": 3,
"icon": null,
"route_name": null,
"route_path": null,
"component_path": null,
"status": 0,
"keep_alive": true,
"hidden": false,
"always_show": false,
"params": null,
"affix": false,
"redirect": null,
"name": "删除",
"order": 4,
"permission": "module_task:storage:transfer:delete",
"title": "删除",
"description": "删除"
} }
], ],
"link": null, "link": null,
+17
View File
@@ -0,0 +1,17 @@
[
{
"name": "日志保留清理",
"code": "log_cleanup",
"jobstore": "default",
"executor": "threadpool",
"trigger": "cron",
"trigger_args": "0 0 3 * * ?",
"func": "def handler(**kwargs):\n import asyncio\n from app.modules.system.log.service import cleanup_expired_logs\n return asyncio.run(cleanup_expired_logs(days=int(kwargs.get(\"days\", 90))))",
"args": null,
"kwargs": "{\"days\": 90}",
"coalesce": true,
"max_instances": 1,
"status": 0,
"description": "每天 03:00 物理清理超过保留天数的操作日志与登录日志(默认保留 90 天,可改 kwargs.days)"
}
]
+12
View File
@@ -0,0 +1,12 @@
[
{
"name": "本地上传目录",
"protocol": "local",
"host": "static/upload",
"port": 0,
"path_prefix": null,
"is_default": true,
"status": 0,
"description": "系统内置本地存储:backend/static/upload 目录(随应用启动自动创建)"
}
]
+25 -1
View File
@@ -179,7 +179,7 @@ wheels = [
[[package]] [[package]]
name = "backend" name = "backend"
version = "2.0.0" version = "3.2.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "agno" }, { name = "agno" },
@@ -198,6 +198,7 @@ dependencies = [
{ name = "cryptography" }, { name = "cryptography" },
{ name = "esdk-obs-python" }, { name = "esdk-obs-python" },
{ name = "fastapi" }, { name = "fastapi" },
{ name = "fastapi-limiter" },
{ name = "greenlet" }, { name = "greenlet" },
{ name = "httpx" }, { name = "httpx" },
{ name = "jinja2" }, { name = "jinja2" },
@@ -263,6 +264,7 @@ requires-dist = [
{ name = "cryptography", specifier = "==50.0.0" }, { name = "cryptography", specifier = "==50.0.0" },
{ name = "esdk-obs-python", specifier = "==3.26.6" }, { name = "esdk-obs-python", specifier = "==3.26.6" },
{ name = "fastapi", specifier = "==0.138.2" }, { name = "fastapi", specifier = "==0.138.2" },
{ name = "fastapi-limiter", specifier = "==0.2.0" },
{ name = "greenlet", specifier = "==3.5.2" }, { name = "greenlet", specifier = "==3.5.2" },
{ name = "httpx", specifier = "==0.28.1" }, { name = "httpx", specifier = "==0.28.1" },
{ name = "jinja2", specifier = "==3.1.6" }, { name = "jinja2", specifier = "==3.1.6" },
@@ -854,6 +856,19 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/b3/38be2c074bdd0c986340db1d72d7b2321b805b1c5a68069aa00b5d31fd02/fastapi-0.138.2-py3-none-any.whl", hash = "sha256:db90c1ffb5517fba5d4a9f80e866daa008747e646310c9ce155c8c535f9d1615", size = 129271, upload-time = "2026-06-29T12:44:13.905Z" },
] ]
[[package]]
name = "fastapi-limiter"
version = "0.2.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "pyrate-limiter" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/32/81f64b69a79345caca0a90d1601ae7c84c39438145793514ee82daac4fc6/fastapi_limiter-0.2.0.tar.gz", hash = "sha256:b042259f6280056b5b2b3f506edd76c8e0648d7a5ce86c3485216a4a0fd0ec14", size = 7401, upload-time = "2026-02-06T07:00:09.831Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/9b/35278a18254f187d55bb450ded6bc9a59c7b3010d164e1796f3dfb7aa73d/fastapi_limiter-0.2.0-py3-none-any.whl", hash = "sha256:e576ebb29cc9614c2f38471c60ed0e849024e9a1b7a2aeb4c2a31a6d708b4aea", size = 5219, upload-time = "2026-02-06T07:00:07.824Z" },
]
[[package]] [[package]]
name = "gitdb" name = "gitdb"
version = "4.0.12" version = "4.0.12"
@@ -1599,6 +1614,15 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, { url = "https://pypi.tuna.tsinghua.edu.cn/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" },
] ]
[[package]]
name = "pyrate-limiter"
version = "4.5.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/43/48693393af06b9fffbaea6bb8fe03be3c3f17be5d1423dab347d1aad1dde/pyrate_limiter-4.5.0.tar.gz", hash = "sha256:098345fff3a52b84dee9bcf6973f184c8b3ef8d34e1f4f781ac0773e3984598b", size = 123240, upload-time = "2026-08-30T10:35:39.272Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/dc/02f88649feeabf4282c8c194a6f6060ca858041bab1204365b5d8ef8a95e/pyrate_limiter-4.5.0-py3-none-any.whl", hash = "sha256:e7320a715adf404cb7b2f399e05ceb59cae165275b21f7708eb1b9fd9608e91c", size = 57188, upload-time = "2026-08-30T10:35:38.069Z" },
]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" version = "9.0.2"
+21 -44
View File
@@ -2,11 +2,12 @@
set -euo pipefail set -euo pipefail
# ==================== 配置 ==================== # ==================== 配置 ====================
# 部署方式:本地开发完成后将项目目录上传至服务器(scp/FTP/面板均可),
# 再在服务器上执行本脚本完成构建与启动——脚本不再从 git 拉取代码。
PROJECT_NAME="FastapiAdmin" PROJECT_NAME="FastapiAdmin"
WORK_DIR="$(cd "$(dirname "$0")" && pwd)" WORK_DIR="$(cd "$(dirname "$0")" && pwd)"
DOCKER_DIR="${WORK_DIR}/docker" DOCKER_DIR="${WORK_DIR}/docker"
ENV_FILE="${DOCKER_DIR}/.env" ENV_FILE="${DOCKER_DIR}/.env"
GIT_REPO="https://gitee.com/fastapiadmin/${PROJECT_NAME}.git"
COLOR_GREEN='\033[0;32m'; COLOR_BLUE='\033[0;34m'; COLOR_YELLOW='\033[0;33m'; COLOR_RED='\033[0;31m'; COLOR_RESET='\033[0m' COLOR_GREEN='\033[0;32m'; COLOR_BLUE='\033[0;34m'; COLOR_YELLOW='\033[0;33m'; COLOR_RED='\033[0;31m'; COLOR_RESET='\033[0m'
@@ -28,6 +29,8 @@ load_env() {
elif [ -f "${DOCKER_DIR}/.env.example" ]; then elif [ -f "${DOCKER_DIR}/.env.example" ]; then
cp "${DOCKER_DIR}/.env.example" "${ENV_FILE}" cp "${DOCKER_DIR}/.env.example" "${ENV_FILE}"
set -a; source "${ENV_FILE}"; set +a set -a; source "${ENV_FILE}"; set +a
log "已从 .env.example 生成 ${ENV_FILE},请先填写密码等配置后重新执行" "WARN"
exit 1
else else
log ".env 文件不存在" "ERROR"; exit 1 log ".env 文件不存在" "ERROR"; exit 1
fi fi
@@ -35,11 +38,11 @@ load_env() {
} }
check_deps() { check_deps() {
for dir in "${DOCKER_DIR}/mysql/data" "${DOCKER_DIR}/redis/data"; do for dir in "${DOCKER_DIR}/mysql/data" "${DOCKER_DIR}/redis/data" "${DOCKER_DIR}/mysql/init" "${DOCKER_DIR}/redis/conf" "${WORK_DIR}/backend/static/upload"; do
[ -d "$dir" ] || mkdir -p "$dir" [ -d "$dir" ] || mkdir -p "$dir"
done done
local missing=() local missing=()
for cmd in git docker; do for cmd in docker; do
command -v $cmd &>/dev/null || missing+=($cmd) command -v $cmd &>/dev/null || missing+=($cmd)
done done
if ! docker compose version &>/dev/null && ! docker-compose --version &>/dev/null; then if ! docker compose version &>/dev/null && ! docker-compose --version &>/dev/null; then
@@ -51,43 +54,16 @@ check_deps() {
log "✅ 依赖检查通过" "SUCCESS" log "✅ 依赖检查通过" "SUCCESS"
} }
pull_code() { check_code() {
cd "${WORK_DIR}" # 代码由用户上传,这里仅校验关键文件齐全
local missing=()
local script_md5="" [ -f "${WORK_DIR}/backend/main.py" ] || missing+=("backend/main.py")
if command -v md5sum &>/dev/null; then [ -f "${DOCKER_DIR}/backend/Dockerfile" ] || missing+=("docker/backend/Dockerfile")
script_md5=$(md5sum "$0" | awk '{print $1}') [ -f "${DOCKER_DIR}/redis/conf/redis.conf" ] || missing+=("docker/redis/conf/redis.conf")
elif command -v md5 &>/dev/null; then if [ ${#missing[@]} -gt 0 ]; then
script_md5=$(md5 -q "$0") log "代码不完整,缺少: ${missing[*]}(请重新上传后重试)" "ERROR"; exit 1
fi fi
if [ -d ".git" ]; then log "✅ 代码完整性检查通过" "SUCCESS"
local branch
branch=$(git rev-parse --abbrev-ref HEAD)
log "分支: ${branch}"
local old_head
old_head=$(git rev-parse HEAD 2>/dev/null || echo "")
git fetch origin || true
git pull || { log "git pull 失败" "ERROR"; exit 1; }
local new_head
new_head=$(git rev-parse HEAD 2>/dev/null || echo "")
if [ -n "$old_head" ] && [ "$old_head" != "$new_head" ]; then
log "📋 本次拉取提交:"
git log --oneline --no-decorate "${old_head}..${new_head}" 2>/dev/null | sed 's/^/ /' || true
fi
# deploy.sh 自身有更新则重载
if [ -n "$script_md5" ]; then
local new_md5=""
command -v md5sum &>/dev/null && new_md5=$(md5sum "$0" | awk '{print $1}') || new_md5=$(md5 -q "$0")
if [ "$script_md5" != "$new_md5" ]; then
log "🔄 deploy.sh 已更新,重新执行..." "WARN"
exec "$0" "$@"
fi
fi
else
git init && git remote add origin "${GIT_REPO}"
git pull origin master || git pull origin main || { log "拉取代码失败" "ERROR"; exit 1; }
fi
log "✅ 代码已更新至 $(git log -1 --oneline)" "SUCCESS"
} }
build_image() { build_image() {
@@ -138,8 +114,10 @@ verify() {
} }
cleanup() { cleanup() {
docker system prune -a -f >/dev/null 2>&1 || true # 仅清理悬空镜像/网络/构建缓存;-a 会删掉服务器上其他项目的未使用镜像,慎用
log "✅ 所有资源已清理" "SUCCESS" docker image prune -f >/dev/null 2>&1 || true
docker builder prune -f >/dev/null 2>&1 || true
log "✅ 构建缓存已清理" "SUCCESS"
} }
# ==================== 主流程 ==================== # ==================== 主流程 ====================
@@ -149,8 +127,7 @@ full_deploy() {
log "时间: $(date '+%Y-%m-%d %H:%M:%S')" log "时间: $(date '+%Y-%m-%d %H:%M:%S')"
load_env load_env
check_deps check_deps
stop_service check_code
pull_code "$@"
build_image build_image
start_service start_service
verify verify
@@ -172,7 +149,7 @@ case ${1:-} in
clean) cleanup ;; clean) cleanup ;;
help|-h|--help) help|-h|--help)
echo "用法: $0 [命令]" echo "用法: $0 [命令]"
echo " 无参数 完整部署(拉代码→构建→启动→清理)" echo " 无参数 完整部署(校验代码→构建→启动→清理;代码需先上传至服务器)"
echo " start 启动服务" echo " start 启动服务"
echo " stop 停止服务" echo " stop 停止服务"
echo " restart 重启服务" echo " restart 重启服务"
+3
View File
@@ -13,6 +13,9 @@ REDIS_PORT=6379
# 后端配置 # 后端配置
BACKEND_PORT=8001 BACKEND_PORT=8001
BACKEND_IMAGE_TAG=3.0.0
# 日志级别(DEBUG/INFO/WARNING/ERROR)
LOGGER_LEVEL=INFO
# Nginx配置 # Nginx配置
HTTP_PORT=80 HTTP_PORT=80
+3 -8
View File
@@ -7,27 +7,22 @@
``` ```
docker/ docker/
├── backend/ # 后端服务配置 ├── backend/ # 后端服务配置
│ └── Dockerfile # 后端 Dockerfile(多阶段构建) │ └── Dockerfile # 后端 Dockerfile(依赖安装 + 代码复制,配合根 .dockerignore)
├── nginx/ # Nginx 配置 ├── nginx/ # Nginx 配置
│ ├── nginx.conf # Nginx 配置文件 │ ├── nginx.conf # Nginx 配置文件
│ ├── ssl/ # SSL 证书目录(放置 server.key / server.pem) │ ├── ssl/ # SSL 证书目录(放置 server.key / server.pem,.gitignore 已排除)
│ │ ├── server.key # SSL 私钥
│ │ └── server.pem # SSL 证书
│ ├── web/ # 前端静态文件(构建后自动放置 dist/) │ ├── web/ # 前端静态文件(构建后自动放置 dist/)
│ └── app/ + docs/ # 移动端 H5 / 文档站点(按需启用) │ └── app/ + docs/ # 移动端 H5 / 文档站点(按需启用)
├── mysql/ # MySQL 持久化 & 初始化 ├── mysql/ # MySQL 持久化 & 初始化
│ ├── init/ # 首次启动时执行的 SQL 脚本 │ ├── init/ # 首次启动时执行的 SQL 脚本(可选)
│ │ └── init.sql
│ └── data/ # 数据库数据文件(自动生成) │ └── data/ # 数据库数据文件(自动生成)
├── redis/ # Redis 持久化 & 配置 ├── redis/ # Redis 持久化 & 配置
│ ├── conf/ │ ├── conf/
│ │ └── redis.conf # Redis 持久化配置文件 │ │ └── redis.conf # Redis 持久化配置文件
│ └── data/ # Redis 数据文件(自动生成) │ └── data/ # Redis 数据文件(自动生成)
├── docker-compose.yaml # Docker Compose 编排文件 ├── docker-compose.yaml # Docker Compose 编排文件
├── docker-compose-example.yaml # 编排文件示例
├── .env # 环境变量配置文件(.gitignore 已排除) ├── .env # 环境变量配置文件(.gitignore 已排除)
├── .env.example # 环境变量示例文件 ├── .env.example # 环境变量示例文件
├── env.sh # 环境变量加载脚本
└── README.md # 本文档 └── README.md # 本文档
``` ```
+5 -2
View File
@@ -1,4 +1,4 @@
# 使用python:3.12-slim 镜像作为基础镜像 # 使用 python:3.12-slim 镜像作为基础镜像
FROM python:3.12-slim FROM python:3.12-slim
# 使用 LABEL 替代 MAINTAINER # 使用 LABEL 替代 MAINTAINER
@@ -10,12 +10,15 @@ ENV TZ Asia/Shanghai
# 设置容器内工作目录 # 设置容器内工作目录
WORKDIR /home WORKDIR /home
# 将当前主机目录全部文件复制至容器工作目录 # 先复制依赖清单并安装(利用 layer 缓存:代码变更时不重装依赖)
COPY ./backend/requirements.txt . COPY ./backend/requirements.txt .
# 安装依赖 # 安装依赖
RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple
# 复制应用代码(backend/.venv、logs、缓存等由根 .dockerignore 排除)
COPY ./backend/ .
# 暴露端口 # 暴露端口
EXPOSE 8001 EXPOSE 8001
+8 -6
View File
@@ -117,11 +117,14 @@ services:
ports: ports:
- "${BACKEND_PORT:-8001}:8001" - "${BACKEND_PORT:-8001}:8001"
volumes: volumes:
# ── 注意 ────────────────────────────────── # ── 开发热更新(可选)─────────────────────
# 以下挂载将宿主机代码覆盖容器内代码,实现热更新。 # 取消注释将宿主机代码覆盖容器内代码。生产环境保持注释,
# 生产环境建议注释掉该行,使用镜像内自带的代码。 # 使用镜像内自带的代码(Dockerfile 已 COPY backend)。
# ───────────────────────────────────────── # ─────────────────────────────────────────
- ../backend:/home # - ../backend:/home
# 本地存储源根目录持久化(种子节点 host=static/upload):
# 必须挂载,否则每次部署重建容器会丢失用户上传的文件
- ./backend/static/upload:/home/static/upload
depends_on: depends_on:
mysql: mysql:
condition: service_healthy condition: service_healthy
@@ -133,7 +136,7 @@ services:
test: test:
[ [
"CMD-SHELL", "CMD-SHELL",
"python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8001/monitor/health\")' || exit 1", "python -c 'import urllib.request; urllib.request.urlopen(\"http://localhost:8001/api/v1/monitor/health/check\", timeout=5)' || exit 1",
] ]
interval: 15s interval: 15s
timeout: 10s timeout: 10s
@@ -167,7 +170,6 @@ services:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/web:/usr/share/nginx/html/web:ro - ./nginx/web:/usr/share/nginx/html/web:ro
- ./nginx/ssl:/etc/nginx/ssl:ro - ./nginx/ssl:/etc/nginx/ssl:ro
# 按需启用(取消注释):
- ./nginx/app:/usr/share/nginx/html/app:ro - ./nginx/app:/usr/share/nginx/html/app:ro
- ./nginx/docs:/usr/share/nginx/html/docs:ro - ./nginx/docs:/usr/share/nginx/html/docs:ro
depends_on: depends_on:
View File
View File
+5 -1
View File
@@ -78,7 +78,8 @@ http {
# ==================== HTTPS Server ==================== # ==================== HTTPS Server ====================
server { server {
listen 443 ssl http2; listen 443 ssl;
http2 on;
server_name service.fastapiadmin.com; server_name service.fastapiadmin.com;
# ==================== SSL 配置 ==================== # ==================== SSL 配置 ====================
@@ -113,6 +114,9 @@ http {
# ==================== 后端 API 代理 ==================== # ==================== 后端 API 代理 ====================
location /api/v1 { location /api/v1 {
limit_req zone=api_limit burst=60 nodelay;
limit_conn conn_limit 100;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+17
View File
@@ -0,0 +1,17 @@
# FastapiAdmin Redis 配置(容器内路径 /usr/local/etc/redis/redis.conf)
# 密码由 docker-compose 的 command --requirepass 注入,勿写在此文件
# 持久化
appendonly yes
appendfsync everysec
save 900 1
save 300 10
save 60 10000
# 内存(与 compose 的 512m 容器限额匹配)
maxmemory 384mb
maxmemory-policy allkeys-lru
# 慢日志
slowlog-log-slower-than 10000
slowlog-max-len 128
View File
+1 -2
View File
@@ -169,10 +169,9 @@ const UserAPI = {
export default UserAPI export default UserAPI
/* 忘记密码表单(与后端 UserForgetPasswordSchema 一致,confirmPassword 为前端校验字段不提交) */ /* 忘记密码申请(与后端 UserForgetPasswordSchema 一致:仅用户名,后端不会直接改密) */
export interface ForgetPasswordForm { export interface ForgetPasswordForm {
username: string username: string
new_password: string
} }
/* 注册表单 */ /* 注册表单 */
+3 -35
View File
@@ -17,8 +17,6 @@ const submitting = ref(false)
const forgetFormRef = ref() const forgetFormRef = ref()
const forgetForm = reactive({ const forgetForm = reactive({
username: '', username: '',
new_password: '',
confirmPassword: '',
}) })
/** 与后端 UserForgetPasswordSchema 一致:字母开头,3-32 位,仅允许字母、数字、_ . - */ /** 与后端 UserForgetPasswordSchema 一致:字母开头,3-32 位,仅允许字母、数字、_ . - */
@@ -29,25 +27,15 @@ const forgetSchema: FormSchema = {
validate: (model) => { validate: (model) => {
const errors: Array<{ path: Array<string | number>, message: string }> = [] const errors: Array<{ path: Array<string | number>, message: string }> = []
const username = String(model.username ?? '').trim() const username = String(model.username ?? '').trim()
const password = String(model.new_password ?? '')
const confirmPassword = String(model.confirmPassword ?? '')
if (!username) if (!username)
errors.push({ path: ['username'], message: t('common.form.usernameRequired') }) errors.push({ path: ['username'], message: t('common.form.usernameRequired') })
else if (!USERNAME_REG.test(username)) else if (!USERNAME_REG.test(username))
errors.push({ path: ['username'], message: t('common.form.usernameFormat') }) errors.push({ path: ['username'], message: t('common.form.usernameFormat') })
if (!password)
errors.push({ path: ['new_password'], message: t('common.form.newPasswordRequired') })
else if (password.length < 6)
errors.push({ path: ['new_password'], message: t('common.form.passwordLength') })
if (!confirmPassword)
errors.push({ path: ['confirmPassword'], message: t('common.form.confirmNewRequired') })
else if (confirmPassword !== password)
errors.push({ path: ['confirmPassword'], message: t('common.form.mismatch') })
return errors return errors
}, },
} }
/** 重置密码成功后记住用户名,回登录页由用户用新密码登录 */ /** 提交忘记密码申请:后端不会直接改密,成功后提示联系管理员 */
async function handleSubmit() { async function handleSubmit() {
if (submitting.value) if (submitting.value)
return return
@@ -58,13 +46,13 @@ async function handleSubmit() {
const username = forgetForm.username.trim() const username = forgetForm.username.trim()
submitting.value = true submitting.value = true
try { try {
await UserAPI.forgetPassword({ username, new_password: forgetForm.new_password }) await UserAPI.forgetPassword({ username })
Storage.set(REMEMBER_ME_KEY, { username, remember: true }) Storage.set(REMEMBER_ME_KEY, { username, remember: true })
toast.success(t('forget.success')) toast.success(t('forget.success'))
uni.reLaunch({ url: '/pages/login/index' }) uni.reLaunch({ url: '/pages/login/index' })
} }
catch { catch {
// http 层已统一错误提示(如用户不存在) // http 层已统一错误提示
} }
finally { finally {
submitting.value = false submitting.value = false
@@ -94,26 +82,6 @@ function goLogin() {
prefix-icon="user" prefix-icon="user"
/> />
</wd-form-item> </wd-form-item>
<wd-form-item prop="new_password" custom-style="margin-bottom: 14rpx; padding-left: 0; padding-right: 0;">
<wd-input
v-model="forgetForm.new_password"
:placeholder="t('common.form.newPasswordPlaceholder')"
show-password
clearable
:compact="false"
prefix-icon="lock"
/>
</wd-form-item>
<wd-form-item prop="confirmPassword" custom-style="margin-bottom: 14rpx; padding-left: 0; padding-right: 0;">
<wd-input
v-model="forgetForm.confirmPassword"
:placeholder="t('common.form.confirmNewPlaceholder')"
show-password
clearable
:compact="false"
prefix-icon="lock"
/>
</wd-form-item>
</wd-form> </wd-form>
<wd-button type="primary" round block :loading="submitting" @click="handleSubmit"> <wd-button type="primary" round block :loading="submitting" @click="handleSubmit">
{{ submitting ? t('forget.submitting') : t('forget.submit') }} {{ submitting ? t('forget.submitting') : t('forget.submit') }}
@@ -141,8 +141,6 @@ export default UserAPI;
export interface ForgetPasswordForm { export interface ForgetPasswordForm {
username: string; username: string;
new_password: string;
confirmPassword: string;
} }
export interface RegisterForm { export interface RegisterForm {
@@ -6,7 +6,9 @@
<el-link type="primary" :href="modelValue" target="_blank" :underline="false"> <el-link type="primary" :href="modelValue" target="_blank" :underline="false">
{{ fileName }} {{ fileName }}
</el-link> </el-link>
<el-icon class="file-item-action" title="移除" @click="handleRemove"><CircleCloseFilled /></el-icon> <el-icon class="file-item-action" title="移除" @click="handleRemove"
><CircleCloseFilled
/></el-icon>
</div> </div>
<el-button v-else type="primary" plain :loading="uploading" @click="fileInputRef?.click()"> <el-button v-else type="primary" plain :loading="uploading" @click="fileInputRef?.click()">
<el-icon class="mr-1"><UploadFilled /></el-icon> <el-icon class="mr-1"><UploadFilled /></el-icon>
@@ -115,20 +117,20 @@ const doUpload = async (file: File) => {
.fa-file-upload .file-item { .fa-file-upload .file-item {
display: flex; display: flex;
align-items: center;
gap: 6px; gap: 6px;
align-items: center;
min-width: 0; min-width: 0;
} }
.fa-file-upload .file-item-icon { .fa-file-upload .file-item-icon {
color: var(--el-color-primary);
flex-shrink: 0; flex-shrink: 0;
color: var(--el-color-primary);
} }
.fa-file-upload .file-item-action { .fa-file-upload .file-item-action {
cursor: pointer;
color: var(--el-text-color-secondary);
flex-shrink: 0; flex-shrink: 0;
color: var(--el-text-color-secondary);
cursor: pointer;
} }
.fa-file-upload .file-item-action:hover { .fa-file-upload .file-item-action:hover {
@@ -138,7 +140,7 @@ const doUpload = async (file: File) => {
.fa-file-upload .file-tip { .fa-file-upload .file-tip {
margin-top: 4px; margin-top: 4px;
font-size: 12px; font-size: 12px;
color: var(--el-text-color-secondary);
line-height: 1.4; line-height: 1.4;
color: var(--el-text-color-secondary);
} }
</style> </style>
@@ -22,11 +22,12 @@
> >
<ElFormItem <ElFormItem
:prop="item.key" :prop="item.key"
:label="typeof item.label === 'string' ? item.label : undefined"
:label-width="item.label ? item.labelWidth || labelWidth : undefined" :label-width="item.label ? item.labelWidth || labelWidth : undefined"
> >
<template #label v-if="item.label"> <!-- 字符串 label 走 ElFormItem 默认渲染使 label-suffix 生效;组件 label 保留插槽 -->
<component v-if="typeof item.label !== 'string'" :is="item.label" /> <template v-if="item.label && typeof item.label !== 'string'" #label>
<span v-else>{{ item.label }}</span> <component :is="item.label" />
</template> </template>
<slot :name="item.key" :item="item" :modelValue="modelValue"> <slot :name="item.key" :item="item" :modelValue="modelValue">
<component <component
@@ -119,11 +120,12 @@
> >
<ElFormItem <ElFormItem
:prop="item.key" :prop="item.key"
:label="typeof item.label === 'string' ? item.label : undefined"
:label-width="item.label ? item.labelWidth || labelWidth : undefined" :label-width="item.label ? item.labelWidth || labelWidth : undefined"
> >
<template #label v-if="item.label"> <!-- 字符串 label 走 ElFormItem 默认渲染使 label-suffix 生效;组件 label 保留插槽 -->
<component v-if="typeof item.label !== 'string'" :is="item.label" /> <template v-if="item.label && typeof item.label !== 'string'" #label>
<span v-else>{{ item.label }}</span> <component :is="item.label" />
</template> </template>
<slot :name="item.key" :item="item" :modelValue="modelValue"> <slot :name="item.key" :item="item" :modelValue="modelValue">
<component <component
+3 -35
View File
@@ -5,8 +5,8 @@
* `fetchDataQuiet` 吞掉 reject,避免页面重复 try/catch。 * `fetchDataQuiet` 吞掉 reject,避免页面重复 try/catch。
* *
* 去重: * 去重:
* - 同实例:`inFlightDedupePromise` + `inFlightDedupeKey` * - 仅限单个 useTable 实例内部:`inFlightDedupePromise` + `inFlightDedupeKey`(同参进行中的请求复用 Promise)。
* - 跨实例(同参):模块级 `globalListNetworkInflight`(布局短时多挂载时合并为一条 HTTP) * - 不做跨实例去重:key 仅由参数生成,不同接口的同参请求会碰撞串台(PR #516)。
* *
* KeepAlive:`onDeactivated` 会取消请求并置 `tableViewActive`,失活期间 `fetchData` 直接返回当前快照不发 HTTP。 * KeepAlive:`onDeactivated` 会取消请求并置 `tableViewActive`,失活期间 `fetchData` 直接返回当前快照不发 HTTP。
*/ */
@@ -44,9 +44,6 @@ import {
} from "@utils"; } from "@utils";
import type { ColumnOption } from "@/types/component"; import type { ColumnOption } from "@/types/component";
/** 跨组件实例:同一 dedupeKey 仅一条进行中的网络请求 */
const globalListNetworkInflight = new Map<string, Promise<TableResponse<unknown>>>();
// --- 类型推导(由 apiFn / 响应类型反推记录类型) --- // --- 类型推导(由 apiFn / 响应类型反推记录类型) ---
type InferApiParams<T> = T extends (params: infer P) => any ? P : never; type InferApiParams<T> = T extends (params: infer P) => any ? P : never;
type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T; type UnwrapAxiosResponse<T> = T extends AxiosResponse<infer D> ? D : T;
@@ -389,26 +386,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
return inFlightDedupePromise; return inFlightDedupePromise;
} }
const sharedGlobal = globalListNetworkInflight.get(dedupeKey);
if (sharedGlobal) {
loadingState.value = "loading";
error.value = null;
try {
const standardResponse = (await sharedGlobal) as TableResponse<TRecord>;
commitNetworkSuccess(standardResponse, requestParams, useCache);
return standardResponse;
} catch (err) {
if (err instanceof Error && err.message === "请求已取消") {
loadingState.value = "idle";
return { records: [], total: 0, current: 1, size: 10 };
}
loadingState.value = "error";
data.value = [];
const tableError = handleError(err, "获取表格数据失败");
throw tableError;
}
}
if (!tableViewActive) { if (!tableViewActive) {
return { return {
records: [...data.value], records: [...data.value],
@@ -477,13 +454,6 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
} }
})(); })();
globalListNetworkInflight.set(dedupeKey, networkPromise as Promise<TableResponse<unknown>>);
networkPromise
.finally(() => {
globalListNetworkInflight.delete(dedupeKey);
})
.catch(() => {}); // 忽略取消/reject,仅用于清理全局 Map
inFlightDedupePromise = networkPromise; inFlightDedupePromise = networkPromise;
try { try {
@@ -826,10 +796,8 @@ function useTableImpl<TApiFn extends (params: any) => Promise<any>>(
removeColumn: columnConfig.removeColumn, removeColumn: columnConfig.removeColumn,
/** 切换列显示状态 */ /** 切换列显示状态 */
toggleColumn: columnConfig.toggleColumn, toggleColumn: columnConfig.toggleColumn,
/** 更新列配置 */ /** 更新列配置(支持单个或批量,批量传数组) */
updateColumn: columnConfig.updateColumn, updateColumn: columnConfig.updateColumn,
/** 批量更新列配置 */
batchUpdateColumns: columnConfig.batchUpdateColumns,
/** 重新排序列 */ /** 重新排序列 */
reorderColumns: columnConfig.reorderColumns, reorderColumns: columnConfig.reorderColumns,
/** 获取指定列配置 */ /** 获取指定列配置 */
@@ -183,29 +183,29 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
{ {
anchor: "page-workflow", anchor: "page-workflow",
title: "流程管理", title: "流程管理",
path: "module_storage/workflow/index.vue", path: "module_task/storage/workflow/index.vue",
notes: [ notes: [
"检索:FaSearchBar(可展开)。", "检索:FaSearchBar(可展开)。",
"工具栏:新增、批量删除、刷新、列配置。", "工具栏:新增、批量删除、刷新、列配置。",
"流程定义:源节点 + 目标节点列表(parallel 多目标 / chain 链式),目标路径随行配置。", "流程定义:源节点 + 目标节点列表(parallel 多目标 / chain 链式),目标路径随行配置。",
"权限:`module_storage:workflow:flow:create|delete|update` + 行级操作。", "权限:`module_task:storage:flow:create|delete|update` + 行级操作。",
], ],
}, },
{ {
anchor: "page-workflow-node", anchor: "page-workflow-node",
title: "节点管理", title: "节点管理",
path: "module_storage/node/index.vue", path: "module_task/storage/node/index.vue",
notes: [ notes: [
"检索:FaSearchBar(可展开)。", "检索:FaSearchBar(可展开)。",
"节点即传输地址(存储源):FTP/FTPS/SFTP/S3/OBS/OSS/COS/本地目录。", "节点即传输地址(存储源):FTP/FTPS/SFTP/S3/OBS/OSS/COS/本地目录。",
"行操作:打开(进入存储管理)、测试连接、详情、编辑、删除。", "行操作:打开(进入存储管理)、测试连接、详情、编辑、删除。",
"权限:`module_storage:workflow:node:create|delete|update` + 行级操作。", "权限:`module_task:storage:node:create|delete|update` + 行级操作。",
], ],
}, },
{ {
anchor: "page-workflow-storage", anchor: "page-workflow-storage",
title: "文件浏览", title: "文件浏览",
path: "module_storage/browse/index.vue", path: "module_task/storage/browse/index.vue",
notes: [ notes: [
"多节点标签浏览各节点内部文件(类似 IDE 标签栏)。", "多节点标签浏览各节点内部文件(类似 IDE 标签栏)。",
"文件操作:上传、新建目录、下载、复制/移动、重命名、分享、删除。", "文件操作:上传、新建目录、下载、复制/移动、重命名、分享、删除。",
@@ -215,7 +215,7 @@ export const MANUAL_MODULES_AFTER_SYSTEM: ManualModuleSection[] = [
{ {
anchor: "page-workflow-transfer", anchor: "page-workflow-transfer",
title: "传输任务", title: "传输任务",
path: "module_storage/transfer/index.vue", path: "module_task/storage/transfer/index.vue",
notes: [ notes: [
"创建传输任务:可选「从流程自动填充」源节点与多目标,或手动指定。", "创建传输任务:可选「从流程自动填充」源节点与多目标,或手动指定。",
"实时进度:WebSocket 推送任务/步骤状态与速度。", "实时进度:WebSocket 推送任务/步骤状态与速度。",
+22 -3
View File
@@ -408,7 +408,7 @@ const getInfo = async () => {
loading.value = true; loading.value = true;
const response = await CacheAPI.getCacheInfo(); const response = await CacheAPI.getCacheInfo();
cache.value = response.data.data || { info: {}, command_stats: [], db_size: 0 }; cache.value = response.data.data || { info: {}, command_stats: [], db_size: 0 };
initCharts(); void initCharts();
} catch (error: unknown) { } catch (error: unknown) {
if (import.meta.env.DEV) console.error("获取缓存监控数据失败:", error); if (import.meta.env.DEV) console.error("获取缓存监控数据失败:", error);
ElMessage.error("获取缓存监控数据失败"); ElMessage.error("获取缓存监控数据失败");
@@ -417,8 +417,27 @@ const getInfo = async () => {
} }
}; };
const initCharts = () => { /** 等待容器完成布局(路由过渡/首次渲染时 clientWidth/Height 可能为 0),避免 ECharts "Can't get DOM width or height" 警告 */
const waitForSize = (el: HTMLElement): Promise<void> =>
new Promise((resolve) => {
const check = () => {
// 组件卸载后 template ref 置 null,此时 ref 不再指向 el,直接结束避免悬挂循环
if (
(commandstats.value !== el && usedmemory.value !== el) ||
(el.clientWidth > 0 && el.clientHeight > 0)
) {
resolve();
return;
}
requestAnimationFrame(check);
};
check();
});
const initCharts = async () => {
if (!commandstats.value || !usedmemory.value) return; if (!commandstats.value || !usedmemory.value) return;
await Promise.all([waitForSize(commandstats.value), waitForSize(usedmemory.value)]);
if (!commandstats.value || !usedmemory.value) return; // 等待期间组件被卸载
commandstatsInstance = echarts.init(commandstats.value, "macarons"); commandstatsInstance = echarts.init(commandstats.value, "macarons");
usedmemoryInstance = echarts.init(usedmemory.value, "macarons"); usedmemoryInstance = echarts.init(usedmemory.value, "macarons");
@@ -480,7 +499,7 @@ onMounted(() => {
watch(activeTab, (tab) => { watch(activeTab, (tab) => {
if (tab === "0") { if (tab === "0") {
nextTick(() => initCharts()); nextTick(() => void initCharts());
} }
}); });
@@ -22,42 +22,6 @@
</template> </template>
</ElInput> </ElInput>
</ElFormItem> </ElFormItem>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="new_password">
<ElInput
v-model.trim="forgetForm.new_password"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.placeholder.password')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<ElTooltip :visible="isCapsLock" :content="$t('login.capsLock')" placement="right">
<ElFormItem prop="confirmPassword">
<ElInput
v-model.trim="forgetForm.confirmPassword"
class="custom-height"
type="password"
autocomplete="off"
show-password
clearable
:placeholder="$t('login.message.password.confirm')"
@keyup="checkCapsLock"
>
<template #prefix>
<ElIcon><Lock /></ElIcon>
</template>
</ElInput>
</ElFormItem>
</ElTooltip>
<div class="mt-6"> <div class="mt-6">
<ElButton <ElButton
class="h-11 w-full min-w-0 rounded-lg! text-base font-medium" class="h-11 w-full min-w-0 rounded-lg! text-base font-medium"
@@ -80,7 +44,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Lock, User } from "@element-plus/icons-vue"; import { User } from "@element-plus/icons-vue";
import type { ForgetPasswordForm } from "@/api/module_system/user"; import type { ForgetPasswordForm } from "@/api/module_system/user";
import type { FormRules } from "element-plus"; import type { FormRules } from "element-plus";
@@ -101,19 +65,9 @@ interface Emits {
toLogin: []; toLogin: [];
} }
const emit = defineEmits<Emits>(); defineEmits<Emits>();
const formRef = ref(); const formRef = ref();
const isCapsLock = ref(false);
function checkCapsLock(event: KeyboardEvent) {
if (event instanceof KeyboardEvent) {
isCapsLock.value = event.getModifierState("CapsLock");
if (event.key === "Enter") {
emit("submit");
}
}
}
defineExpose({ defineExpose({
validate: () => formRef.value?.validate?.(), validate: () => formRef.value?.validate?.(),
@@ -381,8 +381,6 @@ const registerForm = reactive<RegisterForm>({
const forgetForm = reactive<ForgetPasswordForm>({ const forgetForm = reactive<ForgetPasswordForm>({
username: "", username: "",
new_password: "",
confirmPassword: "",
}); });
const validateRegisterPassword = (_rule: unknown, value: string, callback: (e?: Error) => void) => { const validateRegisterPassword = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
@@ -422,29 +420,8 @@ const registerRules = computed<FormRules<RegisterForm>>(() => ({
], ],
})); }));
const validateForgetConfirm = (_rule: unknown, value: string, callback: (e?: Error) => void) => {
if (!value) {
callback(new Error(t("login.message.password.required")));
return;
}
if (value !== forgetForm.new_password) {
callback(new Error(t("login.message.password.inconformity")));
return;
}
callback();
};
const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({ const forgetRules = computed<FormRules<ForgetPasswordForm>>(() => ({
username: [{ required: true, message: t("login.message.username.required"), trigger: "blur" }], username: [{ required: true, message: t("login.message.username.required"), trigger: "blur" }],
new_password: [
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
],
confirmPassword: [
{ required: true, message: t("login.message.password.required"), trigger: "blur" },
{ min: 6, message: t("login.message.password.min"), trigger: "blur" },
{ validator: validateForgetConfirm, trigger: "blur" },
],
})); }));
const loginForm = reactive<LoginFormData>({ const loginForm = reactive<LoginFormData>({
@@ -671,11 +648,7 @@ async function submitForget() {
await forgetPanelRef.value.validate?.(); await forgetPanelRef.value.validate?.();
forgetLoading.value = true; forgetLoading.value = true;
await UserAPI.forgetPassword(forgetForm); await UserAPI.forgetPassword(forgetForm);
loginForm.username = forgetForm.username;
loginForm.password = forgetForm.new_password;
forgetForm.username = ""; forgetForm.username = "";
forgetForm.new_password = "";
forgetForm.confirmPassword = "";
setAuthPanel("login"); setAuthPanel("login");
} catch (error) { } catch (error) {
console.error("[Login] forget password:", error); console.error("[Login] forget password:", error);
@@ -276,9 +276,10 @@ const jobStoreOptions: OptionItem[] = [
{ label: "内存(Memory)", value: "memory" }, { label: "内存(Memory)", value: "memory" },
]; ];
/** 执行器选项(对应 ap_scheduler executors 配置,不再依赖字典) */ /** 执行器选项(对应 ap_scheduler executors 配置:default=AsyncIOExecutor / threadpool / processpool) */
const jobExecutorOptions: OptionItem[] = [ const jobExecutorOptions: OptionItem[] = [
{ label: "线程池", value: "default" }, { label: "异步协程(事件循环)", value: "default" },
{ label: "线程池", value: "threadpool" },
{ label: "进程池", value: "processpool" }, { label: "进程池", value: "processpool" },
]; ];
@@ -833,7 +834,7 @@ const initialFormData: Partial<NodeForm> = {
id: undefined, id: undefined,
name: "", name: "",
code: undefined, code: undefined,
jobstore: "sqlalchemy", jobstore: "default",
executor: "default", executor: "default",
func: defaultCodeBlock, func: defaultCodeBlock,
args: undefined, args: undefined,
@@ -931,7 +932,9 @@ async function handleSubmit() {
? formData.value.start_date || undefined ? formData.value.start_date || undefined
: undefined, : undefined,
end_date: end_date:
trig === "cron" || trig === "interval" ? formData.value.end_date || undefined : undefined, trig === "cron" || trig === "interval"
? formData.value.end_date || undefined
: undefined,
}; };
if (id) { if (id) {
await NodeAPI.updateNode(id, submitData); await NodeAPI.updateNode(id, submitData);
@@ -27,8 +27,8 @@
<template #left> <template #left>
<FaTableHeaderLeft <FaTableHeaderLeft
:remove-ids="selectedIds" :remove-ids="selectedIds"
:perm-create="['module_storage:workflow:node:create']" :perm-create="['module_task:storage:node:create']"
:perm-delete="['module_storage:workflow:node:delete']" :perm-delete="['module_task:storage:node:delete']"
:delete-loading="batchDeleting" :delete-loading="batchDeleting"
:create-loading="createLoading" :create-loading="createLoading"
@add="handleAdd" @add="handleAdd"
@@ -347,14 +347,14 @@ function buildSourceRowActions(
artType: "view", artType: "view",
icon: "ri:link", icon: "ri:link",
iconColor: "var(--el-color-primary)", iconColor: "var(--el-color-primary)",
perm: "module_storage:workflow:node:query", perm: "module_task:storage:node:query",
run: () => ctx.onTest(row.id!), run: () => ctx.onTest(row.id!),
}, },
{ {
key: "detail", key: "detail",
label: "详情", label: "详情",
artType: "view", artType: "view",
perm: "module_storage:workflow:node:query", perm: "module_task:storage:node:query",
run: () => ctx.onDetail(row.id!), run: () => ctx.onDetail(row.id!),
}, },
{ {
@@ -362,7 +362,7 @@ function buildSourceRowActions(
label: "编辑", label: "编辑",
artType: "edit", artType: "edit",
icon: "ri:edit-2-line", icon: "ri:edit-2-line",
perm: "module_storage:workflow:node:update", perm: "module_task:storage:node:update",
run: () => ctx.onEdit(row.id!), run: () => ctx.onEdit(row.id!),
}, },
{ {
@@ -370,7 +370,7 @@ function buildSourceRowActions(
label: "删除", label: "删除",
artType: "delete", artType: "delete",
icon: "ri:delete-bin-4-line", icon: "ri:delete-bin-4-line",
perm: "module_storage:workflow:node:delete", perm: "module_task:storage:node:delete",
run: () => ctx.onDelete(row.id!, row.name ?? ""), run: () => ctx.onDelete(row.id!, row.name ?? ""),
}, },
]; ];
@@ -458,7 +458,7 @@ async function handleTest(id: number) {
// 打开浏览:跳转到文件管理并携带存储源信息 // 打开浏览:跳转到文件管理并携带存储源信息
function handleOpen(row: SourceTable) { function handleOpen(row: SourceTable) {
router.push({ router.push({
path: "/task/storage/workflow/browse", path: "/task/storage/browse",
query: { source_id: row.id, name: row.name, protocol: row.protocol }, query: { source_id: row.id, name: row.name, protocol: row.protocol },
}); });
} }
@@ -34,121 +34,113 @@
:class="sseConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'" :class="sseConnected ? 'bg-(--el-color-success)' : 'bg-(--el-color-info)'"
/>{{ sseConnected ? "实时连接" : "连接中…" }} />{{ sseConnected ? "实时连接" : "连接中…" }}
</span> </span>
<el-button type="primary" @click="openCreate">新建传输任务</el-button> <ElButton
v-hasPerm="['module_task:storage:transfer:create']"
type="primary"
@click="openCreate"
>新建传输任务</ElButton
>
</div> </div>
</template> </template>
</FaTableHeader> </FaTableHeader>
<div class="min-h-0 flex-1"> <FaTable
<FaTable :data="tasks"
:data="tasks" :columns="columns"
:columns="columns" :pagination="{ current: page, size: pageSize, total }"
:pagination="{ current: page, size: pageSize, total }" @pagination:size-change="onPageSizeChange"
stripe @pagination:current-change="onPageChange"
border >
height="100%" <template #task_type="{ row }">
@pagination:size-change="onPageSizeChange" <ElTag :type="row.task_type === 'chain' ? 'warning' : 'primary'" size="small">
@pagination:current-change="onPageChange" {{ row.task_type === "chain" ? "链式" : "多目标" }}
> </ElTag>
<template #task_type="{ row }"> </template>
<el-tag :type="row.task_type === 'chain' ? 'warning' : 'primary'" size="small"> <template #source="{ row }">
{{ row.task_type === "chain" ? "链式" : "多目标" }} <div class="min-w-0">
</el-tag> <div>
</template> <ElTag v-if="row.source_type === 'local'" size="small" type="success">本地</ElTag>
<template #source="{ row }"> <ElTag v-else size="small" type="info">{{
<div class="min-w-0"> protoText(sourceById(row.source_id))
<div> }}</ElTag>
<el-tag v-if="row.source_type === 'local'" size="small" type="success">本地</el-tag> <span class="ml-1 text-(--el-text-color-regular)">{{
<el-tag v-else size="small" type="info">{{ row.source_type === "local"
protoText(sourceById(row.source_id)) ? row.source_name || "本地文件"
}}</el-tag> : sourceName(row.source_id)
<span class="ml-1 text-[#606266]">{{ }}</span>
row.source_type === "local"
? row.source_name || "本地文件"
: sourceName(row.source_id)
}}</span>
</div>
<div v-if="row.source_path" class="mt-0.5 truncate text-xs text-[#909399]">
{{ row.source_path }}
</div>
</div> </div>
</template> <div
<template #target="{ row }"> v-if="row.source_path"
<span class="text-[#606266]" :title="targetFullText(row as TransferTaskItem)">{{ class="mt-0.5 truncate text-xs text-(--el-text-color-secondary)"
targetShortText(row as TransferTaskItem) >
}}</span> {{ row.source_path }}
</template> </div>
<template #status="{ row }"> </div>
<el-tag :type="statusTypeMap[row.status as TransferStatus] || 'info'">{{ </template>
statusText[row.status as TransferStatus] || row.status <template #target="{ row }">
}}</el-tag> <span
</template> class="text-(--el-text-color-regular)"
<template #progress="{ row }"> :title="targetFullText(row as TransferTaskItem)"
<div class="flex flex-col gap-0.5"> >{{ targetShortText(row as TransferTaskItem) }}</span
<el-progress >
:percentage="row.progress || 0" </template>
:status="row.status === 'failed' ? 'exception' : undefined" <template #status="{ row }">
/> <ElTag :type="statusTypeMap[row.status as TransferStatus] || 'info'">{{
statusText[row.status as TransferStatus] || row.status
}}</ElTag>
</template>
<template #progress="{ row }">
<div class="flex flex-col gap-0.5">
<ElProgress
:percentage="row.progress || 0"
:status="row.status === 'failed' ? 'exception' : undefined"
/>
<div
v-if="row.status === 'running' || row.speed > 0"
class="flex items-center gap-1.5 text-xs"
>
<span
class="shrink-0 rounded-[3px] bg-(--el-color-success) px-1.25 py-0.5 text-[11px] leading-none text-white"
>{{ formatSpeed(row.speed) }}</span
>
<span class="whitespace-nowrap text-(--el-text-color-secondary)"
>{{ formatSize(row.transferred_size) }} / {{ formatSize(row.total_size) }}</span
>
</div>
</div>
</template>
<template #info="{ row }">
<div v-if="row.error_msg" class="flex min-w-0 items-center gap-1.5">
<ElPopover placement="top-start" width="360" trigger="hover">
<div <div
v-if="row.status === 'running' || row.speed > 0" class="max-h-75 overflow-auto break-all whitespace-pre-line text-(--el-color-danger) leading-[1.6]"
class="flex items-center gap-1.5 text-xs"
> >
<span {{ row.error_msg }}
class="shrink-0 rounded-[3px] bg-(--el-color-success) px-1.25 py-0.5 text-[11px] leading-none text-white"
>{{ formatSpeed(row.speed) }}</span
>
<span class="whitespace-nowrap text-(--el-text-color-secondary)"
>{{ formatSize(row.transferred_size) }} / {{ formatSize(row.total_size) }}</span
>
</div> </div>
</div> <template #reference>
</template> <span
<template #info="{ row }"> class="inline-block max-w-full truncate align-bottom text-(--el-color-danger)"
<div v-if="row.error_msg" class="flex min-w-0 items-center gap-1.5"> >{{ row.error_msg }}</span
<el-popover placement="top-start" width="360" trigger="hover">
<div
class="max-h-75 overflow-auto break-all whitespace-pre-line text-[#800000] leading-[1.6]"
> >
{{ row.error_msg }} </template>
</div> </ElPopover>
<template #reference> <ElButton
<span class="inline-block max-w-full truncate align-bottom text-[#800000]">{{ v-if="row.status === 'failed'"
row.error_msg
}}</span>
</template>
</el-popover>
<el-button
v-if="row.status === 'failed'"
size="small"
type="primary"
link
@click="copyMessage(row.error_msg)"
>复制</el-button
>
</div>
<span v-else class="ml-1 text-[#606266]">{{
stepSummaryText(row as TransferTaskItem)
}}</span>
</template>
<template #actions="{ row }">
<el-button size="small" link type="primary" @click="openDetail(row as TransferTaskItem)"
>详情</el-button
>
<el-button
v-if="row.status === 'pending' || row.status === 'running'"
size="small" size="small"
type="primary"
link link
type="warning" @click="copyMessage(row.error_msg)"
@click="cancelTask(row as TransferTaskItem)" >复制</ElButton
> >
取消 </div>
</el-button> <span v-else class="ml-1 text-(--el-text-color-regular)">{{
<el-button size="small" link type="danger" @click="removeTask(row as TransferTaskItem)" stepSummaryText(row as TransferTaskItem)
>删除</el-button }}</span>
> </template>
</template> <template #actions="{ row }">
</FaTable> <span v-html="formatTransferOperationCell(row as TransferTaskItem)" />
</div> </template>
</FaTable>
</ElCard> </ElCard>
<!-- 新建传输任务 --> <!-- 新建传输任务 -->
@@ -174,7 +166,7 @@
> >
<!-- 传输流程:具名插槽接管(需 @change 触发流程填充) --> <!-- 传输流程:具名插槽接管(需 @change 触发流程填充) -->
<template #flow_id> <template #flow_id>
<el-select <ElSelect
v-model="selectedFlowId" v-model="selectedFlowId"
placeholder="从已配置流程自动填充(可选,仍可手动调整)" placeholder="从已配置流程自动填充(可选,仍可手动调整)"
clearable clearable
@@ -182,18 +174,18 @@
style="width: 100%" style="width: 100%"
@change="applyFlow" @change="applyFlow"
> >
<el-option <ElOption
v-for="f in enabledFlows" v-for="f in enabledFlows"
:key="f.id" :key="f.id"
:value="f.id!" :value="f.id!"
:label="flowOptionLabel(f)" :label="flowOptionLabel(f)"
/> />
</el-select> </ElSelect>
</template> </template>
<!-- 本地文件:componentMap 无 upload 类型,具名插槽接管 --> <!-- 本地文件:componentMap 无 upload 类型,具名插槽接管 -->
<template #local_file> <template #local_file>
<el-upload <ElUpload
drag drag
:auto-upload="false" :auto-upload="false"
:limit="1" :limit="1"
@@ -203,35 +195,35 @@
style="width: 100%" style="width: 100%"
> >
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div> <div class="el-upload__text">将文件拖到此处,或 <em>点击选择文件</em></div>
</el-upload> </ElUpload>
</template> </template>
<!-- 目标列表:动态增删行,具名插槽接管 --> <!-- 目标列表:动态增删行,具名插槽接管 -->
<template #targets> <template #targets>
<div style="width: 100%"> <div style="width: 100%">
<div v-for="(t, idx) in form.targets" :key="idx" class="mb-2 flex items-center gap-2"> <div v-for="(t, idx) in form.targets" :key="idx" class="mb-2 flex items-center gap-2">
<el-tag <ElTag
v-if="form.task_type === 'chain'" v-if="form.task_type === 'chain'"
size="small" size="small"
:type="idx === 0 ? 'primary' : 'warning'" :type="idx === 0 ? 'primary' : 'warning'"
class="shrink-0" class="shrink-0"
> >
#{{ idx + 1 }} #{{ idx + 1 }}
</el-tag> </ElTag>
<el-select <ElSelect
v-model="t.target_id" v-model="t.target_id"
placeholder="目标存储源" placeholder="目标存储源"
filterable filterable
style="width: 250px" style="width: 250px"
> >
<el-option <ElOption
v-for="s in enabledSources" v-for="s in enabledSources"
:key="s.id" :key="s.id"
:label="`${s.name}(${s.protocol}${s.host ? ' · ' + s.host : ''})`" :label="`${s.name}(${s.protocol}${s.host ? ' · ' + s.host : ''})`"
:value="s.id!" :value="s.id!"
/> />
</el-select> </ElSelect>
<el-input <ElInput
v-model="t.target_path" v-model="t.target_path"
:placeholder=" :placeholder="
selectedFlowId && !t.target_path selectedFlowId && !t.target_path
@@ -240,15 +232,13 @@
" "
style="flex: 1" style="flex: 1"
/> />
<el-button link type="danger" @click="removeTarget(idx)">删除</el-button> <ElButton link type="danger" @click="removeTarget(idx)">删除</ElButton>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<el-button type="primary" plain size="small" @click="addTarget" <ElButton type="primary" plain size="small" @click="addTarget">+ 添加目标</ElButton>
>+ 添加目标</el-button
>
<span <span
v-if="form.task_type === 'chain' && form.targets.length > 1" v-if="form.task_type === 'chain' && form.targets.length > 1"
class="text-[#909399]" class="text-(--el-text-color-secondary)"
> >
将按顺序执行:{{ flowPreview }} 将按顺序执行:{{ flowPreview }}
</span> </span>
@@ -277,12 +267,12 @@
max-height="70vh" max-height="70vh"
> >
<template #name="{ row }"> <template #name="{ row }">
<el-tooltip :content="String(row?.name ?? '')" placement="top" :show-after="100"> <ElTooltip :content="String(row?.name ?? '')" placement="top" :show-after="100">
<div class="max-w-45 truncate">{{ row?.name }}</div> <div class="max-w-45 truncate">{{ row?.name }}</div>
</el-tooltip> </ElTooltip>
</template> </template>
<template #source="{ row }"> <template #source="{ row }">
<el-tooltip <ElTooltip
:content="row ? sourceShortText(row as unknown as TransferTaskItem) : ''" :content="row ? sourceShortText(row as unknown as TransferTaskItem) : ''"
placement="top" placement="top"
:show-after="100" :show-after="100"
@@ -290,7 +280,7 @@
<div class="max-w-55 truncate"> <div class="max-w-55 truncate">
{{ row ? sourceShortText(row as unknown as TransferTaskItem) : "" }} {{ row ? sourceShortText(row as unknown as TransferTaskItem) : "" }}
</div> </div>
</el-tooltip> </ElTooltip>
</template> </template>
<template #total_size="{ row }">{{ formatSize(row?.total_size as number) }}</template> <template #total_size="{ row }">{{ formatSize(row?.total_size as number) }}</template>
<template #speed="{ row }">{{ formatSpeed(row?.speed as number) }}</template> <template #speed="{ row }">{{ formatSpeed(row?.speed as number) }}</template>
@@ -314,12 +304,10 @@
class="rounded-md border border-(--el-border-color-lighter) bg-(--el-fill-color-lighter) px-3 py-2.5" class="rounded-md border border-(--el-border-color-lighter) bg-(--el-fill-color-lighter) px-3 py-2.5"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<el-tag :type="idx === 0 ? 'primary' : 'warning'" size="small" <ElTag :type="idx === 0 ? 'primary' : 'warning'" size="small">#{{ idx + 1 }}</ElTag>
>#{{ idx + 1 }}</el-tag <ElTag :type="statusTypeMap[step.status] || 'info'" size="small">{{
>
<el-tag :type="statusTypeMap[step.status] || 'info'" size="small">{{
statusText[step.status] || step.status statusText[step.status] || step.status
}}</el-tag> }}</ElTag>
<span class="ml-auto text-xs text-(--el-text-color-secondary)" <span class="ml-auto text-xs text-(--el-text-color-secondary)"
>{{ step.progress }}% · {{ formatSpeed(step.speed) }}</span >{{ step.progress }}% · {{ formatSpeed(step.speed) }}</span
> >
@@ -353,7 +341,7 @@
</div> </div>
</template> </template>
</div> </div>
<el-empty v-else description="暂无步骤" :image-size="60" /> <ElEmpty v-else description="暂无步骤" :image-size="60" />
</template> </template>
</FaDialog> </FaDialog>
</div> </div>
@@ -370,6 +358,7 @@ import TransferAPI, {
} from "@/api/module_storage/transfer"; } from "@/api/module_storage/transfer";
import NodeAPI, { type SourceTable } from "@/api/module_storage/node"; import NodeAPI, { type SourceTable } from "@/api/module_storage/node";
import FlowAPI, { type FlowTable } from "@/api/module_storage/workflow"; import FlowAPI, { type FlowTable } from "@/api/module_storage/workflow";
import { renderTableOperationCell, type TableOperationAction } from "@utils";
import FaDescriptions, { import FaDescriptions, {
type DescriptionsItem, type DescriptionsItem,
} from "@/components/display/fa-descriptions/index.vue"; } from "@/components/display/fa-descriptions/index.vue";
@@ -486,7 +475,13 @@ const columnChecks = ref<ColumnOption[]>([
{ prop: "status", label: "状态", width: 92, useSlot: true }, { prop: "status", label: "状态", width: 92, useSlot: true },
{ prop: "progress", label: "进度", minWidth: 240, useSlot: true }, { prop: "progress", label: "进度", minWidth: 240, useSlot: true },
{ prop: "info", label: "信息", minWidth: 180, useSlot: true }, { prop: "info", label: "信息", minWidth: 180, useSlot: true },
{ prop: "actions", label: "操作", width: 150, fixed: "right", useSlot: true }, {
prop: "actions",
label: "操作",
width: 150,
fixed: "right",
formatter: formatTransferOperationCell,
},
]); ]);
/** 渲染列:仅保留列设置中可见的列 */ /** 渲染列:仅保留列设置中可见的列 */
const columns = computed(() => columnChecks.value.filter((c) => c.visible !== false)); const columns = computed(() => columnChecks.value.filter((c) => c.visible !== false));
@@ -811,10 +806,46 @@ async function submit() {
} }
// ── 取消 / 删除 ─────────────────────────────────────────────────────── // ── 取消 / 删除 ───────────────────────────────────────────────────────
/** 操作列(与 demo 案例统一:renderTableOperationCell + 权限码) */
function buildTransferRowActions(row: TransferTaskItem): TableOperationAction[] {
const all: TableOperationAction[] = [
{
key: "detail",
label: "详情",
artType: "view",
perm: "module_task:storage:transfer:query",
run: () => void openDetail(row),
},
];
if (row.status === "pending" || row.status === "running") {
all.push({
key: "cancel",
label: "取消",
artType: "more",
icon: "ri:stop-circle-line",
iconColor: "var(--el-color-warning)",
perm: "module_task:storage:transfer:update",
run: () => void cancelTask(row),
});
}
all.push({
key: "delete",
label: "删除",
artType: "delete",
perm: "module_task:storage:transfer:delete",
run: () => void removeTask(row),
});
return all;
}
function formatTransferOperationCell(row: TransferTaskItem) {
return renderTableOperationCell(buildTransferRowActions(row), {
wrapperClass: "inline-flex flex-wrap items-center justify-end gap-1",
});
}
async function cancelTask(row: TransferTaskItem) { async function cancelTask(row: TransferTaskItem) {
if (!row.id) return; if (!row.id) return;
try { try {
await ElMessageBox.confirm(`确认取消任务「${row.name}」?`, "提示", { type: "warning" }); await confirmDelete(`确认取消任务「${row.name}」?取消后任务将停止传输。`);
} catch { } catch {
return; return;
} }
@@ -824,7 +855,7 @@ async function cancelTask(row: TransferTaskItem) {
async function removeTask(row: TransferTaskItem) { async function removeTask(row: TransferTaskItem) {
if (!row.id) return; if (!row.id) return;
try { try {
await ElMessageBox.confirm(`确认删除任务「${row.name}」?`, "提示", { type: "warning" }); await confirmDelete(`确认删除任务「${row.name}」吗?此操作不可恢复!`);
} catch { } catch {
return; return;
} }
@@ -27,8 +27,8 @@
<template #left> <template #left>
<FaTableHeaderLeft <FaTableHeaderLeft
:remove-ids="selectedIds" :remove-ids="selectedIds"
:perm-create="['module_storage:workflow:flow:create']" :perm-create="['module_task:storage:flow:create']"
:perm-delete="['module_storage:workflow:flow:delete']" :perm-delete="['module_task:storage:flow:delete']"
:delete-loading="batchDeleting" :delete-loading="batchDeleting"
:create-loading="createLoading" :create-loading="createLoading"
@add="handleAdd" @add="handleAdd"
@@ -358,7 +358,7 @@ function buildFlowRowActions(
key: "detail", key: "detail",
label: "查看", label: "查看",
artType: "view", artType: "view",
perm: "module_storage:workflow:flow:query", perm: "module_task:storage:flow:query",
run: () => ctx.onDetail(row.id!), run: () => ctx.onDetail(row.id!),
}, },
{ {
@@ -366,7 +366,7 @@ function buildFlowRowActions(
label: "设计", label: "设计",
artType: "edit", artType: "edit",
icon: "ri:pen-nib-line", icon: "ri:pen-nib-line",
perm: "module_storage:workflow:flow:update", perm: "module_task:storage:flow:update",
run: () => ctx.onEdit(row.id!), run: () => ctx.onEdit(row.id!),
}, },
{ {
@@ -375,7 +375,7 @@ function buildFlowRowActions(
artType: "view", artType: "view",
icon: "ri:play-line", icon: "ri:play-line",
iconColor: "var(--el-color-success)", iconColor: "var(--el-color-success)",
perm: "module_storage:workflow:transfer:create", perm: "module_task:storage:transfer:create",
run: () => ctx.onExecute(row), run: () => ctx.onExecute(row),
}, },
{ {
@@ -383,7 +383,7 @@ function buildFlowRowActions(
label: "删除", label: "删除",
artType: "delete", artType: "delete",
icon: "ri:delete-bin-4-line", icon: "ri:delete-bin-4-line",
perm: "module_storage:workflow:flow:delete", perm: "module_task:storage:flow:delete",
run: () => ctx.onDelete(row.id!, row.name ?? ""), run: () => ctx.onDelete(row.id!, row.name ?? ""),
}, },
]; ];