feat: implement scan terminal service with caching, background refresh, and AI-driven market analysis support
This commit is contained in:
@@ -9600,6 +9600,12 @@
|
||||
color: #87fff1;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.focus) {
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
color: #ffd078;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-shell) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
CircleDot,
|
||||
Clock3,
|
||||
Info,
|
||||
Search,
|
||||
TrendingUp,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -52,13 +51,9 @@ const SCAN_MODES = [
|
||||
export function ScanFilterPanel({
|
||||
value,
|
||||
onChange,
|
||||
onScan,
|
||||
isScanning,
|
||||
}: {
|
||||
value: FilterState;
|
||||
onChange?: (filters: FilterState) => void;
|
||||
onScan?: (filters: FilterState) => void;
|
||||
isScanning?: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
@@ -114,21 +109,6 @@ export function ScanFilterPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="scan-cta-button"
|
||||
onClick={() => onScan?.(value)}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<Search size={16} />
|
||||
{isScanning
|
||||
? isEn
|
||||
? "Scanning..."
|
||||
: "扫描中..."
|
||||
: isEn
|
||||
? "Start Scan"
|
||||
: "开始扫描"}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
import { getMarketFocus } from "@/lib/scan-market-focus";
|
||||
|
||||
function formatPercent(value?: number | null, signed = false): string {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
@@ -41,6 +42,7 @@ export function ScanKPIBar({
|
||||
const tradableRows = rows.filter((row) => row.tradable && !row.closed);
|
||||
const liveRows = rows.filter((row) => row.active || row.accepting_orders);
|
||||
const bestRow = rows[0] || null;
|
||||
const marketFocus = getMarketFocus(rows, locale);
|
||||
const statusLabel =
|
||||
loading && !response
|
||||
? isEn
|
||||
@@ -91,10 +93,14 @@ export function ScanKPIBar({
|
||||
tone: "green",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Risk Layers" : "风险层",
|
||||
value: `${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
|
||||
note: `${isEn ? "High / Med / Low" : "高 / 中 / 低"}`,
|
||||
tone: "purple",
|
||||
label: isEn ? "Current Region" : "当前主盘",
|
||||
value: marketFocus?.label || "--",
|
||||
note: marketFocus
|
||||
? isEn
|
||||
? `${marketFocus.stageLabel} · ${marketFocus.activeCityCount || 1} cities in focus`
|
||||
: `${marketFocus.stageLabel} · ${marketFocus.activeCityCount || 1} 个城市在焦点窗口`
|
||||
: `${isEn ? "Risk" : "风险"} ${riskCounts.high} / ${riskCounts.medium} / ${riskCounts.low}`,
|
||||
tone: "orange",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
formatTemperatureValue,
|
||||
normalizeTemperatureLabel,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import {
|
||||
getMarketFocus,
|
||||
getRowMarketRegion,
|
||||
getRowPeakSortValue,
|
||||
} from "@/lib/scan-market-focus";
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
scan_mode: "tradable",
|
||||
@@ -222,15 +227,31 @@ function getPhaseUrgency(row: ScanOpportunityRow) {
|
||||
const phase = String(row.window_phase || "").toLowerCase();
|
||||
if (phase === "active_peak") return 0;
|
||||
if (phase === "setup_today") return 1;
|
||||
if (phase === "early_today") return 2;
|
||||
if (phase === "post_peak") return 3;
|
||||
if (phase === "post_peak") return 2;
|
||||
if (phase === "early_today") return 3;
|
||||
if (phase === "tomorrow") return 4;
|
||||
if (phase === "week_ahead") return 5;
|
||||
return 6;
|
||||
}
|
||||
|
||||
function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
|
||||
const focus = getMarketFocus(rows);
|
||||
return [...rows].sort((left, right) => {
|
||||
if (focus) {
|
||||
const leftFocusRank = getRowMarketRegion(left) === focus.key ? 0 : 1;
|
||||
const rightFocusRank = getRowMarketRegion(right) === focus.key ? 0 : 1;
|
||||
if (leftFocusRank !== rightFocusRank) return leftFocusRank - rightFocusRank;
|
||||
}
|
||||
|
||||
const leftPeakSort = getRowPeakSortValue(left);
|
||||
const rightPeakSort = getRowPeakSortValue(right);
|
||||
if (leftPeakSort.stage.rank !== rightPeakSort.stage.rank) {
|
||||
return leftPeakSort.stage.rank - rightPeakSort.stage.rank;
|
||||
}
|
||||
if (leftPeakSort.countdown !== rightPeakSort.countdown) {
|
||||
return leftPeakSort.countdown - rightPeakSort.countdown;
|
||||
}
|
||||
|
||||
const leftDateIndex = getLocalDateIndex(left.selected_date || left.local_date);
|
||||
const rightDateIndex = getLocalDateIndex(right.selected_date || right.local_date);
|
||||
if (leftDateIndex !== rightDateIndex) return leftDateIndex - rightDateIndex;
|
||||
@@ -253,6 +274,31 @@ function sortRowsByUserTime(rows: ScanOpportunityRow[]) {
|
||||
});
|
||||
}
|
||||
|
||||
function mergeRefreshingScanSnapshot(
|
||||
previous: ScanTerminalResponse | null,
|
||||
next: ScanTerminalResponse,
|
||||
locale = "zh-CN",
|
||||
): ScanTerminalResponse {
|
||||
if (next.rows.length || !previous?.rows.length) return next;
|
||||
const nextStatus = String(next.status || "").toLowerCase();
|
||||
if (!["partial", "stale", "scanning", "loading", "failed"].includes(nextStatus)) {
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...previous,
|
||||
generated_at: previous.generated_at || next.generated_at,
|
||||
status: nextStatus === "failed" ? "stale" : next.status || "stale",
|
||||
stale: true,
|
||||
stale_reason:
|
||||
next.stale_reason ||
|
||||
(locale === "en-US"
|
||||
? "Refreshing a new scan; showing the previous snapshot until it is ready."
|
||||
: "新扫描仍在刷新中,当前继续展示上一轮快照。"),
|
||||
last_failed_at: next.last_failed_at || previous.last_failed_at,
|
||||
filters: next.filters || previous.filters,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCityKey(value?: string | null) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
@@ -671,6 +717,10 @@ function ScanTerminalScreen() {
|
||||
() => sortRowsByUserTime(deferredRows),
|
||||
[deferredRows],
|
||||
);
|
||||
const marketFocus = useMemo(
|
||||
() => getMarketFocus(timeSortedRows, locale),
|
||||
[locale, timeSortedRows],
|
||||
);
|
||||
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!timeSortedRows.length) return null;
|
||||
@@ -778,26 +828,31 @@ function ScanTerminalScreen() {
|
||||
setAiError(null);
|
||||
try {
|
||||
const response = await dashboardClient.getScanTerminal(filters, { force });
|
||||
const displayRows = response.rows.length
|
||||
? response.rows
|
||||
: terminalData?.rows || [];
|
||||
startTransition(() => {
|
||||
setTerminalData(response);
|
||||
setTerminalData((current) =>
|
||||
mergeRefreshingScanSnapshot(current, response, locale),
|
||||
);
|
||||
setActiveFilters(filters);
|
||||
setError(response.status === "failed" ? response.stale_reason || null : null);
|
||||
setSelectedRowId((current) => {
|
||||
if (current && response.rows.some((row) => row.id === current)) {
|
||||
if (current && displayRows.some((row) => row.id === current)) {
|
||||
return current;
|
||||
}
|
||||
return sortRowsByUserTime(response.rows)[0]?.id || response.top_signal?.id || null;
|
||||
return sortRowsByUserTime(displayRows)[0]?.id || response.top_signal?.id || null;
|
||||
});
|
||||
});
|
||||
prependAiLogs([
|
||||
{
|
||||
id: `rule-${Date.now()}`,
|
||||
time: formatLogTime(),
|
||||
tone: response.rows.length ? "success" : "warning",
|
||||
tone: displayRows.length ? "success" : "warning",
|
||||
title: isEn ? "Rule scan snapshot ready" : "规则扫描快照已就绪",
|
||||
detail: isEn
|
||||
? `snapshot ${response.snapshot_id || "--"} · ${response.rows.length} visible rows`
|
||||
: `快照 ${response.snapshot_id || "--"} · 可见候选 ${response.rows.length} 条`,
|
||||
? `snapshot ${response.snapshot_id || "--"} · ${displayRows.length} visible rows`
|
||||
: `快照 ${response.snapshot_id || "--"} · 可见候选 ${displayRows.length} 条`,
|
||||
},
|
||||
]);
|
||||
} catch (fetchError) {
|
||||
@@ -1248,6 +1303,13 @@ function ScanTerminalScreen() {
|
||||
</button>
|
||||
</div>
|
||||
<div className="scan-list-status">
|
||||
{marketFocus ? (
|
||||
<span className="scan-status-chip focus">
|
||||
{isEn
|
||||
? `Focus: ${marketFocus.label}`
|
||||
: `当前主盘:${marketFocus.label}`}
|
||||
</span>
|
||||
) : null}
|
||||
{terminalData?.stale ? (
|
||||
<span className="scan-status-chip stale">
|
||||
{isEn ? "Delayed snapshot" : "延迟快照"}
|
||||
|
||||
Reference in New Issue
Block a user