- 新增 platform_scheduled_tasks 和 platform_task_logs 数据表 - 实现 APScheduler 调度器服务(支持简单模式和CRON表达式) - 添加定时任务 CRUD API - 支持手动触发执行和查看执行日志 - 前端任务管理页面
This commit is contained in:
@@ -15,8 +15,10 @@ from .routers.alerts import router as alerts_router
|
||||
from .routers.cost import router as cost_router
|
||||
from .routers.quota import router as quota_router
|
||||
from .routers.tool_configs import router as tool_configs_router
|
||||
from .routers.tasks import router as tasks_router
|
||||
from .middleware import TraceMiddleware, setup_exception_handlers, RequestLoggerMiddleware
|
||||
from .middleware.trace import setup_logging
|
||||
from .services.scheduler import start_scheduler, shutdown_scheduler
|
||||
|
||||
# 配置日志(包含 TraceID)
|
||||
setup_logging(level=logging.INFO, include_trace=True)
|
||||
@@ -68,6 +70,27 @@ app.include_router(alerts_router, prefix="/api")
|
||||
app.include_router(cost_router, prefix="/api")
|
||||
app.include_router(quota_router, prefix="/api")
|
||||
app.include_router(tool_configs_router, prefix="/api")
|
||||
app.include_router(tasks_router, prefix="/api")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""应用启动时初始化调度器"""
|
||||
try:
|
||||
start_scheduler()
|
||||
logging.info("Scheduler started successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to start scheduler: {e}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""应用关闭时停止调度器"""
|
||||
try:
|
||||
shutdown_scheduler()
|
||||
logging.info("Scheduler shutdown successfully")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to shutdown scheduler: {e}")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
|
||||
356
backend/app/routers/tasks.py
Normal file
356
backend/app/routers/tasks.py
Normal file
@@ -0,0 +1,356 @@
|
||||
"""定时任务管理路由"""
|
||||
import asyncio
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, BackgroundTasks
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
|
||||
from ..database import get_db
|
||||
from .auth import get_current_user, require_operator
|
||||
from ..models.user import User
|
||||
from ..services.scheduler import (
|
||||
add_task_to_scheduler,
|
||||
remove_task_from_scheduler,
|
||||
reload_task,
|
||||
execute_task
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/scheduled-tasks", tags=["定时任务"])
|
||||
|
||||
|
||||
# Schemas
|
||||
|
||||
class TaskCreate(BaseModel):
|
||||
tenant_id: str
|
||||
task_name: str
|
||||
task_desc: Optional[str] = None
|
||||
schedule_type: str = "simple" # simple | cron
|
||||
time_points: Optional[List[str]] = None # ["09:00", "14:00"]
|
||||
cron_expression: Optional[str] = None # "0 9,14 * * *"
|
||||
webhook_url: str
|
||||
input_params: Optional[dict] = None
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
task_name: Optional[str] = None
|
||||
task_desc: Optional[str] = None
|
||||
schedule_type: Optional[str] = None
|
||||
time_points: Optional[List[str]] = None
|
||||
cron_expression: Optional[str] = None
|
||||
webhook_url: Optional[str] = None
|
||||
input_params: Optional[dict] = None
|
||||
|
||||
|
||||
# API Endpoints
|
||||
|
||||
@router.get("")
|
||||
async def list_tasks(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
tenant_id: Optional[str] = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取定时任务列表"""
|
||||
# 构建查询
|
||||
where_clauses = []
|
||||
params = {}
|
||||
|
||||
if tenant_id:
|
||||
where_clauses.append("tenant_id = :tenant_id")
|
||||
params["tenant_id"] = tenant_id
|
||||
|
||||
where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
|
||||
|
||||
# 查询总数
|
||||
count_result = db.execute(
|
||||
text(f"SELECT COUNT(*) FROM platform_scheduled_tasks WHERE {where_sql}"),
|
||||
params
|
||||
)
|
||||
total = count_result.scalar()
|
||||
|
||||
# 查询列表
|
||||
params["offset"] = (page - 1) * size
|
||||
params["limit"] = size
|
||||
result = db.execute(
|
||||
text(f"""
|
||||
SELECT t.*, tn.name as tenant_name
|
||||
FROM platform_scheduled_tasks t
|
||||
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
|
||||
WHERE {where_sql}
|
||||
ORDER BY t.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
params
|
||||
)
|
||||
tasks = [dict(row) for row in result.mappings().all()]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"items": tasks
|
||||
}
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_task(
|
||||
data: TaskCreate,
|
||||
user: User = Depends(require_operator),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""创建定时任务"""
|
||||
# 验证调度配置
|
||||
if data.schedule_type == "simple":
|
||||
if not data.time_points or len(data.time_points) == 0:
|
||||
raise HTTPException(status_code=400, detail="简单模式需要至少一个时间点")
|
||||
elif data.schedule_type == "cron":
|
||||
if not data.cron_expression:
|
||||
raise HTTPException(status_code=400, detail="CRON模式需要提供表达式")
|
||||
|
||||
# 插入数据库
|
||||
import json
|
||||
time_points_json = json.dumps(data.time_points) if data.time_points else None
|
||||
input_params_json = json.dumps(data.input_params) if data.input_params else None
|
||||
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO platform_scheduled_tasks
|
||||
(tenant_id, task_name, task_desc, schedule_type, time_points,
|
||||
cron_expression, webhook_url, input_params, is_enabled)
|
||||
VALUES (:tenant_id, :task_name, :task_desc, :schedule_type, :time_points,
|
||||
:cron_expression, :webhook_url, :input_params, :is_enabled)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": data.tenant_id,
|
||||
"task_name": data.task_name,
|
||||
"task_desc": data.task_desc,
|
||||
"schedule_type": data.schedule_type,
|
||||
"time_points": time_points_json,
|
||||
"cron_expression": data.cron_expression,
|
||||
"webhook_url": data.webhook_url,
|
||||
"input_params": input_params_json,
|
||||
"is_enabled": 1 if data.is_enabled else 0
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 获取新插入的ID
|
||||
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
|
||||
task_id = result.scalar()
|
||||
|
||||
# 如果启用,添加到调度器
|
||||
if data.is_enabled:
|
||||
reload_task(task_id)
|
||||
|
||||
return {"id": task_id, "message": "创建成功"}
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
async def get_task(
|
||||
task_id: int,
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取任务详情"""
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT t.*, tn.name as tenant_name
|
||||
FROM platform_scheduled_tasks t
|
||||
LEFT JOIN platform_tenants tn ON t.tenant_id = tn.code
|
||||
WHERE t.id = :id
|
||||
"""),
|
||||
{"id": task_id}
|
||||
)
|
||||
task = result.mappings().first()
|
||||
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
return dict(task)
|
||||
|
||||
|
||||
@router.put("/{task_id}")
|
||||
async def update_task(
|
||||
task_id: int,
|
||||
data: TaskUpdate,
|
||||
user: User = Depends(require_operator),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""更新定时任务"""
|
||||
# 检查任务是否存在
|
||||
result = db.execute(
|
||||
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
task = result.mappings().first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 构建更新语句
|
||||
import json
|
||||
updates = []
|
||||
params = {"id": task_id}
|
||||
|
||||
if data.task_name is not None:
|
||||
updates.append("task_name = :task_name")
|
||||
params["task_name"] = data.task_name
|
||||
if data.task_desc is not None:
|
||||
updates.append("task_desc = :task_desc")
|
||||
params["task_desc"] = data.task_desc
|
||||
if data.schedule_type is not None:
|
||||
updates.append("schedule_type = :schedule_type")
|
||||
params["schedule_type"] = data.schedule_type
|
||||
if data.time_points is not None:
|
||||
updates.append("time_points = :time_points")
|
||||
params["time_points"] = json.dumps(data.time_points)
|
||||
if data.cron_expression is not None:
|
||||
updates.append("cron_expression = :cron_expression")
|
||||
params["cron_expression"] = data.cron_expression
|
||||
if data.webhook_url is not None:
|
||||
updates.append("webhook_url = :webhook_url")
|
||||
params["webhook_url"] = data.webhook_url
|
||||
if data.input_params is not None:
|
||||
updates.append("input_params = :input_params")
|
||||
params["input_params"] = json.dumps(data.input_params)
|
||||
|
||||
if updates:
|
||||
db.execute(
|
||||
text(f"UPDATE platform_scheduled_tasks SET {', '.join(updates)} WHERE id = :id"),
|
||||
params
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 重新加载调度器中的任务
|
||||
reload_task(task_id)
|
||||
|
||||
return {"message": "更新成功"}
|
||||
|
||||
|
||||
@router.delete("/{task_id}")
|
||||
async def delete_task(
|
||||
task_id: int,
|
||||
user: User = Depends(require_operator),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""删除定时任务"""
|
||||
# 检查任务是否存在
|
||||
result = db.execute(
|
||||
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
if not result.scalar():
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 从调度器移除
|
||||
remove_task_from_scheduler(task_id)
|
||||
|
||||
# 删除日志
|
||||
db.execute(
|
||||
text("DELETE FROM platform_task_logs WHERE task_id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
|
||||
# 删除任务
|
||||
db.execute(
|
||||
text("DELETE FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return {"message": "删除成功"}
|
||||
|
||||
|
||||
@router.post("/{task_id}/toggle")
|
||||
async def toggle_task(
|
||||
task_id: int,
|
||||
user: User = Depends(require_operator),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""启用/禁用任务"""
|
||||
# 获取当前状态
|
||||
result = db.execute(
|
||||
text("SELECT is_enabled FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
current_enabled = row[0]
|
||||
new_enabled = 0 if current_enabled else 1
|
||||
|
||||
# 更新状态
|
||||
db.execute(
|
||||
text("UPDATE platform_scheduled_tasks SET is_enabled = :enabled WHERE id = :id"),
|
||||
{"id": task_id, "enabled": new_enabled}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 更新调度器
|
||||
reload_task(task_id)
|
||||
|
||||
return {
|
||||
"is_enabled": bool(new_enabled),
|
||||
"message": "已启用" if new_enabled else "已禁用"
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{task_id}/run")
|
||||
async def run_task_now(
|
||||
task_id: int,
|
||||
background_tasks: BackgroundTasks,
|
||||
user: User = Depends(require_operator),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""手动执行任务"""
|
||||
# 检查任务是否存在
|
||||
result = db.execute(
|
||||
text("SELECT id FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
if not result.scalar():
|
||||
raise HTTPException(status_code=404, detail="任务不存在")
|
||||
|
||||
# 在后台执行任务
|
||||
background_tasks.add_task(asyncio.create_task, execute_task(task_id))
|
||||
|
||||
return {"message": "任务已触发执行"}
|
||||
|
||||
|
||||
@router.get("/{task_id}/logs")
|
||||
async def get_task_logs(
|
||||
task_id: int,
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""获取任务执行日志"""
|
||||
# 查询总数
|
||||
count_result = db.execute(
|
||||
text("SELECT COUNT(*) FROM platform_task_logs WHERE task_id = :task_id"),
|
||||
{"task_id": task_id}
|
||||
)
|
||||
total = count_result.scalar()
|
||||
|
||||
# 查询日志
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT * FROM platform_task_logs
|
||||
WHERE task_id = :task_id
|
||||
ORDER BY id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""),
|
||||
{"task_id": task_id, "limit": size, "offset": (page - 1) * size}
|
||||
)
|
||||
logs = [dict(row) for row in result.mappings().all()]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"size": size,
|
||||
"items": logs
|
||||
}
|
||||
286
backend/app/services/scheduler.py
Normal file
286
backend/app/services/scheduler.py
Normal file
@@ -0,0 +1,286 @@
|
||||
"""定时任务调度器服务"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
import httpx
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import SessionLocal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局调度器实例
|
||||
scheduler: Optional[AsyncIOScheduler] = None
|
||||
|
||||
|
||||
def get_scheduler() -> AsyncIOScheduler:
|
||||
"""获取调度器实例"""
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
scheduler = AsyncIOScheduler(timezone="Asia/Shanghai")
|
||||
return scheduler
|
||||
|
||||
|
||||
def get_db_session() -> Session:
|
||||
"""获取数据库会话"""
|
||||
return SessionLocal()
|
||||
|
||||
|
||||
async def execute_task(task_id: int):
|
||||
"""执行定时任务"""
|
||||
db = get_db_session()
|
||||
log_id = None
|
||||
|
||||
try:
|
||||
# 1. 查询任务配置
|
||||
result = db.execute(
|
||||
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id AND is_enabled = 1"),
|
||||
{"id": task_id}
|
||||
)
|
||||
task = result.mappings().first()
|
||||
|
||||
if not task:
|
||||
logger.warning(f"Task {task_id} not found or disabled")
|
||||
return
|
||||
|
||||
# 2. 更新任务状态为运行中
|
||||
db.execute(
|
||||
text("UPDATE platform_scheduled_tasks SET last_run_status = 'running', last_run_at = NOW() WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 3. 创建执行日志
|
||||
db.execute(
|
||||
text("""
|
||||
INSERT INTO platform_task_logs (task_id, tenant_id, started_at, status)
|
||||
VALUES (:task_id, :tenant_id, NOW(), 'running')
|
||||
"""),
|
||||
{"task_id": task_id, "tenant_id": task["tenant_id"]}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# 获取刚插入的日志ID
|
||||
result = db.execute(text("SELECT LAST_INSERT_ID() as id"))
|
||||
log_id = result.scalar()
|
||||
|
||||
# 4. 调用 webhook
|
||||
webhook_url = task["webhook_url"]
|
||||
input_params = task["input_params"] or {}
|
||||
|
||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
||||
response = await client.post(
|
||||
webhook_url,
|
||||
json=input_params,
|
||||
headers={"Content-Type": "application/json"}
|
||||
)
|
||||
|
||||
response_code = response.status_code
|
||||
response_body = response.text[:5000] if response.text else "" # 限制存储长度
|
||||
|
||||
if response.is_success:
|
||||
status = "success"
|
||||
error_message = None
|
||||
else:
|
||||
status = "failed"
|
||||
error_message = f"HTTP {response_code}"
|
||||
|
||||
# 5. 更新执行日志
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE platform_task_logs
|
||||
SET finished_at = NOW(), status = :status, response_code = :code,
|
||||
response_body = :body, error_message = :error
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": log_id,
|
||||
"status": status,
|
||||
"code": response_code,
|
||||
"body": response_body,
|
||||
"error": error_message
|
||||
}
|
||||
)
|
||||
|
||||
# 6. 更新任务状态
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE platform_scheduled_tasks
|
||||
SET last_run_status = :status, last_run_message = :message
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{
|
||||
"id": task_id,
|
||||
"status": status,
|
||||
"message": error_message or "执行成功"
|
||||
}
|
||||
)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Task {task_id} executed with status: {status}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Task {task_id} execution error: {str(e)}")
|
||||
|
||||
# 更新失败状态
|
||||
try:
|
||||
if log_id:
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE platform_task_logs
|
||||
SET finished_at = NOW(), status = 'failed', error_message = :error
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": log_id, "error": str(e)[:1000]}
|
||||
)
|
||||
|
||||
db.execute(
|
||||
text("""
|
||||
UPDATE platform_scheduled_tasks
|
||||
SET last_run_status = 'failed', last_run_message = :message
|
||||
WHERE id = :id
|
||||
"""),
|
||||
{"id": task_id, "message": str(e)[:500]}
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def add_task_to_scheduler(task: Dict[str, Any]):
|
||||
"""将任务添加到调度器"""
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
return
|
||||
|
||||
task_id = task["id"]
|
||||
schedule_type = task["schedule_type"]
|
||||
|
||||
# 先移除已有的任务(如果存在)
|
||||
remove_task_from_scheduler(task_id)
|
||||
|
||||
if schedule_type == "cron":
|
||||
# CRON 模式
|
||||
cron_expr = task["cron_expression"]
|
||||
if cron_expr:
|
||||
try:
|
||||
trigger = CronTrigger.from_crontab(cron_expr, timezone="Asia/Shanghai")
|
||||
scheduler.add_job(
|
||||
execute_task,
|
||||
trigger,
|
||||
args=[task_id],
|
||||
id=f"task_{task_id}_cron",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"Added cron task {task_id}: {cron_expr}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add cron task {task_id}: {e}")
|
||||
else:
|
||||
# 简单模式 - 多个时间点
|
||||
time_points = task.get("time_points") or []
|
||||
if isinstance(time_points, str):
|
||||
import json
|
||||
time_points = json.loads(time_points)
|
||||
|
||||
for i, time_point in enumerate(time_points):
|
||||
try:
|
||||
hour, minute = time_point.split(":")
|
||||
trigger = CronTrigger(
|
||||
hour=int(hour),
|
||||
minute=int(minute),
|
||||
timezone="Asia/Shanghai"
|
||||
)
|
||||
scheduler.add_job(
|
||||
execute_task,
|
||||
trigger,
|
||||
args=[task_id],
|
||||
id=f"task_{task_id}_time_{i}",
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info(f"Added simple task {task_id} at {time_point}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add time point {time_point} for task {task_id}: {e}")
|
||||
|
||||
|
||||
def remove_task_from_scheduler(task_id: int):
|
||||
"""从调度器移除任务"""
|
||||
global scheduler
|
||||
if scheduler is None:
|
||||
return
|
||||
|
||||
# 移除所有相关的 job
|
||||
jobs_to_remove = []
|
||||
for job in scheduler.get_jobs():
|
||||
if job.id.startswith(f"task_{task_id}_"):
|
||||
jobs_to_remove.append(job.id)
|
||||
|
||||
for job_id in jobs_to_remove:
|
||||
try:
|
||||
scheduler.remove_job(job_id)
|
||||
logger.info(f"Removed job {job_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to remove job {job_id}: {e}")
|
||||
|
||||
|
||||
def load_all_tasks():
|
||||
"""从数据库加载所有启用的任务"""
|
||||
db = get_db_session()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("SELECT * FROM platform_scheduled_tasks WHERE is_enabled = 1")
|
||||
)
|
||||
tasks = result.mappings().all()
|
||||
|
||||
for task in tasks:
|
||||
add_task_to_scheduler(dict(task))
|
||||
|
||||
logger.info(f"Loaded {len(tasks)} scheduled tasks")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""启动调度器"""
|
||||
global scheduler
|
||||
scheduler = get_scheduler()
|
||||
|
||||
# 加载所有任务
|
||||
load_all_tasks()
|
||||
|
||||
# 启动调度器
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("Scheduler started")
|
||||
|
||||
|
||||
def shutdown_scheduler():
|
||||
"""关闭调度器"""
|
||||
global scheduler
|
||||
if scheduler and scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
logger.info("Scheduler shutdown")
|
||||
|
||||
|
||||
def reload_task(task_id: int):
|
||||
"""重新加载单个任务"""
|
||||
db = get_db_session()
|
||||
try:
|
||||
result = db.execute(
|
||||
text("SELECT * FROM platform_scheduled_tasks WHERE id = :id"),
|
||||
{"id": task_id}
|
||||
)
|
||||
task = result.mappings().first()
|
||||
|
||||
if task and task["is_enabled"]:
|
||||
add_task_to_scheduler(dict(task))
|
||||
else:
|
||||
remove_task_from_scheduler(task_id)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -12,3 +12,4 @@ python-multipart>=0.0.6
|
||||
httpx>=0.26.0
|
||||
redis>=5.0.0
|
||||
openpyxl>=3.1.0
|
||||
apscheduler>=3.10.0
|
||||
|
||||
@@ -16,6 +16,7 @@ const menuItems = computed(() => {
|
||||
{ path: '/apps', title: '应用管理', icon: 'Grid' },
|
||||
{ path: '/tenant-wechat-apps', title: '企微应用', icon: 'ChatDotRound' },
|
||||
{ path: '/app-config', title: '租户订阅', icon: 'Setting' },
|
||||
{ path: '/scheduled-tasks', title: '定时任务', icon: 'Clock' },
|
||||
{ path: '/stats', title: '统计分析', icon: 'TrendCharts' },
|
||||
{ path: '/logs', title: '日志查看', icon: 'Document' }
|
||||
]
|
||||
|
||||
@@ -53,7 +53,13 @@ const routes = [
|
||||
path: 'app-config',
|
||||
name: 'AppConfig',
|
||||
component: () => import('@/views/app-config/index.vue'),
|
||||
meta: { title: '租户应用配置', icon: 'Setting' }
|
||||
meta: { title: '租户订阅', icon: 'Setting' }
|
||||
},
|
||||
{
|
||||
path: 'scheduled-tasks',
|
||||
name: 'ScheduledTasks',
|
||||
component: () => import('@/views/scheduled-tasks/index.vue'),
|
||||
meta: { title: '定时任务', icon: 'Clock' }
|
||||
},
|
||||
{
|
||||
path: 'stats',
|
||||
|
||||
645
frontend/src/views/scheduled-tasks/index.vue
Normal file
645
frontend/src/views/scheduled-tasks/index.vue
Normal file
@@ -0,0 +1,645 @@
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Delete, Plus, VideoPlay, Clock, Document } from '@element-plus/icons-vue'
|
||||
import api from '@/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const tableData = ref([])
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
size: 20,
|
||||
tenant_id: ''
|
||||
})
|
||||
|
||||
// 租户列表
|
||||
const tenantList = ref([])
|
||||
const tenantMap = ref({})
|
||||
|
||||
// 对话框
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const editingId = ref(null)
|
||||
const formRef = ref(null)
|
||||
const form = reactive({
|
||||
tenant_id: '',
|
||||
task_name: '',
|
||||
task_desc: '',
|
||||
schedule_type: 'simple',
|
||||
time_points: [],
|
||||
cron_expression: '',
|
||||
webhook_url: '',
|
||||
input_params: '',
|
||||
is_enabled: true
|
||||
})
|
||||
|
||||
// 时间选择器
|
||||
const newTimePoint = ref('')
|
||||
|
||||
const rules = {
|
||||
tenant_id: [{ required: true, message: '请选择租户', trigger: 'change' }],
|
||||
task_name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
|
||||
webhook_url: [{ required: true, message: '请输入 Webhook URL', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
// 日志对话框
|
||||
const logsDialogVisible = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const logsData = ref([])
|
||||
const logsTotal = ref(0)
|
||||
const logsTaskId = ref(null)
|
||||
const logsQuery = reactive({
|
||||
page: 1,
|
||||
size: 10
|
||||
})
|
||||
|
||||
// 获取租户名称
|
||||
function getTenantName(code) {
|
||||
return tenantMap.value[code] || code
|
||||
}
|
||||
|
||||
// 获取调度描述
|
||||
function getScheduleDesc(row) {
|
||||
if (row.schedule_type === 'cron') {
|
||||
return `CRON: ${row.cron_expression}`
|
||||
}
|
||||
const points = row.time_points || []
|
||||
if (points.length === 0) return '-'
|
||||
if (points.length <= 3) return points.join(', ')
|
||||
return `${points.slice(0, 3).join(', ')} 等${points.length}个时间点`
|
||||
}
|
||||
|
||||
// 获取状态标签类型
|
||||
function getStatusType(status) {
|
||||
const map = {
|
||||
success: 'success',
|
||||
failed: 'danger',
|
||||
running: 'warning'
|
||||
}
|
||||
return map[status] || 'info'
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
function formatTime(time) {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
async function fetchTenants() {
|
||||
try {
|
||||
const res = await api.get('/api/tenants', { params: { size: 1000 } })
|
||||
tenantList.value = res.data.items || []
|
||||
const map = {}
|
||||
tenantList.value.forEach(t => {
|
||||
map[t.code] = t.name
|
||||
})
|
||||
tenantMap.value = map
|
||||
} catch (e) {
|
||||
console.error('获取租户列表失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get('/api/scheduled-tasks', { params: query })
|
||||
tableData.value = res.data.items || []
|
||||
total.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
query.page = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handlePageChange(page) {
|
||||
query.page = page
|
||||
fetchList()
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editingId.value = null
|
||||
dialogTitle.value = '新建定时任务'
|
||||
Object.assign(form, {
|
||||
tenant_id: '',
|
||||
task_name: '',
|
||||
task_desc: '',
|
||||
schedule_type: 'simple',
|
||||
time_points: [],
|
||||
cron_expression: '',
|
||||
webhook_url: '',
|
||||
input_params: '',
|
||||
is_enabled: true
|
||||
})
|
||||
newTimePoint.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
editingId.value = row.id
|
||||
dialogTitle.value = '编辑定时任务'
|
||||
Object.assign(form, {
|
||||
tenant_id: row.tenant_id,
|
||||
task_name: row.task_name,
|
||||
task_desc: row.task_desc || '',
|
||||
schedule_type: row.schedule_type || 'simple',
|
||||
time_points: row.time_points || [],
|
||||
cron_expression: row.cron_expression || '',
|
||||
webhook_url: row.webhook_url,
|
||||
input_params: row.input_params ? JSON.stringify(row.input_params, null, 2) : '',
|
||||
is_enabled: row.is_enabled
|
||||
})
|
||||
newTimePoint.value = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
// 添加时间点
|
||||
function addTimePoint() {
|
||||
if (!newTimePoint.value) return
|
||||
if (!form.time_points.includes(newTimePoint.value)) {
|
||||
form.time_points.push(newTimePoint.value)
|
||||
form.time_points.sort()
|
||||
}
|
||||
newTimePoint.value = ''
|
||||
}
|
||||
|
||||
// 移除时间点
|
||||
function removeTimePoint(index) {
|
||||
form.time_points.splice(index, 1)
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await formRef.value.validate()
|
||||
|
||||
// 验证调度配置
|
||||
if (form.schedule_type === 'simple' && form.time_points.length === 0) {
|
||||
ElMessage.error('请至少添加一个执行时间点')
|
||||
return
|
||||
}
|
||||
if (form.schedule_type === 'cron' && !form.cron_expression) {
|
||||
ElMessage.error('请输入 CRON 表达式')
|
||||
return
|
||||
}
|
||||
|
||||
// 解析输入参数
|
||||
let inputParams = null
|
||||
if (form.input_params) {
|
||||
try {
|
||||
inputParams = JSON.parse(form.input_params)
|
||||
} catch (e) {
|
||||
ElMessage.error('输入参数格式错误,请输入有效的 JSON')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const data = {
|
||||
tenant_id: form.tenant_id,
|
||||
task_name: form.task_name,
|
||||
task_desc: form.task_desc,
|
||||
schedule_type: form.schedule_type,
|
||||
time_points: form.schedule_type === 'simple' ? form.time_points : null,
|
||||
cron_expression: form.schedule_type === 'cron' ? form.cron_expression : null,
|
||||
webhook_url: form.webhook_url,
|
||||
input_params: inputParams,
|
||||
is_enabled: form.is_enabled
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await api.put(`/api/scheduled-tasks/${editingId.value}`, data)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await api.post('/api/scheduled-tasks', data)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
// 错误已在拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(row) {
|
||||
await ElMessageBox.confirm(`确定删除任务「${row.task_name}」吗?`, '提示', {
|
||||
type: 'warning'
|
||||
})
|
||||
|
||||
try {
|
||||
await api.delete(`/api/scheduled-tasks/${row.id}`)
|
||||
ElMessage.success('删除成功')
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
// 错误已在拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggle(row) {
|
||||
try {
|
||||
const res = await api.post(`/api/scheduled-tasks/${row.id}/toggle`)
|
||||
ElMessage.success(res.data.message)
|
||||
fetchList()
|
||||
} catch (e) {
|
||||
// 错误已在拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRunNow(row) {
|
||||
await ElMessageBox.confirm(`确定立即执行任务「${row.task_name}」吗?`, '手动执行', {
|
||||
type: 'info'
|
||||
})
|
||||
|
||||
try {
|
||||
await api.post(`/api/scheduled-tasks/${row.id}/run`)
|
||||
ElMessage.success('任务已触发执行')
|
||||
// 延迟刷新以获取最新状态
|
||||
setTimeout(() => fetchList(), 2000)
|
||||
} catch (e) {
|
||||
// 错误已在拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function handleViewLogs(row) {
|
||||
logsTaskId.value = row.id
|
||||
logsQuery.page = 1
|
||||
logsDialogVisible.value = true
|
||||
await fetchLogs()
|
||||
}
|
||||
|
||||
async function fetchLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const res = await api.get(`/api/scheduled-tasks/${logsTaskId.value}/logs`, {
|
||||
params: logsQuery
|
||||
})
|
||||
logsData.value = res.data.items || []
|
||||
logsTotal.value = res.data.total || 0
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogsPageChange(page) {
|
||||
logsQuery.page = page
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
// 快速选择租户
|
||||
function selectTenant(code) {
|
||||
if (query.tenant_id === code) {
|
||||
query.tenant_id = ''
|
||||
} else {
|
||||
query.tenant_id = code
|
||||
}
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTenants()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-header">
|
||||
<div class="title">定时任务</div>
|
||||
<el-button v-if="authStore.isOperator" type="primary" @click="handleCreate">
|
||||
<el-icon><Plus /></el-icon>
|
||||
新建任务
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div class="page-tip">
|
||||
<el-alert type="info" :closable="false">
|
||||
管理定时任务,支持简单时间点和 CRON 表达式两种调度方式,可自动调用 n8n 工作流。
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<!-- 租户快速筛选标签 -->
|
||||
<div class="tenant-tags">
|
||||
<span class="tag-label">租户筛选:</span>
|
||||
<el-tag
|
||||
v-for="tenant in tenantList"
|
||||
:key="tenant.code"
|
||||
:type="query.tenant_id === tenant.code ? '' : 'info'"
|
||||
:effect="query.tenant_id === tenant.code ? 'dark' : 'plain'"
|
||||
class="tenant-tag"
|
||||
@click="selectTenant(tenant.code)"
|
||||
>
|
||||
{{ tenant.name }}
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-if="query.tenant_id"
|
||||
type="danger"
|
||||
effect="plain"
|
||||
class="tenant-tag clear-tag"
|
||||
@click="selectTenant('')"
|
||||
>
|
||||
清除筛选
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 表格 -->
|
||||
<el-table v-loading="loading" :data="tableData" style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="租户" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ getTenantName(row.tenant_id) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="task_name" label="任务名称" width="150" />
|
||||
<el-table-column label="调度配置" width="200">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.schedule_type === 'cron' ? 'warning' : ''">
|
||||
{{ row.schedule_type === 'cron' ? 'CRON' : '简单' }}
|
||||
</el-tag>
|
||||
<span style="margin-left: 8px; color: #606266; font-size: 12px">
|
||||
{{ getScheduleDesc(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_enabled ? 'success' : 'info'" size="small">
|
||||
{{ row.is_enabled ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次执行" width="180">
|
||||
<template #default="{ row }">
|
||||
<div v-if="row.last_run_at" style="font-size: 12px">
|
||||
<div>{{ formatTime(row.last_run_at) }}</div>
|
||||
<el-tag :type="getStatusType(row.last_run_status)" size="small">
|
||||
{{ row.last_run_status || '-' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else style="color: #909399">未执行</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link size="small" @click="handleEdit(row)">编辑</el-button>
|
||||
<el-button type="success" link size="small" @click="handleRunNow(row)">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
执行
|
||||
</el-button>
|
||||
<el-button
|
||||
:type="row.is_enabled ? 'warning' : 'success'"
|
||||
link
|
||||
size="small"
|
||||
@click="handleToggle(row)"
|
||||
>
|
||||
{{ row.is_enabled ? '禁用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button type="info" link size="small" @click="handleViewLogs(row)">
|
||||
<el-icon><Document /></el-icon>
|
||||
日志
|
||||
</el-button>
|
||||
<el-button v-if="authStore.isOperator" type="danger" link size="small" @click="handleDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
:page-size="query.size"
|
||||
:total="total"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="dialogTitle"
|
||||
width="700px"
|
||||
:lock-scroll="true"
|
||||
>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<el-form-item label="租户" prop="tenant_id">
|
||||
<el-select
|
||||
v-model="form.tenant_id"
|
||||
:disabled="!!editingId"
|
||||
placeholder="请选择租户"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="tenant in tenantList"
|
||||
:key="tenant.code"
|
||||
:label="`${tenant.name} (${tenant.code})`"
|
||||
:value="tenant.code"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="任务名称" prop="task_name">
|
||||
<el-input v-model="form.task_name" placeholder="如:每日数据同步" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="任务描述">
|
||||
<el-input v-model="form.task_desc" type="textarea" :rows="2" placeholder="可选" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="调度模式">
|
||||
<el-radio-group v-model="form.schedule_type">
|
||||
<el-radio value="simple">简单模式(选择时间点)</el-radio>
|
||||
<el-radio value="cron">CRON 表达式</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 简单模式 -->
|
||||
<el-form-item v-if="form.schedule_type === 'simple'" label="执行时间">
|
||||
<div class="time-points-editor">
|
||||
<div class="time-points-list">
|
||||
<el-tag
|
||||
v-for="(time, index) in form.time_points"
|
||||
:key="time"
|
||||
closable
|
||||
@close="removeTimePoint(index)"
|
||||
style="margin-right: 8px; margin-bottom: 8px"
|
||||
>
|
||||
{{ time }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="time-points-add">
|
||||
<el-time-select
|
||||
v-model="newTimePoint"
|
||||
start="00:00"
|
||||
step="00:30"
|
||||
end="23:30"
|
||||
placeholder="选择时间"
|
||||
style="width: 140px"
|
||||
/>
|
||||
<el-button type="primary" @click="addTimePoint" :disabled="!newTimePoint">
|
||||
添加
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- CRON 模式 -->
|
||||
<el-form-item v-if="form.schedule_type === 'cron'" label="CRON 表达式">
|
||||
<el-input v-model="form.cron_expression" placeholder="如:0 9,18 * * *(每天9点和18点)" />
|
||||
<div class="form-tip">
|
||||
格式:分 时 日 月 周。例如:0 9 * * * 表示每天9点执行
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Webhook URL" prop="webhook_url">
|
||||
<el-input v-model="form.webhook_url" placeholder="如:https://n8n.ireborn.com.cn/webhook/xxx" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="输入参数">
|
||||
<el-input
|
||||
v-model="form.input_params"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder='可选,JSON 格式,如:{"key": "value"}'
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="启用状态">
|
||||
<el-switch v-model="form.is_enabled" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="handleSubmit">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 日志对话框 -->
|
||||
<el-dialog
|
||||
v-model="logsDialogVisible"
|
||||
title="执行日志"
|
||||
width="800px"
|
||||
:lock-scroll="true"
|
||||
>
|
||||
<el-table v-loading="logsLoading" :data="logsData" style="width: 100%">
|
||||
<el-table-column label="开始时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.started_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="结束时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.finished_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getStatusType(row.status)" size="small">
|
||||
{{ row.status }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="响应码" width="80">
|
||||
<template #default="{ row }">
|
||||
{{ row.response_code || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.error_message" style="color: #f56c6c">{{ row.error_message }}</span>
|
||||
<span v-else style="color: #67c23a">成功</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrapper">
|
||||
<el-pagination
|
||||
v-model:current-page="logsQuery.page"
|
||||
:page-size="logsQuery.size"
|
||||
:total="logsTotal"
|
||||
layout="total, prev, pager, next"
|
||||
@current-change="handleLogsPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="logsDialogVisible = false">关闭</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-tip {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tenant-tags {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.tag-label {
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.tenant-tag {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tenant-tag:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.clear-tag {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.pagination-wrapper {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.time-points-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.time-points-list {
|
||||
min-height: 32px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.time-points-add {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user