This commit is contained in:
aaron 2026-05-19 11:25:21 +08:00
parent 9d8e223df8
commit 46a7bf1192
13 changed files with 2046 additions and 155 deletions

View File

@ -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(),
}

View File

@ -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))

View 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

View 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

View File

@ -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),

View File

@ -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", []):

View File

@ -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]

View 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">
TushareAKShare
</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" });
}

View 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" });
}

View File

@ -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>

View 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" });
}

View File

@ -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="管理设置" />
</> </>
)} )}

View File

@ -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");
}