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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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": {},
"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 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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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