feat: implement dashboard detail panel with interactive charts and forecast modal components

This commit is contained in:
2569718930@qq.com
2026-04-24 13:35:36 +08:00
parent b94289a51a
commit 5fc65cbd85
4 changed files with 69 additions and 60 deletions
@@ -3107,6 +3107,33 @@
transform: translateX(0); transform: translateX(0);
} }
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
position: relative;
inset: auto;
width: auto;
min-width: 0;
height: auto;
min-height: calc(100vh - 32px);
z-index: auto;
transform: none;
transition: none;
border: 1px solid rgba(82, 114, 161, 0.18);
border-left: 1px solid rgba(82, 114, 161, 0.18);
border-radius: 22px;
background: linear-gradient(180deg, rgba(15, 28, 47, 0.94), rgba(9, 18, 32, 0.94));
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
backdrop-filter: blur(14px);
align-self: stretch;
}
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail.visible) {
transform: none;
}
.root :global(.scan-city-detail-rail .panel-header) {
padding: 20px 16px 16px;
}
.root :global(.panel-header) { .root :global(.panel-header) {
padding: 20px 20px 16px; padding: 20px 20px 16px;
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
@@ -10565,6 +10592,11 @@
grid-template-columns: 240px minmax(0, 1fr); grid-template-columns: 240px minmax(0, 1fr);
} }
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
grid-column: 1 / -1;
min-height: auto;
}
.root :global(.scan-detail-panel) { .root :global(.scan-detail-panel) {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: auto; min-height: auto;
@@ -10578,7 +10610,8 @@
.root :global(.scan-filter-panel), .root :global(.scan-filter-panel),
.root :global(.scan-data-grid), .root :global(.scan-data-grid),
.root :global(.scan-detail-panel) { .root :global(.scan-detail-panel),
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
min-height: auto; min-height: auto;
} }
+27 -15
View File
@@ -130,10 +130,15 @@ function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
); );
} }
export function DetailPanel() { export function DetailPanel({
variant = "overlay",
}: {
variant?: "overlay" | "rail";
} = {}) {
const store = useDashboardStore(); const store = useDashboardStore();
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const router = useRouter(); const router = useRouter();
const isRail = variant === "rail";
const detail = store.selectedDetail; const detail = store.selectedDetail;
const selectedCityItem = useMemo( const selectedCityItem = useMemo(
() => () =>
@@ -155,8 +160,9 @@ export function DetailPanel() {
const [heavyContentReady, setHeavyContentReady] = useState(false); const [heavyContentReady, setHeavyContentReady] = useState(false);
const isOverlayOpen = const isOverlayOpen =
Boolean(store.futureModalDate) || store.historyState.isOpen; Boolean(store.futureModalDate) || store.historyState.isOpen;
const isVisible = const isVisible = isRail
store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen; ? Boolean(store.selectedCity) && !isOverlayOpen
: store.isPanelOpen && Boolean(store.selectedCity) && !isOverlayOpen;
const hasBasicPanelContent = Boolean( const hasBasicPanelContent = Boolean(
detail || selectedSummary || selectedCityItem, detail || selectedSummary || selectedCityItem,
); );
@@ -311,20 +317,26 @@ export function DetailPanel() {
return ( return (
<aside <aside
ref={panelRef} ref={panelRef}
className={clsx("detail-panel", isVisible && "visible")} className={clsx(
"detail-panel",
isRail && "scan-city-detail-rail",
(isVisible || isRail) && "visible",
)}
> >
<div className="panel-header"> <div className="panel-header">
<button {!isRail ? (
type="button" <button
className="panel-close" type="button"
aria-label={t("detail.closeAria")} className="panel-close"
onClick={() => { aria-label={t("detail.closeAria")}
blurActiveElement(); onClick={() => {
store.closePanel(); blurActiveElement();
}} store.closePanel();
> }}
× >
</button> ×
</button>
) : null}
<div className="panel-title-area"> <div className="panel-title-area">
<div className="panel-title-stack"> <div className="panel-title-stack">
<div className="panel-overline"> <div className="panel-overline">
@@ -41,6 +41,8 @@ import type {
MarketScan, MarketScan,
} from "@/lib/dashboard-types"; } from "@/lib/dashboard-types";
const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
function normalizeMarketValue(value?: number | null) { function normalizeMarketValue(value?: number | null) {
if (value == null) return null; if (value == null) return null;
const numeric = Number(value); const numeric = Number(value);
@@ -830,7 +832,7 @@ function FutureForecastModalContent({
return; return;
} }
refreshMarketScan(); refreshMarketScan();
}, 30_000); }, TODAY_MARKET_SCAN_AUTO_REFRESH_MS);
return () => { return () => {
cancelled = true; cancelled = true;
@@ -68,6 +68,8 @@ const DEFAULT_FILTERS: FilterState = {
limit: 28, limit: 28,
}; };
const SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
type ContentView = "list" | "map" | "calendar"; type ContentView = "list" | "map" | "calendar";
type ThemeMode = "dark" | "light"; type ThemeMode = "dark" | "light";
type AssistantMessage = { type AssistantMessage = {
@@ -1223,8 +1225,6 @@ function ScanTerminalScreen() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedRowId, setSelectedRowId] = useState<string | null>(null); const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [detailByRowId, setDetailByRowId] = useState<Record<string, MarketScan | null>>({});
const [detailLoadingId, setDetailLoadingId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<ContentView>("list"); const [activeView, setActiveView] = useState<ContentView>("list");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null); const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [userLocalTime, setUserLocalTime] = useState("--"); const [userLocalTime, setUserLocalTime] = useState("--");
@@ -1357,30 +1357,6 @@ function ScanTerminalScreen() {
} }
}; };
const fetchDetail = async (row: ScanOpportunityRow) => {
if (!row.market_slug || !row.selected_date || row.closed) return;
if (detailByRowId[row.id] !== undefined) return;
setDetailLoadingId(row.id);
try {
const response = await dashboardClient.getCityMarketScan(row.city, {
force: false,
marketSlug: row.market_slug,
targetDate: row.selected_date,
});
setDetailByRowId((current) => ({
...current,
[row.id]: response.market_scan || null,
}));
} catch {
setDetailByRowId((current) => ({
...current,
[row.id]: null,
}));
} finally {
setDetailLoadingId((current) => (current === row.id ? null : current));
}
};
useEffect(() => { useEffect(() => {
void fetchTerminal(DEFAULT_FILTERS, false); void fetchTerminal(DEFAULT_FILTERS, false);
}, []); }, []);
@@ -1388,7 +1364,7 @@ function ScanTerminalScreen() {
useEffect(() => { useEffect(() => {
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void fetchTerminal(activeFilters, false); void fetchTerminal(activeFilters, false);
}, 30_000); }, SCAN_AUTO_REFRESH_MS);
return () => window.clearInterval(intervalId); return () => window.clearInterval(intervalId);
}, [activeFilters]); }, [activeFilters]);
@@ -1417,18 +1393,16 @@ function ScanTerminalScreen() {
resolvedView === "map" && mapFocusedCity resolvedView === "map" && mapFocusedCity
? mapFocusedRow || mapFallbackRow ? mapFocusedRow || mapFallbackRow
: selectedRow; : selectedRow;
const selectedDetail = activeDetailRow ? detailByRowId[activeDetailRow.id] : null;
const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready"); const scanStatus = terminalData?.status || (loading ? "loading" : error ? "failed" : "ready");
const staleReason = const staleReason =
terminalData?.stale_reason || error || null; terminalData?.stale_reason || error || null;
useEffect(() => { useEffect(() => {
if (!activeDetailRow) return; if (!activeDetailRow) return;
void fetchDetail(activeDetailRow);
if (!store.cityDetailsByName[activeDetailRow.city]) { if (!store.cityDetailsByName[activeDetailRow.city]) {
void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {}); void store.ensureCityDetail(activeDetailRow.city, false, "panel").catch(() => {});
} }
}, [activeDetailRow, detailByRowId]); }, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
const handleMapCitySelect = useCallback((cityName: string) => { const handleMapCitySelect = useCallback((cityName: string) => {
setMapSelectedCityName(cityName); setMapSelectedCityName(cityName);
@@ -1618,19 +1592,7 @@ function ScanTerminalScreen() {
</section> </section>
</main> </main>
<ScanOpportunityDetailPanel <CityDetailPanel variant="rail" />
row={activeDetailRow}
marketScan={selectedDetail}
loading={detailLoadingId === activeDetailRow?.id}
/>
<CityDetailPanel />
<AssistantWidget
terminalData={terminalData}
rows={timeSortedRows}
selectedRow={activeDetailRow}
locale={locale}
totalCities={store.cities.length}
/>
</div> </div>
</div> </div>
); );