This commit is contained in:
2026-06-16 03:00:06 +08:00
parent 964c17c200
commit 5b4d7bf280
5 changed files with 612 additions and 50 deletions

View File

@@ -58,10 +58,11 @@ import position_cost as pc
import trade_calendar as cal
import data_manager as dm
import paper_trading as paper
import task_manager
from db import init_db, get_session
from models import (DailyQuote, IndexDaily, SectorDaily, FundFlowDaily,
SentimentDaily, DragonTiger, Security, JobRun, StockMetric, Trade,
AlertRule, AlertEvent, SelectorStrategy, SelectorAlert)
AlertRule, AlertEvent, SelectorStrategy, SelectorAlert, ScheduledTask)
@asynccontextmanager
@@ -71,8 +72,9 @@ async def lifespan(app: FastAPI):
init_auth.init_default_admin()
wl.init_default_groups()
paper.ensure_default_account()
task_manager.init_tasks()
scheduler.start_scheduler()
print("[startup] db + scheduler + auth ready")
print("[startup] db + scheduler + task_manager + auth ready")
except Exception as e:
print("[startup] WARN:", repr(e)[:160])
yield
@@ -1636,6 +1638,50 @@ def paper_place_order(account_id: int, req: PaperOrderIn):
return paper.place_order(account_id, req.code, req.side, req.qty, req.price, req.reason)
# ============ 定时任务管理 ============
@app.get("/api/tasks")
def list_tasks(current_user = Depends(require_admin)):
"""获取所有定时任务"""
return {"ok": True, "tasks": task_manager.get_all_tasks()}
class UpdateTaskRequest(BaseModel):
enabled: Optional[bool] = None
schedule_type: Optional[str] = None
cron_expression: Optional[str] = None
interval_seconds: Optional[int] = None
@app.put("/api/tasks/{task_id}")
def update_task(task_id: str, req: UpdateTaskRequest, current_user = Depends(require_admin)):
"""更新任务配置"""
return task_manager.update_task(
task_id,
enabled=req.enabled,
schedule_type=req.schedule_type,
cron_expression=req.cron_expression,
interval_seconds=req.interval_seconds
)
@app.post("/api/tasks/{task_id}/toggle")
def toggle_task(task_id: str, current_user = Depends(require_admin)):
"""切换任务开关"""
return task_manager.toggle_task(task_id)
@app.get("/api/tasks/{task_id}/logs")
def task_logs(task_id: str, limit: int = Query(50, le=200), current_user = Depends(require_admin)):
"""获取任务执行日志"""
return {"ok": True, "logs": task_manager.get_task_logs(task_id, limit)}
@app.post("/api/tasks/reload")
def reload_tasks(current_user = Depends(require_admin)):
"""重新加载调度器"""
return scheduler.reload_scheduler()
@app.get("/api/paper/accounts/{account_id}/portfolio")
def paper_get_portfolio(account_id: int):
return paper.get_portfolio(account_id)

View File

@@ -429,3 +429,22 @@ class WatchlistItem(Base):
sort_order: Mapped[int] = mapped_column(Integer, default=0)
note: Mapped[str] = mapped_column(String(200), default="") # 个股备注
added_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())
class ScheduledTask(Base):
"""定时任务配置。"""
__tablename__ = "scheduled_tasks"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
task_id: Mapped[str] = mapped_column(String(40), unique=True, index=True) # 任务标识
name: Mapped[str] = mapped_column(String(80)) # 任务名称
description: Mapped[str] = mapped_column(String(200), default="") # 描述
enabled: Mapped[bool] = mapped_column(default=True) # 是否启用
schedule_type: Mapped[str] = mapped_column(String(20), default="cron") # cron/interval
cron_expression: Mapped[str] = mapped_column(String(50), default="") # cron表达式
interval_seconds: Mapped[int] = mapped_column(Integer, default=0) # 间隔秒数
category: Mapped[str] = mapped_column(String(20), default="其他") # 分类
last_run: Mapped[dt.datetime | None] = mapped_column(DateTime, nullable=True) # 上次运行
run_count: Mapped[int] = mapped_column(Integer, default=0) # 运行次数
last_status: Mapped[str] = mapped_column(String(20), default="") # 上次状态
last_message: Mapped[str] = mapped_column(String(500), default="") # 上次消息
created_at: Mapped[dt.datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -106,11 +106,95 @@ def _job_verify():
print("[predict] verify error:", repr(e)[:160])
def reload_scheduler():
"""重新加载调度器(应用新配置)"""
global _scheduler
if _scheduler:
_scheduler.shutdown(wait=False)
_scheduler = None
start_scheduler()
return {"ok": True, "msg": "调度器已重新加载"}
def start_scheduler():
global _scheduler
if _scheduler is not None:
return _scheduler
# 先初始化任务配置
try:
import task_manager
task_manager.init_tasks()
except Exception as e:
print(f"[scheduler] init tasks error: {repr(e)[:120]}")
_scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
# 从数据库加载任务配置
from db import get_session
from models import ScheduledTask
from sqlalchemy import select
try:
with get_session() as s:
tasks = s.execute(select(ScheduledTask).where(ScheduledTask.enabled == True)).scalars().all()
for task in tasks:
_add_job_from_config(task)
except Exception as e:
print(f"[scheduler] load tasks error: {repr(e)[:120]}")
# 降级:使用默认配置
_add_default_jobs()
_scheduler.start()
return _scheduler
def _add_job_from_config(task):
"""根据配置添加任务"""
job_func = _get_job_function(task.task_id)
if not job_func:
return
if task.schedule_type == "cron" and task.cron_expression:
# 解析 cron 表达式 (格式: "mon-fri 16:00")
parts = task.cron_expression.split()
if len(parts) == 2:
day_of_week, time_str = parts
hour, minute = map(int, time_str.split(':'))
_scheduler.add_job(
job_func,
CronTrigger(day_of_week=day_of_week, hour=hour, minute=minute),
id=task.task_id,
replace_existing=True,
misfire_grace_time=3600
)
elif task.schedule_type == "interval" and task.interval_seconds:
_scheduler.add_job(
job_func,
IntervalTrigger(seconds=task.interval_seconds),
id=task.task_id,
replace_existing=True,
max_instances=1
)
def _get_job_function(task_id):
"""获取任务函数"""
job_map = {
"daily_ingest": _job,
"alert_check": _safe_check_alerts,
"daily_report": _job_report,
"verify_pred": _job_verify,
"signal_stats": lambda: _job_signal_stats(),
"intraday_scan": _safe_scan_intraday,
"calendar_alerts": _job_calendar_alerts
}
return job_map.get(task_id)
def _add_default_jobs():
"""添加默认任务配置(降级方案)"""
_scheduler.add_job(
_job, CronTrigger(day_of_week="mon-fri", hour=config.INGEST_HOUR, minute=config.INGEST_MINUTE),
id="daily_ingest", replace_existing=True, misfire_grace_time=3600,
@@ -119,37 +203,33 @@ def start_scheduler():
_safe_check_alerts, IntervalTrigger(seconds=60),
id="alert_check", replace_existing=True, max_instances=1,
)
# 收盘入库之后 10 分钟生成 AI 复盘日报并推送
_rep_total = config.INGEST_HOUR * 60 + config.INGEST_MINUTE + 10
_scheduler.add_job(
_job_report, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=_rep_total % 60),
id="daily_report", replace_existing=True, misfire_grace_time=3600,
)
# 收盘后核验到期预测(实测准确率)
_scheduler.add_job(
_job_verify, CronTrigger(day_of_week="mon-fri", hour=(_rep_total // 60) % 24, minute=(_rep_total + 5) % 60),
id="verify_pred", replace_existing=True, misfire_grace_time=3600,
)
# 每周六重算信号历史胜率
_scheduler.add_job(
_job_signal_stats, CronTrigger(day_of_week="sat", hour=9, minute=0),
id="signal_stats", replace_existing=True, misfire_grace_time=7200,
)
# 盘中异动扫描(交易时间每分钟)
_scheduler.add_job(
_safe_scan_intraday, IntervalTrigger(seconds=60),
id="intraday_scan", replace_existing=True, max_instances=1,
)
# 每日早盘前推送日历事件提醒(持仓股除权、解禁、财报等)
_scheduler.add_job(
_job_calendar_alerts, CronTrigger(day_of_week="mon-fri", hour=8, minute=30),
id="calendar_alerts", replace_existing=True, misfire_grace_time=3600,
)
_scheduler.start()
return _scheduler
def _safe_check_alerts():
# 只在交易日的交易时间执行
if not intraday_radar._is_trading_time():
return
try:
alerts.check_alerts()
except Exception as e:
@@ -157,6 +237,9 @@ def _safe_check_alerts():
def _safe_scan_intraday():
# 只在交易时间执行
if not intraday_radar._is_trading_time():
return
try:
result = intraday_radar.scan_all()
if result.get("count", 0) > 0:

208
backend/task_manager.py Normal file
View File

@@ -0,0 +1,208 @@
"""定时任务管理系统"""
from typing import Dict, Any, List
from sqlalchemy import select
from db import get_session
from models import ScheduledTask
import scheduler
# 任务配置定义
TASK_CONFIGS = {
"daily_ingest": {
"name": "每日数据入库",
"description": "收盘后自动抓取并入库股票、板块、资金等数据",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:00",
"category": "数据入库"
},
"alert_check": {
"name": "预警检查",
"description": "每分钟检查价格预警规则(仅交易时间)",
"default_enabled": True,
"default_schedule": "interval",
"default_interval": 60,
"category": "实时监控"
},
"daily_report": {
"name": "AI复盘日报",
"description": "生成每日复盘报告并推送",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:10",
"category": "AI分析"
},
"verify_pred": {
"name": "预测准确率核验",
"description": "核验到期的AI预测结果",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 16:15",
"category": "AI分析"
},
"signal_stats": {
"name": "信号历史胜率",
"description": "重新计算技术信号历史统计",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "sat 09:00",
"category": "AI分析"
},
"intraday_scan": {
"name": "盘中异动扫描",
"description": "实时扫描急涨急跌、放量突破等异动",
"default_enabled": True,
"default_schedule": "interval",
"default_interval": 60,
"category": "实时监控"
},
"calendar_alerts": {
"name": "日历事件提醒",
"description": "推送除权、解禁、财报等重要事件",
"default_enabled": True,
"default_schedule": "cron",
"default_cron": "mon-fri 08:30",
"category": "事件提醒"
}
}
def init_tasks():
"""初始化任务配置到数据库"""
with get_session() as s:
for task_id, config in TASK_CONFIGS.items():
existing = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not existing:
task = ScheduledTask(
task_id=task_id,
name=config["name"],
description=config["description"],
enabled=config["default_enabled"],
schedule_type=config["default_schedule"],
cron_expression=config.get("default_cron"),
interval_seconds=config.get("default_interval"),
category=config["category"]
)
s.add(task)
s.commit()
def get_all_tasks() -> List[Dict[str, Any]]:
"""获取所有任务配置"""
with get_session() as s:
tasks = s.execute(select(ScheduledTask).order_by(ScheduledTask.id)).scalars().all()
return [{
"id": t.id,
"task_id": t.task_id,
"name": t.name,
"description": t.description,
"enabled": t.enabled,
"schedule_type": t.schedule_type,
"cron_expression": t.cron_expression,
"interval_seconds": t.interval_seconds,
"category": t.category,
"last_run": t.last_run.strftime("%Y-%m-%d %H:%M:%S") if t.last_run else None,
"next_run": get_next_run_time(t.task_id),
"run_count": t.run_count,
"last_status": t.last_status
} for t in tasks]
def get_next_run_time(task_id: str) -> str:
"""获取任务下次运行时间"""
if scheduler._scheduler:
job = scheduler._scheduler.get_job(task_id)
if job and job.next_run_time:
return job.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
return "未调度"
def update_task(task_id: str, enabled: bool = None, schedule_type: str = None,
cron_expression: str = None, interval_seconds: int = None) -> Dict[str, Any]:
"""更新任务配置"""
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not task:
return {"ok": False, "msg": "任务不存在"}
if enabled is not None:
task.enabled = enabled
if schedule_type is not None:
task.schedule_type = schedule_type
if cron_expression is not None:
task.cron_expression = cron_expression
if interval_seconds is not None:
task.interval_seconds = interval_seconds
s.commit()
# 重新调度
scheduler.reload_scheduler()
return {"ok": True, "msg": "任务配置已更新"}
def toggle_task(task_id: str) -> Dict[str, Any]:
"""切换任务开关"""
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if not task:
return {"ok": False, "msg": "任务不存在"}
task.enabled = not task.enabled
s.commit()
# 重新调度
scheduler.reload_scheduler()
return {
"ok": True,
"enabled": task.enabled,
"msg": f"任务已{'启用' if task.enabled else '禁用'}"
}
def record_task_run(task_id: str, status: str, message: str = ""):
"""记录任务执行"""
import datetime as dt
with get_session() as s:
task = s.execute(
select(ScheduledTask).where(ScheduledTask.task_id == task_id)
).scalar_one_or_none()
if task:
task.last_run = dt.datetime.now()
task.run_count += 1
task.last_status = status
task.last_message = message[:500] if message else ""
s.commit()
def get_task_logs(task_id: str = None, limit: int = 50) -> List[Dict[str, Any]]:
"""获取任务执行日志"""
# 这里可以从 JobRun 表读取,或者创建专门的 TaskLog 表
from models import JobRun
with get_session() as s:
stmt = select(JobRun).order_by(JobRun.id.desc()).limit(limit)
if task_id:
stmt = stmt.where(JobRun.job == task_id)
logs = s.execute(stmt).scalars().all()
return [{
"id": log.id,
"task_id": log.job,
"status": log.status,
"started": log.started_at.strftime("%Y-%m-%d %H:%M:%S") if log.started_at else "",
"finished": log.finished_at.strftime("%Y-%m-%d %H:%M:%S") if log.finished_at else "",
"duration": (log.finished_at - log.started_at).total_seconds() if log.finished_at and log.started_at else 0,
"message": log.message
} for log in logs]

288
prototype/app.js vendored
View File

@@ -53,7 +53,7 @@ function showLoginModal() {
bg.id = '_login_modal';
bg.style.cssText = 'position:fixed;inset:0;background:#00000099;z-index:20000;display:flex;align-items:center;justify-content:center';
bg.innerHTML = `<div style="background:#0f1520;border:1px solid var(--border);border-radius:8px;padding:24px 28px;width:320px;box-shadow:0 16px 48px #000c">
<h3 style="margin:0 0 16px;font-size:15px">?? 登录</h3>
<h3 style="margin:0 0 16px;font-size:15px">🔐 登录</h3>
<div style="margin-bottom:10px"><input id="_li_user" placeholder="用户名" value="admin" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div>
<div style="margin-bottom:14px"><input id="_li_pass" type="password" placeholder="密码" value="admin123" style="width:100%;box-sizing:border-box;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/></div>
<div style="display:flex;gap:8px">
@@ -115,9 +115,10 @@ const MENU = [
{ id: 'review-stock', name: '个股复盘' },
{ id: 'review-trade', name: '交易复盘', soon: true },
]},
{ icon: '⚙', name: '策略与中台', children: [
{ icon: '⚙', name: '策略与中台', children: [
{ id: 'backtest', name: '策略回测' },
{ id: 'admin', name: '数据中台' },
{ id: 'tasks', name: '定时任务' },
]},
{ icon: '◈', name: 'AI 分析', children: [
{ id: 'ai-today', name: '今日策略' },
@@ -140,7 +141,7 @@ const MENU = [
{ id: 'alert-list', name: '预警规则' },
{ id: 'alert-events', name: '触发记录' },
]},
{ icon: '??', name: '用户中心', children: [
{ icon: '👤', name: '用户中心', children: [
{ id: 'user-profile', name: '我的账户' },
{ id: 'user-manage', name: '用户管理' },
]},
@@ -153,7 +154,7 @@ function renderMenu() {
const nav = document.getElementById('menu');
nav.innerHTML = MENU.map((g, gi) => `
<div class="menu-group ${gi === 0 ? 'open' : ''}" data-gi="${gi}">
<div class="g-head"><span class="ico">${g.icon}</span><span class="g-name">${g.name}</span><span class="arrow">?</span></div>
<div class="g-head"><span class="ico">${g.icon}</span><span class="g-name">${g.name}</span><span class="arrow"></span></div>
<div class="submenu">
${g.children.map(c => `<a href="#${c.id}" data-id="${c.id}">${c.name}${c.soon ? ' ·' : ''}</a>`).join('')}
</div>
@@ -275,8 +276,7 @@ function colorByPct(p){ const a=Math.min(Math.abs(p)/10,1); return p>=0?`rgba(24
function treemapOption(items){
const data = items.map(it => {
const node = { name: it.name, value: it.value||1, pct: it.pct,
itemStyle: { color: colorByPct(it.pct) },
label: { formatter: `{name|${it.name}}\n{pct|${sign(it.pct)}${fmt(it.pct)}%}` } };
itemStyle: { color: colorByPct(it.pct) } };
if (it.children) node.children = it.children;
return node;
});
@@ -286,7 +286,9 @@ function treemapOption(items){
series: [{ type: 'treemap', roam: false, nodeClick: 'zoomToNode', drillDownIcon: '', breadcrumb: { show: false },
width: '100%', height: '100%', top: 4, left: 0, right: 0, bottom: 4,
visibleMin: 100,
label: { show: true, color: '#fff', overflow: 'truncate', rich: { name:{fontSize:12,fontWeight:600,color:'#fff'}, pct:{fontSize:11,color:'#fff',padding:[3,0,0,0]} } },
label: { show: true, color: '#fff', overflow: 'truncate',
formatter: params => `${params.name}\n${sign(params.data.pct)}${fmt(params.data.pct)}%`,
rich: { name:{fontSize:12,fontWeight:600,color:'#fff'}, pct:{fontSize:11,color:'#fff',padding:[3,0,0,0]} } },
upperLabel: { show: true, height: 24, color: '#fff', fontWeight: 700, fontSize: 13, backgroundColor: '#00000066' },
itemStyle: { borderColor: '#0a0e15', borderWidth: 2, gapWidth: 2 },
levels: [
@@ -371,35 +373,65 @@ let REVIEW_SYMBOL = '600519';
async function showSectorStocksModal(sectorName) {
const old = document.getElementById('_sector_modal');
if (old) old.remove();
const bg = document.createElement('div');
bg.id = '_sector_modal';
bg.style.cssText = 'position:fixed;inset:0;background:#00000088;z-index:10000;display:flex;align-items:center;justify-content:center;';
bg.innerHTML = `<div style="background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(860px,95vw);max-height:85vh;display:flex;flex-direction:column;box-shadow:0 16px 48px #000c" onclick="event.stopPropagation()">
<div style="display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0">
<h3 style="margin:0;font-size:15px;font-weight:700;flex:1">?? ${sectorName} · 板块成分股</h3>
<button onclick="document.getElementById('_sector_modal').remove()" style="background:none;border:none;color:var(--text-mute);font-size:20px;cursor:pointer;padding:0 4px">×</button>
</div>
<div id="_sector_body" style="padding:12px;overflow-y:auto;flex:1"><div class="trend-loading">加载中…</div></div>
</div>`;
bg.onclick = () => bg.remove();
bg.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(860px,95vw);max-height:85vh;display:flex;flex-direction:column;box-shadow:0 16px 48px rgba(0,0,0,0.7);';
const header = document.createElement('div');
header.style.cssText = 'display:flex;align-items:center;padding:12px 16px;border-bottom:1px solid var(--border);flex-shrink:0;';
header.innerHTML = `<h3 style="margin:0;font-size:15px;font-weight:700;flex:1">📊 ${sectorName} · 板块成分股</h3>`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = 'background:none;border:none;color:var(--text-mute);font-size:20px;cursor:pointer;padding:0 4px;';
closeBtn.onclick = () => bg.remove();
header.appendChild(closeBtn);
const body = document.createElement('div');
body.style.cssText = 'padding:12px;overflow-y:auto;flex:1;';
body.innerHTML = '<div class="trend-loading">加载中…</div>';
modal.appendChild(header);
modal.appendChild(body);
bg.appendChild(modal);
document.body.appendChild(bg);
bg.onclick = (e) => { if (e.target === bg) bg.remove(); };
let r;
try { r = await apiGet('/api/treemap/sector_stocks?name=' + encodeURIComponent(sectorName) + '&limit=50'); }
catch { document.getElementById('_sector_body').innerHTML = '<div class="trend-loading">后端未连接</div>'; return; }
try {
r = await apiGet('/api/treemap/sector_stocks?name=' + encodeURIComponent(sectorName) + '&limit=100');
} catch {
body.innerHTML = '<div class="trend-loading">后端未连接</div>';
return;
}
const stocks = r.stocks || [];
if (!stocks.length) { document.getElementById('_sector_body').innerHTML = '<div class="trend-loading">暂无数据</div>'; return; }
if (!stocks.length) {
body.innerHTML = '<div class="trend-loading">暂无数据</div>';
return;
}
const rows = stocks.map((s,i) => `<tr class="clickrow" data-code="${s.code}" style="cursor:pointer">
<td>${i+1}</td>
<td><b>${s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
<td><b>${s.symbol || s.name}</b> <span style="color:var(--text-mute)">${s.code}</span></td>
<td class="num">${fmt(s.price)}</td>
<td class="${cls(s.pct)} num">${sign(s.pct)}${fmt(s.pct)}%</td>
<td class="num">${fmt(s.amount,2)}亿</td>
<td style="color:var(--accent);font-size:12px">查看详情 →</td></tr>`).join('');
document.getElementById('_sector_body').innerHTML = `<div style="color:var(--text-mute);font-size:12px;margin-bottom:8px">点击股票行查看 K 线详情</div><table class="grid-tbl"><thead><tr><th>#</th><th>名称/代码</th><th>现价</th><th>涨跌幅</th><th>成交额</th><th></th></tr></thead><tbody>${rows}</tbody></table>`;
document.getElementById('_sector_body').querySelectorAll('.clickrow').forEach(tr => {
body.innerHTML = `<div style="color:var(--text-mute);font-size:12px;margin-bottom:8px">共 ${stocks.length} 只 · 点击股票行查看 K 线详情</div>
<table class="grid-tbl"><thead><tr><th>#</th><th>名称/代码</th><th>现价</th><th>涨跌幅</th><th>成交额</th><th></th></tr></thead>
<tbody>${rows}</tbody></table>`;
body.querySelectorAll('.clickrow').forEach(tr => {
tr.addEventListener('click', () => {
REVIEW_SYMBOL = tr.dataset.code;
document.getElementById('_sector_modal').remove();
bg.remove();
navigate('review-stock');
});
});
@@ -413,19 +445,15 @@ async function loadTreemapWithLeaders() {
const leaders = leadersResp.sectors || {};
return boards.map(b => {
const stocks = leaders[b.name] || [];
if (!stocks.length) return b;
const children = stocks.map(s => ({
// 过滤掉占位符数据(名称包含板块名+数字的)
const validStocks = stocks.filter(s => s.name && s.code && !s.name.match(new RegExp(`^${b.name}\\d+$`)));
if (!validStocks.length) return b;
const children = validStocks.map(s => ({
name: s.name, value: Math.max(s.amount || 1, 1), pct: s.pct,
itemStyle: { color: colorByPct(s.pct) },
label: { show: true, formatter: `{name|${s.name}}\n{pct|${sign(s.pct)}${fmt(s.pct)}%}`,
rich: { name:{fontSize:11,fontWeight:600,color:'#fff'}, pct:{fontSize:10,color:'#fff'} } }
}));
const moreVal = Math.max((b.value || 10) * 0.08, 0.5);
children.push({
name: '更多...', value: moreVal, pct: 0, _sector: b.name,
itemStyle: { color: '#1a2236' },
label: { show: true, formatter: '{name|更多...}', rich: { name:{fontSize:11,color:'#7d8796'} } }
});
return { ...b, children };
});
}
@@ -491,7 +519,7 @@ const VIEWS = {
try { r = await apiGet('/api/treemap?mode=sector' + dateParam); } catch { r = null; }
const label = document.getElementById('cloud-date-label');
if (!r || !r.items || !r.items.length) {
if (label) label.innerHTML = `<span style="color:var(--gold)">? ${date} 无历史数据,请先在「数据中台」执行入库</span>`;
if (label) label.innerHTML = `<span style="color:var(--gold)"> ${date} 无历史数据,请先在「数据中台」执行入库</span>`;
t.setOption(treemapOption([]), true);
return;
}
@@ -503,9 +531,9 @@ const VIEWS = {
if (label) label.textContent = '实时行情·红涨绿跌·点击板块查看成分股';
const data = await loadTreemapWithLeaders();
t.setOption(treemapOption(data), true);
t.off('click'); t.on('click', p => {
t.off('click'); t.on('click', p => {
if (!p.data) return;
const sector = p.data.name === '更多...' ? p.data._sector : (p.treePathInfo && p.treePathInfo[0] ? p.treePathInfo[0].name : p.data.name);
const sector = p.treePathInfo && p.treePathInfo[0] ? p.treePathInfo[0].name : p.data.name;
showSectorStocksModal(sector);
});
}
@@ -735,7 +763,7 @@ const VIEWS = {
bodyEl.innerHTML = `<div class="md-doc">${mdToHtml(r.content)}</div>`;
};
const loadHist = async () => {
try { const h = await apiGet('/api/report/history?limit=60'); histEl.innerHTML = (h.list||[]).map(x=>`<option value="${x.date}">${x.date}${x.pushed?' ?':''}</option>`).join('') || '<option>无历史</option>'; } catch {}
try { const h = await apiGet('/api/report/history?limit=60'); histEl.innerHTML = (h.list||[]).map(x=>`<option value="${x.date}">${x.date}${x.pushed?' ':''}</option>`).join('') || '<option>无历史</option>'; } catch {}
};
const loadDate = async (date) => { bodyEl.innerHTML='<div class="loading">加载中…</div>'; try { show(await apiGet('/api/report/daily'+(date?`?date=${date}`:''))); } catch { show(null); } };
histEl.onchange = () => loadDate(histEl.value);
@@ -758,7 +786,7 @@ const VIEWS = {
快线<input id="rs-fast" value="5" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/>
慢线<input id="rs-slow" value="20" style="width:42px;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"/>
<button id="rs-load" class="btn-run">加载</button>
<button id="rs-play" class="btn-run" style="background:#2a3140;border-color:#2a3140">? 回放</button>
<button id="rs-play" class="btn-run" style="background:#2a3140;border-color:#2a3140"> 回放</button>
</span></div>
<div class="panel-body"><div id="rs-stats" class="row c4" style="margin-bottom:8px"></div><div id="rs-chart" style="height:480px"></div><div id="rs-msg" style="color:var(--text-dim);padding:6px"></div></div></div>`;
let chart, playTimer = null;
@@ -1237,6 +1265,103 @@ const VIEWS = {
series:[{type:'line',data:r.equity,symbol:'none',areaStyle:{color:'#e8a13a22'},lineStyle:{width:1.5,color:'#e8a13a'}}] });
},
async tasks(view) {
view.innerHTML = `<div class="panel" style="margin-bottom:10px"><div class="panel-head"><span class="bar"></span>定时任务管理
<button id="tasks-reload" class="btn-run" style="margin-left:auto;background:#2a3140;border-color:#2a3140">重新加载调度器</button></div>
<div class="panel-body"><div id="tasks-list"></div></div></div>
<div class="panel"><div class="panel-head"><span class="bar"></span>执行日志 <select id="task-log-filter" style="margin-left:auto;height:26px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 6px"><option value="">全部任务</option></select></div>
<div class="panel-body" style="padding:0"><div id="task-logs"></div></div></div>`;
const loadTasks = async () => {
let r; try { r = await apiGet('/api/tasks'); } catch { document.getElementById('tasks-list').innerHTML='<div class="loading">后端未连接</div>'; return; }
const tasks = r.tasks || [];
// 按分类分组
const categories = {};
tasks.forEach(t => {
if (!categories[t.category]) categories[t.category] = [];
categories[t.category].push(t);
});
let html = '';
for (const [cat, items] of Object.entries(categories)) {
html += `<div style="margin-bottom:16px"><div style="font-weight:600;margin-bottom:8px;color:var(--accent)">${cat}</div>`;
items.forEach(t => {
const statusColor = t.last_status === 'success' ? 'var(--up)' : (t.last_status === 'error' ? 'var(--down)' : 'var(--text-mute)');
html += `<div class="task-item" style="display:flex;align-items:center;padding:10px 12px;background:#0a0e15;border-radius:4px;margin-bottom:6px">
<label style="display:flex;align-items:center;flex:1;cursor:pointer">
<input type="checkbox" class="task-toggle" data-id="${t.task_id}" ${t.enabled?'checked':''}>
<div style="margin-left:10px">
<div style="font-weight:600">${t.name}</div>
<div style="font-size:11px;color:var(--text-dim);margin-top:2px">${t.description}</div>
</div>
</label>
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-mute)">
<div>
<div>调度:${t.schedule_type === 'cron' ? t.cron_expression : `${t.interval_seconds}`}</div>
<div style="margin-top:2px">下次:${t.next_run || '未调度'}</div>
</div>
<div>
<div>执行:${t.run_count}次</div>
<div style="margin-top:2px;color:${statusColor}">状态:${t.last_status || '—'}</div>
</div>
<div>
<div>上次:${t.last_run || '—'}</div>
</div>
<button class="task-edit" data-id="${t.task_id}" style="height:24px;padding:0 8px;background:#2a3140;border:1px solid var(--border);color:var(--text);border-radius:4px;cursor:pointer">配置</button>
</div>
</div>`;
});
html += '</div>';
}
document.getElementById('tasks-list').innerHTML = html;
// 绑定开关事件
document.querySelectorAll('.task-toggle').forEach(el => {
el.onchange = async () => {
try { await apiPost('/api/tasks/' + el.dataset.id + '/toggle'); loadTasks(); }
catch { alert('操作失败'); }
};
});
// 绑定编辑事件
document.querySelectorAll('.task-edit').forEach(el => {
el.onclick = () => showTaskEditModal(el.dataset.id, tasks.find(t => t.task_id === el.dataset.id));
});
// 填充日志筛选
const sel = document.getElementById('task-log-filter');
sel.innerHTML = '<option value="">全部任务</option>' + tasks.map(t => `<option value="${t.task_id}">${t.name}</option>`).join('');
};
const loadLogs = async (taskId = '') => {
let r; try { r = await apiGet('/api/tasks/' + (taskId || 'all') + '/logs?limit=50'); } catch { return; }
const logs = r.logs || [];
if (!logs.length) { document.getElementById('task-logs').innerHTML = '<div class="loading">暂无日志</div>'; return; }
const rows = logs.map(log => `<tr>
<td>${log.started}</td>
<td>${log.task_id}</td>
<td><span class="tag ${log.status === 'success' ? '' : (log.status === 'error' ? 'hot' : '')}">${log.status}</span></td>
<td>${log.duration ? log.duration.toFixed(1) + 's' : '—'}</td>
<td style="text-align:left;color:var(--text-dim)">${log.message ? log.message.substring(0, 80) : '—'}</td>
</tr>`).join('');
document.getElementById('task-logs').innerHTML = `<table class="grid-tbl"><thead><tr><th>时间</th><th>任务</th><th>状态</th><th>耗时</th><th>消息</th></tr></thead><tbody>${rows}</tbody></table>`;
};
document.getElementById('tasks-reload').onclick = async () => {
try { await apiPost('/api/tasks/reload'); alert('调度器已重新加载'); loadTasks(); }
catch { alert('重新加载失败'); }
};
document.getElementById('task-log-filter').onchange = (e) => loadLogs(e.target.value);
await loadTasks();
await loadLogs();
},
async 'paper-trading'(view) {
view.innerHTML = `
<div class="panel" style="margin-bottom:10px">
@@ -1346,7 +1471,7 @@ const VIEWS = {
<div class="panel-head"><span class="bar"></span>我的账户</div>
<div class="panel-body">
<div style="margin-bottom:16px;padding:12px;background:#0a0e15;border-radius:4px">
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${me.is_admin?'??':'??'} ${me.username}</div>
<div style="font-size:18px;font-weight:700;margin-bottom:4px">${me.is_admin?'👑':'👤'} ${me.username}</div>
<div style="color:var(--text-mute);font-size:12px">${me.is_admin?'管理员':'普通用户'}</div>
</div>
<div style="margin-bottom:8px;font-weight:600">修改密码</div>
@@ -1592,6 +1717,87 @@ function closeTrendModal() {
}
// 显示走势分析弹窗
function showTaskEditModal(taskId, task) {
const old = document.getElementById('_task_edit_modal');
if (old) old.remove();
const bg = document.createElement('div');
bg.id = '_task_edit_modal';
bg.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.6);z-index:10000;display:flex;align-items:center;justify-content:center;';
const modal = document.createElement('div');
modal.style.cssText = 'background:#0f1520;border:1px solid var(--border);border-radius:8px;width:min(500px,90vw);padding:20px;box-shadow:0 16px 48px rgba(0,0,0,0.7);';
modal.innerHTML = `
<h3 style="margin:0 0 16px;font-size:15px;font-weight:700">⚙ 配置任务:${task.name}</h3>
<div style="margin-bottom:12px">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">调度类型</label>
<select id="te-type" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 8px;border-radius:4px">
<option value="cron" ${task.schedule_type === 'cron' ? 'selected' : ''}>定时(Cron)</option>
<option value="interval" ${task.schedule_type === 'interval' ? 'selected' : ''}>间隔(秒)</option>
</select>
</div>
<div id="te-cron" style="margin-bottom:12px;${task.schedule_type === 'cron' ? '' : 'display:none'}">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">Cron表达式 (格式: mon-fri 16:00)</label>
<input id="te-cron-val" value="${task.cron_expression || ''}" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/>
</div>
<div id="te-interval" style="margin-bottom:16px;${task.schedule_type === 'interval' ? '' : 'display:none'}">
<label style="display:block;margin-bottom:4px;font-size:12px;color:var(--text-dim)">间隔秒数</label>
<input id="te-interval-val" type="number" value="${task.interval_seconds || 60}" style="width:100%;height:32px;background:#0a0e15;border:1px solid var(--border);color:var(--text);padding:0 10px;border-radius:4px"/>
</div>
<div style="display:flex;gap:8px">
<button id="te-save" class="btn-run" style="flex:1">保存</button>
<button id="te-cancel" class="btn-run" style="flex:1;background:#2a3140;border-color:#2a3140">取消</button>
</div>
`;
bg.appendChild(modal);
document.body.appendChild(bg);
const typeEl = document.getElementById('te-type');
const cronEl = document.getElementById('te-cron');
const intervalEl = document.getElementById('te-interval');
typeEl.onchange = () => {
if (typeEl.value === 'cron') {
cronEl.style.display = 'block';
intervalEl.style.display = 'none';
} else {
cronEl.style.display = 'none';
intervalEl.style.display = 'block';
}
};
document.getElementById('te-save').onclick = async () => {
const scheduleType = typeEl.value;
const payload = { schedule_type: scheduleType };
if (scheduleType === 'cron') {
payload.cron_expression = document.getElementById('te-cron-val').value.trim();
if (!payload.cron_expression) { alert('请填写Cron表达式'); return; }
} else {
payload.interval_seconds = parseInt(document.getElementById('te-interval-val').value);
if (!payload.interval_seconds || payload.interval_seconds < 1) { alert('请填写有效的间隔秒数'); return; }
}
try {
await fetch(API_BASE + '/api/tasks/' + taskId, {
method: 'PUT',
headers: { ...authHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
bg.remove();
alert('配置已保存,调度器将自动重新加载');
navigate('tasks');
} catch {
alert('保存失败');
}
};
document.getElementById('te-cancel').onclick = () => bg.remove();
bg.onclick = (e) => { if (e.target === bg) bg.remove(); };
}
function showTrendModal(symbol, name, date, initPeriod) {
closeTrendModal();
const bg = document.createElement('div');
@@ -1599,7 +1805,7 @@ function showTrendModal(symbol, name, date, initPeriod) {
bg.innerHTML = `
<div class="trend-modal" onclick="event.stopPropagation()">
<div class="trend-modal-head">
<h3>?? 走势分析 <span style="color:var(--text-dim);font-size:13px;font-weight:400">${name || symbol} (${symbol})</span></h3>
<h3>📈 走势分析 <span style="color:var(--text-dim);font-size:13px;font-weight:400">${name || symbol} (${symbol})</span></h3>
<span class="seg period-seg">
<button data-p="daily" class="${initPeriod==='daily'?'active':''}">日K</button>
<button data-p="weekly" class="${initPeriod==='weekly'?'active':''}">周K</button>
@@ -1660,7 +1866,7 @@ function bindKlineContextMenu(chart, getSymbol, getName) {
const menu = document.createElement('div');
menu.className = 'ctx-menu'; menu.id = '_ctx_menu';
menu.innerHTML = `
<div class="ctx-menu-item" id="_ctx_trend">?? 走势分析</div>
<div class="ctx-menu-item" id="_ctx_trend">📈 走势分析</div>
<div class="ctx-menu-sep"></div>
<div class="ctx-menu-item" id="_ctx_close">关闭</div>`;
menu.style.left = e.event.clientX + 'px';
@@ -1688,7 +1894,7 @@ async function initUserState() {
const j = await r.json();
const el = document.getElementById('user-info');
const btn = document.getElementById('logout-btn');
if (el) { el.textContent = (j.is_admin ? '?? ' : '?? ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; }
if (el) { el.textContent = (j.is_admin ? '👑 ' : '👤 ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; }
if (btn) btn.style.display = 'inline-block';
// 登录成功后导航
navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview');
@@ -1804,7 +2010,7 @@ function showLoginRequired() {
bg.remove();
const el = document.getElementById('user-info');
const btn = document.getElementById('logout-btn');
if (el) { el.textContent = (j.is_admin?'?? ':'?? ')+j.username; el.style.color = j.is_admin?'var(--gold)':'var(--text)'; }
if (el) { el.textContent = (j.is_admin ? '👑 ' : '👤 ') + j.username; el.style.color = j.is_admin ? 'var(--gold)' : 'var(--text)'; }
if (btn) btn.style.display = 'inline-block';
navigate(location.hash.slice(1) && VIEW_INDEX[location.hash.slice(1)] ? location.hash.slice(1) : 'overview');
} else {