feat: add scan terminal dashboard with AI city analysis integration
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
|||||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||||
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
||||||
35_000,
|
35_000,
|
||||||
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "45000") || 45_000,
|
Number(process.env.POLYWEATHER_SCAN_AI_PROXY_TIMEOUT_MS || "55000") || 55_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|||||||
@@ -12624,6 +12624,127 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-state) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 520px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
align-content: center;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 48px 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-orb) {
|
||||||
|
position: relative;
|
||||||
|
width: 74px;
|
||||||
|
height: 74px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at center, rgba(77, 163, 255, 0.2) 0 22%, transparent 23%),
|
||||||
|
conic-gradient(from 20deg, rgba(77, 163, 255, 0.08), rgba(77, 163, 255, 0.95), rgba(34, 197, 94, 0.78), rgba(77, 163, 255, 0.08));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px rgba(77, 163, 255, 0.14),
|
||||||
|
0 18px 42px rgba(77, 163, 255, 0.18);
|
||||||
|
animation: scan-loading-spin 1.25s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-orb::before) {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 8px;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-orb span) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 26px;
|
||||||
|
z-index: 1;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: #4da3ff;
|
||||||
|
box-shadow:
|
||||||
|
0 0 18px rgba(77, 163, 255, 0.72),
|
||||||
|
0 0 36px rgba(34, 197, 94, 0.28);
|
||||||
|
animation: scan-loading-pulse 1.25s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-orb i) {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 1;
|
||||||
|
top: 4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
margin-left: -4.5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e6edf3;
|
||||||
|
box-shadow: 0 0 18px rgba(230, 237, 243, 0.86);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-title) {
|
||||||
|
color: #e6edf3;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-copy) {
|
||||||
|
color: #9fb2c7;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-steps) {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-steps span) {
|
||||||
|
width: 32px;
|
||||||
|
height: 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(77, 163, 255, 0.24);
|
||||||
|
animation: scan-loading-step 1.2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-steps span:nth-child(2)) {
|
||||||
|
animation-delay: 0.16s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-loading-steps span:nth-child(3)) {
|
||||||
|
animation-delay: 0.32s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-loading-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-loading-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: scale(0.82);
|
||||||
|
opacity: 0.74;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan-loading-step {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
background: rgba(77, 163, 255, 0.2);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
background: rgba(77, 163, 255, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-terminal.light) {
|
.root :global(.scan-terminal.light) {
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%),
|
radial-gradient(circle at top, rgba(59, 130, 246, 0.1), transparent 34%),
|
||||||
@@ -12678,6 +12799,7 @@
|
|||||||
.root :global(.scan-terminal.light .scan-detail-city-sub),
|
.root :global(.scan-terminal.light .scan-detail-city-sub),
|
||||||
.root :global(.scan-terminal.light .scan-detail-volume-caption),
|
.root :global(.scan-terminal.light .scan-detail-volume-caption),
|
||||||
.root :global(.scan-terminal.light .scan-empty-copy),
|
.root :global(.scan-terminal.light .scan-empty-copy),
|
||||||
|
.root :global(.scan-terminal.light .scan-loading-copy),
|
||||||
.root :global(.scan-terminal.light .scan-kv span:first-child),
|
.root :global(.scan-terminal.light .scan-kv span:first-child),
|
||||||
.root :global(.scan-terminal.light .scan-chart-label),
|
.root :global(.scan-terminal.light .scan-chart-label),
|
||||||
.root :global(.scan-terminal.light .scan-trade-sub),
|
.root :global(.scan-terminal.light .scan-trade-sub),
|
||||||
@@ -12685,6 +12807,14 @@
|
|||||||
color: #475569;
|
color: #475569;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-terminal.light .scan-loading-title) {
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.scan-terminal.light .scan-loading-orb::before) {
|
||||||
|
background: #f7f9fc;
|
||||||
|
}
|
||||||
|
|
||||||
.root :global(.scan-terminal.light .scan-list-tabs button),
|
.root :global(.scan-terminal.light .scan-list-tabs button),
|
||||||
.root :global(.scan-terminal.light .scan-mode-tab-sub),
|
.root :global(.scan-terminal.light .scan-mode-tab-sub),
|
||||||
.root :global(.scan-terminal.light .scan-opportunity-models em),
|
.root :global(.scan-terminal.light .scan-opportunity-models em),
|
||||||
|
|||||||
@@ -730,7 +730,11 @@ function AiPinnedCityCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="scan-ai-city-icon-button"
|
className="scan-ai-city-icon-button"
|
||||||
onClick={() => setAiRefreshToken((current) => current + 1)}
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
setAiRefreshToken((current) => current + 1);
|
||||||
|
}}
|
||||||
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
|
aria-label={isEn ? `Refresh ${displayName} analysis` : `刷新 ${displayName} 深度分析`}
|
||||||
title={isEn ? "Refresh analysis" : "刷新深度分析"}
|
title={isEn ? "Refresh analysis" : "刷新深度分析"}
|
||||||
disabled={aiForecast.status === "loading"}
|
disabled={aiForecast.status === "loading"}
|
||||||
@@ -740,7 +744,11 @@ function AiPinnedCityCard({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="scan-ai-city-icon-button danger"
|
className="scan-ai-city-icon-button danger"
|
||||||
onClick={onRemove}
|
onClick={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
onRemove();
|
||||||
|
}}
|
||||||
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
|
aria-label={isEn ? `Remove ${displayName}` : `移除 ${displayName}`}
|
||||||
title={isEn ? "Remove city" : "移除城市"}
|
title={isEn ? "Remove city" : "移除城市"}
|
||||||
disabled={removing}
|
disabled={removing}
|
||||||
@@ -832,10 +840,14 @@ function AiPinnedCityCard({
|
|||||||
</>
|
</>
|
||||||
) : aiForecast.status === "ready" ? (
|
) : aiForecast.status === "ready" ? (
|
||||||
<p>
|
<p>
|
||||||
{aiForecast.payload?.reason ||
|
{aiForecast.payload?.status === "timeout"
|
||||||
(isEn
|
? isEn
|
||||||
? "AI read is unavailable for this city right now."
|
? "Deepseek V4-Pro timed out. You can retry; city data and the right briefing were not refreshed."
|
||||||
: "该城市暂时没有可用的 AI 解读。")}
|
: "Deepseek V4-Pro 本次解读超时,可稍后重试;城市数据和右侧简报不会被刷新。"
|
||||||
|
: aiForecast.payload?.reason ||
|
||||||
|
(isEn
|
||||||
|
? "AI read is unavailable for this city right now."
|
||||||
|
: "该城市暂时没有可用的 AI 解读。")}
|
||||||
</p>
|
</p>
|
||||||
) : aiForecast.status === "failed" ? (
|
) : aiForecast.status === "failed" ? (
|
||||||
<p>
|
<p>
|
||||||
@@ -1417,10 +1429,21 @@ function ScanTerminalScreen() {
|
|||||||
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root)}>
|
||||||
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
||||||
<main className="scan-data-grid">
|
<main className="scan-data-grid">
|
||||||
<div className="scan-empty-state">
|
<div className="scan-loading-state" role="status" aria-live="polite">
|
||||||
<div className="scan-empty-title">{isEn ? "Checking access" : "正在检查权限"}</div>
|
<div className="scan-loading-orb" aria-hidden="true">
|
||||||
<div className="scan-empty-copy">
|
<span />
|
||||||
{isEn ? "Preparing your deep analysis workspace." : "正在准备深度分析台。"}
|
<i />
|
||||||
|
</div>
|
||||||
|
<div className="scan-loading-title">
|
||||||
|
{isEn ? "Preparing deep analysis" : "正在准备深度分析"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-loading-copy">
|
||||||
|
{isEn ? "Checking access and loading city context." : "正在检查权限并载入城市上下文。"}
|
||||||
|
</div>
|
||||||
|
<div className="scan-loading-steps" aria-hidden="true">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ SCAN_AI_TIMEOUT_SEC = max(
|
|||||||
30,
|
30,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_TIMEOUT_SEC", "40")),
|
int(os.getenv("POLYWEATHER_SCAN_AI_TIMEOUT_SEC", "40")),
|
||||||
)
|
)
|
||||||
|
SCAN_CITY_AI_TIMEOUT_SEC = max(
|
||||||
|
20,
|
||||||
|
int(os.getenv("POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC", "32")),
|
||||||
|
)
|
||||||
SCAN_AI_CACHE_TTL_SEC = max(
|
SCAN_AI_CACHE_TTL_SEC = max(
|
||||||
30,
|
30,
|
||||||
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")),
|
int(os.getenv("POLYWEATHER_SCAN_AI_CACHE_TTL_SEC", "120")),
|
||||||
@@ -915,9 +919,9 @@ def _call_deepseek_city_ai(ai_input: Dict[str, Any]) -> Dict[str, Any]:
|
|||||||
"city_snapshot": ai_input,
|
"city_snapshot": ai_input,
|
||||||
}
|
}
|
||||||
timeout = httpx.Timeout(
|
timeout = httpx.Timeout(
|
||||||
timeout=float(SCAN_AI_TIMEOUT_SEC),
|
timeout=float(SCAN_CITY_AI_TIMEOUT_SEC),
|
||||||
connect=min(8.0, float(SCAN_AI_TIMEOUT_SEC)),
|
connect=min(8.0, float(SCAN_CITY_AI_TIMEOUT_SEC)),
|
||||||
read=float(SCAN_AI_TIMEOUT_SEC),
|
read=float(SCAN_CITY_AI_TIMEOUT_SEC),
|
||||||
write=10.0,
|
write=10.0,
|
||||||
pool=5.0,
|
pool=5.0,
|
||||||
)
|
)
|
||||||
@@ -988,7 +992,7 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
)
|
)
|
||||||
data = _analyze(
|
data = _analyze(
|
||||||
city_name,
|
city_name,
|
||||||
force_refresh=force_refresh,
|
force_refresh=False,
|
||||||
include_llm_commentary=False,
|
include_llm_commentary=False,
|
||||||
detail_mode="full",
|
detail_mode="full",
|
||||||
)
|
)
|
||||||
@@ -1073,7 +1077,7 @@ def build_scan_city_ai_forecast_payload(
|
|||||||
"city": data.get("name") or city_name,
|
"city": data.get("name") or city_name,
|
||||||
"city_display_name": data.get("display_name") or city_name,
|
"city_display_name": data.get("display_name") or city_name,
|
||||||
"duration_ms": duration_ms,
|
"duration_ms": duration_ms,
|
||||||
"reason": f"V4 provider timed out after {SCAN_AI_TIMEOUT_SEC}s",
|
"reason": f"V4 provider timed out after {SCAN_CITY_AI_TIMEOUT_SEC}s",
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
duration_ms = int((time.time() - started_at) * 1000)
|
duration_ms = int((time.time() - started_at) * 1000)
|
||||||
|
|||||||
Reference in New Issue
Block a user