1
This commit is contained in:
parent
46a7bf1192
commit
d07af5508a
@ -3,8 +3,10 @@ ASTOCK_DEBUG=true
|
|||||||
|
|
||||||
ASTOCK_DEEPSEEK_API_KEY=sk-ee8eee63d5cf41eba14a328de49055ac
|
ASTOCK_DEEPSEEK_API_KEY=sk-ee8eee63d5cf41eba14a328de49055ac
|
||||||
ASTOCK_ALERT_ENABLED=true
|
ASTOCK_ALERT_ENABLED=true
|
||||||
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
|
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/408ab727-0dcd-4c7a-bde7-4aad38cbf807
|
||||||
ASTOCK_ALERT_ENVIRONMENT=local
|
ASTOCK_ALERT_ENVIRONMENT=local
|
||||||
|
ASTOCK_RECOMMENDATION_PUSH_ENABLED=true
|
||||||
|
ASTOCK_RECOMMENDATION_PUSH_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/408ab727-0dcd-4c7a-bde7-4aad38cbf807
|
||||||
ASTOCK_ADMIN_USERNAME=75981230@qq.com
|
ASTOCK_ADMIN_USERNAME=75981230@qq.com
|
||||||
ASTOCK_ADMIN_EMAIL=75981230@qq.com
|
ASTOCK_ADMIN_EMAIL=75981230@qq.com
|
||||||
ASTOCK_ADMIN_PASSWORD=880803
|
ASTOCK_ADMIN_PASSWORD=880803
|
||||||
|
|||||||
@ -9,3 +9,8 @@ ASTOCK_SMTP_PORT=465
|
|||||||
ASTOCK_SMTP_USERNAME=noreply@example.com
|
ASTOCK_SMTP_USERNAME=noreply@example.com
|
||||||
ASTOCK_SMTP_PASSWORD=your_smtp_password
|
ASTOCK_SMTP_PASSWORD=your_smtp_password
|
||||||
ASTOCK_SMTP_SENDER=noreply@example.com
|
ASTOCK_SMTP_SENDER=noreply@example.com
|
||||||
|
ASTOCK_ALERT_ENABLED=false
|
||||||
|
ASTOCK_FEISHU_WEBHOOK_URL=
|
||||||
|
ASTOCK_RECOMMENDATION_PUSH_ENABLED=false
|
||||||
|
ASTOCK_RECOMMENDATION_PUSH_WEBHOOK_URL=
|
||||||
|
ASTOCK_RECOMMENDATION_PUSH_MAX_ITEMS=8
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -429,6 +429,12 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
|||||||
track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0
|
track_count = (await db.execute(text("SELECT COUNT(*) FROM recommendation_tracking"))).scalar() or 0
|
||||||
sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0
|
sector_count = (await db.execute(text("SELECT COUNT(*) FROM sector_heat"))).scalar() or 0
|
||||||
temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0
|
temp_count = (await db.execute(text("SELECT COUNT(*) FROM market_temperature"))).scalar() or 0
|
||||||
|
diagnosis_count = (await db.execute(text("SELECT COUNT(*) FROM stock_diagnoses"))).scalar() or 0
|
||||||
|
watchlist_analysis_count = (await db.execute(text("SELECT COUNT(*) FROM watchlist_analyses"))).scalar() or 0
|
||||||
|
user_count = (await db.execute(text("SELECT COUNT(*) FROM users"))).scalar() or 0
|
||||||
|
invite_count = (await db.execute(text("SELECT COUNT(*) FROM invite_codes"))).scalar() or 0
|
||||||
|
error_log_count = (await db.execute(text("SELECT COUNT(*) FROM error_logs"))).scalar() or 0
|
||||||
|
scan_log_count = (await db.execute(text("SELECT COUNT(*) FROM scan_process_logs"))).scalar() or 0
|
||||||
low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0
|
low_score = (await db.execute(text("SELECT COUNT(*) FROM recommendations WHERE score < 60"))).scalar() or 0
|
||||||
latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or ""
|
latest_rec = (await db.execute(text("SELECT MAX(date(created_at)) FROM recommendations"))).scalar() or ""
|
||||||
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
|
earliest_rec = (await db.execute(text("SELECT MIN(date(created_at)) FROM recommendations"))).scalar() or ""
|
||||||
@ -437,6 +443,12 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
|||||||
"tracking": track_count,
|
"tracking": track_count,
|
||||||
"sector_heat": sector_count,
|
"sector_heat": sector_count,
|
||||||
"market_temperature": temp_count,
|
"market_temperature": temp_count,
|
||||||
|
"stock_diagnoses": diagnosis_count,
|
||||||
|
"watchlist_analyses": watchlist_analysis_count,
|
||||||
|
"users": user_count,
|
||||||
|
"invite_codes": invite_count,
|
||||||
|
"error_logs": error_log_count,
|
||||||
|
"scan_logs": scan_log_count,
|
||||||
"low_score_count": low_score,
|
"low_score_count": low_score,
|
||||||
"latest_date": str(latest_rec),
|
"latest_date": str(latest_rec),
|
||||||
"earliest_date": str(earliest_rec),
|
"earliest_date": str(earliest_rec),
|
||||||
@ -448,13 +460,25 @@ async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_ad
|
|||||||
deleted: dict[str, int] = {}
|
deleted: dict[str, int] = {}
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
if req.mode == "all":
|
if req.mode == "all":
|
||||||
for table in ["recommendation_tracking", "recommendations", "sector_heat", "market_temperature", "stock_diagnoses"]:
|
for table in ["recommendation_tracking", "recommendations", "sector_heat", "market_temperature", "stock_diagnoses", "watchlist_analyses"]:
|
||||||
result = await db.execute(text(f"DELETE FROM {table}"))
|
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||||
deleted[table] = result.rowcount or 0
|
deleted[table] = result.rowcount or 0
|
||||||
elif req.mode == "recommendations":
|
elif req.mode == "recommendations":
|
||||||
for table in ["recommendation_tracking", "recommendations"]:
|
for table in ["recommendation_tracking", "recommendations"]:
|
||||||
result = await db.execute(text(f"DELETE FROM {table}"))
|
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||||
deleted[table] = result.rowcount or 0
|
deleted[table] = result.rowcount or 0
|
||||||
|
elif req.mode == "market_cache":
|
||||||
|
for table in ["sector_heat", "market_temperature"]:
|
||||||
|
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||||
|
deleted[table] = result.rowcount or 0
|
||||||
|
elif req.mode == "diagnostics":
|
||||||
|
for table in ["stock_diagnoses", "watchlist_analyses"]:
|
||||||
|
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||||
|
deleted[table] = result.rowcount or 0
|
||||||
|
elif req.mode == "logs":
|
||||||
|
for table in ["error_logs", "scan_process_logs", "research_observations"]:
|
||||||
|
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||||
|
deleted[table] = result.rowcount or 0
|
||||||
elif req.mode == "date_range":
|
elif req.mode == "date_range":
|
||||||
if not req.before_date:
|
if not req.before_date:
|
||||||
raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数")
|
raise HTTPException(status_code=400, detail="date_range 模式需要 before_date 参数")
|
||||||
@ -466,6 +490,10 @@ async def data_reset(req: DataResetRequest, admin: dict = Depends(get_current_ad
|
|||||||
deleted["sector_heat"] = result.rowcount or 0
|
deleted["sector_heat"] = result.rowcount or 0
|
||||||
result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
|
result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
|
||||||
deleted["market_temperature"] = result.rowcount or 0
|
deleted["market_temperature"] = result.rowcount or 0
|
||||||
|
result = await db.execute(text("DELETE FROM stock_diagnoses WHERE date(created_at) < :bd"), {"bd": req.before_date})
|
||||||
|
deleted["stock_diagnoses"] = result.rowcount or 0
|
||||||
|
result = await db.execute(text("DELETE FROM watchlist_analyses WHERE date(created_at) < :bd"), {"bd": req.before_date})
|
||||||
|
deleted["watchlist_analyses"] = result.rowcount or 0
|
||||||
elif req.mode == "low_score":
|
elif req.mode == "low_score":
|
||||||
threshold = req.min_score or 60
|
threshold = req.min_score or 60
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
|
|||||||
@ -18,6 +18,7 @@ async def get_errors(
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
source: str = None,
|
source: str = None,
|
||||||
level: str = None,
|
level: str = None,
|
||||||
|
q: str = None,
|
||||||
days: int = 7,
|
days: int = 7,
|
||||||
_admin: dict = Depends(get_current_admin),
|
_admin: dict = Depends(get_current_admin),
|
||||||
):
|
):
|
||||||
@ -33,6 +34,9 @@ async def get_errors(
|
|||||||
if level:
|
if level:
|
||||||
conditions.append("level = :level")
|
conditions.append("level = :level")
|
||||||
params["level"] = level
|
params["level"] = level
|
||||||
|
if q:
|
||||||
|
conditions.append("(message LIKE :q OR detail LIKE :q OR source LIKE :q)")
|
||||||
|
params["q"] = f"%{q.strip()}%"
|
||||||
|
|
||||||
where = " AND ".join(conditions)
|
where = " AND ".join(conditions)
|
||||||
|
|
||||||
@ -76,11 +80,25 @@ async def get_errors(
|
|||||||
)
|
)
|
||||||
levels = [r[0] for r in levels_result.fetchall()]
|
levels = [r[0] for r in levels_result.fetchall()]
|
||||||
|
|
||||||
|
source_counts_result = await db.execute(
|
||||||
|
text(f"SELECT source, COUNT(*) FROM error_logs WHERE {where} GROUP BY source ORDER BY COUNT(*) DESC"),
|
||||||
|
{key: value for key, value in params.items() if key != "limit"},
|
||||||
|
)
|
||||||
|
source_counts = {r[0]: r[1] for r in source_counts_result.fetchall()}
|
||||||
|
|
||||||
|
level_counts_result = await db.execute(
|
||||||
|
text(f"SELECT level, COUNT(*) FROM error_logs WHERE {where} GROUP BY level ORDER BY COUNT(*) DESC"),
|
||||||
|
{key: value for key, value in params.items() if key != "limit"},
|
||||||
|
)
|
||||||
|
level_counts = {r[0]: r[1] for r in level_counts_result.fetchall()}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
"sources": sources,
|
"sources": sources,
|
||||||
"levels": levels,
|
"levels": levels,
|
||||||
|
"source_counts": source_counts,
|
||||||
|
"level_counts": level_counts,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,12 @@ class Settings(BaseSettings):
|
|||||||
alert_app_name: str = "AStock Agent"
|
alert_app_name: str = "AStock Agent"
|
||||||
alert_environment: str = "local"
|
alert_environment: str = "local"
|
||||||
|
|
||||||
|
# 飞书推荐推送
|
||||||
|
recommendation_push_enabled: bool = False
|
||||||
|
recommendation_push_webhook_url: str = ""
|
||||||
|
recommendation_push_max_items: int = 8
|
||||||
|
recommendation_push_dedup_ttl_seconds: int = 600
|
||||||
|
|
||||||
# 前端
|
# 前端
|
||||||
frontend_url: str = "http://localhost:3002"
|
frontend_url: str = "http://localhost:3002"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,7 @@
|
|||||||
"""错误日志持久化"""
|
"""错误日志持久化"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
@ -67,3 +68,29 @@ def log_error_background(
|
|||||||
)
|
)
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class PersistentErrorLogHandler(logging.Handler):
|
||||||
|
"""Persist application ERROR/CRITICAL log records into error_logs."""
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
if record.levelno < logging.ERROR:
|
||||||
|
return
|
||||||
|
if getattr(record, "skip_error_persist", False):
|
||||||
|
return
|
||||||
|
if record.name.startswith("app.db.error_logger"):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = ""
|
||||||
|
if record.exc_info:
|
||||||
|
detail = "".join(traceback.format_exception(*record.exc_info))
|
||||||
|
log_error_background(
|
||||||
|
source=record.name,
|
||||||
|
message=record.getMessage(),
|
||||||
|
detail=detail,
|
||||||
|
level=record.levelname.lower(),
|
||||||
|
notify=False,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -108,6 +108,9 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
|
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
|
||||||
await _save_to_db(result)
|
await _save_to_db(result)
|
||||||
|
|
||||||
|
# 推送本轮可操作/重点关注推荐,失败不影响扫描结果。
|
||||||
|
await _push_recommendation_notifications(result, scan_session)
|
||||||
|
|
||||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||||
await _update_tracking()
|
await _update_tracking()
|
||||||
|
|
||||||
@ -116,6 +119,22 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
_scan_running = False
|
_scan_running = False
|
||||||
|
|
||||||
|
|
||||||
|
async def _push_recommendation_notifications(result: dict, scan_session: str) -> None:
|
||||||
|
try:
|
||||||
|
from app.notifications.feishu import send_recommendation_push
|
||||||
|
|
||||||
|
sent = await send_recommendation_push(
|
||||||
|
recommendations=result.get("recommendations", []),
|
||||||
|
market_temp=result.get("market_temp"),
|
||||||
|
scan_session=scan_session,
|
||||||
|
strategy_profile=result.get("strategy_profile"),
|
||||||
|
)
|
||||||
|
if sent:
|
||||||
|
logger.info("已发送飞书推荐推送: scan_session=%s", scan_session)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("飞书推荐推送失败: %s", e)
|
||||||
|
|
||||||
|
|
||||||
async def _update_tracking():
|
async def _update_tracking():
|
||||||
"""更新历史推荐的跟踪数据"""
|
"""更新历史推荐的跟踪数据"""
|
||||||
try:
|
try:
|
||||||
@ -421,7 +440,7 @@ async def get_performance_stats() -> dict:
|
|||||||
avg_max_return = round(float(avg_extremes[0]), 2) if avg_extremes and avg_extremes[0] is not None else 0
|
avg_max_return = round(float(avg_extremes[0]), 2) if avg_extremes and avg_extremes[0] is not None else 0
|
||||||
avg_max_drawdown = round(float(avg_extremes[1]), 2) if avg_extremes and avg_extremes[1] is not None else 0
|
avg_max_drawdown = round(float(avg_extremes[1]), 2) if avg_extremes and avg_extremes[1] is not None else 0
|
||||||
|
|
||||||
# 最近跟踪的推荐详情
|
# 全量跟踪样本用于复盘统计,页面详情只取最近一批展示。
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
text(
|
text(
|
||||||
"SELECT r.ts_code, r.name, r.signal, r.entry_price, "
|
"SELECT r.ts_code, r.name, r.signal, r.entry_price, "
|
||||||
@ -436,13 +455,13 @@ async def get_performance_stats() -> dict:
|
|||||||
" SELECT recommendation_id, MAX(id) as max_id "
|
" SELECT recommendation_id, MAX(id) as max_id "
|
||||||
" FROM recommendation_tracking GROUP BY recommendation_id"
|
" FROM recommendation_tracking GROUP BY recommendation_id"
|
||||||
") latest ON t.id = latest.max_id "
|
") latest ON t.id = latest.max_id "
|
||||||
"ORDER BY r.created_at DESC LIMIT 20"
|
"ORDER BY r.created_at DESC"
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
details = []
|
all_details = []
|
||||||
for row in result.fetchall():
|
for row in result.fetchall():
|
||||||
r = row._mapping
|
r = row._mapping
|
||||||
details.append({
|
all_details.append({
|
||||||
"ts_code": r["ts_code"],
|
"ts_code": r["ts_code"],
|
||||||
"name": r["name"],
|
"name": r["name"],
|
||||||
"signal": r["signal"],
|
"signal": r["signal"],
|
||||||
@ -470,6 +489,7 @@ async def get_performance_stats() -> dict:
|
|||||||
|
|
||||||
winning = min(winning, tracked)
|
winning = min(winning, tracked)
|
||||||
win_rate = round(winning / tracked * 100, 1) if tracked > 0 else 0
|
win_rate = round(winning / tracked * 100, 1) if tracked > 0 else 0
|
||||||
|
details = all_details[:20]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"total_recommendations": total,
|
"total_recommendations": total,
|
||||||
@ -482,8 +502,8 @@ async def get_performance_stats() -> dict:
|
|||||||
"hit_target_count": hit_target_count,
|
"hit_target_count": hit_target_count,
|
||||||
"hit_stop_count": hit_stop_count,
|
"hit_stop_count": hit_stop_count,
|
||||||
"lifecycle_counts": lifecycle_counts,
|
"lifecycle_counts": lifecycle_counts,
|
||||||
"route_breakdown": _build_route_breakdown(details),
|
"route_breakdown": _build_route_breakdown(all_details),
|
||||||
"prefilter_breakdown": _build_prefilter_breakdown(details),
|
"prefilter_breakdown": _build_prefilter_breakdown(all_details),
|
||||||
"details": details,
|
"details": details,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -8,7 +8,7 @@ from fastapi.responses import JSONResponse
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db.error_logger import log_error
|
from app.db.error_logger import PersistentErrorLogHandler, log_error
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug, catalysts
|
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug, catalysts
|
||||||
@ -33,6 +33,10 @@ def configure_logging() -> None:
|
|||||||
for name, level in noisy_loggers.items():
|
for name, level in noisy_loggers.items():
|
||||||
logging.getLogger(name).setLevel(level)
|
logging.getLogger(name).setLevel(level)
|
||||||
|
|
||||||
|
app_logger = logging.getLogger("app")
|
||||||
|
if not any(isinstance(handler, PersistentErrorLogHandler) for handler in app_logger.handlers):
|
||||||
|
app_logger.addHandler(PersistentErrorLogHandler(level=logging.ERROR))
|
||||||
|
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -106,7 +110,7 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info("调度器已启动")
|
logger.info("调度器已启动")
|
||||||
yield
|
yield
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("应用生命周期异常")
|
logger.exception("应用生命周期异常", extra={"skip_error_persist": True})
|
||||||
await log_error(
|
await log_error(
|
||||||
"lifespan",
|
"lifespan",
|
||||||
f"应用生命周期异常: {e}",
|
f"应用生命周期异常: {e}",
|
||||||
@ -152,7 +156,7 @@ app.websocket("/ws")(websocket.ws_endpoint)
|
|||||||
|
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_exception_handler(request: Request, exc: Exception):
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||||
logger.exception("未处理的接口异常: %s %s", request.method, request.url.path)
|
logger.exception("未处理的接口异常: %s %s", request.method, request.url.path, extra={"skip_error_persist": True})
|
||||||
query = str(request.url.query or "")
|
query = str(request.url.query or "")
|
||||||
await log_error(
|
await log_error(
|
||||||
"asgi",
|
"asgi",
|
||||||
|
|||||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@ -91,3 +92,209 @@ async def send_feishu_alert(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Feishu 告警发送失败: %s", e)
|
logger.warning("Feishu 告警发送失败: %s", e)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _recommendation_signature(
|
||||||
|
recommendations: list[Any],
|
||||||
|
scan_session: str,
|
||||||
|
) -> str:
|
||||||
|
parts = [scan_session]
|
||||||
|
for rec in recommendations:
|
||||||
|
parts.append(
|
||||||
|
"|".join([
|
||||||
|
str(getattr(rec, "ts_code", "")),
|
||||||
|
str(getattr(rec, "action_plan", "")),
|
||||||
|
str(getattr(rec, "score", "")),
|
||||||
|
str(getattr(rec, "entry_price", "")),
|
||||||
|
str(getattr(rec, "target_price", "")),
|
||||||
|
str(getattr(rec, "stop_loss", "")),
|
||||||
|
])
|
||||||
|
)
|
||||||
|
return hashlib.sha1("\n".join(parts).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _format_price(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
return f"{float(value):.2f}"
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_percent(value: Any) -> str:
|
||||||
|
if value is None:
|
||||||
|
return "-"
|
||||||
|
try:
|
||||||
|
return f"{float(value):g}%"
|
||||||
|
except Exception:
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _format_signal_label(value: str) -> str:
|
||||||
|
labels = {
|
||||||
|
"breakout": "突破",
|
||||||
|
"breakout_confirm": "突破确认",
|
||||||
|
"pullback": "回踩",
|
||||||
|
"launch": "启动",
|
||||||
|
"reversal": "反转",
|
||||||
|
"flow_momentum": "资金动量",
|
||||||
|
"none": "观察",
|
||||||
|
"BUY": "买入",
|
||||||
|
"HOLD": "持有",
|
||||||
|
}
|
||||||
|
return labels.get(value, value or "-")
|
||||||
|
|
||||||
|
|
||||||
|
def _card_text(content: str, tag: str = "lark_md") -> dict:
|
||||||
|
return {"tag": tag, "content": content}
|
||||||
|
|
||||||
|
|
||||||
|
def _recommendation_card_block(index: int, rec: Any) -> list[dict]:
|
||||||
|
action_plan = getattr(rec, "action_plan", "") or "观察"
|
||||||
|
name = getattr(rec, "name", "") or "-"
|
||||||
|
code = getattr(rec, "ts_code", "") or "-"
|
||||||
|
sector = getattr(rec, "sector", "") or "未归类"
|
||||||
|
score = float(getattr(rec, "score", 0) or 0)
|
||||||
|
position = _format_percent(getattr(rec, "suggested_position_pct", 0) or 0)
|
||||||
|
signal = getattr(rec, "entry_signal_type", "") or getattr(rec, "signal", "") or "-"
|
||||||
|
entry = _format_price(getattr(rec, "entry_price", None))
|
||||||
|
target = _format_price(getattr(rec, "target_price", None))
|
||||||
|
stop = _format_price(getattr(rec, "stop_loss", None))
|
||||||
|
review_days = getattr(rec, "review_after_days", None)
|
||||||
|
trigger = _truncate(getattr(rec, "trigger_condition", "") or "等待触发条件确认", 120)
|
||||||
|
invalidation = _truncate(getattr(rec, "invalidation_condition", "") or "按止损/失效条件处理", 120)
|
||||||
|
risk_note = _truncate(getattr(rec, "risk_note", "") or "", 100)
|
||||||
|
signal_label = _format_signal_label(signal)
|
||||||
|
|
||||||
|
title = (
|
||||||
|
f"**{index}. {name}** `{code}`\n"
|
||||||
|
f"{action_plan} | {sector} | {score:.0f}分 | {signal_label}"
|
||||||
|
)
|
||||||
|
price_line = (
|
||||||
|
f"**仓位** {position} "
|
||||||
|
f"**入场** {entry} "
|
||||||
|
f"**目标** {target} "
|
||||||
|
f"**止损** {stop}"
|
||||||
|
)
|
||||||
|
condition_line = f"**触发**: {trigger}\n**失效**: {invalidation}"
|
||||||
|
if risk_note:
|
||||||
|
condition_line = f"{condition_line}\n**风险**: {risk_note}"
|
||||||
|
if review_days:
|
||||||
|
condition_line = f"{condition_line}\n**复盘**: {review_days} 个交易日"
|
||||||
|
|
||||||
|
return [
|
||||||
|
{"tag": "div", "text": _card_text(title)},
|
||||||
|
{"tag": "div", "text": _card_text(price_line)},
|
||||||
|
{"tag": "div", "text": _card_text(condition_line)},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_recommendation_card(
|
||||||
|
selected: list[Any],
|
||||||
|
market_temp: Any,
|
||||||
|
scan_session: str,
|
||||||
|
strategy_profile: dict | None,
|
||||||
|
now: str,
|
||||||
|
) -> dict:
|
||||||
|
actionable_count = sum(1 for rec in selected if getattr(rec, "action_plan", "") == "可操作")
|
||||||
|
watch_count = sum(1 for rec in selected if getattr(rec, "action_plan", "") == "重点关注")
|
||||||
|
temp = getattr(market_temp, "temperature", None)
|
||||||
|
trade_date = getattr(market_temp, "trade_date", "") if market_temp else ""
|
||||||
|
strategy_name = (strategy_profile or {}).get("name") or (strategy_profile or {}).get("strategy_id") or "-"
|
||||||
|
stance = (strategy_profile or {}).get("market_stance") or "-"
|
||||||
|
header_template = "red" if actionable_count else "orange"
|
||||||
|
|
||||||
|
summary = "\n".join([
|
||||||
|
f"**扫描**: {scan_session or 'manual'}",
|
||||||
|
f"**时间**: {now}",
|
||||||
|
f"**市场**: {trade_date or '-'} / 温度 {temp if temp is not None else '-'}",
|
||||||
|
f"**策略**: {strategy_name} / {stance}",
|
||||||
|
f"**推送**: 可操作 {actionable_count} 只,重点关注 {watch_count} 只",
|
||||||
|
])
|
||||||
|
|
||||||
|
elements: list[dict] = [
|
||||||
|
{"tag": "div", "text": _card_text(summary)},
|
||||||
|
{"tag": "hr"},
|
||||||
|
]
|
||||||
|
for index, rec in enumerate(selected, start=1):
|
||||||
|
if index > 1:
|
||||||
|
elements.append({"tag": "hr"})
|
||||||
|
elements.extend(_recommendation_card_block(index, rec))
|
||||||
|
|
||||||
|
elements.extend([
|
||||||
|
{"tag": "hr"},
|
||||||
|
{
|
||||||
|
"tag": "note",
|
||||||
|
"elements": [
|
||||||
|
_card_text("只推送可操作/重点关注;观察池不推送。请按触发条件确认后再执行。", tag="plain_text")
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"config": {"wide_screen_mode": True},
|
||||||
|
"header": {
|
||||||
|
"template": header_template,
|
||||||
|
"title": _card_text(f"{settings.alert_app_name} 股票推荐", tag="plain_text"),
|
||||||
|
},
|
||||||
|
"elements": elements,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_recommendation_push(
|
||||||
|
recommendations: list[Any],
|
||||||
|
market_temp: Any = None,
|
||||||
|
scan_session: str = "",
|
||||||
|
strategy_profile: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""推送股票推荐到飞书,失败不影响扫描主流程。"""
|
||||||
|
webhook_url = settings.recommendation_push_webhook_url or settings.feishu_webhook_url
|
||||||
|
if not settings.recommendation_push_enabled or not webhook_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
priority = {"可操作": 0, "重点关注": 1}
|
||||||
|
selected = [
|
||||||
|
rec for rec in recommendations
|
||||||
|
if getattr(rec, "action_plan", "") in priority
|
||||||
|
]
|
||||||
|
selected.sort(
|
||||||
|
key=lambda rec: (
|
||||||
|
priority.get(getattr(rec, "action_plan", ""), 9),
|
||||||
|
-float(getattr(rec, "score", 0) or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected = selected[: max(settings.recommendation_push_max_items, 1)]
|
||||||
|
if not selected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
signature = _recommendation_signature(selected, scan_session)
|
||||||
|
dedup_key = f"feishu_recommendation_push:{signature}"
|
||||||
|
if cache.get(dedup_key):
|
||||||
|
return False
|
||||||
|
cache.set(dedup_key, True, settings.recommendation_push_dedup_ttl_seconds)
|
||||||
|
|
||||||
|
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
payload = {
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"card": _build_recommendation_card(
|
||||||
|
selected=selected,
|
||||||
|
market_temp=market_temp,
|
||||||
|
scan_session=scan_session,
|
||||||
|
strategy_profile=strategy_profile,
|
||||||
|
now=now,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
|
||||||
|
resp = await client.post(webhook_url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
body = resp.json()
|
||||||
|
if body.get("code", 0) != 0:
|
||||||
|
logger.warning("Feishu 推荐卡片推送失败: %s", body)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Feishu 推荐推送失败: %s", e)
|
||||||
|
return False
|
||||||
|
|||||||
Binary file not shown.
@ -1,45 +1,30 @@
|
|||||||
{
|
{
|
||||||
"pages": {
|
"pages": {
|
||||||
"/layout": [
|
"/(auth)/dashboard/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/css/app/layout.css",
|
"static/chunks/app/(auth)/dashboard/page.js"
|
||||||
"static/chunks/app/layout.js"
|
|
||||||
],
|
],
|
||||||
"/(auth)/layout": [
|
"/(auth)/layout": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(auth)/layout.js"
|
"static/chunks/app/(auth)/layout.js"
|
||||||
],
|
],
|
||||||
"/(public)/layout": [
|
"/layout": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(public)/layout.js"
|
"static/css/app/layout.css",
|
||||||
|
"static/chunks/app/layout.js"
|
||||||
],
|
],
|
||||||
"/(auth)/diagnose/page": [
|
"/(auth)/recommendations/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(auth)/diagnose/page.js"
|
"static/chunks/app/(auth)/recommendations/page.js"
|
||||||
],
|
],
|
||||||
"/(auth)/stock/[code]/page": [
|
"/(auth)/strategy/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(auth)/stock/[code]/page.js"
|
"static/chunks/app/(auth)/strategy/page.js"
|
||||||
],
|
|
||||||
"/(auth)/watchlists/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(auth)/watchlists/page.js"
|
|
||||||
],
|
|
||||||
"/(auth)/chat/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(auth)/chat/page.js"
|
|
||||||
],
|
|
||||||
"/(public)/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(public)/page.js"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,9 +2,7 @@
|
|||||||
"polyfillFiles": [
|
"polyfillFiles": [
|
||||||
"static/chunks/polyfills.js"
|
"static/chunks/polyfills.js"
|
||||||
],
|
],
|
||||||
"devFiles": [
|
"devFiles": [],
|
||||||
"static/chunks/react-refresh.js"
|
|
||||||
],
|
|
||||||
"ampDevFiles": [],
|
"ampDevFiles": [],
|
||||||
"lowPriorityFiles": [
|
"lowPriorityFiles": [
|
||||||
"static/development/_buildManifest.js",
|
"static/development/_buildManifest.js",
|
||||||
@ -15,16 +13,7 @@
|
|||||||
"static/chunks/main-app.js"
|
"static/chunks/main-app.js"
|
||||||
],
|
],
|
||||||
"pages": {
|
"pages": {
|
||||||
"/_app": [
|
"/_app": []
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_app.js"
|
|
||||||
],
|
|
||||||
"/_error": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_error.js"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ampFirstPages": []
|
"ampFirstPages": []
|
||||||
}
|
}
|
||||||
1
frontend/.next/cache/.tsbuildinfo
vendored
1
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
@ -1,14 +1 @@
|
|||||||
{
|
{}
|
||||||
"components/capital-flow.tsx -> echarts": {
|
|
||||||
"id": "components/capital-flow.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"components/kline-chart.tsx -> echarts": {
|
|
||||||
"id": "components/kline-chart.tsx -> echarts",
|
|
||||||
"files": [
|
|
||||||
"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"/(auth)/chat/page": "app/(auth)/chat/page.js",
|
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||||
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||||
"/(public)/page": "app/(public)/page.js"
|
"/(auth)/strategy/page": "app/(auth)/strategy/page.js"
|
||||||
}
|
}
|
||||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
|||||||
"polyfillFiles": [
|
"polyfillFiles": [
|
||||||
"static/chunks/polyfills.js"
|
"static/chunks/polyfills.js"
|
||||||
],
|
],
|
||||||
"devFiles": [
|
"devFiles": [],
|
||||||
"static/chunks/react-refresh.js"
|
|
||||||
],
|
|
||||||
"ampDevFiles": [],
|
"ampDevFiles": [],
|
||||||
"lowPriorityFiles": [],
|
"lowPriorityFiles": [],
|
||||||
"rootMainFiles": [
|
"rootMainFiles": [
|
||||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
|||||||
"static/chunks/main-app.js"
|
"static/chunks/main-app.js"
|
||||||
],
|
],
|
||||||
"pages": {
|
"pages": {
|
||||||
"/_app": [
|
"/_app": []
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_app.js"
|
|
||||||
],
|
|
||||||
"/_error": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main.js",
|
|
||||||
"static/chunks/pages/_error.js"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"ampFirstPages": []
|
"ampFirstPages": []
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
self.__REACT_LOADABLE_MANIFEST="{\"components/capital-flow.tsx -> echarts\":{\"id\":\"components/capital-flow.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]},\"components/kline-chart.tsx -> echarts\":{\"id\":\"components/kline-chart.tsx -> echarts\",\"files\":[\"static/chunks/_app-pages-browser_node_modules_echarts_index_js.js\"]}}"
|
self.__REACT_LOADABLE_MANIFEST="{}"
|
||||||
@ -1,5 +1 @@
|
|||||||
{
|
{}
|
||||||
"/_app": "pages/_app.js",
|
|
||||||
"/_error": "pages/_error.js",
|
|
||||||
"/_document": "pages/_document.js"
|
|
||||||
}
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"node": {},
|
"node": {},
|
||||||
"edge": {},
|
"edge": {},
|
||||||
"encryptionKey": "5a77t1jXySke+j0Es8vduY/7S7yObSbYfKeh0OReITs="
|
"encryptionKey": "s0qcbsgwKrVRZ+Vvkywm9IM+ti0wNq5+7QtEBPqabTc="
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -250,10 +250,16 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
|
||||||
<MarketSnapshot
|
<OpportunityBoard
|
||||||
|
sectors={sectors}
|
||||||
|
focusQueue={focusQueue.slice(0, 6)}
|
||||||
|
actionableCount={actionable.length}
|
||||||
|
watchCount={watch.length}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<CompactMarketEvidence
|
||||||
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
|
||||||
indices={indices}
|
indices={indices}
|
||||||
sectors={sectors}
|
|
||||||
summary={marketSummary}
|
summary={marketSummary}
|
||||||
/>
|
/>
|
||||||
<AdminPanel
|
<AdminPanel
|
||||||
@ -267,6 +273,7 @@ export default function DashboardPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,32 +366,89 @@ function DecisionHero({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarketSnapshot({
|
function OpportunityBoard({
|
||||||
|
sectors,
|
||||||
|
focusQueue,
|
||||||
|
actionableCount,
|
||||||
|
watchCount,
|
||||||
|
}: {
|
||||||
|
sectors: SectorData[];
|
||||||
|
focusQueue: RecommendationData[];
|
||||||
|
actionableCount: number;
|
||||||
|
watchCount: number;
|
||||||
|
}) {
|
||||||
|
const leadingSectors = sectors.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4 md:p-5">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-amber-400">Opportunity</div>
|
||||||
|
<h3 className="mt-1 text-lg font-bold tracking-tight text-text-primary">今天优先看的方向</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<CompactBadge label="可操作" value={`${actionableCount}`} />
|
||||||
|
<CompactBadge label="重点关注" value={`${watchCount}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-4 lg:grid-cols-[minmax(280px,0.9fr)_minmax(0,1.1fr)]">
|
||||||
|
<section className="rounded-2xl border border-border-subtle bg-surface-1/55 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-text-primary">重点板块</div>
|
||||||
|
<a href="/sectors" className="text-[11px] text-text-muted hover:text-text-secondary">查看全部</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2.5">
|
||||||
|
{leadingSectors.length ? leadingSectors.map((sector, index) => (
|
||||||
|
<PrioritySectorCard key={sector.sector_code} sector={sector} rank={index + 1} />
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-2xl bg-surface-2/60 p-8 text-center text-sm text-text-muted">暂无主线板块</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-2xl border border-border-subtle bg-surface-1/55 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-sm font-semibold text-text-primary">重点标的</div>
|
||||||
|
<a href="/recommendations" className="text-[11px] text-text-muted hover:text-text-secondary">进入推荐池</a>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2.5 md:grid-cols-2">
|
||||||
|
{focusQueue.length ? focusQueue.map((rec) => (
|
||||||
|
<LargeFocusStockCard key={rec.ts_code} rec={rec} />
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-2xl bg-surface-2/60 p-8 text-center text-sm text-text-muted md:col-span-2">暂无重点标的</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompactMarketEvidence({
|
||||||
marketTemperature,
|
marketTemperature,
|
||||||
indices,
|
indices,
|
||||||
sectors,
|
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
marketTemperature: MarketTemperatureData | null;
|
marketTemperature: MarketTemperatureData | null;
|
||||||
indices: IndexOverview[];
|
indices: IndexOverview[];
|
||||||
sectors: SectorData[];
|
|
||||||
summary: ReturnType<typeof buildMarketSummary>;
|
summary: ReturnType<typeof buildMarketSummary>;
|
||||||
}) {
|
}) {
|
||||||
const leadingSectors = sectors.slice(0, 4);
|
|
||||||
const majorIndices = indices.slice(0, 3);
|
const majorIndices = indices.slice(0, 3);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-4 md:p-5">
|
<div className="glass-card-static p-4">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-text-primary">盘面依据</h3>
|
<h3 className="text-sm font-semibold text-text-primary">盘面依据</h3>
|
||||||
|
<p className="mt-1 text-xs text-text-muted">用于校验仓位和风险,不作为主视图。</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
|
<span className="rounded-xl bg-surface-1/70 px-3 py-1.5 text-xs font-mono tabular-nums text-text-secondary">
|
||||||
温度 {Math.round(marketTemperature?.temperature ?? 0)}
|
温度 {Math.round(marketTemperature?.temperature ?? 0)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||||
<EvidenceStat label="上涨" value={marketTemperature?.up_count ?? 0} tone="text-red-400" />
|
<EvidenceStat label="上涨" value={marketTemperature?.up_count ?? 0} tone="text-red-400" />
|
||||||
<EvidenceStat label="下跌" value={marketTemperature?.down_count ?? 0} tone="text-emerald-400" />
|
<EvidenceStat label="下跌" value={marketTemperature?.down_count ?? 0} tone="text-emerald-400" />
|
||||||
<EvidenceStat label="涨停" value={marketTemperature?.limit_up_count ?? 0} tone="text-amber-400" />
|
<EvidenceStat label="涨停" value={marketTemperature?.limit_up_count ?? 0} tone="text-amber-400" />
|
||||||
@ -392,49 +456,19 @@ function MarketSnapshot({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{majorIndices.length ? (
|
{majorIndices.length ? (
|
||||||
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-2">
|
<div className="mt-4 space-y-2">
|
||||||
{majorIndices.map((item) => (
|
{majorIndices.map((item) => (
|
||||||
<div key={item.code} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
<div key={item.code} className="flex items-center justify-between gap-3 rounded-xl bg-surface-1/70 px-3 py-2">
|
||||||
<div className="text-xs font-semibold text-text-primary">{item.name}</div>
|
<span className="text-xs font-semibold text-text-primary">{item.name}</span>
|
||||||
<div className="mt-1 flex items-center justify-between gap-3">
|
|
||||||
<span className="text-xs font-mono tabular-nums text-text-secondary">{item.close.toFixed(2)}</span>
|
|
||||||
<span className={`text-xs font-mono tabular-nums ${item.pct_chg >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
<span className={`text-xs font-mono tabular-nums ${item.pct_chg >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
{item.pct_chg >= 0 ? "+" : ""}{item.pct_chg.toFixed(2)}%
|
{item.pct_chg >= 0 ? "+" : ""}{item.pct_chg.toFixed(2)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{leadingSectors.length ? (
|
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-xs leading-5 text-text-secondary">
|
||||||
<div className="mt-4">
|
|
||||||
<div className="flex items-center justify-between border-b border-border-subtle pb-2">
|
|
||||||
<div className="text-[11px] font-semibold text-text-secondary">今日盯住的板块</div>
|
|
||||||
<div className="text-[10px] text-text-muted">主线优先</div>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-border-subtle">
|
|
||||||
{leadingSectors.map((sector) => {
|
|
||||||
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
|
||||||
return (
|
|
||||||
<div key={sector.sector_code} className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 text-sm">
|
|
||||||
<div className="min-w-0 overflow-hidden">
|
|
||||||
<div className="truncate text-[13px] font-medium text-text-primary">{sector.sector_name}</div>
|
|
||||||
<div className="mt-0.5 truncate text-[11px] text-text-muted">
|
|
||||||
{sector.limit_up_count > 0 ? `${sector.limit_up_count} 涨停` : "无涨停"} · {sector.stage || "mid"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className={`min-w-[4.5rem] text-right font-mono text-xs tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
|
||||||
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-sm text-text-secondary">
|
|
||||||
结论:{summary.headline}
|
结论:{summary.headline}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -564,6 +598,38 @@ function SectorChip({ sector }: { sector: SectorData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PrioritySectorCard({ sector, rank }: { sector: SectorData; rank: number }) {
|
||||||
|
const pct = sector.realtime_pct_change ?? sector.pct_change;
|
||||||
|
const leaders = (sector.leading_stocks_realtime ?? sector.leading_stocks ?? []).slice(0, 3);
|
||||||
|
return (
|
||||||
|
<a href="/sectors" className="group block rounded-2xl bg-surface-2/35 px-3 py-3 transition-colors hover:bg-surface-2/65">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 font-mono text-[11px] text-amber-400">
|
||||||
|
{rank}
|
||||||
|
</span>
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{sector.sector_name}</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
<span className="rounded-md bg-bg-primary/45 px-2 py-0.5 text-[10px] text-text-muted">{sector.stage || "mid"}</span>
|
||||||
|
<span className="rounded-md bg-bg-primary/45 px-2 py-0.5 text-[10px] text-text-muted">{sector.limit_up_count || 0} 涨停</span>
|
||||||
|
<span className="rounded-md bg-bg-primary/45 px-2 py-0.5 text-[10px] text-text-muted">{sector.member_count || 0} 成员</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={`shrink-0 font-mono text-sm font-semibold tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
|
||||||
|
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{leaders.length ? (
|
||||||
|
<div className="mt-2 truncate text-[11px] text-text-secondary">
|
||||||
|
前排:{leaders.map((item) => item.name).join("、")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||||
const stripeClass =
|
const stripeClass =
|
||||||
rec.action_plan === "可操作"
|
rec.action_plan === "可操作"
|
||||||
@ -619,6 +685,46 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
|
||||||
|
const isActionable = rec.action_plan === "可操作";
|
||||||
|
const badgeClass = isActionable
|
||||||
|
? "border-red-500/15 bg-red-500/[0.08] text-red-400"
|
||||||
|
: rec.action_plan === "重点关注"
|
||||||
|
? "border-amber-500/15 bg-amber-500/[0.08] text-amber-400"
|
||||||
|
: "border-border-subtle bg-surface-2 text-text-muted";
|
||||||
|
return (
|
||||||
|
<a href={`/stock/${rec.ts_code}`} className="group rounded-2xl bg-surface-2/35 p-3 transition-colors hover:bg-surface-2/65">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-sm font-semibold text-text-primary">{rec.name}</div>
|
||||||
|
<div className="mt-1 font-mono text-[11px] text-text-muted">{rec.ts_code}</div>
|
||||||
|
</div>
|
||||||
|
<span className={`shrink-0 rounded-lg border px-2 py-1 text-[10px] font-medium ${badgeClass}`}>
|
||||||
|
{rec.action_plan ?? "观察"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid grid-cols-3 gap-2">
|
||||||
|
<TinyInfo label="评分" value={Math.round(rec.score ?? 0)} />
|
||||||
|
<TinyInfo label="仓位" value={rec.suggested_position_pct != null ? `${rec.suggested_position_pct}%` : "-"} />
|
||||||
|
<TinyInfo label="入场" value={rec.entry_signal_type || "等待"} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 line-clamp-2 text-xs leading-5 text-text-secondary">
|
||||||
|
{rec.decision_trace?.headline ?? rec.trigger_condition ?? rec.entry_timing ?? rec.reasons?.[0] ?? "等待新的触发条件。"}
|
||||||
|
</div>
|
||||||
|
{rec.sector ? <div className="mt-2 truncate text-[11px] text-text-muted">{rec.sector}</div> : null}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TinyInfo({ label, value }: { label: string; value: string | number }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">
|
||||||
|
<div className="text-[9px] text-text-muted">{label}</div>
|
||||||
|
<div className="mt-0.5 truncate font-mono text-[11px] text-text-secondary">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) {
|
function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||||||
|
|||||||
@ -1,47 +1,37 @@
|
|||||||
import { AuthGuard } from "@/components/auth-guard";
|
import { AuthGuard } from "@/components/auth-guard";
|
||||||
import { UserMenu } from "@/components/user-menu";
|
import { UserMenu } from "@/components/user-menu";
|
||||||
import { SidebarNav, MobileBottomNav } from "@/components/nav";
|
import { SidebarNav } from "@/components/nav";
|
||||||
|
|
||||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
{/* Desktop: sidebar + main */}
|
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
{/* Desktop sidebar */}
|
<aside className="fixed inset-y-0 left-0 z-40 flex w-48 sm:w-60 flex-col glass-sidebar">
|
||||||
<aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40">
|
<div className="px-4 sm:px-6 pt-5 sm:pt-7 pb-4 sm:pb-5">
|
||||||
{/* Brand */}
|
|
||||||
<div className="px-6 pt-7 pb-5">
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 flex items-center justify-center text-sm font-bold text-white shadow-glow-sm">
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-amber-500 to-amber-600 text-sm font-bold text-white shadow-glow-sm">
|
||||||
A
|
A
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<h1 className="text-sm font-semibold tracking-tight">AlphaX Agent</h1>
|
<h1 className="text-sm font-semibold tracking-tight">AlphaX Agent</h1>
|
||||||
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A 股投研作战台</p>
|
<p className="text-xs text-text-muted mt-0.5 font-light tracking-wide">A 股投研作战台</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
|
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
|
||||||
|
|
||||||
{/* Nav */}
|
|
||||||
<SidebarNav />
|
<SidebarNav />
|
||||||
|
|
||||||
{/* Footer */}
|
<div className="px-4 sm:px-6 py-4 sm:py-5 border-t border-border-subtle">
|
||||||
<div className="px-6 py-5 border-t border-border-subtle">
|
|
||||||
<UserMenu />
|
<UserMenu />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main content area */}
|
<main className="min-h-screen flex-1 ml-48 sm:ml-60 pb-10">
|
||||||
<main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen">
|
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile bottom nav */}
|
|
||||||
<MobileBottomNav />
|
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const STAGE_ORDER = [
|
|||||||
|
|
||||||
export default function OpsLogsPage() {
|
export default function OpsLogsPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [tab, setTab] = useState<OpsTab>("funnel");
|
const [tab, setTab] = useState<OpsTab>("errors");
|
||||||
const [days, setDays] = useState(7);
|
const [days, setDays] = useState(7);
|
||||||
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
||||||
const [selectedSession, setSelectedSession] = useState("");
|
const [selectedSession, setSelectedSession] = useState("");
|
||||||
@ -43,8 +43,11 @@ export default function OpsLogsPage() {
|
|||||||
const [errorsTotal, setErrorsTotal] = useState(0);
|
const [errorsTotal, setErrorsTotal] = useState(0);
|
||||||
const [sources, setSources] = useState<string[]>([]);
|
const [sources, setSources] = useState<string[]>([]);
|
||||||
const [levels, setLevels] = useState<string[]>([]);
|
const [levels, setLevels] = useState<string[]>([]);
|
||||||
|
const [sourceCounts, setSourceCounts] = useState<Record<string, number>>({});
|
||||||
|
const [levelCounts, setLevelCounts] = useState<Record<string, number>>({});
|
||||||
const [source, setSource] = useState("");
|
const [source, setSource] = useState("");
|
||||||
const [level, setLevel] = useState("");
|
const [level, setLevel] = useState("");
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
const [errorsLoading, setErrorsLoading] = useState(false);
|
const [errorsLoading, setErrorsLoading] = useState(false);
|
||||||
const [expandedErrorId, setExpandedErrorId] = useState<number | null>(null);
|
const [expandedErrorId, setExpandedErrorId] = useState<number | null>(null);
|
||||||
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
const [systemStatus, setSystemStatus] = useState<SystemStatus | null>(null);
|
||||||
@ -57,6 +60,7 @@ export default function OpsLogsPage() {
|
|||||||
);
|
);
|
||||||
const maxCount = Math.max(...sortedLogs.map((item) => Math.max(item.input_count, item.output_count)), 1);
|
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 finalLog = sortedLogs.find((item) => item.stage === "final_filter");
|
||||||
|
const latestError = errors[0];
|
||||||
|
|
||||||
const fetchScanData = useCallback(async (session?: string) => {
|
const fetchScanData = useCallback(async (session?: string) => {
|
||||||
setScanLoading(true);
|
setScanLoading(true);
|
||||||
@ -82,17 +86,21 @@ export default function OpsLogsPage() {
|
|||||||
const fetchErrors = useCallback(async () => {
|
const fetchErrors = useCallback(async () => {
|
||||||
setErrorsLoading(true);
|
setErrorsLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await getErrorLogsAPI(80, source, level, days);
|
const result = await getErrorLogsAPI(120, source, level, days, query.trim());
|
||||||
setErrors(result.errors);
|
setErrors(result.errors);
|
||||||
setErrorsTotal(result.total);
|
setErrorsTotal(result.total);
|
||||||
setSources(result.sources);
|
setSources(result.sources);
|
||||||
setLevels(result.levels);
|
setLevels(result.levels);
|
||||||
|
setSourceCounts(result.source_counts || {});
|
||||||
|
setLevelCounts(result.level_counts || {});
|
||||||
} catch {
|
} catch {
|
||||||
setErrors([]);
|
setErrors([]);
|
||||||
|
setSourceCounts({});
|
||||||
|
setLevelCounts({});
|
||||||
} finally {
|
} finally {
|
||||||
setErrorsLoading(false);
|
setErrorsLoading(false);
|
||||||
}
|
}
|
||||||
}, [days, level, source]);
|
}, [days, level, query, source]);
|
||||||
|
|
||||||
const fetchSystemStatus = useCallback(async () => {
|
const fetchSystemStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -151,7 +159,7 @@ export default function OpsLogsPage() {
|
|||||||
<div className="text-[10px] font-semibold uppercase tracking-[0.22em] text-text-muted">Ops Center</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>
|
<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 className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||||
最新筛选批次、漏斗关口和异常记录集中在这里。
|
系统错误、接口异常、数据源失败和扫描漏斗集中在这里,方便快速定位异常来源。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@ -177,8 +185,8 @@ export default function OpsLogsPage() {
|
|||||||
|
|
||||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||||
{[
|
{[
|
||||||
{ key: "funnel", label: "筛选漏斗" },
|
|
||||||
{ key: "errors", label: "系统错误" },
|
{ key: "errors", label: "系统错误" },
|
||||||
|
{ key: "funnel", label: "筛选漏斗" },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.key}
|
key={item.key}
|
||||||
@ -305,13 +313,29 @@ export default function OpsLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<SummaryCard label="错误总数" value={errorsTotal} sub={`最近 ${days} 天`} tone={errorsTotal > 0 ? "danger" : "muted"} />
|
||||||
|
<SummaryCard label="严重错误" value={levelCounts.critical ?? 0} sub="critical 级别" tone={(levelCounts.critical ?? 0) > 0 ? "danger" : "muted"} />
|
||||||
|
<SummaryCard label="异常来源" value={Object.keys(sourceCounts).length} sub={topSourceLabel(sourceCounts)} tone="warning" />
|
||||||
|
<SummaryCard label="最近一次" value={latestError ? formatDateTime(latestError.created_at) : "-"} sub={latestError?.source || "暂无错误"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="glass-card-static p-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 className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
||||||
<p className="mt-1 text-xs text-text-muted">这里保留接口、数据源和后台任务的异常,便于追踪不稳定数据源。</p>
|
<p className="mt-1 text-xs text-text-muted">自动记录应用内 ERROR/CRITICAL 日志,并保留手动上报的数据源和后台任务异常。</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "Enter") fetchErrors();
|
||||||
|
}}
|
||||||
|
placeholder="搜索消息、详情或来源"
|
||||||
|
className="w-full rounded-lg border border-border-default bg-surface-2 px-3 py-1.5 text-xs text-text-primary outline-none focus:ring-1 focus:ring-amber-500/30 md:w-48"
|
||||||
|
/>
|
||||||
<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">
|
<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>
|
<option value="">全部来源</option>
|
||||||
{sources.map((item) => <option key={item} value={item}>{item}</option>)}
|
{sources.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
@ -323,6 +347,16 @@ export default function OpsLogsPage() {
|
|||||||
<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 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>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setSource("");
|
||||||
|
setLevel("");
|
||||||
|
}}
|
||||||
|
className="rounded-lg border border-border-subtle bg-surface-2 px-3 py-1.5 text-xs text-text-muted hover:bg-surface-4 hover:text-text-secondary"
|
||||||
|
>
|
||||||
|
重置
|
||||||
|
</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">
|
<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天前
|
清除30天前
|
||||||
</button>
|
</button>
|
||||||
@ -330,10 +364,16 @@ export default function OpsLogsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr]">
|
||||||
|
<aside className="space-y-4">
|
||||||
|
<ErrorBreakdown title="来源分布" items={sourceCounts} active={source} onSelect={setSource} />
|
||||||
|
<ErrorBreakdown title="级别分布" items={levelCounts} active={level} onSelect={setLevel} labeler={statusLabel} />
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div className="glass-card-static overflow-hidden">
|
<div className="glass-card-static overflow-hidden">
|
||||||
<div className="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
<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-xs font-semibold text-text-primary">错误记录</span>
|
||||||
<span className="text-[10px] text-text-muted">{errorsTotal} 条</span>
|
<span className="text-[10px] text-text-muted">显示 {errors.length} / {errorsTotal} 条</span>
|
||||||
</div>
|
</div>
|
||||||
{errorsLoading ? (
|
{errorsLoading ? (
|
||||||
<div className="space-y-2 p-4">
|
<div className="space-y-2 p-4">
|
||||||
@ -344,31 +384,105 @@ export default function OpsLogsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border-subtle">
|
<div className="divide-y divide-border-subtle">
|
||||||
{errors.map((item) => (
|
{errors.map((item) => (
|
||||||
<div key={item.id} className="px-4 py-3">
|
<ErrorRow
|
||||||
<button className="w-full text-left" onClick={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}>
|
key={item.id}
|
||||||
<div className="grid gap-2 md:grid-cols-[88px_120px_1fr_140px] md:items-center">
|
item={item}
|
||||||
<StatusPill status={item.level} />
|
expanded={expandedErrorId === item.id}
|
||||||
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
onToggle={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ErrorBreakdown({
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
active,
|
||||||
|
onSelect,
|
||||||
|
labeler,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
items: Record<string, number>;
|
||||||
|
active: string;
|
||||||
|
onSelect: (value: string) => void;
|
||||||
|
labeler?: (value: string) => string;
|
||||||
|
}) {
|
||||||
|
const rows = Object.entries(items).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
||||||
|
const max = Math.max(...rows.map(([, count]) => count), 1);
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold text-text-primary">{title}</h3>
|
||||||
|
{active ? (
|
||||||
|
<button onClick={() => onSelect("")} className="text-[10px] text-text-muted hover:text-text-secondary">
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1/70 p-5 text-xs text-text-muted">暂无分布</div>
|
||||||
|
) : rows.map(([key, count]) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
onClick={() => onSelect(active === key ? "" : key)}
|
||||||
|
className={`w-full rounded-xl border px-3 py-2 text-left transition-all ${
|
||||||
|
active === key
|
||||||
|
? "border-amber-500/20 bg-amber-500/[0.07]"
|
||||||
|
: "border-border-subtle bg-surface-1/70 hover:bg-surface-2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="truncate text-xs text-text-secondary">{labeler ? labeler(key) : key}</span>
|
||||||
|
<span className="font-mono text-xs tabular-nums text-text-primary">{count}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-bg-primary/60">
|
||||||
|
<div className="h-full rounded-full bg-amber-400/70" style={{ width: `${Math.max(6, Math.round((count / max) * 100))}%` }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorRow({ item, expanded, onToggle }: { item: ErrorLog; expanded: boolean; onToggle: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<button className="w-full text-left" onClick={onToggle}>
|
||||||
|
<div className="grid gap-2 md:grid-cols-[88px_160px_1fr_140px] md:items-center">
|
||||||
|
<StatusPill status={item.level} />
|
||||||
|
<span className="truncate font-mono 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>
|
||||||
|
{expanded ? (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-wider text-text-muted">Message</div>
|
||||||
|
<div className="mt-2 break-words text-sm leading-6 text-text-secondary">{item.message}</div>
|
||||||
|
</div>
|
||||||
|
{item.detail ? (
|
||||||
|
<pre className="max-h-80 overflow-auto rounded-xl border border-border-subtle bg-bg-primary/70 p-3 text-xs leading-6 text-text-muted whitespace-pre-wrap">
|
||||||
|
{item.detail}
|
||||||
|
</pre>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-border-subtle bg-surface-1 p-3 text-xs text-text-muted">没有堆栈详情</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FunnelStage({ log, index, maxCount }: { log: ScanProcessLog; index: number; maxCount: number }) {
|
function FunnelStage({ log, index, maxCount }: { log: ScanProcessLog; index: number; maxCount: number }) {
|
||||||
const outputWidth = Math.max(4, Math.round((log.output_count / maxCount) * 100));
|
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 dropWidth = Math.max(0, Math.round((log.filtered_count / maxCount) * 100));
|
||||||
@ -565,3 +679,8 @@ function formatDateTime(value: string) {
|
|||||||
if (Number.isNaN(date.getTime())) return value.slice(0, 16);
|
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" });
|
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function topSourceLabel(counts: Record<string, number>) {
|
||||||
|
const [source, count] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0] || [];
|
||||||
|
return source ? `${source} ${count} 条` : "暂无来源";
|
||||||
|
}
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { fetchAPI } from "@/lib/api";
|
import { fetchAPI, postAPI } from "@/lib/api";
|
||||||
import type {
|
import type {
|
||||||
DayGroup,
|
DayGroup,
|
||||||
LatestResult,
|
LatestResult,
|
||||||
OpsStatusResponse,
|
OpsStatusResponse,
|
||||||
|
PerformanceStats,
|
||||||
RecommendationData,
|
RecommendationData,
|
||||||
|
TrackedRecommendation,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
@ -44,18 +46,22 @@ export default function RecommendationsPage() {
|
|||||||
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
||||||
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
||||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||||
|
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||||
|
const [trackingUpdating, setTrackingUpdating] = useState(false);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [history, latestResult, ops] = await Promise.all([
|
const [history, latestResult, ops, perf] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||||||
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
||||||
|
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setDayGroups(history);
|
setDayGroups(history);
|
||||||
setLatest(latestResult);
|
setLatest(latestResult);
|
||||||
setOpsStatus(ops);
|
setOpsStatus(ops);
|
||||||
|
setPerformance(perf);
|
||||||
|
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
if (prev.size || history.length === 0) return prev;
|
if (prev.size || history.length === 0) return prev;
|
||||||
@ -70,6 +76,16 @@ export default function RecommendationsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
|
const updateTracking = async () => {
|
||||||
|
setTrackingUpdating(true);
|
||||||
|
try {
|
||||||
|
await postAPI("/api/recommendations/update-tracking");
|
||||||
|
await loadData();
|
||||||
|
} finally {
|
||||||
|
setTrackingUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const toggleDay = (date: string) => {
|
const toggleDay = (date: string) => {
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@ -214,6 +230,13 @@ export default function RecommendationsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<PerformanceReviewCard
|
||||||
|
performance={performance}
|
||||||
|
canUpdate={user?.role === "admin"}
|
||||||
|
updating={trackingUpdating}
|
||||||
|
onUpdate={updateTracking}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@ -393,6 +416,184 @@ function buildFocusSummary({
|
|||||||
return { headline, detail, now, later };
|
return { headline, detail, now, later };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PerformanceReviewCard({
|
||||||
|
performance,
|
||||||
|
canUpdate,
|
||||||
|
updating,
|
||||||
|
onUpdate,
|
||||||
|
}: {
|
||||||
|
performance: PerformanceStats | null;
|
||||||
|
canUpdate: boolean;
|
||||||
|
updating: boolean;
|
||||||
|
onUpdate: () => void;
|
||||||
|
}) {
|
||||||
|
const details = performance?.details ?? [];
|
||||||
|
const topRoutes = (performance?.route_breakdown ?? []).slice(0, 4);
|
||||||
|
const conclusion = buildPerformanceConclusion(performance);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-4 md:p-5 animate-fade-in-up">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.22em] text-amber-400 font-semibold">推荐复盘</div>
|
||||||
|
<h2 className="mt-2 text-base font-bold tracking-tight text-text-primary">{conclusion.headline}</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm leading-6 text-text-secondary">{conclusion.detail}</p>
|
||||||
|
</div>
|
||||||
|
{canUpdate ? (
|
||||||
|
<button
|
||||||
|
onClick={onUpdate}
|
||||||
|
disabled={updating}
|
||||||
|
className="shrink-0 rounded-xl border border-amber-500/20 bg-amber-500/[0.06] px-3 py-2 text-xs font-semibold text-amber-400 transition-colors hover:bg-amber-500/[0.1] disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{updating ? "更新中" : "更新跟踪"}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-2 lg:grid-cols-5 gap-2">
|
||||||
|
<SummaryMetric label="已跟踪" value={performance?.tracked ?? 0} tone="text-text-primary" />
|
||||||
|
<SummaryMetric label="胜率" value={performance?.win_rate ?? 0} tone="text-amber-400" suffix="%" />
|
||||||
|
<SummaryMetric
|
||||||
|
label="平均收益"
|
||||||
|
value={performance?.avg_return ?? 0}
|
||||||
|
tone={(performance?.avg_return ?? 0) >= 0 ? "text-red-400" : "text-emerald-400"}
|
||||||
|
suffix="%"
|
||||||
|
signed
|
||||||
|
/>
|
||||||
|
<SummaryMetric label="平均浮盈" value={performance?.avg_max_return ?? 0} tone="text-red-400" suffix="%" signed />
|
||||||
|
<SummaryMetric label="平均回撤" value={performance?.avg_max_drawdown ?? 0} tone="text-amber-400" suffix="%" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 xl:grid-cols-[320px_minmax(0,1fr)] gap-3">
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary">有效路线</div>
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{topRoutes.length ? topRoutes.map((route) => (
|
||||||
|
<div key={route.route} className="flex items-center justify-between gap-3 rounded-xl bg-surface-2/70 px-3 py-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-xs font-medium text-text-primary">{formatRouteLabel(route.route)}</div>
|
||||||
|
<div className="mt-0.5 text-[11px] text-text-muted">{route.count} 个样本</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right font-mono text-xs tabular-nums">
|
||||||
|
<div className="text-amber-400">{route.win_rate}%</div>
|
||||||
|
<div className={route.avg_return >= 0 ? "text-red-400" : "text-emerald-400"}>
|
||||||
|
{route.avg_return > 0 ? "+" : ""}{route.avg_return}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : (
|
||||||
|
<div className="rounded-xl bg-surface-2/70 px-3 py-6 text-center text-xs text-text-muted">暂无路线样本。</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="text-[11px] font-semibold text-text-secondary">最近复盘样本</div>
|
||||||
|
<div className="text-[11px] text-text-muted">按推荐时间倒序</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 overflow-x-auto">
|
||||||
|
{details.length ? (
|
||||||
|
<table className="w-full min-w-[620px] border-separate border-spacing-y-2 text-left text-xs">
|
||||||
|
<thead className="text-[10px] uppercase tracking-wider text-text-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 font-medium">标的</th>
|
||||||
|
<th className="px-3 py-2 font-medium">推荐日</th>
|
||||||
|
<th className="px-3 py-2 font-medium">现价/入场</th>
|
||||||
|
<th className="px-3 py-2 font-medium">涨跌</th>
|
||||||
|
<th className="px-3 py-2 font-medium">过程</th>
|
||||||
|
<th className="px-3 py-2 font-medium">结论</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.slice(0, 8).map((item) => (
|
||||||
|
<ReviewRow key={`${item.ts_code}-${item.created_at}-${item.track_date}`} item={item} />
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl bg-surface-2/70 px-3 py-8 text-center text-sm text-text-muted">
|
||||||
|
暂无跟踪记录。先执行一次跟踪更新后再看复盘。
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewRow({ item }: { item: TrackedRecommendation }) {
|
||||||
|
const pct = item.pct_from_entry ?? 0;
|
||||||
|
const pctTone = pct >= 0 ? "text-red-400" : "text-emerald-400";
|
||||||
|
const cellClass = "bg-surface-2/30 px-3 py-3 transition-colors group-hover:bg-surface-2/55";
|
||||||
|
return (
|
||||||
|
<tr className="group">
|
||||||
|
<td className={`${cellClass} rounded-l-xl`}>
|
||||||
|
<div className="font-medium text-text-primary">{item.name}</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[11px] text-text-muted">{item.ts_code}</div>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono text-text-muted`}>{item.created_at || "-"}</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums text-text-secondary`}>
|
||||||
|
{formatNumber(item.current_price)} / {formatNumber(item.entry_price)}
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums ${pctTone}`}>{pct > 0 ? "+" : ""}{pct}%</td>
|
||||||
|
<td className={`${cellClass} font-mono tabular-nums text-text-secondary`}>
|
||||||
|
<span className="text-red-400">{formatSigned(item.max_return_pct)}</span>
|
||||||
|
<span className="mx-1 text-text-muted">/</span>
|
||||||
|
<span className="text-amber-400">{formatSigned(item.max_drawdown_pct)}</span>
|
||||||
|
</td>
|
||||||
|
<td className={`${cellClass} rounded-r-xl text-text-secondary`}>{item.review_note || formatCloseReason(item.close_reason)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPerformanceConclusion(performance: PerformanceStats | null) {
|
||||||
|
if (!performance || performance.tracked === 0) {
|
||||||
|
return {
|
||||||
|
headline: "等待形成有效复盘样本",
|
||||||
|
detail: "当前还没有可统计的推荐跟踪记录。更新跟踪后,会按推荐后的价格表现判断是否兑现预期。",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const avg = performance.avg_return ?? 0;
|
||||||
|
const direction = avg > 0 ? "整体兑现为正" : avg < 0 ? "整体仍在回撤" : "整体接近持平";
|
||||||
|
return {
|
||||||
|
headline: `${performance.tracked} 个样本,胜率 ${performance.win_rate}%`,
|
||||||
|
detail: `${direction},平均收益 ${avg > 0 ? "+" : ""}${avg.toFixed(2)}%,平均最大浮盈 ${performance.avg_max_return.toFixed(2)}%,平均最大回撤 ${performance.avg_max_drawdown.toFixed(2)}%。`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRouteLabel(route: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
hot_theme_core: "主线核心",
|
||||||
|
theme_leader: "板块龙头",
|
||||||
|
top_theme_member: "强主题成员",
|
||||||
|
sector_recall: "板块召回",
|
||||||
|
};
|
||||||
|
return labels[route] ?? route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCloseReason(reason?: string) {
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
hit_target: "命中目标",
|
||||||
|
hit_stop_loss: "触发止损",
|
||||||
|
review_expired_profit: "到期盈利",
|
||||||
|
review_expired_loss: "到期亏损",
|
||||||
|
review_expired_flat: "到期震荡",
|
||||||
|
};
|
||||||
|
return labels[reason ?? ""] ?? "跟踪中";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value: number | null | undefined) {
|
||||||
|
return value == null ? "-" : Number(value).toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSigned(value: number | null | undefined) {
|
||||||
|
if (value == null) return "-";
|
||||||
|
return `${value > 0 ? "+" : ""}${Number(value).toFixed(2)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
function KeyList({ title, items }: { title: string; items: string[] }) {
|
function KeyList({ title, items }: { title: string; items: string[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -140,7 +140,7 @@ export default function StrategyPage() {
|
|||||||
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
<MetricCard label="复盘样本" value={iteration?.sample_size ?? 0} />
|
||||||
<MetricCard label="整体胜率" value={`${safeWinRate.toFixed(1)}%`} tone={safeWinRate >= 50 ? "up" : "down"} />
|
<MetricCard label="整体胜率" value={`${safeWinRate.toFixed(1)}%`} tone={safeWinRate >= 50 ? "up" : "down"} />
|
||||||
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
<MetricCard label="平均收益" value={`${(performance?.avg_return ?? 0) > 0 ? "+" : ""}${(performance?.avg_return ?? 0).toFixed(2)}%`} tone={(performance?.avg_return ?? 0) >= 0 ? "up" : "down"} />
|
||||||
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="down" />
|
<MetricCard label="平均回撤" value={`${(performance?.avg_max_drawdown ?? 0).toFixed(2)}%`} tone="risk" />
|
||||||
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0} 只`} />
|
<MetricFact label="已跟踪" value={`${performance?.tracked ?? 0} 只`} />
|
||||||
<MetricFact label="页面角色" value="方法迭代" />
|
<MetricFact label="页面角色" value="方法迭代" />
|
||||||
</div>
|
</div>
|
||||||
@ -550,9 +550,9 @@ function MetricCard({
|
|||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
tone?: "up" | "down";
|
tone?: "up" | "down" | "risk";
|
||||||
}) {
|
}) {
|
||||||
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : "text-text-primary";
|
const color = tone === "up" ? "text-red-400" : tone === "down" ? "text-emerald-400" : tone === "risk" ? "text-amber-400" : "text-text-primary";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card-static p-4">
|
<div className="glass-card-static p-4">
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
|
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
|
||||||
@ -28,7 +28,15 @@ export default function LoginPage() {
|
|||||||
const [sendingCode, setSendingCode] = useState(false);
|
const [sendingCode, setSendingCode] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
|
|
||||||
useMemo(() => {
|
useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const invitedCode = params.get("invite") || params.get("code");
|
||||||
|
if (!invitedCode) return;
|
||||||
|
setTab("register");
|
||||||
|
setInviteCode(invitedCode.trim().toUpperCase());
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (!cooldown) return;
|
if (!cooldown) return;
|
||||||
const timer = window.setTimeout(() => setCooldown((v) => Math.max(v - 1, 0)), 1000);
|
const timer = window.setTimeout(() => setCooldown((v) => Math.max(v - 1, 0)), 1000);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
|
|||||||
@ -141,14 +141,14 @@ function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNod
|
|||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className={`flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm transition-all duration-200 ${
|
className={`flex min-w-0 items-center gap-2.5 sm:gap-3 rounded-xl px-3 sm:px-4 py-2.5 text-sm transition-all duration-200 ${
|
||||||
isActive
|
isActive
|
||||||
? "text-text-primary bg-surface-4"
|
? "text-text-primary bg-surface-4"
|
||||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-3"
|
: "text-text-secondary hover:text-text-primary hover:bg-surface-3"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-base opacity-70">{icon}</span>
|
<span className="shrink-0 text-base opacity-70">{icon}</span>
|
||||||
<span className="font-medium">{label}</span>
|
<span className="min-w-0 truncate font-medium">{label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -157,7 +157,7 @@ export function SidebarNav() {
|
|||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex-1 py-5 px-3 space-y-1">
|
<nav className="flex-1 overflow-y-auto px-2 sm:px-3 py-4 sm:py-5 space-y-1">
|
||||||
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
<SideNavItem href="/dashboard" icon={<DashboardIcon />} label="今日作战" />
|
||||||
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
<SideNavItem href="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||||
@ -167,7 +167,7 @@ export function SidebarNav() {
|
|||||||
{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="/ops-logs" icon={<LogsIcon />} label="系统日志" />
|
||||||
<SideNavItem href="/data-health" icon={<HealthIcon />} label="数据源健康" />
|
<SideNavItem href="/data-health" icon={<HealthIcon />} label="数据源健康" />
|
||||||
<SideNavItem href="/tasks" icon={<TasksIcon />} label="任务中心" />
|
<SideNavItem href="/tasks" icon={<TasksIcon />} label="任务中心" />
|
||||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
||||||
@ -176,51 +176,3 @@ export function SidebarNav() {
|
|||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MobileNavItem({ href, label, children }: { href: string; label: string; children: React.ReactNode }) {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const isActive = pathname === href;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
href={href}
|
|
||||||
className={`flex flex-col items-center gap-1 transition-colors active:scale-95 ${
|
|
||||||
isActive ? "text-amber-400" : "text-text-muted hover:text-text-primary"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-lg">{children}</span>
|
|
||||||
<span className="text-xs font-medium">{label}</span>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MobileBottomNav() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="fixed bottom-0 left-0 right-0 md:hidden z-50 bg-bg-secondary/95 backdrop-blur-xl border-t border-border-subtle">
|
|
||||||
<div className="flex justify-around py-2 pb-[max(0.5rem,env(safe-area-inset-bottom))]">
|
|
||||||
<MobileNavItem href="/dashboard" label="作战">
|
|
||||||
<DashboardIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
<MobileNavItem href="/recommendations" label="推荐池">
|
|
||||||
<TargetIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
<MobileNavItem href="/chat" label="助手">
|
|
||||||
<ChatIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
<MobileNavItem href="/sentiment" label="舆情">
|
|
||||||
<RadarIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
<MobileNavItem href="/watchlists" label="自选">
|
|
||||||
<WatchlistIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
{user?.role === "admin" ? (
|
|
||||||
<MobileNavItem href="/strategy" label="校准">
|
|
||||||
<StrategyIcon />
|
|
||||||
</MobileNavItem>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -342,15 +342,15 @@ export interface PerformanceStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface TrackedRecommendation {
|
export interface TrackedRecommendation {
|
||||||
recommendation_id: number;
|
recommendation_id?: number;
|
||||||
ts_code: string;
|
ts_code: string;
|
||||||
name: string;
|
name: string;
|
||||||
entry_price: number;
|
entry_price: number | null;
|
||||||
current_price: number;
|
current_price: number | null;
|
||||||
pct_from_entry: number;
|
pct_from_entry: number | null;
|
||||||
hit_target: boolean;
|
hit_target: boolean;
|
||||||
hit_stop_loss: boolean;
|
hit_stop_loss: boolean;
|
||||||
status: string;
|
status?: string;
|
||||||
action_plan?: string;
|
action_plan?: string;
|
||||||
lifecycle_status?: string;
|
lifecycle_status?: string;
|
||||||
recall_tags?: string[];
|
recall_tags?: string[];
|
||||||
@ -361,6 +361,7 @@ export interface TrackedRecommendation {
|
|||||||
close_reason?: string;
|
close_reason?: string;
|
||||||
review_note?: string;
|
review_note?: string;
|
||||||
track_date: string;
|
track_date: string;
|
||||||
|
created_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Daily Review ----------
|
// ---------- Daily Review ----------
|
||||||
@ -845,6 +846,12 @@ export interface DataStats {
|
|||||||
tracking: number;
|
tracking: number;
|
||||||
sector_heat: number;
|
sector_heat: number;
|
||||||
market_temperature: number;
|
market_temperature: number;
|
||||||
|
stock_diagnoses?: number;
|
||||||
|
watchlist_analyses?: number;
|
||||||
|
users?: number;
|
||||||
|
invite_codes?: number;
|
||||||
|
error_logs?: number;
|
||||||
|
scan_logs?: number;
|
||||||
low_score_count: number;
|
low_score_count: number;
|
||||||
latest_date: string;
|
latest_date: string;
|
||||||
earliest_date: string;
|
earliest_date: string;
|
||||||
@ -884,6 +891,8 @@ export interface ErrorLogsResult {
|
|||||||
errors: ErrorLog[];
|
errors: ErrorLog[];
|
||||||
sources: string[];
|
sources: string[];
|
||||||
levels: string[];
|
levels: string[];
|
||||||
|
source_counts: Record<string, number>;
|
||||||
|
level_counts: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStatus {
|
export interface SystemStatus {
|
||||||
@ -1001,10 +1010,11 @@ export interface TaskCenterResult {
|
|||||||
generated_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, q?: string): 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);
|
||||||
if (level) params.set("level", level);
|
if (level) params.set("level", level);
|
||||||
|
if (q) params.set("q", q);
|
||||||
return fetchAPI<ErrorLogsResult>(`/api/debug/errors?${params}`);
|
return fetchAPI<ErrorLogsResult>(`/api/debug/errors?${params}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user