feat: tune scan dashboard and telegram monitor
This commit is contained in:
@@ -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",
|
||||
);
|
||||
}
|
||||
+24
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user