feat: implement dashboard store and scan terminal components for city market analysis
This commit is contained in:
@@ -9879,20 +9879,24 @@
|
||||
|
||||
.root :global(.scan-distribution-card) {
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
padding: 9px 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(87, 119, 166, 0.1);
|
||||
background: rgba(15, 28, 46, 0.72);
|
||||
color: #b4c7df;
|
||||
font-size: 11px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-card strong) {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 1px;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@@ -9900,6 +9904,47 @@
|
||||
color: #22eb98;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line) {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(22px, 1fr) auto;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
color: rgba(180, 199, 223, 0.9);
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line b) {
|
||||
color: rgba(148, 163, 184, 0.78);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line i) {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line.model i) {
|
||||
background: linear-gradient(90deg, #17d98b, #20e3d2);
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line.market i) {
|
||||
background: linear-gradient(90deg, #3f8cff, #57a8ff);
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-line em) {
|
||||
overflow: hidden;
|
||||
color: rgba(226, 237, 252, 0.92);
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 760;
|
||||
line-height: 1;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-cell) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -10083,35 +10128,6 @@
|
||||
border-color: rgba(23, 217, 139, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-inline-analysis) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-inline-analysis .models-section) {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-inline-analysis .model-bars) {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(90, 123, 166, 0.14);
|
||||
background: rgba(10, 20, 35, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.scan-inline-analysis-state) {
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
color: #9bb0cb;
|
||||
font-size: 13px;
|
||||
background: rgba(10, 20, 35, 0.52);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section) {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
|
||||
@@ -10447,6 +10463,14 @@
|
||||
color: #647a98;
|
||||
}
|
||||
|
||||
.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),
|
||||
@@ -10455,9 +10479,7 @@
|
||||
.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-trade-card),
|
||||
.root :global(.scan-terminal.light .scan-detail-inline-analysis .model-bars),
|
||||
.root :global(.scan-terminal.light .scan-inline-analysis-state) {
|
||||
.root :global(.scan-terminal.light .scan-trade-card) {
|
||||
border-color: rgba(35, 72, 118, 0.12);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
@@ -153,33 +153,40 @@ function ProbabilityPreview({
|
||||
});
|
||||
}
|
||||
|
||||
const modelLabel = locale === "en-US" ? "M" : "模";
|
||||
const marketLabel = locale === "en-US" ? "P" : "市";
|
||||
|
||||
return (
|
||||
<div className="scan-distribution-preview">
|
||||
{preview.slice(0, 6).map((item) => (
|
||||
<div
|
||||
key={`${item.label}-${item.value ?? ""}`}
|
||||
className={`scan-distribution-card ${item.highlighted ? "featured" : ""}`}
|
||||
>
|
||||
<strong>
|
||||
{normalizeTemperatureLabel(item.label, tempSymbol) || item.label || "--"}
|
||||
</strong>
|
||||
<span>
|
||||
{locale === "en-US" ? "Model" : "模型"}
|
||||
<br />
|
||||
{formatPercent(
|
||||
item.model_probability != null ? item.model_probability * 100 : null,
|
||||
)}
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
{locale === "en-US" ? "Market" : "市场"}
|
||||
<br />
|
||||
{formatPercent(
|
||||
item.market_probability != null ? item.market_probability * 100 : null,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{preview.slice(0, 6).map((item) => {
|
||||
const modelPercent =
|
||||
item.model_probability != null ? item.model_probability * 100 : null;
|
||||
const marketPercent =
|
||||
item.market_probability != null ? item.market_probability * 100 : null;
|
||||
const modelWidth = Math.max(3, Math.min(100, Number(modelPercent || 0)));
|
||||
const marketWidth = Math.max(3, Math.min(100, Number(marketPercent || 0)));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${item.label}-${item.value ?? ""}`}
|
||||
className={`scan-distribution-card ${item.highlighted ? "featured" : ""}`}
|
||||
>
|
||||
<strong>
|
||||
{normalizeTemperatureLabel(item.label, tempSymbol) || item.label || "--"}
|
||||
</strong>
|
||||
<span className="scan-distribution-line model">
|
||||
<b>{modelLabel}</b>
|
||||
<i style={{ width: `${modelWidth}%` }} />
|
||||
<em>{formatPercent(modelPercent)}</em>
|
||||
</span>
|
||||
<span className="scan-distribution-line market">
|
||||
<b>{marketLabel}</b>
|
||||
<i style={{ width: `${marketWidth}%` }} />
|
||||
<em>{formatPercent(marketPercent)}</em>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,9 +25,9 @@ import {
|
||||
FilterState,
|
||||
ScanFilterPanel,
|
||||
} from "@/components/dashboard/ScanFilterPanel";
|
||||
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
||||
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
||||
import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
||||
import { ModelForecast } from "@/components/dashboard/PanelSections";
|
||||
import { ScanKPIBar } from "@/components/dashboard/ScanKPIBar";
|
||||
import { OpportunityTable } from "@/components/dashboard/OpportunityTable";
|
||||
import {
|
||||
@@ -151,12 +151,11 @@ function formatShortDate(value?: string | null, locale = "zh-CN") {
|
||||
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatUserLocalTime(locale = "zh-CN") {
|
||||
return new Intl.DateTimeFormat(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date());
|
||||
function formatUserLocalTime() {
|
||||
const now = new Date();
|
||||
return `${String(now.getHours()).padStart(2, "0")}:${String(
|
||||
now.getMinutes(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getLocalDateIndex(value?: string | null) {
|
||||
@@ -345,30 +344,15 @@ function DetailPanel({
|
||||
);
|
||||
const scoreClass = scoreTone(displayRow.final_score);
|
||||
const phaseMeta = getWindowPhaseMeta(displayRow, locale);
|
||||
const analysisDetail =
|
||||
const cityDetail =
|
||||
store.selectedDetail?.name?.toLowerCase() === row.city.toLowerCase()
|
||||
? store.selectedDetail
|
||||
: store.cityDetailsByName[row.city] || null;
|
||||
const [showInlineAnalysis, setShowInlineAnalysis] = useState(false);
|
||||
const analysisLoading = Boolean(
|
||||
showInlineAnalysis &&
|
||||
row.city &&
|
||||
!analysisDetail &&
|
||||
store.loadingState.cityDetail,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShowInlineAnalysis(false);
|
||||
}, [row.id]);
|
||||
|
||||
const openTodayAnalysis = async () => {
|
||||
if (!row.city) return;
|
||||
if (showInlineAnalysis && analysisDetail) {
|
||||
setShowInlineAnalysis(false);
|
||||
return;
|
||||
}
|
||||
setShowInlineAnalysis(true);
|
||||
await store.selectCity(row.city);
|
||||
await store.openTodayModal();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -416,29 +400,6 @@ function DetailPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showInlineAnalysis && (
|
||||
<section className="scan-detail-section scan-detail-inline-analysis">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Today's Intraday Analysis" : "今日日内分析"}
|
||||
</div>
|
||||
{analysisLoading ? (
|
||||
<div className="scan-inline-analysis-state">
|
||||
{isEn ? "Loading model spread card..." : "正在加载模型分歧卡片..."}
|
||||
</div>
|
||||
) : analysisDetail ? (
|
||||
<ModelForecast
|
||||
detail={analysisDetail}
|
||||
hideTitle
|
||||
targetDate={row.selected_date || null}
|
||||
/>
|
||||
) : (
|
||||
<div className="scan-inline-analysis-state">
|
||||
{isEn ? "No intraday model card available yet." : "当前还没有可展示的日内模型卡片。"}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-detail-section-title">
|
||||
{isEn ? "Current Context" : "当前概况"}
|
||||
@@ -503,8 +464,8 @@ function DetailPanel({
|
||||
|
||||
<section className="scan-detail-section">
|
||||
<div className="scan-timeline-head">
|
||||
<span>00:00</span>
|
||||
<span>23:59</span>
|
||||
<span>{cityDetail?.forecast?.sunrise || "--"}</span>
|
||||
<span>{cityDetail?.forecast?.sunset || "--"}</span>
|
||||
</div>
|
||||
<div className="scan-timeline-bar">
|
||||
<span
|
||||
@@ -521,7 +482,7 @@ function DetailPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className="scan-timeline-caption">
|
||||
{phaseMeta.label}
|
||||
{isEn ? "Sunrise / Sunset" : "日出 / 日落"} · {phaseMeta.label}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -901,12 +862,12 @@ function ScanTerminalScreen() {
|
||||
}, [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserLocalTime(formatUserLocalTime(locale));
|
||||
setUserLocalTime(formatUserLocalTime());
|
||||
const intervalId = window.setInterval(() => {
|
||||
setUserLocalTime(formatUserLocalTime(locale));
|
||||
}, 30_000);
|
||||
setUserLocalTime(formatUserLocalTime());
|
||||
}, 10_000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [locale]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
||||
@@ -932,6 +893,9 @@ function ScanTerminalScreen() {
|
||||
useEffect(() => {
|
||||
if (!activeDetailRow) return;
|
||||
void fetchDetail(activeDetailRow);
|
||||
if (!store.cityDetailsByName[activeDetailRow.city]) {
|
||||
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
|
||||
}
|
||||
}, [activeDetailRow, detailByRowId]);
|
||||
|
||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||
@@ -1171,6 +1135,7 @@ export function ScanTerminalDashboard() {
|
||||
<I18nProvider>
|
||||
<DashboardStoreProvider>
|
||||
<ScanTerminalScreen />
|
||||
<FutureForecastModal />
|
||||
</DashboardStoreProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
|
||||
@@ -1237,12 +1237,13 @@ export function DashboardStoreProvider({
|
||||
},
|
||||
openHistory,
|
||||
openTodayModal: async (forceRefresh?: boolean) => {
|
||||
if (!selectedCity) {
|
||||
const activeCity = selectedCityRef.current || selectedCity;
|
||||
if (!activeCity) {
|
||||
return;
|
||||
}
|
||||
|
||||
mapStopMotionRef.current();
|
||||
const cityName = selectedCity;
|
||||
const cityName = activeCity;
|
||||
const modalSeq = (modalOpenSeqRef.current += 1);
|
||||
const isLatestModalRequest = () =>
|
||||
modalOpenSeqRef.current === modalSeq &&
|
||||
|
||||
Reference in New Issue
Block a user