This commit is contained in:
aaron 2026-06-08 11:11:05 +08:00
parent 153dcc2f70
commit 767c80d387
12 changed files with 382 additions and 113 deletions

View File

@ -86,6 +86,7 @@ async def get_latest():
"market_anomalies": anomalies, "market_anomalies": anomalies,
"scan_mode": result.get("scan_mode", "unknown"), "scan_mode": result.get("scan_mode", "unknown"),
"strategy_profile": result.get("strategy_profile"), "strategy_profile": result.get("strategy_profile"),
"latest_scan": result.get("latest_scan"),
} }

View File

@ -85,6 +85,61 @@ def _build_legacy_decision_trace(row) -> dict:
} }
def _scan_meta_from_row(row) -> dict | None:
if not row:
return None
r = row._mapping if hasattr(row, "_mapping") else row
detail = _safe_json_dict(r.get("detail_json"))
return {
"scan_session": r.get("scan_session") or "",
"scan_mode": r.get("scan_mode") or "",
"status": r.get("status") or "ok",
"stage": r.get("stage") or "",
"input_count": int(r.get("input_count") or 0),
"output_count": int(r.get("output_count") or 0),
"filtered_count": int(r.get("filtered_count") or 0),
"summary": r.get("summary") or "",
"created_at": str(r.get("created_at") or ""),
"date": str(r.get("created_at") or "")[:10] if r.get("created_at") else "",
"detail": detail,
"action_counts": detail.get("action_counts") or {},
"elimination_reasons": detail.get("elimination_reasons") or {},
}
async def _load_latest_completed_scan(db) -> dict | None:
from sqlalchemy import text
result = await db.execute(
text(
"SELECT * FROM scan_process_logs "
"WHERE stage = 'final_filter' "
"ORDER BY created_at DESC, id DESC LIMIT 1"
)
)
return _scan_meta_from_row(result.fetchone())
async def _load_latest_strategy_profile_for_session(db, scan_session: str) -> dict | None:
if not scan_session:
return None
from sqlalchemy import text
result = await db.execute(
text(
"SELECT detail_json FROM scan_process_logs "
"WHERE scan_session = :session AND stage = 'strategy_profile' "
"ORDER BY created_at DESC, id DESC LIMIT 1"
),
{"session": scan_session},
)
row = result.fetchone()
if not row:
return None
profile = _safe_json_dict(row._mapping.get("detail_json"))
return profile or None
async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict: async def refresh_recommendations(trade_date: str = None, scan_session: str = "manual") -> dict:
"""刷新推荐列表(带扫描锁防止并发)""" """刷新推荐列表(带扫描锁防止并发)"""
global _scan_running global _scan_running
@ -577,6 +632,25 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
async with get_db() as db: async with get_db() as db:
from sqlalchemy import text from sqlalchemy import text
scan_result = await db.execute(
text(
"SELECT s.* FROM scan_process_logs s "
"INNER JOIN ("
" SELECT date(created_at) AS scan_date, MAX(id) AS max_id "
" FROM scan_process_logs "
" WHERE stage = 'final_filter' AND created_at >= :start "
" GROUP BY date(created_at)"
") latest ON s.id = latest.max_id "
"ORDER BY s.created_at DESC, s.id DESC"
),
{"start": start},
)
scan_groups = {
meta["date"]: meta
for meta in (_scan_meta_from_row(row) for row in scan_result.fetchall())
if meta and meta.get("date")
}
# 查询所有历史推荐,按 ts_code 去重(每天取最新一条) # 查询所有历史推荐,按 ts_code 去重(每天取最新一条)
stmt = text( stmt = text(
"SELECT r.*, " "SELECT r.*, "
@ -614,8 +688,11 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
result = await db.execute(stmt, {"start": start}) result = await db.execute(stmt, {"start": start})
rows = result.fetchall() rows = result.fetchall()
# 按日期分组 # 按日期分组。先用扫描日志建组,保证“当天扫描但无推荐”也会被展示。
grouped: dict[str, list[dict]] = {} grouped: dict[str, dict] = {
date_str: {"scan": scan_meta, "recommendations": []}
for date_str, scan_meta in scan_groups.items()
}
for row in rows: for row in rows:
r = row._mapping r = row._mapping
# SQLite created_at 是字符串 "YYYY-MM-DD HH:MM:SS" # SQLite created_at 是字符串 "YYYY-MM-DD HH:MM:SS"
@ -679,13 +756,15 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
} }
if date_str not in grouped: if date_str not in grouped:
grouped[date_str] = [] grouped[date_str] = {"scan": None, "recommendations": []}
grouped[date_str].append(rec_dict) grouped[date_str]["recommendations"].append(rec_dict)
# 转为列表,按日期降序 # 转为列表,按日期降序
result_list = [] result_list = []
for date_str in sorted(grouped.keys(), reverse=True): for date_str in sorted(grouped.keys(), reverse=True):
recs = grouped[date_str] group = grouped[date_str]
recs = group["recommendations"]
scan_meta = group["scan"]
buy_count = sum(1 for r in recs if r["signal"] == "BUY") buy_count = sum(1 for r in recs if r["signal"] == "BUY")
avg_score = round(sum(r["score"] for r in recs) / len(recs), 1) if recs else 0 avg_score = round(sum(r["score"] for r in recs) / len(recs), 1) if recs else 0
result_list.append({ result_list.append({
@ -694,6 +773,15 @@ async def get_recommendation_history(days: int = 7) -> list[dict]:
"buy_count": buy_count, "buy_count": buy_count,
"avg_score": avg_score, "avg_score": avg_score,
"recommendations": recs, "recommendations": recs,
"scan_session": scan_meta.get("scan_session") if scan_meta else "",
"scan_mode": scan_meta.get("scan_mode") if scan_meta else "",
"scan_status": scan_meta.get("status") if scan_meta else "",
"scanned_at": scan_meta.get("created_at") if scan_meta else "",
"scan_summary": scan_meta.get("summary") if scan_meta else "",
"scan_input_count": scan_meta.get("input_count") if scan_meta else 0,
"scan_output_count": scan_meta.get("output_count") if scan_meta else len(recs),
"scan_filtered_count": scan_meta.get("filtered_count") if scan_meta else 0,
"elimination_reasons": scan_meta.get("elimination_reasons") if scan_meta else {},
}) })
return result_list return result_list
@ -908,6 +996,10 @@ async def _load_today_from_db() -> dict:
from sqlalchemy import text from sqlalchemy import text
import json import json
latest_scan = await _load_latest_completed_scan(db)
latest_scan_date = (latest_scan or {}).get("date") or ""
latest_scan_session = (latest_scan or {}).get("scan_session") or ""
# 加载市场温度(按 trade_date 取最新交易日) # 加载市场温度(按 trade_date 取最新交易日)
result = await db.execute( result = await db.execute(
text( text(
@ -931,22 +1023,47 @@ async def _load_today_from_db() -> dict:
temperature=m["temperature"], temperature=m["temperature"],
) )
# 加载推荐(取最近一个有数据的日期,按 ts_code 去重,优先保留行动级别更高的结果) if latest_scan:
result = await db.execute( recommendation_sql = (
text("SELECT * FROM recommendations " "SELECT * FROM recommendations "
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " "WHERE date(created_at) = :target_date "
"AND (action_plan IN ('可操作', '重点关注') OR score >= 56) " "AND (:scan_session = '' OR scan_session = :scan_session) "
"AND (" "AND (action_plan IN ('可操作', '重点关注') OR score >= 56) "
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' " "AND ("
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' " " COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
" OR COALESCE(recall_tags, '[]') LIKE '%top_theme_member%' " " OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
" OR COALESCE(recall_tags, '[]') LIKE '%sector_recall%'" " OR COALESCE(recall_tags, '[]') LIKE '%top_theme_member%' "
") " " OR COALESCE(recall_tags, '[]') LIKE '%sector_recall%'"
"AND id IN (SELECT MAX(id) FROM recommendations " ") "
" WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) " "AND id IN ("
" GROUP BY ts_code) " " SELECT MAX(id) FROM recommendations "
"ORDER BY score DESC") " WHERE date(created_at) = :target_date "
) " AND (:scan_session = '' OR scan_session = :scan_session) "
" GROUP BY ts_code"
") "
"ORDER BY score DESC"
)
result = await db.execute(
text(recommendation_sql),
{"target_date": latest_scan_date, "scan_session": latest_scan_session},
)
else:
# 兼容旧库:没有扫描日志时,才回退到最近一个有推荐的日期。
result = await db.execute(
text("SELECT * FROM recommendations "
"WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
"AND (action_plan IN ('可操作', '重点关注') OR score >= 56) "
"AND ("
" COALESCE(recall_tags, '[]') LIKE '%hot_theme_core%' "
" OR COALESCE(recall_tags, '[]') LIKE '%theme_leader%' "
" OR COALESCE(recall_tags, '[]') LIKE '%top_theme_member%' "
" OR COALESCE(recall_tags, '[]') LIKE '%sector_recall%'"
") "
"AND id IN (SELECT MAX(id) FROM recommendations "
" WHERE date(created_at) = (SELECT date(created_at) FROM recommendations ORDER BY created_at DESC LIMIT 1) "
" GROUP BY ts_code) "
"ORDER BY score DESC")
)
rows = result.fetchall() rows = result.fetchall()
recommendations = [] recommendations = []
for row in rows: for row in rows:
@ -988,24 +1105,29 @@ async def _load_today_from_db() -> dict:
focus_points=json.loads(r.get("focus_points") or "[]"), focus_points=json.loads(r.get("focus_points") or "[]"),
decision_trace=_safe_json_dict(r.get("decision_trace")) or _build_legacy_decision_trace(r), decision_trace=_safe_json_dict(r.get("decision_trace")) or _build_legacy_decision_trace(r),
scan_session=r["scan_session"] or "", scan_session=r["scan_session"] or "",
created_at=r["created_at"],
)) ))
strategy_profile = None
if latest_scan:
strategy_profile = await _load_latest_strategy_profile_for_session(db, latest_scan_session)
if not strategy_profile and recommendations:
strategy_profile = get_strategy_profile_by_id(recommendations[0].strategy).model_dump()
return { return {
"market_temp": market_temp, "market_temp": market_temp,
"hot_sectors": [], "hot_sectors": [],
"capital_filtered": [], "capital_filtered": [],
"recommendations": recommendations, "recommendations": recommendations,
"strategy_profile": ( "strategy_profile": strategy_profile,
get_strategy_profile_by_id(recommendations[0].strategy).model_dump() "latest_scan": latest_scan,
if recommendations "scan_mode": (latest_scan or {}).get("scan_mode", "unknown"),
else None
),
} }
except Exception as e: except Exception as e:
logger.error(f"从数据库加载推荐失败: {e}") logger.error(f"从数据库加载推荐失败: {e}")
from app.db.error_logger import log_error from app.db.error_logger import log_error
await log_error("recommender", f"从数据库加载推荐失败: {e}", detail=traceback.format_exc()) await log_error("recommender", f"从数据库加载推荐失败: {e}", detail=traceback.format_exc())
return {"market_temp": None, "hot_sectors": [], "capital_filtered": [], "recommendations": []} return {"market_temp": None, "hot_sectors": [], "capital_filtered": [], "recommendations": [], "latest_scan": None}
async def _load_sectors_from_db() -> list[SectorInfo]: async def _load_sectors_from_db() -> list[SectorInfo]:

Binary file not shown.

View File

@ -12,16 +12,15 @@
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/css/b12d1381ac64f6da.css", "static/css/cc34bfc02fe53996.css",
"static/chunks/app/layout-3a83ac2605aef91b.js" "static/chunks/app/layout-9da89db040cc7fe7.js"
], ],
"/(auth)/sectors/page": [ "/(auth)/dashboard/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/648-737196aebda2b095.js", "static/chunks/app/(auth)/dashboard/page-7c42cdee67f7ba1f.js"
"static/chunks/app/(auth)/sectors/page-b1b520bb87096d8c.js"
], ],
"/(auth)/layout": [ "/(auth)/layout": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
@ -29,14 +28,29 @@
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/648-737196aebda2b095.js", "static/chunks/648-737196aebda2b095.js",
"static/chunks/app/(auth)/layout-895972713478d7d5.js" "static/chunks/app/(auth)/layout-00c8b5ffc11909fc.js"
], ],
"/(auth)/sentiment/page": [ "/(auth)/recommendations/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/sentiment/page-914adbd9eac71003.js" "static/chunks/app/(auth)/recommendations/page-04ad0514ad59d914.js"
],
"/(auth)/admin/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/648-737196aebda2b095.js",
"static/chunks/app/(auth)/admin/page-d0e73ac1163fe810.js"
],
"/(auth)/chat/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/chat/page-2ee86107f57d5938.js"
], ],
"/(auth)/sectors/[name]/page": [ "/(auth)/sectors/[name]/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
@ -44,49 +58,59 @@
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/648-737196aebda2b095.js", "static/chunks/648-737196aebda2b095.js",
"static/chunks/app/(auth)/sectors/[name]/page-b47ed772b7ed0b86.js" "static/chunks/app/(auth)/sectors/[name]/page-f200c8a32f30483d.js"
], ],
"/(auth)/chat/page": [ "/(auth)/admin/users/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/chat/page-2460c0ba93c793ad.js" "static/chunks/648-737196aebda2b095.js",
"static/chunks/app/(auth)/admin/users/page-133cb4455ae7ff45.js"
], ],
"/(auth)/dashboard/page": [ "/(auth)/sectors/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/dashboard/page-7ff91dee1ef2fdbc.js" "static/chunks/648-737196aebda2b095.js",
], "static/chunks/app/(auth)/sectors/page-af480a7c6de07a7d.js"
"/(auth)/watchlists/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/watchlists/page-d8baeb97cf398976.js"
],
"/(auth)/recommendations/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/recommendations/page-7a67286c61a1d8d0.js"
], ],
"/(auth)/stock/[code]/page": [ "/(auth)/stock/[code]/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/stock/[code]/page-5a76132c106da8d3.js" "static/chunks/app/(auth)/stock/[code]/page-2af464be6f422116.js"
],
"/(auth)/sentiment/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/sentiment/page-23ada87205189134.js"
],
"/(auth)/admin/invites/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/648-737196aebda2b095.js",
"static/chunks/app/(auth)/admin/invites/page-f0d0e84e699f2a8e.js"
],
"/(auth)/watchlists/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(auth)/watchlists/page-5728882e0ff74783.js"
], ],
"/(public)/login/page": [ "/(public)/login/page": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/fd9d1056-04f4ef6e08ec3462.js", "static/chunks/fd9d1056-04f4ef6e08ec3462.js",
"static/chunks/117-a8e886455f28924b.js", "static/chunks/117-a8e886455f28924b.js",
"static/chunks/main-app-7d7e5d1021afd90c.js", "static/chunks/main-app-7d7e5d1021afd90c.js",
"static/chunks/app/(public)/login/page-a72b3e6424d449e4.js" "static/chunks/app/(public)/login/page-0b6bac111efdebf4.js"
], ],
"/(public)/layout": [ "/(public)/layout": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",

View File

@ -5,8 +5,8 @@
"devFiles": [], "devFiles": [],
"ampDevFiles": [], "ampDevFiles": [],
"lowPriorityFiles": [ "lowPriorityFiles": [
"static/ENQu76Djfgx3eox8nyd0C/_buildManifest.js", "static/DIAczInZnAiIi82P18FrS/_buildManifest.js",
"static/ENQu76Djfgx3eox8nyd0C/_ssgManifest.js" "static/DIAczInZnAiIi82P18FrS/_ssgManifest.js"
], ],
"rootMainFiles": [ "rootMainFiles": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
@ -18,13 +18,13 @@
"/_app": [ "/_app": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/framework-f66176bb897dc684.js", "static/chunks/framework-f66176bb897dc684.js",
"static/chunks/main-728cc0b2064d905a.js", "static/chunks/main-b28cc01a35d4249a.js",
"static/chunks/pages/_app-72b849fbd24ac258.js" "static/chunks/pages/_app-72b849fbd24ac258.js"
], ],
"/_error": [ "/_error": [
"static/chunks/webpack-76aa9cbbdedb6a49.js", "static/chunks/webpack-76aa9cbbdedb6a49.js",
"static/chunks/framework-f66176bb897dc684.js", "static/chunks/framework-f66176bb897dc684.js",
"static/chunks/main-728cc0b2064d905a.js", "static/chunks/main-b28cc01a35d4249a.js",
"static/chunks/pages/_error-7ba65e1336b92748.js" "static/chunks/pages/_error-7ba65e1336b92748.js"
] ]
}, },

View File

@ -1,14 +1,17 @@
{ {
"/_not-found/page": "app/_not-found/page.js", "/_not-found/page": "app/_not-found/page.js",
"/(auth)/sectors/page": "app/(auth)/sectors/page.js",
"/(auth)/sentiment/page": "app/(auth)/sentiment/page.js",
"/(auth)/sectors/[name]/page": "app/(auth)/sectors/[name]/page.js",
"/(auth)/chat/page": "app/(auth)/chat/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js", "/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/api/chat/stream/route": "app/(auth)/api/chat/stream/route.js",
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js",
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js", "/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
"/(auth)/admin/page": "app/(auth)/admin/page.js",
"/(auth)/chat/page": "app/(auth)/chat/page.js",
"/(auth)/sectors/[name]/page": "app/(auth)/sectors/[name]/page.js",
"/(auth)/admin/users/page": "app/(auth)/admin/users/page.js",
"/(auth)/api/chat/stream/route": "app/(auth)/api/chat/stream/route.js",
"/(auth)/sectors/page": "app/(auth)/sectors/page.js",
"/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js", "/(auth)/stock/[code]/page": "app/(auth)/stock/[code]/page.js",
"/(auth)/sentiment/page": "app/(auth)/sentiment/page.js",
"/(auth)/admin/invites/page": "app/(auth)/admin/invites/page.js",
"/(auth)/watchlists/page": "app/(auth)/watchlists/page.js",
"/(public)/login/page": "app/(public)/login/page.js", "/(public)/login/page": "app/(public)/login/page.js",
"/(public)/page": "app/(public)/page.js" "/(public)/page": "app/(public)/page.js"
} }

View File

@ -1 +1 @@
self.__BUILD_MANIFEST={polyfillFiles:["static/chunks/polyfills-42372ed130431b0a.js"],devFiles:[],ampDevFiles:[],lowPriorityFiles:[],rootMainFiles:["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/fd9d1056-04f4ef6e08ec3462.js","static/chunks/117-a8e886455f28924b.js","static/chunks/main-app-7d7e5d1021afd90c.js"],pages:{"/_app":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-728cc0b2064d905a.js","static/chunks/pages/_app-72b849fbd24ac258.js"],"/_error":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-728cc0b2064d905a.js","static/chunks/pages/_error-7ba65e1336b92748.js"]},ampFirstPages:[]},self.__BUILD_MANIFEST.lowPriorityFiles=["/static/"+process.env.__NEXT_BUILD_ID+"/_buildManifest.js",,"/static/"+process.env.__NEXT_BUILD_ID+"/_ssgManifest.js"]; self.__BUILD_MANIFEST={polyfillFiles:["static/chunks/polyfills-42372ed130431b0a.js"],devFiles:[],ampDevFiles:[],lowPriorityFiles:[],rootMainFiles:["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/fd9d1056-04f4ef6e08ec3462.js","static/chunks/117-a8e886455f28924b.js","static/chunks/main-app-7d7e5d1021afd90c.js"],pages:{"/_app":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-b28cc01a35d4249a.js","static/chunks/pages/_app-72b849fbd24ac258.js"],"/_error":["static/chunks/webpack-76aa9cbbdedb6a49.js","static/chunks/framework-f66176bb897dc684.js","static/chunks/main-b28cc01a35d4249a.js","static/chunks/pages/_error-7ba65e1336b92748.js"]},ampFirstPages:[]},self.__BUILD_MANIFEST.lowPriorityFiles=["/static/"+process.env.__NEXT_BUILD_ID+"/_buildManifest.js",,"/static/"+process.env.__NEXT_BUILD_ID+"/_ssgManifest.js"];

View File

@ -1 +1 @@
{"node":{},"edge":{},"encryptionKey":"Y+iTYVqqfu0PTKokbxxh1ZopwesJ4kb+sPOCvZ1fElY="} {"node":{},"edge":{},"encryptionKey":"s1k3DNf5kMTqjFQA+B2LjtdtSv8ZKMACjH1mm7oDr6I="}

File diff suppressed because one or more lines are too long

View File

@ -170,6 +170,7 @@ export default function DashboardPage() {
}, [clearScanTimeout]); }, [clearScanTimeout]);
const recommendations = data?.recommendations ?? []; const recommendations = data?.recommendations ?? [];
const latestScan = data?.latest_scan ?? null;
const actionable = recommendations.filter((rec) => rec.action_plan === "可操作"); const actionable = recommendations.filter((rec) => rec.action_plan === "可操作");
const watch = recommendations.filter((rec) => rec.action_plan === "重点关注"); const watch = recommendations.filter((rec) => rec.action_plan === "重点关注");
const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? "")); const observe = recommendations.filter((rec) => !["可操作", "重点关注"].includes(rec.action_plan ?? ""));
@ -185,6 +186,7 @@ export default function DashboardPage() {
); );
const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations; const focusQueue = actionable.length ? actionable : watch.length ? watch : recommendations;
const scanTimeLabel = latestScan?.created_at ? formatScanTime(latestScan.created_at) : "暂无完成扫描";
if (loading) { if (loading) {
return ( return (
@ -212,6 +214,9 @@ export default function DashboardPage() {
</span> </span>
)} )}
<span className="inline-flex items-center gap-1.5 rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
{scanTimeLabel}
</span>
</div> </div>
</div> </div>
@ -246,6 +251,7 @@ export default function DashboardPage() {
actionableCount={actionable.length} actionableCount={actionable.length}
watchCount={watch.length} watchCount={watch.length}
observeCount={observe.length} observeCount={observe.length}
latestScan={latestScan}
/> />
</div> </div>
@ -255,6 +261,7 @@ export default function DashboardPage() {
focusQueue={focusQueue.slice(0, 6)} focusQueue={focusQueue.slice(0, 6)}
actionableCount={actionable.length} actionableCount={actionable.length}
watchCount={watch.length} watchCount={watch.length}
latestScan={latestScan}
/> />
<div className="space-y-4"> <div className="space-y-4">
<CompactMarketEvidence <CompactMarketEvidence
@ -287,6 +294,7 @@ function DecisionHero({
actionableCount, actionableCount,
watchCount, watchCount,
observeCount, observeCount,
latestScan,
}: { }: {
board: StrategyBoard | null; board: StrategyBoard | null;
summary: ReturnType<typeof buildMarketSummary>; summary: ReturnType<typeof buildMarketSummary>;
@ -297,6 +305,7 @@ function DecisionHero({
actionableCount: number; actionableCount: number;
watchCount: number; watchCount: number;
observeCount: number; observeCount: number;
latestScan: LatestResult["latest_scan"];
}) { }) {
const leadingSectors = sectors.slice(0, 3); const leadingSectors = sectors.slice(0, 3);
@ -349,14 +358,15 @@ function DecisionHero({
</div> </div>
<div className="mt-5"> <div className="mt-5">
<div className="text-[11px] font-semibold text-text-secondary"></div> <div className="flex items-center justify-between gap-3">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="text-[10px] text-text-muted">{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "暂无扫描"}</div>
</div>
<div className="mt-3 space-y-2.5"> <div className="mt-3 space-y-2.5">
{focusQueue.length ? ( {focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />) focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : ( ) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted"> <EmptyScanResult latestScan={latestScan} compact />
</div>
)} )}
</div> </div>
</div> </div>
@ -371,11 +381,13 @@ function OpportunityBoard({
focusQueue, focusQueue,
actionableCount, actionableCount,
watchCount, watchCount,
latestScan,
}: { }: {
sectors: SectorData[]; sectors: SectorData[];
focusQueue: RecommendationData[]; focusQueue: RecommendationData[];
actionableCount: number; actionableCount: number;
watchCount: number; watchCount: number;
latestScan: LatestResult["latest_scan"];
}) { }) {
const leadingSectors = sectors.slice(0, 5); const leadingSectors = sectors.slice(0, 5);
@ -416,7 +428,9 @@ function OpportunityBoard({
{focusQueue.length ? focusQueue.map((rec) => ( {focusQueue.length ? focusQueue.map((rec) => (
<LargeFocusStockCard key={rec.ts_code} rec={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 className="md:col-span-2">
<EmptyScanResult latestScan={latestScan} />
</div>
)} )}
</div> </div>
</section> </section>
@ -727,6 +741,32 @@ function LargeFocusStockCard({ rec }: { rec: RecommendationData }) {
); );
} }
function EmptyScanResult({ latestScan, compact = false }: { latestScan: LatestResult["latest_scan"]; compact?: boolean }) {
const reasons = latestScan?.elimination_reasons ? Object.entries(latestScan.elimination_reasons).slice(0, 3) : [];
return (
<div className={`rounded-2xl border border-border-subtle bg-surface-1/70 ${compact ? "p-4" : "p-6"} text-left`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-text-primary"></div>
<span className="rounded-lg bg-surface-2 px-2 py-1 text-[10px] text-text-muted">
{latestScan?.created_at ? formatScanTime(latestScan.created_at) : "未扫描"}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-text-secondary">
{latestScan?.summary || "最近一次扫描没有保留到作战池,不展示历史标的,避免误导。"}
</div>
{reasons.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{reasons.map(([reason, count]) => (
<span key={reason} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
{reason} {count}
</span>
))}
</div>
) : null}
</div>
);
}
function getExecutionMeta(rec: RecommendationData) { function getExecutionMeta(rec: RecommendationData) {
const hint = rec.decision_trace?.position_adjustment?.hint ?? rec.decision_trace?.context?.position_hint ?? "neutral"; const hint = rec.decision_trace?.position_adjustment?.hint ?? rec.decision_trace?.context?.position_hint ?? "neutral";
const note = rec.decision_trace?.position_adjustment?.notes?.[0] ?? ""; const note = rec.decision_trace?.position_adjustment?.notes?.[0] ?? "";
@ -757,6 +797,17 @@ function getExecutionMeta(rec: RecommendationData) {
return { label: "", note: "", className: "", textClass: "" }; return { label: "", note: "", className: "", textClass: "" };
} }
function formatScanTime(value: string) {
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return value;
const today = new Date();
const isToday = date.toDateString() === today.toDateString();
const time = date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
if (isToday) return `今日 ${time}`;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
function TinyInfo({ label, value }: { label: string; value: string | number }) { function TinyInfo({ label, value }: { label: string; value: string | number }) {
return ( return (
<div className="rounded-lg bg-bg-primary/45 px-2 py-1.5"> <div className="rounded-lg bg-bg-primary/45 px-2 py-1.5">

View File

@ -26,6 +26,18 @@ function formatDate(dateStr: string): string {
return `${d.getMonth() + 1}${d.getDate()}${weekDays[d.getDay()]}`; return `${d.getMonth() + 1}${d.getDate()}${weekDays[d.getDay()]}`;
} }
function formatScanTime(value?: string): string {
if (!value) return "暂无扫描";
const normalized = value.includes("T") ? value : value.replace(" ", "T");
const d = new Date(normalized);
if (Number.isNaN(d.getTime())) return value;
const today = new Date();
const isToday = d.toDateString() === today.toDateString();
const time = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
if (isToday) return `今日 ${time}`;
return d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
type RecommendationWithDate = RecommendationData & { groupDate: string }; type RecommendationWithDate = RecommendationData & { groupDate: string };
type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed"; type FocusTab = "actionable" | "watch" | "observe" | "tracking" | "closed";
@ -149,6 +161,7 @@ export default function RecommendationsPage() {
const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length; const todayCount = applyHistoryFilter(dayGroups[0]?.recommendations ?? []).length;
const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0); const totalCount = dayGroups.reduce((sum, group) => sum + applyHistoryFilter(group.recommendations).length, 0);
const latestScanLabel = latest?.latest_scan?.created_at || dayGroups[0]?.scanned_at;
const focusSummary = buildFocusSummary({ const focusSummary = buildFocusSummary({
strategyProfile: latest?.strategy_profile ?? null, strategyProfile: latest?.strategy_profile ?? null,
actionable, actionable,
@ -209,6 +222,8 @@ export default function RecommendationsPage() {
<div className="mt-3 grid grid-cols-2 gap-2"> <div className="mt-3 grid grid-cols-2 gap-2">
<SummaryFact label="历史记录" value={`${totalCount}`} /> <SummaryFact label="历史记录" value={`${totalCount}`} />
<SummaryFact label="已复盘" value={`${closed.length}`} /> <SummaryFact label="已复盘" value={`${closed.length}`} />
<SummaryFact label="最近扫描" value={formatScanTime(latestScanLabel)} />
<SummaryFact label="扫描状态" value={latest?.latest_scan?.status || dayGroups[0]?.scan_status || "暂无"} />
</div> </div>
</div> </div>
</div> </div>
@ -312,10 +327,10 @@ export default function RecommendationsPage() {
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
{dayGroups.map((group, index) => { {dayGroups.map((group, index) => {
const filtered = applyHistoryFilter(group.recommendations); const filtered = applyHistoryFilter(group.recommendations);
if (historyFilter !== "all" && filtered.length === 0) return null;
const isExpanded = expandedDays.has(group.date); const isExpanded = expandedDays.has(group.date);
const isToday = index === 0; const isToday = index === 0;
const reasons = group.elimination_reasons ? Object.entries(group.elimination_reasons).slice(0, 3) : [];
return ( return (
<div key={group.date} className="rounded-2xl border border-border-subtle bg-surface-1/40"> <div key={group.date} className="rounded-2xl border border-border-subtle bg-surface-1/40">
@ -331,17 +346,43 @@ export default function RecommendationsPage() {
<span className="text-[11px] text-text-muted font-mono tabular-nums">{group.date}</span> <span className="text-[11px] text-text-muted font-mono tabular-nums">{group.date}</span>
</div> </div>
<div className="mt-1 text-[11px] text-text-muted"> <div className="mt-1 text-[11px] text-text-muted">
{filtered.length} {formatScanTime(group.scanned_at)} · {filtered.length}
{group.scan_input_count ? ` / 候选 ${group.scan_input_count}` : ""}
</div> </div>
{group.scan_summary ? (
<div className="mt-1 line-clamp-1 text-[11px] text-text-secondary">{group.scan_summary}</div>
) : null}
</div> </div>
<span className="text-xs text-text-muted">{isExpanded ? "收起" : "展开"}</span> <span className="text-xs text-text-muted">{isExpanded ? "收起" : "展开"}</span>
</button> </button>
{isExpanded ? ( {isExpanded ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 border-t border-border-subtle px-4 py-4"> <div className="border-t border-border-subtle px-4 py-4">
{filtered.map((rec) => ( {filtered.length ? (
<StockCard key={`${group.date}-${rec.ts_code}`} rec={rec} compact /> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
))} {filtered.map((rec) => (
<StockCard key={`${group.date}-${rec.ts_code}`} rec={rec} compact />
))}
</div>
) : (
<div className="rounded-2xl border border-border-subtle bg-surface-1/60 p-5 text-sm text-text-muted">
<div className="font-medium text-text-secondary">
{historyFilter === "all" ? "本日扫描无入选标的" : "本日扫描存在,但当前筛选条件下无标的"}
</div>
<div className="mt-2 text-xs leading-5">
{group.scan_summary || "系统已完成扫描,但没有标的进入历史推荐池。"}
</div>
{reasons.length ? (
<div className="mt-3 flex flex-wrap gap-1.5">
{reasons.map(([reason, count]) => (
<span key={reason} className="rounded-lg bg-surface-2/70 px-2 py-1 text-[10px] text-text-muted">
{reason} {count}
</span>
))}
</div>
) : null}
</div>
)}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -365,6 +365,23 @@ export interface LatestResult {
feedback_notes?: string[]; feedback_notes?: string[];
generated_by?: string; generated_by?: string;
} | null; } | null;
latest_scan?: ScanMeta | null;
scan_mode?: string;
}
export interface ScanMeta {
scan_session: string;
scan_mode: string;
status: string;
stage: string;
input_count: number;
output_count: number;
filtered_count: number;
summary: string;
created_at: string;
date: string;
action_counts?: Record<string, number>;
elimination_reasons?: Record<string, number>;
} }
export interface DayGroup { export interface DayGroup {
@ -373,6 +390,15 @@ export interface DayGroup {
buy_count: number; buy_count: number;
avg_score: number; avg_score: number;
recommendations: RecommendationData[]; recommendations: RecommendationData[];
scan_session?: string;
scan_mode?: string;
scan_status?: string;
scanned_at?: string;
scan_summary?: string;
scan_input_count?: number;
scan_output_count?: number;
scan_filtered_count?: number;
elimination_reasons?: Record<string, number>;
} }
// ---------- Performance Stats ---------- // ---------- Performance Stats ----------