1
This commit is contained in:
parent
9d8e223df8
commit
46a7bf1192
@ -1,5 +1,6 @@
|
|||||||
"""Debug API — 系统日志与运行状态"""
|
"""Debug API — 系统日志与运行状态"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@ -33,7 +34,7 @@ async def get_errors(
|
|||||||
conditions.append("level = :level")
|
conditions.append("level = :level")
|
||||||
params["level"] = level
|
params["level"] = level
|
||||||
|
|
||||||
where = " AND " + " AND ".join(conditions)
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
# 总数
|
# 总数
|
||||||
count_result = await db.execute(
|
count_result = await db.execute(
|
||||||
@ -110,7 +111,7 @@ async def system_status(_admin: dict = Depends(get_current_admin)):
|
|||||||
tables_counts = {}
|
tables_counts = {}
|
||||||
for t in ["recommendations", "sector_heat", "market_temperature",
|
for t in ["recommendations", "sector_heat", "market_temperature",
|
||||||
"recommendation_tracking", "stock_diagnoses",
|
"recommendation_tracking", "stock_diagnoses",
|
||||||
"error_logs", "users"]:
|
"error_logs", "scan_process_logs", "users"]:
|
||||||
result = await db.execute(text(f"SELECT COUNT(*) FROM {t}"))
|
result = await db.execute(text(f"SELECT COUNT(*) FROM {t}"))
|
||||||
tables_counts[t] = result.scalar() or 0
|
tables_counts[t] = result.scalar() or 0
|
||||||
|
|
||||||
@ -146,3 +147,354 @@ async def system_status(_admin: dict = Depends(get_current_admin)):
|
|||||||
"tables_counts": tables_counts,
|
"tables_counts": tables_counts,
|
||||||
"db_size_mb": db_size_mb,
|
"db_size_mb": db_size_mb,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_detail(raw: str | None) -> dict:
|
||||||
|
if not raw:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
parsed = json.loads(raw)
|
||||||
|
return parsed if isinstance(parsed, dict) else {"value": parsed}
|
||||||
|
except Exception:
|
||||||
|
return {"raw": raw}
|
||||||
|
|
||||||
|
|
||||||
|
def _row_observation(row) -> dict:
|
||||||
|
r = row._mapping
|
||||||
|
return {
|
||||||
|
"id": r["id"],
|
||||||
|
"scan_session": r["scan_session"],
|
||||||
|
"scan_mode": r["scan_mode"] or "",
|
||||||
|
"ts_code": r["ts_code"],
|
||||||
|
"name": r["name"],
|
||||||
|
"theme_name": r["theme_name"] or "",
|
||||||
|
"stock_role": r["stock_role"] or "",
|
||||||
|
"action_plan": r["action_plan"] or "观察",
|
||||||
|
"final_score": r["final_score"] or 0,
|
||||||
|
"catalyst_score": r["catalyst_score"] or 0,
|
||||||
|
"theme_money_score": r["theme_money_score"] or 0,
|
||||||
|
"stock_money_score": r["stock_money_score"] or 0,
|
||||||
|
"emotion_role_score": r["emotion_role_score"] or 0,
|
||||||
|
"timing_score": r["timing_score"] or 0,
|
||||||
|
"entry_signal_type": r["entry_signal_type"] or "none",
|
||||||
|
"elimination_reason": r["elimination_reason"] or "",
|
||||||
|
"detail": _decode_detail(r["detail_json"]),
|
||||||
|
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan-logs")
|
||||||
|
async def get_scan_logs(
|
||||||
|
limit: int = 100,
|
||||||
|
scan_session: str = None,
|
||||||
|
days: int = 7,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取筛选过程日志(管理员)"""
|
||||||
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
limit = max(1, min(limit, 300))
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
selected_session = scan_session
|
||||||
|
if not selected_session:
|
||||||
|
latest = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session FROM scan_process_logs "
|
||||||
|
"WHERE created_at >= :start "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
selected_session = latest.scalar()
|
||||||
|
|
||||||
|
if not selected_session:
|
||||||
|
return {"scan_session": None, "logs": []}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, scan_session, scan_mode, stage, stage_label, status, "
|
||||||
|
"input_count, output_count, filtered_count, summary, detail_json, created_at "
|
||||||
|
"FROM scan_process_logs "
|
||||||
|
"WHERE created_at >= :start AND scan_session = :scan_session "
|
||||||
|
"ORDER BY created_at ASC LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"start": start, "scan_session": selected_session, "limit": limit},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
logs = []
|
||||||
|
for row in rows:
|
||||||
|
r = row._mapping
|
||||||
|
logs.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"scan_session": r["scan_session"],
|
||||||
|
"scan_mode": r["scan_mode"] or "",
|
||||||
|
"stage": r["stage"],
|
||||||
|
"stage_label": r["stage_label"],
|
||||||
|
"status": r["status"] or "ok",
|
||||||
|
"input_count": r["input_count"] or 0,
|
||||||
|
"output_count": r["output_count"] or 0,
|
||||||
|
"filtered_count": r["filtered_count"] or 0,
|
||||||
|
"summary": r["summary"] or "",
|
||||||
|
"detail": _decode_detail(r["detail_json"]),
|
||||||
|
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scan_session": selected_session,
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/research-observations")
|
||||||
|
async def get_research_observations(
|
||||||
|
scan_session: str = None,
|
||||||
|
limit: int = 80,
|
||||||
|
days: int = 7,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取候选股投研观察记录(管理员)"""
|
||||||
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
limit = max(1, min(limit, 200))
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
selected_session = scan_session
|
||||||
|
if not selected_session:
|
||||||
|
latest = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session FROM research_observations "
|
||||||
|
"WHERE created_at >= :start ORDER BY created_at DESC LIMIT 1"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
selected_session = latest.scalar()
|
||||||
|
|
||||||
|
if not selected_session:
|
||||||
|
return {"scan_session": None, "observations": [], "reason_counts": {}}
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, scan_session, scan_mode, ts_code, name, theme_name, stock_role, "
|
||||||
|
"action_plan, final_score, catalyst_score, theme_money_score, stock_money_score, "
|
||||||
|
"emotion_role_score, timing_score, entry_signal_type, elimination_reason, detail_json, created_at "
|
||||||
|
"FROM research_observations "
|
||||||
|
"WHERE created_at >= :start AND scan_session = :scan_session "
|
||||||
|
"ORDER BY final_score DESC LIMIT :limit"
|
||||||
|
),
|
||||||
|
{"start": start, "scan_session": selected_session, "limit": limit},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
observations = [_row_observation(row) for row in rows]
|
||||||
|
reason_counts = {}
|
||||||
|
for item in observations:
|
||||||
|
for part in (item["elimination_reason"] or "未知").split(";"):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
reason_counts[part] = reason_counts.get(part, 0) + 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scan_session": selected_session,
|
||||||
|
"observations": observations,
|
||||||
|
"reason_counts": reason_counts,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/scan-sessions")
|
||||||
|
async def get_scan_sessions(
|
||||||
|
days: int = 7,
|
||||||
|
limit: int = 30,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""获取筛选会话摘要(管理员)"""
|
||||||
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
limit = max(1, min(limit, 100))
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session, scan_mode, stage, status, input_count, "
|
||||||
|
"output_count, filtered_count, summary, created_at "
|
||||||
|
"FROM scan_process_logs "
|
||||||
|
"WHERE created_at >= :start "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1000"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
sessions = {}
|
||||||
|
order = []
|
||||||
|
for row in rows:
|
||||||
|
r = row._mapping
|
||||||
|
session_id = r["scan_session"]
|
||||||
|
if session_id not in sessions:
|
||||||
|
sessions[session_id] = {
|
||||||
|
"scan_session": session_id,
|
||||||
|
"scan_mode": r["scan_mode"] or "",
|
||||||
|
"created_at": str(r["created_at"]) if r["created_at"] else "",
|
||||||
|
"stage_count": 0,
|
||||||
|
"status": "ok",
|
||||||
|
"input_count": 0,
|
||||||
|
"final_count": 0,
|
||||||
|
"drop_count": 0,
|
||||||
|
"last_summary": r["summary"] or "",
|
||||||
|
}
|
||||||
|
order.append(session_id)
|
||||||
|
|
||||||
|
item = sessions[session_id]
|
||||||
|
item["stage_count"] += 1
|
||||||
|
item["input_count"] = max(item["input_count"], int(r["input_count"] or 0))
|
||||||
|
item["drop_count"] += int(r["filtered_count"] or 0)
|
||||||
|
if r["stage"] == "final_filter" and item["final_count"] == 0:
|
||||||
|
item["final_count"] = int(r["output_count"] or 0)
|
||||||
|
item["last_summary"] = r["summary"] or item["last_summary"]
|
||||||
|
|
||||||
|
status = (r["status"] or "ok").lower()
|
||||||
|
if status in {"failed", "error", "critical"}:
|
||||||
|
item["status"] = "failed"
|
||||||
|
elif status in {"warning", "empty"} and item["status"] == "ok":
|
||||||
|
item["status"] = status
|
||||||
|
|
||||||
|
return {
|
||||||
|
"sessions": [sessions[sid] for sid in order[:limit]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/data-source-health")
|
||||||
|
async def get_data_source_health(
|
||||||
|
days: int = 7,
|
||||||
|
_admin: dict = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""数据源健康摘要(管理员,只读)。"""
|
||||||
|
start = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||||
|
known_sources = ["eastmoney", "tencent", "tushare", "akshare", "sina", "news", "tushare_news"]
|
||||||
|
health = {
|
||||||
|
source: {
|
||||||
|
"source": source,
|
||||||
|
"status": "ok",
|
||||||
|
"error_count": 0,
|
||||||
|
"warning_count": 0,
|
||||||
|
"last_error": "",
|
||||||
|
"last_seen_at": "",
|
||||||
|
}
|
||||||
|
for source in known_sources
|
||||||
|
}
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
result = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT source, level, message, created_at FROM error_logs "
|
||||||
|
"WHERE created_at >= :start "
|
||||||
|
"ORDER BY created_at DESC LIMIT 500"
|
||||||
|
),
|
||||||
|
{"start": start},
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
table_rows = {}
|
||||||
|
for table_name, sql in {
|
||||||
|
"market_temperature": "SELECT trade_date, created_at FROM market_temperature ORDER BY id DESC LIMIT 1",
|
||||||
|
"sector_heat": "SELECT trade_date, created_at FROM sector_heat ORDER BY id DESC LIMIT 1",
|
||||||
|
"recommendations": "SELECT created_at FROM recommendations ORDER BY id DESC LIMIT 1",
|
||||||
|
"news_items": "SELECT created_at FROM news_items ORDER BY id DESC LIMIT 1",
|
||||||
|
"catalysts": "SELECT created_at FROM catalysts ORDER BY id DESC LIMIT 1",
|
||||||
|
}.items():
|
||||||
|
row = (await db.execute(text(sql))).fetchone()
|
||||||
|
table_rows[table_name] = dict(row._mapping) if row else {}
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
r = row._mapping
|
||||||
|
source_text = str(r["source"] or "").lower()
|
||||||
|
matched = next((source for source in known_sources if source in source_text), source_text or "unknown")
|
||||||
|
if matched not in health:
|
||||||
|
health[matched] = {
|
||||||
|
"source": matched,
|
||||||
|
"status": "ok",
|
||||||
|
"error_count": 0,
|
||||||
|
"warning_count": 0,
|
||||||
|
"last_error": "",
|
||||||
|
"last_seen_at": "",
|
||||||
|
}
|
||||||
|
item = health[matched]
|
||||||
|
level_text = str(r["level"] or "error").lower()
|
||||||
|
if level_text in {"warning", "warn"}:
|
||||||
|
item["warning_count"] += 1
|
||||||
|
if item["status"] == "ok":
|
||||||
|
item["status"] = "warning"
|
||||||
|
else:
|
||||||
|
item["error_count"] += 1
|
||||||
|
item["status"] = "error"
|
||||||
|
if not item["last_error"]:
|
||||||
|
item["last_error"] = r["message"] or ""
|
||||||
|
item["last_seen_at"] = str(r["created_at"] or "")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"days": days,
|
||||||
|
"sources": sorted(health.values(), key=lambda item: (item["status"] != "error", item["status"] != "warning", item["source"])),
|
||||||
|
"freshness": {
|
||||||
|
key: {k: str(v or "") for k, v in value.items()}
|
||||||
|
for key, value in table_rows.items()
|
||||||
|
},
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tasks")
|
||||||
|
async def get_tasks(_admin: dict = Depends(get_current_admin)):
|
||||||
|
"""后台任务中心摘要(管理员,只读)。"""
|
||||||
|
from app.engine.scheduler import scheduler
|
||||||
|
from app.engine.recommender import _scan_running, _scan_lock
|
||||||
|
|
||||||
|
jobs = []
|
||||||
|
for job in scheduler.get_jobs():
|
||||||
|
jobs.append({
|
||||||
|
"id": job.id,
|
||||||
|
"name": job.name,
|
||||||
|
"next_run_time": str(job.next_run_time) if job.next_run_time else "",
|
||||||
|
"trigger": str(job.trigger),
|
||||||
|
})
|
||||||
|
|
||||||
|
async with get_db() as db:
|
||||||
|
recent_scan = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT scan_session, scan_mode, stage, status, output_count, summary, created_at "
|
||||||
|
"FROM scan_process_logs ORDER BY created_at DESC LIMIT 12"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
recent_errors = await db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT source, level, message, created_at FROM error_logs "
|
||||||
|
"ORDER BY created_at DESC LIMIT 8"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scheduler_running": scheduler.running,
|
||||||
|
"scan_running": _scan_running,
|
||||||
|
"scan_locked": _scan_lock.locked(),
|
||||||
|
"job_count": len(jobs),
|
||||||
|
"jobs": sorted(jobs, key=lambda item: item["next_run_time"] or "9999"),
|
||||||
|
"recent_scan_logs": [
|
||||||
|
{
|
||||||
|
"scan_session": r._mapping["scan_session"],
|
||||||
|
"scan_mode": r._mapping["scan_mode"] or "",
|
||||||
|
"stage": r._mapping["stage"],
|
||||||
|
"status": r._mapping["status"] or "ok",
|
||||||
|
"output_count": r._mapping["output_count"] or 0,
|
||||||
|
"summary": r._mapping["summary"] or "",
|
||||||
|
"created_at": str(r._mapping["created_at"] or ""),
|
||||||
|
}
|
||||||
|
for r in recent_scan.fetchall()
|
||||||
|
],
|
||||||
|
"recent_errors": [
|
||||||
|
{
|
||||||
|
"source": r._mapping["source"],
|
||||||
|
"level": r._mapping["level"],
|
||||||
|
"message": r._mapping["message"],
|
||||||
|
"created_at": str(r._mapping["created_at"] or ""),
|
||||||
|
}
|
||||||
|
for r in recent_errors.fetchall()
|
||||||
|
],
|
||||||
|
"generated_at": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|||||||
@ -113,6 +113,11 @@ async def init_db():
|
|||||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_items_dedup_key ON news_items(dedup_key)",
|
"CREATE UNIQUE INDEX IF NOT EXISTS idx_news_items_dedup_key ON news_items(dedup_key)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_news_items_status_time ON news_items(status, published_at)",
|
"CREATE INDEX IF NOT EXISTS idx_news_items_status_time ON news_items(status, published_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_catalysts_source_url ON catalysts(source, url)",
|
"CREATE INDEX IF NOT EXISTS idx_catalysts_source_url ON catalysts(source, url)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_scan_process_session_time ON scan_process_logs(scan_session, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_scan_process_stage_time ON scan_process_logs(stage, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_session_score ON research_observations(scan_session, final_score)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_code_time ON research_observations(ts_code, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_research_observations_theme_time ON research_observations(theme_name, created_at)",
|
||||||
]:
|
]:
|
||||||
try:
|
try:
|
||||||
await conn.execute(__import__("sqlalchemy").text(index_sql))
|
await conn.execute(__import__("sqlalchemy").text(index_sql))
|
||||||
|
|||||||
56
backend/app/db/research_logger.py
Normal file
56
backend/app/db/research_logger.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""投研观察记录。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db import tables
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(data: dict[str, Any] | None) -> str:
|
||||||
|
if not data:
|
||||||
|
return "{}"
|
||||||
|
try:
|
||||||
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
except Exception:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
async def save_research_observations(observations: list[dict[str, Any]]) -> None:
|
||||||
|
"""批量保存本轮候选股投研观察。
|
||||||
|
|
||||||
|
记录失败不能影响筛选主流程。
|
||||||
|
"""
|
||||||
|
if not observations:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
values = []
|
||||||
|
now = datetime.now()
|
||||||
|
for item in observations:
|
||||||
|
values.append({
|
||||||
|
"scan_session": item.get("scan_session") or "manual",
|
||||||
|
"scan_mode": item.get("scan_mode") or "",
|
||||||
|
"ts_code": item.get("ts_code") or "",
|
||||||
|
"name": item.get("name") or item.get("ts_code") or "",
|
||||||
|
"theme_name": item.get("theme_name") or "",
|
||||||
|
"stock_role": item.get("stock_role") or "",
|
||||||
|
"action_plan": item.get("action_plan") or "观察",
|
||||||
|
"final_score": float(item.get("final_score") or 0),
|
||||||
|
"catalyst_score": float(item.get("catalyst_score") or 0),
|
||||||
|
"theme_money_score": float(item.get("theme_money_score") or 0),
|
||||||
|
"stock_money_score": float(item.get("stock_money_score") or 0),
|
||||||
|
"emotion_role_score": float(item.get("emotion_role_score") or 0),
|
||||||
|
"timing_score": float(item.get("timing_score") or 0),
|
||||||
|
"entry_signal_type": item.get("entry_signal_type") or "none",
|
||||||
|
"elimination_reason": item.get("elimination_reason") or "",
|
||||||
|
"detail_json": _safe_json(item.get("detail")),
|
||||||
|
"created_at": now,
|
||||||
|
})
|
||||||
|
async with get_db() as db:
|
||||||
|
await db.execute(tables.research_observations_table.insert(), values)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
60
backend/app/db/scan_logger.py
Normal file
60
backend/app/db/scan_logger.py
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"""筛选过程日志持久化。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.db import tables
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_json(data: dict[str, Any] | None) -> str:
|
||||||
|
if not data:
|
||||||
|
return "{}"
|
||||||
|
try:
|
||||||
|
return json.dumps(data, ensure_ascii=False, default=str)
|
||||||
|
except Exception:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
async def log_scan_stage(
|
||||||
|
*,
|
||||||
|
scan_session: str,
|
||||||
|
scan_mode: str,
|
||||||
|
stage: str,
|
||||||
|
stage_label: str,
|
||||||
|
input_count: int = 0,
|
||||||
|
output_count: int = 0,
|
||||||
|
filtered_count: int | None = None,
|
||||||
|
status: str = "ok",
|
||||||
|
summary: str = "",
|
||||||
|
detail: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""记录筛选漏斗的一关。
|
||||||
|
|
||||||
|
这是观测能力,不能反过来影响筛选主流程,所以所有异常都会被吞掉。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if filtered_count is None:
|
||||||
|
filtered_count = max(int(input_count or 0) - int(output_count or 0), 0)
|
||||||
|
async with get_db() as db:
|
||||||
|
await db.execute(
|
||||||
|
tables.scan_process_logs_table.insert().values(
|
||||||
|
scan_session=scan_session or "manual",
|
||||||
|
scan_mode=scan_mode or "",
|
||||||
|
stage=stage,
|
||||||
|
stage_label=stage_label,
|
||||||
|
status=status,
|
||||||
|
input_count=int(input_count or 0),
|
||||||
|
output_count=int(output_count or 0),
|
||||||
|
filtered_count=int(filtered_count or 0),
|
||||||
|
summary=summary or "",
|
||||||
|
detail_json=_safe_json(detail),
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@ -197,6 +197,44 @@ error_logs_table = Table(
|
|||||||
Column("created_at", DateTime, server_default=func.now()),
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
scan_process_logs_table = Table(
|
||||||
|
"scan_process_logs", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("scan_mode", Text, default=""),
|
||||||
|
Column("stage", Text, nullable=False),
|
||||||
|
Column("stage_label", Text, nullable=False),
|
||||||
|
Column("status", Text, default="ok"),
|
||||||
|
Column("input_count", Integer, default=0),
|
||||||
|
Column("output_count", Integer, default=0),
|
||||||
|
Column("filtered_count", Integer, default=0),
|
||||||
|
Column("summary", Text, default=""),
|
||||||
|
Column("detail_json", Text, default="{}"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
research_observations_table = Table(
|
||||||
|
"research_observations", metadata,
|
||||||
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
Column("scan_session", Text, nullable=False),
|
||||||
|
Column("scan_mode", Text, default=""),
|
||||||
|
Column("ts_code", Text, nullable=False),
|
||||||
|
Column("name", Text, nullable=False),
|
||||||
|
Column("theme_name", Text, default=""),
|
||||||
|
Column("stock_role", Text, default=""),
|
||||||
|
Column("action_plan", Text, default="观察"),
|
||||||
|
Column("final_score", Float, default=0),
|
||||||
|
Column("catalyst_score", Float, default=0),
|
||||||
|
Column("theme_money_score", Float, default=0),
|
||||||
|
Column("stock_money_score", Float, default=0),
|
||||||
|
Column("emotion_role_score", Float, default=0),
|
||||||
|
Column("timing_score", Float, default=0),
|
||||||
|
Column("entry_signal_type", Text, default="none"),
|
||||||
|
Column("elimination_reason", Text, default=""),
|
||||||
|
Column("detail_json", Text, default="{}"),
|
||||||
|
Column("created_at", DateTime, server_default=func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
strategy_configs_table = Table(
|
strategy_configs_table = Table(
|
||||||
"strategy_configs", metadata,
|
"strategy_configs", metadata,
|
||||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||||
|
|||||||
@ -98,7 +98,7 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
try:
|
try:
|
||||||
# run_screening 内部混合了大量同步行情请求和 pandas 计算,
|
# run_screening 内部混合了大量同步行情请求和 pandas 计算,
|
||||||
# 若直接在主事件循环执行,会导致页面读接口和 WebSocket 被拖住。
|
# 若直接在主事件循环执行,会导致页面读接口和 WebSocket 被拖住。
|
||||||
result = await _run_async_in_worker(run_screening, trade_date)
|
result = await _run_async_in_worker(run_screening, trade_date, scan_session=scan_session)
|
||||||
|
|
||||||
# 给每条推荐添加 scan_session
|
# 给每条推荐添加 scan_session
|
||||||
for rec in result.get("recommendations", []):
|
for rec in result.get("recommendations", []):
|
||||||
|
|||||||
@ -40,6 +40,8 @@ from app.config import settings, should_prefer_realtime_today
|
|||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.llm.strategy_selector import StrategyProfile, select_strategy_profile
|
from app.llm.strategy_selector import StrategyProfile, select_strategy_profile
|
||||||
from app.catalyst.service import build_theme_catalyst_scores
|
from app.catalyst.service import build_theme_catalyst_scores
|
||||||
|
from app.db.scan_logger import log_scan_stage
|
||||||
|
from app.db.research_logger import save_research_observations
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -49,7 +51,7 @@ def _is_main_theme_recommendation(rec: Recommendation) -> bool:
|
|||||||
return bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"})
|
return bool(tags & {"hot_theme_core", "theme_leader", "top_theme_member", "sector_recall"})
|
||||||
|
|
||||||
|
|
||||||
async def run_screening(trade_date: str = None) -> dict:
|
async def run_screening(trade_date: str = None, scan_session: str = "manual") -> dict:
|
||||||
"""执行趋势突破筛选流程
|
"""执行趋势突破筛选流程
|
||||||
|
|
||||||
返回: {
|
返回: {
|
||||||
@ -79,6 +81,24 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
logger.info(f"市场温度: {market_temp.temperature}")
|
logger.info(f"市场温度: {market_temp.temperature}")
|
||||||
|
|
||||||
market_temp_score = market_temp.temperature
|
market_temp_score = market_temp.temperature
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="market_temperature",
|
||||||
|
stage_label="市场温度",
|
||||||
|
input_count=(market_temp.up_count or 0) + (market_temp.down_count or 0),
|
||||||
|
output_count=1,
|
||||||
|
filtered_count=0,
|
||||||
|
summary=f"市场温度 {market_temp.temperature:.1f},上涨{market_temp.up_count or 0}家,下跌{market_temp.down_count or 0}家",
|
||||||
|
detail={
|
||||||
|
"temperature": market_temp.temperature,
|
||||||
|
"up_count": market_temp.up_count,
|
||||||
|
"down_count": market_temp.down_count,
|
||||||
|
"limit_up_count": market_temp.limit_up_count,
|
||||||
|
"limit_down_count": market_temp.limit_down_count,
|
||||||
|
"intraday": intraday,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 1: 主线主题定位 ──
|
# ── Step 1: 主线主题定位 ──
|
||||||
logger.info("=== Step 1: 主线主题定位 ===")
|
logger.info("=== Step 1: 主线主题定位 ===")
|
||||||
@ -97,6 +117,30 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
hot_sectors = all_themes[:settings.top_sector_count]
|
hot_sectors = all_themes[:settings.top_sector_count]
|
||||||
|
|
||||||
hot_sectors = await _apply_catalyst_scores(hot_sectors)
|
hot_sectors = await _apply_catalyst_scores(hot_sectors)
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="theme_selection",
|
||||||
|
stage_label="主线主题",
|
||||||
|
input_count=len(all_themes),
|
||||||
|
output_count=len(hot_sectors),
|
||||||
|
summary=f"从 {len(all_themes)} 个主题中保留 {len(hot_sectors)} 条主线",
|
||||||
|
detail={
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"name": s.sector_name,
|
||||||
|
"heat_score": s.heat_score,
|
||||||
|
"pct_change": s.realtime_pct_change if s.realtime_pct_change is not None else s.pct_change,
|
||||||
|
"capital_inflow": s.capital_inflow,
|
||||||
|
"limit_up_count": s.limit_up_count,
|
||||||
|
"stage": s.stage,
|
||||||
|
"catalyst_score": getattr(s, "catalyst_score", 0),
|
||||||
|
"catalyst_count": getattr(s, "catalyst_count", 0),
|
||||||
|
}
|
||||||
|
for s in hot_sectors[:10]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
for s in hot_sectors:
|
for s in hot_sectors:
|
||||||
logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}万 "
|
logger.info(f" 目标主题: {s.sector_name} 涨幅{s.pct_change}% 资金{s.capital_inflow:.0f}万 "
|
||||||
@ -111,18 +155,53 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
f"=== 今日策略: {strategy_profile.name} ({strategy_profile.strategy_id}) "
|
||||||
f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ==="
|
f"threshold={strategy_profile.buy_threshold} min_score={strategy_profile.min_score} ==="
|
||||||
)
|
)
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="strategy_profile",
|
||||||
|
stage_label="策略参数",
|
||||||
|
input_count=len(hot_sectors),
|
||||||
|
output_count=1,
|
||||||
|
filtered_count=0,
|
||||||
|
summary=f"{strategy_profile.name}: 买入线 {strategy_profile.buy_threshold},保留线 {strategy_profile.min_score}",
|
||||||
|
detail=strategy_profile.model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 2: 多路召回构建候选池 ──
|
# ── Step 2: 多路召回构建候选池 ──
|
||||||
logger.info("=== Step 2: 多路召回候选池 ===")
|
logger.info("=== Step 2: 多路召回候选池 ===")
|
||||||
|
candidate_metrics: dict = {}
|
||||||
candidates = await _build_candidate_pool(
|
candidates = await _build_candidate_pool(
|
||||||
hot_sectors=hot_sectors,
|
hot_sectors=hot_sectors,
|
||||||
trade_date=trade_date,
|
trade_date=trade_date,
|
||||||
intraday=intraday,
|
intraday=intraday,
|
||||||
market_temp=market_temp,
|
market_temp=market_temp,
|
||||||
|
metrics=candidate_metrics,
|
||||||
|
)
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="candidate_recall",
|
||||||
|
stage_label="候选召回",
|
||||||
|
input_count=len(hot_sectors),
|
||||||
|
output_count=len(candidates),
|
||||||
|
filtered_count=max(int(candidate_metrics.get("merged_count", 0) or 0) - len(candidates), 0),
|
||||||
|
summary=f"多路召回合并后进入规则评分 {len(candidates)} 只",
|
||||||
|
detail=candidate_metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not candidates:
|
if not candidates:
|
||||||
logger.info("=== 筛选完成: 0 只股票 ===")
|
logger.info("=== 筛选完成: 0 只股票 ===")
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="final_filter",
|
||||||
|
stage_label="最终作战池",
|
||||||
|
input_count=0,
|
||||||
|
output_count=0,
|
||||||
|
filtered_count=0,
|
||||||
|
status="empty",
|
||||||
|
summary="候选池为空,本轮没有形成推荐",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"market_temp": market_temp,
|
"market_temp": market_temp,
|
||||||
"hot_sectors": hot_sectors,
|
"hot_sectors": hot_sectors,
|
||||||
@ -132,6 +211,9 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
|
|
||||||
# ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ──
|
# ── Step 3 之前:注入腾讯实时价格(防止 Tushare 日线数据过时) ──
|
||||||
if candidates:
|
if candidates:
|
||||||
|
quote_requested = len([c for c in candidates if "ts_code" in c])
|
||||||
|
quote_updated = 0
|
||||||
|
quote_error = ""
|
||||||
try:
|
try:
|
||||||
from app.data.tencent_client import get_realtime_quotes_batch
|
from app.data.tencent_client import get_realtime_quotes_batch
|
||||||
codes = [c["ts_code"] for c in candidates if "ts_code" in c]
|
codes = [c["ts_code"] for c in candidates if "ts_code" in c]
|
||||||
@ -140,19 +222,56 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
q = quotes.get(c["ts_code"])
|
q = quotes.get(c["ts_code"])
|
||||||
if q and q.price > 0:
|
if q and q.price > 0:
|
||||||
c["price"] = q.price
|
c["price"] = q.price
|
||||||
|
quote_updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
quote_error = str(e)
|
||||||
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
|
logger.warning(f"注入实时价格失败,使用 Tushare 收盘价: {e}")
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="realtime_quote",
|
||||||
|
stage_label="实时行情校准",
|
||||||
|
input_count=quote_requested,
|
||||||
|
output_count=quote_updated,
|
||||||
|
status="warning" if quote_error else "ok",
|
||||||
|
summary=f"实时行情更新 {quote_updated}/{quote_requested} 只",
|
||||||
|
detail={"requested": quote_requested, "updated": quote_updated, "error": quote_error},
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 3: 规则评分与交易计划 ──
|
# ── Step 3: 规则评分与交易计划 ──
|
||||||
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
logger.info("=== Step 3: 规则评分与交易计划 ===")
|
||||||
|
scoring_metrics: dict = {}
|
||||||
|
research_observations: list[dict] = []
|
||||||
recommendations = await _build_recommendations(
|
recommendations = await _build_recommendations(
|
||||||
candidates, market_temp, hot_sectors, market_temp_score, intraday, strategy_profile,
|
candidates,
|
||||||
|
market_temp,
|
||||||
|
hot_sectors,
|
||||||
|
market_temp_score,
|
||||||
|
intraday,
|
||||||
|
strategy_profile,
|
||||||
|
metrics=scoring_metrics,
|
||||||
|
research_observations=research_observations,
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
)
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="rule_scoring",
|
||||||
|
stage_label="规则评分",
|
||||||
|
input_count=len(candidates),
|
||||||
|
output_count=len(recommendations),
|
||||||
|
summary=f"完成 {scoring_metrics.get('analyzed_count', len(candidates))} 只规则评分,生成 {len(recommendations)} 个交易计划",
|
||||||
|
detail=scoring_metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
before_final_filter = len(recommendations)
|
||||||
|
final_filter_reasons = _build_final_filter_reasons(recommendations, strategy_profile)
|
||||||
recommendations = [
|
recommendations = [
|
||||||
r for r in recommendations
|
r for r in recommendations
|
||||||
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
|
if _is_main_theme_recommendation(r) and r.score >= strategy_profile.min_score
|
||||||
]
|
]
|
||||||
|
after_theme_filter = len(recommendations)
|
||||||
|
|
||||||
recommendations = _finalize_battle_plan(
|
recommendations = _finalize_battle_plan(
|
||||||
recommendations=recommendations,
|
recommendations=recommendations,
|
||||||
@ -160,6 +279,45 @@ async def run_screening(trade_date: str = None) -> dict:
|
|||||||
market_temp=market_temp,
|
market_temp=market_temp,
|
||||||
strategy_profile=strategy_profile,
|
strategy_profile=strategy_profile,
|
||||||
)
|
)
|
||||||
|
action_counts = {"可操作": 0, "重点关注": 0, "观察": 0}
|
||||||
|
for rec in recommendations:
|
||||||
|
action_counts[rec.action_plan] = action_counts.get(rec.action_plan, 0) + 1
|
||||||
|
final_codes = {rec.ts_code for rec in recommendations}
|
||||||
|
_apply_final_research_outcomes(
|
||||||
|
observations=research_observations,
|
||||||
|
final_codes=final_codes,
|
||||||
|
final_filter_reasons=final_filter_reasons,
|
||||||
|
min_score=strategy_profile.min_score,
|
||||||
|
)
|
||||||
|
await save_research_observations(research_observations)
|
||||||
|
await log_scan_stage(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stage="final_filter",
|
||||||
|
stage_label="最终作战池",
|
||||||
|
input_count=before_final_filter,
|
||||||
|
output_count=len(recommendations),
|
||||||
|
filtered_count=max(before_final_filter - len(recommendations), 0),
|
||||||
|
status="empty" if len(recommendations) == 0 else "ok",
|
||||||
|
summary=f"主线与分数过滤后保留 {after_theme_filter} 只,最终作战池 {len(recommendations)} 只",
|
||||||
|
detail={
|
||||||
|
"before_final_filter": before_final_filter,
|
||||||
|
"after_theme_score_filter": after_theme_filter,
|
||||||
|
"final_count": len(recommendations),
|
||||||
|
"action_counts": action_counts,
|
||||||
|
"elimination_reasons": _count_elimination_reasons(research_observations),
|
||||||
|
"top": [
|
||||||
|
{
|
||||||
|
"ts_code": r.ts_code,
|
||||||
|
"name": r.name,
|
||||||
|
"score": r.score,
|
||||||
|
"action_plan": r.action_plan,
|
||||||
|
"entry_signal_type": r.entry_signal_type,
|
||||||
|
}
|
||||||
|
for r in recommendations[:10]
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
logger.info(f"=== 筛选完成: {len(recommendations)} 只股票 ({scan_mode}) ===")
|
||||||
for r in recommendations[:5]:
|
for r in recommendations[:5]:
|
||||||
@ -371,6 +529,7 @@ async def _build_candidate_pool(
|
|||||||
trade_date: str | None,
|
trade_date: str | None,
|
||||||
intraday: bool,
|
intraday: bool,
|
||||||
market_temp: MarketTemperature,
|
market_temp: MarketTemperature,
|
||||||
|
metrics: dict | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""多路召回候选池。
|
"""多路召回候选池。
|
||||||
|
|
||||||
@ -408,6 +567,7 @@ async def _build_candidate_pool(
|
|||||||
realtime_candidates = []
|
realtime_candidates = []
|
||||||
_merge_candidate_batch(merged, realtime_candidates, route="realtime_market")
|
_merge_candidate_batch(merged, realtime_candidates, route="realtime_market")
|
||||||
else:
|
else:
|
||||||
|
intraday_candidates = []
|
||||||
realtime_candidates = []
|
realtime_candidates = []
|
||||||
|
|
||||||
candidates = list(merged.values())
|
candidates = list(merged.values())
|
||||||
@ -425,6 +585,31 @@ async def _build_candidate_pool(
|
|||||||
f"{'intraday=' + str(len(intraday_candidates)) + ' realtime=' + str(len(realtime_candidates)) if intraday else ''} "
|
f"{'intraday=' + str(len(intraday_candidates)) + ' realtime=' + str(len(realtime_candidates)) if intraday else ''} "
|
||||||
f"→ merged={len(top)}"
|
f"→ merged={len(top)}"
|
||||||
)
|
)
|
||||||
|
if metrics is not None:
|
||||||
|
route_counts = {
|
||||||
|
"sector_recall": len(sector_candidates),
|
||||||
|
"trend_scan": len(trend_candidates),
|
||||||
|
"intraday_active": len(intraday_candidates),
|
||||||
|
"realtime_market": len(realtime_candidates),
|
||||||
|
}
|
||||||
|
metrics.update({
|
||||||
|
"route_counts": route_counts,
|
||||||
|
"raw_total": sum(route_counts.values()),
|
||||||
|
"merged_count": len(candidates),
|
||||||
|
"pool_limit": settings.candidate_pool_limit,
|
||||||
|
"output_count": len(top),
|
||||||
|
"deduplicated_count": max(sum(route_counts.values()) - len(candidates), 0),
|
||||||
|
"top_candidates": [
|
||||||
|
{
|
||||||
|
"ts_code": item.get("ts_code"),
|
||||||
|
"name": item.get("name"),
|
||||||
|
"sector": item.get("sector"),
|
||||||
|
"recall_score": item.get("recall_score"),
|
||||||
|
"recall_tags": item.get("recall_tags", []),
|
||||||
|
}
|
||||||
|
for item in top[:10]
|
||||||
|
],
|
||||||
|
})
|
||||||
return top
|
return top
|
||||||
|
|
||||||
|
|
||||||
@ -595,6 +780,10 @@ async def _build_recommendations(
|
|||||||
market_temp_score: float = 0,
|
market_temp_score: float = 0,
|
||||||
intraday: bool = False,
|
intraday: bool = False,
|
||||||
strategy_profile=None,
|
strategy_profile=None,
|
||||||
|
metrics: dict | None = None,
|
||||||
|
research_observations: list[dict] | None = None,
|
||||||
|
scan_session: str = "manual",
|
||||||
|
scan_mode: str = "",
|
||||||
) -> list[Recommendation]:
|
) -> list[Recommendation]:
|
||||||
"""Step 3: 规则边界建模、评分与交易计划生成。"""
|
"""Step 3: 规则边界建模、评分与交易计划生成。"""
|
||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
@ -618,6 +807,7 @@ async def _build_recommendations(
|
|||||||
|
|
||||||
recommendations = []
|
recommendations = []
|
||||||
total = len(candidates)
|
total = len(candidates)
|
||||||
|
skipped_counts = {"missing_code": 0, "kline_empty": 0, "stale_kline": 0, "exception": 0}
|
||||||
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
signal_counts = {"breakout": 0, "breakout_confirm": 0, "pullback": 0, "launch": 0, "reversal": 0, "none": 0}
|
||||||
score_weights = strategy_profile.score_weights if strategy_profile else {
|
score_weights = strategy_profile.score_weights if strategy_profile else {
|
||||||
"catalyst": 0.30,
|
"catalyst": 0.30,
|
||||||
@ -633,6 +823,7 @@ async def _build_recommendations(
|
|||||||
for idx, stock in enumerate(candidates):
|
for idx, stock in enumerate(candidates):
|
||||||
ts_code = stock.get("ts_code", "")
|
ts_code = stock.get("ts_code", "")
|
||||||
if not ts_code:
|
if not ts_code:
|
||||||
|
skipped_counts["missing_code"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
name = stock.get("name") or name_map.get(ts_code, ts_code)
|
name = stock.get("name") or name_map.get(ts_code, ts_code)
|
||||||
@ -642,6 +833,7 @@ async def _build_recommendations(
|
|||||||
# 获取 120 日 K 线
|
# 获取 120 日 K 线
|
||||||
df = tushare_client.get_stock_daily(ts_code, 120)
|
df = tushare_client.get_stock_daily(ts_code, 120)
|
||||||
if df.empty or len(df) < 30:
|
if df.empty or len(df) < 30:
|
||||||
|
skipped_counts["kline_empty"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 数据新鲜度校验:最后一行必须是近 10 天内的数据
|
# 数据新鲜度校验:最后一行必须是近 10 天内的数据
|
||||||
@ -650,6 +842,7 @@ async def _build_recommendations(
|
|||||||
cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
|
cutoff = (datetime.now() - timedelta(days=10)).strftime("%Y%m%d")
|
||||||
if last_date < cutoff:
|
if last_date < cutoff:
|
||||||
logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过")
|
logger.warning(f"K线数据过时 {ts_code}: 最新={last_date}, 需≥{cutoff}, 跳过")
|
||||||
|
skipped_counts["stale_kline"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 添加技术指标
|
# 添加技术指标
|
||||||
@ -979,9 +1172,29 @@ async def _build_recommendations(
|
|||||||
decision_trace=decision_trace,
|
decision_trace=decision_trace,
|
||||||
)
|
)
|
||||||
recommendations.append(rec)
|
recommendations.append(rec)
|
||||||
|
if research_observations is not None:
|
||||||
|
research_observations.append(_build_research_observation(
|
||||||
|
scan_session=scan_session,
|
||||||
|
scan_mode=scan_mode,
|
||||||
|
stock=stock,
|
||||||
|
rec=rec,
|
||||||
|
scoring_axes=scoring_axes,
|
||||||
|
flow_momentum_score=flow_momentum_score,
|
||||||
|
entry_signal_score=entry_signal.get("signal_score", 0),
|
||||||
|
sector_stage=sector_stage,
|
||||||
|
sector_limit_up=sector_limit_up,
|
||||||
|
catalyst_reasons=catalyst_reasons,
|
||||||
|
hot_theme_match=hot_theme_match,
|
||||||
|
market_temp=market_temp,
|
||||||
|
score_weights=score_weights,
|
||||||
|
boosts=boosts,
|
||||||
|
penalties=penalty_notes,
|
||||||
|
risk_tags=risk_tags,
|
||||||
|
))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"规则分析 {ts_code} 失败: {e}")
|
logger.debug(f"规则分析 {ts_code} 失败: {e}")
|
||||||
|
skipped_counts["exception"] += 1
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@ -993,6 +1206,29 @@ async def _build_recommendations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
recommendations.sort(key=lambda rec: rec.score, reverse=True)
|
recommendations.sort(key=lambda rec: rec.score, reverse=True)
|
||||||
|
if metrics is not None:
|
||||||
|
action_counts = {"可操作": 0, "重点关注": 0, "观察": 0}
|
||||||
|
for rec in recommendations:
|
||||||
|
action_counts[rec.action_plan] = action_counts.get(rec.action_plan, 0) + 1
|
||||||
|
metrics.update({
|
||||||
|
"input_count": total,
|
||||||
|
"analyzed_count": total - sum(skipped_counts.values()),
|
||||||
|
"output_count": len(recommendations),
|
||||||
|
"skipped_counts": skipped_counts,
|
||||||
|
"signal_counts": signal_counts,
|
||||||
|
"action_counts_before_final_filter": action_counts,
|
||||||
|
"score_top": [
|
||||||
|
{
|
||||||
|
"ts_code": rec.ts_code,
|
||||||
|
"name": rec.name,
|
||||||
|
"sector": rec.sector,
|
||||||
|
"score": rec.score,
|
||||||
|
"action_plan": rec.action_plan,
|
||||||
|
"entry_signal_type": rec.entry_signal_type,
|
||||||
|
}
|
||||||
|
for rec in recommendations[:10]
|
||||||
|
],
|
||||||
|
})
|
||||||
return recommendations
|
return recommendations
|
||||||
|
|
||||||
|
|
||||||
@ -1691,6 +1927,156 @@ def _score_to_level(score: float) -> str:
|
|||||||
return "回避"
|
return "回避"
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_stock_role(stock: dict, hot_theme_match: SectorInfo | None) -> str:
|
||||||
|
tags = set(stock.get("recall_tags", []) or [])
|
||||||
|
if "theme_leader" in tags:
|
||||||
|
return "龙头/前排"
|
||||||
|
if "top_theme_member" in tags:
|
||||||
|
return "主题前排"
|
||||||
|
if "intraday_active" in tags or "realtime_active" in tags or "realtime_market" in tags:
|
||||||
|
return "盘中异动"
|
||||||
|
if hot_theme_match:
|
||||||
|
return "主线成分"
|
||||||
|
return "观察候选"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_research_observation(
|
||||||
|
*,
|
||||||
|
scan_session: str,
|
||||||
|
scan_mode: str,
|
||||||
|
stock: dict,
|
||||||
|
rec: Recommendation,
|
||||||
|
scoring_axes: dict[str, float],
|
||||||
|
flow_momentum_score: float,
|
||||||
|
entry_signal_score: float,
|
||||||
|
sector_stage: str,
|
||||||
|
sector_limit_up: int,
|
||||||
|
catalyst_reasons: list[str],
|
||||||
|
hot_theme_match: SectorInfo | None,
|
||||||
|
market_temp: MarketTemperature,
|
||||||
|
score_weights: dict[str, float],
|
||||||
|
boosts: list[dict],
|
||||||
|
penalties: list[dict],
|
||||||
|
risk_tags: list[str],
|
||||||
|
) -> dict:
|
||||||
|
theme_name = hot_theme_match.sector_name if hot_theme_match else rec.sector
|
||||||
|
stock_role = _derive_stock_role(stock, hot_theme_match)
|
||||||
|
detail = {
|
||||||
|
"market": {
|
||||||
|
"temperature": round(market_temp.temperature, 1),
|
||||||
|
"up_count": market_temp.up_count,
|
||||||
|
"down_count": market_temp.down_count,
|
||||||
|
"limit_up_count": market_temp.limit_up_count,
|
||||||
|
"broken_rate": market_temp.broken_rate,
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"name": theme_name,
|
||||||
|
"matched": bool(hot_theme_match),
|
||||||
|
"stage": sector_stage,
|
||||||
|
"limit_up_count": sector_limit_up,
|
||||||
|
"heat_score": rec.sector_score,
|
||||||
|
"catalyst_reasons": catalyst_reasons[:3],
|
||||||
|
},
|
||||||
|
"stock": {
|
||||||
|
"role": stock_role,
|
||||||
|
"recall_score": stock.get("recall_score", 0),
|
||||||
|
"recall_tags": stock.get("recall_tags", []),
|
||||||
|
"main_net_inflow": stock.get("main_net_inflow", 0),
|
||||||
|
"inflow_ratio": stock.get("inflow_ratio", 0),
|
||||||
|
"turnover_rate": stock.get("turnover_rate", 0),
|
||||||
|
"volume_ratio": stock.get("volume_ratio"),
|
||||||
|
"circ_mv": stock.get("circ_mv"),
|
||||||
|
},
|
||||||
|
"scores": {
|
||||||
|
"weights": score_weights,
|
||||||
|
"axes": scoring_axes,
|
||||||
|
"flow_momentum": flow_momentum_score,
|
||||||
|
"entry_signal_score": entry_signal_score,
|
||||||
|
"final_score": rec.score,
|
||||||
|
},
|
||||||
|
"decision": {
|
||||||
|
"action_plan": rec.action_plan,
|
||||||
|
"signal": rec.signal,
|
||||||
|
"entry_signal_type": rec.entry_signal_type,
|
||||||
|
"trigger_condition": rec.trigger_condition,
|
||||||
|
"invalidation_condition": rec.invalidation_condition,
|
||||||
|
"risk_note": rec.risk_note,
|
||||||
|
"boosts": boosts[:4],
|
||||||
|
"penalties": penalties[:4],
|
||||||
|
"risk_tags": risk_tags,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"scan_session": scan_session,
|
||||||
|
"scan_mode": scan_mode,
|
||||||
|
"ts_code": rec.ts_code,
|
||||||
|
"name": rec.name,
|
||||||
|
"theme_name": theme_name,
|
||||||
|
"stock_role": stock_role,
|
||||||
|
"action_plan": rec.action_plan,
|
||||||
|
"final_score": rec.score,
|
||||||
|
"catalyst_score": scoring_axes.get("catalyst", 0),
|
||||||
|
"theme_money_score": scoring_axes.get("theme_money", 0),
|
||||||
|
"stock_money_score": scoring_axes.get("stock_money", 0),
|
||||||
|
"emotion_role_score": scoring_axes.get("emotion_role", 0),
|
||||||
|
"timing_score": scoring_axes.get("timing", 0),
|
||||||
|
"entry_signal_type": rec.entry_signal_type,
|
||||||
|
"elimination_reason": "",
|
||||||
|
"detail": detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_final_filter_reasons(
|
||||||
|
recommendations: list[Recommendation],
|
||||||
|
strategy_profile: StrategyProfile,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
reasons = {}
|
||||||
|
for rec in recommendations:
|
||||||
|
reason_parts = []
|
||||||
|
if not _is_main_theme_recommendation(rec):
|
||||||
|
reason_parts.append("非主线候选")
|
||||||
|
if rec.score < strategy_profile.min_score:
|
||||||
|
reason_parts.append(f"低于保留线{strategy_profile.min_score:.0f}")
|
||||||
|
if rec.action_plan == "观察":
|
||||||
|
reason_parts.append("仅观察档")
|
||||||
|
elif rec.action_plan == "重点关注":
|
||||||
|
reason_parts.append("关注未入最终池")
|
||||||
|
elif rec.action_plan == "可操作":
|
||||||
|
reason_parts.append("可操作但名额/风控限制")
|
||||||
|
reasons[rec.ts_code] = ";".join(reason_parts) or "最终名额限制"
|
||||||
|
return reasons
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_final_research_outcomes(
|
||||||
|
*,
|
||||||
|
observations: list[dict],
|
||||||
|
final_codes: set[str],
|
||||||
|
final_filter_reasons: dict[str, str],
|
||||||
|
min_score: float,
|
||||||
|
) -> None:
|
||||||
|
for item in observations:
|
||||||
|
ts_code = item.get("ts_code", "")
|
||||||
|
if ts_code in final_codes:
|
||||||
|
item["elimination_reason"] = "进入最终作战池"
|
||||||
|
item.setdefault("detail", {}).setdefault("decision", {})["final_outcome"] = "kept"
|
||||||
|
continue
|
||||||
|
reason = final_filter_reasons.get(ts_code) or f"未达到保留线{min_score:.0f}"
|
||||||
|
item["elimination_reason"] = reason
|
||||||
|
item.setdefault("detail", {}).setdefault("decision", {})["final_outcome"] = "filtered"
|
||||||
|
item["detail"]["decision"]["elimination_reason"] = reason
|
||||||
|
|
||||||
|
|
||||||
|
def _count_elimination_reasons(observations: list[dict]) -> dict[str, int]:
|
||||||
|
counts: dict[str, int] = {}
|
||||||
|
for item in observations:
|
||||||
|
reason = item.get("elimination_reason") or "未知"
|
||||||
|
for part in str(reason).split(";"):
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
counts[part] = counts.get(part, 0) + 1
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _generate_reasons(
|
def _generate_reasons(
|
||||||
stock: dict, entry_signal: dict, tech: TechnicalSignal | None,
|
stock: dict, entry_signal: dict, tech: TechnicalSignal | None,
|
||||||
df, intraday: bool = False,
|
df, intraday: bool = False,
|
||||||
@ -2018,4 +2404,3 @@ def _build_trace_evidence(
|
|||||||
if signal_matches_profile:
|
if signal_matches_profile:
|
||||||
evidence.append("符合今日策略偏好的入场类型")
|
evidence.append("符合今日策略偏好的入场类型")
|
||||||
return evidence[:5]
|
return evidence[:5]
|
||||||
|
|
||||||
|
|||||||
183
frontend/src/app/(auth)/data-health/page.tsx
Normal file
183
frontend/src/app/(auth)/data-health/page.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { getDataSourceHealthAPI, type DataSourceHealthResult } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function DataHealthPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const [data, setData] = useState<DataSourceHealthResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await getDataSourceHealthAPI(days));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === "admin") loadData();
|
||||||
|
}, [user, loadData]);
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const sources = data?.sources || [];
|
||||||
|
return {
|
||||||
|
total: sources.length,
|
||||||
|
errors: sources.filter((item) => item.status === "error").length,
|
||||||
|
warnings: sources.filter((item) => item.status === "warning").length,
|
||||||
|
ok: sources.filter((item) => item.status === "ok").length,
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||||
|
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
|
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Data Health</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">数据源健康</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||||
|
东方财富、腾讯、Tushare、AKShare 等数据源的错误聚合和本地数据新鲜度。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={days}
|
||||||
|
onChange={(event) => setDays(Number(event.target.value))}
|
||||||
|
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||||
|
>
|
||||||
|
<option value={1}>1天</option>
|
||||||
|
<option value={3}>3天</option>
|
||||||
|
<option value={7}>7天</option>
|
||||||
|
<option value={14}>14天</option>
|
||||||
|
<option value={30}>30天</option>
|
||||||
|
</select>
|
||||||
|
<button onClick={loadData} className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<MetricCard label="数据源" value={summary.total} />
|
||||||
|
<MetricCard label="正常" value={summary.ok} tone="ok" />
|
||||||
|
<MetricCard label="警告" value={summary.warnings} tone="warning" />
|
||||||
|
<MetricCard label="错误" value={summary.errors} tone="error" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_360px]">
|
||||||
|
<div className="glass-card-static overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">数据源状态</h2>
|
||||||
|
<span className="text-[10px] text-text-muted">{data ? formatDateTime(data.generated_at) : "-"}</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||||
|
</div>
|
||||||
|
) : !data?.sources.length ? (
|
||||||
|
<div className="p-10 text-center text-sm text-text-muted">暂无数据源记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{data.sources.map((item) => (
|
||||||
|
<article key={item.source} className="grid gap-3 px-4 py-4 lg:grid-cols-[150px_110px_120px_1fr_140px] lg:items-center">
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{sourceLabel(item.source)}</div>
|
||||||
|
<StatusPill status={item.status} />
|
||||||
|
<div className="font-mono text-xs tabular-nums text-text-secondary">
|
||||||
|
{item.error_count}错 / {item.warning_count}警
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 text-xs leading-5 text-text-muted line-clamp-2">
|
||||||
|
{item.last_error || "最近没有记录到异常"}
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-[10px] tabular-nums text-text-muted lg:text-right">
|
||||||
|
{formatDateTime(item.last_seen_at)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside className="glass-card-static p-4 self-start">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">本地数据新鲜度</h2>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{Object.entries(data?.freshness || {}).map(([key, value]) => (
|
||||||
|
<div key={key} className="rounded-xl border border-border-subtle bg-surface-1/70 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-xs font-semibold text-text-primary">{freshnessLabel(key)}</span>
|
||||||
|
<span className="font-mono text-[10px] text-text-muted">{formatDateTime(value.created_at || "")}</span>
|
||||||
|
</div>
|
||||||
|
{"trade_date" in value ? (
|
||||||
|
<div className="mt-2 text-xs text-text-secondary">交易日 {value.trade_date || "-"}</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data && <div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">读取中...</div>}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, tone = "default" }: { label: string; value: number; tone?: "default" | "ok" | "warning" | "error" }) {
|
||||||
|
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-400" : "text-text-primary";
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-2 font-mono text-2xl font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const className = status === "ok"
|
||||||
|
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||||
|
: status === "warning"
|
||||||
|
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||||
|
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||||
|
const label = status === "ok" ? "正常" : status === "warning" ? "警告" : "异常";
|
||||||
|
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
eastmoney: "东方财富",
|
||||||
|
tencent: "腾讯行情",
|
||||||
|
tushare: "Tushare",
|
||||||
|
akshare: "AKShare",
|
||||||
|
sina: "新浪行情",
|
||||||
|
news: "新闻管道",
|
||||||
|
tushare_news: "Tushare新闻",
|
||||||
|
};
|
||||||
|
return map[source] || source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freshnessLabel(key: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
market_temperature: "市场温度",
|
||||||
|
sector_heat: "板块热度",
|
||||||
|
recommendations: "推荐结论",
|
||||||
|
news_items: "新闻原文",
|
||||||
|
catalysts: "舆情催化",
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
567
frontend/src/app/(auth)/ops-logs/page.tsx
Normal file
567
frontend/src/app/(auth)/ops-logs/page.tsx
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
clearErrorLogsAPI,
|
||||||
|
getErrorLogsAPI,
|
||||||
|
getResearchObservationsAPI,
|
||||||
|
getScanLogsAPI,
|
||||||
|
getScanSessionsAPI,
|
||||||
|
getSystemStatusAPI,
|
||||||
|
type ErrorLog,
|
||||||
|
type ResearchObservation,
|
||||||
|
type ScanProcessLog,
|
||||||
|
type ScanSessionSummary,
|
||||||
|
type SystemStatus,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
|
type OpsTab = "funnel" | "errors";
|
||||||
|
|
||||||
|
const STAGE_ORDER = [
|
||||||
|
"market_temperature",
|
||||||
|
"theme_selection",
|
||||||
|
"strategy_profile",
|
||||||
|
"candidate_recall",
|
||||||
|
"realtime_quote",
|
||||||
|
"rule_scoring",
|
||||||
|
"final_filter",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function OpsLogsPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [tab, setTab] = useState<OpsTab>("funnel");
|
||||||
|
const [days, setDays] = useState(7);
|
||||||
|
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
||||||
|
const [selectedSession, setSelectedSession] = useState("");
|
||||||
|
const [scanLogs, setScanLogs] = useState<ScanProcessLog[]>([]);
|
||||||
|
const [observations, setObservations] = useState<ResearchObservation[]>([]);
|
||||||
|
const [reasonCounts, setReasonCounts] = useState<Record<string, number>>({});
|
||||||
|
const [scanLoading, setScanLoading] = useState(true);
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<ErrorLog[]>([]);
|
||||||
|
const [errorsTotal, setErrorsTotal] = useState(0);
|
||||||
|
const [sources, setSources] = useState<string[]>([]);
|
||||||
|
const [levels, setLevels] = useState<string[]>([]);
|
||||||
|
const [source, setSource] = useState("");
|
||||||
|
const [level, setLevel] = useState("");
|
||||||
|
const [errorsLoading, setErrorsLoading] = useState(false);
|
||||||
|
const [expandedErrorId, setExpandedErrorId] = useState<number | null>(null);
|
||||||
|
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||||
|
|
||||||
|
const latestSession = sessions[0];
|
||||||
|
const activeSession = sessions.find((item) => item.scan_session === selectedSession) || latestSession;
|
||||||
|
const sortedLogs = useMemo(
|
||||||
|
() => [...scanLogs].sort((a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage)),
|
||||||
|
[scanLogs],
|
||||||
|
);
|
||||||
|
const maxCount = Math.max(...sortedLogs.map((item) => Math.max(item.input_count, item.output_count)), 1);
|
||||||
|
const finalLog = sortedLogs.find((item) => item.stage === "final_filter");
|
||||||
|
|
||||||
|
const fetchScanData = useCallback(async (session?: string) => {
|
||||||
|
setScanLoading(true);
|
||||||
|
try {
|
||||||
|
const sessionData = await getScanSessionsAPI(days, 30);
|
||||||
|
setSessions(sessionData.sessions);
|
||||||
|
const nextSession = session || sessionData.sessions[0]?.scan_session || "";
|
||||||
|
setSelectedSession(nextSession);
|
||||||
|
const logData = await getScanLogsAPI(nextSession || undefined, days, 140);
|
||||||
|
setScanLogs(logData.logs);
|
||||||
|
const observationData = await getResearchObservationsAPI(nextSession || undefined, days, 80);
|
||||||
|
setObservations(observationData.observations);
|
||||||
|
setReasonCounts(observationData.reason_counts);
|
||||||
|
} catch {
|
||||||
|
setScanLogs([]);
|
||||||
|
setObservations([]);
|
||||||
|
setReasonCounts({});
|
||||||
|
} finally {
|
||||||
|
setScanLoading(false);
|
||||||
|
}
|
||||||
|
}, [days]);
|
||||||
|
|
||||||
|
const fetchErrors = useCallback(async () => {
|
||||||
|
setErrorsLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getErrorLogsAPI(80, source, level, days);
|
||||||
|
setErrors(result.errors);
|
||||||
|
setErrorsTotal(result.total);
|
||||||
|
setSources(result.sources);
|
||||||
|
setLevels(result.levels);
|
||||||
|
} catch {
|
||||||
|
setErrors([]);
|
||||||
|
} finally {
|
||||||
|
setErrorsLoading(false);
|
||||||
|
}
|
||||||
|
}, [days, level, source]);
|
||||||
|
|
||||||
|
const fetchSystemStatus = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setSystemStatus(await getSystemStatusAPI());
|
||||||
|
} catch {
|
||||||
|
setSystemStatus(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === "admin") {
|
||||||
|
fetchScanData();
|
||||||
|
fetchSystemStatus();
|
||||||
|
}
|
||||||
|
}, [user, days, fetchScanData, fetchSystemStatus]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === "admin" && tab === "errors") {
|
||||||
|
fetchErrors();
|
||||||
|
}
|
||||||
|
}, [user, tab, fetchErrors]);
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||||
|
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSelectSession(session: string) {
|
||||||
|
setSelectedSession(session);
|
||||||
|
setScanLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await getScanLogsAPI(session, days, 140);
|
||||||
|
setScanLogs(result.logs);
|
||||||
|
const observationData = await getResearchObservationsAPI(session, days, 80);
|
||||||
|
setObservations(observationData.observations);
|
||||||
|
setReasonCounts(observationData.reason_counts);
|
||||||
|
} finally {
|
||||||
|
setScanLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearErrors() {
|
||||||
|
const result = await clearErrorLogsAPI(30);
|
||||||
|
await fetchErrors();
|
||||||
|
await fetchSystemStatus();
|
||||||
|
alert(`已清除 ${result.deleted} 条旧日志`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
|
<header className="flex flex-col gap-4 animate-fade-in-up md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Ops Center</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">运行日志</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||||
|
最新筛选批次、漏斗关口和异常记录集中在这里。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={days}
|
||||||
|
onChange={(event) => setDays(Number(event.target.value))}
|
||||||
|
className="rounded-xl border border-border-default bg-surface-2 px-3 py-2 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30"
|
||||||
|
>
|
||||||
|
<option value={1}>1天</option>
|
||||||
|
<option value={3}>3天</option>
|
||||||
|
<option value={7}>7天</option>
|
||||||
|
<option value={14}>14天</option>
|
||||||
|
<option value={30}>30天</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => tab === "funnel" ? fetchScanData(selectedSession) : fetchErrors()}
|
||||||
|
className="rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary"
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||||
|
{[
|
||||||
|
{ key: "funnel", label: "筛选漏斗" },
|
||||||
|
{ key: "errors", label: "系统错误" },
|
||||||
|
].map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
onClick={() => setTab(item.key as OpsTab)}
|
||||||
|
className={`rounded-xl border px-4 py-2 text-sm font-medium transition-all ${
|
||||||
|
tab === item.key
|
||||||
|
? "border-amber-500/15 bg-amber-500/[0.08] text-amber-400"
|
||||||
|
: "border-transparent bg-surface-2 text-text-muted hover:bg-surface-3 hover:text-text-secondary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === "funnel" ? (
|
||||||
|
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[280px_1fr]">
|
||||||
|
<aside className="glass-card-static p-4 self-start">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">扫描批次</h2>
|
||||||
|
<span className="text-[10px] text-text-muted">{sessions.length}轮</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
{sessions.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-sm text-text-muted">
|
||||||
|
暂无筛选日志,下一次扫描后会自动出现。
|
||||||
|
</div>
|
||||||
|
) : sessions.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.scan_session}
|
||||||
|
onClick={() => handleSelectSession(item.scan_session)}
|
||||||
|
className={`w-full rounded-xl border p-3 text-left transition-all ${
|
||||||
|
activeSession?.scan_session === item.scan_session
|
||||||
|
? "border-amber-500/20 bg-amber-500/[0.06]"
|
||||||
|
: "border-border-subtle bg-surface-1/70 hover:border-border-default hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-xs font-semibold text-text-primary">{shortSession(item.scan_session)}</span>
|
||||||
|
<StatusPill status={item.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[10px] text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<TinyMetric label="关口" value={item.stage_count} />
|
||||||
|
<TinyMetric label="保留" value={item.final_count} />
|
||||||
|
<TinyMetric label="过滤" value={item.drop_count} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="space-y-5">
|
||||||
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<SummaryCard label="当前批次" value={activeSession ? shortSession(activeSession.scan_session) : "-"} sub={activeSession ? formatDateTime(activeSession.created_at) : "等待扫描"} />
|
||||||
|
<SummaryCard label="最终保留" value={finalLog?.output_count ?? activeSession?.final_count ?? 0} sub={finalLog?.summary || activeSession?.last_summary || "暂无结果"} tone="primary" />
|
||||||
|
<SummaryCard label="最近错误" value={systemStatus?.recent_errors ?? 0} sub="过去24小时" tone={(systemStatus?.recent_errors ?? 0) > 0 ? "danger" : "muted"} />
|
||||||
|
<SummaryCard label="扫描状态" value={systemStatus?.scan_running ? "扫描中" : "空闲"} sub={systemStatus?.scan_locked ? "任务锁占用" : "可接受下一轮任务"} tone={systemStatus?.scan_running ? "warning" : "muted"} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static p-4 md:p-5">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">筛选过程可视化</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">输入、输出、过滤量和关键摘要按筛选顺序归档。</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] font-mono tabular-nums text-text-muted">{activeSession?.scan_mode || "-"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scanLoading ? (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{[1, 2, 3, 4].map((item) => <div key={item} className="h-20 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||||
|
</div>
|
||||||
|
) : sortedLogs.length === 0 ? (
|
||||||
|
<div className="mt-5 rounded-2xl border border-border-subtle bg-surface-1/70 p-10 text-center text-sm text-text-muted">
|
||||||
|
暂无筛选过程日志
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{sortedLogs.map((log, index) => (
|
||||||
|
<FunnelStage key={log.id} log={log} index={index} maxCount={maxCount} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static p-4 md:p-5">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">投研观察</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">候选股的主题、资金、角色、入场信号和最终淘汰原因。</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-text-muted">{observations.length} 条记录</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 xl:grid-cols-[260px_1fr]">
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||||
|
<div className="text-xs font-semibold text-text-primary">淘汰原因</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{Object.entries(reasonCounts).slice(0, 8).map(([reason, count]) => (
|
||||||
|
<div key={reason} className="flex items-center justify-between gap-3 rounded-lg bg-surface-2 px-3 py-2">
|
||||||
|
<span className="truncate text-[11px] text-text-muted">{reason}</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-text-secondary">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.keys(reasonCounts).length === 0 ? (
|
||||||
|
<div className="rounded-lg bg-surface-2 px-3 py-4 text-xs text-text-muted">暂无原因分布</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden rounded-2xl border border-border-subtle bg-surface-1/70">
|
||||||
|
{observations.length === 0 ? (
|
||||||
|
<div className="p-8 text-center text-sm text-text-muted">暂无投研观察记录</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{observations.slice(0, 12).map((item) => (
|
||||||
|
<ResearchRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">这里保留接口、数据源和后台任务的异常,便于追踪不稳定数据源。</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select value={source} onChange={(event) => setSource(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
||||||
|
<option value="">全部来源</option>
|
||||||
|
{sources.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<select value={level} onChange={(event) => setLevel(event.target.value)} className="rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none">
|
||||||
|
<option value="">全部级别</option>
|
||||||
|
{levels.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<button onClick={fetchErrors} className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-secondary hover:bg-surface-4 hover:text-text-primary">
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
<button onClick={handleClearErrors} className="rounded-lg border border-red-500/[0.08] bg-red-500/[0.04] px-3 py-1.5 text-xs text-red-400/70 hover:bg-red-500/[0.08] hover:text-red-400">
|
||||||
|
清除30天前
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="glass-card-static overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||||
|
<span className="text-xs font-semibold text-text-primary">错误记录</span>
|
||||||
|
<span className="text-[10px] text-text-muted">{errorsTotal} 条</span>
|
||||||
|
</div>
|
||||||
|
{errorsLoading ? (
|
||||||
|
<div className="space-y-2 p-4">
|
||||||
|
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||||
|
</div>
|
||||||
|
) : errors.length === 0 ? (
|
||||||
|
<div className="p-10 text-center text-sm text-text-muted">暂无错误日志</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{errors.map((item) => (
|
||||||
|
<div key={item.id} className="px-4 py-3">
|
||||||
|
<button className="w-full text-left" onClick={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}>
|
||||||
|
<div className="grid gap-2 md:grid-cols-[88px_120px_1fr_140px] md:items-center">
|
||||||
|
<StatusPill status={item.level} />
|
||||||
|
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
||||||
|
<span className="truncate text-sm text-text-secondary">{item.message}</span>
|
||||||
|
<span className="text-[10px] font-mono tabular-nums text-text-muted md:text-right">{formatDateTime(item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{expandedErrorId === item.id && item.detail ? (
|
||||||
|
<pre className="mt-3 max-h-80 overflow-auto rounded-xl border border-border-subtle bg-surface-1 p-3 text-xs leading-6 text-text-muted whitespace-pre-wrap">
|
||||||
|
{item.detail}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FunnelStage({ log, index, maxCount }: { log: ScanProcessLog; index: number; maxCount: number }) {
|
||||||
|
const outputWidth = Math.max(4, Math.round((log.output_count / maxCount) * 100));
|
||||||
|
const dropWidth = Math.max(0, Math.round((log.filtered_count / maxCount) * 100));
|
||||||
|
const detailItems = extractDetailItems(log);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className="rounded-2xl border border-border-subtle bg-surface-1/70 p-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[42px_1fr_260px] lg:items-center">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-border-subtle bg-surface-2 font-mono text-xs tabular-nums text-text-secondary">
|
||||||
|
{index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h3 className="text-sm font-semibold text-text-primary">{log.stage_label}</h3>
|
||||||
|
<StatusPill status={log.status} />
|
||||||
|
<span className="text-[10px] font-mono tabular-nums text-text-muted">{formatDateTime(log.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs leading-5 text-text-secondary">{log.summary || "暂无摘要"}</p>
|
||||||
|
<div className="mt-3 h-2 overflow-hidden rounded-full bg-surface-2">
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="h-full bg-amber-400/70" style={{ width: `${outputWidth}%` }} />
|
||||||
|
<div className="h-full bg-red-400/40" style={{ width: `${dropWidth}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<TinyMetric label="输入" value={log.input_count} />
|
||||||
|
<TinyMetric label="输出" value={log.output_count} />
|
||||||
|
<TinyMetric label="过滤" value={log.filtered_count} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{detailItems.length > 0 ? (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{detailItems.map((item) => (
|
||||||
|
<span key={item} className="rounded-lg border border-border-subtle bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDetailItems(log: ScanProcessLog) {
|
||||||
|
const detail = log.detail || {};
|
||||||
|
if (log.stage === "candidate_recall") {
|
||||||
|
const routes = detail.route_counts as Record<string, number> | undefined;
|
||||||
|
return routes ? Object.entries(routes).map(([key, value]) => `${routeLabel(key)} ${value}`) : [];
|
||||||
|
}
|
||||||
|
if (log.stage === "rule_scoring") {
|
||||||
|
const skipped = detail.skipped_counts as Record<string, number> | undefined;
|
||||||
|
const signals = detail.signal_counts as Record<string, number> | undefined;
|
||||||
|
return [
|
||||||
|
...(skipped ? Object.entries(skipped).filter(([, value]) => value > 0).map(([key, value]) => `${skipLabel(key)} ${value}`) : []),
|
||||||
|
...(signals ? Object.entries(signals).filter(([, value]) => value > 0).map(([key, value]) => `${signalLabel(key)} ${value}`) : []),
|
||||||
|
].slice(0, 10);
|
||||||
|
}
|
||||||
|
if (log.stage === "final_filter") {
|
||||||
|
const actions = detail.action_counts as Record<string, number> | undefined;
|
||||||
|
const reasons = detail.elimination_reasons as Record<string, number> | undefined;
|
||||||
|
return [
|
||||||
|
...(actions ? Object.entries(actions).map(([key, value]) => `${key} ${value}`) : []),
|
||||||
|
...(reasons ? Object.entries(reasons).slice(0, 6).map(([key, value]) => `${key} ${value}`) : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
if (log.stage === "theme_selection") {
|
||||||
|
const themes = detail.themes as Array<{ name?: string; heat_score?: number }> | undefined;
|
||||||
|
return themes?.slice(0, 5).map((item) => `${item.name || "主题"} ${Math.round(Number(item.heat_score || 0))}`) || [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResearchRow({ item }: { item: ResearchObservation }) {
|
||||||
|
return (
|
||||||
|
<article className="grid gap-3 px-4 py-3 lg:grid-cols-[160px_90px_1fr_300px] lg:items-center">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{item.name}</div>
|
||||||
|
<div className="mt-1 font-mono text-[10px] text-text-muted">{item.ts_code}</div>
|
||||||
|
</div>
|
||||||
|
<div className="font-mono text-lg font-bold tabular-nums text-amber-400">{item.final_score.toFixed(1)}</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.theme_name || "未归类"}</span>
|
||||||
|
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{item.stock_role || "候选"}</span>
|
||||||
|
<span className="rounded-md border border-border-subtle bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">{signalLabel(item.entry_signal_type)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.elimination_reason || "待确认"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-1.5">
|
||||||
|
<ScorePill label="催化" value={item.catalyst_score} />
|
||||||
|
<ScorePill label="题材" value={item.theme_money_score} />
|
||||||
|
<ScorePill label="资金" value={item.stock_money_score} />
|
||||||
|
<ScorePill label="角色" value={item.emotion_role_score} />
|
||||||
|
<ScorePill label="时机" value={item.timing_score} />
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScorePill({ label, value }: { label: string; value: number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-2 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] text-text-muted/60">{label}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{Math.round(value)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ label, value, sub, tone = "muted" }: { label: string; value: string | number; sub: string; tone?: "primary" | "warning" | "danger" | "muted" }) {
|
||||||
|
const valueClass = tone === "primary" ? "text-amber-400" : tone === "warning" ? "text-amber-300" : tone === "danger" ? "text-red-400" : "text-text-primary";
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-2 truncate text-xl font-bold tabular-nums ${valueClass}`}>{value}</div>
|
||||||
|
<div className="mt-1 line-clamp-1 text-[11px] text-text-muted">{sub}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TinyMetric({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-surface-2 px-2 py-1.5">
|
||||||
|
<div className="text-[9px] text-text-muted/60">{label}</div>
|
||||||
|
<div className="mt-0.5 truncate font-mono text-[11px] font-semibold tabular-nums text-text-secondary">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
const className = normalized === "ok"
|
||||||
|
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||||
|
: normalized === "warning" || normalized === "empty"
|
||||||
|
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||||
|
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex w-fit items-center rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>
|
||||||
|
{statusLabel(status)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
if (normalized === "ok") return "正常";
|
||||||
|
if (normalized === "warning") return "警告";
|
||||||
|
if (normalized === "empty") return "空结果";
|
||||||
|
if (normalized === "error") return "错误";
|
||||||
|
if (normalized === "critical") return "严重";
|
||||||
|
return status || "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeLabel(key: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
sector_recall: "主线召回",
|
||||||
|
trend_scan: "趋势召回",
|
||||||
|
intraday_active: "盘中异动",
|
||||||
|
realtime_market: "全市场异动",
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipLabel(key: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
missing_code: "缺代码",
|
||||||
|
kline_empty: "K线不足",
|
||||||
|
stale_kline: "K线过期",
|
||||||
|
exception: "评分异常",
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function signalLabel(key: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
breakout: "突破",
|
||||||
|
breakout_confirm: "确认",
|
||||||
|
pullback: "回踩",
|
||||||
|
launch: "启动",
|
||||||
|
reversal: "反转",
|
||||||
|
none: "无信号",
|
||||||
|
};
|
||||||
|
return map[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortSession(session: string) {
|
||||||
|
if (!session) return "-";
|
||||||
|
return session.length > 20 ? `${session.slice(0, 10)}...${session.slice(-6)}` : session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
listUsersAPI,
|
listUsersAPI,
|
||||||
disableUserAPI,
|
disableUserAPI,
|
||||||
@ -11,17 +12,12 @@ import {
|
|||||||
toggleInviteCodeAPI,
|
toggleInviteCodeAPI,
|
||||||
getDataStatsAPI,
|
getDataStatsAPI,
|
||||||
dataResetAPI,
|
dataResetAPI,
|
||||||
getErrorLogsAPI,
|
|
||||||
clearErrorLogsAPI,
|
|
||||||
getSystemStatusAPI,
|
|
||||||
type UserItem,
|
type UserItem,
|
||||||
type InviteCodeItem,
|
type InviteCodeItem,
|
||||||
type DataStats,
|
type DataStats,
|
||||||
type ErrorLog,
|
|
||||||
type SystemStatus,
|
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
type Tab = "users" | "data" | "logs";
|
type Tab = "users" | "data" | "ops";
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
@ -51,17 +47,6 @@ export default function UsersPage() {
|
|||||||
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
|
const [resetResultMsg, setResetResultMsg] = useState<string | null>(null);
|
||||||
const [confirmReset, setConfirmReset] = useState(false);
|
const [confirmReset, setConfirmReset] = useState(false);
|
||||||
|
|
||||||
const [logs, setLogs] = useState<ErrorLog[]>([]);
|
|
||||||
const [logsTotal, setLogsTotal] = useState(0);
|
|
||||||
const [logSources, setLogSources] = useState<string[]>([]);
|
|
||||||
const [logLevels, setLogLevels] = useState<string[]>([]);
|
|
||||||
const [logFilterSource, setLogFilterSource] = useState<string>("");
|
|
||||||
const [logFilterLevel, setLogFilterLevel] = useState<string>("");
|
|
||||||
const [logDays, setLogDays] = useState(7);
|
|
||||||
const [logsLoading, setLogsLoading] = useState(false);
|
|
||||||
const [expandedLogId, setExpandedLogId] = useState<number | null>(null);
|
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
|
||||||
|
|
||||||
function copyCredential(account: string, password: string) {
|
function copyCredential(account: string, password: string) {
|
||||||
const text = `邮箱:${account}\n密码:${password}`;
|
const text = `邮箱:${account}\n密码:${password}`;
|
||||||
navigator.clipboard.writeText(text).then(() => {
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
@ -101,44 +86,13 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchLogs = useCallback(async () => {
|
|
||||||
setLogsLoading(true);
|
|
||||||
try {
|
|
||||||
const result = await getErrorLogsAPI(50, logFilterSource, logFilterLevel, logDays);
|
|
||||||
setLogs(result.errors);
|
|
||||||
setLogsTotal(result.total);
|
|
||||||
setLogSources(result.sources);
|
|
||||||
setLogLevels(result.levels);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLogsLoading(false);
|
|
||||||
}
|
|
||||||
}, [logFilterSource, logFilterLevel, logDays]);
|
|
||||||
|
|
||||||
const fetchSystemStatus = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const status = await getSystemStatusAPI();
|
|
||||||
setSystemStatus(status);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentUser?.role === "admin") {
|
if (currentUser?.role === "admin") {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
fetchInviteCodes();
|
fetchInviteCodes();
|
||||||
fetchStats();
|
fetchStats();
|
||||||
fetchSystemStatus();
|
|
||||||
}
|
}
|
||||||
}, [currentUser, fetchUsers, fetchInviteCodes, fetchStats, fetchSystemStatus]);
|
}, [currentUser, fetchUsers, fetchInviteCodes, fetchStats]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (currentUser?.role === "admin" && tab === "logs") {
|
|
||||||
fetchLogs();
|
|
||||||
}
|
|
||||||
}, [currentUser, tab, fetchLogs]);
|
|
||||||
|
|
||||||
if (currentUser?.role !== "admin") {
|
if (currentUser?.role !== "admin") {
|
||||||
return (
|
return (
|
||||||
@ -231,21 +185,10 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleClearLogs() {
|
|
||||||
try {
|
|
||||||
const result = await clearErrorLogsAPI(30);
|
|
||||||
fetchLogs();
|
|
||||||
fetchSystemStatus();
|
|
||||||
alert(`已清除 ${result.deleted} 条旧日志`);
|
|
||||||
} catch (err) {
|
|
||||||
alert(err instanceof Error ? err.message : "清除失败");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs: { key: Tab; label: string }[] = [
|
const tabs: { key: Tab; label: string }[] = [
|
||||||
{ key: "users", label: "用户与邀请码" },
|
{ key: "users", label: "用户与邀请码" },
|
||||||
{ key: "data", label: "数据管理" },
|
{ key: "data", label: "数据管理" },
|
||||||
{ key: "logs", label: "系统日志" },
|
{ key: "ops", label: "运维入口" },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -586,93 +529,19 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === "logs" && (
|
{tab === "ops" && (
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 animate-fade-in-up">
|
||||||
{systemStatus && (
|
{[
|
||||||
<div className="glass-card-static p-4 rounded-xl">
|
{ href: "/ops-logs", title: "运行日志", desc: "筛选漏斗、系统错误和扫描批次" },
|
||||||
<h2 className="text-xs font-semibold text-text-muted uppercase tracking-wider mb-3">系统状态</h2>
|
{ href: "/data-health", title: "数据源健康", desc: "东方财富、腾讯、Tushare、AKShare 状态" },
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
{ href: "/tasks", title: "任务中心", desc: "新闻、扫描、复盘、策略校准任务" },
|
||||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
].map((item) => (
|
||||||
<div className="text-[10px] text-text-muted/50">交易状态</div>
|
<Link key={item.href} href={item.href} className="glass-card p-4 rounded-xl">
|
||||||
<div className={`text-sm font-medium ${systemStatus.is_trading ? "text-emerald-400" : "text-text-muted"}`}>
|
<div className="text-sm font-semibold text-text-primary">{item.title}</div>
|
||||||
{systemStatus.is_trading ? "交易中" : "已收盘"}
|
<div className="mt-2 text-xs leading-5 text-text-muted">{item.desc}</div>
|
||||||
</div>
|
<div className="mt-4 text-xs text-amber-400">打开</div>
|
||||||
</div>
|
</Link>
|
||||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
))}
|
||||||
<div className="text-[10px] text-text-muted/50">扫描状态</div>
|
|
||||||
<div className={`text-sm font-medium ${systemStatus.scan_running ? "text-amber-400" : "text-text-secondary"}`}>
|
|
||||||
{systemStatus.scan_running ? "扫描中" : systemStatus.scan_locked ? "锁定中" : "空闲"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
|
||||||
<div className="text-[10px] text-text-muted/50">24h 错误</div>
|
|
||||||
<div className={`text-sm font-bold font-mono tabular-nums ${systemStatus.recent_errors > 0 ? "text-red-400" : "text-text-secondary"}`}>{systemStatus.recent_errors}</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-surface-1 rounded-lg px-3 py-2">
|
|
||||||
<div className="text-[10px] text-text-muted/50">数据库大小</div>
|
|
||||||
<div className="text-sm font-mono tabular-nums text-text-secondary">{systemStatus.db_size_mb} MB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<select value={logFilterSource} onChange={(e) => setLogFilterSource(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
|
||||||
<option value="">全部来源</option>
|
|
||||||
{logSources.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={logFilterLevel} onChange={(e) => setLogFilterLevel(e.target.value)} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
|
||||||
<option value="">全部级别</option>
|
|
||||||
{logLevels.map((l) => <option key={l} value={l}>{l}</option>)}
|
|
||||||
</select>
|
|
||||||
<select value={logDays} onChange={(e) => setLogDays(Number(e.target.value))} className="bg-surface-2 border border-border-default rounded-lg px-3 py-1.5 text-xs text-text-primary focus:outline-none focus:ring-1 focus:ring-amber-500/30 appearance-none">
|
|
||||||
<option value={1}>1天</option>
|
|
||||||
<option value={3}>3天</option>
|
|
||||||
<option value={7}>7天</option>
|
|
||||||
<option value={14}>14天</option>
|
|
||||||
<option value={30}>30天</option>
|
|
||||||
</select>
|
|
||||||
<button onClick={() => fetchLogs()} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-surface-2 text-text-secondary hover:text-text-primary hover:bg-surface-4 border border-border-subtle transition-all">
|
|
||||||
刷新
|
|
||||||
</button>
|
|
||||||
<button onClick={handleClearLogs} className="px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/[0.03] text-red-400/60 hover:text-red-400 hover:bg-red-500/[0.08] border border-red-500/[0.06] transition-all">
|
|
||||||
清除30天前
|
|
||||||
</button>
|
|
||||||
<span className="text-xs text-text-muted ml-auto">{logsTotal} 条记录</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{logsLoading ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{[1, 2, 3].map((i) => <div key={i} className="glass-card-static p-4 animate-shimmer rounded-xl h-20" />)}
|
|
||||||
</div>
|
|
||||||
) : logs.length === 0 ? (
|
|
||||||
<div className="glass-card-static p-8 rounded-xl text-center">
|
|
||||||
<p className="text-sm text-text-muted">暂无错误日志</p>
|
|
||||||
<p className="text-xs text-text-muted/50 mt-1">系统运行正常</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{logs.map((log) => (
|
|
||||||
<div key={log.id} className="glass-card-static p-3 rounded-xl">
|
|
||||||
<button onClick={() => setExpandedLogId(expandedLogId === log.id ? null : log.id)} className="w-full text-left">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
|
|
||||||
log.level === "error" ? "bg-red-500/15 text-red-400" : "bg-amber-500/15 text-amber-400"
|
|
||||||
}`}>{log.level}</span>
|
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-3 text-text-muted">{log.source}</span>
|
|
||||||
<span className="text-xs text-text-secondary flex-1 truncate">{log.message}</span>
|
|
||||||
<span className="text-[10px] text-text-muted/50 font-mono tabular-nums shrink-0">{log.created_at.slice(0, 16)}</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
{expandedLogId === log.id && log.detail && (
|
|
||||||
<div className="mt-2 p-3 rounded-lg bg-surface-1 border border-border-subtle">
|
|
||||||
<pre className="text-xs text-text-muted whitespace-pre-wrap break-all font-mono leading-relaxed">{log.detail}</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
211
frontend/src/app/(auth)/tasks/page.tsx
Normal file
211
frontend/src/app/(auth)/tasks/page.tsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { getTaskCenterAPI, type TaskCenterResult } from "@/lib/api";
|
||||||
|
|
||||||
|
export default function TasksPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [data, setData] = useState<TaskCenterResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
setData(await getTaskCenterAPI());
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.role === "admin") loadData();
|
||||||
|
}, [user, loadData]);
|
||||||
|
|
||||||
|
const jobGroups = useMemo(() => {
|
||||||
|
const jobs = data?.jobs || [];
|
||||||
|
return {
|
||||||
|
news: jobs.filter((job) => job.id.startsWith("news")),
|
||||||
|
scan: jobs.filter((job) => job.id.includes("market") || job.id.includes("morning") || job.id.includes("afternoon") || job.id.includes("late") || job.id.includes("pre_") || job.id.includes("post_") || job.id.includes("close")),
|
||||||
|
review: jobs.filter((job) => job.id.includes("watchlist") || job.id.includes("strategy")),
|
||||||
|
};
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
if (user?.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<div className="max-w-4xl mx-auto px-4 md:px-8 pt-6">
|
||||||
|
<p className="text-sm text-text-muted">需要管理员权限</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 pb-20 md:pb-10 space-y-5">
|
||||||
|
<header className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Task Center</div>
|
||||||
|
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">任务中心</h1>
|
||||||
|
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||||
|
后台新闻采集、选股扫描、跟踪复盘和策略校准任务的运行视图。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={loadData} className="w-fit rounded-xl border border-border-subtle bg-surface-2 px-3 py-2 text-xs font-medium text-text-secondary transition-all hover:bg-surface-4 hover:text-text-primary">
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<MetricCard label="调度器" value={data?.scheduler_running ? "运行中" : "停止"} tone={data?.scheduler_running ? "ok" : "error"} />
|
||||||
|
<MetricCard label="扫描状态" value={data?.scan_running ? "扫描中" : "空闲"} tone={data?.scan_running ? "warning" : "default"} />
|
||||||
|
<MetricCard label="任务数量" value={data?.job_count ?? 0} />
|
||||||
|
<MetricCard label="最近错误" value={data?.recent_errors.length ?? 0} tone={(data?.recent_errors.length ?? 0) > 0 ? "error" : "default"} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-1 gap-5 xl:grid-cols-[1fr_380px]">
|
||||||
|
<main className="space-y-5">
|
||||||
|
<JobGroup title="新闻与舆情" jobs={jobGroups.news} loading={loading} />
|
||||||
|
<JobGroup title="选股扫描" jobs={jobGroups.scan} loading={loading} />
|
||||||
|
<JobGroup title="跟踪复盘" jobs={jobGroups.review} loading={loading} />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside className="space-y-5">
|
||||||
|
<section className="glass-card-static overflow-hidden">
|
||||||
|
<div className="border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">最近筛选事件</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{(data?.recent_scan_logs || []).slice(0, 8).map((item, index) => (
|
||||||
|
<div key={`${item.scan_session}-${item.stage}-${index}`} className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-xs font-semibold text-text-primary">{stageLabel(item.stage)}</span>
|
||||||
|
<StatusPill status={item.status} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-muted line-clamp-2">{item.summary || item.scan_session}</div>
|
||||||
|
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data?.recent_scan_logs.length && <div className="p-5 text-sm text-text-muted">暂无扫描事件</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="glass-card-static overflow-hidden">
|
||||||
|
<div className="border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">最近错误</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{(data?.recent_errors || []).slice(0, 6).map((item, index) => (
|
||||||
|
<div key={`${item.source}-${index}`} className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<StatusPill status={item.level} />
|
||||||
|
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs leading-5 text-text-secondary line-clamp-2">{item.message}</div>
|
||||||
|
<div className="mt-2 font-mono text-[10px] tabular-nums text-text-muted">{formatDateTime(item.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!data?.recent_errors.length && <div className="p-5 text-sm text-text-muted">暂无错误</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobGroup({ title, jobs, loading }: { title: string; jobs: TaskCenterResult["jobs"]; loading: boolean }) {
|
||||||
|
return (
|
||||||
|
<section className="glass-card-static overflow-hidden">
|
||||||
|
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||||
|
<h2 className="text-sm font-semibold text-text-primary">{title}</h2>
|
||||||
|
<span className="text-[10px] text-text-muted">{jobs.length}项</span>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-3 p-4">
|
||||||
|
{[1, 2, 3].map((item) => <div key={item} className="h-16 animate-shimmer rounded-xl bg-surface-2" />)}
|
||||||
|
</div>
|
||||||
|
) : jobs.length === 0 ? (
|
||||||
|
<div className="p-6 text-sm text-text-muted">暂无任务</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-border-subtle">
|
||||||
|
{jobs.map((job) => (
|
||||||
|
<article key={job.id} className="grid gap-3 px-4 py-4 md:grid-cols-[190px_1fr_180px] md:items-center">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-text-primary">{jobLabel(job.id)}</div>
|
||||||
|
<div className="mt-1 font-mono text-[10px] text-text-muted">{job.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs leading-5 text-text-muted line-clamp-2">{cleanTrigger(job.trigger)}</div>
|
||||||
|
<div className="font-mono text-[10px] tabular-nums text-text-secondary md:text-right">
|
||||||
|
{formatDateTime(job.next_run_time)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MetricCard({ label, value, tone = "default" }: { label: string; value: string | number; tone?: "default" | "ok" | "warning" | "error" }) {
|
||||||
|
const color = tone === "ok" ? "text-emerald-400" : tone === "warning" ? "text-amber-400" : tone === "error" ? "text-red-400" : "text-text-primary";
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="text-[10px] text-text-muted">{label}</div>
|
||||||
|
<div className={`mt-2 truncate text-xl font-bold tabular-nums ${color}`}>{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusPill({ status }: { status: string }) {
|
||||||
|
const normalized = status.toLowerCase();
|
||||||
|
const className = normalized === "ok"
|
||||||
|
? "border-emerald-500/15 bg-emerald-500/[0.06] text-emerald-400"
|
||||||
|
: normalized === "warning" || normalized === "empty"
|
||||||
|
? "border-amber-500/15 bg-amber-500/[0.07] text-amber-400"
|
||||||
|
: "border-red-500/15 bg-red-500/[0.07] text-red-400";
|
||||||
|
const label = normalized === "ok" ? "正常" : normalized === "empty" ? "空结果" : normalized === "warning" ? "警告" : "异常";
|
||||||
|
return <span className={`w-fit rounded-md border px-2 py-0.5 text-[10px] font-medium ${className}`}>{label}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jobLabel(id: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
news_pre_market: "盘前新闻",
|
||||||
|
news_morning: "早盘新闻",
|
||||||
|
news_noon: "午间新闻",
|
||||||
|
news_afternoon: "午后新闻",
|
||||||
|
news_post_market: "盘后新闻",
|
||||||
|
pre_market: "盘前扫描",
|
||||||
|
post_market: "盘后扫描",
|
||||||
|
watchlist_analysis: "自选股分析",
|
||||||
|
strategy_iteration: "策略复盘",
|
||||||
|
};
|
||||||
|
if (map[id]) return map[id];
|
||||||
|
if (id.includes("morning")) return "早盘扫描";
|
||||||
|
if (id.includes("afternoon")) return "午后扫描";
|
||||||
|
if (id.includes("late")) return "尾盘扫描";
|
||||||
|
if (id.includes("close")) return "收盘扫描";
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stageLabel(stage: string) {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
market_temperature: "市场温度",
|
||||||
|
theme_selection: "主线主题",
|
||||||
|
strategy_profile: "策略参数",
|
||||||
|
candidate_recall: "候选召回",
|
||||||
|
realtime_quote: "实时行情",
|
||||||
|
rule_scoring: "规则评分",
|
||||||
|
final_filter: "最终作战池",
|
||||||
|
};
|
||||||
|
return map[stage] || stage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanTrigger(trigger: string) {
|
||||||
|
return trigger.replace("cron[", "").replace("]", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string) {
|
||||||
|
if (!value) return "-";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
||||||
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
@ -101,6 +101,39 @@ function SettingsIcon() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LogsIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M4 5h16" />
|
||||||
|
<path d="M4 12h10" />
|
||||||
|
<path d="M4 19h7" />
|
||||||
|
<path d="M17 15l2 2 3-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M20 13c0 5-3.5 8-8 8s-8-3-8-8V5l8-3 8 3v8z" />
|
||||||
|
<path d="M9 12l2 2 4-5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TasksIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M9 6h11" />
|
||||||
|
<path d="M9 12h11" />
|
||||||
|
<path d="M9 18h11" />
|
||||||
|
<path d="M4 6h.01" />
|
||||||
|
<path d="M4 12h.01" />
|
||||||
|
<path d="M4 18h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
|
function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isActive = pathname === href || (href !== "/dashboard" && pathname.startsWith(href));
|
const isActive = pathname === href || (href !== "/dashboard" && pathname.startsWith(href));
|
||||||
@ -131,10 +164,12 @@ export function SidebarNav() {
|
|||||||
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" />
|
<SideNavItem href="/sentiment" icon={<RadarIcon />} label="舆情雷达" />
|
||||||
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
<SideNavItem href="/watchlists" icon={<WatchlistIcon />} label="自选股" />
|
||||||
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" />
|
<SideNavItem href="/chat" icon={<ChatIcon />} label="研究助手" />
|
||||||
<SideNavItem href="/diagnose" icon={<DiagnoseIcon />} label="个股诊断" />
|
|
||||||
{user?.role === "admin" && (
|
{user?.role === "admin" && (
|
||||||
<>
|
<>
|
||||||
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" />
|
<SideNavItem href="/strategy" icon={<StrategyIcon />} label="策略校准" />
|
||||||
|
<SideNavItem href="/ops-logs" icon={<LogsIcon />} label="运行日志" />
|
||||||
|
<SideNavItem href="/data-health" icon={<HealthIcon />} label="数据源健康" />
|
||||||
|
<SideNavItem href="/tasks" icon={<TasksIcon />} label="任务中心" />
|
||||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -896,6 +896,111 @@ export interface SystemStatus {
|
|||||||
db_size_mb: number;
|
db_size_mb: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ScanProcessLog {
|
||||||
|
id: number;
|
||||||
|
scan_session: string;
|
||||||
|
scan_mode: string;
|
||||||
|
stage: string;
|
||||||
|
stage_label: string;
|
||||||
|
status: string;
|
||||||
|
input_count: number;
|
||||||
|
output_count: number;
|
||||||
|
filtered_count: number;
|
||||||
|
summary: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanLogsResult {
|
||||||
|
scan_session: string | null;
|
||||||
|
logs: ScanProcessLog[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSessionSummary {
|
||||||
|
scan_session: string;
|
||||||
|
scan_mode: string;
|
||||||
|
created_at: string;
|
||||||
|
stage_count: number;
|
||||||
|
status: string;
|
||||||
|
input_count: number;
|
||||||
|
final_count: number;
|
||||||
|
drop_count: number;
|
||||||
|
last_summary: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanSessionsResult {
|
||||||
|
sessions: ScanSessionSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchObservation {
|
||||||
|
id: number;
|
||||||
|
scan_session: string;
|
||||||
|
scan_mode: string;
|
||||||
|
ts_code: string;
|
||||||
|
name: string;
|
||||||
|
theme_name: string;
|
||||||
|
stock_role: string;
|
||||||
|
action_plan: string;
|
||||||
|
final_score: number;
|
||||||
|
catalyst_score: number;
|
||||||
|
theme_money_score: number;
|
||||||
|
stock_money_score: number;
|
||||||
|
emotion_role_score: number;
|
||||||
|
timing_score: number;
|
||||||
|
entry_signal_type: string;
|
||||||
|
elimination_reason: string;
|
||||||
|
detail: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResearchObservationsResult {
|
||||||
|
scan_session: string | null;
|
||||||
|
observations: ResearchObservation[];
|
||||||
|
reason_counts: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceHealthItem {
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
error_count: number;
|
||||||
|
warning_count: number;
|
||||||
|
last_error: string;
|
||||||
|
last_seen_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSourceHealthResult {
|
||||||
|
days: number;
|
||||||
|
sources: DataSourceHealthItem[];
|
||||||
|
freshness: Record<string, Record<string, string>>;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCenterJob {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
next_run_time: string;
|
||||||
|
trigger: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskCenterResult {
|
||||||
|
scheduler_running: boolean;
|
||||||
|
scan_running: boolean;
|
||||||
|
scan_locked: boolean;
|
||||||
|
job_count: number;
|
||||||
|
jobs: TaskCenterJob[];
|
||||||
|
recent_scan_logs: Array<{
|
||||||
|
scan_session: string;
|
||||||
|
scan_mode: string;
|
||||||
|
stage: string;
|
||||||
|
status: string;
|
||||||
|
output_count: number;
|
||||||
|
summary: string;
|
||||||
|
created_at: string;
|
||||||
|
}>;
|
||||||
|
recent_errors: Array<{ source: string; level: string; message: string; created_at: string }>;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getErrorLogsAPI(limit: number = 50, source?: string, level?: string, days: number = 7): Promise<ErrorLogsResult> {
|
export async function getErrorLogsAPI(limit: number = 50, source?: string, level?: string, days: number = 7): Promise<ErrorLogsResult> {
|
||||||
const params = new URLSearchParams({ limit: String(limit), days: String(days) });
|
const params = new URLSearchParams({ limit: String(limit), days: String(days) });
|
||||||
if (source) params.set("source", source);
|
if (source) params.set("source", source);
|
||||||
@ -910,3 +1015,28 @@ export async function clearErrorLogsAPI(days: number = 30): Promise<{ status: st
|
|||||||
export async function getSystemStatusAPI(): Promise<SystemStatus> {
|
export async function getSystemStatusAPI(): Promise<SystemStatus> {
|
||||||
return fetchAPI<SystemStatus>("/api/debug/system");
|
return fetchAPI<SystemStatus>("/api/debug/system");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getScanSessionsAPI(days: number = 7, limit: number = 30): Promise<ScanSessionsResult> {
|
||||||
|
const params = new URLSearchParams({ days: String(days), limit: String(limit) });
|
||||||
|
return fetchAPI<ScanSessionsResult>(`/api/debug/scan-sessions?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getScanLogsAPI(scanSession?: string, days: number = 7, limit: number = 120): Promise<ScanLogsResult> {
|
||||||
|
const params = new URLSearchParams({ days: String(days), limit: String(limit) });
|
||||||
|
if (scanSession) params.set("scan_session", scanSession);
|
||||||
|
return fetchAPI<ScanLogsResult>(`/api/debug/scan-logs?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getResearchObservationsAPI(scanSession?: string, days: number = 7, limit: number = 80): Promise<ResearchObservationsResult> {
|
||||||
|
const params = new URLSearchParams({ days: String(days), limit: String(limit) });
|
||||||
|
if (scanSession) params.set("scan_session", scanSession);
|
||||||
|
return fetchAPI<ResearchObservationsResult>(`/api/debug/research-observations?${params}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDataSourceHealthAPI(days: number = 7): Promise<DataSourceHealthResult> {
|
||||||
|
return fetchAPI<DataSourceHealthResult>(`/api/debug/data-source-health?days=${days}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTaskCenterAPI(): Promise<TaskCenterResult> {
|
||||||
|
return fetchAPI<TaskCenterResult>("/api/debug/tasks");
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user