This commit is contained in:
aaron 2026-04-28 12:22:43 +08:00
parent e1d4916615
commit 76bad64163
3 changed files with 212 additions and 153 deletions

View File

@ -13,11 +13,28 @@ from app.db.database import init_db
from app.engine.scheduler import start_scheduler, stop_scheduler from app.engine.scheduler import start_scheduler, stop_scheduler
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
logging.basicConfig( def configure_logging() -> None:
level=logging.DEBUG if settings.debug else logging.INFO, logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=logging.DEBUG if settings.debug else logging.INFO,
datefmt="%Y-%m-%d %H:%M:%S", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) datefmt="%Y-%m-%d %H:%M:%S",
)
# 保留应用日志的调试能力,但压制基础设施层的噪音。
noisy_loggers = {
"aiosqlite": logging.WARNING,
"sqlalchemy": logging.WARNING,
"sqlalchemy.engine": logging.WARNING,
"sqlalchemy.pool": logging.WARNING,
"httpx": logging.INFO,
"httpcore": logging.WARNING,
"uvicorn.access": logging.INFO,
}
for name, level in noisy_loggers.items():
logging.getLogger(name).setLevel(level)
configure_logging()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -11,8 +11,6 @@ import type {
SectorData, SectorData,
StrategyBoard, StrategyBoard,
} from "@/lib/api"; } from "@/lib/api";
import MarketTemp from "@/components/market-temp";
import SectorHeatmap from "@/components/sector-heatmap";
import { ThemeToggle } from "@/components/theme-toggle"; import { ThemeToggle } from "@/components/theme-toggle";
import { useAuth } from "@/hooks/use-auth"; import { useAuth } from "@/hooks/use-auth";
import { useWebSocket } from "@/hooks/use-websocket"; import { useWebSocket } from "@/hooks/use-websocket";
@ -209,9 +207,7 @@ export default function DashboardPage() {
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-text-muted mt-1"> <p className="mt-1 text-xs text-text-muted"></p>
</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -242,17 +238,27 @@ export default function DashboardPage() {
observeCount={observe.length} observeCount={observe.length}
/> />
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(280px,0.7fr)] gap-4 animate-fade-in-up"> <div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.2fr)_minmax(300px,0.8fr)] gap-4 animate-fade-in-up">
<ActionPanel <ActionPanel
actions={todayActions} actions={todayActions}
focusQueue={focusQueue.slice(0, 4)} summary={marketSummary}
fallbackTitle={actionable.length ? "优先执行" : watch.length ? "重点观察" : "仅保留观察"}
/> />
<ExecutionPanel <FocusPanel
recommendations={recommendations} focusQueue={focusQueue.slice(0, 3)}
actionableCount={actionable.length} actionableCount={actionable.length}
watchCount={watch.length} watchCount={watch.length}
observeCount={observe.length} observeCount={observe.length}
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_320px] gap-4 animate-fade-in-up">
<MarketSnapshot
marketTemperature={marketTemperature ?? data?.market_temperature ?? null}
indices={indices}
sectors={sectors}
summary={marketSummary}
/>
<AdminPanel
isAdmin={user?.role === "admin"} isAdmin={user?.role === "admin"}
opsStatus={opsStatus} opsStatus={opsStatus}
refreshing={refreshing} refreshing={refreshing}
@ -261,11 +267,6 @@ export default function DashboardPage() {
onAction={handleAdminAction} onAction={handleAdminAction}
/> />
</div> </div>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-4 animate-fade-in-up">
<MarketTemp data={marketTemperature ?? data?.market_temperature ?? null} indices={indices} />
<SectorHeatmap sectors={sectors} />
</div>
</div> </div>
); );
} }
@ -321,63 +322,147 @@ function DecisionHero({
function ActionPanel({ function ActionPanel({
actions, actions,
focusQueue, summary,
fallbackTitle,
}: { }: {
actions: ReturnType<typeof buildActionGuides>; actions: ReturnType<typeof buildActionGuides>;
focusQueue: RecommendationData[]; summary: ReturnType<typeof buildMarketSummary>;
fallbackTitle: string;
}) { }) {
return ( return (
<div className="space-y-4"> <div className="glass-card-static p-4 md:p-5">
<div className="glass-card-static p-4 md:p-5"> <div className="flex flex-wrap items-center gap-2">
<div className="flex items-center justify-between gap-3"> <CompactBadge label="策略" value={summary.modeLabel} />
<div> <CompactBadge label="仓位" value={summary.positionLabel} />
<h3 className="text-sm font-semibold text-text-primary"></h3> <CompactBadge label="风险" value={summary.riskLabel} />
<p className="mt-1 text-xs text-text-muted"></p>
</div>
<a href="/strategy" className="text-xs text-text-muted transition-colors hover:text-amber-400">
</a>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<ActionBucket title="优先动作" items={actions.priority} tone="priority" />
<ActionBucket title="观察队列" items={actions.watch} tone="watch" />
<ActionBucket title="回避事项" items={actions.avoid} tone="avoid" />
</div>
</div> </div>
<div className="glass-card-static p-4 md:p-5"> <div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="flex items-center justify-between gap-3"> <ActionBucket title="现在做" items={actions.priority} tone="priority" />
<div> <ActionBucket title="盯住" items={actions.watch} tone="watch" />
<h3 className="text-sm font-semibold text-text-primary">{fallbackTitle}</h3> <ActionBucket title="不要做" items={actions.avoid} tone="avoid" />
<p className="mt-1 text-xs text-text-muted"></p>
</div>
<a href="/recommendations" className="text-xs text-text-muted transition-colors hover:text-amber-400">
</a>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
{focusQueue.length ? (
focusQueue.map((rec) => <FocusStockCard key={rec.ts_code} rec={rec} />)
) : (
<div className="col-span-full rounded-2xl border border-border-subtle bg-surface-1/70 p-6 text-center text-sm text-text-muted">
</div>
)}
</div>
</div> </div>
</div> </div>
); );
} }
function ExecutionPanel({ function FocusPanel({
recommendations, focusQueue,
actionableCount, actionableCount,
watchCount, watchCount,
observeCount, observeCount,
}: {
focusQueue: RecommendationData[];
actionableCount: number;
watchCount: number;
observeCount: number;
}) {
return (
<div className="glass-card-static p-4 md:p-5">
<div className="flex items-start 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>
<div className="grid grid-cols-3 gap-2 text-center">
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
<MiniCount label="关注" value={watchCount} tone="text-amber-400" />
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
</div>
</div>
<div className="mt-4 space-y-3">
{focusQueue.length ? (
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">
</div>
)}
</div>
</div>
);
}
function MarketSnapshot({
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="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">
<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" />
<EvidenceStat label="跌停" value={marketTemperature?.limit_down_count ?? 0} tone="text-cyan-400" />
</div>
{majorIndices.length ? (
<div className="mt-4 grid grid-cols-1 md:grid-cols-3 gap-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>
<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 rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className="text-[11px] font-semibold text-text-secondary"></div>
<div className="mt-3 space-y-2">
{leadingSectors.map((sector) => {
const pct = sector.realtime_pct_change ?? sector.pct_change;
return (
<div key={sector.sector_code} className="flex items-center justify-between gap-3 text-sm">
<div className="min-w-0">
<div className="truncate font-medium text-text-primary">{sector.sector_name}</div>
<div className="text-[11px] text-text-muted">
{sector.limit_up_count > 0 ? `${sector.limit_up_count} 涨停` : "无涨停"} · {sector.stage || "mid"}
</div>
</div>
<span className={`font-mono tabular-nums ${pct >= 0 ? "text-red-400" : "text-emerald-400"}`}>
{pct >= 0 ? "+" : ""}{pct.toFixed(2)}%
</span>
</div>
);
})}
</div>
</div>
) : null}
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3 text-sm text-text-secondary">
{summary.headline}
</div>
</div>
);
}
function AdminPanel({
isAdmin, isAdmin,
opsStatus, opsStatus,
refreshing, refreshing,
@ -385,10 +470,6 @@ function ExecutionPanel({
onRefresh, onRefresh,
onAction, onAction,
}: { }: {
recommendations: RecommendationData[];
actionableCount: number;
watchCount: number;
observeCount: number;
isAdmin?: boolean; isAdmin?: boolean;
opsStatus: OpsStatusResponse | null; opsStatus: OpsStatusResponse | null;
refreshing: boolean; refreshing: boolean;
@ -396,92 +477,43 @@ function ExecutionPanel({
onRefresh: () => void; onRefresh: () => void;
onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void; onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void;
}) { }) {
if (!isAdmin || !opsStatus) return null;
return ( return (
<div className="space-y-4"> <div className="glass-card-static p-4">
<div className="glass-card-static p-4 md:p-5"> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-text-primary"></h3> <div>
<p className="mt-1 text-xs text-text-muted"></p> <h3 className="text-sm font-semibold text-text-primary"></h3>
<p className="mt-1 text-xs text-text-muted">{opsStatus.data_freshness.message}</p>
<div className="mt-4 grid grid-cols-1 gap-3">
<NavCard
href="/recommendations"
title="推荐池"
description={`当前 ${recommendations.length} 只候选,${actionableCount} 只可操作,${watchCount} 只重点关注。`}
/>
<NavCard
href="/watchlist"
title="自选股"
description="查看你的自选股跟踪、AI 诊断与定时分析结果。"
/>
<NavCard
href="/strategy"
title="系统校准"
description="看系统近期方法是否有效、哪里失效,以及下一轮该怎么调整。"
/>
<NavCard
href="/chat"
title="AI 对话"
description="用于追问个股、板块和策略,不承担首页主决策职责。"
/>
</div>
<div className="mt-4 rounded-2xl border border-border-subtle bg-surface-1/70 p-3">
<div className="flex items-center justify-between gap-3">
<span className="text-[11px] font-semibold text-text-secondary"></span>
<span className="text-[11px] text-text-muted">{recommendations.length} </span>
</div>
<div className="mt-2 grid grid-cols-3 gap-2 text-center">
<MiniCount label="可操作" value={actionableCount} tone="text-red-400" />
<MiniCount label="重点关注" value={watchCount} tone="text-amber-400" />
<MiniCount label="观察" value={observeCount} tone="text-text-secondary" />
</div>
</div> </div>
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
{opsStatus.scan_running ? "扫描中" : "空闲"}
</span>
</div> </div>
{isAdmin && opsStatus ? ( <div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-muted">
<div className="glass-card-static p-4"> <FreshnessPill label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} />
<div className="flex items-center justify-between gap-3"> <FreshnessPill label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} />
<div> <FreshnessPill label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} />
<h3 className="text-sm font-semibold text-text-primary"></h3> <FreshnessPill label="推荐" value={opsStatus.data_freshness.last_recommendation_created_at || "暂无"} />
<p className="mt-1 text-xs text-text-muted">{opsStatus.data_freshness.message}</p> </div>
</div>
<span className="rounded-full bg-surface-2 px-2 py-0.5 text-[10px] text-text-muted">
{opsStatus.scan_running ? "扫描中" : "空闲"}
</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] text-text-muted"> <div className="mt-3 flex flex-wrap gap-2">
<FreshnessPill label="市场" value={opsStatus.data_freshness.market_trade_date || "暂无"} /> <button
<FreshnessPill label="板块" value={opsStatus.data_freshness.sector_trade_date || "暂无"} /> onClick={onRefresh}
<FreshnessPill label="跟踪" value={opsStatus.data_freshness.tracking_trade_date || "暂无"} /> disabled={refreshing || !!opsRunning}
<FreshnessPill label="推荐" value={opsStatus.data_freshness.last_recommendation_created_at || "暂无"} /> className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40"
</div> >
{refreshing ? "扫描中..." : "立即扫描"}
<div className="mt-3 flex flex-wrap gap-2"> </button>
<button <button
onClick={onRefresh} onClick={() => onAction("update_tracking")}
disabled={refreshing || !!opsRunning} disabled={refreshing || !!opsRunning}
className="rounded-xl border border-amber-500/15 bg-amber-500/10 px-3 py-2 text-xs text-amber-400 disabled:opacity-40" className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
> >
{refreshing ? "扫描中..." : "立即扫描"} {opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
</button> </button>
<button </div>
onClick={() => onAction("update_tracking")}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
>
{opsRunning === "update_tracking" ? "更新中..." : "更新跟踪"}
</button>
<button
onClick={() => onAction("generate_strategy_board")}
disabled={refreshing || !!opsRunning}
className="rounded-xl border border-border-subtle bg-surface-1 px-3 py-2 text-xs text-text-secondary disabled:opacity-40"
>
{opsRunning === "generate_strategy_board" ? "生成中..." : "生成策略板"}
</button>
</div>
</div>
) : null}
</div> </div>
); );
} }
@ -530,6 +562,15 @@ function HeroFact({ label, value }: { label: string; value: string }) {
); );
} }
function CompactBadge({ label, value }: { label: string; value: string }) {
return (
<span className="inline-flex items-center gap-2 rounded-xl border border-border-subtle bg-surface-1/70 px-3 py-1.5 text-xs text-text-secondary">
<span className="text-text-muted">{label}</span>
<span className="font-medium text-text-primary">{value}</span>
</span>
);
}
function ActionBucket({ function ActionBucket({
title, title,
items, items,
@ -602,12 +643,12 @@ function FocusStockCard({ rec }: { rec: RecommendationData }) {
); );
} }
function NavCard({ href, title, description }: { href: string; title: string; description: string }) { function EvidenceStat({ label, value, tone }: { label: string; value: number; tone: string }) {
return ( return (
<a href={href} className="rounded-2xl border border-border-subtle bg-surface-1/70 p-3 transition-colors hover:border-amber-500/20"> <div className="rounded-2xl border border-border-subtle bg-surface-1/70 px-3 py-3">
<div className="text-sm font-semibold text-text-primary">{title}</div> <div className="text-[10px] uppercase tracking-wider text-text-muted">{label}</div>
<div className="mt-1 text-xs leading-5 text-text-muted">{description}</div> <div className={`mt-1 text-lg font-bold font-mono tabular-nums ${tone}`}>{value}</div>
</a> </div>
); );
} }
@ -677,6 +718,7 @@ function buildMarketSummary(
cannotDo, cannotDo,
modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新", modeLabel: strategyProfile?.market_stance || board?.recommended_mode || "等待更新",
positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"), positionLabel: board?.position_suggestion || (strategyProfile?.max_position_pct ? `${strategyProfile.max_position_pct}% 以内` : "等待更新"),
riskLabel: board?.risk_level || "等待更新",
}; };
} }