feat: add scan terminal dashboard, fallback city mapping, and Nginx deployment configuration

This commit is contained in:
2569718930@qq.com
2026-05-26 05:20:36 +08:00
parent 1ac41dde33
commit 8ca9ce4223
9 changed files with 265 additions and 28 deletions
@@ -0,0 +1,58 @@
import { cityListItemsToScanRows } from "@/components/dashboard/scan-terminal/city-fallback-rows";
import type { CityListItem } from "@/lib/dashboard-types";
function assert(condition: unknown, message: string) {
if (!condition) throw new Error(message);
}
export function runTests() {
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
const dashboardSource = fs.readFileSync(
path.join(process.cwd(), "components", "dashboard", "ScanTerminalDashboard.tsx"),
"utf8",
);
const cities: CityListItem[] = [
{
airport: "Taipei Songshan",
display_name: "Taipei",
icao: "RCSS",
lat: 25.0697,
lon: 121.5525,
name: "taipei",
risk_level: "medium",
temp_unit: "celsius",
utc_offset_seconds: 28800,
},
{
airport: "LaGuardia",
display_name: "New York",
icao: "KLGA",
lat: 40.7769,
lon: -73.874,
name: "new york",
risk_level: "low",
temp_unit: "fahrenheit",
utc_offset_seconds: -14400,
},
];
const rows = cityListItemsToScanRows(cities);
assert(rows.length === 2, "fallback rows should preserve every city");
assert(rows[0].id === "city-fallback:taipei", "fallback row id should be stable");
assert(rows[0].city === "taipei", "fallback row should keep canonical city key");
assert(rows[0].city_display_name === "Taipei", "fallback row should keep display name");
assert(rows[0].airport === "Taipei Songshan", "fallback row should expose airport for selector display");
assert(rows[0].trading_region === "east_asia", "fallback row should derive region from timezone");
assert(rows[0].temp_symbol === "°C", "celsius cities should use °C");
assert(rows[1].trading_region === "north_america", "known cities should keep their configured product region");
assert(rows[1].temp_symbol === "°F", "fahrenheit cities should use °F");
assert(
dashboardSource.includes("cityListItemsToScanRows") &&
dashboardSource.includes("/api/cities") &&
dashboardSource.includes("cityFallbackRows"),
"terminal dashboard should use /api/cities fallback rows when scan terminal rows are not ready",
);
}
@@ -0,0 +1,63 @@
import type { CityListItem, ScanOpportunityRow } from "@/lib/dashboard-types";
import {
REGIONS,
getCityRegion,
type RegionKey,
} from "@/components/dashboard/scan-terminal/continent-grouping";
function normalizeCityKey(value: string) {
return String(value || "").trim().toLowerCase();
}
function tempSymbolForCity(city: CityListItem) {
return city.temp_unit === "fahrenheit" ? "°F" : "°C";
}
function regionFromTzOffset(tzOffsetSeconds: number): (typeof REGIONS)[number] {
const hours = tzOffsetSeconds / 3600;
if (hours >= 8) return REGIONS[0];
if (hours >= 7) return REGIONS[1];
if (hours >= 4.5) return REGIONS[2];
if (hours >= 2) return REGIONS[3];
if (hours >= -2) return REGIONS[4];
if (hours >= -4) return REGIONS[5];
return REGIONS[6];
}
function resolveRegion(cityKey: string, tzOffsetSeconds: number) {
const configuredRegion = getCityRegion({ city: cityKey, id: `region:${cityKey}` } as ScanOpportunityRow);
if (configuredRegion) {
return REGIONS.find((region) => region.key === configuredRegion) || regionFromTzOffset(tzOffsetSeconds);
}
return regionFromTzOffset(tzOffsetSeconds);
}
export function cityListItemsToScanRows(cities: CityListItem[]): ScanOpportunityRow[] {
return cities
.filter((city) => normalizeCityKey(city.name))
.map((city) => {
const cityKey = normalizeCityKey(city.name);
const region = resolveRegion(cityKey, city.utc_offset_seconds ?? 0);
return {
active: true,
airport: city.airport || city.icao || null,
city: cityKey,
city_display_name: city.display_name || city.name,
closed: false,
current_temp: null,
deb_prediction: null,
display_name: city.display_name || city.name,
id: `city-fallback:${cityKey}`,
is_primary_signal: true,
local_time: null,
risk_level: city.risk_level || null,
temp_symbol: tempSymbolForCity(city),
tradable: false,
trading_region: region.key as RegionKey,
trading_region_label: region.labelEn,
trading_region_label_zh: region.labelZh,
trading_region_sort: region.sort,
tz_offset_seconds: city.utc_offset_seconds ?? 0,
};
});
}