This commit is contained in:
aaron 2026-05-28 23:32:52 +08:00
parent 46a7bf1192
commit d07af5508a
49 changed files with 1410 additions and 749 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@ -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": []
}; };

View File

@ -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="{}"

View File

@ -1,5 +1 @@
{ {}
"/_app": "pages/_app.js",
"/_error": "pages/_error.js",
"/_document": "pages/_document.js"
}

View File

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

View File

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

View File

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

View File

@ -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}` : "暂无来源";
}

View File

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

View File

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

View File

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

View File

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

View File

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