1
This commit is contained in:
parent
6d741a7ec2
commit
865db50369
Binary file not shown.
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
@ -114,6 +115,8 @@ async def _run_scan_background(scan_session: str):
|
|||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"后台扫描失败: {e}")
|
logger.error(f"后台扫描失败: {e}")
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
await log_error("recommender_api", f"后台扫描失败: {e}", detail=traceback.format_exc())
|
||||||
await broadcast_update({
|
await broadcast_update({
|
||||||
"type": "scan_error",
|
"type": "scan_error",
|
||||||
"session": scan_session,
|
"session": scan_session,
|
||||||
|
|||||||
Binary file not shown.
@ -2,11 +2,21 @@
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy import event
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
# SQLite 异步需要 aiosqlite
|
# SQLite 异步需要 aiosqlite
|
||||||
db_url = settings.database_url.replace("sqlite:///", "sqlite+aiosqlite:///")
|
db_url = settings.database_url.replace("sqlite:///", "sqlite+aiosqlite:///")
|
||||||
engine = create_async_engine(db_url, echo=False)
|
engine = create_async_engine(db_url, echo=False)
|
||||||
|
|
||||||
|
# 启用 WAL 模式:允许读写并发,写操作不阻塞读操作
|
||||||
|
@event.listens_for(engine.sync_engine, "connect")
|
||||||
|
def _set_sqlite_pragma(dbapi_connection, connection_record):
|
||||||
|
cursor = dbapi_connection.cursor()
|
||||||
|
cursor.execute("PRAGMA journal_mode=WAL")
|
||||||
|
cursor.execute("PRAGMA busy_timeout=5000") # 写锁最多等待 5 秒
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
@ -26,8 +36,6 @@ async def init_db():
|
|||||||
await conn.run_sync(metadata.create_all)
|
await conn.run_sync(metadata.create_all)
|
||||||
# 补充新增列(SQLite ALTER TABLE ADD COLUMN,已存在会忽略)
|
# 补充新增列(SQLite ALTER TABLE ADD COLUMN,已存在会忽略)
|
||||||
for col_sql in [
|
for col_sql in [
|
||||||
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
|
||||||
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
|
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
|
||||||
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
|
||||||
"ALTER TABLE recommendations ADD COLUMN position_score REAL",
|
"ALTER TABLE recommendations ADD COLUMN position_score REAL",
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -39,7 +39,7 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
|
|||||||
rec.scan_session = scan_session
|
rec.scan_session = scan_session
|
||||||
rec.created_at = datetime.now()
|
rec.created_at = datetime.now()
|
||||||
|
|
||||||
# 持久化到数据库
|
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
|
||||||
await _save_to_db(result)
|
await _save_to_db(result)
|
||||||
|
|
||||||
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
# 更新历史推荐跟踪(检查之前推荐的后续表现)
|
||||||
@ -392,73 +392,79 @@ async def _save_to_db(result: dict):
|
|||||||
"temp": mt.temperature,
|
"temp": mt.temperature,
|
||||||
})
|
})
|
||||||
|
|
||||||
# 保存板块热度(先清除同一 trade_date 的旧数据,避免重复)
|
# 保存板块热度(先清除同一 trade_date 的旧数据,再批量插入)
|
||||||
trade_date_val = mt.trade_date if mt else ""
|
trade_date_val = mt.trade_date if mt else ""
|
||||||
if trade_date_val:
|
if trade_date_val:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("DELETE FROM sector_heat WHERE trade_date = :td"),
|
text("DELETE FROM sector_heat WHERE trade_date = :td"),
|
||||||
{"td": trade_date_val},
|
{"td": trade_date_val},
|
||||||
)
|
)
|
||||||
for sector in result.get("hot_sectors", []):
|
sector_values = [
|
||||||
stmt = tables.sector_heat_table.insert().values(
|
{
|
||||||
sector_code=sector.sector_code,
|
"sector_code": sector.sector_code,
|
||||||
sector_name=sector.sector_name,
|
"sector_name": sector.sector_name,
|
||||||
pct_change=sector.pct_change,
|
"pct_change": sector.pct_change,
|
||||||
capital_inflow=sector.capital_inflow,
|
"capital_inflow": sector.capital_inflow,
|
||||||
limit_up_count=sector.limit_up_count,
|
"limit_up_count": sector.limit_up_count,
|
||||||
heat_score=sector.heat_score,
|
"heat_score": sector.heat_score,
|
||||||
stage=sector.stage,
|
"stage": sector.stage,
|
||||||
days_continuous=sector.days_continuous,
|
"days_continuous": sector.days_continuous,
|
||||||
member_count=sector.member_count,
|
"member_count": sector.member_count,
|
||||||
leading_stocks=json.dumps(sector.leading_stocks, ensure_ascii=False),
|
"leading_stocks": json.dumps(sector.leading_stocks, ensure_ascii=False),
|
||||||
pct_trend=json.dumps(sector.pct_trend, ensure_ascii=False),
|
"pct_trend": json.dumps(sector.pct_trend, ensure_ascii=False),
|
||||||
turnover_avg=sector.turnover_avg,
|
"turnover_avg": sector.turnover_avg,
|
||||||
main_force_ratio=sector.main_force_ratio,
|
"main_force_ratio": sector.main_force_ratio,
|
||||||
trade_date=trade_date_val,
|
"trade_date": trade_date_val,
|
||||||
)
|
}
|
||||||
await db.execute(stmt)
|
for sector in result.get("hot_sectors", [])
|
||||||
|
]
|
||||||
|
if sector_values:
|
||||||
|
await db.execute(tables.sector_heat_table.insert(), sector_values)
|
||||||
|
|
||||||
# 保存推荐(按 ts_code 清除当日旧记录,避免同一天多次扫描产生重复)
|
# 保存推荐:先批量清除当日旧记录,再批量插入
|
||||||
today_str = datetime.now().strftime("%Y-%m-%d")
|
today_str = datetime.now().strftime("%Y-%m-%d")
|
||||||
now_dt = datetime.now()
|
now_dt = datetime.now()
|
||||||
saved_count = 0
|
qualified_recs = [rec for rec in result.get("recommendations", []) if rec.score >= 60]
|
||||||
for rec in result.get("recommendations", []):
|
if qualified_recs:
|
||||||
if rec.score < 60:
|
# 批量删除当日同一 ts_code 的旧记录
|
||||||
continue
|
codes = [rec.ts_code for rec in qualified_recs]
|
||||||
await db.execute(
|
await db.execute(
|
||||||
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code = :code"),
|
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code IN :codes"),
|
||||||
{"today": today_str, "code": rec.ts_code},
|
{"today": today_str, "codes": tuple(codes)},
|
||||||
)
|
)
|
||||||
stmt = tables.recommendations_table.insert().values(
|
# 批量插入新记录
|
||||||
ts_code=rec.ts_code,
|
rec_values = [
|
||||||
name=rec.name,
|
{
|
||||||
sector=rec.sector,
|
"ts_code": rec.ts_code,
|
||||||
score=rec.score,
|
"name": rec.name,
|
||||||
market_temp_score=rec.market_temp_score,
|
"sector": rec.sector,
|
||||||
sector_score=rec.sector_score,
|
"score": rec.score,
|
||||||
capital_score=rec.capital_score,
|
"market_temp_score": rec.market_temp_score,
|
||||||
technical_score=rec.technical_score,
|
"sector_score": rec.sector_score,
|
||||||
supply_demand_score=rec.supply_demand_score,
|
"capital_score": rec.capital_score,
|
||||||
price_action_score=rec.price_action_score,
|
"technical_score": rec.technical_score,
|
||||||
position_score=rec.position_score,
|
"supply_demand_score": rec.supply_demand_score,
|
||||||
valuation_score=rec.valuation_score,
|
"price_action_score": rec.price_action_score,
|
||||||
signal=rec.signal,
|
"position_score": rec.position_score,
|
||||||
entry_price=rec.entry_price,
|
"valuation_score": rec.valuation_score,
|
||||||
target_price=rec.target_price,
|
"signal": rec.signal,
|
||||||
stop_loss=rec.stop_loss,
|
"entry_price": rec.entry_price,
|
||||||
reasons=json.dumps(rec.reasons, ensure_ascii=False),
|
"target_price": rec.target_price,
|
||||||
llm_analysis=rec.llm_analysis,
|
"stop_loss": rec.stop_loss,
|
||||||
strategy=rec.strategy,
|
"reasons": json.dumps(rec.reasons, ensure_ascii=False),
|
||||||
entry_signal_type=rec.entry_signal_type,
|
"llm_analysis": rec.llm_analysis,
|
||||||
llm_score=rec.llm_score,
|
"strategy": rec.strategy,
|
||||||
scan_session=rec.scan_session,
|
"entry_signal_type": rec.entry_signal_type,
|
||||||
created_at=now_dt,
|
"llm_score": rec.llm_score,
|
||||||
)
|
"scan_session": rec.scan_session,
|
||||||
await db.execute(stmt)
|
"created_at": now_dt,
|
||||||
saved_count += 1
|
}
|
||||||
|
for rec in qualified_recs
|
||||||
|
]
|
||||||
|
await db.execute(tables.recommendations_table.insert(), rec_values)
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
logger.info(f"已保存 {saved_count} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)")
|
logger.info(f"已保存 {len(qualified_recs)} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)")
|
||||||
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
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
|
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@ -1,21 +1,5 @@
|
|||||||
{
|
{
|
||||||
"pages": {
|
"pages": {
|
||||||
"/(public)/page": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(public)/page.js"
|
|
||||||
],
|
|
||||||
"/(public)/layout": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/chunks/app/(public)/layout.js"
|
|
||||||
],
|
|
||||||
"/layout": [
|
|
||||||
"static/chunks/webpack.js",
|
|
||||||
"static/chunks/main-app.js",
|
|
||||||
"static/css/app/layout.css",
|
|
||||||
"static/chunks/app/layout.js"
|
|
||||||
],
|
|
||||||
"/(auth)/dashboard/page": [
|
"/(auth)/dashboard/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
@ -26,6 +10,12 @@
|
|||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(auth)/layout.js"
|
"static/chunks/app/(auth)/layout.js"
|
||||||
],
|
],
|
||||||
|
"/layout": [
|
||||||
|
"static/chunks/webpack.js",
|
||||||
|
"static/chunks/main-app.js",
|
||||||
|
"static/css/app/layout.css",
|
||||||
|
"static/chunks/app/layout.js"
|
||||||
|
],
|
||||||
"/(auth)/recommendations/page": [
|
"/(auth)/recommendations/page": [
|
||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
@ -35,6 +25,11 @@
|
|||||||
"static/chunks/webpack.js",
|
"static/chunks/webpack.js",
|
||||||
"static/chunks/main-app.js",
|
"static/chunks/main-app.js",
|
||||||
"static/chunks/app/(auth)/settings/page.js"
|
"static/chunks/app/(auth)/settings/page.js"
|
||||||
|
],
|
||||||
|
"/_not-found/page": [
|
||||||
|
"static/chunks/webpack.js",
|
||||||
|
"static/chunks/main-app.js",
|
||||||
|
"static/chunks/app/_not-found/page.js"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"/(public)/page": "app/(public)/page.js",
|
"/_not-found/page": "app/_not-found/page.js",
|
||||||
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
|
||||||
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
|
||||||
"/(auth)/settings/page": "app/(auth)/settings/page.js"
|
"/(auth)/settings/page": "app/(auth)/settings/page.js"
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"node": {},
|
"node": {},
|
||||||
"edge": {},
|
"edge": {},
|
||||||
"encryptionKey": "q9MinLsRBzgOwAUsxhHLGTEgY9kBDWvoZzf7iynqzyI="
|
"encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8="
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
import { fetchAPI, postAPI } from "@/lib/api";
|
||||||
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
|
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
|
||||||
import MarketTemp from "@/components/market-temp";
|
import MarketTemp from "@/components/market-temp";
|
||||||
@ -17,6 +17,8 @@ interface ScanStatus {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SCAN_TIMEOUT_MS = 120_000; // 扫描超时:120秒后自动刷新数据
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [data, setData] = useState<LatestResult | null>(null);
|
const [data, setData] = useState<LatestResult | null>(null);
|
||||||
@ -29,6 +31,7 @@ export default function DashboardPage() {
|
|||||||
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
const [indices, setIndices] = useState<IndexOverview[]>([]);
|
||||||
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
const [dailyReview, setDailyReview] = useState<string | null>(null);
|
||||||
const [generatingReview, setGeneratingReview] = useState(false);
|
const [generatingReview, setGeneratingReview] = useState(false);
|
||||||
|
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@ -55,12 +58,21 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 清除扫描超时计时器
|
||||||
|
const clearScanTimeout = useCallback(() => {
|
||||||
|
if (scanTimeoutRef.current) {
|
||||||
|
clearTimeout(scanTimeoutRef.current);
|
||||||
|
scanTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
useWebSocket(
|
useWebSocket(
|
||||||
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
|
||||||
|
clearScanTimeout();
|
||||||
if (msg.type === "scan_update") {
|
if (msg.type === "scan_update") {
|
||||||
const modeLabel = msg.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
const modeLabel = msg.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
||||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
|
||||||
@ -75,7 +87,7 @@ export default function DashboardPage() {
|
|||||||
// 其他消息类型(如 llm_analysis_ready),刷新数据
|
// 其他消息类型(如 llm_analysis_ready),刷新数据
|
||||||
loadData();
|
loadData();
|
||||||
}
|
}
|
||||||
}, [loadData]),
|
}, [loadData, clearScanTimeout]),
|
||||||
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -96,6 +108,14 @@ export default function DashboardPage() {
|
|||||||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
setRefreshResult("扫描已启动,完成后自动刷新...");
|
||||||
// 保持 refreshing,等待 WS 推送
|
// 保持 refreshing,等待 WS 推送
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置超时:如果 120 秒内没收到 WebSocket 消息,自动停止加载状态并刷新数据
|
||||||
|
scanTimeoutRef.current = setTimeout(() => {
|
||||||
|
setRefreshResult("扫描超时,已自动刷新数据");
|
||||||
|
setRefreshing(false);
|
||||||
|
loadData();
|
||||||
|
setTimeout(() => setRefreshResult(null), 5000);
|
||||||
|
}, SCAN_TIMEOUT_MS);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("触发扫描失败:", e);
|
console.error("触发扫描失败:", e);
|
||||||
setRefreshResult("触发扫描失败,请重试");
|
setRefreshResult("触发扫描失败,请重试");
|
||||||
@ -104,6 +124,11 @@ export default function DashboardPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 清理超时计时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearScanTimeout();
|
||||||
|
}, [clearScanTimeout]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">
|
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { fetchAPI, postAPI } from "@/lib/api";
|
import { fetchAPI } from "@/lib/api";
|
||||||
import type { DayGroup, PerformanceStats } from "@/lib/api";
|
import type { DayGroup, PerformanceStats } from "@/lib/api";
|
||||||
import StockCard from "@/components/stock-card";
|
import StockCard from "@/components/stock-card";
|
||||||
import { useWebSocket } from "@/hooks/use-websocket";
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
function formatDate(dateStr: string): string {
|
||||||
const d = new Date(dateStr);
|
const d = new Date(dateStr);
|
||||||
@ -23,20 +22,15 @@ export default function RecommendationsPage() {
|
|||||||
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
|
||||||
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
const [llmEnabled, setLlmEnabled] = useState(false);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [refreshResult, setRefreshResult] = useState<string | null>(null);
|
|
||||||
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const [history, health, perf] = await Promise.all([
|
const [history, perf] = await Promise.all([
|
||||||
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
|
||||||
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
|
|
||||||
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
|
||||||
]);
|
]);
|
||||||
setDayGroups(history);
|
setDayGroups(history);
|
||||||
setLlmEnabled(health.llm_enabled);
|
|
||||||
setPerformance(perf);
|
setPerformance(perf);
|
||||||
|
|
||||||
// 默认展开最近一天
|
// 默认展开最近一天
|
||||||
@ -56,50 +50,6 @@ export default function RecommendationsPage() {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
// WebSocket 监听扫描结果
|
|
||||||
useWebSocket(
|
|
||||||
useCallback((data: { type: string; session?: string; count?: number; temperature?: number; scan_mode?: string; message?: string }) => {
|
|
||||||
if (data.type === "scan_update") {
|
|
||||||
const modeLabel = data.scan_mode === "intraday" ? "盘中实时" : "盘后";
|
|
||||||
setRefreshResult(`${modeLabel}扫描完成,发现 ${data.count ?? 0} 只股票`);
|
|
||||||
setRefreshing(false);
|
|
||||||
loadData();
|
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
|
||||||
} else if (data.type === "scan_error") {
|
|
||||||
setRefreshResult("扫描失败,请重试");
|
|
||||||
setRefreshing(false);
|
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
|
||||||
}
|
|
||||||
}, [loadData]),
|
|
||||||
["scan_update", "scan_error"]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
|
||||||
setRefreshing(true);
|
|
||||||
setRefreshResult(null);
|
|
||||||
try {
|
|
||||||
const res = await postAPI<{
|
|
||||||
status: string;
|
|
||||||
message?: string;
|
|
||||||
is_trading: boolean;
|
|
||||||
}>("/api/recommendations/refresh?scan_session=manual");
|
|
||||||
|
|
||||||
if (res.status === "already_running") {
|
|
||||||
setRefreshResult(res.message || "扫描正在执行中,请稍候");
|
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
|
||||||
// 不关闭 refreshing,等待 WS 推送完成通知
|
|
||||||
} else if (res.status === "scanning") {
|
|
||||||
setRefreshResult("扫描已启动,完成后自动刷新...");
|
|
||||||
// 保持 refreshing 状态,等待 WS 推送
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("触发扫描失败:", e);
|
|
||||||
setRefreshResult("触发扫描失败,请重试");
|
|
||||||
setRefreshing(false);
|
|
||||||
setTimeout(() => setRefreshResult(null), 5000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDay = (date: string) => {
|
const toggleDay = (date: string) => {
|
||||||
setExpandedDays((prev) => {
|
setExpandedDays((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@ -135,30 +85,8 @@ export default function RecommendationsPage() {
|
|||||||
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span> 天记录
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleRefresh}
|
|
||||||
disabled={refreshing}
|
|
||||||
className="text-xs px-3 py-1.5 sm:px-4 sm:py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg sm:rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium self-start sm:self-auto"
|
|
||||||
>
|
|
||||||
{refreshing ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
|
|
||||||
扫描中...
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"立即扫描"
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scan result toast */}
|
|
||||||
{refreshResult && (
|
|
||||||
<div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400 animate-fade-in-up flex items-center gap-2 mb-5">
|
|
||||||
<span className="w-1 h-1 rounded-full bg-amber-400" />
|
|
||||||
{refreshResult}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Performance Stats */}
|
{/* Performance Stats */}
|
||||||
{performance && performance.total_recommendations > 0 && (
|
{performance && performance.total_recommendations > 0 && (
|
||||||
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
|
||||||
@ -316,7 +244,7 @@ export default function RecommendationsPage() {
|
|||||||
className="animate-fade-in-up"
|
className="animate-fade-in-up"
|
||||||
style={{ animationDelay: `${i * 40}ms` }}
|
style={{ animationDelay: `${i * 40}ms` }}
|
||||||
>
|
>
|
||||||
<StockCard rec={rec} showLLMLoading={isToday && llmEnabled} />
|
<StockCard rec={rec} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user