feat: implement weather dashboard components, state management, and API routes for terminal scanning and assistant chat

This commit is contained in:
2569718930@qq.com
2026-04-25 06:42:24 +08:00
parent 3083d6b8fe
commit d5fb40a544
13 changed files with 2109 additions and 357 deletions
@@ -10014,6 +10014,25 @@
background: rgba(59, 130, 246, 0.1);
}
.root :global(.scan-opportunity-strength.approve) {
color: #71f7c8;
border-color: rgba(45, 212, 191, 0.34);
background: rgba(20, 184, 166, 0.12);
}
.root :global(.scan-opportunity-strength.downgrade),
.root :global(.scan-opportunity-strength.watchlist) {
color: #ffd166;
border-color: rgba(245, 158, 11, 0.32);
background: rgba(245, 158, 11, 0.12);
}
.root :global(.scan-opportunity-strength.veto) {
color: #ff8585;
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
}
.root :global(.scan-opportunity-expand) {
display: inline-flex;
align-items: center;
@@ -10129,6 +10148,367 @@
font-weight: 900;
}
.root :global(.scan-opportunity-group) {
overflow: visible;
border-radius: 14px;
}
.root :global(.scan-opportunity-items) {
gap: 10px;
padding: 10px 12px 14px;
}
.root :global(.scan-opportunity-item) {
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
min-height: 0;
padding: 0;
overflow: visible;
border-color: rgba(89, 128, 178, 0.16);
border-radius: 14px;
background: rgba(6, 18, 32, 0.72);
}
.root :global(.scan-opportunity-item.expanded) {
border-color: rgba(23, 217, 139, 0.42);
background:
linear-gradient(180deg, rgba(8, 29, 43, 0.94), rgba(5, 17, 31, 0.96)),
radial-gradient(circle at 12% 0%, rgba(20, 184, 166, 0.12), transparent 36%);
box-shadow:
inset 0 1px 0 rgba(125, 251, 231, 0.09),
0 18px 42px rgba(0, 0, 0, 0.2);
}
.root :global(.scan-opportunity-summary-row) {
display: grid;
grid-template-columns:
4px minmax(128px, 180px) minmax(360px, 1fr)
max-content max-content;
gap: 10px;
align-items: stretch;
padding: 12px 14px;
}
.root :global(.scan-opportunity-branch) {
align-self: stretch;
width: 4px;
border-radius: 999px;
background: linear-gradient(180deg, rgba(23, 217, 139, 0.92), rgba(34, 211, 238, 0.28));
}
.root :global(.scan-opportunity-branch::before),
.root :global(.scan-opportunity-branch i) {
display: none;
}
.root :global(.scan-opportunity-metrics) {
display: grid;
grid-template-columns: repeat(4, minmax(96px, 1fr));
gap: 8px;
min-width: 0;
}
.root :global(.scan-opportunity-stat) {
display: grid;
align-items: start;
gap: 3px;
min-width: 0;
max-width: none;
width: 100%;
padding: 8px 10px;
border-color: rgba(90, 123, 166, 0.14);
background: rgba(8, 25, 43, 0.62);
}
.root :global(.scan-opportunity-stat.edge) {
justify-self: stretch;
justify-content: stretch;
justify-items: start;
min-width: 0;
}
.root :global(.scan-opportunity-trade) {
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
}
.root :global(.scan-opportunity-action) {
white-space: normal;
}
.root :global(.scan-opportunity-ai) {
display: grid;
grid-column: auto;
gap: 4px;
margin: 0 14px;
padding: 11px 13px;
border-color: rgba(64, 104, 168, 0.16);
background: rgba(5, 15, 27, 0.64);
}
.root :global(.scan-opportunity-ai small) {
overflow: visible;
color: #a9bdd8;
white-space: normal;
text-overflow: clip;
}
.root :global(.scan-v4-analysis) {
grid-column: auto;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 14px 14px;
padding: 14px;
border-color: rgba(56, 189, 248, 0.18);
border-radius: 14px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.11), transparent 28%);
}
.root :global(.scan-v4-analysis section) {
padding: 13px;
border-color: rgba(92, 132, 188, 0.14);
background: rgba(10, 25, 43, 0.7);
}
.root :global(.scan-v4-analysis p),
.root :global(.scan-v4-analysis ul) {
color: #b8c9e3;
}
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: 1 / -1;
}
.root :global(.scan-v4-analysis .scan-v4-decision) {
grid-column: 1 / -1;
border-color: rgba(45, 212, 191, 0.24);
background:
linear-gradient(180deg, rgba(6, 31, 43, 0.86), rgba(8, 23, 38, 0.86)),
radial-gradient(circle at 0 0, rgba(20, 184, 166, 0.12), transparent 32%);
}
.root :global(.scan-v4-analysis .scan-v4-decision.downgrade),
.root :global(.scan-v4-analysis .scan-v4-decision.watchlist) {
border-color: rgba(245, 158, 11, 0.24);
}
.root :global(.scan-v4-analysis .scan-v4-decision.veto) {
border-color: rgba(248, 113, 113, 0.28);
}
.root :global(.scan-v4-decision p b) {
color: #ecfeff;
font-weight: 950;
}
.root :global(.scan-v4-metar-summary) {
margin-top: 9px;
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-evidence > div),
.root :global(.scan-v4-model-sources) {
gap: 8px;
}
.root :global(.scan-v4-analysis) {
display: flex;
flex-direction: column;
gap: 13px;
margin: 0 14px 14px;
padding: 15px 16px 16px;
border: 1px solid rgba(56, 189, 248, 0.16);
border-radius: 12px;
background:
linear-gradient(180deg, rgba(7, 20, 36, 0.96), rgba(5, 15, 28, 0.98)),
radial-gradient(circle at 4% 0%, rgba(45, 212, 191, 0.08), transparent 30%);
}
.root :global(.scan-v4-analysis section),
.root :global(.scan-v4-analysis section:first-child),
.root :global(.scan-v4-analysis .scan-v4-evidence) {
grid-column: auto;
min-width: 0;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
}
.root :global(.scan-v4-heading) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.14);
}
.root :global(.scan-v4-heading > div) {
display: grid;
gap: 7px;
min-width: 0;
}
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
color: #72f3d1;
font-size: 12px;
font-weight: 950;
letter-spacing: 0.02em;
}
.root :global(.scan-v4-heading p) {
margin: 0;
color: #d2e1f4;
font-size: 14px;
font-weight: 800;
line-height: 1.55;
}
.root :global(.scan-v4-decision-pill) {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
min-height: 30px;
padding: 0 12px;
border: 1px solid rgba(45, 212, 191, 0.3);
border-radius: 999px;
background: rgba(20, 184, 166, 0.12);
color: #7dfbe7;
font-size: 12px;
font-weight: 950;
white-space: nowrap;
}
.root :global(.scan-v4-decision-pill.downgrade),
.root :global(.scan-v4-decision-pill.watchlist) {
border-color: rgba(245, 158, 11, 0.34);
background: rgba(245, 158, 11, 0.12);
color: #ffd166;
}
.root :global(.scan-v4-decision-pill.veto) {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(248, 113, 113, 0.12);
color: #ff8585;
}
.root :global(.scan-v4-current) {
display: grid;
gap: 6px;
padding-bottom: 12px;
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(.scan-v4-current span) {
color: #c8d8ec;
font-size: 13px;
font-weight: 850;
line-height: 1.5;
}
.root :global(.scan-v4-current small) {
color: #8fb3d8;
font-size: 12px;
font-weight: 800;
line-height: 1.45;
}
.root :global(.scan-v4-brief-grid) {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(260px, 0.85fr);
gap: 26px;
}
.root :global(.scan-v4-brief-grid section:first-child) {
grid-column: auto;
}
.root :global(.scan-v4-brief-grid ul) {
display: grid;
gap: 8px;
margin: 8px 0 0;
padding: 0;
list-style: none;
}
.root :global(.scan-v4-brief-grid li) {
position: relative;
padding-left: 16px;
color: #aebfd8;
font-size: 12px;
font-weight: 800;
line-height: 1.48;
}
.root :global(.scan-v4-brief-grid li::before) {
content: "";
position: absolute;
top: 0.65em;
left: 0;
width: 5px;
height: 5px;
border-radius: 999px;
background: #2dd4bf;
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
.root :global(.scan-v4-brief-grid section:nth-child(2) li::before) {
background: #f59e0b;
box-shadow: 0 0 10px rgba(245, 158, 11, 0.35);
}
.root :global(details.scan-v4-evidence) {
padding-top: 11px;
border-top: 1px solid rgba(90, 123, 166, 0.12);
}
.root :global(details.scan-v4-evidence summary) {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border: 1px solid rgba(65, 118, 216, 0.28);
border-radius: 8px;
background: rgba(10, 30, 58, 0.52);
color: #8fbdff;
font-size: 12px;
font-weight: 950;
cursor: pointer;
list-style: none;
}
.root :global(details.scan-v4-evidence summary::-webkit-details-marker) {
display: none;
}
.root :global(details.scan-v4-evidence summary::after) {
content: "v";
color: #8fbdff;
font-size: 12px;
}
.root :global(details.scan-v4-evidence[open] summary::after) {
content: "^";
}
.root :global(details.scan-v4-evidence > div) {
margin-top: 10px;
}
.root :global(.scan-table-body::-webkit-scrollbar),
.root :global(.scan-calendar-view::-webkit-scrollbar),
.root :global(.scan-detail-panel::-webkit-scrollbar) {
File diff suppressed because it is too large Load Diff
+23 -34
View File
@@ -831,6 +831,7 @@ export function ProbabilityDistribution({
}) {
const { locale, t } = useI18n();
const view = getProbabilityView(detail, targetDate);
const modelView = getModelView(detail, targetDate);
const marketYesPrice = getMarketYesPrice(marketScan);
const marketYesText = toPercent(marketYesPrice);
const isToday = !targetDate || targetDate === detail.local_date;
@@ -1216,34 +1217,26 @@ export function ProbabilityDistribution({
<div className="prob-price-card">
<div className="prob-price-head">
<span>
{locale === "en-US" ? "Probability x Price" : "概率 x 价格联动"}
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
</span>
<strong>
{!marketScan
? locale === "en-US"
? "Waiting for market layer"
: "等待市场"
? "Waiting for market context"
: "等待市场参照"
: !marketScan.available
? locale === "en-US"
? "No matched active market"
: "未匹配到活跃盘口"
: linkedMarketBucket
? locale === "en-US"
? `${actionText}: ${linkedContractLabel || "contract bucket"}`
: `${actionText}${linkedContractLabel || "合约桶"}`
: preferredPriceView?.edge != null
? locale === "en-US"
? `${actionText} · edge ${formatSignedPercent(preferredPriceView.edge)}`
: `${actionText} · 优势 ${formatSignedPercent(preferredPriceView.edge)}`
: locale === "en-US"
? "Waiting for executable quote"
: "等待可执行报价"}
: locale === "en-US"
? `${linkedContractLabel || topProbabilityLabel || "Temperature bucket"} · model ${linkedMarketProbabilityText || topProbabilityText || "--"}`
: `${linkedContractLabel || topProbabilityLabel || "温度桶"} · 模型 ${linkedMarketProbabilityText || topProbabilityText || "--"}`}
</strong>
</div>
<div className="prob-price-grid">
<div>
<span>
{locale === "en-US" ? "Contract bucket" : "合约桶口径"}
{locale === "en-US" ? "Bucket" : "温度桶"}
</span>
<strong>
{linkedContractLabel || topProbabilityLabel || "--"}
@@ -1253,33 +1246,29 @@ export function ProbabilityDistribution({
</em>
</div>
<div>
<span>{locale === "en-US" ? "Candidate" : "可关注"}</span>
<strong>{actionText}</strong>
<em>{actionNote}</em>
<span>{locale === "en-US" ? "DEB" : "DEB"}</span>
<strong>
{modelView.deb != null && Number.isFinite(Number(modelView.deb))
? `${Number(modelView.deb).toFixed(1)}${detail.temp_symbol}`
: "--"}
</strong>
<em>{locale === "en-US" ? "final fused forecast" : "最终融合预测"}</em>
</div>
<div>
<span>{locale === "en-US" ? "YES price" : "YES 价格"}</span>
<strong>{toPriceCents(yesDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(yesDisplayEdge)}`
: `优势 ${formatSignedPercent(yesDisplayEdge)}`}
</em>
<span>{locale === "en-US" ? "Model support" : "模型支持"}</span>
<strong>{modelVoteHint || "--"}</strong>
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
</div>
<div>
<span>{locale === "en-US" ? "NO price" : "NO 价格"}</span>
<strong>{toPriceCents(noDisplayPrice) || "--"}</strong>
<em>
{locale === "en-US"
? `edge ${formatSignedPercent(noDisplayEdge)}`
: `优势 ${formatSignedPercent(noDisplayEdge)}`}
</em>
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
<em>{quoteSourceLabel}</em>
</div>
</div>
<p>
{locale === "en-US"
? `Read-only comparison between model probability and executable ask; it does not place orders. Source: ${quoteSourceLabel}${lockAvailable ? ` · lock ${formatSignedPercent(lockEdge)}` : ""}.`
: `只比较模型概率与可执行买价;系统不会下单。来源:${quoteSourceLabel}${lockAvailable ? ` · 锁价 ${formatSignedPercent(lockEdge)}` : ""}`}
? "This card follows the same rule as the opportunity list: DEB first, model agreement second, METAR conflict check before settlement."
: "该卡片与机会列表口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
</p>
</div>
)}
@@ -701,7 +701,7 @@ function ScanTerminalScreen() {
const [aiError, setAiError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<ContentView>("list");
const [activeView, setActiveView] = useState<ContentView>("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [showScanPaywall, setShowScanPaywall] = useState(false);
const [aiLogs, setAiLogs] = useState<ScanAiLogEntry[]>([]);
@@ -1000,7 +1000,7 @@ function ScanTerminalScreen() {
.slice(0, 12);
cities.forEach((cityName) => {
void store.ensureCityDetail(cityName, true, "market").catch(() => {});
void store.ensureCityDetail(cityName, false, "market").catch(() => {});
});
}, [
isPro,
@@ -1086,12 +1086,26 @@ function ScanTerminalScreen() {
setMapSelectedCityName(cityName);
const matchedRow = findRowForCity(timeSortedRows, cityName);
setSelectedRowId(matchedRow?.id || null);
void store.selectCity(cityName);
}, [store, timeSortedRows]);
}, [timeSortedRows]);
const handleSelectRow = useCallback((row: ScanOpportunityRow) => {
const cityName = row.city || row.city_display_name || row.display_name || "";
if (!cityName) return;
setSelectedRowId(row.id);
void store.selectCity(row.city);
const selectedCityKey = normalizeCityKey(store.selectedCity);
const rowCityKey = normalizeCityKey(cityName);
const hasCachedDetail =
Boolean(store.cityDetailsByName[cityName]) ||
Object.values(store.cityDetailsByName).some((detail) =>
rowMatchesCity(row, detail?.name || detail?.display_name || ""),
);
if (store.isPanelOpen && selectedCityKey === rowCityKey) {
if (!hasCachedDetail) {
void store.ensureCityDetail(cityName, false, "panel").catch(() => {});
}
return;
}
void store.selectCity(cityName);
}, [store]);
const openScanPaywall = useCallback(() => {