feat: implement PolyWeather dashboard UI with Leaflet map integration and backend data collection support

This commit is contained in:
2569718930@qq.com
2026-04-26 10:22:11 +08:00
parent 1ee8408b1f
commit 312b366a50
4 changed files with 169 additions and 20 deletions
@@ -14052,3 +14052,123 @@
padding: 0 13px 13px;
}
}
/* Light-mode hard overrides for the scan terminal. Keep this block last so the
decision workspace cannot inherit the dark map/card palette from base rules. */
.root:global(.light) {
--bg-primary: #eef7ff;
--bg-secondary: #e0f2fe;
--bg-card: #ffffff;
--bg-glass: rgba(255, 255, 255, 0.92);
--border-glass: #cfe8ff;
--border-subtle: rgba(125, 171, 214, 0.34);
--text-primary: #0f172a;
--text-secondary: #334155;
--text-muted: #475569;
}
.root:global(.light) :global(.scan-terminal.light) {
background:
radial-gradient(circle at 18% 12%, rgba(59, 130, 246, 0.12), transparent 30%),
linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%) !important;
color: #0f172a !important;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-container),
.root:global(.light) :global(.leaflet-container),
.root:global(.light) :global(.leaflet-pane),
.root:global(.light) :global(.leaflet-map-pane),
.root:global(.light) :global(.leaflet-tile-pane) {
background: #eef7ff !important;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell) {
border-color: rgba(37, 99, 235, 0.18) !important;
box-shadow:
0 18px 40px rgba(61, 100, 145, 0.14),
inset 0 0 0 1px rgba(255, 255, 255, 0.72) !important;
}
:global(html.light) .root :global(.map .leaflet-tile),
.root:global(.light) :global(.map .leaflet-tile),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
filter: none !important;
opacity: 1 !important;
}
.root:global(.light) :global(.scan-city-detail-rail),
.root:global(.light) :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
background:
linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96)) !important;
border-color: rgba(37, 99, 235, 0.16) !important;
color: #0f172a !important;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-header),
.root:global(.light) :global(.scan-city-detail-rail .detail-structured-section),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-shell),
.root:global(.light) :global(.scan-city-detail-rail .detail-card) {
background: rgba(255, 255, 255, 0.74) !important;
border-color: rgba(125, 171, 214, 0.28) !important;
}
.root:global(.light) :global(.scan-city-detail-rail h2),
.root:global(.light) :global(.scan-city-detail-rail h3),
.root:global(.light) :global(.scan-city-detail-rail strong),
.root:global(.light) :global(.scan-city-detail-rail .detail-value),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-temp),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-head h3),
.root:global(.light) :global(.scan-city-detail-rail .panel-title-area h2) {
color: #0f172a !important;
}
.root:global(.light) :global(.scan-city-detail-rail p),
.root:global(.light) :global(.scan-city-detail-rail li),
.root:global(.light) :global(.scan-city-detail-rail small),
.root:global(.light) :global(.scan-city-detail-rail .panel-overline),
.root:global(.light) :global(.scan-city-detail-rail .panel-loading-hint),
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-kicker),
.root:global(.light) :global(.scan-city-detail-rail .detail-label),
.root:global(.light) :global(.scan-city-detail-rail .detail-value-muted),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-note),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-kind),
.root:global(.light) :global(.scan-city-detail-rail .detail-mini-meta),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-supporting),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-city-sub),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-volume-caption),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-score-label),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-empty) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .panel-weather-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-link),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-secondary),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-ghost) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.18) !important;
color: #1f3654 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card p),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
color: #334155 !important;
}
@@ -78,6 +78,18 @@ function ScanTerminalScreen() {
const aiHydrationQueueRef = useRef<string[]>([]);
const aiHydrationRunningRef = useRef(false);
useEffect(() => {
const root = document.documentElement;
const hadLight = root.classList.contains("light");
const hadDark = root.classList.contains("dark");
root.classList.toggle("light", themeMode === "light");
root.classList.toggle("dark", themeMode === "dark");
return () => {
root.classList.toggle("light", hadLight);
root.classList.toggle("dark", hadDark);
};
}, [themeMode]);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(terminalData?.rows || []),
[terminalData?.rows],
@@ -537,7 +549,7 @@ function ScanTerminalScreen() {
if (store.proAccess.loading) {
return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-loading-state">
@@ -557,7 +569,7 @@ function ScanTerminalScreen() {
}
return (
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
+5 -3
View File
@@ -57,14 +57,16 @@ function getMarkerDisplayOffset(cityName: string) {
};
}
function getMapTileUrl() {
function getMapTileUrl(container?: Element | null) {
if (typeof document === "undefined") {
return MAP_TILE_URLS.dark;
}
const lightMode =
Boolean(container?.closest(".light")) ||
document.documentElement.classList.contains("light") ||
document.body.classList.contains("light") ||
Boolean(document.querySelector(`.${CSS.escape("light")}`)) ||
Boolean(document.querySelector(".scan-terminal.light"));
if (lightMode) {
return MAP_TILE_URLS.light;
@@ -518,7 +520,7 @@ export function useLeafletMap({
});
L.control.zoom({ position: "bottomright" }).addTo(map);
const tileLayer = L.tileLayer(getMapTileUrl(), {
const tileLayer = L.tileLayer(getMapTileUrl(container), {
attribution:
'&copy; <a href="https://www.openstreetmap.org/">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
@@ -599,7 +601,7 @@ export function useLeafletMap({
if (!tileLayer || typeof MutationObserver === "undefined") return;
const syncMapTheme = () => {
tileLayer.setUrl(getMapTileUrl());
tileLayer.setUrl(getMapTileUrl(containerRef.current));
};
syncMapTheme();
+30 -15
View File
@@ -1749,12 +1749,19 @@ class PolymarketReadOnlyLayer:
def _fetch_token_market_data(self, token_id: str) -> Dict[str, Any]:
# REST-only path: CLOB public endpoints.
bid = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"}))
ask = _extract_price(
# Polymarket CLOB semantics:
# - side=BUY returns the executable ask, i.e. the price paid to buy.
# - side=SELL returns the executable bid, i.e. the price received to sell.
buy_price = _extract_price(self._clob_get("/price", {"token_id": token_id, "side": "BUY"}))
sell_price = _extract_price(
self._clob_get("/price", {"token_id": token_id, "side": "SELL"})
)
if self.fast_price_only:
buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=None)
buy, sell = self._resolve_trade_prices(
buy=buy_price,
sell=sell_price,
book=None,
)
midpoint = (buy + sell) / 2.0 if buy is not None and sell is not None else (buy or sell)
spread = max(0.0, float(buy) - float(sell)) if buy is not None and sell is not None else None
return {
@@ -1775,7 +1782,11 @@ class PolymarketReadOnlyLayer:
)
orderbook_raw = self._clob_get("/book", {"token_id": token_id})
book, book_liquidity = self._normalize_orderbook(orderbook_raw)
buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=book)
buy, sell = self._resolve_trade_prices(
buy=buy_price,
sell=sell_price,
book=book,
)
if midpoint is None and buy is not None and sell is not None:
midpoint = (buy + sell) / 2.0
spread = max(0.0, float(buy) - float(sell)) if buy is not None and sell is not None else None
@@ -2366,22 +2377,22 @@ class PolymarketReadOnlyLayer:
if not missing:
return results
ask_map: Dict[str, float] = {}
bid_map: Dict[str, float] = {}
buy_map: Dict[str, float] = {}
sell_map: Dict[str, float] = {}
midpoint_map: Dict[str, float] = {}
spread_map: Dict[str, float] = {}
last_trade_map: Dict[str, float] = {}
book_map: Dict[str, Dict[str, Any]] = {}
for chunk in self._batch_chunks(missing):
sell_payload = self._clob_post(
"/prices",
[{"token_id": token_id, "side": "SELL"} for token_id in chunk],
)
buy_payload = self._clob_post(
"/prices",
[{"token_id": token_id, "side": "BUY"} for token_id in chunk],
)
sell_payload = self._clob_post(
"/prices",
[{"token_id": token_id, "side": "SELL"} for token_id in chunk],
)
midpoint_payload = (
self._clob_post(
"/midpoints",
@@ -2414,8 +2425,8 @@ class PolymarketReadOnlyLayer:
if include_books and not self.fast_price_only
else None
)
ask_map.update(self._extract_batch_price_map(sell_payload, "SELL"))
bid_map.update(self._extract_batch_price_map(buy_payload, "BUY"))
buy_map.update(self._extract_batch_price_map(buy_payload, "BUY"))
sell_map.update(self._extract_batch_price_map(sell_payload, "SELL"))
midpoint_map.update(self._extract_batch_scalar_map(midpoint_payload))
spread_map.update(self._extract_batch_scalar_map(spread_payload))
last_trade_map.update(self._extract_batch_scalar_map(last_trade_payload))
@@ -2427,13 +2438,17 @@ class PolymarketReadOnlyLayer:
for token_id in missing:
raw_book = book_map.get(token_id)
book, book_liquidity = self._normalize_orderbook(raw_book)
ask = _extract_price(ask_map.get(token_id))
bid = _extract_price(bid_map.get(token_id))
buy_price = _extract_price(buy_map.get(token_id))
sell_price = _extract_price(sell_map.get(token_id))
midpoint = _extract_price(midpoint_map.get(token_id))
spread = _extract_price(spread_map.get(token_id))
last_trade = _extract_price(last_trade_map.get(token_id))
buy, sell = self._resolve_trade_prices(buy=ask, sell=bid, book=book)
buy, sell = self._resolve_trade_prices(
buy=buy_price,
sell=sell_price,
book=book,
)
if midpoint is None and buy is not None and sell is not None:
midpoint = (buy + sell) / 2.0
if midpoint is None: