Make freshness and timing drive city-card trust
Users need to know whether a city can be acted on before reading the full explanation. City cards now expose METAR or official-observation freshness, model update timing, market quote freshness, and AI state in a dedicated trust block, while the calendar view groups rows into action-oriented timing buckets with a single reason per city. Constraint: Keep the existing card and calendar data contracts; derive freshness and action reasons from fields already present in the frontend payload. Rejected: Add another long explanatory paragraph | it would repeat the same trust problem instead of making the first glance clearer. Confidence: high Scope-risk: moderate Reversibility: clean Tested: npm run build Not-tested: Live browser visual QA with stale METAR and delayed quote examples.
This commit is contained in:
@@ -5,6 +5,8 @@
|
||||
- 新增 Qingdao / 青岛城市,结算锚点接入 Wunderground 青岛胶东国际机场 `ZSQD` 历史页,并补齐别名、时区、预热、官方来源和前端地区归类
|
||||
- 城市决策卡顶部状态标签收口为 2-3 个高优先级信号,优先展示“实测突破 / 峰值窗口已过 / METAR 过旧 / AI 解读中 / 市场价格缺失 / 模型高度一致 / 需要等待下一报文”,让用户第一眼看到重点
|
||||
- 城市决策卡 AI 机场报文区明确拆分“快速判断已完成,AI 正在补充机场报文细节… / AI 机场报文解读已完成 / AI 解读未完整返回,当前使用规则证据”三种状态,减少 fallback 与流式返回造成的误解
|
||||
- 城市决策卡新增“数据新鲜度”区块,分别展示 METAR/官方观测、模型、市场价格和 AI 状态;过旧观测会标明“仅作背景参考”
|
||||
- 日历视图升级为行动视图,按“现在可看 / 1-3 小时内 / 今天稍后 / 已过峰值,等待确认”分组,并为每个城市显示一句核心原因
|
||||
- 城市决策卡新增 AI 机场报文解读缓存说明:页面内存缓存保留 loading / 流式片段 / 最终结果,`localStorage` 保存最终成功 payload,后端 AI 缓存不再因 `local_time` 变化失效
|
||||
- 城市决策卡兜底文案明确标记“快速证据模式”,避免在 DeepSeek 未完整返回时误写成“AI 机场报文解读正常”
|
||||
- 城市决策卡流式 AI 解读改为只请求 METAR/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
|
||||
|
||||
@@ -11377,21 +11377,64 @@
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 7px;
|
||||
margin-top: 8px;
|
||||
color: #8fa4bd;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
max-width: 620px;
|
||||
margin-top: 10px;
|
||||
color: #9fb2c7;
|
||||
font-size: 11px;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness strong) {
|
||||
grid-column: 1 / -1;
|
||||
color: #d8e7f8;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness span) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
border-radius: 8px;
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
padding: 4px 7px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness b) {
|
||||
color: #d6e2f0;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness em) {
|
||||
color: #9fb2c7;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness span.fresh em),
|
||||
.root :global(.scan-ai-city-freshness span.green em) {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness span.loading em),
|
||||
.root :global(.scan-ai-city-freshness span.blue em) {
|
||||
color: #9ecbff;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness span.stale em),
|
||||
.root :global(.scan-ai-city-freshness span.amber em) {
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness span.red em) {
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-hero-side) {
|
||||
min-width: 180px;
|
||||
display: grid;
|
||||
@@ -12104,6 +12147,14 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-reason) {
|
||||
margin: 10px 0 0;
|
||||
color: #d6e4f5;
|
||||
font-size: 13px;
|
||||
font-weight: 760;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@@ -13469,6 +13520,10 @@
|
||||
color: #122033;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-calendar-reason) {
|
||||
color: #243b5a;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.light .scan-calendar-countdown) {
|
||||
border-color: rgba(10, 160, 100, 0.18);
|
||||
background: rgba(220, 252, 239, 0.68);
|
||||
|
||||
@@ -115,7 +115,29 @@ function formatFreshnessAge(value: unknown, isEn: boolean) {
|
||||
return isEn ? `${hours}h ${remaining}m ago` : `${hours} 小时 ${remaining} 分钟前`;
|
||||
}
|
||||
|
||||
function buildObservationFreshnessLabel({
|
||||
function formatUpdateTime(value: unknown, locale: string) {
|
||||
const epochMs = parseEpochMs(value);
|
||||
if (epochMs == null) return "";
|
||||
const date = new Date(epochMs);
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
const time = date.toLocaleTimeString(locale === "en-US" ? "en-US" : "zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
if (locale === "en-US") {
|
||||
const day = sameDay
|
||||
? "today"
|
||||
: date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
return `${day} ${time} updated`;
|
||||
}
|
||||
const day = sameDay
|
||||
? "今日"
|
||||
: date.toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" });
|
||||
return `${day} ${time} 更新`;
|
||||
}
|
||||
|
||||
function buildObservationFreshnessValue({
|
||||
detail,
|
||||
displayTime,
|
||||
isEn,
|
||||
@@ -126,20 +148,50 @@ function buildObservationFreshnessLabel({
|
||||
isEn: boolean;
|
||||
isHkoObservation: boolean;
|
||||
}) {
|
||||
const source = isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR";
|
||||
const stale = Boolean(
|
||||
detail?.metar_status?.stale_for_today ||
|
||||
detail?.airport_current?.stale_for_today ||
|
||||
detail?.current?.observation_status === "stale",
|
||||
);
|
||||
if (stale) return isEn ? `${source} stale` : `${source} 过旧`;
|
||||
if (stale) {
|
||||
return isEn ? "stale; background only" : "已过旧,仅作背景参考";
|
||||
}
|
||||
const ageLabel = formatFreshnessAge(
|
||||
isHkoObservation ? detail?.current?.obs_age_min : detail?.airport_current?.obs_age_min ?? detail?.current?.obs_age_min,
|
||||
isEn,
|
||||
);
|
||||
if (ageLabel) return `${source} ${ageLabel}`;
|
||||
if (displayTime) return `${source} ${displayTime}`;
|
||||
return isEn ? `${source} time pending` : `${source} 时间待确认`;
|
||||
if (ageLabel) return ageLabel;
|
||||
if (displayTime) return displayTime;
|
||||
return isEn ? "time pending" : "时间待确认";
|
||||
}
|
||||
|
||||
function buildModelFreshnessValue(detail: CityDetail | null, locale: string, isEn: boolean) {
|
||||
return (
|
||||
formatUpdateTime(detail?.updated_at, locale) ||
|
||||
(isEn ? "latest run loaded" : "已加载最新模型")
|
||||
);
|
||||
}
|
||||
|
||||
function buildMarketFreshnessValue({
|
||||
isEn,
|
||||
marketScan,
|
||||
marketStatus,
|
||||
}: {
|
||||
isEn: boolean;
|
||||
marketScan: ReturnType<typeof useCityMarketScan>["marketScan"];
|
||||
marketStatus: ReturnType<typeof useCityMarketScan>["marketStatus"];
|
||||
}) {
|
||||
if (marketStatus === "loading") return isEn ? "syncing" : "同步中";
|
||||
if (!marketScan?.available) return isEn ? "missing" : "缺失";
|
||||
const quoteAgeMs = Number(
|
||||
marketScan.quote_age_ms ??
|
||||
marketScan.yes_token?.quote_age_ms ??
|
||||
marketScan.no_token?.quote_age_ms,
|
||||
);
|
||||
if (Number.isFinite(quoteAgeMs) && quoteAgeMs >= 0) {
|
||||
return formatFreshnessAge(quoteAgeMs / 60_000, isEn);
|
||||
}
|
||||
return isEn ? "synced" : "已同步";
|
||||
}
|
||||
|
||||
function uniqueStatusTags(tags: Array<StatusTag | null | undefined>) {
|
||||
@@ -362,12 +414,6 @@ function AiPinnedCityCard({
|
||||
!observedLowBreak &&
|
||||
!peakHasPassed &&
|
||||
(observedLowLag || paceTone === "neutral" || aiForecast.status === "loading");
|
||||
const observationFreshnessLabel = buildObservationFreshnessLabel({
|
||||
detail,
|
||||
displayTime: metarReportTimeDisplay,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
});
|
||||
const aiRuleEvidenceMode = Boolean(
|
||||
aiForecast.status === "failed" ||
|
||||
(aiForecast.status === "ready" && !aiCityForecast) ||
|
||||
@@ -421,24 +467,40 @@ function AiPinnedCityCard({
|
||||
: aiRuleEvidenceMode
|
||||
? "amber"
|
||||
: "muted";
|
||||
const marketFreshnessLabel =
|
||||
marketDecisionView.status === "ready"
|
||||
? isEn
|
||||
? "Market synced"
|
||||
: "市场已同步"
|
||||
: marketDecisionView.status === "loading"
|
||||
? isEn
|
||||
? "Market loading"
|
||||
: "市场同步中"
|
||||
: isEn
|
||||
? "No market price"
|
||||
: "暂无市场价";
|
||||
const marketStatusTone: StatusTagTone =
|
||||
marketDecisionView.status === "ready"
|
||||
? "green"
|
||||
: marketDecisionView.status === "loading"
|
||||
? "blue"
|
||||
: "muted";
|
||||
const dataFreshnessRows = [
|
||||
{
|
||||
label: isHkoObservation ? (isEn ? "HKO" : "天文台") : "METAR",
|
||||
value: buildObservationFreshnessValue({
|
||||
detail,
|
||||
displayTime: metarReportTimeDisplay,
|
||||
isEn,
|
||||
isHkoObservation,
|
||||
}),
|
||||
tone: observationStale ? "stale" : "fresh",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Models" : "模型",
|
||||
value: buildModelFreshnessValue(detail, locale, isEn),
|
||||
tone: "fresh",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Market" : "市场价格",
|
||||
value: buildMarketFreshnessValue({ isEn, marketScan, marketStatus }),
|
||||
tone:
|
||||
marketDecisionView.status === "ready"
|
||||
? "fresh"
|
||||
: marketDecisionView.status === "loading"
|
||||
? "loading"
|
||||
: "stale",
|
||||
},
|
||||
];
|
||||
const freshnessSeparator = isEn ? ": " : ":";
|
||||
const statusTags = uniqueStatusTags([
|
||||
observedHighBreak
|
||||
? {
|
||||
@@ -544,10 +606,18 @@ function AiPinnedCityCard({
|
||||
<span>{isEn ? "Model" : "模型"} {modelRange}</span>
|
||||
<span>{isEn ? "Peak" : "峰值"} {peakWindow}</span>
|
||||
</div>
|
||||
<div className="scan-ai-city-freshness">
|
||||
<span>{observationFreshnessLabel}</span>
|
||||
<span>{marketFreshnessLabel}</span>
|
||||
<span>{aiStatusLabel}</span>
|
||||
<div className="scan-ai-city-freshness" aria-label={isEn ? "Data freshness" : "数据新鲜度"}>
|
||||
<strong>{isEn ? "Data freshness" : "数据新鲜度"}</strong>
|
||||
{dataFreshnessRows.map((freshness) => (
|
||||
<span key={freshness.label} className={freshness.tone}>
|
||||
<b>{freshness.label}{freshnessSeparator}</b>
|
||||
<em>{freshness.value}</em>
|
||||
</span>
|
||||
))}
|
||||
<span className={aiStatusTone}>
|
||||
<b>AI{freshnessSeparator}</b>
|
||||
<em>{aiStatusLabel}</em>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-ai-city-hero-side">
|
||||
|
||||
@@ -3,7 +3,10 @@ import { getWindowPhaseMeta } from "@/components/dashboard/OpportunityTable";
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import { formatTemperatureValue } from "@/lib/dashboard-utils";
|
||||
import { formatShortDate, getPeakCountdownMeta } from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import {
|
||||
formatShortDate,
|
||||
getPeakCountdownMeta,
|
||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
|
||||
const CALENDAR_UPCOMING_HORIZON_MINUTES = 12 * 60;
|
||||
const CALENDAR_POST_PEAK_GRACE_MINUTES = 3 * 60;
|
||||
@@ -16,6 +19,13 @@ type CalendarMeta = ReturnType<typeof getPeakCountdownMeta> & {
|
||||
endAtMs?: number | null;
|
||||
};
|
||||
|
||||
type CalendarActionGroup = {
|
||||
key: "now" | "next" | "later" | "past";
|
||||
label: string;
|
||||
subtitle: string;
|
||||
sort: number;
|
||||
};
|
||||
|
||||
function normalizeCalendarCityKey(value?: string | null) {
|
||||
return String(value || "")
|
||||
.trim()
|
||||
@@ -154,6 +164,133 @@ function isCalendarActionable(row: ScanOpportunityRow, meta: CalendarMeta, nowMs
|
||||
return startDelta >= 0 && startDelta <= CALENDAR_UPCOMING_HORIZON_MINUTES;
|
||||
}
|
||||
|
||||
function getCalendarActionGroup(
|
||||
row: ScanOpportunityRow,
|
||||
meta: CalendarMeta,
|
||||
nowMs: number,
|
||||
locale: string,
|
||||
): CalendarActionGroup {
|
||||
const isEn = locale === "en-US";
|
||||
const phase = String(row.window_phase || "").toLowerCase();
|
||||
const startDelta =
|
||||
meta.startAtMs != null ? (meta.startAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_start);
|
||||
const endDelta =
|
||||
meta.endAtMs != null ? (meta.endAtMs - nowMs) / MINUTE_MS : finiteCalendarNumber(row.minutes_until_peak_end);
|
||||
const isPast =
|
||||
phase === "post_peak" ||
|
||||
(endDelta != null && endDelta < 0) ||
|
||||
meta.key === "past";
|
||||
if (isPast) {
|
||||
return {
|
||||
key: "past",
|
||||
label: isEn ? "Past peak · confirm" : "已过峰值,等待确认",
|
||||
subtitle: isEn ? "Check whether a new high printed; avoid chasing if it did not." : "确认是否刷新高点;若无新高,避免追高。",
|
||||
sort: 3,
|
||||
};
|
||||
}
|
||||
const isNow =
|
||||
phase === "active_peak" ||
|
||||
(startDelta != null && startDelta <= 60) ||
|
||||
meta.key === "active";
|
||||
if (isNow) {
|
||||
return {
|
||||
key: "now",
|
||||
label: isEn ? "Watch now" : "现在可看",
|
||||
subtitle: isEn ? "Peak window is live or close enough to require immediate checks." : "峰值窗口正在进行或即将开始,需要马上核对。",
|
||||
sort: 0,
|
||||
};
|
||||
}
|
||||
if (startDelta != null && startDelta <= 180) {
|
||||
return {
|
||||
key: "next",
|
||||
label: isEn ? "In 1-3 hours" : "1-3 小时内",
|
||||
subtitle: isEn ? "Prepare the setup and wait for the next observation." : "提前准备,只等下一轮观测确认。",
|
||||
sort: 1,
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: "later",
|
||||
label: isEn ? "Later today" : "今天稍后",
|
||||
subtitle: isEn ? "Keep on the board, but do not spend attention yet." : "先放在行动面板,不需要立刻盯盘。",
|
||||
sort: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function getCalendarModelUpper(row: ScanOpportunityRow) {
|
||||
const values = [
|
||||
finiteCalendarNumber(row.cluster_core_high),
|
||||
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
||||
].filter((value): value is number => value != null);
|
||||
return values.length ? Math.max(...values) : null;
|
||||
}
|
||||
|
||||
function getCalendarModelLower(row: ScanOpportunityRow) {
|
||||
const values = [
|
||||
finiteCalendarNumber(row.cluster_core_low),
|
||||
...Object.values(row.model_cluster_sources || {}).map((value) => finiteCalendarNumber(value)),
|
||||
].filter((value): value is number => value != null);
|
||||
return values.length ? Math.min(...values) : null;
|
||||
}
|
||||
|
||||
function firstSentence(value?: string | null) {
|
||||
const text = String(value || "").trim();
|
||||
if (!text) return "";
|
||||
const match = text.match(/^.*?[。.!??](?:\s|$)/);
|
||||
return (match?.[0] || text).trim();
|
||||
}
|
||||
|
||||
function buildCalendarCoreReason(
|
||||
row: ScanOpportunityRow,
|
||||
group: CalendarActionGroup,
|
||||
locale: string,
|
||||
) {
|
||||
const isEn = locale === "en-US";
|
||||
const tempSymbol = row.temp_symbol || "°C";
|
||||
const currentTemp = finiteCalendarNumber(row.current_temp ?? row.metar_context?.last_temp);
|
||||
const modelUpper = getCalendarModelUpper(row);
|
||||
const modelLower = getCalendarModelLower(row);
|
||||
const modelSpread =
|
||||
modelUpper != null && modelLower != null ? modelUpper - modelLower : null;
|
||||
if (currentTemp != null && modelUpper != null && currentTemp > modelUpper + 0.2) {
|
||||
return isEn
|
||||
? `Observed ${formatTemperatureValue(currentTemp, tempSymbol)} is above the model upper bound; watch whether the high keeps revising up.`
|
||||
: `实测已高于模型上沿,需关注是否继续上修`;
|
||||
}
|
||||
if (group.key === "past") {
|
||||
return isEn
|
||||
? "Peak window has passed; avoid chasing if no new high prints."
|
||||
: "峰值窗口已过,若无新高应避免追高";
|
||||
}
|
||||
if (row.metar_status?.stale_for_today || row.metar_context?.stale_for_today) {
|
||||
return isEn
|
||||
? "METAR is stale, so use it as background only."
|
||||
: "METAR 已过旧,仅作背景参考";
|
||||
}
|
||||
if (currentTemp != null && modelLower != null && currentTemp < modelLower - 0.5) {
|
||||
return isEn
|
||||
? "Observed temperature is still below the model core; wait for the next report."
|
||||
: "实测仍低于模型核心区,等待下一报文确认";
|
||||
}
|
||||
if (Number(row.cluster_model_count || 0) >= 4 && modelSpread != null && modelSpread <= 2) {
|
||||
return isEn
|
||||
? "Models are tightly aligned; the next observation should decide direction."
|
||||
: "模型高度一致,等待下一报文确认方向";
|
||||
}
|
||||
const aiReason = firstSentence(
|
||||
isEn
|
||||
? row.ai_watchlist_reason_en || row.ai_forecast_match_reason_en || row.ai_reason_en || row.ai_city_thesis_en
|
||||
: row.ai_watchlist_reason_zh || row.ai_forecast_match_reason_zh || row.ai_reason_zh || row.ai_city_thesis_zh,
|
||||
);
|
||||
if (aiReason) return aiReason;
|
||||
return group.key === "now"
|
||||
? isEn
|
||||
? "Peak timing is close; open the card and verify live evidence."
|
||||
: "峰值时间接近,打开卡片核对实况证据"
|
||||
: isEn
|
||||
? "Keep it on the action board until the next observation."
|
||||
: "先放入行动面板,等待下一轮观测";
|
||||
}
|
||||
|
||||
export function CalendarView({
|
||||
rows,
|
||||
locale,
|
||||
@@ -181,31 +318,38 @@ export function CalendarView({
|
||||
}, []);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const order = ["active", "next", "today", "later", "past"];
|
||||
const byPhase = new Map<
|
||||
string,
|
||||
{
|
||||
label: string;
|
||||
subtitle: string;
|
||||
sort: number;
|
||||
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta }>;
|
||||
items: Array<{ row: ScanOpportunityRow; meta: CalendarMeta; reason: string }>;
|
||||
}
|
||||
>();
|
||||
dedupeCalendarRows(rows).forEach((row) => {
|
||||
const meta = buildCalendarMeta(row, locale, snapshotMs, nowMs);
|
||||
if (!isCalendarActionable(row, meta, nowMs)) return;
|
||||
const current = byPhase.get(meta.key) || {
|
||||
label: meta.groupLabel,
|
||||
sort: order.indexOf(meta.key) >= 0 ? order.indexOf(meta.key) : order.length,
|
||||
const actionGroup = getCalendarActionGroup(row, meta, nowMs, locale);
|
||||
const current = byPhase.get(actionGroup.key) || {
|
||||
label: actionGroup.label,
|
||||
subtitle: actionGroup.subtitle,
|
||||
sort: actionGroup.sort,
|
||||
items: [],
|
||||
};
|
||||
current.items.push({ row, meta });
|
||||
byPhase.set(meta.key, current);
|
||||
current.items.push({
|
||||
row,
|
||||
meta,
|
||||
reason: buildCalendarCoreReason(row, actionGroup, locale),
|
||||
});
|
||||
byPhase.set(actionGroup.key, current);
|
||||
});
|
||||
return Array.from(byPhase.entries())
|
||||
.sort(([, left], [, right]) => left.sort - right.sort)
|
||||
.map(([key, group]) => ({
|
||||
key,
|
||||
label: group.label,
|
||||
subtitle: group.subtitle,
|
||||
items: group.items.sort((left, right) => {
|
||||
if (left.meta.sort !== right.meta.sort) return left.meta.sort - right.meta.sort;
|
||||
return Number(right.row.edge_percent || 0) - Number(left.row.edge_percent || 0);
|
||||
@@ -233,9 +377,7 @@ export function CalendarView({
|
||||
<div>
|
||||
<div className="scan-calendar-date">{group.label}</div>
|
||||
<div className="scan-calendar-subtitle">
|
||||
{locale === "en-US"
|
||||
? "Ordered by DEB peak-window countdown"
|
||||
: "按 DEB 峰值窗口倒计时排序"}
|
||||
{group.subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-calendar-count">
|
||||
@@ -243,7 +385,7 @@ export function CalendarView({
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-calendar-grid">
|
||||
{group.items.map(({ row, meta }) => (
|
||||
{group.items.map(({ row, meta, reason }) => (
|
||||
<button
|
||||
key={row.id}
|
||||
type="button"
|
||||
@@ -275,6 +417,7 @@ export function CalendarView({
|
||||
{meta.cityWindowLabel || meta.detail}
|
||||
</small>
|
||||
</div>
|
||||
<p className="scan-calendar-reason">{reason}</p>
|
||||
<div className="scan-calendar-action">
|
||||
<span>{locale === "en-US" ? "DEB high" : "DEB 预测高点"}</span>
|
||||
<b>
|
||||
|
||||
Reference in New Issue
Block a user