From a0407f69a2bdc338eb982a9777a426348089a5f7 Mon Sep 17 00:00:00 2001 From: aaron <> Date: Tue, 28 Apr 2026 12:46:10 +0800 Subject: [PATCH] 1 --- .../api/__pycache__/market.cpython-313.pyc | Bin 11168 -> 12035 bytes backend/app/api/market.py | 25 ++++- backend/app/llm/strategy_iteration.py | 67 +++++++++++ backend/app/llm/strategy_selector.py | 36 ++++++ frontend/.next/server/app-paths-manifest.json | 4 +- frontend/src/app/(auth)/dashboard/page.tsx | 47 ++++---- .../src/app/(auth)/recommendations/page.tsx | 105 +++++------------- frontend/src/app/(auth)/strategy/page.tsx | 16 ++- frontend/src/components/nav.tsx | 16 ++- 9 files changed, 205 insertions(+), 111 deletions(-) diff --git a/backend/app/api/__pycache__/market.cpython-313.pyc b/backend/app/api/__pycache__/market.cpython-313.pyc index 0480197e9946a536d89e2dc3627866ae1d87786f..e810f9d1a953f0af66e4ac8f98cc6a4a07ea5f5b 100644 GIT binary patch delta 2990 zcmai0YfK#16`ngY``|IVENfud<*~b9k4vj;?l3>GR>n29sF1usA_CC;^ zfy#X?!9Q$SZ4+`!(^O5=NKrziN_9meS(Tb9>Yu2;u%LoW)1PU}KU}*}V^wX>xy!D> z67fj;-81KY=iWK@JnsJD{Izd5?kP&S0MB>(cT$nVOAZe?xzwiS{entV`m_Q#R1kJ;^yc?(+j77{O+{0+ z_K~GTbbv1>tIdx%(W?5r0zIq-0FTTBL#=aF@KvAPUi|Fmzx&&Ji~o54hM!#)C#;r4 zJaI`|y)CZGq>lZ-HprUn!EPD=GOa@h0)$+&5(ZP6(G2QDtrwvJ0TYXsv1WUqhFh67 zAjJ)XQUzyP**W`VODBl#)j!xj6p534zv7Ii0~BT@Fk`uSjn1dEZ_!>*onmIie60nf zYw)}GAwY}Z7nUJWv+P`XARiTqr;#O5GA#;1l(Y(}q{?GrM9PYOAu2*jT2$)*85LCV zk|YT8WLX-wR0>(?0<2efChbM(;TROYE?wYtg1Slc_>rft4wl0Qwvk!|yD)yNU%E&_ zHnv=Oy6J&06Q@@+Bdi-VZfG-CW7D}fO@`@QTGPkbMrB7nWTUt-Jpy3X#%E_c)9Fm- zem*^klaj6(rX9dYWoLA=nujT780i?*;%Ot5(L%CWR?uG2u9{XZCb=;^3M=R`fFB;p z&$=4Vn)NHr{4nT8k)Q%>3k$-IRd97KjDD(AzxvY6mtMQNt^^iFwrt8BTm6=^=7#cD zdGHf=+pTBrxWk)DcwyvIx$OGr;^>XkyPnO!(4D~0hJ0$P!nN?iU5nrt{6fbBS?m~Y zB)=xZKKWKVNZ)pM6`f=^mG;_?fl>@?RB%9`dZa926XpJVl$0VYMnxWB5h5(B79L>< zk3J|w1=XtB21JOlv@gc;V`F^iOJiIw^gWERyeGzXc3(Mt_<{G)7^e&kqMgcR!w;;* zQ1>|6Yn}!dX&7K{%-I!3WB56o8A3RP@Fa39l=Dxc;wc19;&H@kz&Ju6**lJg{207{ z4SsqIU^jm5j+N-jv$syHRfWGakdaT^$8Xns456IdR3;x2N|o%!^7wF&{FV&+^#IH=$b&zXpsjeCNB)EcHvm9z7aV3_7!Z1k( z^{oD>?hTOy*|hgq?+Zw9Vl^bEJAo0Oj_Vp7M=h@rEjyFSYMoR|^6tB=2Q88 z;J!!)0LSSGz>rJ_VJOtA9VFA5)Y6)vg(P~TG{F-9^O1)i)#)Pq3IrYdDyG1{tb%0- zi}cJL_rRtyP>AgFi_fpQIydC5tqSjMd4|8xv9B#wb(7agm&_Ww3A^O)C9~`;fA<;a zkdO6}m#eKu+T(4Z75cficDB|WWdHH|^3TFPFH-C@URQ7qKCnvP@eGnPN+A4Q+4X+}k*3xtPwyjgZ~6rLxIX zLeu%3K+!$=CfjH`>cqUK$BXc1R&n%mM-*PiLxg@6#!wq`hR(Viyez@HPJY2qJ9|0Q zO3GM1)RgB}KmQY8;5vAsW;yZ`x$MQ%OyLiJ1E=_Z240>@VV-K8No`iAF0`@I+#GaK zek)Q;Y5re;;zeajsjNY<#1(Z1hucQU Ttb?2V(@_Jfe8oem`FH;x173p7 delta 2174 zcmZ`)No*Ts6#oC2apJ~X>?Tg^W^wGK!PH69Ep|xSLTRhkL5a(>6hR>$!XMPA?bk_68#ddWn?f^%Csa&=8JL4{Jnew{+>bM)+ zaSwRn>tTJo25L-wRex>V3*G=5l7mdOsrJoWb@WJ3H&plR77D)hQJp*Zl16p;*^Xr+ z_{!K*WgcqvD&1X1_m|Pj*`8JQ`ZD&0N3d&F*+JDi)UaJr>-_AvGIgA<*46k3*ZNgg zUs<9iWbZDkXIWJJE9@a9$cDTtU)#C5VJLuU8z}8+)+B?LM+9nIaaC&{3MJ7A%y5d@ zv?d&bpxW$b5K;q(t)ngBkVs3Z>5^1ZxL15s*<0bmLcuRKTZ3I*5?c_$Zm{9YkVmy5 zI7w@=XkK6)ft7%qTB?moBqfVQ06Qr|LbD#XbfD;&{?_`1#GOLe_SQ#GVc3y5tWRn% znbA(bHdOVAUu=^$1*Nn2x7f|ZDR+%@gb8P`-rmZrY(R<1BRpIowmNo;^Nx^uF+B-s=*Ad8?JLlj1;9+|p`;HHd(nJ=cpDJr*(vr%1+(T^I%^7M@9rRX6&cLOCpfsVBK0 zAq4OoVy3#GcOQNXlC}w9rRaWs*WGn<;%lrsF=tDZl*Ftpv0S&?&jqhnE?fN5FSrM- zyj6VS^Ye(f?dy1Ak^;FAPO_HrWrxC;Q(g2vb%_*SM2WSlN4Pz_tF$(`X3YdRz&Nf(524`)94`4s? zroU-zlIq_>$xu>yS~D!EtX|OI0tLG$+uDg@%_nmjbq0j#fvry#=$5gF zl`^`>li<_o)9v@Aqi@sqI|T0{;2GW|`7MHT2=n9~=7y3^o-CNlWd0v~<~iB^MhPyH zltzH_2!=eVXZOP;G4pt6?@?0N3OW>rCzII;tzambte!IDtd@tXBTr4@iq19WN-V&kUAT;>JR|0N)}C0^R}r!#@lK$yc$e23daGBPM`M*`d`eu^|r znY+&1dOCTTJF$wEPfE{^WJZ@Zo`X#0k~D|dv1A9WGp&smpc<`41?V{a0BuC2-Doa9 zpfzO3nS2rUk&Z^xP{#E_5$`FJDnh?GX=caG`=+XIFrbo`cAK>?r>7^f+AjDUb#$49 z1BeSU=lm}7-eZmT*~WWp>-Q`qW;?P`nfENpOln?`P+G7v$@-@Mw7+C;xx<6MqV5pC zF8|KxO|%fv4)F!Mc(pU;m3i!XD;Z;C3zIE|rkPHEr_2-ly1Ib21pll^oP-&mgGdSf PTU9OhEOym%k6HS^ dict: + rows = await _load_recent_tracking(limit) + report = _build_rule_report(rows) + return _derive_feedback_controls(report) + + async def _load_recent_tracking(limit: int) -> list[dict]: from sqlalchemy import text from app.db.database import get_db @@ -240,6 +246,67 @@ def _build_adjustment_suggestions( return suggestions[:6] +def _derive_feedback_controls(report: dict) -> dict: + suggestions = report.get("adjustment_suggestions", []) or [] + sample_size = int(report.get("sample_size") or 0) + + controls = { + "sample_size": sample_size, + "enabled": sample_size >= 10, + "buy_threshold_delta": 0, + "max_position_pct_delta": 0, + "actionable_limit_delta": 0, + "watch_limit_delta": 0, + "force_defensive": False, + "notes": [], + } + + if sample_size < 10: + controls["notes"].append("样本不足,暂不启用自动回写。") + return controls + + promote_count = 0 + tighten_count = 0 + reduce_count = 0 + + for item in suggestions[:6]: + action = item.get("action") + reason = item.get("reason", "") + + if action == "promote": + promote_count += 1 + controls["buy_threshold_delta"] -= 1 + controls["watch_limit_delta"] += 1 + elif action == "tighten": + tighten_count += 1 + controls["buy_threshold_delta"] += 1 + controls["actionable_limit_delta"] -= 1 + controls["max_position_pct_delta"] -= 5 + elif action == "reduce": + reduce_count += 1 + controls["buy_threshold_delta"] += 1 + controls["watch_limit_delta"] -= 1 + + if "弱势市场" in reason or item.get("target") == "defensive_watch": + controls["force_defensive"] = True + + controls["buy_threshold_delta"] = max(-2, min(3, controls["buy_threshold_delta"])) + controls["max_position_pct_delta"] = max(-10, min(5, controls["max_position_pct_delta"])) + controls["actionable_limit_delta"] = max(-2, min(1, controls["actionable_limit_delta"])) + controls["watch_limit_delta"] = max(-2, min(2, controls["watch_limit_delta"])) + + if controls["force_defensive"]: + controls["notes"].append("最近弱市亏损样本偏多,优先启用防守约束。") + elif tighten_count > promote_count: + controls["notes"].append("最近失效样本偏多,整体建议略收紧。") + elif promote_count > 0 and reduce_count == 0: + controls["notes"].append("最近有效样本改善,可适度放宽观察与出手空间。") + else: + controls["notes"].append("最近样本无明显单边倾向,仅做轻微校正。") + + return controls + + async def _generate_ai_iteration(rule_report: dict, rows: list[dict]) -> str: from app.llm.client import chat_completion diff --git a/backend/app/llm/strategy_selector.py b/backend/app/llm/strategy_selector.py index 721fd01c..c91ff944 100644 --- a/backend/app/llm/strategy_selector.py +++ b/backend/app/llm/strategy_selector.py @@ -121,6 +121,7 @@ async def select_strategy_profile( intraday: bool, ) -> StrategyProfile: profile = _select_rule_profile(market_temp, hot_sectors, intraday) + profile = await _apply_strategy_feedback(profile) if settings.deepseek_api_key: llm_profile = await _select_llm_profile(market_temp, hot_sectors, intraday, profile) @@ -151,6 +152,41 @@ def _select_rule_profile( return get_strategy_profile_by_id("defensive_watch") +async def _apply_strategy_feedback(profile: StrategyProfile) -> StrategyProfile: + from app.llm.strategy_iteration import build_strategy_feedback_controls + + try: + controls = await build_strategy_feedback_controls(limit=50) + except Exception as e: + logger.debug(f"策略反馈控制生成失败: {e}") + return profile + + if not controls.get("enabled"): + return profile + + updated = profile.model_copy(deep=True) + + if controls.get("force_defensive"): + updated.allow_trading = False + updated.actionable_limit = 0 + updated.watch_limit = min(updated.watch_limit, 3) + updated.max_position_pct = min(updated.max_position_pct, 10) + updated.market_stance = "防守观察" + + updated.buy_threshold = max(updated.min_score, min(updated.buy_threshold + int(controls.get("buy_threshold_delta") or 0), 80)) + updated.max_position_pct = max(0, min(updated.max_position_pct + int(controls.get("max_position_pct_delta") or 0), 40)) + updated.actionable_limit = max(0, min(updated.actionable_limit + int(controls.get("actionable_limit_delta") or 0), settings.actionable_limit)) + updated.watch_limit = max(1, min(updated.watch_limit + int(controls.get("watch_limit_delta") or 0), settings.watch_limit)) + + notes = controls.get("notes") or [] + if notes: + updated.notes.extend(notes[:2]) + updated.decision_note = notes[0] + + updated.generated_by = f"{updated.generated_by}+feedback" + return updated + + async def _select_llm_profile( market_temp: MarketTemperature | None, hot_sectors: list[SectorInfo], diff --git a/frontend/.next/server/app-paths-manifest.json b/frontend/.next/server/app-paths-manifest.json index 55fc5abc..1f6f5ce8 100644 --- a/frontend/.next/server/app-paths-manifest.json +++ b/frontend/.next/server/app-paths-manifest.json @@ -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)/chat/page": "app/(auth)/chat/page.js" + "/(public)/page": "app/(public)/page.js" } \ No newline at end of file diff --git a/frontend/src/app/(auth)/dashboard/page.tsx b/frontend/src/app/(auth)/dashboard/page.tsx index 4b39885b..e70ce218 100644 --- a/frontend/src/app/(auth)/dashboard/page.tsx +++ b/frontend/src/app/(auth)/dashboard/page.tsx @@ -40,21 +40,17 @@ export default function DashboardPage() { const loadData = useCallback(async () => { try { - const [latestResult, sectorResult, statusResult, overviewResult, boardResult, opsResult] = await Promise.allSettled([ + const [latestResult, sectorResult, statusResult, overviewResult] = await Promise.allSettled([ fetchAPI("/api/recommendations/latest"), fetchAPI("/api/sectors/hot?limit=8"), fetchAPI("/api/recommendations/status"), fetchAPI("/api/market/overview"), - fetchAPI("/api/market/strategy-board"), - fetchAPI("/api/market/ops-status"), ]); const latest = latestResult.status === "fulfilled" ? latestResult.value : null; const sectorData = sectorResult.status === "fulfilled" ? sectorResult.value : []; const status = statusResult.status === "fulfilled" ? statusResult.value : null; const overview = overviewResult.status === "fulfilled" ? overviewResult.value : []; - const board = boardResult.status === "fulfilled" ? boardResult.value : null; - const ops = opsResult.status === "fulfilled" ? opsResult.value : null; if (latest) { setData(latest); @@ -64,8 +60,6 @@ export default function DashboardPage() { setSectors(sectorData); if (status) setScanStatus(status); setIndices(overview); - setStrategyBoard(board); - setOpsStatus(ops); } catch (error) { console.error("加载数据失败:", error); } finally { @@ -73,6 +67,19 @@ export default function DashboardPage() { } }, []); + const loadSecondaryData = useCallback(async () => { + try { + const [board, ops] = await Promise.all([ + fetchAPI("/api/market/strategy-board").catch(() => null), + fetchAPI("/api/market/ops-status").catch(() => null), + ]); + setStrategyBoard(board); + setOpsStatus(ops); + } catch (error) { + console.error("加载次要数据失败:", error); + } + }, []); + const clearScanTimeout = useCallback(() => { if (scanTimeoutRef.current) { clearTimeout(scanTimeoutRef.current); @@ -84,6 +91,10 @@ export default function DashboardPage() { loadData(); }, [loadData]); + useEffect(() => { + loadSecondaryData(); + }, [loadSecondaryData]); + useWebSocket( useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => { clearScanTimeout(); @@ -92,6 +103,7 @@ export default function DashboardPage() { setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`); setRefreshing(false); loadData(); + loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } else if (msg.type === "scan_error") { setRefreshResult("扫描失败,请重试"); @@ -99,8 +111,9 @@ export default function DashboardPage() { setTimeout(() => setRefreshResult(null), 5000); } else { loadData(); + loadSecondaryData(); } - }, [clearScanTimeout, loadData]), + }, [clearScanTimeout, loadData, loadSecondaryData]), ["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"] ); @@ -124,6 +137,7 @@ export default function DashboardPage() { setRefreshResult("扫描超时,已自动刷新数据"); setRefreshing(false); loadData(); + loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); }, SCAN_TIMEOUT_MS); } catch (error) { @@ -134,21 +148,14 @@ export default function DashboardPage() { } }; - const handleAdminAction = async (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => { + const handleAdminAction = async (action: "update_tracking") => { setOpsRunning(action); setRefreshResult(null); try { - if (action === "update_tracking") { - const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking"); - setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`); - } else if (action === "generate_strategy_board") { - await postAPI("/api/market/generate-strategy-board"); - setRefreshResult("策略板已生成"); - } else { - await postAPI("/api/market/generate-strategy-iteration"); - setRefreshResult("策略复盘已生成"); - } + const result = await postAPI<{ tracked?: number; win_rate?: number }>("/api/recommendations/update-tracking"); + setRefreshResult(`跟踪已更新,样本 ${result.tracked ?? 0},胜率 ${(result.win_rate ?? 0).toFixed(1)}%`); await loadData(); + await loadSecondaryData(); setTimeout(() => setRefreshResult(null), 5000); } catch (error) { console.error("管理员操作失败:", error); @@ -475,7 +482,7 @@ function AdminPanel({ refreshing: boolean; opsRunning: string | null; onRefresh: () => void; - onAction: (action: "update_tracking" | "generate_strategy_board" | "generate_strategy_iteration") => void; + onAction: (action: "update_tracking") => void; }) { if (!isAdmin || !opsStatus) return null; diff --git a/frontend/src/app/(auth)/recommendations/page.tsx b/frontend/src/app/(auth)/recommendations/page.tsx index 377f3a81..213b3665 100644 --- a/frontend/src/app/(auth)/recommendations/page.tsx +++ b/frontend/src/app/(auth)/recommendations/page.tsx @@ -6,9 +6,7 @@ import type { DayGroup, LatestResult, OpsStatusResponse, - PerformanceStats, RecommendationData, - StrategyIterationReport, } from "@/lib/api"; import StockCard from "@/components/stock-card"; @@ -43,24 +41,18 @@ export default function RecommendationsPage() { const [expandedDays, setExpandedDays] = useState>(new Set()); const [historyFilter, setHistoryFilter] = useState("all"); const [focusTab, setFocusTab] = useState("actionable"); - const [performance, setPerformance] = useState(null); - const [iteration, setIteration] = useState(null); const [opsStatus, setOpsStatus] = useState(null); const loadData = useCallback(async () => { try { - const [history, latestResult, perf, iterationReport, ops] = await Promise.all([ + const [history, latestResult, ops] = await Promise.all([ fetchAPI("/api/recommendations/history?days=14"), fetchAPI("/api/recommendations/latest").catch(() => null), - fetchAPI("/api/recommendations/performance").catch(() => null), - fetchAPI("/api/market/strategy-iteration?limit=50").catch(() => null), fetchAPI("/api/market/ops-status").catch(() => null), ]); setDayGroups(history); setLatest(latestResult); - setPerformance(perf); - setIteration(iterationReport); setOpsStatus(ops); setExpandedDays((prev) => { @@ -144,8 +136,6 @@ export default function RecommendationsPage() { observe, tracking, closed, - iteration, - performance, }); return ( @@ -264,9 +254,6 @@ export default function RecommendationsPage() { 只给今天真正要处理的少量标的,剩余候选不占主视图。

- - 看系统校准 - {focusItems.length ? ( @@ -283,64 +270,32 @@ export default function RecommendationsPage() { -
-
-
-
-

后台观察

-

只保留少量名字方便回看,不参与今天的主决策。

-
- {observe.length} 只 +
+
+
+

后台观察

+

只保留少量名字方便回看,不参与今天的主决策。

- - {observe.length ? ( -
- {observe.slice(0, 12).map((rec) => ( - - {rec.name} - · - {rec.sector} - - ))} -
- ) : ( -
暂无观察池标的。
- )} + {observe.length} 只
-
-
闭环概览
-
- = 50 ? "text-red-400" : "text-emerald-400"} - /> - = 0 ? "text-red-400" : "text-emerald-400"} - signed - /> - - + {observe.length ? ( +
+ {observe.slice(0, 12).map((rec) => ( + + {rec.name} + · + {rec.sector} + + ))}
- - {(iteration?.summary || iteration?.ai_analysis) ? ( -
-
AI 迭代提示
-
- {iteration?.ai_analysis || iteration?.summary} -
-
- ) : null} -
+ ) : ( +
暂无观察池标的。
+ )}
@@ -423,8 +378,6 @@ function buildFocusSummary({ observe, tracking, closed, - iteration, - performance, }: { strategyProfile: LatestResult["strategy_profile"]; actionable: RecommendationData[]; @@ -432,8 +385,6 @@ function buildFocusSummary({ observe: RecommendationData[]; tracking: RecommendationData[]; closed: RecommendationData[]; - iteration: StrategyIterationReport | null; - performance: PerformanceStats | null; }) { const allowTrading = strategyProfile?.allow_trading ?? actionable.length > 0; const headline = @@ -453,7 +404,7 @@ function buildFocusSummary({ ? "首页只保留最接近执行的标的,真正长分析进入个股详情。" : watch.length > 0 ? "今天偏等待确认,不适合在大量候选里反复横跳。" - : "当前更多是维护候选池和复盘闭环,不是积极出手阶段。"); + : "当前没有明确优势机会,先观察,不主动扩池。"); const now = [ !allowTrading @@ -470,15 +421,13 @@ function buildFocusSummary({ : "没有确认信号前,不把观察股抬升为执行名单。", tracking.length > 0 ? `${tracking.length} 只跟踪中标的继续看兑现情况,避免只看新增不看结果。` - : "如果没有跟踪样本,说明闭环还不够,要继续积累和复盘。", + : "没有跟踪中的标的时,就把注意力集中在今天的新结论上。", ]; const later = [ observe.length > 0 ? `${observe.length} 只后台观察标的不应占据首页主注意力。` : "没有必要把弱标的堆在默认视图。", - closed.length > 0 ? `${closed.length} 只已结束样本主要用于复盘,不参与今日执行决策。` : "没有结束样本时,先积累更多闭环数据。", - iteration?.summary || performance - ? "策略迭代和胜率统计放在辅助区,不应该压过今天的执行结论。" - : "方法说明和统计信息只作为辅助,不抢首页决策位置。", + closed.length > 0 ? `${closed.length} 只已结束样本只在需要回看时再展开,不参与今日执行决策。` : "没有结束样本时,也不需要额外制造解释性信息。", + "方法说明只作为辅助,不应该压过今天的执行结论。", ]; return { headline, detail, now, later }; diff --git a/frontend/src/app/(auth)/strategy/page.tsx b/frontend/src/app/(auth)/strategy/page.tsx index 2456a6e5..4b28905a 100644 --- a/frontend/src/app/(auth)/strategy/page.tsx +++ b/frontend/src/app/(auth)/strategy/page.tsx @@ -1,7 +1,9 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; import { fetchAPI } from "@/lib/api"; +import { useAuth } from "@/hooks/use-auth"; import type { PerformanceStats, StrategyAdjustment, @@ -18,6 +20,8 @@ const ACTION_LABELS: Record = { }; export default function StrategyPage() { + const { user, loading: authLoading } = useAuth(); + const router = useRouter(); const [iteration, setIteration] = useState(null); const [performance, setPerformance] = useState(null); const [loading, setLoading] = useState(true); @@ -36,12 +40,20 @@ export default function StrategyPage() { }, []); useEffect(() => { + if (!authLoading && user?.role !== "admin") { + router.replace("/dashboard"); + return; + } + }, [authLoading, router, user]); + + useEffect(() => { + if (authLoading || user?.role !== "admin") return; loadData(); - }, [loadData]); + }, [authLoading, loadData, user]); const diagnosis = useMemo(() => buildCalibrationDiagnosis(iteration, performance), [iteration, performance]); - if (loading) { + if (authLoading || user?.role !== "admin" || loading) { return (
diff --git a/frontend/src/components/nav.tsx b/frontend/src/components/nav.tsx index 9d1c2b8d..9f1e6866 100644 --- a/frontend/src/components/nav.tsx +++ b/frontend/src/components/nav.tsx @@ -116,13 +116,15 @@ export function SidebarNav() { ); @@ -146,6 +148,8 @@ function MobileNavItem({ href, label, children }: { href: string; label: string; } export function MobileBottomNav() { + const { user } = useAuth(); + return ( );