1
This commit is contained in:
parent
46a7bf1192
commit
d07af5508a
@ -3,8 +3,10 @@ ASTOCK_DEBUG=true
|
||||
|
||||
ASTOCK_DEEPSEEK_API_KEY=sk-ee8eee63d5cf41eba14a328de49055ac
|
||||
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_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_EMAIL=75981230@qq.com
|
||||
ASTOCK_ADMIN_PASSWORD=880803
|
||||
|
||||
@ -9,3 +9,8 @@ ASTOCK_SMTP_PORT=465
|
||||
ASTOCK_SMTP_USERNAME=noreply@example.com
|
||||
ASTOCK_SMTP_PASSWORD=your_smtp_password
|
||||
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
|
||||
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
|
||||
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
|
||||
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 ""
|
||||
@ -437,6 +443,12 @@ async def get_data_stats(admin: dict = Depends(get_current_admin)):
|
||||
"tracking": track_count,
|
||||
"sector_heat": sector_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,
|
||||
"latest_date": str(latest_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] = {}
|
||||
async with get_db() as db:
|
||||
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}"))
|
||||
deleted[table] = result.rowcount or 0
|
||||
elif req.mode == "recommendations":
|
||||
for table in ["recommendation_tracking", "recommendations"]:
|
||||
result = await db.execute(text(f"DELETE FROM {table}"))
|
||||
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":
|
||||
if not req.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
|
||||
result = await db.execute(text("DELETE FROM market_temperature WHERE trade_date < :bd"), {"bd": req.before_date})
|
||||
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":
|
||||
threshold = req.min_score or 60
|
||||
result = await db.execute(
|
||||
|
||||
@ -18,6 +18,7 @@ async def get_errors(
|
||||
limit: int = 50,
|
||||
source: str = None,
|
||||
level: str = None,
|
||||
q: str = None,
|
||||
days: int = 7,
|
||||
_admin: dict = Depends(get_current_admin),
|
||||
):
|
||||
@ -33,6 +34,9 @@ async def get_errors(
|
||||
if level:
|
||||
conditions.append("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)
|
||||
|
||||
@ -76,11 +80,25 @@ async def get_errors(
|
||||
)
|
||||
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 {
|
||||
"total": total,
|
||||
"errors": errors,
|
||||
"sources": sources,
|
||||
"levels": levels,
|
||||
"source_counts": source_counts,
|
||||
"level_counts": level_counts,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -83,6 +83,12 @@ class Settings(BaseSettings):
|
||||
alert_app_name: str = "AStock Agent"
|
||||
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"
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -1,6 +1,7 @@
|
||||
"""错误日志持久化"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from app.db.database import get_db
|
||||
@ -67,3 +68,29 @@ def log_error_background(
|
||||
)
|
||||
except RuntimeError:
|
||||
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 操作,需要在主线程中执行)
|
||||
await _save_to_db(result)
|
||||
|
||||
# 推送本轮可操作/重点关注推荐,失败不影响扫描结果。
|
||||
await _push_recommendation_notifications(result, scan_session)
|
||||
|
||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||
await _update_tracking()
|
||||
|
||||
@ -116,6 +119,22 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
||||
_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():
|
||||
"""更新历史推荐的跟踪数据"""
|
||||
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_drawdown = round(float(avg_extremes[1]), 2) if avg_extremes and avg_extremes[1] is not None else 0
|
||||
|
||||
# 最近跟踪的推荐详情
|
||||
# 全量跟踪样本用于复盘统计,页面详情只取最近一批展示。
|
||||
result = await db.execute(
|
||||
text(
|
||||
"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 "
|
||||
" FROM recommendation_tracking GROUP BY recommendation_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():
|
||||
r = row._mapping
|
||||
details.append({
|
||||
all_details.append({
|
||||
"ts_code": r["ts_code"],
|
||||
"name": r["name"],
|
||||
"signal": r["signal"],
|
||||
@ -470,6 +489,7 @@ async def get_performance_stats() -> dict:
|
||||
|
||||
winning = min(winning, tracked)
|
||||
win_rate = round(winning / tracked * 100, 1) if tracked > 0 else 0
|
||||
details = all_details[:20]
|
||||
|
||||
return {
|
||||
"total_recommendations": total,
|
||||
@ -482,8 +502,8 @@ async def get_performance_stats() -> dict:
|
||||
"hit_target_count": hit_target_count,
|
||||
"hit_stop_count": hit_stop_count,
|
||||
"lifecycle_counts": lifecycle_counts,
|
||||
"route_breakdown": _build_route_breakdown(details),
|
||||
"prefilter_breakdown": _build_prefilter_breakdown(details),
|
||||
"route_breakdown": _build_route_breakdown(all_details),
|
||||
"prefilter_breakdown": _build_prefilter_breakdown(all_details),
|
||||
"details": details,
|
||||
}
|
||||
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 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.engine.scheduler import start_scheduler, stop_scheduler
|
||||
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():
|
||||
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()
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -106,7 +110,7 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("调度器已启动")
|
||||
yield
|
||||
except Exception as e:
|
||||
logger.exception("应用生命周期异常")
|
||||
logger.exception("应用生命周期异常", extra={"skip_error_persist": True})
|
||||
await log_error(
|
||||
"lifespan",
|
||||
f"应用生命周期异常: {e}",
|
||||
@ -152,7 +156,7 @@ app.websocket("/ws")(websocket.ws_endpoint)
|
||||
|
||||
@app.exception_handler(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 "")
|
||||
await log_error(
|
||||
"asgi",
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import httpx
|
||||
@ -91,3 +92,209 @@ async def send_feishu_alert(
|
||||
except Exception as e:
|
||||
logger.warning("Feishu 告警发送失败: %s", e)
|
||||
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": {
|
||||
"/layout": [
|
||||
"/(auth)/dashboard/page": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/css/app/layout.css",
|
||||
"static/chunks/app/layout.js"
|
||||
"static/chunks/app/(auth)/dashboard/page.js"
|
||||
],
|
||||
"/(auth)/layout": [
|
||||
"static/chunks/webpack.js",
|
||||
"static/chunks/main-app.js",
|
||||
"static/chunks/app/(auth)/layout.js"
|
||||
],
|
||||
"/(public)/layout": [
|
||||
"/layout": [
|
||||
"static/chunks/webpack.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/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/main-app.js",
|
||||
"static/chunks/app/(auth)/stock/[code]/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"
|
||||
"static/chunks/app/(auth)/strategy/page.js"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -2,9 +2,7 @@
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [
|
||||
"static/development/_buildManifest.js",
|
||||
@ -15,16 +13,7 @@
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"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)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
|
||||
"/(public)/page": "app/(public)/page.js"
|
||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||
"/(auth)/strategy/page": "app/(auth)/strategy/page.js"
|
||||
}
|
||||
@ -2,9 +2,7 @@ self.__BUILD_MANIFEST = {
|
||||
"polyfillFiles": [
|
||||
"static/chunks/polyfills.js"
|
||||
],
|
||||
"devFiles": [
|
||||
"static/chunks/react-refresh.js"
|
||||
],
|
||||
"devFiles": [],
|
||||
"ampDevFiles": [],
|
||||
"lowPriorityFiles": [],
|
||||
"rootMainFiles": [
|
||||
@ -12,16 +10,7 @@ self.__BUILD_MANIFEST = {
|
||||
"static/chunks/main-app.js"
|
||||
],
|
||||
"pages": {
|
||||
"/_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"
|
||||
]
|
||||
"/_app": []
|
||||
},
|
||||
"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": {},
|
||||
"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 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}
|
||||
indices={indices}
|
||||
sectors={sectors}
|
||||
summary={marketSummary}
|
||||
/>
|
||||
<AdminPanel
|
||||
@ -267,6 +273,7 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</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,
|
||||
indices,
|
||||
sectors,
|
||||
summary,
|
||||
}: {
|
||||
marketTemperature: MarketTemperatureData | null;
|
||||
indices: IndexOverview[];
|
||||
sectors: SectorData[];
|
||||
summary: ReturnType<typeof buildMarketSummary>;
|
||||
}) {
|
||||
const leadingSectors = sectors.slice(0, 4);
|
||||
const majorIndices = indices.slice(0, 3);
|
||||
|
||||
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>
|
||||
<h3 className="text-sm font-semibold text-text-primary">盘面依据</h3>
|
||||
<p className="mt-1 text-xs text-text-muted">用于校验仓位和风险,不作为主视图。</p>
|
||||
</div>
|
||||
<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)}
|
||||
</span>
|
||||
</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?.down_count ?? 0} tone="text-emerald-400" />
|
||||
<EvidenceStat label="涨停" value={marketTemperature?.limit_up_count ?? 0} tone="text-amber-400" />
|
||||
@ -392,49 +456,19 @@ function MarketSnapshot({
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<div key={item.code} className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
|
||||
<div className="text-xs font-semibold text-text-primary">{item.name}</div>
|
||||
<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>
|
||||
<div key={item.code} className="flex items-center justify-between gap-3 rounded-xl bg-surface-1/70 px-3 py-2">
|
||||
<span className="text-xs font-semibold text-text-primary">{item.name}</span>
|
||||
<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)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{leadingSectors.length ? (
|
||||
<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">
|
||||
<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">
|
||||
结论:{summary.headline}
|
||||
</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 }) {
|
||||
const stripeClass =
|
||||
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 }) {
|
||||
return (
|
||||
<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 { UserMenu } from "@/components/user-menu";
|
||||
import { SidebarNav, MobileBottomNav } from "@/components/nav";
|
||||
import { SidebarNav } from "@/components/nav";
|
||||
|
||||
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
{/* Desktop: sidebar + main */}
|
||||
<div className="flex min-h-screen">
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden md:flex flex-col w-60 glass-sidebar fixed inset-y-0 left-0 z-40">
|
||||
{/* Brand */}
|
||||
<div className="px-6 pt-7 pb-5">
|
||||
<aside className="fixed inset-y-0 left-0 z-40 flex w-48 sm:w-60 flex-col glass-sidebar">
|
||||
<div className="px-4 sm:px-6 pt-5 sm:pt-7 pb-4 sm:pb-5">
|
||||
<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
|
||||
</div>
|
||||
<div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-border-default to-transparent" />
|
||||
|
||||
{/* Nav */}
|
||||
<SidebarNav />
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-5 border-t border-border-subtle">
|
||||
<div className="px-4 sm:px-6 py-4 sm:py-5 border-t border-border-subtle">
|
||||
<UserMenu />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content area */}
|
||||
<main className="flex-1 md:ml-60 pb-16 md:pb-0 min-h-screen">
|
||||
<main className="min-h-screen flex-1 ml-48 sm:ml-60 pb-10">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom nav */}
|
||||
<MobileBottomNav />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ const STAGE_ORDER = [
|
||||
|
||||
export default function OpsLogsPage() {
|
||||
const { user } = useAuth();
|
||||
const [tab, setTab] = useState<OpsTab>("funnel");
|
||||
const [tab, setTab] = useState<OpsTab>("errors");
|
||||
const [days, setDays] = useState(7);
|
||||
const [sessions, setSessions] = useState<ScanSessionSummary[]>([]);
|
||||
const [selectedSession, setSelectedSession] = useState("");
|
||||
@ -43,8 +43,11 @@ export default function OpsLogsPage() {
|
||||
const [errorsTotal, setErrorsTotal] = useState(0);
|
||||
const [sources, setSources] = 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 [level, setLevel] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [errorsLoading, setErrorsLoading] = useState(false);
|
||||
const [expandedErrorId, setExpandedErrorId] = useState<number | 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 finalLog = sortedLogs.find((item) => item.stage === "final_filter");
|
||||
const latestError = errors[0];
|
||||
|
||||
const fetchScanData = useCallback(async (session?: string) => {
|
||||
setScanLoading(true);
|
||||
@ -82,17 +86,21 @@ export default function OpsLogsPage() {
|
||||
const fetchErrors = useCallback(async () => {
|
||||
setErrorsLoading(true);
|
||||
try {
|
||||
const result = await getErrorLogsAPI(80, source, level, days);
|
||||
const result = await getErrorLogsAPI(120, source, level, days, query.trim());
|
||||
setErrors(result.errors);
|
||||
setErrorsTotal(result.total);
|
||||
setSources(result.sources);
|
||||
setLevels(result.levels);
|
||||
setSourceCounts(result.source_counts || {});
|
||||
setLevelCounts(result.level_counts || {});
|
||||
} catch {
|
||||
setErrors([]);
|
||||
setSourceCounts({});
|
||||
setLevelCounts({});
|
||||
} finally {
|
||||
setErrorsLoading(false);
|
||||
}
|
||||
}, [days, level, source]);
|
||||
}, [days, level, query, source]);
|
||||
|
||||
const fetchSystemStatus = useCallback(async () => {
|
||||
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>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight text-text-primary">运行日志</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-text-secondary">
|
||||
最新筛选批次、漏斗关口和异常记录集中在这里。
|
||||
系统错误、接口异常、数据源失败和扫描漏斗集中在这里,方便快速定位异常来源。
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -177,8 +185,8 @@ export default function OpsLogsPage() {
|
||||
|
||||
<div className="flex gap-1.5 overflow-x-auto pb-1">
|
||||
{[
|
||||
{ key: "funnel", label: "筛选漏斗" },
|
||||
{ key: "errors", label: "系统错误" },
|
||||
{ key: "funnel", label: "筛选漏斗" },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
@ -305,13 +313,29 @@ export default function OpsLogsPage() {
|
||||
</div>
|
||||
) : (
|
||||
<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="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-primary">系统错误日志</h2>
|
||||
<p className="mt-1 text-xs text-text-muted">这里保留接口、数据源和后台任务的异常,便于追踪不稳定数据源。</p>
|
||||
<p className="mt-1 text-xs text-text-muted">自动记录应用内 ERROR/CRITICAL 日志,并保留手动上报的数据源和后台任务异常。</p>
|
||||
</div>
|
||||
<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">
|
||||
<option value="">全部来源</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>
|
||||
<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">
|
||||
清除30天前
|
||||
</button>
|
||||
@ -330,10 +364,16 @@ export default function OpsLogsPage() {
|
||||
</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="flex items-center justify-between border-b border-border-subtle px-4 py-3">
|
||||
<span className="text-xs font-semibold text-text-primary">错误记录</span>
|
||||
<span className="text-[10px] text-text-muted">{errorsTotal} 条</span>
|
||||
<span className="text-[10px] text-text-muted">显示 {errors.length} / {errorsTotal} 条</span>
|
||||
</div>
|
||||
{errorsLoading ? (
|
||||
<div className="space-y-2 p-4">
|
||||
@ -344,31 +384,105 @@ export default function OpsLogsPage() {
|
||||
) : (
|
||||
<div className="divide-y divide-border-subtle">
|
||||
{errors.map((item) => (
|
||||
<div key={item.id} className="px-4 py-3">
|
||||
<button className="w-full text-left" onClick={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}>
|
||||
<div className="grid gap-2 md:grid-cols-[88px_120px_1fr_140px] md:items-center">
|
||||
<StatusPill status={item.level} />
|
||||
<span className="truncate text-xs text-text-muted">{item.source}</span>
|
||||
<span className="truncate text-sm text-text-secondary">{item.message}</span>
|
||||
<span className="text-[10px] font-mono tabular-nums text-text-muted md:text-right">{formatDateTime(item.created_at)}</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedErrorId === item.id && item.detail ? (
|
||||
<pre className="mt-3 max-h-80 overflow-auto rounded-xl border border-border-subtle bg-surface-1 p-3 text-xs leading-6 text-text-muted whitespace-pre-wrap">
|
||||
{item.detail}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
<ErrorRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedErrorId === item.id}
|
||||
onToggle={() => setExpandedErrorId(expandedErrorId === item.id ? null : item.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</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 }) {
|
||||
const outputWidth = Math.max(4, Math.round((log.output_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);
|
||||
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";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchAPI } from "@/lib/api";
|
||||
import { fetchAPI, postAPI } from "@/lib/api";
|
||||
import type {
|
||||
DayGroup,
|
||||
LatestResult,
|
||||
OpsStatusResponse,
|
||||
PerformanceStats,
|
||||
RecommendationData,
|
||||
TrackedRecommendation,
|
||||
} from "@/lib/api";
|
||||
import StockCard from "@/components/stock-card";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
@ -44,18 +46,22 @@ export default function RecommendationsPage() {
|
||||
const [historyFilter, setHistoryFilter] = useState<string>("all");
|
||||
const [focusTab, setFocusTab] = useState<FocusTab>("actionable");
|
||||
const [opsStatus, setOpsStatus] = useState<OpsStatusResponse | null>(null);
|
||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||
const [trackingUpdating, setTrackingUpdating] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
const [history, latestResult, ops] = await Promise.all([
|
||||
const [history, latestResult, ops, perf] = await Promise.all([
|
||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||
fetchAPI<LatestResult>("/api/recommendations/latest").catch(() => null),
|
||||
user?.role === "admin" ? fetchAPI<OpsStatusResponse>("/api/market/ops-status").catch(() => null) : Promise.resolve(null),
|
||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||
]);
|
||||
|
||||
setDayGroups(history);
|
||||
setLatest(latestResult);
|
||||
setOpsStatus(ops);
|
||||
setPerformance(perf);
|
||||
|
||||
setExpandedDays((prev) => {
|
||||
if (prev.size || history.length === 0) return prev;
|
||||
@ -70,6 +76,16 @@ export default function RecommendationsPage() {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
const updateTracking = async () => {
|
||||
setTrackingUpdating(true);
|
||||
try {
|
||||
await postAPI("/api/recommendations/update-tracking");
|
||||
await loadData();
|
||||
} finally {
|
||||
setTrackingUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDay = (date: string) => {
|
||||
setExpandedDays((prev) => {
|
||||
const next = new Set(prev);
|
||||
@ -214,6 +230,13 @@ export default function RecommendationsPage() {
|
||||
</div>
|
||||
) : 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="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@ -393,6 +416,184 @@ function buildFocusSummary({
|
||||
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[] }) {
|
||||
return (
|
||||
<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={`${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_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="方法迭代" />
|
||||
</div>
|
||||
@ -550,9 +550,9 @@ function MetricCard({
|
||||
}: {
|
||||
label: string;
|
||||
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 (
|
||||
<div className="glass-card-static p-4">
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { registerAPI, sendRegisterCodeAPI } from "@/lib/api";
|
||||
@ -28,7 +28,15 @@ export default function LoginPage() {
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
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;
|
||||
const timer = window.setTimeout(() => setCooldown((v) => Math.max(v - 1, 0)), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
|
||||
@ -141,14 +141,14 @@ function SideNavItem({ href, icon, label }: { href: string; icon: React.ReactNod
|
||||
return (
|
||||
<Link
|
||||
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
|
||||
? "text-text-primary bg-surface-4"
|
||||
: "text-text-secondary hover:text-text-primary hover:bg-surface-3"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base opacity-70">{icon}</span>
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="shrink-0 text-base opacity-70">{icon}</span>
|
||||
<span className="min-w-0 truncate font-medium">{label}</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@ -157,7 +157,7 @@ export function SidebarNav() {
|
||||
const { user } = useAuth();
|
||||
|
||||
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="/recommendations" icon={<TargetIcon />} label="推荐池" />
|
||||
<SideNavItem href="/sectors" icon={<FireIcon />} label="板块主线" />
|
||||
@ -167,7 +167,7 @@ export function SidebarNav() {
|
||||
{user?.role === "admin" && (
|
||||
<>
|
||||
<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="/tasks" icon={<TasksIcon />} label="任务中心" />
|
||||
<SideNavItem href="/settings" icon={<SettingsIcon />} label="管理设置" />
|
||||
@ -176,51 +176,3 @@ export function SidebarNav() {
|
||||
</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 {
|
||||
recommendation_id: number;
|
||||
recommendation_id?: number;
|
||||
ts_code: string;
|
||||
name: string;
|
||||
entry_price: number;
|
||||
current_price: number;
|
||||
pct_from_entry: number;
|
||||
entry_price: number | null;
|
||||
current_price: number | null;
|
||||
pct_from_entry: number | null;
|
||||
hit_target: boolean;
|
||||
hit_stop_loss: boolean;
|
||||
status: string;
|
||||
status?: string;
|
||||
action_plan?: string;
|
||||
lifecycle_status?: string;
|
||||
recall_tags?: string[];
|
||||
@ -361,6 +361,7 @@ export interface TrackedRecommendation {
|
||||
close_reason?: string;
|
||||
review_note?: string;
|
||||
track_date: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// ---------- Daily Review ----------
|
||||
@ -845,6 +846,12 @@ export interface DataStats {
|
||||
tracking: number;
|
||||
sector_heat: 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;
|
||||
latest_date: string;
|
||||
earliest_date: string;
|
||||
@ -884,6 +891,8 @@ export interface ErrorLogsResult {
|
||||
errors: ErrorLog[];
|
||||
sources: string[];
|
||||
levels: string[];
|
||||
source_counts: Record<string, number>;
|
||||
level_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
@ -1001,10 +1010,11 @@ export interface TaskCenterResult {
|
||||
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) });
|
||||
if (source) params.set("source", source);
|
||||
if (level) params.set("level", level);
|
||||
if (q) params.set("q", q);
|
||||
return fetchAPI<ErrorLogsResult>(`/api/debug/errors?${params}`);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user