完善定时任务功能

master
Lxy 2 weeks ago
parent 4eaee5c594
commit 778a713591

@ -2,11 +2,13 @@
定时任务接口 - 创建/启动/停止/删除/列表 定时任务接口 - 创建/启动/停止/删除/列表
""" """
import logging import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.models import ScheduledTask
from app.schemas import ( from app.schemas import (
CreateTaskRequest, CreateTaskRequest,
TaskInfo, TaskInfo,
@ -35,18 +37,23 @@ router = APIRouter(prefix="/tasks", tags=["定时任务"])
def create_new_task(req: CreateTaskRequest, db: Session = Depends(get_db)): def create_new_task(req: CreateTaskRequest, db: Session = Depends(get_db)):
""" """
创建并启动一个定时采集任务 创建并启动一个定时采集任务
输入品种合约和轮询时长自动开始定时获取数据 输入品种合约和轮询时长,自动开始定时获取数据
""" """
# 将periods数组转为逗号分隔的字符串
periods_str = ",".join(req.periods) if isinstance(req.periods, list) else req.periods
task = create_task( task = create_task(
db=db, db=db,
symbol=req.symbol, symbol=req.symbol,
data_type=req.data_type, data_type=req.data_type,
periods=req.periods, periods=periods_str,
interval_seconds=req.interval_seconds, interval_seconds=req.interval_seconds,
task_type=req.task_type,
run_time=req.run_time,
) )
# 注册到调度器 # 注册到调度器
job_id = add_job(task.id, task.interval_seconds) job_id = add_job(task.id, task.interval_seconds, task.task_type, task.run_time)
task.job_id = job_id task.job_id = job_id
db.commit() db.commit()
db.refresh(task) db.refresh(task)
@ -56,30 +63,63 @@ def create_new_task(req: CreateTaskRequest, db: Session = Depends(get_db)):
@router.get("", response_model=TaskListResponse) @router.get("", response_model=TaskListResponse)
def list_all_tasks(db: Session = Depends(get_db)): def list_all_tasks(db: Session = Depends(get_db)):
"""列出所有定时任务""" """列出所有定时任务(未完成的)"""
tasks = list_tasks(db) tasks = db.query(ScheduledTask).filter(
ScheduledTask.is_finished == False
).order_by(ScheduledTask.created_at.desc()).all()
job_status = get_all_jobs() job_status = get_all_jobs()
task_infos = [] task_infos = []
for t in tasks: for t in tasks:
running = is_job_running(t.id) if t.enabled else False job_id = f"task_{t.id}"
task_infos.append(TaskInfo( job_info = job_status.get(job_id)
id=t.id,
symbol=t.symbol, task_infos.append(_to_task_info(t, job_info))
data_type=t.data_type,
periods=t.periods.split(",") if t.periods else [],
interval_seconds=t.interval_seconds,
enabled=t.enabled,
running=running,
last_run=t.last_run.isoformat() if t.last_run else None,
last_status=t.last_status,
created_at=t.created_at.isoformat(),
updated_at=t.updated_at.isoformat(),
))
return TaskListResponse(tasks=task_infos, total=len(task_infos)) return TaskListResponse(tasks=task_infos, total=len(task_infos))
@router.get("/history", response_model=TaskListResponse)
def list_finished_tasks(db: Session = Depends(get_db)):
"""列出已完成的历史任务"""
tasks = db.query(ScheduledTask).filter(
ScheduledTask.is_finished == True
).order_by(ScheduledTask.updated_at.desc()).all()
task_infos = []
for t in tasks:
task_infos.append(_to_task_info(t, None))
return TaskListResponse(tasks=task_infos, total=len(task_infos))
@router.post("/{task_id}/rerun", response_model=TaskInfo)
def rerun_task(task_id: int, db: Session = Depends(get_db)):
"""重新执行已完成的任务"""
task = get_task(db, task_id)
if not task:
raise HTTPException(status_code=404, detail=f"任务 {task_id} 不存在")
if not task.is_finished:
raise HTTPException(status_code=400, detail=f"任务 {task_id} 尚未完成,无法重新执行")
# 重置任务状态
task.is_finished = False
task.enabled = True
task.last_run = None
task.last_status = None
db.commit()
db.refresh(task)
# 重新注册到调度器
job_id = add_job(task.id, task.interval_seconds, task.task_type, task.run_time)
task.job_id = job_id
db.commit()
db.refresh(task)
return _to_task_info(task)
@router.post("/{task_id}/stop", response_model=TaskInfo) @router.post("/{task_id}/stop", response_model=TaskInfo)
def stop_task(task_id: int, db: Session = Depends(get_db)): def stop_task(task_id: int, db: Session = Depends(get_db)):
"""停止定时任务(从调度器移除,但保留配置)""" """停止定时任务(从调度器移除,但保留配置)"""
@ -101,7 +141,7 @@ def start_task(task_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail=f"任务 {task_id} 不存在") raise HTTPException(status_code=404, detail=f"任务 {task_id} 不存在")
enable_task(db, task_id) enable_task(db, task_id)
add_job(task.id, task.interval_seconds) add_job(task.id, task.interval_seconds, task.task_type, task.run_time)
db.refresh(task) db.refresh(task)
return _to_task_info(task) return _to_task_info(task)
@ -139,23 +179,29 @@ def update_interval(
# 如果任务正在运行,更新调度器 # 如果任务正在运行,更新调度器
if task.enabled and is_job_running(task_id): if task.enabled and is_job_running(task_id):
remove_job(task_id) remove_job(task_id)
add_job(task.id, task.interval_seconds) add_job(task.id, task.interval_seconds, task.task_type, task.run_time)
return _to_task_info(task) return _to_task_info(task)
def _to_task_info(task) -> TaskInfo: def _to_task_info(task, job_info: Optional[dict] = None) -> TaskInfo:
"""ORM -> Pydantic""" """ORM -> Pydantic"""
next_run = None
if job_info and job_info.get("next_run_time"):
next_run = job_info["next_run_time"]
return TaskInfo( return TaskInfo(
id=task.id, id=task.id,
symbol=task.symbol, symbol=task.symbol,
data_type=task.data_type, data_type=task.data_type,
periods=task.periods.split(",") if task.periods else [], periods=task.periods.split(",") if task.periods else [],
interval_seconds=task.interval_seconds, interval_seconds=task.interval_seconds,
task_type=task.task_type if hasattr(task, 'task_type') else 'interval',
enabled=task.enabled, enabled=task.enabled,
running=is_job_running(task.id), running=is_job_running(task.id),
last_run=task.last_run.isoformat() if task.last_run else None, last_run=task.last_run.isoformat() if task.last_run else None,
last_status=task.last_status, last_status=task.last_status,
next_run=next_run,
created_at=task.created_at.isoformat(), created_at=task.created_at.isoformat(),
updated_at=task.updated_at.isoformat(), updated_at=task.updated_at.isoformat(),
) )

@ -37,7 +37,10 @@ class ScheduledTask(Base):
data_type = Column(String(16), nullable=False, default="futures", comment="数据类型") data_type = Column(String(16), nullable=False, default="futures", comment="数据类型")
periods = Column(String(256), nullable=False, comment="周期列表(逗号分隔), 如 5min,15min,60min") periods = Column(String(256), nullable=False, comment="周期列表(逗号分隔), 如 5min,15min,60min")
interval_seconds = Column(Integer, nullable=False, default=300, comment="轮询间隔(秒)") interval_seconds = Column(Integer, nullable=False, default=300, comment="轮询间隔(秒)")
task_type = Column(String(16), nullable=False, default="interval", comment="任务类型: interval, daily, once")
run_time = Column(String(8), nullable=True, comment="执行时间,格式 HH:MM")
enabled = Column(Boolean, nullable=False, default=True, comment="是否启用") enabled = Column(Boolean, nullable=False, default=True, comment="是否启用")
is_finished = Column(Boolean, nullable=False, default=False, comment="是否已完成(仅一次任务执行完成后为True)")
job_id = Column(String(64), nullable=True, unique=True, comment="APScheduler job_id") job_id = Column(String(64), nullable=True, unique=True, comment="APScheduler job_id")
last_run = Column(DateTime, nullable=True, comment="最后执行时间") last_run = Column(DateTime, nullable=True, comment="最后执行时间")
last_status = Column(String(16), nullable=True, comment="最后状态: success/failed") last_status = Column(String(16), nullable=True, comment="最后状态: success/failed")

@ -70,9 +70,9 @@ class CreateTaskRequest(BaseModel):
"""创建定时任务请求""" """创建定时任务请求"""
symbol: str = Field(..., description="品种合约代码") symbol: str = Field(..., description="品种合约代码")
data_type: str = Field(default="futures", description="数据类型") data_type: str = Field(default="futures", description="数据类型")
periods: List[str] = Field( periods: str = Field(
default=["5min", "15min", "30min", "60min", "daily"], default="5min,15min,30min,60min,daily",
description="需要定时获取的周期" description="需要定时获取的周期,逗号分隔"
) )
interval_seconds: int = Field( interval_seconds: int = Field(
default=300, default=300,
@ -80,6 +80,8 @@ class CreateTaskRequest(BaseModel):
le=86400, le=86400,
description="轮询间隔(秒),范围 30~86400" description="轮询间隔(秒),范围 30~86400"
) )
task_type: str = Field(default="interval", description="任务类型: interval, daily, once")
run_time: Optional[str] = Field(default=None, description="执行时间,格式 HH:MM")
class TaskInfo(BaseModel): class TaskInfo(BaseModel):
@ -89,10 +91,12 @@ class TaskInfo(BaseModel):
data_type: str data_type: str
periods: List[str] periods: List[str]
interval_seconds: int interval_seconds: int
task_type: str = Field(default="interval", description="任务类型: interval, daily, once")
enabled: bool enabled: bool
running: bool = Field(description="当前是否正在运行") running: bool = Field(description="当前是否正在运行")
last_run: Optional[str] = None last_run: Optional[str] = None
last_status: Optional[str] = None last_status: Optional[str] = None
next_run: Optional[str] = Field(default=None, description="下次执行时间")
created_at: str created_at: str
updated_at: str updated_at: str

@ -177,16 +177,20 @@ def create_task(
db: Session, db: Session,
symbol: str, symbol: str,
data_type: str, data_type: str,
periods: List[str], periods: str,
interval_seconds: int, interval_seconds: int,
task_type: str = "interval",
run_time: Optional[str] = None,
) -> ScheduledTask: ) -> ScheduledTask:
"""创建定时任务配置""" """创建定时任务配置"""
existing = db.query(ScheduledTask).filter_by( existing = db.query(ScheduledTask).filter_by(
symbol=symbol, data_type=data_type symbol=symbol, data_type=data_type
).first() ).first()
if existing: if existing:
existing.periods = ",".join(periods) existing.periods = periods
existing.interval_seconds = interval_seconds existing.interval_seconds = interval_seconds
existing.task_type = task_type
existing.run_time = run_time
existing.enabled = True existing.enabled = True
existing.updated_at = datetime.now() existing.updated_at = datetime.now()
db.commit() db.commit()
@ -196,8 +200,10 @@ def create_task(
task = ScheduledTask( task = ScheduledTask(
symbol=symbol, symbol=symbol,
data_type=data_type, data_type=data_type,
periods=",".join(periods), periods=periods,
interval_seconds=interval_seconds, interval_seconds=interval_seconds,
task_type=task_type,
run_time=run_time,
enabled=True, enabled=True,
) )
db.add(task) db.add(task)

@ -2,11 +2,14 @@
调度服务 - APScheduler 管理定时采集任务 调度服务 - APScheduler 管理定时采集任务
""" """
import logging import logging
from datetime import datetime import re
from datetime import datetime, timedelta
from typing import Dict, Optional from typing import Dict, Optional
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.executors.pool import ThreadPoolExecutor from apscheduler.executors.pool import ThreadPoolExecutor
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -54,6 +57,14 @@ def job_handler(task_id: int):
save_market_data(db, task.symbol, result) save_market_data(db, task.symbol, result)
update_task_status(db, task_id, "success") update_task_status(db, task_id, "success")
logger.info(f"[定时任务] {task.symbol} 采集成功") logger.info(f"[定时任务] {task.symbol} 采集成功")
# 如果是一次性任务,标记为已完成并从调度器移除
if task.task_type == "once":
task.is_finished = True
task.enabled = False
db.commit()
remove_job(task_id)
logger.info(f"[定时任务] {task.symbol} 一次性任务已完成")
else: else:
update_task_status(db, task_id, "failed") update_task_status(db, task_id, "failed")
logger.error(f"[定时任务] {task.symbol} 采集失败: {result.get('error')}") logger.error(f"[定时任务] {task.symbol} 采集失败: {result.get('error')}")
@ -82,10 +93,16 @@ def stop_scheduler():
logger.info("调度器已停止") logger.info("调度器已停止")
def add_job(task_id: int, interval_seconds: int) -> str: def add_job(task_id: int, interval_seconds: int, task_type: str = "interval", run_time: Optional[str] = None) -> str:
""" """
添加定时任务到调度器 添加定时任务到调度器
Args:
task_id: 任务ID
interval_seconds: 间隔秒数
task_type: 任务类型 (interval, daily, once)
run_time: 执行时间格式 HH:MM (用于daily和once类型)
Returns: Returns:
job_id job_id
""" """
@ -95,15 +112,50 @@ def add_job(task_id: int, interval_seconds: int) -> str:
if scheduler.get_job(job_id): if scheduler.get_job(job_id):
scheduler.remove_job(job_id) scheduler.remove_job(job_id)
# 根据任务类型选择trigger
try:
if task_type == "daily" and run_time:
# 每天定时执行 - 验证run_time格式
if not re.match(r'^\d{2}:\d{2}$', run_time):
raise ValueError(f"run_time格式错误: {run_time}, 应为 HH:MM")
hour, minute = map(int, run_time.split(":"))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError(f"run_time值无效: {run_time}, 小时应为0-23, 分钟应为0-59")
trigger = CronTrigger(hour=hour, minute=minute, timezone="Asia/Shanghai")
elif task_type == "once" and run_time:
# 仅执行一次 - 验证run_time格式
if not re.match(r'^\d{2}:\d{2}$', run_time):
raise ValueError(f"run_time格式错误: {run_time}, 应为 HH:MM")
hour, minute = map(int, run_time.split(":"))
if not (0 <= hour <= 23 and 0 <= minute <= 59):
raise ValueError(f"run_time值无效: {run_time}, 小时应为0-23, 分钟应为0-59")
now = datetime.now()
run_dt = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
if run_dt <= now:
# 如果时间已过设置为明天使用timedelta避免月末日期计算错误
run_dt = run_dt + timedelta(days=1)
trigger = DateTrigger(run_date=run_dt, timezone="Asia/Shanghai")
else:
# 默认:间隔执行
trigger = IntervalTrigger(seconds=interval_seconds)
except ValueError as e:
logger.error(f"任务 {task_id} 时间配置错误: {e}, 使用默认间隔触发器")
trigger = IntervalTrigger(seconds=interval_seconds)
scheduler.add_job( scheduler.add_job(
func=job_handler, func=job_handler,
trigger=IntervalTrigger(seconds=interval_seconds), trigger=trigger,
args=[task_id], args=[task_id],
id=job_id, id=job_id,
name=f"auto_collect_{task_id}", name=f"auto_collect_{task_id}",
replace_existing=True, replace_existing=True,
) )
logger.info(f"已添加定时任务: job_id={job_id}, interval={interval_seconds}s") logger.info(f"已添加定时任务: job_id={job_id}, type={task_type}, trigger={trigger}")
return job_id return job_id

@ -924,29 +924,16 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div> <div>
<div class="card-title">批量创建定时任务</div> <div class="card-title">定时任务管理</div>
<div class="card-subtitle">为配置中的所有品种自动创建定时采集任务</div> <div class="card-subtitle">创建、管理和监控定时数据采集任务</div>
</div> </div>
</div> <div style="display: flex; gap: 12px;">
<div class="form-row"> <button class="btn btn-outline" onclick="showHistoryTasks()">
<div class="form-group"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<label class="form-label">数据类型</label> 历史任务
<select class="form-select" id="taskDataType"> </button>
<option value="futures">期货</option> <button class="btn btn-warning" onclick="openCreateTaskModal()">
<option value="stock">股票</option> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</select>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="taskPeriods" value="5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="taskInterval" value="300" min="30" max="86400">
</div>
<div class="form-group">
<button class="btn btn-warning" id="btnBatchTasks" onclick="batchCreateTasks()" style="height: 42px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
批量创建任务 批量创建任务
</button> </button>
</div> </div>
@ -956,15 +943,53 @@
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title">任务列表</div> <div class="card-title">任务列表</div>
<button class="btn btn-outline btn-sm" onclick="loadTasks()"> <div style="display: flex; gap: 12px;">
<button class="btn btn-danger btn-sm" onclick="batchDeleteTasks()" id="btnBatchDeleteTasks" style="display: none;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
批量删除
</button>
<button class="btn btn-outline btn-sm" onclick="loadTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
</button>
</div>
</div>
<div class="table-container" id="taskTable">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>暂无定时任务</p>
</div>
</div>
</div>
</div>
<!-- History Tasks -->
<div class="page" id="page-history-tasks" style="display: none;">
<div class="card">
<div class="card-header">
<div>
<div class="card-title">历史任务</div>
<div class="card-subtitle">查看已完成的任务记录</div>
</div>
<button class="btn btn-outline" onclick="showActiveTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
返回任务列表
</button>
</div>
</div>
<div class="card">
<div class="card-header">
<div class="card-title">已完成任务列表</div>
<button class="btn btn-outline btn-sm" onclick="loadHistoryTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新 刷新
</button> </button>
</div> </div>
<div class="table-container" id="taskTable"> <div class="table-container" id="historyTaskTable">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<p>暂无定时任务</p> <p>暂无历史任务</p>
</div> </div>
</div> </div>
</div> </div>
@ -1047,6 +1072,150 @@
</div> </div>
</div> </div>
<!-- Create Task Modal -->
<div class="modal-overlay" id="createTaskModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">批量创建定时任务</div>
<button class="modal-close" onclick="closeCreateTaskModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 24px; height: 24px;">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="taskDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div style="margin-bottom: 16px;">
<div style="position: relative; margin-bottom: 12px;">
<input class="form-input" id="taskSymbolSearch" placeholder="搜索合约..." style="width: 100%; padding-left: 36px;" oninput="filterTaskSymbols(this.value)">
<svg style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); width: 16px; height: 16px; color: var(--gray-400);" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</div>
<label style="display: flex; align-items: center; cursor: pointer; margin-bottom: 12px;">
<input type="checkbox" id="taskSelectAll" onchange="toggleTaskSelectAll()" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: 500;">全选</span>
</label>
<span id="taskSelectedCount" style="color: var(--gray-500); font-size: 14px;">已选择 0 个合约</span>
<div class="symbol-select-list" id="taskSymbolList" style="max-height: 200px; margin-top: 12px;"></div>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="taskPeriods" value="5min,15min,60min" placeholder="如: 5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">任务类型</label>
<select class="form-select" id="taskType" onchange="onTaskTypeChange()">
<option value="daily">每天定时执行</option>
<option value="once">仅执行一次</option>
<option value="interval" selected>指定周期循环</option>
</select>
</div>
<div id="taskIntervalGroup" class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="taskInterval" value="300" min="30" max="86400">
</div>
<div id="taskTimeGroup" class="form-group" style="display: none;">
<label class="form-label">执行时间</label>
<input class="form-input" type="time" id="taskTime" value="09:00">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeCreateTaskModal()">取消</button>
<button class="btn btn-warning" onclick="confirmCreateTasks()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
创建任务
</button>
</div>
</div>
</div>
<!-- Task Detail Modal -->
<div class="modal-overlay" id="taskDetailModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">任务详情</div>
<button class="modal-close" onclick="closeTaskDetailModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 24px; height: 24px;">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body" id="taskDetailContent"></div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeTaskDetailModal()">关闭</button>
<button class="btn btn-primary" id="btnEditTask" onclick="">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
修改
</button>
</div>
</div>
</div>
<!-- Edit Task Modal -->
<div class="modal-overlay" id="editTaskModal">
<div class="modal">
<div class="modal-header">
<div class="modal-title">修改任务</div>
<button class="modal-close" onclick="closeEditTaskModal()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 24px; height: 24px;">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="modal-body">
<input type="hidden" id="editTaskId">
<div class="form-group">
<label class="form-label">品种代码</label>
<input class="form-input" id="editTaskSymbol" readonly style="background: var(--gray-100);">
</div>
<div class="form-group">
<label class="form-label">数据类型</label>
<select class="form-select" id="editTaskDataType">
<option value="futures">期货</option>
<option value="stock">股票</option>
</select>
</div>
<div class="form-group">
<label class="form-label">采集周期</label>
<input class="form-input" id="editTaskPeriods" placeholder="如: 5min,15min,60min">
</div>
<div class="form-group">
<label class="form-label">任务类型</label>
<select class="form-select" id="editTaskType" onchange="onEditTaskTypeChange()">
<option value="daily">每天定时执行</option>
<option value="once">仅执行一次</option>
<option value="interval">指定周期循环</option>
</select>
</div>
<div id="editTaskIntervalGroup" class="form-group">
<label class="form-label">轮询间隔(秒)</label>
<input class="form-input" type="number" id="editTaskInterval" value="300" min="30" max="86400">
</div>
<div id="editTaskTimeGroup" class="form-group" style="display: none;">
<label class="form-label">执行时间</label>
<input class="form-input" type="time" id="editTaskTime" value="09:00">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeEditTaskModal()">取消</button>
<button class="btn btn-primary" onclick="saveEditTask()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width: 16px; height: 16px;"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
保存
</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
<script> <script>
const API = '/api/v1'; const API = '/api/v1';
@ -1055,6 +1224,10 @@
let currentSearchTerm = ''; let currentSearchTerm = '';
let selectedSymbolsForFetch = []; let selectedSymbolsForFetch = [];
let modalSymbolsData = []; let modalSymbolsData = [];
let taskSymbolsData = [];
let selectedTaskSymbols = [];
let currentTaskId = null;
let allTasks = [];
// Navigation // Navigation
function navigateTo(page) { function navigateTo(page) {
@ -2005,6 +2178,481 @@
loadTasks(); loadTasks();
} }
// Task Management
function openCreateTaskModal() {
const dataType = document.getElementById('taskDataType').value;
taskSymbolsData = Object.entries(currentConfig[dataType] || {}).map(([name, code]) => ({ name, code }));
renderTaskSymbols();
document.getElementById('taskSelectAll').checked = true;
updateTaskSelectedCount();
document.getElementById('createTaskModal').classList.add('active');
}
function closeCreateTaskModal() {
document.getElementById('createTaskModal').classList.remove('active');
}
function renderTaskSymbols(filterText = '') {
const listContainer = document.getElementById('taskSymbolList');
const filterLower = filterText.toLowerCase();
const filteredSymbols = filterText
? taskSymbolsData.filter(s =>
s.name.toLowerCase().includes(filterLower) ||
s.code.toLowerCase().includes(filterLower)
)
: taskSymbolsData;
let html = '';
filteredSymbols.forEach(({ name, code }) => {
html += `
<label class="symbol-select-item">
<input type="checkbox" value="${code}" data-name="${name}" checked onchange="updateTaskSelectedCount()">
<div class="symbol-select-info">
<div class="symbol-select-name">${name}</div>
<div class="symbol-select-code">${code}</div>
</div>
</label>
`;
});
if (filteredSymbols.length === 0) {
html = `<div class="empty-state" style="padding: 20px;"><p>未找到匹配的合约</p></div>`;
}
listContainer.innerHTML = html;
updateTaskSelectedCount();
}
function filterTaskSymbols(searchTerm) {
renderTaskSymbols(searchTerm.trim());
}
function toggleTaskSelectAll() {
const selectAll = document.getElementById('taskSelectAll').checked;
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]');
checkboxes.forEach(cb => cb.checked = selectAll);
updateTaskSelectedCount();
}
function updateTaskSelectedCount() {
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]:checked');
const count = checkboxes.length;
const total = document.querySelectorAll('#taskSymbolList input[type="checkbox"]').length;
document.getElementById('taskSelectedCount').textContent = `已选择 ${count}/${total} 个合约`;
const selectAll = document.getElementById('taskSelectAll');
if (count === total) {
selectAll.checked = true;
} else if (count === 0) {
selectAll.checked = false;
} else {
selectAll.checked = false;
}
}
function onTaskTypeChange() {
const taskType = document.getElementById('taskType').value;
document.getElementById('taskIntervalGroup').style.display = taskType === 'interval' ? 'block' : 'none';
document.getElementById('taskTimeGroup').style.display = (taskType === 'daily' || taskType === 'once') ? 'block' : 'none';
}
async function confirmCreateTasks() {
const checkboxes = document.querySelectorAll('#taskSymbolList input[type="checkbox"]:checked');
if (checkboxes.length === 0) {
showToast('请至少选择一个合约', 'error');
return;
}
const dataType = document.getElementById('taskDataType').value;
const periods = document.getElementById('taskPeriods').value;
const taskType = document.getElementById('taskType').value;
const interval = document.getElementById('taskInterval').value;
const time = document.getElementById('taskTime').value;
let intervalSeconds = parseInt(interval);
if (taskType === 'daily') {
intervalSeconds = 86400; // 24小时
}
const selectedSymbols = Array.from(checkboxes).map(cb => cb.value);
closeCreateTaskModal();
addLog(`批量创建定时任务: ${dataType}, ${selectedSymbols.length}个合约, 类型: ${taskType}`);
try {
let createdCount = 0;
for (const symbol of selectedSymbols) {
const body = {
symbol: symbol,
data_type: dataType,
periods: periods,
interval_seconds: intervalSeconds,
task_type: taskType
};
if (taskType === 'daily' || taskType === 'once') {
body.run_time = time;
}
const res = await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.success) {
createdCount++;
}
}
showToast(`成功创建 ${createdCount} 个定时任务`, 'success');
loadTasks();
} catch (e) {
showToast(`创建任务失败: ${e.message}`, 'error');
}
}
async function loadTasks() {
try {
const res = await fetch(`${API}/tasks`);
const data = await res.json();
allTasks = data.tasks || [];
if (!allTasks.length) {
document.getElementById('taskTable').innerHTML = '<div class="empty-state"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><p>暂无定时任务</p></div>';
document.getElementById('btnBatchDeleteTasks').style.display = 'none';
return;
}
let html = `
<div style="margin-bottom: 12px; display: flex; align-items: center; gap: 12px;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="selectAllTasks" onchange="toggleSelectAllTasks()" style="margin-right: 8px; width: 18px; height: 18px; cursor: pointer;">
<span style="font-weight: 500;">全选</span>
</label>
<span id="taskSelectedTaskCount" style="color: var(--gray-500); font-size: 14px;">已选择 0 个任务</span>
</div>
<table>
<thead><tr><th><input type="checkbox" style="width: 18px; height: 18px; cursor: pointer;" onchange="toggleSelectAllTasks()"></th><th>ID</th><th>品种</th><th>周期</th><th>类型</th><th>状态</th><th>最后执行</th><th>下次执行</th><th>操作</th></tr></thead>
<tbody>
`;
for (const t of allTasks) {
const statusBadge = t.running
? '<span class="badge badge-success">运行中</span>'
: t.enabled
? '<span class="badge badge-warning">已停止</span>'
: '<span class="badge badge-gray">已禁用</span>';
const taskTypeText = t.task_type === 'daily' ? '每天' : t.task_type === 'once' ? '一次' : '循环';
const nextRunText = t.next_run ? new Date(t.next_run).toLocaleString() : '-';
html += `<tr>
<td><input type="checkbox" class="task-checkbox" value="${t.id}" onchange="updateSelectedTaskCount()" style="width: 18px; height: 18px; cursor: pointer;"></td>
<td style="cursor: pointer; color: var(--primary);" onclick="showTaskDetail(${t.id})">${t.id}</td>
<td><code style="color: var(--primary);">${t.symbol}</code></td>
<td>${t.periods ? t.periods.join(', ') : '-'}</td>
<td><span class="badge badge-gray">${taskTypeText}</span></td>
<td>${statusBadge}</td>
<td>${t.last_run ? new Date(t.last_run).toLocaleString() : '-'}</td>
<td>${nextRunText}</td>
<td>
<button class="btn btn-primary btn-sm" onclick="showTaskDetail(${t.id})">详情</button>
${t.running
? `<button class="btn btn-warning btn-sm" onclick="stopTask(${t.id})">停止</button>`
: `<button class="btn btn-success btn-sm" onclick="startTask(${t.id})">启动</button>`
}
<button class="btn btn-danger btn-sm" onclick="deleteTask(${t.id})">删除</button>
</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('taskTable').innerHTML = html;
document.getElementById('btnBatchDeleteTasks').style.display = 'inline-flex';
} catch (e) {
addLog(`加载任务失败: ${e.message}`, 'error');
}
}
function toggleSelectAllTasks() {
const selectAll = document.getElementById('selectAllTasks').checked;
const checkboxes = document.querySelectorAll('.task-checkbox');
checkboxes.forEach(cb => cb.checked = selectAll);
updateSelectedTaskCount();
}
function updateSelectedTaskCount() {
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
const count = checkboxes.length;
document.getElementById('taskSelectedTaskCount').textContent = `已选择 ${count} 个任务`;
document.getElementById('btnBatchDeleteTasks').disabled = count === 0;
}
async function batchDeleteTasks() {
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
if (checkboxes.length === 0) {
showToast('请至少选择一个任务', 'error');
return;
}
if (!confirm(`确定删除选中的 ${checkboxes.length} 个任务?`)) return;
const taskIds = Array.from(checkboxes).map(cb => cb.value);
let deletedCount = 0;
for (const id of taskIds) {
try {
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' });
deletedCount++;
} catch (e) {
console.error(`删除任务 ${id} 失败:`, e);
}
}
showToast(`成功删除 ${deletedCount} 个任务`, 'success');
loadTasks();
}
function showTaskDetail(id) {
const task = allTasks.find(t => t.id === id);
if (!task) return;
currentTaskId = id;
const taskTypeText = task.task_type === 'daily' ? '每天定时执行' : task.task_type === 'once' ? '仅执行一次' : '指定周期循环';
const statusText = task.running ? '运行中' : task.enabled ? '已停止' : '已禁用';
let html = `
<div style="display: grid; gap: 16px;">
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">任务ID:</div>
<div>${task.id}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">品种:</div>
<div><code style="color: var(--primary);">${task.symbol}</code></div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">数据类型:</div>
<div>${task.data_type === 'futures' ? '期货' : '股票'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">采集周期:</div>
<div>${task.periods ? task.periods.join(', ') : '-'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">任务类型:</div>
<div><span class="badge badge-info">${taskTypeText}</span></div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">轮询间隔:</div>
<div>${task.interval_seconds}秒 (${Math.floor(task.interval_seconds / 60)}分钟)</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">状态:</div>
<div>${statusText}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">创建时间:</div>
<div>${task.created_at ? new Date(task.created_at).toLocaleString() : '-'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">最后执行:</div>
<div>${task.last_run ? new Date(task.last_run).toLocaleString() : '尚未执行'}</div>
</div>
<div style="display: grid; grid-template-columns: 120px 1fr; gap: 12px;">
<div style="color: var(--gray-500); font-weight: 500;">下次执行:</div>
<div>${task.next_run ? new Date(task.next_run).toLocaleString() : '-'}</div>
</div>
</div>
`;
document.getElementById('taskDetailContent').innerHTML = html;
document.getElementById('btnEditTask').setAttribute('onclick', `closeTaskDetailModal(); editTask(${id})`);
document.getElementById('taskDetailModal').classList.add('active');
}
function closeTaskDetailModal() {
document.getElementById('taskDetailModal').classList.remove('active');
}
function editTask(id) {
showToast('修改功能开发中...', 'info');
}
// History Tasks
function showHistoryTasks() {
document.getElementById('page-tasks').style.display = 'none';
document.getElementById('page-history-tasks').style.display = 'block';
loadHistoryTasks();
}
function showActiveTasks() {
document.getElementById('page-history-tasks').style.display = 'none';
document.getElementById('page-tasks').style.display = 'block';
}
async function loadHistoryTasks() {
try {
const res = await fetch(`${API}/tasks/history`);
const data = await res.json();
const historyTasks = data.tasks || [];
if (!historyTasks.length) {
document.getElementById('historyTaskTable').innerHTML = '<div class="empty-state"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg><p>暂无历史任务</p></div>';
return;
}
let html = `<table>
<thead><tr><th>ID</th><th>品种</th><th>周期</th><th>类型</th><th>状态</th><th>最后执行</th><th>完成时间</th><th>操作</th></tr></thead>
<tbody>
`;
for (const t of historyTasks) {
const statusBadge = t.last_status === 'success'
? '<span class="badge badge-success">已完成</span>'
: '<span class="badge badge-danger">失败</span>';
const taskTypeText = t.task_type === 'daily' ? '每天' : t.task_type === 'once' ? '一次' : '循环';
const finishedTime = t.updated_at ? new Date(t.updated_at).toLocaleString() : '-';
html += `<tr>
<td style="cursor: pointer; color: var(--primary);" onclick="showTaskDetail(${t.id})">${t.id}</td>
<td><code style="color: var(--primary);">${t.symbol}</code></td>
<td>${t.periods ? t.periods.join(', ') : '-'}</td>
<td><span class="badge badge-gray">${taskTypeText}</span></td>
<td>${statusBadge}</td>
<td>${t.last_run ? new Date(t.last_run).toLocaleString() : '-'}</td>
<td>${finishedTime}</td>
<td>
<button class="btn btn-success btn-sm" onclick="rerunTask(${t.id})">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:14px;height:14px"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
重新执行
</button>
<button class="btn btn-primary btn-sm" onclick="openEditTaskModal(${t.id})">修改</button>
<button class="btn btn-danger btn-sm" onclick="deleteTask(${t.id})">删除</button>
</td>
</tr>`;
}
html += '</tbody></table>';
document.getElementById('historyTaskTable').innerHTML = html;
} catch (e) {
addLog(`加载历史任务失败: ${e.message}`, 'error');
}
}
async function rerunTask(id) {
if (!confirm('确定重新执行此任务?')) return;
try {
const res = await fetch(`${API}/tasks/${id}/rerun`, { method: 'POST' });
const data = await res.json();
if (res.ok) {
showToast('任务已重新开始执行', 'success');
showActiveTasks();
loadTasks();
} else {
showToast(data.detail || '重新执行失败', 'error');
}
} catch (e) {
showToast(`重新执行失败: ${e.message}`, 'error');
}
}
// Edit Task
function openEditTaskModal(id) {
const task = allTasks.find(t => t.id === id);
if (!task) {
showToast('任务不存在', 'error');
return;
}
document.getElementById('editTaskId').value = id;
document.getElementById('editTaskSymbol').value = task.symbol;
document.getElementById('editTaskDataType').value = task.data_type;
document.getElementById('editTaskPeriods').value = task.periods ? task.periods.join(',') : '';
document.getElementById('editTaskType').value = task.task_type || 'interval';
document.getElementById('editTaskInterval').value = task.interval_seconds;
// 如果有run_time则填充
if (task.run_time) {
document.getElementById('editTaskTime').value = task.run_time;
}
onEditTaskTypeChange();
document.getElementById('editTaskModal').classList.add('active');
}
function closeEditTaskModal() {
document.getElementById('editTaskModal').classList.remove('active');
}
function onEditTaskTypeChange() {
const taskType = document.getElementById('editTaskType').value;
document.getElementById('editTaskIntervalGroup').style.display = taskType === 'interval' ? 'block' : 'none';
document.getElementById('editTaskTimeGroup').style.display = (taskType === 'daily' || taskType === 'once') ? 'block' : 'none';
}
async function saveEditTask() {
const id = document.getElementById('editTaskId').value;
const dataType = document.getElementById('editTaskDataType').value;
const periods = document.getElementById('editTaskPeriods').value;
const taskType = document.getElementById('editTaskType').value;
const interval = document.getElementById('editTaskInterval').value;
const time = document.getElementById('editTaskTime').value;
let intervalSeconds = parseInt(interval);
if (taskType === 'daily') {
intervalSeconds = 86400;
}
const body = {
symbol: document.getElementById('editTaskSymbol').value,
data_type: dataType,
periods: periods,
interval_seconds: intervalSeconds,
task_type: taskType
};
if (taskType === 'daily' || taskType === 'once') {
body.run_time = time;
}
try {
// 先停止旧任务
await fetch(`${API}/tasks/${id}/stop`, { method: 'POST' });
// 删除旧任务
await fetch(`${API}/tasks/${id}`, { method: 'DELETE' });
// 创建新任务
const res = await fetch(`${API}/tasks`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
showToast('任务修改成功', 'success');
closeEditTaskModal();
loadTasks();
} else {
const data = await res.json();
showToast(data.detail || '修改失败', 'error');
}
} catch (e) {
showToast(`修改失败: ${e.message}`, 'error');
}
}
// Cache Status // Cache Status
async function checkCacheStatus() { async function checkCacheStatus() {
const symbol = document.getElementById('cacheSymbol').value.trim(); const symbol = document.getElementById('cacheSymbol').value.trim();

Binary file not shown.
Loading…
Cancel
Save