Stabilize the decision workspace data boundaries

The scan terminal had grown into overlapping CSS, request-state, AI-provider, and city-card data responsibilities. This refactor separates those boundaries without changing product behavior: CSS modules are split by surface, city AI prompt/provider/fallback logic is isolated, and scan terminal request state now has reusable RemoteData adapters plus business-state tests.

Constraint: Preserve existing global scan-terminal class names and API responses during the refactor

Constraint: No new dependencies; keep this as a file-boundary cleanup

Rejected: Introduce React Query now | higher migration risk than the requested lightweight query-client path

Rejected: Rewrite AI stream behavior | progressive/fallback states are product-sensitive and were only adapter-split

Confidence: high

Scope-risk: moderate

Reversibility: clean

Directive: Keep AI stream state changes covered by business snapshots before changing fallback/cache wording

Tested: npm run test:business; npx tsc --noEmit; npm run build; python pytest -q; ruff check; py_compile targeted city AI modules

Not-tested: Live DeepSeek provider network replay and browser visual QA
This commit is contained in:
2569718930@qq.com
2026-04-28 14:45:34 +08:00
parent 12d90c5051
commit b122e7cbae
23 changed files with 5699 additions and 4940 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,168 @@
/* Scan terminal calendar/action view styles. */
.root :global(.scan-calendar-view) {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 14px;
padding-right: 4px;
}
.root :global(.scan-calendar-group) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 18px;
background: rgba(8, 17, 30, 0.8);
overflow: hidden;
}
.root :global(.scan-calendar-group-head) {
display: flex;
justify-content: space-between;
align-items: center;
padding: 14px 18px;
border-bottom: 1px solid rgba(90, 123, 166, 0.1);
}
.root :global(.scan-calendar-date) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-count) {
font-size: 13px;
color: #8fa4c3;
}
.root :global(.scan-calendar-subtitle) {
margin-top: 4px;
color: #7f96b6;
font-size: 12px;
font-weight: 700;
}
.root :global(.scan-calendar-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 12px;
padding: 14px;
}
.root :global(.scan-calendar-card),
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
border: 1px solid rgba(90, 123, 166, 0.12);
border-radius: 16px;
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
}
.root :global(.scan-calendar-card) {
text-align: left;
padding: 14px;
cursor: pointer;
}
.root :global(.scan-calendar-card.selected) {
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
}
.root :global(.scan-calendar-card.peak-active) {
border-color: rgba(23, 217, 139, 0.34);
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
}
.root :global(.scan-calendar-card.peak-next) {
border-color: rgba(94, 234, 212, 0.3);
}
.root :global(.scan-calendar-card.peak-past) {
opacity: 0.78;
}
.root :global(.scan-calendar-city) {
font-size: 16px;
font-weight: 800;
color: #eef7ff;
}
.root :global(.scan-calendar-action) {
margin-top: 8px;
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
font-size: 15px;
font-weight: 800;
color: #dcecff;
}
.root :global(.scan-calendar-action span) {
color: #8fa4c3;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-action b) {
color: #eef7ff;
font-size: 16px;
font-weight: 900;
}
.root :global(.scan-calendar-action.buy) {
color: #21e391;
}
.root :global(.scan-calendar-action.sell) {
color: #ff6e6e;
}
.root :global(.scan-calendar-meta) {
margin-top: 10px;
display: flex;
justify-content: space-between;
gap: 12px;
color: #8fa4c3;
font-size: 13px;
}
.root :global(.scan-calendar-countdown) {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid rgba(94, 234, 212, 0.18);
border-radius: 12px;
background: rgba(20, 184, 166, 0.1);
color: #5fffe8;
font-size: 15px;
font-weight: 900;
}
.root :global(.scan-calendar-countdown small) {
display: block;
margin-top: 4px;
color: #9fb4d2;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-calendar-reason) {
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
font-weight: 760;
line-height: 1.45;
}
.root :global(.scan-summary-grid) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-summary-grid.monitor) {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.root :global(.scan-summary-card),
.root :global(.scan-settings-card) {
padding: 18px;
}
File diff suppressed because it is too large Load Diff
@@ -19,6 +19,11 @@ import {
import styles from "./Dashboard.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
@@ -78,6 +83,18 @@ function ScanTerminalScreen() {
const userLocalTime = useUserLocalClock();
const { setThemeMode, themeMode } = useScanTerminalTheme();
const lastMapSelectedCityRef = useRef<string>("");
const scanTerminalRootClassName = clsx(
styles.root,
scanTerminalStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
modalChromeStyles.root,
themeMode === "light" && "light",
);
const timeSortedRows = useMemo(
() => sortRowsByUserTime(terminalData?.rows || []),
@@ -373,7 +390,7 @@ function ScanTerminalScreen() {
if (store.proAccess.loading) {
return (
<div className={clsx(styles.root, scanTerminalStyles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-loading-state">
@@ -393,7 +410,7 @@ function ScanTerminalScreen() {
}
return (
<div className={clsx(styles.root, scanTerminalStyles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
<div className={scanTerminalRootClassName}>
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
<main className="scan-data-grid">
<div className="scan-topbar">
@@ -0,0 +1,841 @@
/* Scan terminal light theme overrides. Keep imported after other scan CSS modules. */
.root :global(.scan-terminal.light) {
background:
radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%),
linear-gradient(180deg, #F7F9FC 0%, #EEF2F7 100%);
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-panel),
.root :global(.scan-terminal.light .scan-data-grid),
.root :global(.scan-terminal.light .scan-detail-panel),
.root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
border-color: #E2E8F0;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(248, 250, 252, 0.96));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-sidebar-brand-name),
.root :global(.scan-terminal.light .scan-topbar-tab.active),
.root :global(.scan-terminal.light .scan-hero h1),
.root :global(.scan-terminal.light .scan-topbar-title strong),
.root :global(.scan-terminal.light .scan-city-name),
.root :global(.scan-terminal.light .scan-opportunity-city strong),
.root :global(.scan-terminal.light .panel-title-area h2),
.root :global(.scan-terminal.light .detail-summary-temp),
.root :global(.scan-terminal.light .scan-detail-city-name),
.root :global(.scan-terminal.light .scan-empty-title),
.root :global(.scan-terminal.light .scan-detail-section-title),
.root :global(.scan-terminal.light .scan-detail-volume-big),
.root :global(.scan-terminal.light .scan-kv strong),
.root :global(.scan-terminal.light .scan-time-main),
.root :global(.scan-terminal.light .scan-calendar-date),
.root :global(.scan-terminal.light .scan-summary-value),
.root :global(.scan-terminal.light .scan-distribution-card strong),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-list-tabs button.active) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-filter-heading),
.root :global(.scan-terminal.light .scan-topbar-actions),
.root :global(.scan-terminal.light .scan-topbar-time),
.root :global(.scan-terminal.light .scan-hero p),
.root :global(.scan-terminal.light .scan-topbar-title span),
.root :global(.scan-terminal.light .scan-city-sub),
.root :global(.scan-terminal.light .scan-opportunity-phase),
.root :global(.scan-terminal.light .scan-opportunity-phase em),
.root :global(.scan-terminal.light .panel-overline),
.root :global(.scan-terminal.light .detail-summary-supporting),
.root :global(.scan-terminal.light .detail-value-muted),
.root :global(.scan-terminal.light .detail-empty-state),
.root :global(.scan-terminal.light .scan-time-remaining),
.root :global(.scan-terminal.light .scan-detail-city-sub),
.root :global(.scan-terminal.light .scan-detail-volume-caption),
.root :global(.scan-terminal.light .scan-empty-copy),
.root :global(.scan-terminal.light .scan-loading-copy-block span),
.root :global(.scan-terminal.light .scan-kv span:first-child),
.root :global(.scan-terminal.light .scan-chart-label),
.root :global(.scan-terminal.light .scan-trade-sub),
.root :global(.scan-terminal.light .scan-trade-note) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-loading-copy-block strong) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-loading-signal) {
border-color: #dbeafe;
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(241, 245, 249, 0.9));
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.1);
}
.root :global(.scan-terminal.light .scan-loading-signal.compact) {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.1), transparent 34%),
rgba(255, 255, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-loading-node) {
background: #ffffff;
border-color: rgba(148, 163, 184, 0.22);
}
.root :global(.scan-terminal.light .scan-loading-node.hot) {
background: linear-gradient(135deg, #f97316, #fde047);
}
.root :global(.scan-terminal.light .scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #2563eb);
}
.root :global(.scan-terminal.light .scan-loading-node.action) {
background: linear-gradient(135deg, #16a34a, #22c55e);
}
.root :global(.scan-terminal.light .scan-list-tabs button),
.root :global(.scan-terminal.light .scan-mode-tab-sub),
.root :global(.scan-terminal.light .scan-opportunity-models em),
.root :global(.scan-terminal.light .scan-opportunity-stat small),
.root :global(.scan-terminal.light .panel-meta-chip),
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note) {
color: #94A3B8;
}
.root :global(.scan-terminal.light .scan-distribution-line em) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-distribution-line b) {
color: #6f86a4;
}
.root :global(.scan-terminal.light .scan-mode-tab),
.root :global(.scan-terminal.light .scan-table-shell),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-kpi-card),
.root :global(.scan-terminal.light .scan-calendar-group),
.root :global(.scan-terminal.light .scan-calendar-card),
.root :global(.scan-terminal.light .scan-summary-card),
.root :global(.scan-terminal.light .scan-settings-card),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-opportunity-group),
.root :global(.scan-terminal.light .detail-summary-shell),
.root :global(.scan-terminal.light .detail-structured-section),
.root :global(.scan-terminal.light .detail-card),
.root :global(.scan-terminal.light .scan-trade-card) {
border-color: #E2E8F0;
background: #FFFFFF;
}
.root :global(.scan-terminal.light .scan-mode-tab.active) {
color: #1D4ED8;
background: #DBEAFE;
border-color: rgba(59, 130, 246, 0.34);
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.16);
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-label) {
color: #1D4ED8;
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-tab-sub) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-calendar-action b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-calendar-reason) {
color: #243b5a;
}
.root :global(.scan-terminal.light .scan-calendar-countdown) {
border-color: rgba(10, 160, 100, 0.18);
background: rgba(220, 252, 239, 0.68);
color: #087b55;
}
.root :global(.scan-terminal.light .scan-calendar-card.peak-active) {
border-color: rgba(10, 160, 100, 0.34);
background: linear-gradient(180deg, rgba(220, 252, 239, 0.84), rgba(255, 255, 255, 0.86));
}
.root :global(.scan-terminal.light .scan-mode-tab.active .scan-mode-icon) {
background: rgba(16, 185, 129, 0.16);
color: #069668;
}
.root :global(.scan-terminal.light .scan-table-row.selected) {
background: linear-gradient(180deg, rgba(228, 252, 242, 0.92), rgba(241, 250, 247, 0.96));
border-color: rgba(10, 160, 100, 0.34);
box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
}
.root :global(.scan-terminal.light .scan-table-row) {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(247, 250, 254, 0.9));
border-bottom-color: rgba(35, 72, 118, 0.08);
}
.root :global(.scan-terminal.light .scan-table-row:hover) {
background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96));
}
.root :global(.scan-terminal.light .scan-kpi-card) {
box-shadow: 0 10px 24px rgba(40, 70, 110, 0.08);
}
.root :global(.scan-terminal.light .scan-kpi-value),
.root :global(.scan-terminal.light .scan-opportunity-phase > span) {
color: #14253a;
}
.root :global(.scan-terminal.light .scan-opportunity-group-head) {
background: rgba(246, 250, 255, 0.78);
border-bottom-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .scan-opportunity-item) {
background: rgba(255, 255, 255, 0.62);
color: #405977;
border-bottom-color: rgba(35, 72, 118, 0.08);
border-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .scan-opportunity-item:hover) {
background: rgba(230, 242, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-item.selected) {
background: linear-gradient(180deg, rgba(222, 250, 238, 0.9), rgba(241, 250, 247, 0.96));
border-color: rgba(10, 160, 100, 0.34);
}
.root :global(.scan-terminal.light .scan-opportunity-group.selected) {
border-color: rgba(10, 160, 100, 0.34);
box-shadow: inset 0 0 0 1px rgba(10, 160, 100, 0.18);
}
.root :global(.scan-terminal.light .scan-opportunity-models span) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(255, 255, 255, 0.68);
}
.root :global(.scan-terminal.light .scan-opportunity-stat) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(248, 252, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-models b),
.root :global(.scan-terminal.light .scan-opportunity-stat b) {
color: #14253a;
}
.root :global(.scan-terminal.light .scan-opportunity-branch::before),
.root :global(.scan-terminal.light .scan-opportunity-branch i) {
background: rgba(56, 86, 126, 0.24);
}
.root :global(.scan-terminal.light .panel-header),
.root :global(.scan-terminal.light .panel-body section) {
border-color: rgba(35, 72, 118, 0.1);
}
.root :global(.scan-terminal.light .panel-action-button-secondary),
.root :global(.scan-terminal.light .panel-action-button-ghost) {
border-color: rgba(35, 72, 118, 0.14);
background: rgba(255, 255, 255, 0.76);
}
.root :global(.scan-terminal.light .detail-summary-shell-live) {
background: linear-gradient(180deg, rgba(224, 247, 255, 0.82), rgba(241, 248, 255, 0.92));
}
.root :global(.scan-terminal.light .scan-locale-switch),
.root :global(.scan-terminal.light .scan-ghost-button),
.root :global(.scan-terminal.light .scan-theme-button),
.root :global(.scan-terminal.light .scan-sort-pill),
.root :global(.scan-terminal.light .scan-icon-pill),
.root :global(.scan-terminal.light .scan-account-button),
.root :global(.scan-terminal.light .scan-detail-action-button) {
border-color: rgba(35, 72, 118, 0.14);
background: rgba(255, 255, 255, 0.78);
color: #1f3654;
}
.root :global(.scan-terminal.light .scan-primary-button) {
color: #FFFFFF;
border-color: rgba(59, 130, 246, 0.34);
background: linear-gradient(180deg, #3B82F6, #2563EB);
}
.root :global(.scan-terminal.light .scan-ai-button) {
color: #1D4ED8;
border-color: rgba(59, 130, 246, 0.28);
background: #DBEAFE;
}
.root :global(.scan-terminal.light .scan-opportunity-ai) {
border-color: rgba(35, 72, 118, 0.1);
background: rgba(248, 252, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-opportunity-ai small) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-opportunity-strength) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(248, 252, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-opportunity-expand) {
color: #1d4ed8;
border-color: rgba(37, 99, 235, 0.18);
background: rgba(239, 246, 255, 0.84);
}
.root :global(.scan-terminal.light .scan-v4-analysis),
.root :global(.scan-terminal.light .scan-v4-analysis section),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span),
.root :global(.scan-terminal.light .scan-v4-model-sources span) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(255, 255, 255, 0.76);
}
.root :global(.scan-terminal.light .scan-v4-analysis strong) {
color: #057a6b;
}
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources em) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-v4-model-sources b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-forecast-city-card),
.root :global(.scan-terminal.light .scan-forecast-row),
.root :global(.scan-terminal.light .scan-ai-analysis) {
border-color: #E2E8F0;
background: #FFFFFF;
}
.root :global(.scan-terminal.light .scan-forecast-city-head) {
background: #EEF2F7;
}
.root :global(.scan-terminal.light .scan-forecast-city-card.selected),
.root :global(.scan-terminal.light .scan-forecast-city-card:has(.scan-forecast-row.selected)) {
border-color: rgba(59, 130, 246, 0.38);
box-shadow:
inset 0 0 0 1px rgba(59, 130, 246, 0.12),
0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-forecast-row.selected),
.root :global(.scan-terminal.light .scan-forecast-row.expanded) {
border-color: rgba(59, 130, 246, 0.38);
background:
linear-gradient(180deg, rgba(219, 234, 254, 0.62), rgba(255, 255, 255, 0.98)),
#FFFFFF;
box-shadow: inset 4px 0 0 #3B82F6;
}
.root :global(.scan-terminal.light .scan-forecast-city-title strong),
.root :global(.scan-terminal.light .scan-forecast-city-read > b:not(.scan-phase-badge)),
.root :global(.scan-terminal.light .scan-forecast-bucket strong),
.root :global(.scan-terminal.light .scan-forecast-signals b),
.root :global(.scan-terminal.light .scan-ai-temperature-line b) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-forecast-city-chips span),
.root :global(.scan-terminal.light .scan-forecast-signals span),
.root :global(.scan-terminal.light .scan-ai-evidence-line span) {
border-color: #E2E8F0;
background: #EEF2F7;
}
.root :global(.scan-terminal.light .scan-forecast-city-chips span),
.root :global(.scan-terminal.light .scan-forecast-city-read span),
.root :global(.scan-terminal.light .scan-forecast-bucket small),
.root :global(.scan-terminal.light .scan-forecast-ai-line small),
.root :global(.scan-terminal.light .scan-ai-brief-grid li),
.root :global(.scan-terminal.light .scan-ai-airport-read p) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-analysis-head p) {
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-ai-analysis-head small),
.root :global(.scan-terminal.light .scan-ai-evidence-line b) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-workspace) {
background: #f7f9fc;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-overview) {
background: #f7f9fc;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero > div:first-child),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane),
.root :global(.scan-terminal.light .scan-opportunity-decision-card),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span) {
background: #ffffff;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-opportunity-hero strong),
.root :global(.scan-terminal.light .scan-opportunity-summary b),
.root :global(.scan-terminal.light .scan-opportunity-lane-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-head strong),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-opportunity-hero p),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-card),
.root :global(.scan-terminal.light .scan-ai-city-hero) {
background: #ffffff;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-ai-city-section),
.root :global(.scan-terminal.light .scan-ai-decision-band),
.root :global(.scan-terminal.light .scan-ai-market-decision),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness span) {
background: #eef2f7;
border-color: #e2e8f0;
}
.root :global(.scan-terminal.light .scan-ai-workspace-head strong),
.root :global(.scan-terminal.light .scan-ai-city-hero h3),
.root :global(.scan-terminal.light .scan-ai-city-hero-side > strong),
.root :global(.scan-terminal.light .scan-ai-decision-band strong),
.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
.root :global(.scan-terminal.light .scan-ai-weather-summary),
.root :global(.scan-terminal.light .scan-ai-decision-reasons small),
.root :global(.scan-terminal.light .scan-ai-market-decision strong),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-ai-workspace-head p),
.root :global(.scan-terminal.light .scan-ai-city-section p),
.root :global(.scan-terminal.light .scan-ai-decision-band p),
.root :global(.scan-terminal.light .scan-ai-city-pills span),
.root :global(.scan-terminal.light .scan-ai-city-freshness),
.root :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root :global(.scan-terminal.light .scan-ai-market-bucket span),
.root :global(.scan-terminal.light .scan-ai-market-decision span),
.root :global(.scan-terminal.light .scan-ai-market-decision p),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats small),
.root :global(.scan-terminal.light .scan-ai-city-muted),
.root :global(.scan-terminal.light .scan-ai-city-loading),
.root :global(.scan-terminal.light .scan-ai-city-chart-legend),
.root :global(.scan-terminal.light .scan-ai-weather-bullets) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-ai-raw-metar) {
border-top-color: #e2e8f0;
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-city-chart-placeholder) {
border-color: #cbd5e1;
background: #f8fafc;
color: #64748b;
}
.root :global(.scan-terminal.light .scan-ai-log-panel) {
border-color: rgba(35, 72, 118, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(246, 250, 255, 0.9));
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact) {
background: rgba(255, 255, 255, 0.82);
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary span) {
color: #334155;
}
.root :global(.scan-terminal.light .scan-ai-log-panel.compact .scan-ai-log-summary strong) {
color: #2563eb;
}
.root :global(.scan-terminal.light .scan-ai-log-head strong),
.root :global(.scan-terminal.light .scan-ai-log-item b) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
.root :global(.scan-terminal.light .scan-ai-log-empty) {
color: #647a98;
}
.root :global(.scan-terminal.light .scan-ai-log-item) {
border-color: rgba(35, 72, 118, 0.12);
background: rgba(255, 255, 255, 0.72);
}
.root :global(.scan-terminal.light .scan-ai-log-item small) {
color: #58708f;
}
.root :global(.scan-terminal.light .scan-ai-log-item.info) {
border-color: rgba(14, 165, 233, 0.24);
}
.root :global(.scan-terminal.light .scan-ai-log-item.success) {
border-color: rgba(10, 160, 100, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-log-item.warning) {
border-color: rgba(217, 119, 6, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-log-item.error) {
border-color: rgba(220, 38, 38, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-summary-card),
.root :global(.scan-terminal.light .scan-ai-city-card) {
background: rgba(255, 255, 255, 0.92);
border-color: rgba(148, 163, 184, 0.28);
}
.root :global(.scan-terminal.light .scan-ai-summary-card strong),
.root :global(.scan-terminal.light .scan-ai-city-head strong),
.root :global(.scan-terminal.light .scan-ai-contract b) {
color: #0f172a;
}
.root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) {
color: #6B7A90;
}
.root :global(.scan-terminal.light .scan-ai-city-head) {
background: #f8fafc;
}
.root :global(.scan-terminal.light .scan-ai-cluster-note) {
color: #334155;
background: #f8fafc;
border-color: rgba(148, 163, 184, 0.26);
}
.root :global(.scan-terminal.light .scan-ai-contract) {
border-top-color: rgba(148, 163, 184, 0.2);
}
.root :global(.scan-terminal.light .scan-ai-inline-button) {
color: #064e3b;
background: #d1fae5;
border-color: rgba(16, 185, 129, 0.28);
}
.root :global(.scan-terminal.light .scan-cta-ghost),
.root :global(.scan-terminal.light .scan-detail-analysis-button) {
border-color: rgba(7, 160, 100, 0.24);
background: rgba(11, 188, 116, 0.12);
color: #057a4d;
}
.root :global(.scan-terminal.light .scan-score-ring::after) {
background: #f5f9fe;
}
.root :global(.scan-terminal.light .scan-score-ring span) {
color: #122033;
}
.root :global(.scan-terminal.light .scan-trade-card p) {
color: #405977;
}
.root :global(.scan-terminal.light .scan-map-shell) {
background:
radial-gradient(circle at 18% 18%, rgba(59, 130, 246, 0.12), transparent 34%),
linear-gradient(180deg, #f8fbff, #eaf3ff);
border-color: rgba(37, 99, 235, 0.16);
box-shadow: 0 16px 34px rgba(40, 70, 110, 0.12);
}
.root :global(.scan-terminal.light .scan-map-shell .map),
.root :global(.scan-terminal.light .scan-map-shell .leaflet-container) {
background: #eaf3ff !important;
}
.root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
filter: saturate(1.06) contrast(0.98) brightness(1.04) !important;
}
.root :global(.scan-terminal.light .scan-map-caption) {
color: #475569;
}
.root :global(.scan-terminal.light .scan-list-tabs button),
.root :global(.scan-terminal.light .scan-mode-tab-sub),
.root :global(.scan-terminal.light .scan-opportunity-models em),
.root :global(.scan-terminal.light .scan-opportunity-stat small),
.root :global(.scan-terminal.light .panel-meta-chip),
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note),
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small),
.root :global(.scan-terminal.light .scan-opportunity-ai small),
.root :global(.scan-terminal.light .scan-ai-log-head span),
.root :global(.scan-terminal.light .scan-ai-log-time),
.root :global(.scan-terminal.light .scan-ai-log-empty),
.root :global(.scan-terminal.light .scan-ai-log-item small),
.root :global(.scan-terminal.light .scan-ai-summary-card p),
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small),
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span),
.root :global(.scan-terminal.light .scan-v4-model-sources em),
.root :global(.scan-terminal.light .scan-ai-raw-metar),
.root :global(.scan-terminal.light .scan-opportunity-hero p),
.root :global(.scan-terminal.light .scan-opportunity-summary span),
.root :global(.scan-terminal.light .scan-opportunity-lane-head p),
.root :global(.scan-terminal.light .scan-opportunity-decision-card p),
.root :global(.scan-terminal.light .scan-opportunity-decision-head span),
.root :global(.scan-terminal.light .scan-opportunity-decision-primary span),
.root :global(.scan-terminal.light .scan-opportunity-decision-foot small) {
color: #475569;
}
/* 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),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) {
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;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
color: #047857 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
color: #1d4ed8 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
color: #b45309 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
color: #b91c1c !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement) {
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%),
#ffffff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
color: #0f172a !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: #334155 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
color: #047857 !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why) {
color: #1d4ed8 !important;
}
@@ -0,0 +1,413 @@
/* Scan terminal mobile action-card layout and responsive overrides. */
.root :global(.scan-mobile-decision-card) {
display: grid;
gap: 12px;
padding-bottom: 14px;
}
.root :global(.scan-mobile-decision-head) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 16px 16px 0;
}
.root :global(.scan-mobile-decision-head h3) {
margin: 6px 0 0;
color: #e6edf3;
font-size: 24px;
line-height: 1.08;
}
.root :global(.scan-mobile-decision-metrics) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
padding: 0 14px;
}
.root :global(.scan-mobile-decision-metrics span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-mobile-decision-metrics small) {
color: #9fb2c7;
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-mobile-decision-metrics b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-mobile-decision-reason) {
margin: 0 14px;
border: 1px solid rgba(77, 163, 255, 0.24);
border-radius: 13px;
background: rgba(77, 163, 255, 0.1);
color: #d8e7f8;
font-size: 14px;
font-weight: 900;
line-height: 1.45;
padding: 11px 12px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
.root :global(.scan-mobile-decision-folds) {
margin-right: 14px;
margin-left: 14px;
}
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
grid-template-columns: 1fr;
max-width: none;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 0;
}
.root :global(.scan-mobile-decision-card .scan-ai-market-decision) {
display: none;
}
.root :global(.scan-mobile-decision-folds) {
display: grid;
gap: 10px;
}
.root :global(.scan-mobile-fold) {
padding: 13px 14px;
}
.root :global(.scan-mobile-fold summary) {
cursor: pointer;
list-style: none;
margin-bottom: 0;
}
.root :global(.scan-mobile-fold summary::-webkit-details-marker) {
display: none;
}
.root :global(.scan-mobile-fold[open] summary) {
margin-bottom: 10px;
}
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
border: 0;
background: transparent;
padding: 0;
}
.root :global(.scan-ai-city-collapse svg) {
transition: transform 0.18s ease;
}
@media (max-width: 1480px) {
.root :global(.scan-terminal) {
grid-template-columns: minmax(0, 1fr);
}
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
position: relative;
top: auto;
grid-column: 1 / -1;
height: auto;
max-height: none;
min-height: auto;
}
.root :global(.scan-detail-panel) {
grid-column: 1 / -1;
min-height: auto;
}
}
@media (max-width: 1100px) {
.root :global(.scan-terminal) {
grid-template-columns: 1fr;
}
.root :global(.scan-filter-panel),
.root :global(.scan-data-grid),
.root :global(.scan-detail-panel),
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
min-height: auto;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.scan-topbar) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-upgrade-announcement) {
grid-template-columns: 1fr;
}
.root :global(.scan-upgrade-announcement ul) {
justify-content: flex-start;
}
.root :global(.scan-table-header),
.root :global(.scan-table-row) {
grid-template-columns: 1fr;
}
.root :global(.scan-table-header) {
display: none;
}
.root :global(.scan-opportunity-group-head),
.root :global(.scan-opportunity-item) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-head),
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-brief-grid),
.root :global(.scan-ai-city-analysis-grid),
.root :global(.scan-ai-decision-band),
.root :global(.scan-ai-evidence-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-workspace-head),
.root :global(.scan-opportunity-hero),
.root :global(.scan-ai-city-hero),
.root :global(.scan-ai-city-section-head) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-hero) {
display: flex;
}
.root :global(.scan-ai-workspace-head p),
.root :global(.scan-ai-city-hero-side) {
text-align: left;
justify-items: start;
}
.root :global(.scan-ai-decision-metrics) {
min-width: 0;
grid-template-columns: 1fr;
}
.root :global(.scan-ai-market-decision) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-card-grid),
.root :global(.scan-opportunity-summary) {
grid-template-columns: 1fr;
}
.root :global(.scan-forecast-city-read) {
justify-items: start;
text-align: left;
}
.root :global(.scan-forecast-signals),
.root :global(.scan-ai-temperature-line) {
grid-template-columns: 1fr;
}
.root :global(.scan-opportunity-phase) {
flex-wrap: wrap;
}
.root :global(.scan-opportunity-branch) {
display: none;
}
.root :global(.scan-opportunity-stat.edge) {
justify-self: start;
justify-items: start;
}
.root :global(.scan-opportunity-ai),
.root :global(.scan-v4-analysis) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis) {
grid-template-columns: 1fr;
}
.root :global(.scan-trade-cards) {
grid-template-columns: 1fr;
}
}
@media (max-width: 820px) {
.root :global(.scan-terminal) {
padding: 10px;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: 1fr;
}
.root :global(.scan-topbar-tabs) {
gap: 14px;
flex-wrap: wrap;
}
.root :global(.scan-upgrade-announcement) {
padding: 14px;
}
.root :global(.scan-upgrade-announcement-copy strong) {
font-size: 16px;
}
.root :global(.scan-upgrade-announcement ul) {
gap: 6px;
}
.root :global(.scan-upgrade-announcement li) {
font-size: 11px;
padding: 6px 8px;
}
.root :global(.scan-ai-city-hero) {
padding: 16px;
}
.root :global(.scan-ai-city-hero h3) {
margin-bottom: 10px;
font-size: 24px;
}
.root :global(.scan-ai-city-mobile-priority) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin: 0 0 12px;
}
.root :global(.scan-ai-city-mobile-priority span) {
display: grid;
gap: 4px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(77, 163, 255, 0.09);
padding: 9px;
}
.root :global(.scan-ai-city-mobile-priority small) {
color: #9fb2c7;
font-size: 10px;
font-weight: 900;
}
.root :global(.scan-ai-city-mobile-priority b) {
color: #f3f8ff;
font-size: 13px;
line-height: 1.15;
}
.root :global(.scan-ai-city-pills) {
display: none;
}
.root :global(.scan-ai-city-freshness) {
grid-template-columns: 1fr;
}
.root :global(.scan-ai-decision-band) {
padding: 14px;
}
.root :global(.scan-ai-decision-band strong) {
font-size: 20px;
}
.root :global(.scan-ai-decision-long),
.root :global(.scan-ai-decision-reasons),
.root :global(.scan-ai-decision-risk) {
display: none;
}
.root :global(.scan-ai-market-mobile-line) {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 12px;
background: rgba(11, 18, 32, 0.48);
padding: 10px 12px;
}
.root :global(.scan-ai-market-mobile-line span) {
color: #9fb2c7;
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-market-mobile-line b) {
color: #e6edf3;
font-size: 13px;
text-align: right;
}
.root :global(.scan-ai-market-decision) {
margin-top: 10px;
}
.root :global(.scan-ai-city-ai-read:not([open])) {
padding-bottom: 4px;
}
.root :global(.scan-list-header) {
flex-direction: column;
align-items: flex-start;
}
.root :global(.scan-opportunity-groups) {
padding: 12px;
}
.root :global(.scan-opportunity-models span) {
white-space: normal;
}
.root :global(.scan-forecast-city-title strong) {
font-size: 21px;
}
.root :global(.scan-forecast-row-main),
.root :global(.scan-ai-analysis) {
padding: 13px;
}
.root :global(.scan-forecast-ai-line) {
grid-template-columns: 1fr;
padding: 0 13px 13px;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,155 @@
import assert from "node:assert/strict";
import {
buildAiCityErrorForecastState,
buildAiCityForecastCacheKey,
buildAiCityForecastKey,
buildAiCityProgressForecastState,
buildAiCityReadyForecastState,
readReadyCachedAiForecastState,
} from "@/components/dashboard/scan-terminal/ai-city-forecast-stream-state";
import { readCachedPayload, writeCachedPayload } from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { AiCityForecastPayload, AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
function installLocalStorageMock() {
const store = new Map<string, string>();
const localStorage = {
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { localStorage },
});
return localStorage;
}
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
return {
airport_current: {
raw_metar: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
temp: 21,
},
current: {
temp: 21,
},
local_date: "2026-04-28",
metar_status: {
last_observation_time: "2026-04-28T00:00:00Z",
stale_for_today: false,
},
name: "Test City",
temp_symbol: "°C",
...extra,
} as CityDetail;
}
function readyPayload(extra: Partial<AiCityForecastPayload> = {}): AiCityForecastPayload {
return {
city_forecast: {
confidence: "medium",
final_judgment_en: "Centered near 25°C.",
final_judgment_zh: "预计最高温以 25°C 为中枢。",
metar_read_en: "Latest METAR supports the path.",
metar_read_zh: "最新 METAR 支撑当前路径。",
model_cluster_note_en: "Models are clustered.",
model_cluster_note_zh: "模型较集中。",
predicted_max: 25,
range_high: 26,
range_low: 24,
reasoning_en: "Evidence is aligned.",
reasoning_zh: "证据一致。",
risks_en: [],
risks_zh: [],
unit: "°C",
},
status: "ready",
...extra,
};
}
export function runTests() {
const storage = installLocalStorageMock();
storage.clear();
const forecastKey = buildAiCityForecastKey({
detail: cityDetail(),
detailCityName: "Test City",
locale: "zh-CN",
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
const cacheKey = buildAiCityForecastCacheKey(forecastKey);
const payload = readyPayload();
writeCachedPayload(cacheKey, payload);
const cachedReady = readReadyCachedAiForecastState(cacheKey, 0);
assert.equal(cachedReady?.status, "ready");
assert.equal(cachedReady?.payload?.city_forecast?.predicted_max, 25);
const degradedCacheKey = `${cacheKey}:degraded`;
writeCachedPayload(degradedCacheKey, readyPayload({ degraded: true }));
assert.equal(readReadyCachedAiForecastState(degradedCacheKey, 0), null);
assert.equal(readCachedPayload(degradedCacheKey, 60 * 60 * 1000), null);
const currentLoading: AiCityForecastState = {
status: "loading",
streamText: "已有快速判断",
};
const callingAiProgress = buildAiCityProgressForecastState({
cacheKey: `${cacheKey}:progress`,
current: currentLoading,
isEn: false,
progress: {
message_zh: "DeepSeek 正在补充机场报文细节",
stage: "calling_ai",
},
});
assert.equal(callingAiProgress?.streamText, "已有快速判断");
const errorState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:error`,
detail: cityDetail(),
error: new Error("timeout"),
isEn: false,
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
assert.equal(errorState.status, "ready");
assert.equal(errorState.payload?.status, "timeout_fallback");
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
const hkoState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:hko`,
detail: cityDetail({
airport_current: null,
current: {
settlement_source: "hko",
temp: 30,
},
settlement_station: {
settlement_source: "hko",
},
} as unknown as Partial<CityDetail>),
error: new Error("timeout"),
isEn: false,
report: "",
});
const hkoRead = hkoState.payload?.city_forecast?.metar_read_zh || "";
assert.doesNotMatch(hkoRead, /METAR|机场报文/);
assert.match(hkoRead, /官方观测|30\.0°C/);
const degradedReadyState = buildAiCityReadyForecastState({
cacheKey: `${cacheKey}:ready-degraded`,
detail: cityDetail(),
isEn: false,
payload: readyPayload({ degraded: true }),
report: "METAR TEST 010000Z 34004KT CAVOK 21/10 Q1012",
});
assert.equal(degradedReadyState.status, "ready");
assert.equal(readCachedPayload(`${cacheKey}:ready-degraded`, 60 * 60 * 1000), null);
}
@@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import {
buildCityMarketScanCacheKey,
deriveCityMarketScanView,
resolveCityMarketScanSnapshot,
writeCachedCityMarketScan,
} from "@/components/dashboard/scan-terminal/market-scan-state";
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
function installLocalStorageMock() {
const store = new Map<string, string>();
const localStorage = {
clear: () => store.clear(),
getItem: (key: string) => store.get(key) ?? null,
removeItem: (key: string) => {
store.delete(key);
},
setItem: (key: string, value: string) => {
store.set(key, value);
},
};
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { localStorage },
});
return localStorage;
}
function marketScan(label = "cached"): MarketScan {
return {
generated_at: "2026-04-28T00:00:00Z",
label,
} as unknown as MarketScan;
}
function cityDetail(extra: Partial<CityDetail> = {}): CityDetail {
return {
local_date: "2026-04-28",
name: "Test City",
...extra,
} as CityDetail;
}
export function runTests() {
const storage = installLocalStorageMock();
storage.clear();
const embeddedScan = marketScan("embedded");
const embeddedSnapshot = resolveCityMarketScanSnapshot({
detail: cityDetail({ market_scan: embeddedScan }),
detailCityName: "Test City",
enabled: true,
});
assert.equal(embeddedSnapshot.action, "success");
if (embeddedSnapshot.action === "success") {
assert.equal(embeddedSnapshot.payload, embeddedScan);
assert.equal(embeddedSnapshot.shouldWriteCache, true);
}
const cacheKey = buildCityMarketScanCacheKey({
detailCityName: "Test City",
localDate: "2026-04-28",
});
const cachedScan = marketScan("cached");
writeCachedCityMarketScan(cacheKey, cachedScan);
const cachedSnapshot = resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: false,
});
assert.equal(cachedSnapshot.action, "success");
if (cachedSnapshot.action === "success") {
assert.equal((cachedSnapshot.payload as unknown as { label: string }).label, "cached");
}
storage.clear();
assert.equal(
resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: false,
}).action,
"reset",
);
assert.equal(
resolveCityMarketScanSnapshot({
detail: cityDetail(),
detailCityName: "Test City",
enabled: true,
}).action,
"fetch",
);
const previous = marketScan("previous");
const loadingRemote: RemoteData<MarketScan> = {
previous,
status: "loading",
};
const loadingView = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: loadingRemote,
});
assert.equal(loadingView.marketStatus, "loading");
assert.equal(loadingView.marketScan, previous);
const errorWithPrevious = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: {
error: "network",
previous,
status: "error",
},
});
assert.equal(errorWithPrevious.marketStatus, "ready");
assert.equal(errorWithPrevious.marketScan, previous);
const errorWithoutPrevious = deriveCityMarketScanView({
detailMarketScan: null,
marketRemote: {
error: "network",
status: "error",
},
});
assert.equal(errorWithoutPrevious.marketStatus, "failed");
assert.equal(errorWithoutPrevious.marketScan, null);
}
@@ -0,0 +1,346 @@
import type { AiCityStreamProgress } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
buildStorageKey,
readCachedPayload,
removeCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
export function buildAiCityForecastKey({
detail,
detailCityName,
locale,
report,
}: {
detail: CityDetail | null;
detailCityName: string;
locale: string;
report: string;
}) {
if (!detail) return "";
const isHkoObservation = isHkoObservationCity(detail);
const observationSource = isHkoObservation ? "hko" : "metar";
const observationCurrent = isHkoObservation
? detail.current || {}
: detail.airport_current || detail.current || {};
const observationSignature =
(!isHkoObservation ? String(report || "").trim() : "") ||
[
observationSource,
observationCurrent.report_time,
observationCurrent.obs_time_epoch,
observationCurrent.obs_time,
observationCurrent.receipt_time,
observationCurrent.temp,
observationCurrent.max_so_far,
observationCurrent.station_code,
detail.metar_status?.stale_for_today,
detail.metar_status?.last_observation_time,
]
.filter((part) => part != null && part !== "")
.join("|");
return [
normalizeCityKey(detailCityName),
detail.local_date || "",
locale,
observationSignature,
].join(":");
}
export function buildAiCityForecastCacheKey(aiForecastKey: string) {
return buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
}
export function buildAiCityForecastRequestKey(cacheKey: string, refreshToken: number) {
return `${cacheKey}:${refreshToken > 0 ? `refresh:${refreshToken}` : "normal"}`;
}
export function readCachedAiForecastState(key: string) {
const cached = aiCityForecastStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.updatedAt > AI_CITY_FORECAST_CACHE_TTL_MS) {
aiCityForecastStateCache.delete(key);
return null;
}
return cached.state;
}
export function writeCachedAiForecastState(
key: string,
state: AiCityForecastState,
) {
if (!key || state.status === "idle") return;
aiCityForecastStateCache.set(key, {
state,
updatedAt: Date.now(),
});
}
export function readReadyCachedAiForecastState(cacheKey: string, refreshToken: number) {
if (refreshToken > 0) return null;
const cachedPayload = readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
);
if (cachedPayload) {
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
removeCachedPayload(cacheKey);
}
const cachedState = readCachedAiForecastState(cacheKey);
return cachedState?.status === "ready" ? cachedState : null;
}
function getAiCityStreamProgressText(
progress: AiCityStreamProgress,
isEn: boolean,
) {
const localizedMessage = String(
(isEn ? progress.message_en : progress.message_zh) ||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
"",
).trim();
if (localizedMessage) return localizedMessage;
const rawLength = Number(progress.raw_length);
if (Number.isFinite(rawLength) && rawLength > 0) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
export function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
const currentText =
currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
: isEn
? "the latest observed temperature"
: "最新实测温度";
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
const rawMetar = isHkoObservation
? ""
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
const finalZh = timeoutLike
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
const finalEn = timeoutLike
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
return {
city_forecast: {
confidence: "low",
final_judgment_en: finalEn,
final_judgment_zh: finalZh,
metar_read_en: metarEn,
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
risks_zh: [],
unit: tempSymbol,
},
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
reason: isEn ? reasonEn : reasonZh,
reason_en: reasonEn,
reason_zh: reasonZh,
status: timeoutLike ? "timeout_fallback" : "fallback",
};
}
export function buildAiCityLoadingForecastState({
cacheKey,
detail,
isEn,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
isEn: boolean;
report: string;
}) {
const cachedState = readCachedAiForecastState(cacheKey);
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
const loadingState: AiCityForecastState =
cachedState?.status === "loading"
? cachedState
: {
status: "loading",
streamText:
(isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh) ||
(isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
};
writeCachedAiForecastState(cacheKey, loadingState);
return loadingState;
}
export function buildAiCityProgressForecastState({
cacheKey,
current,
isEn,
progress,
}: {
cacheKey: string;
current: AiCityForecastState;
isEn: boolean;
progress: AiCityStreamProgress;
}) {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return null;
const cachedProgressState = readCachedAiForecastState(cacheKey);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
const cachedNextState: AiCityForecastState = {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
};
writeCachedAiForecastState(cacheKey, cachedNextState);
return {
...current,
status: "loading",
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
} satisfies AiCityForecastState;
}
export function buildAiCityReadyForecastState({
cacheKey,
detail,
isEn,
payload,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
isEn: boolean;
payload: AiCityForecastPayload;
report: string;
}) {
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload);
}
const readyState: AiCityForecastState = {
payload: usablePayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
export function buildAiCityErrorForecastState({
cacheKey,
detail,
error,
isEn,
report,
}: {
cacheKey: string;
detail: CityDetail | null;
error: unknown;
isEn: boolean;
report: string;
}) {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
const readyState: AiCityForecastState = {
payload: fallbackPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
return readyState;
}
@@ -0,0 +1,102 @@
import {
buildStorageKey,
readCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { RemoteData } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
export type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
export type CityMarketScanSnapshot =
| { action: "reset"; cacheKey?: string }
| { action: "success"; cacheKey: string; payload: MarketScan; shouldWriteCache?: boolean }
| { action: "fetch"; cacheKey: string };
export function buildCityMarketScanCacheKey({
detailCityName,
localDate,
}: {
detailCityName: string;
localDate?: string | null;
}) {
return buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
localDate || "",
"full",
]);
}
export function readCachedCityMarketScan(cacheKey: string) {
return readCachedPayload<MarketScan>(cacheKey, CITY_MARKET_SCAN_CACHE_TTL_MS);
}
export function writeCachedCityMarketScan(cacheKey: string, payload: MarketScan) {
writeCachedPayload(cacheKey, payload);
}
export function resolveCityMarketScanSnapshot({
detail,
detailCityName,
enabled,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled: boolean;
}): CityMarketScanSnapshot {
if (!detail) return { action: "reset" };
const cacheKey = buildCityMarketScanCacheKey({
detailCityName,
localDate: detail.local_date || "",
});
if (detail.market_scan) {
return {
action: "success",
cacheKey,
payload: detail.market_scan,
shouldWriteCache: true,
};
}
const cached = readCachedCityMarketScan(cacheKey);
if (cached) {
return {
action: "success",
cacheKey,
payload: cached,
};
}
return enabled ? { action: "fetch", cacheKey } : { action: "reset", cacheKey };
}
export function deriveCityMarketScanView({
detailMarketScan,
marketRemote,
}: {
detailMarketScan?: MarketScan | null;
marketRemote: RemoteData<MarketScan>;
}) {
const previousMarketScan =
marketRemote.status === "loading" || marketRemote.status === "error"
? marketRemote.previous ?? null
: null;
const marketScan =
marketRemote.status === "success"
? marketRemote.data
: previousMarketScan ?? detailMarketScan ?? null;
const marketStatus: CityMarketScanStatus =
marketRemote.status === "success"
? "ready"
: marketRemote.status === "loading"
? "loading"
: marketRemote.status === "error"
? marketScan
? "ready"
: "failed"
: "idle";
return { marketScan, marketStatus };
}
@@ -29,6 +29,11 @@ export type AiCityStreamProgress = {
raw_length?: number | null;
};
export const scanTerminalQueryPolicy = {
autoRefreshMs: 10 * 60_000,
manualForceRefreshCooldownMs: 2 * 60_000,
} as const;
type AiCityStreamEvent = {
data: Record<string, unknown>;
event: string;
@@ -100,6 +105,32 @@ export function toRemoteError<T>(
};
}
export function shouldSkipManualTerminalRefresh({
hasCurrentData,
lastForcedRefreshAt,
now = Date.now(),
}: {
hasCurrentData: boolean;
lastForcedRefreshAt: number;
now?: number;
}) {
return (
hasCurrentData &&
lastForcedRefreshAt > 0 &&
now - lastForcedRefreshAt < scanTerminalQueryPolicy.manualForceRefreshCooldownMs
);
}
export function shouldRunAutoTerminalRefresh({
documentHidden,
isLoading,
}: {
documentHidden: boolean;
isLoading: boolean;
}) {
return !documentHidden && !isLoading;
}
async function readJsonOrThrow<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetchBackendApi(path, init);
if (response.ok) return response.json() as Promise<T>;
@@ -1,457 +1,4 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import type {
AiCityForecastPayload,
AiCityForecastState,
} from "@/components/dashboard/scan-terminal/types";
import {
scanTerminalClient,
type AiCityStreamProgress,
type RemoteData,
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import {
buildStorageKey,
readCachedPayload,
removeCachedPayload,
writeCachedPayload,
} from "@/components/dashboard/scan-terminal/scan-terminal-cache";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import { normalizeCityKey } from "./decision-utils";
const AI_CITY_FORECAST_CACHE_PREFIX = "polyWeather_aiCityForecast_v6";
const AI_CITY_FORECAST_CACHE_TTL_MS = 60 * 60 * 1000;
const CITY_MARKET_SCAN_CACHE_PREFIX = "polyWeather_cityMarketScan_v3";
const CITY_MARKET_SCAN_CACHE_TTL_MS = 10 * 60 * 1000;
type CityMarketScanStatus = "idle" | "loading" | "ready" | "failed";
const aiCityForecastStateCache = new Map<
string,
{ state: AiCityForecastState; updatedAt: number }
>();
function isHkoObservationCity(detail?: CityDetail | null) {
const source = String(
detail?.current?.settlement_source ||
detail?.settlement_station?.settlement_source ||
"",
)
.trim()
.toLowerCase();
return source === "hko";
}
function readCachedAiForecastState(key: string, ttlMs: number) {
const cached = aiCityForecastStateCache.get(key);
if (!cached) return null;
if (Date.now() - cached.updatedAt > ttlMs) {
aiCityForecastStateCache.delete(key);
return null;
}
return cached.state;
}
function writeCachedAiForecastState(key: string, state: AiCityForecastState) {
if (!key || state.status === "idle") return;
aiCityForecastStateCache.set(key, {
state,
updatedAt: Date.now(),
});
}
function getAiCityStreamProgressText(progress: AiCityStreamProgress, isEn: boolean) {
const localizedMessage = String(
(isEn ? progress.message_en : progress.message_zh) ||
(isEn ? progress.final_judgment_en : progress.final_judgment_zh) ||
(isEn ? progress.metar_read_en : progress.metar_read_zh) ||
"",
).trim();
if (localizedMessage) return localizedMessage;
const rawLength = Number(progress.raw_length);
if (Number.isFinite(rawLength) && rawLength > 0) {
return isEn
? `DeepSeek is streaming the observation enhancement... ${Math.round(rawLength)} chars received.`
: `DeepSeek 正在流式增强观测解读... 已收到 ${Math.round(rawLength)} 字符。`;
}
return "";
}
function buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
}: {
detail: CityDetail | null;
error?: unknown;
isEn: boolean;
report: string;
}): AiCityForecastPayload {
const tempSymbol = detail?.temp_symbol || "°C";
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
const currentText =
currentTemp != null && Number.isFinite(Number(currentTemp))
? `${Number(currentTemp).toFixed(1)}${tempSymbol}`
: isEn
? "the latest observed temperature"
: "最新实测温度";
const timeoutLike = /timeout|timed out|504|aborted|超时/i.test(String(error || ""));
const rawMetar = isHkoObservation
? ""
: String(report || detail?.airport_current?.raw_metar || detail?.current?.raw_metar || "").trim();
const sourceZh = isHkoObservation ? "香港天文台观测" : "METAR";
const sourceEn = isHkoObservation ? "Hong Kong Observatory observation" : "METAR";
const bulletinZh = isHkoObservation ? "官方观测" : "机场报文";
const bulletinEn = isHkoObservation ? "official observation" : "airport bulletin";
const finalZh = timeoutLike
? `DeepSeek 增强暂未返回;当前先以多模型集中度和最新${sourceZh}快速判断。`
: `当前先以多模型集中度和最新${sourceZh}快速判断。`;
const finalEn = timeoutLike
? `DeepSeek enhancement is not back yet; use the model cluster and latest ${sourceEn} as the fast working read.`
: `Use the model cluster and latest ${sourceEn} as the fast working read.`;
const metarZh = rawMetar
? `最新 METAR 显示 ${currentText};当前先作为实况锚点,并结合后续报文确认温度路径。`
: `当前可先参考 ${currentText} 与多模型路径,等待下一次${bulletinZh}更新。`;
const metarEn = rawMetar
? `Latest METAR shows ${currentText}; use it as the live anchor while later reports confirm the path.`
: `Use ${currentText} and the model path for now while waiting for the next ${bulletinEn}.`;
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
return {
city_forecast: {
confidence: "low",
final_judgment_en: finalEn,
final_judgment_zh: finalZh,
metar_read_en: metarEn,
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
risks_zh: [],
unit: tempSymbol,
},
raw_reason: timeoutLike ? "ai_timeout_fallback" : "ai_unavailable_fallback",
reason: isEn ? reasonEn : reasonZh,
reason_en: reasonEn,
reason_zh: reasonZh,
status: timeoutLike ? "timeout_fallback" : "fallback",
};
}
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() => {
if (!detail) return "";
const isHkoObservation = isHkoObservationCity(detail);
const observationSource = isHkoObservation ? "hko" : "metar";
const observationCurrent = isHkoObservation
? detail.current || {}
: detail.airport_current || detail.current || {};
const observationSignature =
(!isHkoObservation ? String(report || "").trim() : "") ||
[
observationSource,
observationCurrent.report_time,
observationCurrent.obs_time_epoch,
observationCurrent.obs_time,
observationCurrent.receipt_time,
observationCurrent.temp,
observationCurrent.max_so_far,
observationCurrent.station_code,
detail.metar_status?.stale_for_today,
detail.metar_status?.last_observation_time,
]
.filter((part) => part != null && part !== "")
.join("|");
return [
normalizeCityKey(detailCityName),
detail.local_date || "",
locale,
observationSignature,
].join(":");
},
[detail, detailCityName, locale, report],
);
useEffect(() => {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const cacheKey = buildStorageKey(AI_CITY_FORECAST_CACHE_PREFIX, [aiForecastKey]);
const requestKey = `${cacheKey}:${aiRefreshToken > 0 ? `refresh:${aiRefreshToken}` : "normal"}`;
const cachedPayload =
aiRefreshToken <= 0
? readCachedPayload<AiCityForecastPayload>(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
)
: null;
if (cachedPayload) {
if (
cachedPayload.status === "ready" &&
!cachedPayload.degraded &&
cachedPayload.city_forecast
) {
const readyState: AiCityForecastState = {
payload: cachedPayload,
status: "ready",
};
writeCachedAiForecastState(cacheKey, readyState);
setAiForecast(readyState);
return () => {
cancelled = true;
};
}
removeCachedPayload(cacheKey);
}
const cachedState =
aiRefreshToken <= 0
? readCachedAiForecastState(cacheKey, AI_CITY_FORECAST_CACHE_TTL_MS)
: null;
if (cachedState?.status === "ready") {
setAiForecast(cachedState);
return () => {
cancelled = true;
};
}
const initialFallback = buildAiCityFallbackPayload({ detail, isEn, report });
const loadingState: AiCityForecastState =
cachedState?.status === "loading"
? cachedState
: {
status: "loading",
streamText:
(isEn
? initialFallback.city_forecast?.metar_read_en
: initialFallback.city_forecast?.metar_read_zh) ||
(isEn
? "Reading the latest observation with model fallback ready..."
: "已先用最新观测给出兜底解读,正在等待 DeepSeek 补充…"),
};
writeCachedAiForecastState(cacheKey, loadingState);
setAiForecast(loadingState);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return;
const cachedProgressState = readCachedAiForecastState(
cacheKey,
AI_CITY_FORECAST_CACHE_TTL_MS,
);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
writeCachedAiForecastState(cacheKey, {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
});
if (cancelled) return;
setAiForecast((current) => ({
...current,
status: "loading",
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
}));
},
requestKey,
})
.then((payload) => {
if (!payload) return;
const usablePayload =
payload?.city_forecast
? payload
: buildAiCityFallbackPayload({
detail,
error: payload?.reason || payload?.raw_reason || payload?.status,
isEn,
report,
});
if (usablePayload.status === "ready" && !usablePayload.degraded) {
writeCachedPayload(cacheKey, usablePayload);
}
writeCachedAiForecastState(cacheKey, {
payload: usablePayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: usablePayload, status: "ready" });
}
})
.catch((error) => {
const fallbackPayload = buildAiCityFallbackPayload({
detail,
error,
isEn,
report,
});
writeCachedAiForecastState(cacheKey, {
payload: fallbackPayload,
status: "ready",
});
if (!cancelled) {
setAiForecast({ payload: fallbackPayload, status: "ready" });
}
});
return () => {
cancelled = true;
};
}, [aiForecastKey, aiRefreshToken, detail, detailCityName, enabled, isEn, locale, report]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const [marketRemote, setMarketRemote] = useState<RemoteData<MarketScan>>(
detail?.market_scan ? toRemoteSuccess(detail.market_scan) : { status: "idle" },
);
useEffect(() => {
if (!detail) {
setMarketRemote({ status: "idle" });
return;
}
const cacheKey = buildStorageKey(CITY_MARKET_SCAN_CACHE_PREFIX, [
normalizeCityKey(detailCityName),
detail.local_date || "",
"full",
]);
let cancelled = false;
if (detail.market_scan) {
setMarketRemote(toRemoteSuccess(detail.market_scan));
writeCachedPayload(cacheKey, detail.market_scan);
return () => {
cancelled = true;
};
}
if (!enabled) {
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketRemote(toRemoteSuccess(cached));
} else {
setMarketRemote({ status: "idle" });
}
return () => {
cancelled = true;
};
}
const cached = readCachedPayload<MarketScan>(
cacheKey,
CITY_MARKET_SCAN_CACHE_TTL_MS,
);
if (cached) {
setMarketRemote(toRemoteSuccess(cached));
return () => {
cancelled = true;
};
} else {
setMarketRemote((current) => toRemoteLoading(current));
}
const controller = new AbortController();
void scanTerminalClient.getMarketScan(detailCityName, {
lite: false,
signal: controller.signal,
targetDate: detail.local_date || null,
})
.then((payload) => {
if (cancelled) return;
if (payload) {
writeCachedPayload(cacheKey, payload);
}
const nextPayload = payload || detail.market_scan || null;
if (nextPayload) {
setMarketRemote(toRemoteSuccess(nextPayload));
} else {
setMarketRemote({ status: "idle" });
}
})
.catch((error) => {
if (cancelled) return;
if (detail.market_scan) {
setMarketRemote(toRemoteSuccess(detail.market_scan));
} else {
setMarketRemote((current) => toRemoteError(error, current));
}
});
return () => {
cancelled = true;
controller.abort();
};
}, [detail, detailCityName, enabled]);
const previousMarketScan =
marketRemote.status === "loading" || marketRemote.status === "error"
? marketRemote.previous ?? null
: null;
const marketScan =
marketRemote.status === "success"
? marketRemote.data
: previousMarketScan ?? detail?.market_scan ?? null;
const marketStatus: CityMarketScanStatus =
marketRemote.status === "success"
? "ready"
: marketRemote.status === "loading"
? "loading"
: marketRemote.status === "error"
? marketScan
? "ready"
: "failed"
: "idle";
return { marketRemote, marketScan, marketStatus };
}
export { useAiCityForecast } from "./use-ai-city-forecast";
export { useCityMarketScan } from "./use-city-market-scan";
@@ -0,0 +1,128 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import type { AiCityForecastState } from "@/components/dashboard/scan-terminal/types";
import type { CityDetail } from "@/lib/dashboard-types";
import {
buildAiCityErrorForecastState,
buildAiCityForecastCacheKey,
buildAiCityForecastKey,
buildAiCityForecastRequestKey,
buildAiCityLoadingForecastState,
buildAiCityProgressForecastState,
buildAiCityReadyForecastState,
readReadyCachedAiForecastState,
} from "./ai-city-forecast-stream-state";
export function useAiCityForecast({
detail,
detailCityName,
isEn,
locale,
report,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
isEn: boolean;
locale: string;
report: string;
}) {
const [aiForecast, setAiForecast] = useState<AiCityForecastState>({
status: "idle",
});
const [aiRefreshToken, setAiRefreshToken] = useState(0);
const aiForecastKey = useMemo(
() => buildAiCityForecastKey({ detail, detailCityName, locale, report }),
[detail, detailCityName, locale, report],
);
useEffect(() => {
if (!enabled || !aiForecastKey) {
setAiForecast({ status: "idle" });
return;
}
let cancelled = false;
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
const readyCachedState = readReadyCachedAiForecastState(
cacheKey,
aiRefreshToken,
);
if (readyCachedState) {
setAiForecast(readyCachedState);
return () => {
cancelled = true;
};
}
const loadingState = buildAiCityLoadingForecastState({
cacheKey,
detail,
isEn,
report,
});
setAiForecast(loadingState);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
locale,
onProgress: (progress) => {
if (cancelled) return;
setAiForecast((current) =>
buildAiCityProgressForecastState({
cacheKey,
current,
isEn,
progress,
}) ?? current,
);
},
requestKey,
})
.then((payload) => {
if (!payload) return;
const readyState = buildAiCityReadyForecastState({
cacheKey,
detail,
isEn,
payload,
report,
});
if (!cancelled) {
setAiForecast(readyState);
}
})
.catch((error) => {
const errorState = buildAiCityErrorForecastState({
cacheKey,
detail,
error,
isEn,
report,
});
if (!cancelled) {
setAiForecast(errorState);
}
});
return () => {
cancelled = true;
};
}, [
aiForecastKey,
aiRefreshToken,
detail,
detailCityName,
enabled,
isEn,
locale,
report,
]);
const refreshAiForecast = useCallback(() => {
setAiRefreshToken((current) => current + 1);
}, []);
return { aiForecast, refreshAiForecast };
}
@@ -0,0 +1,75 @@
"use client";
import { useEffect } from "react";
import { scanTerminalClient } from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import type { CityDetail, MarketScan } from "@/lib/dashboard-types";
import {
deriveCityMarketScanView,
resolveCityMarketScanSnapshot,
writeCachedCityMarketScan,
} from "./market-scan-state";
export function useCityMarketScan({
detail,
detailCityName,
enabled = true,
}: {
detail: CityDetail | null;
detailCityName: string;
enabled?: boolean;
}) {
const {
remote: marketRemote,
reset: resetMarketRemote,
run: runMarketScanQuery,
setSuccess: setMarketScanSuccess,
} = useRemoteDataQuery<MarketScan>();
useEffect(() => {
const snapshot = resolveCityMarketScanSnapshot({
detail,
detailCityName,
enabled,
});
if (snapshot.action === "reset") {
resetMarketRemote();
return;
}
if (snapshot.action === "success") {
setMarketScanSuccess(snapshot.payload);
if (snapshot.shouldWriteCache) {
writeCachedCityMarketScan(snapshot.cacheKey, snapshot.payload);
}
return;
}
void runMarketScanQuery({
request: (signal) =>
scanTerminalClient.getMarketScan(detailCityName, {
lite: false,
signal,
targetDate: detail?.local_date || null,
}),
showLoading: true,
onSuccess: (payload) => {
if (payload) {
writeCachedCityMarketScan(snapshot.cacheKey, payload);
}
},
});
}, [
detail,
detailCityName,
enabled,
resetMarketRemote,
runMarketScanQuery,
setMarketScanSuccess,
]);
const { marketScan, marketStatus } = deriveCityMarketScanView({
detailMarketScan: detail?.market_scan,
marketRemote,
});
return { marketRemote, marketScan, marketStatus };
}
@@ -0,0 +1,114 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
type RemoteData,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
type RunRemoteQueryOptions<T> = {
onSuccess?: (data: T) => void;
request: (signal: AbortSignal) => Promise<T>;
showLoading?: boolean;
};
export function useRemoteDataQuery<T>() {
const [data, setData] = useState<T | null>(null);
const [remote, setRemote] = useState<RemoteData<T>>({ status: "idle" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const requestSeqRef = useRef(0);
const loadingRef = useRef(false);
const abort = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
}, []);
const reset = useCallback(() => {
requestSeqRef.current += 1;
abort();
loadingRef.current = false;
setLoading(false);
setError(null);
setData(null);
setRemote({ status: "idle" });
}, [abort]);
const setSuccess = useCallback(
(nextData: T) => {
requestSeqRef.current += 1;
abort();
loadingRef.current = false;
setLoading(false);
setError(null);
setData(nextData);
setRemote(toRemoteSuccess(nextData));
},
[abort],
);
const run = useCallback(
async ({
onSuccess,
request,
showLoading = false,
}: RunRemoteQueryOptions<T>) => {
const requestSeq = ++requestSeqRef.current;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
if (showLoading) {
loadingRef.current = true;
setLoading(true);
setRemote((current) => toRemoteLoading(current));
}
setError(null);
try {
const payload = await request(controller.signal);
if (requestSeq !== requestSeqRef.current) return null;
setData(payload);
setRemote(toRemoteSuccess(payload));
setError(null);
onSuccess?.(payload);
return payload;
} catch (caught) {
if (controller.signal.aborted || requestSeq !== requestSeqRef.current) {
return null;
}
const message = caught instanceof Error ? caught.message : String(caught);
setError(message);
setRemote((current) => toRemoteError(caught, current));
return null;
} finally {
if (abortRef.current === controller) {
abortRef.current = null;
}
if (showLoading) {
loadingRef.current = false;
setLoading(false);
}
}
},
[],
);
const isLoading = useCallback(() => loadingRef.current, []);
useEffect(() => abort, [abort]);
return {
abort,
data,
error,
isLoading,
loading,
remote,
reset,
run,
setSuccess,
};
}
@@ -1,18 +1,15 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef } from "react";
import {
scanTerminalQueryPolicy,
scanTerminalClient,
toRemoteError,
toRemoteLoading,
toRemoteSuccess,
type RemoteData,
shouldRunAutoTerminalRefresh,
shouldSkipManualTerminalRefresh,
} from "@/components/dashboard/scan-terminal/scan-terminal-client";
import { useRemoteDataQuery } from "@/components/dashboard/scan-terminal/use-remote-data-query";
import type { ScanTerminalResponse } from "@/lib/dashboard-types";
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
export function useScanTerminalQuery({
isPro,
proAccessLoading,
@@ -20,15 +17,15 @@ export function useScanTerminalQuery({
isPro: boolean;
proAccessLoading: boolean;
}) {
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
const [scanRemote, setScanRemote] = useState<RemoteData<ScanTerminalResponse>>({
status: "idle",
});
const [scanLoading, setScanLoading] = useState(false);
const [scanError, setScanError] = useState<string | null>(null);
const scanAbortRef = useRef<AbortController | null>(null);
const scanRequestSeqRef = useRef(0);
const scanLoadingRef = useRef(false);
const {
data: terminalData,
error: scanError,
isLoading,
loading: scanLoading,
remote: scanRemote,
reset,
run,
} = useRemoteDataQuery<ScanTerminalResponse>();
const lastForcedScanRefreshAtRef = useRef(0);
const fetchScanTerminal = useCallback(
@@ -40,74 +37,37 @@ export function useScanTerminalQuery({
showLoading?: boolean;
} = {}) => {
if (proAccessLoading || !isPro) return;
const requestSeq = ++scanRequestSeqRef.current;
scanAbortRef.current?.abort();
const controller = new AbortController();
scanAbortRef.current = controller;
if (forceRefresh) {
lastForcedScanRefreshAtRef.current = Date.now();
}
if (showLoading) {
scanLoadingRef.current = true;
setScanLoading(true);
setScanRemote((current) => toRemoteLoading(current));
}
setScanError(null);
try {
const payload = await scanTerminalClient.getTerminal({
forceRefresh,
signal: controller.signal,
});
if (requestSeq !== scanRequestSeqRef.current) return;
setTerminalData(payload);
setScanRemote(toRemoteSuccess(payload));
setScanError(null);
} catch (error) {
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
const message = error instanceof Error ? error.message : String(error);
setScanError(message);
setScanRemote((current) => toRemoteError(error, current));
} finally {
if (scanAbortRef.current === controller) {
scanAbortRef.current = null;
}
if (showLoading) {
scanLoadingRef.current = false;
setScanLoading(false);
}
}
await run({
request: (signal) =>
scanTerminalClient.getTerminal({
forceRefresh,
signal,
}),
showLoading,
});
},
[isPro, proAccessLoading],
[isPro, proAccessLoading, run],
);
useEffect(() => {
if (proAccessLoading) return;
if (!isPro) {
scanLoadingRef.current = false;
setScanLoading(false);
setScanError(null);
setTerminalData(null);
setScanRemote({ status: "idle" });
reset();
return;
}
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
}, [fetchScanTerminal, isPro, proAccessLoading]);
useEffect(() => {
return () => {
scanAbortRef.current?.abort();
};
}, []);
}, [fetchScanTerminal, isPro, proAccessLoading, reset]);
const refreshScanTerminalManually = useCallback(() => {
const now = Date.now();
const lastForced = lastForcedScanRefreshAtRef.current;
const withinCooldown =
lastForced > 0 &&
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
terminalData;
if (withinCooldown) {
setScanError(null);
if (
shouldSkipManualTerminalRefresh({
hasCurrentData: Boolean(terminalData),
lastForcedRefreshAt: lastForcedScanRefreshAtRef.current,
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
@@ -116,12 +76,18 @@ export function useScanTerminalQuery({
useEffect(() => {
if (proAccessLoading || !isPro) return;
const intervalId = window.setInterval(() => {
if (document.hidden) return;
if (scanLoadingRef.current) return;
if (
!shouldRunAutoTerminalRefresh({
documentHidden: document.hidden,
isLoading: isLoading(),
})
) {
return;
}
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
}, scanTerminalQueryPolicy.autoRefreshMs);
return () => window.clearInterval(intervalId);
}, [fetchScanTerminal, isPro, proAccessLoading]);
}, [fetchScanTerminal, isLoading, isPro, proAccessLoading]);
return {
refreshScanTerminalManually,