diff --git a/.gitignore b/.gitignore index d5a6f5ee..92147b91 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ frontend/*.tsbuildinfo # Browser extension build artifacts /extension.zip /extension-*.zip + diff --git a/frontend/components/dashboard/Dashboard.module.css b/frontend/components/dashboard/Dashboard.module.css index 50660139..c2f9c58a 100644 --- a/frontend/components/dashboard/Dashboard.module.css +++ b/frontend/components/dashboard/Dashboard.module.css @@ -2023,10 +2023,10 @@ z-index: 880; display: flex; flex-direction: column; - gap: 12px; - padding: 12px; + gap: 14px; + padding: 14px; border: 1px solid rgba(148, 163, 184, 0.14); - border-radius: 22px; + border-radius: 24px; background: radial-gradient( circle at 8% 50%, @@ -2113,24 +2113,84 @@ .root :global(.opportunity-strip-heading) { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - gap: 12px; - padding: 2px 4px; + gap: 16px; + padding: 4px 4px 0; } .root :global(.opportunity-strip-heading > div) { display: flex; flex-direction: column; gap: 5px; + min-width: 0; +} + +.root :global(.opportunity-strip-heading > div:first-child) { + flex: 1; +} + +.root :global(.opportunity-strip-heading span) { + color: rgba(148, 163, 184, 0.82); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; } .root :global(.opportunity-strip-heading strong) { color: #f8fafc; - font-size: 15px; + font-size: 17px; + font-weight: 820; line-height: 1.25; } +.root :global(.opportunity-strip-status) { + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: flex-end; + max-width: 360px; +} + +.root :global(.opportunity-status-chip) { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.18); + background: rgba(8, 15, 28, 0.78); + color: rgba(226, 232, 240, 0.88); + font-size: 11px; + font-weight: 780; + white-space: nowrap; +} + +.root :global(.opportunity-status-chip.live) { + border-color: rgba(74, 222, 128, 0.24); + color: #86efac; +} + +.root :global(.opportunity-status-chip.pending) { + border-color: rgba(251, 191, 36, 0.24); + color: #fcd34d; +} + +.root :global(.opportunity-status-chip.tradable) { + border-color: rgba(34, 211, 238, 0.26); + color: #67e8f9; +} + +.root :global(.opportunity-status-chip.side) { + color: rgba(191, 219, 254, 0.9); +} + +.root :global(.opportunity-status-chip.muted) { + color: rgba(148, 163, 184, 0.88); +} + .root :global(.opportunity-card-grid) { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -2146,6 +2206,7 @@ min-height: 132px; align-items: center; justify-content: center; + padding: 0 18px; border: 1px dashed rgba(34, 211, 238, 0.2); border-radius: 16px; background: @@ -2155,10 +2216,25 @@ transparent 44% ), rgba(7, 12, 22, 0.72); - color: rgba(148, 163, 184, 0.9); - font-size: 13px; - font-weight: 800; - letter-spacing: 0.04em; +} + +.root :global(.opportunity-empty-copy) { + display: grid; + gap: 6px; + justify-items: center; + text-align: center; +} + +.root :global(.opportunity-empty-copy strong) { + color: rgba(241, 245, 249, 0.94); + font-size: 15px; + font-weight: 840; +} + +.root :global(.opportunity-empty-copy span) { + color: rgba(148, 163, 184, 0.88); + font-size: 12px; + line-height: 1.6; } .root :global(.opportunity-card) { @@ -7196,6 +7272,10 @@ width: min(380px, calc(100vw - var(--sidebar-width) - 72px)); } +.root :global(.home-ai-assistant.dragging) { + user-select: none; +} + .root :global(.home-ai-launcher), .root :global(.home-ai-panel) { width: 100%; @@ -7216,11 +7296,13 @@ .root :global(.home-ai-launcher) { display: grid; - grid-template-columns: auto 1fr; + grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; padding: 14px 16px; - cursor: pointer; + cursor: grab; + touch-action: none; + user-select: none; transition: transform 160ms ease, border-color 160ms ease, @@ -7232,6 +7314,10 @@ border-color: rgba(34, 211, 238, 0.32); } +.root :global(.home-ai-launcher-copy) { + min-width: 0; +} + .root :global(.home-ai-launcher-badge) { display: inline-flex; align-items: center; @@ -7276,6 +7362,41 @@ gap: 12px; } +.root :global(.home-ai-header-actions) { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.root :global(.home-ai-drag-handle) { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: rgba(15, 23, 42, 0.42); + color: rgba(191, 219, 254, 0.84); + cursor: grab; + flex-shrink: 0; + font-size: 15px; + font-weight: 900; + line-height: 1; + letter-spacing: -0.2em; + touch-action: none; + user-select: none; +} + +.root :global(.home-ai-assistant.dragging .home-ai-drag-handle) { + cursor: grabbing; +} + +.root :global(.home-ai-assistant.dragging .home-ai-launcher) { + cursor: grabbing; + transform: none; +} + .root :global(.home-ai-close) { border: 1px solid rgba(148, 163, 184, 0.14); border-radius: 999px; diff --git a/frontend/components/dashboard/PolyWeatherDashboard.tsx b/frontend/components/dashboard/PolyWeatherDashboard.tsx index 70bb8447..c68cb651 100644 --- a/frontend/components/dashboard/PolyWeatherDashboard.tsx +++ b/frontend/components/dashboard/PolyWeatherDashboard.tsx @@ -2,7 +2,15 @@ import clsx from "clsx"; import dynamic from "next/dynamic"; import Link from "next/link"; -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; import styles from "./Dashboard.module.css"; import detailChromeStyles from "./DetailPanelChrome.module.css"; import modalChromeStyles from "./ModalChrome.module.css"; @@ -272,6 +280,37 @@ function formatEdge(value: number | null | undefined) { return `${sign}${normalized.toFixed(1)}%`; } +function normalizeEdgePercent(value: number | null | undefined) { + if (!Number.isFinite(Number(value))) return null; + const numeric = Number(value); + return Math.abs(numeric) <= 1 ? numeric * 100 : numeric; +} + +function getBestSide(detail?: CityDetail | null) { + const bestSide = String( + detail?.market_scan?.price_analysis?.best_side || "", + ).toLowerCase(); + if (bestSide.includes("yes")) return "yes"; + if (bestSide.includes("no")) return "no"; + return null; +} + +function isLiveMarketScan(detail?: CityDetail | null) { + const marketScan = detail?.market_scan; + if (!marketScan) return false; + if (marketScan.available === false) return false; + if (marketScan.primary_market?.closed === true) return false; + if (marketScan.primary_market?.active === false) return false; + if (marketScan.selected_date && detail?.local_date) { + if (marketScan.selected_date < detail.local_date) return false; + } + const endDateMs = marketScan.primary_market?.end_date + ? Date.parse(marketScan.primary_market.end_date) + : Number.NaN; + if (Number.isFinite(endDateMs) && endDateMs < Date.now()) return false; + return true; +} + function buildSparklinePoints(values: number[] | undefined) { if (!values?.length) return ""; const width = 92; @@ -296,6 +335,105 @@ type AssistantMessage = { cached?: boolean; }; +type OpportunityScanState = "pending" | "complete" | "empty" | "error"; + +type AssistantDockPosition = { + right: number; + bottom: number; +}; + +type AssistantDockDragState = { + pointerId: number; + startX: number; + startY: number; + right: number; + bottom: number; + hasMoved: boolean; +}; + +const HOME_AI_DOCK_POSITION_STORAGE_KEY = + "polyweather_home_ai_dock_position_v1"; + +function readAssistantDockPosition() { + if (typeof window === "undefined") return null; + try { + const raw = window.localStorage.getItem(HOME_AI_DOCK_POSITION_STORAGE_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as AssistantDockPosition | null; + if ( + parsed && + Number.isFinite(Number(parsed.right)) && + Number.isFinite(Number(parsed.bottom)) + ) { + return { + right: Number(parsed.right), + bottom: Number(parsed.bottom), + }; + } + } catch { + // Ignore malformed dock position cache. + } + return null; +} + +function writeAssistantDockPosition(position: AssistantDockPosition | null) { + if (typeof window === "undefined") return; + if (!position) { + window.localStorage.removeItem(HOME_AI_DOCK_POSITION_STORAGE_KEY); + return; + } + window.localStorage.setItem( + HOME_AI_DOCK_POSITION_STORAGE_KEY, + JSON.stringify(position), + ); +} + +function getDefaultAssistantDockPosition() { + if (typeof window === "undefined" || window.innerWidth <= 960) { + return null; + } + const detailPanel = document.querySelector( + ".home-intelligence-panel.full", + ) as HTMLElement | null; + const opportunityStrip = document.querySelector( + ".home-opportunity-strip", + ) as HTMLElement | null; + const panelRect = detailPanel?.getBoundingClientRect(); + const stripRect = opportunityStrip?.getBoundingClientRect(); + return { + right: panelRect + ? Math.max(24, window.innerWidth - panelRect.left + 18) + : 408, + bottom: stripRect + ? Math.max(24, window.innerHeight - stripRect.top + 18) + : 340, + }; +} + +function clampAssistantDockPosition( + position: AssistantDockPosition | null, + dockElement?: HTMLElement | null, +) { + if (typeof window === "undefined" || window.innerWidth <= 960) { + return null; + } + const rect = dockElement?.getBoundingClientRect(); + const width = rect?.width || 380; + const height = rect?.height || 88; + const minOffset = 24; + const maxRight = Math.max(minOffset, window.innerWidth - width - minOffset); + const maxBottom = Math.max( + minOffset, + window.innerHeight - height - minOffset, + ); + const base = position || getDefaultAssistantDockPosition(); + if (!base) return null; + return { + right: Math.min(maxRight, Math.max(minOffset, Number(base.right))), + bottom: Math.min(maxBottom, Math.max(minOffset, Number(base.bottom))), + }; +} + function hashSnapshotText(source: string) { let hash = 5381; for (let index = 0; index < source.length; index += 1) { @@ -1395,23 +1533,199 @@ function HomeIntelligencePanel({ snapshots }: { snapshots: CitySnapshot[] }) { ); } -function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) { +function OpportunityStrip({ + snapshots, + marketScanStatusByCity, + scanTargetNames, +}: { + snapshots: CitySnapshot[]; + marketScanStatusByCity: Record; + scanTargetNames: string[]; +}) { const { locale } = useI18n(); const store = useDashboardStore(); - const summaryCards = useMemo( - () => buildDashboardSummaryCards(snapshots, locale), - [locale, snapshots], - ); + const stripState = useMemo(() => { + const targetSet = new Set(scanTargetNames); + const targetedSnapshots = (scanTargetNames.length + ? snapshots.filter((snapshot) => targetSet.has(snapshot.city.name)) + : snapshots + ).slice(0, Math.max(scanTargetNames.length, 1)); + const effectiveStatusByCity = Object.fromEntries( + targetedSnapshots.map((snapshot) => [ + snapshot.city.name, + marketScanStatusByCity[snapshot.city.name] || + (snapshot.detail?.market_scan ? "complete" : undefined), + ]), + ) as Record; + const completedCount = scanTargetNames.filter((cityName) => { + const status = effectiveStatusByCity[cityName]; + return status === "complete" || status === "empty"; + }).length; + const pendingCount = scanTargetNames.filter( + (cityName) => effectiveStatusByCity[cityName] === "pending", + ).length; + const errorCount = scanTargetNames.filter( + (cityName) => effectiveStatusByCity[cityName] === "error", + ).length; + const liveSnapshots = targetedSnapshots.filter((snapshot) => + isLiveMarketScan(snapshot.detail), + ); + const tradableSnapshots = targetedSnapshots + .filter((snapshot) => snapshot.tradableOpportunity) + .sort((left, right) => { + const leftEdge = normalizeEdgePercent(getMarketEdgeValue(left.detail)); + const rightEdge = normalizeEdgePercent(getMarketEdgeValue(right.detail)); + return ( + Number(rightEdge ?? Number.NEGATIVE_INFINITY) - + Number(leftEdge ?? Number.NEGATIVE_INFINITY) || right.score - left.score + ); + }); + const yesCount = liveSnapshots.filter( + (snapshot) => getBestSide(snapshot.detail) === "yes", + ).length; + const noCount = liveSnapshots.filter( + (snapshot) => getBestSide(snapshot.detail) === "no", + ).length; + const avgTradableEdge = tradableSnapshots.length + ? tradableSnapshots.reduce((sum, snapshot) => { + const edge = normalizeEdgePercent(getMarketEdgeValue(snapshot.detail)); + return sum + (edge ?? 0); + }, 0) / tradableSnapshots.length + : null; + const bestTradableSnapshot = tradableSnapshots[0] || null; + const bestTradableCityName = bestTradableSnapshot + ? getLocalizedCityDisplay( + bestTradableSnapshot.city, + locale, + bestTradableSnapshot.summary, + bestTradableSnapshot.detail, + ) + : "--"; + const highCount = snapshots.filter( + (snapshot) => snapshot.city.deb_recent_tier === "high", + ).length; + const mediumCount = snapshots.filter( + (snapshot) => snapshot.city.deb_recent_tier === "medium", + ).length; + const lowCount = snapshots.filter( + (snapshot) => snapshot.city.deb_recent_tier === "low", + ).length; - const items = useMemo( - () => - snapshots - .filter((snapshot) => snapshot.tradableOpportunity) - .slice(0, 5), - [snapshots], - ); + return { + items: tradableSnapshots.slice(0, 5), + completedCount, + pendingCount, + errorCount, + liveCount: liveSnapshots.length, + tradableCount: tradableSnapshots.length, + yesCount, + noCount, + summaryCards: [ + { + key: "scan-progress", + title: locale === "en-US" ? "Scan Progress" : "扫描进度", + items: [ + { + label: locale === "en-US" ? "Completed" : "已完成", + value: `${completedCount}/${scanTargetNames.length || snapshots.length}`, + accent: "cyan" as const, + }, + { + label: locale === "en-US" ? "Scanning" : "扫描中", + value: String(pendingCount), + accent: "amber" as const, + }, + { + label: locale === "en-US" ? "Errors" : "异常", + value: String(errorCount), + accent: errorCount > 0 ? ("red" as const) : ("green" as const), + }, + ], + }, + { + key: "market-live", + title: locale === "en-US" ? "Market Status" : "盘口状态", + items: [ + { + label: locale === "en-US" ? "Live" : "在线", + value: String(liveSnapshots.length), + accent: "green" as const, + }, + { + label: locale === "en-US" ? "Tradable" : "可交易", + value: String(tradableSnapshots.length), + accent: "cyan" as const, + }, + { + label: locale === "en-US" ? "No edge" : "无机会", + value: String(Math.max(completedCount - tradableSnapshots.length, 0)), + }, + ], + }, + { + key: "market-quality", + title: locale === "en-US" ? "Opportunity Quality" : "机会质量", + items: [ + { + label: locale === "en-US" ? "Avg. edge" : "平均优势", + value: formatEdge(avgTradableEdge), + accent: "green" as const, + }, + { + label: locale === "en-US" ? "Best edge" : "最高优势", + value: bestTradableSnapshot + ? formatEdge(getMarketEdgeValue(bestTradableSnapshot.detail)) + : "--", + accent: "cyan" as const, + }, + { + label: locale === "en-US" ? "Focus" : "最佳城市", + value: bestTradableCityName, + accent: "amber" as const, + }, + ], + }, + { + key: "risk-summary", + title: locale === "en-US" ? "Risk Layer" : "风险层", + items: [ + { + label: locale === "en-US" ? "High" : "高", + value: String(highCount), + accent: "red" as const, + }, + { + label: locale === "en-US" ? "Medium" : "中", + value: String(mediumCount), + accent: "amber" as const, + }, + { + label: locale === "en-US" ? "Low" : "低", + value: String(lowCount), + accent: "green" as const, + }, + ], + }, + ], + headingTitle: + tradableSnapshots.length > 0 + ? locale === "en-US" + ? `${tradableSnapshots.length} tradable markets · focus ${bestTradableCityName}` + : `发现 ${tradableSnapshots.length} 个可交易市场 · 当前最佳 ${bestTradableCityName}` + : pendingCount > 0 + ? locale === "en-US" + ? `Scanning ${completedCount}/${scanTargetNames.length || snapshots.length} cities` + : `正在扫描 ${completedCount}/${scanTargetNames.length || snapshots.length} 个城市市场层` + : locale === "en-US" + ? `Completed ${completedCount} scans · no tradable market` + : `已完成 ${completedCount} 个城市扫描 · 当前无可交易市场`, + yesCountLabel: + locale === "en-US" ? `YES bias ${yesCount}` : `YES 倾向 ${yesCount}`, + noCountLabel: + locale === "en-US" ? `NO bias ${noCount}` : `NO 倾向 ${noCount}`, + }; + }, [locale, marketScanStatusByCity, scanTargetNames, snapshots]); - // Always render: summary cards should be visible even without tradable opportunities if (!snapshots.length) return null; return ( @@ -1420,7 +1734,7 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) { aria-label={locale === "en-US" ? "Opportunity strip" : "机会条"} >
- {summaryCards.map((card) => ( + {stripState.summaryCards.map((card) => (
{card.title} @@ -1450,19 +1764,48 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
{locale === "en-US" - ? "Current Best Markets" - : "当前最佳机会市场"} + ? "Live Market Scan" + : "实时市场扫描"} - + {stripState.headingTitle} +
+
+ {locale === "en-US" - ? "Live market question · YES/NO · Edge · Trend" - : "当前市场问题 · YES/NO 价格 · Edge · 小趋势线"} - + ? `Live ${stripState.liveCount}` + : `在线 ${stripState.liveCount}`} + + 0 ? "pending" : "muted", + )} + > + {locale === "en-US" + ? `Pending ${stripState.pendingCount}` + : `待补齐 ${stripState.pendingCount}`} + + 0 ? "tradable" : "muted", + )} + > + {locale === "en-US" + ? `Tradable ${stripState.tradableCount}` + : `可交易 ${stripState.tradableCount}`} + + + {stripState.yesCountLabel} + + + {stripState.noCountLabel} +
- {items.length > 0 ? ( + {stripState.items.length > 0 ? (
- {items.map(({ city, detail, summary }, index) => { + {stripState.items.map(({ city, detail, summary }, index) => { const symbol = getTempSymbol(city, summary, detail); const debPrediction = summary?.deb?.prediction ?? detail?.deb?.prediction; @@ -1546,9 +1889,22 @@ function OpportunityStrip({ snapshots }: { snapshots: CitySnapshot[] }) {
) : (
- {locale === "en-US" - ? "No active opportunity market right now." - : "当前暂无可交易机会市场"} +
+ + {stripState.pendingCount > 0 + ? locale === "en-US" + ? "Scanning current market layers..." + : "正在扫描当前市场层..." + : locale === "en-US" + ? "No tradable market at the moment" + : "当前没有满足条件的可交易市场"} + + + {locale === "en-US" + ? `Completed ${stripState.completedCount} cities, live ${stripState.liveCount}, tradable ${stripState.tradableCount}.` + : `已完成 ${stripState.completedCount} 个城市扫描,在线盘口 ${stripState.liveCount} 个,可交易机会 ${stripState.tradableCount} 个。`} + +
)} @@ -1564,6 +1920,13 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { const [input, setInput] = useState(""); const [messages, setMessages] = useState([]); const [error, setError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const [dockPosition, setDockPosition] = + useState(null); + const dockRef = useRef(null); + const dockPositionRef = useRef(null); + const dragStateRef = useRef(null); + const suppressLauncherClickRef = useRef(false); const messagesEndRef = useRef(null); const selectedSnapshot = useMemo( @@ -1621,7 +1984,38 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { }); }, [loading, messages]); + useEffect(() => { + dockPositionRef.current = dockPosition; + }, [dockPosition]); + + useEffect(() => { + if (typeof window === "undefined") return; + + const syncDockPosition = () => { + if (window.innerWidth <= 960) { + setDockPosition(null); + return; + } + setDockPosition((current) => + clampAssistantDockPosition( + current || readAssistantDockPosition() || getDefaultAssistantDockPosition(), + dockRef.current, + ), + ); + }; + + syncDockPosition(); + window.addEventListener("resize", syncDockPosition); + return () => { + window.removeEventListener("resize", syncDockPosition); + }; + }, [isOpen]); + const openAssistant = () => { + if (suppressLauncherClickRef.current) { + suppressLauncherClickRef.current = false; + return; + } if (!store.proAccess.subscriptionActive) { setShowPaywall(true); return; @@ -1629,6 +2023,90 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { setIsOpen(true); }; + const beginDockDrag = (event: ReactPointerEvent) => { + if (typeof window === "undefined" || window.innerWidth <= 960) return; + const basePosition = clampAssistantDockPosition( + dockPositionRef.current || + readAssistantDockPosition() || + getDefaultAssistantDockPosition(), + dockRef.current, + ); + if (!basePosition) return; + event.preventDefault(); + event.stopPropagation(); + dragStateRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + right: basePosition.right, + bottom: basePosition.bottom, + hasMoved: false, + }; + event.currentTarget.setPointerCapture?.(event.pointerId); + dockPositionRef.current = basePosition; + setDockPosition(basePosition); + setIsDragging(true); + }; + + const updateDockDrag = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + const deltaX = event.clientX - dragState.startX; + const deltaY = event.clientY - dragState.startY; + if (!dragState.hasMoved && Math.abs(deltaX) + Math.abs(deltaY) >= 6) { + dragState.hasMoved = true; + } + const nextPosition = clampAssistantDockPosition( + { + right: dragState.right - deltaX, + bottom: dragState.bottom - deltaY, + }, + dockRef.current, + ); + if (!nextPosition) return; + dockPositionRef.current = nextPosition; + setDockPosition(nextPosition); + }; + + const endDockDrag = (event: ReactPointerEvent) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + try { + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + } catch { + // Pointer capture may already be released by the browser. + } + const finalPosition = clampAssistantDockPosition( + dockPositionRef.current || { + right: dragState.right, + bottom: dragState.bottom, + }, + dockRef.current, + ); + if (dragState.hasMoved) { + suppressLauncherClickRef.current = true; + } + dockPositionRef.current = finalPosition; + setDockPosition(finalPosition); + writeAssistantDockPosition(finalPosition); + dragStateRef.current = null; + setIsDragging(false); + }; + + const handleLauncherKeyDown = ( + event: ReactKeyboardEvent, + ) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + openAssistant(); + }; + const sendQuestion = async (rawQuestion?: string) => { const question = String(rawQuestion ?? input).trim(); if (!question || loading) return; @@ -1689,15 +2167,32 @@ function HomeAssistantDock({ snapshots }: { snapshots: CitySnapshot[] }) { return ( <> -
+
{!isOpen ? ( - + +
) : (
- +
+ + +
@@ -1826,7 +2341,10 @@ function DashboardScreen() { const store = useDashboardStore(); const { t } = useI18n(); const didAutoFocusRef = useRef(false); - const preloadedOpportunityRef = useRef>(new Set()); + const marketScanInflightRef = useRef>(new Set()); + const [marketScanStatusByCity, setMarketScanStatusByCity] = useState< + Record + >({}); const activeSummary = store.selectedCity ? store.citySummariesByName[store.selectedCity] || null : null; @@ -1884,6 +2402,10 @@ function DashboardScreen() { ), [store.cities, store.cityDetailsByName, store.citySummariesByName], ); + const marketScanTargetNames = useMemo( + () => homepageSnapshots.map((snapshot) => snapshot.city.name), + [homepageSnapshots], + ); const showHomepageChrome = !store.historyState.isOpen && !store.futureModalDate; @@ -1898,19 +2420,82 @@ function DashboardScreen() { void store.focusCity(topOpportunity); }, [homepageSnapshots, showHomepageChrome, store]); + useEffect(() => { + if (store.loadingState.refresh) { + marketScanInflightRef.current.clear(); + setMarketScanStatusByCity({}); + } + }, [store.loadingState.refresh]); + + useEffect(() => { + if (store.proAccess.loading) return; + if (store.proAccess.authenticated) return; + marketScanInflightRef.current.clear(); + setMarketScanStatusByCity({}); + }, [store.proAccess.authenticated, store.proAccess.loading]); + useEffect(() => { if (!showHomepageChrome) return; - const targets = homepageSnapshots - .slice(0, 4) - .map((snapshot) => snapshot.city.name); - targets.forEach((cityName) => { - if (preloadedOpportunityRef.current.has(cityName)) return; - preloadedOpportunityRef.current.add(cityName); - void store.ensureCityDetail(cityName, false, "panel").catch(() => { - preloadedOpportunityRef.current.delete(cityName); - }); + if (store.proAccess.loading || !store.proAccess.authenticated) return; + if (!marketScanTargetNames.length) return; + + let cancelled = false; + const queue = marketScanTargetNames.filter((cityName) => { + const status = marketScanStatusByCity[cityName]; + const existingMarketScan = store.cityDetailsByName[cityName]?.market_scan; + return ( + !status && + !existingMarketScan && + !marketScanInflightRef.current.has(cityName) + ); }); - }, [homepageSnapshots, showHomepageChrome, store]); + if (!queue.length) return; + + const runWorker = async () => { + while (!cancelled) { + const cityName = queue.shift(); + if (!cityName) return; + marketScanInflightRef.current.add(cityName); + setMarketScanStatusByCity((current) => ({ + ...current, + [cityName]: "pending", + })); + try { + const detail = await store.ensureCityDetail(cityName, false, "panel"); + if (cancelled) return; + const marketScan = + detail.market_scan || + (await store.ensureCityMarketScan(cityName, false)); + if (cancelled) return; + setMarketScanStatusByCity((current) => ({ + ...current, + [cityName]: marketScan ? "complete" : "empty", + })); + } catch { + if (cancelled) return; + setMarketScanStatusByCity((current) => ({ + ...current, + [cityName]: "error", + })); + } finally { + marketScanInflightRef.current.delete(cityName); + } + } + }; + + void Promise.allSettled( + Array.from({ length: Math.min(4, queue.length) }, () => runWorker()), + ); + + return () => { + cancelled = true; + }; + }, [ + marketScanStatusByCity, + marketScanTargetNames, + showHomepageChrome, + store, + ]); return (
- + ) : null} diff --git a/frontend/hooks/useDashboardStore.tsx b/frontend/hooks/useDashboardStore.tsx index 469a5150..a3c1fab3 100644 --- a/frontend/hooks/useDashboardStore.tsx +++ b/frontend/hooks/useDashboardStore.tsx @@ -42,6 +42,10 @@ interface DashboardStoreValue extends DashboardState { force?: boolean, depth?: "panel" | "market" | "nearby" | "full", ) => Promise; + ensureCityMarketScan: ( + cityName: string, + force?: boolean, + ) => Promise; focusCity: (cityName: string) => Promise; forecastModalMode: ForecastModalMode | null; futureModalDate: string | null; @@ -1113,6 +1117,7 @@ export function DashboardStoreProvider({ setIsPanelOpen(false); }, ensureCityDetail, + ensureCityMarketScan, focusCity, forecastModalMode, futureModalDate, diff --git a/index.html b/index.html deleted file mode 100644 index e08f458c..00000000 --- a/index.html +++ /dev/null @@ -1,487 +0,0 @@ - - - - - -PolyWeather - 天气衍生品智能分析平台 - - - - - - - -
- - -
-
LIVE
- - - - -
-
- - -
- - - - -
-
-
-
全球天气态势 更新时间:09:41 北京
-
-
-
温度
-
降水
-
风场
-
- -
-
-
-
-
- -
-
-
-
-
- -
-
-
-
- -
芝加哥 72°F
-
温哥华 56°F
-
达拉斯 63°F
-
旧金山 61°F
-
纽约 66°F
-
墨西哥城 64°F
-
伦敦 57°F
-
巴黎 59°F
-
西雅图 58°F
-
迈阿密 82°F
-
北京 61°F
-
马尼拉 84°F
-
曼谷 83°F
-
圣保罗 70°F
-
-
-
- 温度 (°F) -
-
-
-41432506886104
-
-
-
- - -
-
-
-
今日机会概览查看全部
-
-
6
高风险
-
14
中等风险
-
28
低风险
-
-
-
-
市场总览查看全部
-
-
交易市场数1,248
-
活跃合约数892
-
24h 成交量$2.34M
-
平均 Edge+6.7%
-
-
-
-
模型一致性查看全部
-
-
48%
-
32%
-
20%
-
-
-
-
-
-
-
-
-
高影响窗口 (未来6小时)
-
-
影响市场数12
-
预计成交量$1.26M
-
-
-
- - -
-
-
今日最佳机会 Top 机会
-
-
-
-
-
1芝加哥高风险
-
> 76°F 出现时间 5月22日
-
YES 62¢·Edge +12.4%
-
-
-
-
2达拉斯高风险
-
> 76°F 出现时间 5月22日
-
YES 58¢·Edge +10.1%
-
-
-
-
3伦敦中等风险
-
> 76°F 出现时间 5月22日
-
YES 54¢·Edge +6.8%
-
-
-
-
4墨西哥城低风险
-
> 76°F 出现时间 5月22日
-
YES 53¢·Edge +6.8%
-
-
-
-
5马尼拉高风险
-
> 76°F 出现时间 5月22日
-
YES 51¢·Edge +4.7%
-
-
-
-
-
-
- - - -
- - - -