feat: tune scan dashboard and telegram monitor

This commit is contained in:
2569718930@qq.com
2026-05-15 14:36:43 +08:00
parent e7fc3c97cb
commit 747c8aa401
11 changed files with 406 additions and 22 deletions
@@ -43,17 +43,6 @@
background: rgba(77, 163, 255, 0.04);
}
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--color-accent-primary, #4DA3FF);
font-weight: 700;
font-size: 12px;
letter-spacing: 0.03em;
flex-shrink: 0;
}
.preview {
flex: 1;
min-width: 0;
@@ -142,9 +131,6 @@
padding: 4px 8px;
}
.badge {
font-size: 11px;
}
}
/* Mobile < 640px */
@@ -95,10 +95,6 @@ export function MarketOverviewBanner({
onClick={() => setCollapsed((c) => !c)}
aria-label={isEn ? "Toggle market overview" : "切换市场概览"}
>
<span className={styles.badge}>
<Sparkles size={13} />
{isEn ? "AI Overview" : "AI 概览"}
</span>
<span className={styles.preview}>
{collapsed && overviewText ? overviewText.slice(0, isEn ? 120 : 60) + "…" : ""}
</span>
@@ -0,0 +1,33 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const hookPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"use-ai-city-forecast.ts",
);
const source = fs.readFileSync(hookPath, "utf8");
assert(
source.includes("AI_CITY_READ_SOFT_TIMEOUT_MS") &&
source.includes("softTimeoutId"),
"AI city read hook must use a soft timeout so airport-read loading does not linger",
);
assert(
source.includes("ai_soft_timeout_fallback") &&
source.includes("buildAiCityErrorForecastState"),
"AI city read soft timeout must switch to the fast fallback state while allowing later merge",
);
assert(
!source.includes("controller.abort()"),
"AI city read soft timeout should not abort the stream; late AI results should still merge",
);
}
@@ -0,0 +1,24 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const source = fs.readFileSync(
path.join(process.cwd(), "hooks", "useDashboardStore.tsx"),
"utf8",
);
assert(
source.includes("function pickRicherHourly"),
"city detail merge should have an explicit hourly-series preservation helper",
);
assert(
/hourly:\s*pickRicherHourly\(\s*current\.hourly,\s*incoming\.hourly\s*\)/.test(
source,
),
"deep-analysis refresh must not let sparse incoming hourly data overwrite chart-capable cached hourly data",
);
}
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const projectRoot = process.cwd();
const bannerPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"MarketOverviewBanner.tsx",
);
const source = fs.readFileSync(bannerPath, "utf8");
assert(
!source.includes("AI Overview") &&
!source.includes("AI 概览") &&
!source.includes("styles.badge"),
"market overview banner should not render the AI overview badge",
);
}
@@ -15,6 +15,8 @@ import {
readReadyCachedAiForecastState,
} from "./ai-city-forecast-stream-state";
const AI_CITY_READ_SOFT_TIMEOUT_MS = 4_500;
export function useAiCityForecast({
detail,
detailCityName,
@@ -50,6 +52,7 @@ export function useAiCityForecast({
return;
}
let cancelled = false;
let resolved = false;
const cacheKey = buildAiCityForecastCacheKey(aiForecastKey);
const requestKey = buildAiCityForecastRequestKey(cacheKey, aiRefreshToken);
@@ -69,6 +72,17 @@ export function useAiCityForecast({
report,
});
setAiForecast(loadingState);
const softTimeoutId = window.setTimeout(() => {
if (cancelled || resolved) return;
const fallbackState = buildAiCityErrorForecastState({
cacheKey,
detail,
error: "ai_soft_timeout_fallback",
isEn,
report,
});
setAiForecast(fallbackState);
}, AI_CITY_READ_SOFT_TIMEOUT_MS);
void scanTerminalClient.streamAiCityRead({
city: detailCityName,
forceRefresh: aiRefreshToken > 0,
@@ -88,6 +102,8 @@ export function useAiCityForecast({
})
.then((payload) => {
if (!payload) return;
resolved = true;
window.clearTimeout(softTimeoutId);
const readyState = buildAiCityReadyForecastState({
cacheKey,
detail,
@@ -100,6 +116,8 @@ export function useAiCityForecast({
}
})
.catch((error) => {
resolved = true;
window.clearTimeout(softTimeoutId);
const errorState = buildAiCityErrorForecastState({
cacheKey,
detail,
@@ -113,6 +131,7 @@ export function useAiCityForecast({
});
return () => {
cancelled = true;
window.clearTimeout(softTimeoutId);
};
}, [
aiForecastKey,
+18
View File
@@ -492,6 +492,23 @@ function pickRicherForecast(
};
}
function countHourlyPoints(value: CityDetail["hourly"] | undefined) {
const times = Array.isArray(value?.times) ? value?.times || [] : [];
const temps = Array.isArray(value?.temps) ? value?.temps || [] : [];
return Math.min(times.length, temps.length);
}
function pickRicherHourly(
currentValue: CityDetail["hourly"] | undefined,
incomingValue: CityDetail["hourly"] | undefined,
) {
const incomingCount = countHourlyPoints(incomingValue);
const currentCount = countHourlyPoints(currentValue);
if (incomingCount <= 0) return currentValue;
if (currentCount <= 0) return incomingValue;
return incomingCount >= currentCount ? incomingValue : currentValue;
}
function pickPreferredNearbyStations(
currentValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
incomingValue: CityDetail["official_nearby"] | CityDetail["mgm_nearby"],
@@ -536,6 +553,7 @@ function mergeCityDetail(
incoming.multi_model_daily,
),
forecast: pickRicherForecast(current.forecast, incoming.forecast),
hourly: pickRicherHourly(current.hourly, incoming.hourly),
official_nearby: pickPreferredNearbyStations(
current.official_nearby,
incoming.official_nearby,