Improve scan decision card hydration

This commit is contained in:
2569718930@qq.com
2026-05-17 18:57:02 +08:00
parent 5c2977fe71
commit 8ade6dd7d2
6 changed files with 122 additions and 10 deletions
@@ -38,6 +38,14 @@ function toFiniteDecisionNumber(value: unknown) {
return Number.isFinite(numeric) ? numeric : null;
}
function getRowModelEntries(row: ScanOpportunityRow | null) {
const sources = row?.model_cluster_sources;
if (!sources || typeof sources !== "object") return [];
return Object.entries(sources)
.map(([name, value]) => [name, Number(value)] as const)
.filter(([, value]) => Number.isFinite(value));
}
function parseEpochMs(value: unknown) {
if (value == null || value === "") return null;
const numeric = Number(value);
@@ -228,11 +236,12 @@ export function AiPinnedCityCard({
item.cityName;
const tempSymbol = detail?.temp_symbol || row?.temp_symbol || "°C";
const modelView = detail ? getModelView(detail, detail.local_date) : null;
const modelEntries = modelView
const detailModelEntries = modelView
? Object.entries(modelView.models || {})
.map(([name, value]) => [name, Number(value)] as const)
.filter(([, value]) => Number.isFinite(value))
: [];
const modelEntries = detailModelEntries.length ? detailModelEntries : getRowModelEntries(row);
const modelValues = modelEntries.map(([, value]) => value);
const modelMin = modelValues.length ? Math.min(...modelValues) : null;
const modelMax = modelValues.length ? Math.max(...modelValues) : null;
@@ -325,10 +334,24 @@ export function AiPinnedCityCard({
: isEn
? `Model support is unavailable, so this city must rely on DEB path and ${observationSourceEn}.`
: `暂无可用多模型支撑,需要主要参考 DEB 路径和${observationSourceZh}`;
const aiPredictedMax = toFiniteDecisionNumber(aiCityForecast?.predicted_max);
const aiRangeLow = toFiniteDecisionNumber(aiCityForecast?.range_low);
const aiRangeHigh = toFiniteDecisionNumber(aiCityForecast?.range_high);
const aiConfidence = String(aiCityForecast?.confidence || "").trim() || null;
const rowAiPredictedMax =
toFiniteDecisionNumber(row?.ai_predicted_max) ??
toFiniteDecisionNumber(row?.ai_predicted_high) ??
toFiniteDecisionNumber(row?.cluster_median) ??
debNumber;
const aiPredictedMax =
toFiniteDecisionNumber(aiCityForecast?.predicted_max) ?? rowAiPredictedMax;
const aiRangeLow =
toFiniteDecisionNumber(aiCityForecast?.range_low) ??
toFiniteDecisionNumber(row?.ai_predicted_low) ??
modelMin;
const aiRangeHigh =
toFiniteDecisionNumber(aiCityForecast?.range_high) ??
toFiniteDecisionNumber(row?.ai_predicted_high) ??
modelMax;
const aiConfidence =
String(aiCityForecast?.confidence || row?.ai_forecast_confidence || "").trim() ||
(rowAiPredictedMax != null ? (isEn ? "fast" : "快速") : null);
const decisionExpectedHighNumber = resolveExpectedHighCandidate({
aiPredictedMax,
currentTemp: currentTempNumber,
@@ -123,6 +123,20 @@ export function runTests() {
assert.equal(errorState.payload?.status, "timeout_fallback");
assert.match(errorState.payload?.reason_zh || "", /DeepSeek|DEB|METAR/);
const modelFallbackState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:models`,
detail: cityDetail({
deb: { prediction: 29 },
multi_model: { ECMWF: 30, GFS: 32, ICON: 31 },
} as unknown as Partial<CityDetail>),
error: new Error("timeout"),
isEn: false,
report: "",
});
assert.equal(modelFallbackState.payload?.city_forecast?.predicted_max, 31);
assert.equal(modelFallbackState.payload?.city_forecast?.range_low, 30);
assert.equal(modelFallbackState.payload?.city_forecast?.range_high, 32);
const hkoState = buildAiCityErrorForecastState({
cacheKey: `${cacheKey}:hko`,
detail: cityDetail({
@@ -14,7 +14,17 @@ export function runTests() {
"scan-terminal",
"use-ai-pinned-city-workspace.ts",
);
const storePath = path.join(projectRoot, "hooks", "useDashboardStore.tsx");
const cardPath = path.join(
projectRoot,
"components",
"dashboard",
"scan-terminal",
"AiPinnedCityCard.tsx",
);
const source = fs.readFileSync(workspacePath, "utf8");
const storeSource = fs.readFileSync(storePath, "utf8");
const cardSource = fs.readFileSync(cardPath, "utf8");
assert(
!source.includes("waitForDeepAnalysisQueue"),
@@ -24,4 +34,17 @@ export function runTests() {
/store\.ensureCityDetail\(\s*nextCity,\s*false,\s*"full",?\s*\)/.test(source),
"automatic deep analysis hydration should use cache-friendly full detail requests",
);
assert(
storeSource.includes("row.model_cluster_sources") &&
storeSource.includes("deb_prediction") &&
storeSource.includes("multi_model: multiModel"),
"decision-card preload must hydrate model cluster and DEB data from the scan row",
);
assert(
cardSource.includes("getRowModelEntries") &&
cardSource.includes("row?.ai_predicted_max") &&
cardSource.includes("row?.cluster_median") &&
cardSource.includes("detailModelEntries.length ? detailModelEntries : getRowModelEntries(row)"),
"decision card should render model support and AI predicted max from the row before full detail/AI stream arrives",
);
}