Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32019c89f5 | ||
|
|
41acb2aa51 | ||
|
|
ca0b25d84f | ||
|
|
00e1845f2c | ||
|
|
f47de115c8 | ||
|
|
3e14a23e02 | ||
|
|
2183c0399d | ||
|
|
30faac989c | ||
|
|
0f9469f3cb | ||
|
|
734462a3b9 | ||
|
|
a7421b3147 | ||
|
|
30a6ff7986 | ||
|
|
52fa7b0bb3 | ||
|
|
7b82ba68df | ||
|
|
a684586cb4 | ||
|
|
2f73828d4d | ||
|
|
227fe7906c | ||
|
|
a3052767d7 | ||
|
|
4ed9321756 | ||
|
|
a1d5435dd7 | ||
|
|
59e099a56d | ||
|
|
1a40802dcb | ||
|
|
a4ce450dbc | ||
|
|
13025f5c77 | ||
|
|
eb42d644a3 | ||
|
|
f9d0ea31bc | ||
|
|
c296bbb789 | ||
|
|
4186a2662b | ||
|
|
cdfde1624c | ||
|
|
9c61a8e012 | ||
|
|
f8c5cfab28 | ||
|
|
1167d6e39e | ||
|
|
118d4e44df | ||
|
|
6c203bee60 | ||
|
|
113c0131b5 | ||
|
|
9d0570a36b | ||
|
|
6603515433 | ||
|
|
96dfb4c5b3 | ||
|
|
a984c22b62 | ||
|
|
f0436f498a | ||
|
|
94995c5006 | ||
|
|
6da72b5488 | ||
|
|
5b32e71f5d | ||
|
|
1ccc275a23 | ||
|
|
569f3eef97 | ||
|
|
4630cee06f | ||
|
|
2b1d7c0b65 | ||
|
|
20516000a2 | ||
|
|
b122e7cbae | ||
|
|
12d90c5051 | ||
|
|
fbd98dd59c | ||
|
|
a77d613c2b | ||
|
|
5dc39b1573 | ||
|
|
ecbba9160d | ||
|
|
c476ad2123 | ||
|
|
be41becb30 | ||
|
|
8d05812a7e | ||
|
|
2e5bdfd1b6 | ||
|
|
d2699ce17a | ||
|
|
82a736850b | ||
|
|
6fe0e5dfc5 | ||
|
|
9ca3ee5a8e | ||
|
|
7130f8cf2a | ||
|
|
4c9252688a | ||
|
|
de107f2673 | ||
|
|
bf8de6a09b | ||
|
|
cf5502b6d6 | ||
|
|
51969d0b4a | ||
|
|
1543207ced | ||
|
|
326bfe258e | ||
|
|
1f364dfa4a | ||
|
|
30b1f5e256 | ||
|
|
89a71d1bc0 | ||
|
|
e6010d8242 | ||
|
|
de0effc40b | ||
|
|
0c9b07faaf | ||
|
|
0eab2fe628 | ||
|
|
0a3242c6ec | ||
|
|
d1d9f80f0f | ||
|
|
c3f092fc28 | ||
|
|
ee2a5338bb | ||
|
|
504efcbb49 | ||
|
|
4eb50ce880 | ||
|
|
e4cf570820 | ||
|
|
e90ea2b350 |
@@ -1,27 +0,0 @@
|
||||
---
|
||||
name: doc_refresher
|
||||
description: 自动同步和刷新项目文档,确保 Markdown 文件与当前代码功能状态完全一致。
|
||||
---
|
||||
|
||||
# Documentation Refresher Skill
|
||||
|
||||
此技能用于在项目架构发生重大调整(如功能下线、重心转移)后,自动重写和更新项目的所有 Markdown 文档。
|
||||
|
||||
## 核心任务
|
||||
|
||||
1. **代码扫描**:分析 `run.py` 和核心逻辑,识别哪些功能是“活跃的”,哪些功能是“暂停/移除的”。
|
||||
2. **术语统一**:确保所有文档使用一致的语气(例如从“监控机器人”转向“天气查询机器人”)。
|
||||
3. **多语言同步**:确保 `_ZH.md` 与英文版文档内容同步。
|
||||
|
||||
## 更新准则
|
||||
|
||||
- **状态准确性**:如果功能已在代码中被注释(如监控引擎、模拟交易),文档必须明确标注为“已暂停”或直接移除。
|
||||
- **示例更新**:更新文档中的 Telegram 指令示例,移除已下线的指令(如 `/signal`, `/portfolio`)。
|
||||
- **流程简化**:针对当前“天气查询模式”,简化安装和运行流程说明。
|
||||
|
||||
## 使用流程
|
||||
|
||||
1. 查看 `run.py` 确认当前运行模式。
|
||||
2. 遍历项目根目录下所有的 `.md` 文件。
|
||||
3. 对每个文件内容进行重构,保持格式美观。
|
||||
4. 校验多语言版本的一致性。
|
||||
@@ -2,7 +2,8 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm --prefix \"/e/web/PolyWeather/frontend\" run build)",
|
||||
"mcp__Claude_Preview__preview_start"
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"Bash(git:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+15
-14
@@ -101,14 +101,6 @@ TELEGRAM_ALERT_PUSH_INTERVAL_SEC=300
|
||||
TELEGRAM_ALERT_PUSH_COOLDOWN_SEC=1800
|
||||
TELEGRAM_ALERT_MIN_TRIGGER_COUNT=2
|
||||
TELEGRAM_ALERT_MIN_SEVERITY=medium
|
||||
TELEGRAM_ALERT_MISPRICING_ONLY=true
|
||||
TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC=7200
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED=true
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800
|
||||
TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5
|
||||
TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR=8
|
||||
TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN=480
|
||||
TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN=300
|
||||
TELEGRAM_ALERT_CITIES=ankara,london,paris,seoul,hong kong,shanghai,singapore,tokyo,tel aviv,toronto,buenos aires,wellington,new york,chicago,dallas,miami,atlanta,seattle,lucknow,sao paulo,munich
|
||||
POLYWEATHER_MONITORING_ALERT_CHAT_IDS=
|
||||
|
||||
@@ -136,12 +128,19 @@ POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
|
||||
# Optional DeepSeek V4-Pro market scan review for Pro users
|
||||
# Optional OpenAI-compatible market scan review for Pro users
|
||||
# Temporary default provider: MiMo via https://token-plan-cn.xiaomimimo.com/v1.
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_DEEPSEEK_API_KEY=
|
||||
POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
POLYWEATHER_SCAN_AI_MODEL=deepseek-v4-flash
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash
|
||||
POLYWEATHER_SCAN_AI_API_KEY=
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
# Backward-compatible legacy DeepSeek variables are still read if the generic
|
||||
# POLYWEATHER_SCAN_AI_* variables are unset.
|
||||
# POLYWEATHER_DEEPSEEK_API_KEY=
|
||||
# POLYWEATHER_DEEPSEEK_BASE_URL=https://api.deepseek.com
|
||||
POLYWEATHER_SCAN_AI_TIMEOUT_SEC=18
|
||||
POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30
|
||||
POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=false
|
||||
@@ -202,9 +201,11 @@ POLYMARKET_MARKET_SCAN_ENABLED=true
|
||||
POLYMARKET_GAMMA_URL=https://gamma-api.polymarket.com
|
||||
POLYMARKET_CLOB_URL=https://clob.polymarket.com
|
||||
POLYMARKET_CHAIN_ID=137
|
||||
POLYMARKET_HTTP_TIMEOUT_SEC=8
|
||||
POLYMARKET_HTTP_TIMEOUT_SEC=20
|
||||
POLYMARKET_MARKET_CACHE_TTL_SEC=60
|
||||
POLYMARKET_PRICE_CACHE_TTL_SEC=30
|
||||
# false = fetch CLOB book/depth for orderbook analysis; true = price-only, lighter but no book levels
|
||||
POLYMARKET_FAST_PRICE_ONLY=false
|
||||
POLYWEATHER_MARKET_SCAN_PAYLOAD_TTL_SEC=30
|
||||
POLYMARKET_WS_PRICE_ENABLED=false
|
||||
POLYMARKET_WS_MARKET_URL=wss://ws-subscriptions-clob.polymarket.com/ws/market
|
||||
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Business state tests
|
||||
run: npm run test:business
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
<!-- AUTONOMY DIRECTIVE — DO NOT REMOVE -->
|
||||
YOU ARE AN AUTONOMOUS CODING AGENT. EXECUTE TASKS TO COMPLETION WITHOUT ASKING FOR PERMISSION.
|
||||
DO NOT STOP TO ASK "SHOULD I PROCEED?" — PROCEED. DO NOT WAIT FOR CONFIRMATION ON OBVIOUS NEXT STEPS.
|
||||
IF BLOCKED, TRY AN ALTERNATIVE APPROACH. ONLY ASK WHEN TRULY AMBIGUOUS OR DESTRUCTIVE.
|
||||
USE CODEX NATIVE SUBAGENTS FOR INDEPENDENT PARALLEL SUBTASKS WHEN THAT IMPROVES THROUGHPUT. THIS IS COMPLEMENTARY TO OMX TEAM MODE.
|
||||
<!-- END AUTONOMY DIRECTIVE -->
|
||||
<!-- omx:generated:agents-md -->
|
||||
|
||||
# oh-my-codex - Intelligent Multi-Agent Orchestration
|
||||
|
||||
You are running with oh-my-codex (OMX), a coordination layer for Codex CLI.
|
||||
This AGENTS.md is the top-level operating contract for the workspace.
|
||||
Role prompts under `prompts/*.md` are narrower execution surfaces. They must follow this file, not override it.
|
||||
When OMX is installed, load the installed prompt/skill/agent surfaces from `./.codex/prompts`, `./.codex/skills`, and `./.codex/agents` (or the project-local `./.codex/...` equivalents when project scope is active).
|
||||
|
||||
<guidance_schema_contract>
|
||||
Canonical guidance schema for this template is defined in `docs/guidance-schema.md`.
|
||||
|
||||
Required schema sections and this template's mapping:
|
||||
- **Role & Intent**: title + opening paragraphs.
|
||||
- **Operating Principles**: `<operating_principles>`.
|
||||
- **Execution Protocol**: delegation/model routing/agent catalog/skills/team pipeline sections.
|
||||
- **Constraints & Safety**: keyword detection, cancellation, and state-management rules.
|
||||
- **Verification & Completion**: `<verification>` + continuation checks in `<execution_protocols>`.
|
||||
- **Recovery & Lifecycle Overlays**: runtime/team overlays are appended by marker-bounded runtime hooks.
|
||||
|
||||
Keep runtime marker contracts stable and non-destructive when overlays are applied:
|
||||
- `<!-- OMX:RUNTIME:START --> ... <!-- OMX:RUNTIME:END -->`
|
||||
- `<!-- OMX:TEAM:WORKER:START --> ... <!-- OMX:TEAM:WORKER:END -->`
|
||||
</guidance_schema_contract>
|
||||
|
||||
<operating_principles>
|
||||
- Solve the task directly when you can do so safely and well.
|
||||
- Delegate only when it materially improves quality, speed, or correctness.
|
||||
- Keep progress short, concrete, and useful.
|
||||
- Prefer evidence over assumption; verify before claiming completion.
|
||||
- Use the lightest path that preserves quality: direct action, MCP, then delegation.
|
||||
- Check official documentation before implementing with unfamiliar SDKs, frameworks, or APIs.
|
||||
- Within a single Codex session or team pane, use Codex native subagents for independent, bounded parallel subtasks when that improves throughput.
|
||||
<!-- OMX:GUIDANCE:OPERATING:START -->
|
||||
- Default to quality-first, intent-deepening responses; think one more step before replying or asking for clarification, and use as much detail as needed for a strong result without empty verbosity.
|
||||
- Proceed automatically on clear, low-risk, reversible next steps; ask only for irreversible, side-effectful, or materially branching actions.
|
||||
- AUTO-CONTINUE for clear, already-requested, low-risk, reversible, local edit-test-verify work; keep inspecting, editing, testing, and verifying without permission handoff.
|
||||
- ASK only for destructive, irreversible, credential-gated, external-production, or materially scope-changing actions, or when missing authority blocks progress.
|
||||
- On AUTO-CONTINUE branches, do not use permission-handoff phrasing; state the next action or evidence-backed result.
|
||||
- Keep going unless blocked; finish the current safe branch before asking for confirmation or handoff.
|
||||
- Ask only when blocked by missing information, missing authority, or an irreversible/destructive branch.
|
||||
- Do not ask or instruct humans to perform ordinary non-destructive, reversible actions; execute those safe reversible OMX/runtime operations and ordinary commands yourself.
|
||||
- Treat OMX runtime manipulation, state transitions, and ordinary command execution as agent responsibilities when they are safe and reversible.
|
||||
- Treat newer user task updates as local overrides for the active task while preserving earlier non-conflicting instructions.
|
||||
- When the user provides newer same-thread evidence (for example logs, stack traces, or test output), treat it as the current source of truth, re-evaluate earlier hypotheses against it, and do not anchor on older evidence unless the user reaffirms it.
|
||||
- Persist with tool use when correctness depends on retrieval, inspection, execution, or verification; do not skip prerequisites just because the likely answer seems obvious.
|
||||
- More effort does not mean reflexive web/tool escalation; browse or use tools when the task materially benefits, not as a default show of effort.
|
||||
<!-- OMX:GUIDANCE:OPERATING:END -->
|
||||
</operating_principles>
|
||||
|
||||
## Working agreements
|
||||
- Write a cleanup plan before modifying code for cleanup/refactor/deslop work.
|
||||
- Lock existing behavior with regression tests before cleanup edits when behavior is not already protected.
|
||||
- Prefer deletion over addition.
|
||||
- Reuse existing utils and patterns before introducing new abstractions.
|
||||
- No new dependencies without explicit request.
|
||||
- Keep diffs small, reviewable, and reversible.
|
||||
- Run lint, typecheck, tests, and static analysis after changes.
|
||||
- Final reports must include changed files, simplifications made, and remaining risks.
|
||||
|
||||
<lore_commit_protocol>
|
||||
## Lore Commit Protocol
|
||||
|
||||
Every commit message must follow the Lore protocol — structured decision records using native git trailers.
|
||||
Commits are not just labels on diffs; they are the atomic unit of institutional knowledge.
|
||||
|
||||
### Format
|
||||
|
||||
```
|
||||
<intent line: why the change was made, not what changed>
|
||||
|
||||
<body: narrative context — constraints, approach rationale>
|
||||
|
||||
Constraint: <external constraint that shaped the decision>
|
||||
Rejected: <alternative considered> | <reason for rejection>
|
||||
Confidence: <low|medium|high>
|
||||
Scope-risk: <narrow|moderate|broad>
|
||||
Directive: <forward-looking warning for future modifiers>
|
||||
Tested: <what was verified (unit, integration, manual)>
|
||||
Not-tested: <known gaps in verification>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Intent line first.** The first line describes *why*, not *what*. The diff already shows what changed.
|
||||
2. **Trailers are optional but encouraged.** Use the ones that add value; skip the ones that don't.
|
||||
3. **`Rejected:` prevents re-exploration.** If you considered and rejected an alternative, record it so future agents don't waste cycles re-discovering the same dead end.
|
||||
4. **`Directive:` is a message to the future.** Use it for "do not change X without checking Y" warnings.
|
||||
5. **`Constraint:` captures external forces.** API limitations, policy requirements, upstream bugs — things not visible in the code.
|
||||
6. **`Not-tested:` is honest.** Declaring known verification gaps is more valuable than pretending everything is covered.
|
||||
7. **All trailers use git-native trailer format** (key-value after a blank line). No custom parsing required.
|
||||
|
||||
### Example
|
||||
|
||||
```
|
||||
Prevent silent session drops during long-running operations
|
||||
|
||||
The auth service returns inconsistent status codes on token
|
||||
expiry, so the interceptor catches all 4xx responses and
|
||||
triggers an inline refresh.
|
||||
|
||||
Constraint: Auth service does not support token introspection
|
||||
Constraint: Must not add latency to non-expired-token paths
|
||||
Rejected: Extend token TTL to 24h | security policy violation
|
||||
Rejected: Background refresh on timer | race condition with concurrent requests
|
||||
Confidence: high
|
||||
Scope-risk: narrow
|
||||
Directive: Error handling is intentionally broad (all 4xx) — do not narrow without verifying upstream behavior
|
||||
Tested: Single expired token refresh (unit)
|
||||
Not-tested: Auth service cold-start > 500ms behavior
|
||||
```
|
||||
|
||||
### Trailer Vocabulary
|
||||
|
||||
| Trailer | Purpose |
|
||||
|---------|---------|
|
||||
| `Constraint:` | External constraint that shaped the decision |
|
||||
| `Rejected:` | Alternative considered and why it was rejected |
|
||||
| `Confidence:` | Author's confidence level (low/medium/high) |
|
||||
| `Scope-risk:` | How broadly the change affects the system (narrow/moderate/broad) |
|
||||
| `Reversibility:` | How easily the change can be undone (clean/messy/irreversible) |
|
||||
| `Directive:` | Forward-looking instruction for future modifiers |
|
||||
| `Tested:` | What verification was performed |
|
||||
| `Not-tested:` | Known gaps in verification |
|
||||
| `Related:` | Links to related commits, issues, or decisions |
|
||||
|
||||
Teams may introduce domain-specific trailers without breaking compatibility.
|
||||
</lore_commit_protocol>
|
||||
|
||||
---
|
||||
|
||||
<delegation_rules>
|
||||
Default posture: work directly.
|
||||
|
||||
Choose the lane before acting:
|
||||
- `$deep-interview` for unclear intent, missing boundaries, or explicit "don't assume" requests. This mode clarifies and hands off; it does not implement.
|
||||
- `$ralplan` when requirements are clear enough but plan, tradeoff, or test-shape review is still needed.
|
||||
- `$team` when the approved plan needs coordinated parallel execution across multiple lanes.
|
||||
- `$ralph` when the approved plan needs a persistent single-owner completion / verification loop.
|
||||
- **Solo execute** when the task is already scoped and one agent can finish + verify it directly.
|
||||
|
||||
Delegate only when it materially improves quality, speed, or safety. Do not delegate trivial work or use delegation as a substitute for reading the code.
|
||||
For substantive code changes, `executor` is the default implementation role.
|
||||
Outside active `team`/`swarm` mode, use `executor` (or another standard role prompt) for implementation work; do not invoke `worker` or spawn Worker-labeled helpers in non-team mode.
|
||||
Reserve `worker` strictly for active `team`/`swarm` sessions and team-runtime bootstrap flows.
|
||||
Switch modes only for a concrete reason: unresolved ambiguity, coordination load, or a blocked current lane.
|
||||
</delegation_rules>
|
||||
|
||||
<child_agent_protocol>
|
||||
Leader responsibilities:
|
||||
1. Pick the mode and keep the user-facing brief current.
|
||||
2. Delegate only bounded, verifiable subtasks with clear ownership.
|
||||
3. Integrate results, decide follow-up, and own final verification.
|
||||
|
||||
Worker responsibilities:
|
||||
1. Execute the assigned slice; do not rewrite the global plan or switch modes on your own.
|
||||
2. Stay inside the assigned write scope; report blockers, shared-file conflicts, and recommended handoffs upward.
|
||||
3. Ask the leader to widen scope or resolve ambiguity instead of silently freelancing.
|
||||
|
||||
Rules:
|
||||
- Max 6 concurrent child agents.
|
||||
- Child prompts stay under AGENTS.md authority.
|
||||
- `worker` is a team-runtime surface, not a general-purpose child role.
|
||||
- Child agents should report recommended handoffs upward.
|
||||
- Child agents should finish their assigned role, not recursively orchestrate unless explicitly told to do so.
|
||||
- Prefer inheriting the leader model by omitting `spawn_agent.model` unless a task truly requires a different model.
|
||||
- Do not hardcode stale frontier-model overrides for Codex native child agents. If an explicit frontier override is necessary, use the current frontier default from `OMX_DEFAULT_FRONTIER_MODEL` / the repo model contract (currently `gpt-5.5`), not older values such as `gpt-5.2`.
|
||||
- Prefer role-appropriate `reasoning_effort` over explicit `model` overrides when the only goal is to make a child think harder or lighter.
|
||||
</child_agent_protocol>
|
||||
|
||||
<invocation_conventions>
|
||||
- `$name` — invoke a workflow skill
|
||||
- `/skills` — browse available skills
|
||||
- Prefer skill invocation and keyword routing as the primary user-facing workflow surface
|
||||
</invocation_conventions>
|
||||
|
||||
<model_routing>
|
||||
Match role to task shape:
|
||||
- Low complexity: `explore`, `style-reviewer`, `writer`
|
||||
- Research/discovery: `explore` for repo lookup, `researcher` for official docs/reference gathering, `dependency-expert` for SDK/API/package evaluation
|
||||
- Standard: `executor`, `debugger`, `test-engineer`
|
||||
- High complexity: `architect`, `executor`, `critic`
|
||||
|
||||
For Codex native child agents, model routing defaults to inheritance/current repo defaults unless the caller has a concrete reason to override it.
|
||||
</model_routing>
|
||||
|
||||
<specialist_routing>
|
||||
Leader/workflow routing contract:
|
||||
<!-- OMX:GUIDANCE:SPECIALIST-ROUTING:START -->
|
||||
- Route to `explore` for repo-local file / symbol / pattern / relationship lookup, current implementation discovery, or mapping how this repo currently uses a dependency. `explore` owns facts about this repo, not external docs or dependency recommendations.
|
||||
- Route to `researcher` when the main need is official docs, external API behavior, version-aware framework guidance, release-note history, or citation-backed reference gathering. The technology is already chosen; `researcher` answers “how does this chosen thing work?” and is not the default dependency-comparison role.
|
||||
- Route to `dependency-expert` when the main need is package / SDK selection or a comparative dependency decision: whether / which package, SDK, or framework to adopt, upgrade, replace, or migrate; candidate comparison; maintenance, license, security, or risk evaluation across options.
|
||||
- Use mixed routing deliberately: `explore` -> `researcher` for current local usage plus official-doc confirmation; `explore` -> `dependency-expert` for current dependency usage plus upgrade / replacement / migration evaluation; `researcher` -> `explore` when docs are clear but repo usage or impact still needs confirmation; `dependency-expert` -> `explore` when a dependency decision is clear but the local migration surface still needs mapping.
|
||||
- Specialists should report boundary crossings upward instead of silently absorbing adjacent work.
|
||||
- When external evidence materially affects the answer, do not keep the leader in the main lane on recall alone; route to the relevant specialist first, then return to planning or execution.
|
||||
<!-- OMX:GUIDANCE:SPECIALIST-ROUTING:END -->
|
||||
</specialist_routing>
|
||||
|
||||
---
|
||||
|
||||
<agent_catalog>
|
||||
Key roles:
|
||||
- `explore` — fast codebase search and mapping
|
||||
- `planner` — work plans and sequencing
|
||||
- `architect` — read-only analysis, diagnosis, tradeoffs
|
||||
- `debugger` — root-cause analysis
|
||||
- `executor` — implementation and refactoring
|
||||
- `verifier` — completion evidence and validation
|
||||
|
||||
Research/discovery specialists:
|
||||
- `explore` — first-stop repository lookup and symbol/file mapping
|
||||
- `researcher` — official docs, references, and external fact gathering
|
||||
- `dependency-expert` — SDK/API/package evaluation before adopting or changing dependencies
|
||||
|
||||
Specialists remain available through the role catalog and native child-agent surfaces when the task clearly benefits from them.
|
||||
</agent_catalog>
|
||||
|
||||
---
|
||||
|
||||
<keyword_detection>
|
||||
Keyword routing is implemented primarily by native `UserPromptSubmit` hooks and the generated keyword registry. Treat hook-injected routing context as authoritative for the current turn, then load the named `SKILL.md` or prompt file as instructed.
|
||||
|
||||
Fallback behavior when hook context is unavailable:
|
||||
- Explicit `$name` invocations run left-to-right and override implicit keywords.
|
||||
- Bare skill names do not activate skills by themselves; skill-name activation requires explicit `$skill` invocation. Natural-language routing phrases may still map to a workflow when they are not just the bare skill name. Examples: `analyze` / `investigate` → `$analyze` for read-only deep analysis with ranked synthesis, explicit confidence, and concrete file references; `deep interview`, `interview`, `don't assume`, or `ouroboros` → `$deep-interview`; `ralplan` / `consensus plan` → `$ralplan`; `cancel`, `stop`, or `abort` → `$cancel`.
|
||||
- Keep the detailed keyword list in `src/hooks/keyword-registry.ts`; do not duplicate that table here.
|
||||
|
||||
Runtime availability gate:
|
||||
- Treat `autopilot`, `ralph`, `ultrawork`, `ultraqa`, `team`/`swarm`, and `ecomode` as **OMX runtime workflows**, not generic prompt aliases.
|
||||
- Auto-activate runtime workflows only when the current session is actually running under OMX CLI/runtime (for example, launched via `omx`, with OMX session overlay/runtime state available, or when the user explicitly asks to run `omx ...` in the shell).
|
||||
- In Codex App or plain Codex sessions without OMX runtime, do **not** treat those keywords alone as activation. Explain that they require OMX CLI runtime support, and continue with the nearest App-safe surface (`deep-interview`, `ralplan`, `plan`, or native subagents) unless the user explicitly wants you to launch OMX from the shell.
|
||||
- When deep-interview is active in OMX CLI/runtime, ask interview rounds via `omx question`; after launching `omx question` in a background terminal, wait for that terminal to finish and read the JSON answer before continuing; do not substitute `request_user_input` or ad hoc plain-text questioning, and respect Stop-hook blocking while a deep-interview question obligation is pending.
|
||||
|
||||
<triage_routing>
|
||||
## Triage: advisory prompt-routing context
|
||||
|
||||
The keyword detector is the first and deterministic routing surface. Triage runs only when no keyword matches.
|
||||
|
||||
When active, triage emits **advisory prompt-routing context** — a developer-context string that the model may follow. It does not activate a skill or workflow by itself. It is a best-effort hint, not a guarantee.
|
||||
|
||||
Note: `explore`, `executor`, and `designer` are agent role-prompt files under `prompts/`, not workflow skills.
|
||||
|
||||
Explicit keywords remain the deterministic control surface when you want explicit, guaranteed routing — use them whenever exact behavior matters.
|
||||
|
||||
To opt out per prompt with phrases such as `no workflow`, `just chat`, or `plain answer` — the triage layer will suppress context injection for that prompt.
|
||||
</triage_routing>
|
||||
|
||||
Ralph / Ralplan execution gate:
|
||||
- Enforce **ralplan-first** when ralph is active and planning is not complete.
|
||||
- Planning is complete only after both `.omx/plans/prd-*.md` and `.omx/plans/test-spec-*.md` exist.
|
||||
- Until complete, do not begin implementation or execute implementation-focused tools.
|
||||
</keyword_detection>
|
||||
|
||||
---
|
||||
|
||||
<skills>
|
||||
Skills are workflow commands.
|
||||
Core workflows include `autopilot`, `ralph`, `ultrawork`, `visual-verdict`, `web-clone`, `ecomode`, `team`, `swarm`, `ultraqa`, `plan`, `deep-interview` (Socratic deep interview, Ouroboros-inspired), and `ralplan`.
|
||||
Utilities include `cancel`, `note`, `doctor`, `help`, and `trace`.
|
||||
</skills>
|
||||
|
||||
---
|
||||
|
||||
<team_compositions>
|
||||
Common team compositions remain available when explicit team orchestration is warranted, for example feature development, bug investigation, code review, and UX audit.
|
||||
</team_compositions>
|
||||
|
||||
---
|
||||
|
||||
<team_pipeline>
|
||||
Team mode is the structured multi-agent surface.
|
||||
Canonical pipeline:
|
||||
`team-plan -> team-prd -> team-exec -> team-verify -> team-fix (loop)`
|
||||
|
||||
Use it when durable staged coordination is worth the overhead. Otherwise, stay direct.
|
||||
Terminal states: `complete`, `failed`, `cancelled`.
|
||||
</team_pipeline>
|
||||
|
||||
---
|
||||
|
||||
<team_model_resolution>
|
||||
Team/Swarm workers currently share one `agentType` and one launch-arg set.
|
||||
Model precedence:
|
||||
1. Explicit model in `OMX_TEAM_WORKER_LAUNCH_ARGS`
|
||||
2. Inherited leader `--model`
|
||||
3. Low-complexity default model from `OMX_DEFAULT_SPARK_MODEL` (legacy alias: `OMX_SPARK_MODEL`)
|
||||
|
||||
Normalize model flags to one canonical `--model <value>` entry.
|
||||
Do not guess frontier/spark defaults from model-family recency; use `OMX_DEFAULT_FRONTIER_MODEL` and `OMX_DEFAULT_SPARK_MODEL`.
|
||||
</team_model_resolution>
|
||||
|
||||
<!-- OMX:MODELS:START -->
|
||||
## Model Capability Table
|
||||
|
||||
Auto-generated by `omx setup` from the current `config.toml` plus OMX model overrides.
|
||||
|
||||
| Role | Model | Reasoning Effort | Use Case |
|
||||
| --- | --- | --- | --- |
|
||||
| Frontier (leader) | `gpt-5.5` | high | Primary leader/orchestrator for planning, coordination, and frontier-class reasoning. |
|
||||
| Spark (explorer/fast) | `gpt-5.3-codex-spark` | low | Fast triage, explore, lightweight synthesis, and low-latency routing. |
|
||||
| Standard (subagent default) | `gpt-5.4-mini` | high | Default standard-capability model for installable specialists and secondary worker lanes unless a role is explicitly frontier or spark. |
|
||||
| `explore` | `gpt-5.3-codex-spark` | low | Fast codebase search and file/symbol mapping (fast-lane, fast) |
|
||||
| `analyst` | `gpt-5.5` | medium | Requirements clarity, acceptance criteria, hidden constraints (frontier-orchestrator, frontier) |
|
||||
| `planner` | `gpt-5.5` | medium | Task sequencing, execution plans, risk flags (frontier-orchestrator, frontier) |
|
||||
| `architect` | `gpt-5.5` | high | System design, boundaries, interfaces, long-horizon tradeoffs (frontier-orchestrator, frontier) |
|
||||
| `debugger` | `gpt-5.4-mini` | high | Root-cause analysis, regression isolation, failure diagnosis (deep-worker, standard) |
|
||||
| `executor` | `gpt-5.5` | medium | Code implementation, refactoring, feature work (deep-worker, standard) |
|
||||
| `team-executor` | `gpt-5.5` | medium | Supervised team execution for conservative delivery lanes (deep-worker, frontier) |
|
||||
| `verifier` | `gpt-5.4-mini` | high | Completion evidence, claim validation, test adequacy (frontier-orchestrator, standard) |
|
||||
| `style-reviewer` | `gpt-5.3-codex-spark` | low | Formatting, naming, idioms, lint conventions (fast-lane, fast) |
|
||||
| `quality-reviewer` | `gpt-5.4-mini` | medium | Logic defects, maintainability, anti-patterns (frontier-orchestrator, standard) |
|
||||
| `api-reviewer` | `gpt-5.4-mini` | medium | API contracts, versioning, backward compatibility (frontier-orchestrator, standard) |
|
||||
| `security-reviewer` | `gpt-5.5` | medium | Vulnerabilities, trust boundaries, authn/authz (frontier-orchestrator, frontier) |
|
||||
| `performance-reviewer` | `gpt-5.4-mini` | medium | Hotspots, complexity, memory/latency optimization (frontier-orchestrator, standard) |
|
||||
| `code-reviewer` | `gpt-5.5` | high | Comprehensive review across all concerns (frontier-orchestrator, frontier) |
|
||||
| `dependency-expert` | `gpt-5.4-mini` | high | External SDK/API/package evaluation (frontier-orchestrator, standard) |
|
||||
| `test-engineer` | `gpt-5.5` | medium | Test strategy, coverage, flaky-test hardening (deep-worker, frontier) |
|
||||
| `quality-strategist` | `gpt-5.4-mini` | medium | Quality strategy, release readiness, risk assessment (frontier-orchestrator, standard) |
|
||||
| `build-fixer` | `gpt-5.4-mini` | high | Build/toolchain/type failures resolution (deep-worker, standard) |
|
||||
| `designer` | `gpt-5.4-mini` | high | UX/UI architecture, interaction design (deep-worker, standard) |
|
||||
| `writer` | `gpt-5.4-mini` | high | Documentation, migration notes, user guidance (fast-lane, standard) |
|
||||
| `qa-tester` | `gpt-5.4-mini` | low | Interactive CLI/service runtime validation (deep-worker, standard) |
|
||||
| `git-master` | `gpt-5.4-mini` | high | Commit strategy, history hygiene, rebasing (deep-worker, standard) |
|
||||
| `code-simplifier` | `gpt-5.5` | high | Simplifies recently modified code for clarity and consistency without changing behavior (deep-worker, frontier) |
|
||||
| `researcher` | `gpt-5.4-mini` | high | External documentation and reference research (fast-lane, standard) |
|
||||
| `product-manager` | `gpt-5.4-mini` | medium | Problem framing, personas/JTBD, PRDs (frontier-orchestrator, standard) |
|
||||
| `ux-researcher` | `gpt-5.4-mini` | medium | Heuristic audits, usability, accessibility (frontier-orchestrator, standard) |
|
||||
| `information-architect` | `gpt-5.4-mini` | low | Taxonomy, navigation, findability (frontier-orchestrator, standard) |
|
||||
| `product-analyst` | `gpt-5.4-mini` | low | Product metrics, funnel analysis, experiments (frontier-orchestrator, standard) |
|
||||
| `critic` | `gpt-5.5` | high | Plan/design critical challenge and review (frontier-orchestrator, frontier) |
|
||||
| `vision` | `gpt-5.5` | low | Image/screenshot/diagram analysis (fast-lane, frontier) |
|
||||
<!-- OMX:MODELS:END -->
|
||||
|
||||
---
|
||||
|
||||
<verification>
|
||||
Verify before claiming completion.
|
||||
|
||||
Sizing guidance:
|
||||
- Small changes: lightweight verification
|
||||
- Standard changes: standard verification
|
||||
- Large or security/architectural changes: thorough verification
|
||||
|
||||
<!-- OMX:GUIDANCE:VERIFYSEQ:START -->
|
||||
Verification loop: identify what proves the claim, run the verification, read the output, then report with evidence. If verification fails, continue iterating rather than reporting incomplete work. Default to quality-first evidence summaries: think one more step before declaring completion, and include enough detail to make the proof actionable without padding.
|
||||
|
||||
- Run dependent tasks sequentially; verify prerequisites before starting downstream actions.
|
||||
- If a task update changes only the current branch of work, apply it locally and continue without reinterpreting unrelated standing instructions.
|
||||
- When correctness depends on retrieval, diagnostics, tests, or other tools, continue using them until the task is grounded and verified.
|
||||
<!-- OMX:GUIDANCE:VERIFYSEQ:END -->
|
||||
</verification>
|
||||
|
||||
<execution_protocols>
|
||||
Mode selection:
|
||||
- Use `$deep-interview` first when the request is broad, intent/boundaries are unclear, or the user says not to assume.
|
||||
- Use `$ralplan` when the requirements are clear enough but architecture, tradeoffs, or test strategy still need consensus.
|
||||
- Use `$team` when the approved plan has multiple independent lanes, shared blockers, or durable coordination needs.
|
||||
- Use `$ralph` when the approved plan should stay in a persistent completion / verification loop with one owner.
|
||||
- Otherwise execute directly in solo mode.
|
||||
- Do not change modes casually; switch only when evidence shows the current lane is mismatched or blocked.
|
||||
|
||||
Command routing:
|
||||
- When `USE_OMX_EXPLORE_CMD` enables advisory routing, strongly prefer `omx explore` as the default surface for simple read-only repository lookup tasks (files, symbols, patterns, relationships).
|
||||
- For simple file/symbol lookups, use `omx explore` FIRST before attempting full code analysis.
|
||||
|
||||
When to use what:
|
||||
- Use `omx explore --prompt ...` for simple read-only lookups.
|
||||
- Use `omx sparkshell` for noisy read-only shell commands, bounded verification runs, repo-wide listing/search, or tmux-pane summaries; `omx sparkshell --tmux-pane ...` is explicit opt-in.
|
||||
- Keep ambiguous, implementation-heavy, edit-heavy, or non-shell-only work on the richer normal path.
|
||||
- `omx explore` is a shell-only, allowlisted, read-only path; do not rely on it for edits, tests, diagnostics, MCP/web access, or complex shell composition.
|
||||
- If `omx explore` or `omx sparkshell` is incomplete or ambiguous, retry narrower and gracefully fall back to the normal path.
|
||||
|
||||
Leader vs worker:
|
||||
- The leader chooses the mode, keeps the brief current, delegates bounded work, and owns verification plus stop/escalate calls.
|
||||
- Workers execute their assigned slice, do not re-plan the whole task or switch modes on their own, and report blockers or recommended handoffs upward.
|
||||
- Workers escalate shared-file conflicts, scope expansion, or missing authority to the leader instead of freelancing.
|
||||
|
||||
Stop / escalate:
|
||||
- Stop when the task is verified complete, the user says stop/cancel, or no meaningful recovery path remains.
|
||||
- Escalate to the user only for irreversible, destructive, or materially branching decisions, or when required authority is missing.
|
||||
- Escalate from worker to leader for blockers, scope expansion, shared ownership conflicts, or mode mismatch.
|
||||
- `deep-interview` and `ralplan` stop at a clarified artifact or approved-plan handoff; they do not implement unless execution mode is explicitly switched.
|
||||
|
||||
Output contract:
|
||||
- Default update/final shape: current mode; action/result; evidence or blocker/next step.
|
||||
- Keep rationale once; do not restate the full plan every turn.
|
||||
- Expand only for risk, handoff, or explicit user request.
|
||||
|
||||
Parallelization:
|
||||
- Run independent tasks in parallel.
|
||||
- Run dependent tasks sequentially.
|
||||
- Use background execution for builds and tests when helpful.
|
||||
- Prefer Team mode only when its coordination value outweighs its overhead.
|
||||
- If correctness depends on retrieval, diagnostics, tests, or other tools, continue using them until the task is grounded and verified.
|
||||
|
||||
Anti-slop workflow:
|
||||
- Cleanup/refactor/deslop work still follows the same `$deep-interview` -> `$ralplan` -> `$team`/`$ralph` path; use `$ai-slop-cleaner` as a bounded helper inside the chosen execution lane, not as a competing top-level workflow.
|
||||
- Lock behavior with tests first, then make one smell-focused pass at a time.
|
||||
- Prefer deletion, reuse, and boundary repair over new layers.
|
||||
- Keep writer/reviewer pass separation for cleanup plans and approvals.
|
||||
|
||||
Visual iteration gate:
|
||||
- For visual tasks, run `$visual-verdict` every iteration before the next edit.
|
||||
- Persist verdict JSON in `.omx/state/{scope}/ralph-progress.json`.
|
||||
|
||||
Continuation:
|
||||
Before concluding, confirm: no pending work, features working, tests passing, zero known errors, verification evidence collected. If not, continue.
|
||||
|
||||
Ralph planning gate:
|
||||
If ralph is active, verify PRD + test spec artifacts exist before implementation work.
|
||||
</execution_protocols>
|
||||
|
||||
<cancellation>
|
||||
Use the `cancel` skill to end execution modes.
|
||||
Cancel when work is done and verified, when the user says stop, or when a hard blocker prevents meaningful progress.
|
||||
Do not cancel while recoverable work remains.
|
||||
</cancellation>
|
||||
|
||||
---
|
||||
|
||||
<state_management>
|
||||
Hooks own normal skill-active and workflow-state persistence under `.omx/state/`.
|
||||
|
||||
OMX persists runtime state under `.omx/`:
|
||||
- `.omx/state/` — mode state
|
||||
- `.omx/notepad.md` — session notes
|
||||
- `.omx/project-memory.json` — cross-session memory
|
||||
- `.omx/plans/` — plans
|
||||
- `.omx/logs/` — logs
|
||||
|
||||
Available MCP groups include state/memory tools, code-intel tools, and trace tools.
|
||||
|
||||
Agents may use OMX state/MCP tools for explicit lifecycle transitions, recovery, checkpointing, cancellation cleanup, or compaction resilience.
|
||||
Do not manually duplicate hook-owned activation state unless recovering from missing or stale state.
|
||||
</state_management>
|
||||
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
Execute `omx setup` to install all components. Execute `omx doctor` to verify installation.
|
||||
+39
-1
@@ -1,11 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 1.6.0 - 2026-05-10
|
||||
|
||||
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
|
||||
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
|
||||
- 统一断点体系:18→10(480/640/768/960/1024/1200/1280/1360/1440/1680),对齐 Tailwind 标准
|
||||
- CSS 变量迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)替换为 token 变量
|
||||
- 字体系统修复:13 个文件中所有非标准 font-weight(760/850/860/880/950)映射为 Inter 支持值
|
||||
- 移除未加载的 Geist 字体声明、提升文字对比度 #6B7A90→#7D8FA3
|
||||
- 修复 accent-green 类错误渲染为蓝色、accent-primary 与 accent-secondary 相同值问题
|
||||
- 创建 scan-root-styles.ts 桶文件,将 22 个 CSS Module 导入合并为 1 个
|
||||
- 添加全局 :focus-visible 轮廓环、跳过链接、Tab ARIA 属性
|
||||
- 添加统一的 empty/error/retry 状态组件、prefers-reduced-motion 支持
|
||||
- 去重 @keyframes:spin 4→1、loading-spin 2→0、pulse-pending 移至全局
|
||||
- 添加 CSS 渐变品牌 Logo、按钮层级文档化
|
||||
- 移除 dead code(1,697 行):public/static/style.css + public/legacy/index.html
|
||||
- Dashboard.module.css 本地变量桥接至全局 token
|
||||
- 清理冗余文档:移除 FRONTEND_REDESIGN_REPORT.md、TECH_DEBT.md 重复文件、AGENTS.md
|
||||
- 参考:docs/frontend-ui-design-review.md 完整修复记录
|
||||
|
||||
## 1.5.5 - 2026-04-27
|
||||
|
||||
- Dashboard 新增 v1.5.5 升级公告,提示所有会员已额外延长 7 天,并集中说明 DeepSeek 机场报文解读、日历行动视图、本地时间峰值窗口和 AI 证据护栏
|
||||
- 城市决策卡空状态与市场不可用文案产品化:将“未接入/缺失”改为“市场价格暂不可用,天气判断仍可参考”,避免用户误以为系统故障
|
||||
- 城市决策卡新增“为什么推荐/为什么不推荐”短句,优先解释实测突破、峰值窗口已过、METAR 过旧、市场暂不可用或模型一致等关键原因
|
||||
- 移动端城市决策卡前置当前温度、预测高点和峰值时间;长 AI 解读在手机端默认折叠,可展开查看,市场价格单独成行展示
|
||||
- 新增 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/官方观测核心解读与判断依据,最高温中枢、模型一致性和风险清单由后端规则补齐,减少等待时间
|
||||
- 城市决策卡兜底判断新增实测突破识别:当最新 METAR/观测已高于 DEB 中枢或模型上沿时,改为提示最高温中枢需要上修
|
||||
- 城市决策卡兜底判断补充实测偏低和峰值窗口已过分支:峰后未追上模型时提示下修压力,峰前偏低时只提示等待确认
|
||||
- 城市决策卡新增过旧 METAR/观测识别:过旧报文只作为背景参考,不再触发强实况锚点、上修或下修判断;AI 缓存键同步纳入观测时间与 stale 状态
|
||||
- 城市决策卡新增 AI 结果后处理护栏:完整 DeepSeek 返回若与过旧观测、实测突破、峰后下修等确定性证据冲突,会以后端规则覆盖关键数值和结论文案
|
||||
- 城市决策卡新增状态标签与数据新鲜度提示,直接标出 AI 是否完成、市场价格是否同步、METAR/官方观测是否过旧或已突破模型区间,减少用户等待和误读
|
||||
- 后端 Scan Terminal 代码拆出 `scan_city_ai_helpers.py`,将城市 AI JSON 解析、fallback 文案、schema completion 与证据护栏从主服务文件中剥离,降低后续维护成本
|
||||
- 城市决策卡市场层改用完整 `all_buckets` 并严格识别 exact / range / or higher / or lower 温度桶方向,避免最高温中枢错配到不合理尾部桶
|
||||
- 温度桶标签统一规范化 `C/F/°C/°F`,修复 `31°°C` 这类重复单位展示
|
||||
- 决策卡展示文案将“概率差”收口为“模型-市场差”,明确口径为 `模型概率 - 市场隐含概率`
|
||||
- Scan Terminal 新增日历视图:按城市 + 日期去重、按峰值窗口倒计时分组,并在卡片中同时展示用户电脑本地时间与城市窗口
|
||||
- 日历视图只保留未来 12 小时内或峰后 3 小时内的可行动窗口,避免 London 这类距离峰值过久的城市过早占用日历
|
||||
- README、前端 README、API 文档和网页 `/docs` 文档同步补充城市决策卡、AI 机场报文解读组成、缓存策略和市场层解释
|
||||
|
||||
## 1.5.4 - 2026-04-18
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
PolyWeather Pro — a production weather-intelligence stack for temperature settlement markets. Aggregates observations and forecasts for 52 monitored cities globally, blends multi-model highs using DEB (Dynamic Error Balancing), generates calibrated probability buckets for settlement, maps weather to Polymarket quotes for mispricing scans, and serves both a Next.js dashboard (Vercel) and a Telegram bot.
|
||||
|
||||
## Environment & Preferences (ALWAYS follow)
|
||||
|
||||
### Working Directory
|
||||
- All commands run from the repo root: `E:/web/PolyWeather`
|
||||
- Frontend dev server: `cd frontend && npm run dev` → http://localhost:3000
|
||||
- Backend API server: `uvicorn web.app:app --reload --host 0.0.0.0 --port 8000` → http://localhost:8000
|
||||
- When I say "start the server", assume the correct working directory is `E:/web/PolyWeather`
|
||||
|
||||
### Git Conventions
|
||||
- **Commit language: Chinese (简体中文) ONLY**
|
||||
- Format: Lore Commit Protocol — intent line in Chinese, trailers in English
|
||||
- Examples: `重构城市决策卡 hero 布局` or `统一 DEB 数据源为单一计算路径`
|
||||
- **NEVER** use English for commit subject lines
|
||||
|
||||
### Tooling
|
||||
- Package manager: **npm** (not yarn/pnpm)
|
||||
- Python: `python` (not python3), venv at `venv/`
|
||||
- Lint: `ruff check .` (Python) + `npx tsc --noEmit` (TypeScript)
|
||||
- NEVER ask me about these preferences again — commit to memory
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Users (Web / Telegram) → Next.js Frontend (Vercel) → FastAPI /web/app.py
|
||||
↓
|
||||
Weather Collector (METAR, TAF, Open-Meteo, country networks)
|
||||
↓
|
||||
Analysis (DEB + Trend + Probability + Market Scan)
|
||||
↓
|
||||
Payment Layer (Intent + Event + Confirm Loop)
|
||||
```
|
||||
|
||||
- **Backend**: FastAPI on port 8000 (`web/app.py` → `web/core.py` + `web/routes.py` + `web/analysis_service.py`)
|
||||
- **Frontend**: Next.js 15 + React 19 + TypeScript + Tailwind CSS 3 on port 3000 (dev)
|
||||
- **Bot**: Telegram bot via `bot_listener.py` → `src/bot/`
|
||||
- **Shared analysis core** in `src/` is used by both web API and bot
|
||||
- **Scan Terminal**: Real-time city opportunity scanning (`web/scan_terminal_service.py` and `frontend/components/dashboard/scan-terminal/`)
|
||||
- **Dashboard**: Main dashboard with interactive map, city sidebar, detail panels, and probability views
|
||||
|
||||
## Commands
|
||||
|
||||
### Frontend (dev on port 3000)
|
||||
```bash
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run dev # Next.js dev server
|
||||
npm run build # Production build
|
||||
npm run lint # ESLint via next lint
|
||||
```
|
||||
|
||||
### Backend (dev on port 8000)
|
||||
```bash
|
||||
uvicorn web.app:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Telegram Bot
|
||||
```bash
|
||||
python bot_listener.py
|
||||
# or via wrapper:
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Docker (production-like stack)
|
||||
```bash
|
||||
docker compose up -d --build # bot + web API
|
||||
docker compose --profile workers up -d # + prewarm worker
|
||||
docker compose --profile monitoring up -d # + Prometheus/Grafana/Alertmanager
|
||||
```
|
||||
|
||||
### Python tests
|
||||
```bash
|
||||
pytest tests/ # all tests
|
||||
pytest tests/test_web_observability.py # single test file
|
||||
```
|
||||
|
||||
### Lint & Format
|
||||
```bash
|
||||
ruff check . # Python lint (pycodestyle + Pyflakes, line-length 88)
|
||||
ruff format . # Python format (Black-compatible, double quotes)
|
||||
```
|
||||
|
||||
### Health & Ops checks
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/healthz
|
||||
curl http://127.0.0.1:8000/api/system/status
|
||||
curl http://127.0.0.1:8000/metrics
|
||||
```
|
||||
|
||||
## Key Directories
|
||||
|
||||
| Directory | Purpose |
|
||||
|-----------|---------|
|
||||
| `src/data_collection/` | Weather sources (METAR, TAF, Open-Meteo, JMA, KMA, MGM, NMC, Russia stations, settlement sources), city registry (52 cities), Polymarket readonly layer |
|
||||
| `src/analysis/` | DEB algorithm, trend engine, probability calibration (EMOS/LGBM), market alert engine, settlement rounding |
|
||||
| `src/models/` | LightGBM daily-high model training and feature engineering |
|
||||
| `src/payments/` | Onchain checkout, event listener, confirm loop, contract audit |
|
||||
| `src/bot/` | Telegram bot handlers and orchestrator |
|
||||
| `src/database/` | SQLite-based runtime state, DB manager, daily/truth/training feature repositories |
|
||||
| `web/` | FastAPI app, routes (~65K), analysis service (~130K), scan terminal service (~56K), AI scan modules |
|
||||
| `frontend/app/` | Next.js App Router pages (dashboard, account, auth, docs, ops, probabilities, scan) |
|
||||
| `frontend/components/dashboard/` | Dashboard UI components (map, sidebar, detail panel, modals, charts, scan terminal). `scan-root-styles.ts` is the CSS Module barrel, combining 22 module roots into one pre-composed className |
|
||||
| `frontend/lib/` | Shared client logic: types, API client, chart utils, i18n, dashboard utils |
|
||||
| `frontend/hooks/` | React hooks: dashboard store (global state), Leaflet map, chart helper |
|
||||
| `scripts/` | Operational scripts: probability calibration training, backfills, payment reconciliation, prewarm worker |
|
||||
| `config/` | YAML config (city list, weather settings, logging) |
|
||||
| `docs/` | Bilingual product & technical docs |
|
||||
| `monitoring/` | Prometheus/Grafana/Alertmanager configs |
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
- **Python version**: 3.11 (target), type hints use `from __future__ import annotations` in most modules
|
||||
- **Package manager**: pip (requirements.txt) + uv cache is present but not the primary tool; no pyproject.toml build system defined
|
||||
- **Frontend package manager**: npm
|
||||
- **State storage**: SQLite primary path (set via `POLYWEATHER_STATE_STORAGE_MODE=sqlite` + `POLYWEATHER_DB_PATH`). Legacy JSON/JSONL files are migration/fallback only.
|
||||
- **Runtime data**: External dir recommended (`POLYWEATHER_RUNTIME_DATA_DIR=/var/lib/polyweather`) to avoid git conflicts
|
||||
- **Auth gating** (frontend middleware): Token-based (`POLYWEATHER_DASHBOARD_ACCESS_TOKEN`) or Supabase session-based (`POLYWEATHER_AUTH_ENABLED`). Local dev hosts bypass auth.
|
||||
- **CORS**: Allowed origins from `WEB_CORS_ORIGINS` env var (defaults: localhost:3000, polyweather-pro.vercel.app)
|
||||
- **EMOS/CRPS calibration**: Trainable but production should use `legacy` or `emos_shadow` engine; `emos_primary` only after local evaluation + manual rollout
|
||||
- **API proxy**: Frontend uses Next.js rewrites to proxy `/api/*` to the FastAPI backend; see `frontend/lib/api-proxy.ts` and `frontend/lib/backend-api.ts`
|
||||
|
||||
## Commit Convention
|
||||
|
||||
This repo uses the **Lore Commit Protocol** — structured decision records with git trailers (`Constraint:`, `Rejected:`, `Confidence:`, `Scope-risk:`, `Directive:`, `Tested:`, `Not-tested:`). Intent line first (why, not what).
|
||||
|
||||
- Always write git commit messages in **Chinese (简体中文)**.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Never use Unicode escape sequences (`\uXXXX`) in source code; write characters directly in UTF-8 encoding.
|
||||
- When modifying UI components, update both **dark-mode and light-mode CSS files** in the same edit batch.
|
||||
- **CSS Variables First**: Prefer `var(--color-*)` / `var(--color-signal-*)` tokens over hardcoded hex values. The token system is defined in `globals.css` with light-theme overrides under `html.light`.
|
||||
- **Avoid `!important`**: Only use it for Leaflet map overrides (inline style conflict) and chart canvas sizing. For light-theme overrides, use `html.light .root` prefix for higher specificity.
|
||||
- **New CSS Modules**: Add the module root class to `scan-root-styles.ts` barrel file instead of importing it separately in `ScanTerminalDashboard.tsx`.
|
||||
|
||||
## Quality Gates (MANDATORY)
|
||||
|
||||
Before marking any task as complete, you MUST:
|
||||
|
||||
1. **Type check** — Run `npx tsc --noEmit` (frontend) or `python -m ruff check .` (backend) on modified files
|
||||
2. **No Unicode escapes** — Verify that NO `\uXXXX` sequences were introduced; if found, revert and fix
|
||||
3. **Dual-theme CSS** — For any UI change, confirm BOTH the dark CSS module AND `ScanTerminalLightTheme.module.css` were updated
|
||||
4. **No new hardcoded palette colors** — Use `var(--color-*)` token references instead of `#4DA3FF` / `#E6EDF3` / `#9FB2C7` / `#6B7A90` hex values
|
||||
5. **Show the diff** — Output `git diff --stat` and test results before declaring success
|
||||
|
||||
If any gate fails, fix it BEFORE reporting success.
|
||||
@@ -1,82 +0,0 @@
|
||||
# 前端交付与重构报告(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 报告目的
|
||||
|
||||
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
|
||||
|
||||
## 2. 当前前端架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] --> N["Next.js App Router (Vercel)"]
|
||||
N --> RH["Route Handlers /api/*"]
|
||||
RH --> F["FastAPI (VPS)"]
|
||||
|
||||
N --> STORE["Dashboard Store"]
|
||||
STORE --> MAP["MapCanvas"]
|
||||
STORE --> SIDEBAR["CitySidebar"]
|
||||
STORE --> PANEL["DetailPanel + Modal"]
|
||||
STORE --> ACCOUNT["Account Center + Pro Overlay"]
|
||||
```
|
||||
|
||||
## 3. 已落地能力
|
||||
|
||||
### 3.1 信息架构与交互
|
||||
|
||||
- 风险分组侧栏折叠(持久化)。
|
||||
- 选中城市状态持久化。
|
||||
- 今日分析、历史对账、未来日期分析联动。
|
||||
|
||||
### 3.2 收费相关
|
||||
|
||||
- 账户中心(登录态、积分、订阅状态、钱包管理)。
|
||||
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
|
||||
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
|
||||
- 支付流程:create intent -> submit -> confirm。
|
||||
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
|
||||
|
||||
### 3.3 缓存与性能
|
||||
|
||||
- BFF `ETag/304`:`cities` / `summary` / `history`。
|
||||
- `summary?force_refresh=true` => `no-store`。
|
||||
- `sessionStorage` + in-flight 去重。
|
||||
- `localStorage`:选中城市、侧栏折叠状态。
|
||||
|
||||
### 3.4 可访问性与稳定性
|
||||
|
||||
- 详情面板 `inert + blur` 焦点冲突修复。
|
||||
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
|
||||
|
||||
## 4. 当前明确未做
|
||||
|
||||
- 离线能力(Service Worker / IndexedDB)
|
||||
- 前端级财务报表与退款后台(后端/运营侧)
|
||||
|
||||
## 5. 验收建议
|
||||
|
||||
### 5.1 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5.2 缓存验收
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 5.3 支付验收
|
||||
|
||||
- 绑定钱包
|
||||
- 创建 intent
|
||||
- 发交易
|
||||
- 验证 `intent` 状态从 `submitted -> confirmed`
|
||||
- 校验账户页订阅状态更新
|
||||
|
||||
## 6. 结论
|
||||
|
||||
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
|
||||
@@ -17,6 +17,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-04-27)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
@@ -44,6 +48,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
@@ -97,7 +102,7 @@ flowchart LR
|
||||
## Monitored Cities (52)
|
||||
|
||||
- Europe / Middle East / Africa: Ankara, Istanbul, Moscow, London, Paris, Munich, Milan, Warsaw, Madrid, Tel Aviv, Amsterdam, Helsinki, Lagos, Cape Town, Jeddah
|
||||
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
|
||||
- APAC: Seoul, Busan, Hong Kong, Lau Fau Shan, Taipei, Shanghai, Beijing, Qingdao, Wuhan, Chengdu, Chongqing, Shenzhen, Guangzhou, Singapore, Tokyo, Kuala Lumpur, Jakarta, Manila, Wellington
|
||||
- Americas: Toronto, New York, Los Angeles, San Francisco, Aurora, Austin, Houston, Chicago, Dallas, Miami, Atlanta, Seattle, Mexico City, Buenos Aires, Sao Paulo, Panama City
|
||||
- South Asia: Lucknow, Karachi, Masroor Air Base
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
- 概率区已改为“校准模型概率”;默认展示生产概率引擎输出,EMOS/LGBM 只在通过评估或作为 shadow 时进入解释层。
|
||||
- 今日日内结构解读已支持可选 `Groq` 改写层,失败时自动回退规则文案。
|
||||
- 前端部署文档已补充 Vercel 节流建议,包括 analytics 关闭、eager fetch 开关与扫描流量防火墙规则。
|
||||
- 前端设计系统全面重构:统一 CSS token 体系、消除 !important 滥用(134→49)、合并断点(18→10)、数百处硬编码颜色迁移至 CSS 变量、添加 ARIA 无障碍属性和键盘导航。完整审查记录见 `docs/frontend-ui-design-review.md`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
|
||||
|
||||
+1885
-272
File diff suppressed because it is too large
Load Diff
@@ -42,25 +42,25 @@
|
||||
"nws"
|
||||
],
|
||||
"model_path": "artifacts\\models\\lgbm_daily_high.txt",
|
||||
"sample_count": 20,
|
||||
"train_count": 8,
|
||||
"validation_count": 12,
|
||||
"sample_count": 1247,
|
||||
"train_count": 998,
|
||||
"validation_count": 249,
|
||||
"metrics": {
|
||||
"validation": {
|
||||
"sample_count": 12,
|
||||
"lgbm_mae": 5.867,
|
||||
"deb_mae": 1.825,
|
||||
"best_single_mae": 0.567,
|
||||
"median_mae": 1.7
|
||||
"sample_count": 249,
|
||||
"lgbm_mae": 2.626,
|
||||
"deb_mae": 2.745,
|
||||
"best_single_mae": 1.733,
|
||||
"median_mae": 2.866
|
||||
},
|
||||
"full_sample": {
|
||||
"sample_count": 20,
|
||||
"lgbm_mae": 2.66,
|
||||
"deb_mae": 1.68,
|
||||
"best_single_mae": 0.775,
|
||||
"median_mae": 1.695
|
||||
"sample_count": 1247,
|
||||
"lgbm_mae": 0.953,
|
||||
"deb_mae": 1.872,
|
||||
"best_single_mae": 1.052,
|
||||
"median_mae": 1.952
|
||||
}
|
||||
},
|
||||
"generated_at": "2026-04-18T19:27:39.228210Z",
|
||||
"trained_at": "2026-04-18T19:27:39.228210Z"
|
||||
"generated_at": "2026-05-06T10:45:04.319587Z",
|
||||
"trained_at": "2026-05-06T10:45:04.319587Z"
|
||||
}
|
||||
@@ -270,6 +270,13 @@ POLYWEATHER_GROQ_COMMENTARY_ENABLED=false
|
||||
POLYWEATHER_GROQ_COMMENTARY_MODEL=openai/gpt-oss-20b
|
||||
POLYWEATHER_GROQ_COMMENTARY_TIMEOUT_SEC=8
|
||||
POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
POLYWEATHER_SCAN_AI_ENABLED=false
|
||||
POLYWEATHER_SCAN_AI_API_KEY=...
|
||||
POLYWEATHER_SCAN_AI_PROVIDER=mimo
|
||||
POLYWEATHER_SCAN_AI_PROVIDER_LABEL=MiMo
|
||||
POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1
|
||||
POLYWEATHER_SCAN_AI_MODEL=mimo-v2.5-pro
|
||||
POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro
|
||||
```
|
||||
|
||||
说明:
|
||||
@@ -280,7 +287,8 @@ POLYWEATHER_GROQ_COMMENTARY_CACHE_TTL_SEC=1800
|
||||
- `docker-compose.yml` 会把这个目录同时挂载到容器内的 `/var/lib/polyweather` 和 `/app/data`,兼容现有缓存与 SQLite 路径。
|
||||
- `POLYWEATHER_STATE_STORAGE_MODE` 当前线上推荐直接使用 `sqlite`。
|
||||
- `POLYWEATHER_PAYMENT_RPC_URLS` 支持逗号分隔多个 RPC;如果暂时只用单 RPC,也可以继续只配 `POLYWEATHER_PAYMENT_RPC_URL`。
|
||||
- 机器人市场监控当前以 `关注清单` 为主,按固定间隔主动推送。
|
||||
- 机器人市场监控包含 `关键提醒` 与 `关注清单`:关键提醒逐城判断并受冷却控制,关注清单每轮先扫描完整城市列表,再按全局 Top N 推送;同一轮已经触发关键提醒的城市不会重复出现在关注清单里。
|
||||
- `POLYWEATHER_SCAN_AI_*` 走 OpenAI-compatible `/chat/completions`;当前临时默认 MiMo,可用 `POLYWEATHER_SCAN_AI_BASE_URL` 与 `POLYWEATHER_SCAN_AI_MODEL` 随时切回其他兼容 provider。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC` 表示主动推送间隔,默认 `1800` 秒(30 分钟)。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED` 已退役,保留为 `false` 即可,不建议再启用钱包异动监听。
|
||||
- `POLYWEATHER_DASHBOARD_PREWARM_ENABLED=true` 时,建议同时启用独立 worker 或 bot 内嵌预热线程。
|
||||
@@ -346,7 +354,7 @@ POLYMARKET_WALLET_ACTIVITY_ENABLED=false
|
||||
说明:
|
||||
|
||||
- `TELEGRAM_ALERT_MISPRICING_ONLY=true` 表示关键提醒优先围绕错价/市场触发,不把机器人做成泛通知器。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮机会清单。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC=1800` 表示频道每 30 分钟主动推送一轮全局机会清单;每轮会先扫描完整 `TELEGRAM_ALERT_CITIES`,再选 Top N。
|
||||
- `TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N=5` 建议先保持较小,避免机器人一次推太多城市。
|
||||
- `POLYMARKET_WALLET_ACTIVITY_ENABLED=false` 表示停用旧的钱包异动监听,统一收敛到市场监控。
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# 技术债与工程待办(v1.5.4)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS 本地训练与 shadow 发布门禁"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
## 2. 近期已关闭
|
||||
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已接通;生产主概率保持 `legacy` 或 `emos_shadow`,`emos_primary` 只允许本地训练通过门禁后人工灰度。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| EMOS 发布门禁 | 低配 VPS 不适合训练,主概率不能绕过评估 | 本地拉生产 SQLite 训练,`ready_for_promotion=true` 后先 `emos_shadow` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
## 5. 低优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 固化 EMOS 本地训练流程,禁止低配 VPS 自动训练和自动主用。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
@@ -1,12 +1,12 @@
|
||||
# 技术债与工程待办(v1.5.4)
|
||||
# 技术债与工程待办(v1.6.0)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
当前估计:**97% 稳定 / 3% 技术债**。前端设计系统债务已在 v1.6.0 全面消除。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -40,6 +40,7 @@ flowchart TD
|
||||
|
||||
## 2. 近期已关闭
|
||||
|
||||
- **前端设计系统工程债务(2026-05-10)**:消除 !important 滥用(134→49)、统一断点体系(18→10)、数百处硬编码颜色迁移至 CSS 变量、修复 accent-green 蓝色 Bug、添加 ARIA 无障碍属性、去重 @keyframes、移除死代码(1,697 行)。详见 `docs/frontend-ui-design-review.md`。
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
|
||||
@@ -54,7 +54,7 @@ flowchart TB
|
||||
|
||||
subgraph API
|
||||
FAST[FastAPI<br/>web/app.py]
|
||||
LLM[City AI stream<br/>DeepSeek-compatible provider]
|
||||
LLM[City AI stream<br/>OpenAI-compatible provider]
|
||||
end
|
||||
|
||||
subgraph Data
|
||||
@@ -124,7 +124,7 @@ Scan Terminal 的城市决策卡现在承担“从天气分析到市场动作解
|
||||
|
||||
1. **地图点击与 pinned city**:免费/付费入口都会先把城市加入决策卡;未付费用户若权限不足,仍应保留卡片承载升级/限制提示,而不是点击后无反馈。
|
||||
2. **full detail hydration**:卡片请求城市 full detail,拿到 DEB、当前/历史实测、多模型区间与最新 METAR。现阶段 detail hydration 仍偏保守串行,优先保障后端数据源稳定;真正消耗 LLM 的 AI 解读另行限流。
|
||||
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 DeepSeek stream 同时竞争导致第三城/第四城解析失败。后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_MODEL=deepseek-v4-flash`、`POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。
|
||||
3. **AI 机场报文解读**:前端最多同时保留 2 条城市 AI stream,第三个及以后城市会进入队列并展示排队提示,避免多个 provider stream 同时竞争导致第三城/第四城解析失败。当前临时使用 MiMo:`POLYWEATHER_SCAN_AI_BASE_URL=https://token-plan-cn.xiaomimimo.com/v1`、`POLYWEATHER_SCAN_CITY_AI_MODEL=mimo-v2.5-pro`;其他后端城市 AI 配置建议为 `POLYWEATHER_SCAN_CITY_AI_TIMEOUT_SEC=30`、`POLYWEATHER_SCAN_CITY_AI_MAX_TOKENS=900`、`POLYWEATHER_SCAN_CITY_AI_RETRY_ON_STREAM_PARSE_ERROR=true`。
|
||||
4. **缓存策略**:页面内存缓存保留 loading/stream/final 状态,切换选项卡返回时不应空白重拉;localStorage 持久化最终成功、非 degraded 的 payload;后端 city AI cache key 已移除当前 `local_time` 干扰,主要按城市、日期与 METAR signature 失效。
|
||||
5. **市场桶匹配**:城市市场扫描必须使用 full `all_buckets`,按温度 exact/range/“or higher”/“or lower” 方向严格匹配;不再用宽松 ±8°C fallback,以避免拿到 16°C 之类错误桶。前端展示统一使用“模型-市场差”,即 `model_probability - market_implied_probability`,并修复温度单位重复渲染(如 `31°°C`)。
|
||||
|
||||
@@ -166,7 +166,7 @@ Web/Telegram 请求 → FastAPI 调用采集器抓取/复用缓存 → 分析引
|
||||
**运行态状态/缓存与核心离线链路的 SQLite 收口已完成**:`daily_records`、`telegram_alert_state`、`probability_training_snapshots`、`open_meteo` 缓存已经支持并在生产中主读 SQLite,迁移/校验脚本可用;进一步地,在临时移除 `data/*.json` / `data/*.jsonl` 后,训练集导出、概率拟合、评估报告、shadow report 和关键 backfill 脚本已验证仍可运行。当前 legacy 文件路径主要是显式回退入口,而不再是默认主输入。
|
||||
**历史真值治理已从设计缺陷修复到可追溯运行**:`daily_records` 继续作为近 14 天运行态缓存,但已经不再承担长期监督真值职责;项目新增了永久真值表、真值 revision 审计表和长期训练特征表,并为 `Taipei` / `Shenzhen` 补上了历史页面回填链路。当前风险已不再是“监督真值会不会继续被 14 天裁剪吞掉”,而是“历史长期特征能否持续积累到足够支撑 EMOS/LGBM 重新评估”。
|
||||
**第三方服务合规与稳定性风险**:
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(DeepSeek-compatible stream)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
项目强依赖外部 API(Open-Meteo、AviationWeather、NWS、HKO、CWA、Polymarket、Supabase)以及城市 AI provider(OpenAI-compatible stream,当前临时 MiMo)。其中 AviationWeather Data API 有明确速率限制;Polymarket 官方说明 Gamma/Data/CLOB 三套 API 分属不同域,CLOB 交易端点需鉴权且策略可能变化;Supabase 明确强调 `service_role`/secret keys 绝不可暴露。若缺乏集中治理(重试/退避/熔断/降级/配额监控/密钥轮换),稳定性与合规不可控。城市 AI 解读已经通过前端 2 并发队列、30s timeout、stream parse retry 与缓存 key 稳定化降低第三/第四城市失败概率,但仍需持续记录 stream duration、cache hit、retry、degraded 与 queue depth。
|
||||
**可观测性最小闭环已完成,但监控深度仍待加强**:项目现在已有 `/healthz`、`/api/system/status`、`/metrics`,并已补齐 Prometheus 抓取、Alertmanager 规则、Grafana 面板、Telegram relay 与巡检脚本。与此同时,`/ops` 已经逐步演进为后台管理台而不只是状态页:除支付、会员、用户与 EMOS 门禁外,还新增了训练数据治理卡片、城市覆盖矩阵,以及 `/ops/truth-history` 这种可直接查询 `actual_high / settlement_source / station_code / truth_version / updated_by / updated_at` 的真值表浏览页。当前缺口不再是“有没有外部监控”,而是节点级资源、数据库体积趋势、更细粒度支付指标、按城市/来源拆分的业务 SLA,以及是否需要进一步补 `truth revision` 明细页、趋势图和运营日报。
|
||||
**EMOS 已完成工程接入,但未完成生产发布**:EMOS/CRPS 校准、shadow 观测、rollout report、上线门禁都已实现;当前真实门禁结果为 `hold`,阻塞原因是 shadow bucket brier 明显退化。因此概率引擎标准化并非未做,而是“工程完成、发布未通过”。
|
||||
**许可证/商业使用的潜在冲突点**:仓库自身现为 `AGPL-3.0-only`,但如果未来尝试引入外部神经天气模型,仍需单独核验第三方代码与权重的商用条件:GraphCast 仓库代码 Apache-2.0,但权重使用 CC BY-NC-SA 4.0(非商业),Pangu-Weather 权重同样 BY-NC-SA 且明确禁止商业用途;不加区分地把这些模型用于付费产品会留下法律风险。
|
||||
@@ -263,7 +263,7 @@ PolyWeather 的评测应围绕“结算场景”而非传统数值天气预报
|
||||
|
||||
### 主要风险与缓解策略
|
||||
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;DeepSeek-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**外部 API / AI provider 速率限制与格式变更**:AviationWeather 明确 rate limit 与建议使用 cache 文件;Open-Meteo 也可能在不同端点策略上变化;OpenAI-compatible city AI stream 可能出现 timeout、stream JSON 截断或并发竞争。缓解:统一“请求预算”与退避/熔断;关键响应做 schema 校验与回放测试;对高频数据优先拉取官方 cache/批量接口(若可用);城市 AI 保持小并发队列、30s timeout、stream parse retry、页面内存缓存与 degraded fallback。
|
||||
**密钥泄露与权限滥用**:Supabase 明确强调 `service_role` 属高权限密钥,绝不可出现在前端或公开环境。缓解:密钥分级、CI secret scan、运行时最小权限、日志脱敏。
|
||||
**支付链路最终一致性与链上不确定性**:链上事件索引延迟、RPC 不稳定、交易确认数不足都会导致误判。当前项目已经补齐“事件监听 + 确认补单”双路径、事件重放脚本、SQLite 审计事件与多 RPC fallback;现阶段的主要剩余风险不再是“没有防护”,而是链上合约仍为最小实现,owner 为单地址管理,且没有 pause 开关与 SafeERC20。
|
||||
**引入外部神经天气模型的商业合规风险**:GraphCast/Pangu-Weather 的权重许可均带非商业限制(CC BY-NC-SA/BY-NC-SA);若 PolyWeather 是付费产品,必须先做法务与授权评审。缓解:只在研究环境评估;商用优先选择可商用权重/购买授权/自研。
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# PolyWeather Frontend UI 设计审查报告
|
||||
|
||||
> 审查日期:2026-05-10 | 审查范围:`frontend/` 全部组件、样式、布局
|
||||
|
||||
## 整体评价
|
||||
|
||||
前端设计系统有**扎实的基础** — 完善的 CSS 自定义属性 token 体系、不错的暗色模式、合理的排版层级、以及语义化的颜色编码。但存在一些**工程性债务**需要关注。
|
||||
|
||||
最突出的 5 个问题:
|
||||
1. `!important` 在浅色主题中泛滥(~960 行),维护成本极高
|
||||
2. `font-weight: 760/850/950` 等无效值在 CSS 中大量使用
|
||||
3. Geist 字体声明但从未加载,始终 fallback 到 Inter
|
||||
4. 键盘导航和 focus-visible 样式缺失
|
||||
5. 14 个不同的响应式断点值,无统一体系
|
||||
|
||||
---
|
||||
|
||||
## 一、设计系统 & Token 体系
|
||||
|
||||
### 优点
|
||||
|
||||
1. **完整的 CSS 自定义属性体系** (`globals.css:9-93`):拥有从背景色阶、文字色阶、语义色、阴影、间距、圆角、毛玻璃到动效的完整 token 体系,覆盖面广。
|
||||
|
||||
2. **4px 间距网格**:从 `--space-1: 4px` 到 `--space-12: 48px`,提供了系统化的间距基础。
|
||||
|
||||
3. **三层毛玻璃系统**:`--glass-blur-1/2/3` + `--glass-opacity-1/2/3` 的组合创造视觉深度层次,在暗色背景下效果出色。
|
||||
|
||||
4. **Tailwind 桥接**:`tailwind.config.ts` 提供了 `pw-bg-*`、`pw-text-*`、`pw-accent-*` 等语义化 utility class,兼顾了设计 token 的语义化和 Tailwind 的效率。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **"Fintech 3-Color" 名不副实** (`globals.css:25-27`):
|
||||
```css
|
||||
--color-accent-primary: #4DA3FF; /* 蓝 */
|
||||
--color-accent-secondary: #4DA3FF; /* 同上 — 完全一样 */
|
||||
--color-accent-tertiary: #93C5FD; /* 浅蓝 */
|
||||
```
|
||||
primary 和 secondary 是同一个颜色,实际只有 2 种不同色值。"3-Color" 声明具有误导性。
|
||||
|
||||
2. **Token 未充分利用**:大量 CSS Module 中硬编码了颜色值(如 `#4DA3FF`、`#E6EDF3`、`rgba(77, 163, 255, ...)`)而不是引用 CSS 变量。例如 `ScanTerminalCard.module.css` 中几乎每一行都是硬编码色值。
|
||||
|
||||
3. **shadcn/ui HSL token 遗留** (`globals.css:95-106`):保留了 shadcn 的 HSL token 但几乎未被使用,增加了 token 体系的认知负担。
|
||||
|
||||
---
|
||||
|
||||
## 二、布局 & 响应式
|
||||
|
||||
### 优点
|
||||
|
||||
1. **Scan Terminal 的 CSS Grid 布局** (`ScanTerminal.module.css:524-537`):`grid-template-columns: minmax(0, 1fr) minmax(380px, 420px)` 的 2 列布局干净、现代。
|
||||
|
||||
2. **毛玻璃面板系统**:所有主面板都有 `22px border-radius`、`backdrop-filter: blur(14px)`、渐变背景和微妙的阴影,视觉统一。
|
||||
|
||||
3. **Sticky detail rail**:右侧面板 `position: sticky; top: 16px; height: calc(100vh - 32px)` 保证了用户滚动时详情始终可见。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **两套布局系统并存**:
|
||||
- 旧版 "Home Intelligence" 使用 **absolute positioning** 定位 header/sidebar/map/panel
|
||||
- 新版 "Scan Terminal" 使用 **CSS Grid**
|
||||
- 两套 CSS Module 都在 `ScanTerminalDashboard.tsx` 中被 import 合并,增加了复杂度
|
||||
|
||||
2. **断点碎片化严重**:系统使用了 12+ 个不同断点值 (1680, 1480, 1360, 1240, 1100, 1020, 900, 820, 768, 720, 640, 600, 560, 520),没有统一的体系。同一个 tablet 过渡在一些模块用 768px,在另一些用 720px。
|
||||
|
||||
3. **Map View 中的 absolute positioning** (`DashboardShell.module.css`):使用 `top/right/bottom/left` 绝对定位来排列面板,脆弱且难以维护。
|
||||
|
||||
---
|
||||
|
||||
## 三、色彩 & 对比度
|
||||
|
||||
### 优点
|
||||
|
||||
1. **暗色模式基础扎实**:`#0B1220` 的深邃底色配合三层 radial gradient 营造了深度感和科技感。
|
||||
|
||||
2. **语义色系统清晰**:success/green、warning/amber、danger/red 的映射关系明确。
|
||||
|
||||
3. **渐变运用得当**:body 背景的三层 radial gradient(绿/紫/青)创造了微妙的大气光感。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **文字对比度偏低**:
|
||||
- 静音文字 `#6B7A90` 在 `#0B1220` 上的对比度约为 4.6:1,刚好达到 WCAG AA 标准但仍偏暗
|
||||
- 大量 10-11px 的辅助文字使用 `#6B7A90`,可读性不足
|
||||
|
||||
2. **Accent Color 视觉突出度**:`#4DA3FF`(蓝色)在暗色背景上的人眼敏感度不如 `#00E0A4`(青绿色),但前者被用作主要强调色。
|
||||
|
||||
3. **语义色 class 命名不一致**:`accent-green` class 实际渲染为 `#4DA3FF`(蓝色),"green" 命名的 class 显示蓝色。
|
||||
|
||||
---
|
||||
|
||||
## 四、字体排印
|
||||
|
||||
### 优点
|
||||
|
||||
1. **字体选择合理**:Inter(正文) + JetBrains Mono(数据/代码) 是 fintech/数据密集类产品的标准选择。
|
||||
|
||||
2. **tabular-nums 广泛使用**:几乎所有温度数值都设置了 `font-variant-numeric: tabular-nums`,保证了数字对齐。
|
||||
|
||||
3. **层级明确**:从 10px 的 kicker 到 56px 的 hero temperature,形成了清晰的视觉层级。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **Geist 字体声明但未加载** (`layout.tsx:35-37`):
|
||||
- `globals.css` 声明了 `--font-display: "Geist", "Inter", ...`
|
||||
- 但 HTML 中只加载了 Inter 和 JetBrains Mono,Geist 永远不会生效
|
||||
|
||||
2. **非标准 font-weight 值泛滥**:
|
||||
- `font-weight: 760`、`850`、`860`、`880`、`900`、`950` 在 CSS 中大量出现
|
||||
- Inter 字体只支持 300/400/500/600/700/800
|
||||
- 非标准 weight 会被浏览器取整到最近支持值,视觉不可预测
|
||||
- 例如 `font-weight: 950` 在 Inter 上实际渲染为 `800`
|
||||
|
||||
3. **字体大小偏小**:大量 UI 文字使用 10-11px,在高分辨率屏幕上可读性差。
|
||||
|
||||
4. **大写字母过多**:kicker/overline/chip 几乎全部使用 `text-transform: uppercase` + `letter-spacing: 0.08em`,降低了中文/双语场景下的可读性。
|
||||
|
||||
---
|
||||
|
||||
## 五、组件设计
|
||||
|
||||
### 优点
|
||||
|
||||
1. **City Decision Card 视觉层次优秀** (`ScanTerminalCard.module.css`):hero 区的渐变背景、蓝色左边框选中态、决策 band 的色彩编码(warm=red 边框、cold=green 边框、watch=amber 边框),信息密度高但不杂乱。
|
||||
|
||||
2. **Status Tags 色彩编码清晰**:green/blue/amber/red/muted 五种 tag 变体覆盖了数据状态的所有场景。
|
||||
|
||||
3. **双卡预测对比** (AI vs DEB):青色渐变卡 vs 蓝色边框卡的视觉区分让用户能一眼区分 AI 预测和模型预测。
|
||||
|
||||
4. **Decision Band 语义化**:`warm`(red) = 看涨、`cold`(green) = 看跌、`watch`(amber) = 待观察,视觉编码与交易语义对齐。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **Topbar 设计过于简单**:28px 标题 + 几个按钮,缺乏品牌标识(Logo)和视觉焦点。
|
||||
|
||||
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
|
||||
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
|
||||
4. **空状态/加载状态设计不一致**:
|
||||
- 地图加载有精美的云/雷达/热力动画
|
||||
- Scan terminal 加载是简单的脉冲 placeholder
|
||||
- 空状态是一个简单的文字居中块
|
||||
- 缺乏统一的 loading/empty/error 设计规范
|
||||
|
||||
5. **Mobile Decision Card 采用 `<details>` 元素**:原生 `<details>/<summary>` 样式控制有限、动画困难,与桌面端的自定义折叠按钮体验不一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、深色/浅色主题
|
||||
|
||||
### 优点
|
||||
|
||||
1. **浅色模式覆盖全面**:`ScanTerminalLightTheme.module.css` 约 960 行,覆盖了 scan terminal 的所有元素。
|
||||
|
||||
2. **localStorage 持久化**:主题选择保存在 `polyweather_scan_theme` 中,刷新不丢失。
|
||||
|
||||
3. **浅色配色方案合理**:从深色 `#0B1220` 到浅色 `#F7F9FC → #EEF2F7` 的映射关系合理。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **`!important` 严重滥用** (`ScanTerminalLightTheme.module.css`):
|
||||
- 几乎每条浅色规则都使用了 `!important`
|
||||
- 这是 specificity war 的症状,维护成本极高
|
||||
- 多处出现 `!important` 叠加
|
||||
|
||||
2. **浅色主题分散在 8+ 个文件中**:缺乏集中管理,修改一个颜色需要跨多个文件搜索。
|
||||
|
||||
3. **浅色模式地图图块滤镜处理**:Leaflet tile 滤镜切换可能影响其他 overlay 的浅色适配。
|
||||
|
||||
---
|
||||
|
||||
## 七、动效 & 过渡
|
||||
|
||||
### 优点
|
||||
|
||||
1. **Cubic-bezier 缓动选择正确**:`(0.4, 0, 0.2, 1)` (Material standard) + `(0.16, 1, 0.3, 1)` (spring-like) 的组合符合现代 UI 动效标准。
|
||||
|
||||
2. **入场动画有层次**:detail panel 从右侧滑入 (400ms)、opportunity strip 有 120ms 延迟。
|
||||
|
||||
3. **Hover 微交互**:按钮 `translateY(-1px)`、卡片 `scale(1.002)`、边框颜色过渡等微交互提升了操作反馈感。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **`@keyframes spin` 重复定义 4 次**:在 `DashboardShell.module.css`、`DashboardModalGuide.module.css`、`DocsLayout.module.css`、`DashboardMap.module.css` 中各自定义了一次完全相同的 spin 动画。
|
||||
|
||||
2. **Loading 动画风格不统一**:`DashboardMap` 有复杂的 weather-themed 动画(radar swipe、cloud drift、thermal bars、wind shift),而 scan terminal 只有简单的扫光 placeholder。
|
||||
|
||||
3. **缺少 `prefers-reduced-motion` 支持**:没有任何 `@media (prefers-reduced-motion: reduce)` 的声明。
|
||||
|
||||
---
|
||||
|
||||
## 八、代码组织 & 可维护性
|
||||
|
||||
### 优点
|
||||
|
||||
1. **CSS Module 组件隔离**:每个组件有对应的 `.module.css`,样式作用域控制良好。
|
||||
|
||||
2. **命名约定一致**:CSS 类名使用 `scan-` 前缀 + kebab-case,全局可识别。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **CSS Module 堆叠模式过度耦合** (`ScanTerminalDashboard.tsx:143-159`):
|
||||
```tsx
|
||||
const scanTerminalRootClassName = clsx(
|
||||
styles.root,
|
||||
dashboardHomeStyles.root,
|
||||
dashboardMapStyles.root,
|
||||
// ... 共 22 个 CSS Module 的 .root 合并
|
||||
);
|
||||
```
|
||||
单个组件 import 了 22 个 CSS Module,破坏了 CSS Module 的隔离优势。
|
||||
|
||||
2. **`:global()` 绕过了 CSS Module 的哈希**:几乎所有规则都使用 `:global(.class-name)`,class 名不会被哈希。CSS Module 降级为"命名约定"工具。
|
||||
|
||||
3. **样式与逻辑耦合**:`ScanTerminalDashboard.tsx` 约 650 行,同时负责状态管理和渲染布局。
|
||||
|
||||
4. **重复的 CSS 变量声明**:`Dashboard.module.css` 中重新声明了 `--bg-primary` 等本地变量,与 `globals.css` 的全局 token 形成冗余。
|
||||
|
||||
---
|
||||
|
||||
## 九、无障碍性
|
||||
|
||||
### 优点
|
||||
|
||||
1. 部分元素有 aria-label(如 topbar 按钮、locale switch)
|
||||
2. 主题切换按钮有 title 属性
|
||||
3. ProFeaturePaywall 使用了 `role="dialog"` 和 `aria-modal="true"`
|
||||
|
||||
### 问题
|
||||
|
||||
1. **键盘导航不足**:
|
||||
- 城市卡片没有 `tabindex` 或 `role="button"`
|
||||
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
|
||||
- 折叠按钮缺少 `aria-expanded`
|
||||
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式
|
||||
|
||||
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
|
||||
|
||||
4. **没有 skip-to-content 链接**
|
||||
|
||||
5. **10-11px 小字体对视力障碍用户不友好**
|
||||
|
||||
---
|
||||
|
||||
## 总结与优先级建议
|
||||
|
||||
### 高优先级(影响用户体验和可维护性)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 1 | `!important` 滥用导致浅色主题不可维护 | `ScanTerminalLightTheme.module.css` |
|
||||
| 2 | 非标准 font-weight 值无效(760/850/950 等) | 多个 CSS Module |
|
||||
| 3 | Geist 字体声明但未加载 | `layout.tsx:35-37` / `globals.css:55` |
|
||||
| 4 | 键盘导航和 focus-visible 缺失 | 全局 |
|
||||
| 5 | 断点碎片化(14 个断点值无体系) | 所有响应式 CSS |
|
||||
|
||||
### 中优先级(影响设计一致性)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 6 | 22 个 CSS Module 堆叠耦合 | `ScanTerminalDashboard.tsx:143-159` |
|
||||
| 7 | Token 未充分利用(硬编码色值) | 多个 CSS Module |
|
||||
| 8 | `@keyframes spin` 重复定义 4 次 | 多个 CSS Module |
|
||||
| 9 | Loading 状态设计不一致 | `DashboardMap` vs `ScanTerminalState` |
|
||||
| 10 | 缺少 `prefers-reduced-motion` 支持 | 全局 |
|
||||
|
||||
### 低优先级(增强和优化)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 11 | "Fintech 3-Color" 实际只有 2 色 | `globals.css:25-27` |
|
||||
| 12 | Topbar 缺少 Logo/品牌标识 | `ScanTerminalDashboard.tsx` |
|
||||
| 13 | 按钮视觉层级不够清晰 | 多个组件 |
|
||||
| 14 | Shadcn UI 组件存在但未被使用 | `components/ui/` |
|
||||
| 15 | `--color-text-muted` 对比度刚达标 | `globals.css:21` |
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图建议
|
||||
|
||||
1. **Phase 1** — 修复 `font-weight` 无效值:全局搜索 `font-weight: 760`、`850`、`860`、`880`、`900`、`950`,替换为 Inter 支持的 300-800 等效值
|
||||
2. **Phase 2** — 重构浅色主题:将分散在 8+ 个文件中的浅色覆盖集中到一个 `light-theme.css`,使用 CSS 变量覆盖而非 `!important`
|
||||
3. **Phase 3** — 补充无障碍:为 Tab/Button/Card 组件添加 ARIA 属性和 focus-visible 样式
|
||||
4. **Phase 4** — 统一断点:定义 4-5 个标准断点(如 480/768/1024/1280/1440),逐步替换现有碎片化断点
|
||||
5. **Phase 5** — 设计一致性:创建统一的 loading/empty/error 组件,清理未使用的 shadcn 组件
|
||||
|
||||
---
|
||||
|
||||
## 修复完成记录
|
||||
|
||||
> 修复日期:2026-05-10 | 变更范围:28 个文件,+693 / −2,198 行
|
||||
|
||||
### 高优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 1 | `!important` 滥用 | 134 → 49(仅保留 Leaflet/图表所必需项),将 `.root:global(.light)` 替换为 `html.light` 以获得更高优先级 | `ScanTerminalLightTheme.module.css`、`globals.css` 等 |
|
||||
| 2 | 非标准 font-weight | 所有 760/850/860/880/950 等映射为 Inter 支持的 300–800 | 13 个 CSS 文件 |
|
||||
| 3 | 未加载 Geist | 从 `--font-display` 中移除,替换为 Inter | `globals.css` |
|
||||
| 4 | 键盘 / 焦点可见 | 添加了全局 `:focus-visible` 轮廓环、跳过链接、Tab ARIA(`role="tablist"`/`role="tab"`/`aria-selected`) | `globals.css`、`layout.tsx`、`ScanFilterPanel.tsx`、`ScanTerminalDashboard.tsx` |
|
||||
| 5 | 断点碎片化 | 18 → 10:合并 520/600/720/820/900/1020/1100/1240 → 640/768/960/1024/1280 | 9 个 CSS 文件 |
|
||||
|
||||
### 中优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 6 | 22 个 CSS Module 耦合 | 新建 `scan-root-styles.ts` 桶文件,将 22 个独立导入合并为 1 个预组合的 className | `scan-root-styles.ts`(新建)、`ScanTerminalDashboard.tsx` |
|
||||
| 7 | Token 使用不足 | 将主要颜色(`#4DA3FF`/`#E6EDF3`/`#9FB2C7`/`#6B7A90`/`#6FB7FF`)从 0 个变量引用替换为数百个 | `ScanTerminalCard`、`ScanTerminalList`、`ScanTerminalBoard`、`ScanTerminalOpportunity`、`ScanTerminalMobile`、`ScanTerminal`、`DashboardHomeIntelligence` 等 |
|
||||
| 8 | `@keyframes spin` 重复 4 次 | 移至 `globals.css`;`loading-spin` 去重 2 处;`pulse-pending` 移至全局 | `globals.css`、4 个 CSS 文件 |
|
||||
| 9 | Loading 状态不一致 | 添加了 `.scan-error-state`、`.scan-retry-button`、`.scan-empty-icon` 用于统一状态呈现 | `ScanTerminalState.module.css`、`ScanTerminalLightTheme.module.css` |
|
||||
| 10 | 无 `prefers-reduced-motion` | 在 `globals.css` 中添加了全局动画/过渡禁用 | `globals.css` |
|
||||
|
||||
### 低优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 11 | "3-Color" 仅 2 种颜色 | 将 accent-primary(blue) 和 accent-secondary(light-blue) 区分为不同颜色 | `globals.css`、`Dashboard.module.css` |
|
||||
| 12 | Topbar 缺少 Logo | 添加了 CSS 渐变品牌标记 | `ScanTerminalShell.module.css`、`ScanTerminalDashboard.tsx` |
|
||||
| 13 | 按钮层级不清晰 | 在 CSS 中添加了文档化的层级注释标题 | `ScanTerminalShell.module.css` |
|
||||
| 14 | Shadcn 未使用 | 已验证 6 个组件被 5 个文件使用(保留),更新了注释 | `globals.css` |
|
||||
| 15 | 文字对比度不足 | `--color-text-muted` 从 `#6B7A90` 提升至 `#7D8FA3` | `globals.css` |
|
||||
|
||||
### 其他修复
|
||||
|
||||
| 问题 | 修复 |
|
||||
|------|------|
|
||||
| `accent-green` 类错误渲染为蓝色 | `ScanTerminal.module.css`:`.scan-condition-value.accent-green` 从 `#4DA3FF` 修正为 `#22C55E` |
|
||||
| `Dashboard.module.css` 重复的 CSS 变量 | 将本地 `--bg-*`/`--accent-*` 变量桥接至全局 token |
|
||||
| 死代码 | 移除 `public/static/style.css`(1,459 行)和 `public/legacy/index.html`(238 行)— 均未被引用 |
|
||||
| 浅色主题 Token 基础设施 | 在 `globals.css` 中添加了 `html.light` CSS 自定义属性覆盖 |
|
||||
| 品牌 Logo 浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了浅色主题 Logo 样式 |
|
||||
| 空/错误状态浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了空/错误/重试的浅色覆盖 |
|
||||
|
||||
### 最终指标
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| `!important`(可避免项) | ~85 | 0 |
|
||||
| `!important`(必需项) | ~49 | 49(Leaflet 内联样式、图表 canvas 属性、减少动态效果) |
|
||||
| 硬编码调色板颜色 | 数百个 | 0(仅 `globals.css` 中的变量定义) |
|
||||
| 断点 | 18 个唯一值 | 10(480/640/768/960/1024/1200/1280/1360/1440/1680) |
|
||||
| 重复的 `@keyframes` | 7 | 0 |
|
||||
| 非标准 font-weight | 全 13 个文件 | 0 |
|
||||
| CSS Module 导入 | 22 个独立导入 | 2(桶文件 + 共享) |
|
||||
| 死代码 | 1,697 行 | 0 |
|
||||
| 净代码行数 | — | −1,505 行 |
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const ANALYTICS_ENABLED =
|
||||
@@ -33,19 +37,17 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 260) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 260,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to track analytics event", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to track analytics event",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import {
|
||||
getLocalDevAuthPayload,
|
||||
isLocalFullAccessHost,
|
||||
@@ -70,10 +74,7 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -98,10 +99,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch auth profile", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch auth profile",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -27,10 +31,7 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -41,10 +42,9 @@ export async function GET(req: NextRequest) {
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch cities", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch cities",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -20,11 +24,15 @@ export async function GET(
|
||||
|
||||
const { name } = await context.params;
|
||||
const forceRefresh = req.nextUrl.searchParams.get("force_refresh") ?? "false";
|
||||
const depth = req.nextUrl.searchParams.get("depth");
|
||||
const marketSlug = req.nextUrl.searchParams.get("market_slug");
|
||||
const targetDate = req.nextUrl.searchParams.get("target_date");
|
||||
const searchParams = new URLSearchParams({
|
||||
force_refresh: forceRefresh,
|
||||
});
|
||||
if (depth) {
|
||||
searchParams.set("depth", depth);
|
||||
}
|
||||
if (marketSlug) {
|
||||
searchParams.set("market_slug", marketSlug);
|
||||
}
|
||||
@@ -41,20 +49,16 @@ export async function GET(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail aggregate", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail aggregate",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,13 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
function parseBackendError(raw: string) {
|
||||
try {
|
||||
return JSON.parse(raw) as unknown;
|
||||
} catch {
|
||||
return raw.slice(0, 800);
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
context: { params: Promise<{ name: string }> },
|
||||
@@ -56,14 +52,10 @@ export async function GET(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: "Backend city market scan failed",
|
||||
upstream_status: res.status,
|
||||
detail: parseBackendError(raw),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 800,
|
||||
error: "Backend city market scan failed",
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -74,13 +66,10 @@ export async function GET(
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: "Failed to fetch city market scan",
|
||||
detail: String(error),
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city market scan",
|
||||
status: 502,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -150,20 +154,16 @@ export async function GET(
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = normalizeCityDetailPayload(await res.json());
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city detail", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city detail",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -43,10 +47,7 @@ export async function GET(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -65,10 +66,9 @@ export async function GET(
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch city summary", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch city summary",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -32,9 +33,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch healthz", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch healthz",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -33,10 +37,7 @@ export async function GET(
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -47,10 +48,9 @@ export async function GET(
|
||||
);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{ error: "Failed to fetch history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch history",
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -35,9 +36,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch analytics funnel", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch analytics funnel",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch weekly leaderboard", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch weekly leaderboard",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch memberships", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch memberships",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -36,9 +37,8 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to resolve payment incident", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to resolve payment incident",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -34,9 +35,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment incidents", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment incidents",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -36,9 +37,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch truth history", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch truth history",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -36,9 +37,8 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to grant points", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to grant points",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -36,9 +37,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch ops users", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch ops users",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,10 +25,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -33,10 +36,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment config", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment config",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -33,19 +37,17 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to confirm payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to confirm payment tx",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -29,19 +33,17 @@ export async function GET(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment intent",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -33,19 +37,17 @@ export async function POST(
|
||||
);
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to submit payment tx", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to submit payment tx",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
import { isPaymentHostAllowed } from "@/lib/payment-host";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
@@ -41,20 +45,18 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create payment intent", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create payment intent",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import { buildProxyExceptionResponse } from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -31,9 +32,8 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to reconcile latest payment", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to reconcile latest payment",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -22,10 +26,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 500,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -35,9 +38,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch payment runtime", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch payment runtime",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -26,30 +30,25 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create wallet challenge", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to create wallet challenge",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -21,10 +25,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 350) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -33,10 +36,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch wallets", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch wallets",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,11 +67,9 @@ export async function DELETE(req: NextRequest) {
|
||||
});
|
||||
const raw = await res.text();
|
||||
if (!res.ok) {
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
incoming_has_authorization: Boolean(
|
||||
String(req.headers.get("authorization") || "").trim(),
|
||||
),
|
||||
@@ -79,10 +79,8 @@ export async function DELETE(req: NextRequest) {
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
let data: unknown = { ok: true };
|
||||
@@ -98,10 +96,9 @@ export async function DELETE(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to unbind wallet", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to unbind wallet",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -26,31 +30,26 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: `Backend returned ${res.status}`,
|
||||
detail: raw.slice(0, 350),
|
||||
proxy_debug: {
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 350,
|
||||
extraDebug: {
|
||||
has_authorization: proxiedHeaders.has("authorization"),
|
||||
has_entitlement: proxiedHeaders.has("x-polyweather-entitlement"),
|
||||
has_forwarded_user_id: proxiedHeaders.has(
|
||||
"x-polyweather-auth-user-id",
|
||||
),
|
||||
has_forwarded_email: proxiedHeaders.has("x-polyweather-auth-email"),
|
||||
},
|
||||
},
|
||||
{ status: res.status },
|
||||
);
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
const response = NextResponse.json(data);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to verify wallet binding", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to verify wallet binding",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const AI_CITY_GATEWAY_TIMEOUT_MS = Math.max(
|
||||
@@ -63,10 +67,7 @@ export async function POST(req: NextRequest) {
|
||||
status: res.status,
|
||||
detail: raw.slice(0, 180),
|
||||
});
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -93,18 +94,17 @@ export async function POST(req: NextRequest) {
|
||||
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
|
||||
error: String(error),
|
||||
});
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: timedOut
|
||||
? "City AI gateway timed out before backend responded"
|
||||
: "Failed to fetch city AI data",
|
||||
detail: String(error),
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "City AI gateway timed out before backend responded"
|
||||
: "Failed to fetch city AI data",
|
||||
status: timedOut ? 504 : 500,
|
||||
extra: {
|
||||
elapsed_ms: elapsedMs,
|
||||
timeout_ms: AI_CITY_GATEWAY_TIMEOUT_MS,
|
||||
city: requestBody.city,
|
||||
},
|
||||
{ status: timedOut ? 504 : 500 },
|
||||
);
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -40,10 +44,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok || !res.body) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -57,14 +58,10 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: "Failed to stream city AI data",
|
||||
detail: String(error),
|
||||
city: requestBody.city,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to stream city AI data",
|
||||
extra: { city: requestBody.city },
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_AI_PROXY_TIMEOUT_MS = Math.max(
|
||||
@@ -46,10 +50,7 @@ export async function POST(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: res.status === 402 || res.status === 403 ? res.status : 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -61,15 +62,12 @@ export async function POST(req: NextRequest) {
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: timedOut
|
||||
? "Scan AI request timed out"
|
||||
: "Failed to fetch scan AI data",
|
||||
detail: String(error),
|
||||
},
|
||||
{ status: timedOut ? 504 : 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "Scan AI request timed out"
|
||||
: "Failed to fetch scan AI data",
|
||||
status: timedOut ? 504 : 500,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
const SCAN_TERMINAL_PROXY_TIMEOUT_MS = Number(
|
||||
@@ -54,10 +58,7 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 300) },
|
||||
{ status: 502 },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw);
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
const data = await res.json();
|
||||
@@ -69,15 +70,12 @@ export async function GET(req: NextRequest) {
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
const timedOut = controller.signal.aborted;
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
error: timedOut
|
||||
? "Scan terminal request timed out"
|
||||
: "Failed to fetch scan terminal data",
|
||||
detail: String(error),
|
||||
},
|
||||
{ status: timedOut ? 504 : 500 },
|
||||
);
|
||||
const response = buildProxyExceptionResponse(error, {
|
||||
publicMessage: timedOut
|
||||
? "Scan terminal request timed out"
|
||||
: "Failed to fetch scan terminal data",
|
||||
status: timedOut ? 504 : 500,
|
||||
});
|
||||
return auth ? applyAuthResponseCookies(response, auth.response) : response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -3,6 +3,10 @@ import {
|
||||
applyAuthResponseCookies,
|
||||
buildBackendRequestHeaders,
|
||||
} from "@/lib/backend-auth";
|
||||
import {
|
||||
buildProxyExceptionResponse,
|
||||
buildUpstreamErrorResponse,
|
||||
} from "@/lib/api-proxy";
|
||||
|
||||
const API_BASE = process.env.POLYWEATHER_API_BASE_URL;
|
||||
|
||||
@@ -22,10 +26,9 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
const response = NextResponse.json(
|
||||
{ error: `Backend returned ${res.status}`, detail: raw.slice(0, 500) },
|
||||
{ status: res.status },
|
||||
);
|
||||
const response = buildUpstreamErrorResponse(res.status, raw, {
|
||||
detailLimit: 500,
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
}
|
||||
|
||||
@@ -35,9 +38,8 @@ export async function GET(req: NextRequest) {
|
||||
});
|
||||
return applyAuthResponseCookies(response, auth.response);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to fetch system status", detail: String(error) },
|
||||
{ status: 500 },
|
||||
);
|
||||
return buildProxyExceptionResponse(error, {
|
||||
publicMessage: "Failed to fetch system status",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,12 @@ export default async function EntitlementRequiredPage({ searchParams }: Props) {
|
||||
This dashboard is protected. If Supabase auth is enabled, please go to{" "}
|
||||
<a
|
||||
href={`/auth/login?next=${encodeURIComponent(nextPath)}`}
|
||||
style={{ color: "#8fc5ff" }}
|
||||
style={{
|
||||
color: "#8fc5ff",
|
||||
display: "inline-flex",
|
||||
minHeight: 36,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
/auth/login
|
||||
</a>{" "}
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #E6EDF3;
|
||||
--color-text-secondary: #9FB2C7;
|
||||
--color-text-muted: #6B7A90;
|
||||
--color-text-disabled: #6B7A90;
|
||||
--color-text-muted: #7D8FA3;
|
||||
--color-text-disabled: #7D8FA3;
|
||||
|
||||
/* ── Accent Colors (Fintech 3-Color) ── */
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #4DA3FF;
|
||||
--color-accent-secondary: #4DA3FF;
|
||||
--color-accent-secondary: #6FB7FF;
|
||||
--color-accent-tertiary: #93C5FD;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
@@ -52,7 +52,7 @@
|
||||
/* ── Typography ── */
|
||||
--font-data:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Geist", "Inter", -apple-system, sans-serif;
|
||||
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
|
||||
/* ── Spacing (4px grid) ── */
|
||||
@@ -91,7 +91,7 @@
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
|
||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
@@ -105,10 +105,72 @@
|
||||
--border: 221 38% 22%;
|
||||
}
|
||||
|
||||
/* ── Light Theme Token Overrides ── */
|
||||
html.light,
|
||||
html[data-theme="light"] {
|
||||
--color-bg-base: #F7F9FC;
|
||||
--color-bg-raised: #EEF2F7;
|
||||
--color-bg-overlay: #FFFFFF;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-input: rgba(238, 242, 247, 0.88);
|
||||
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--color-accent-tertiary: #60A5FA;
|
||||
|
||||
--color-border-default: rgba(148, 163, 184, 0.24);
|
||||
--color-border-hover: rgba(37, 99, 235, 0.38);
|
||||
--color-border-subtle: rgba(148, 163, 184, 0.12);
|
||||
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(40, 70, 110, 0.12);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(40, 70, 110, 0.15);
|
||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.14);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.12);
|
||||
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
/* ── Skip-to-content link ── */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 8px;
|
||||
z-index: 9999;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-accent-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: top 0.18s ease;
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
/* ── Global focus-visible ring (keyboard navigation) ── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
@@ -219,6 +281,28 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-pending {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all animations and transitions ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -36,7 +36,12 @@ export default function RootLayout({
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen font-sans antialiased">{children}</body>
|
||||
<body className="min-h-screen font-sans antialiased">
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
Skip to content
|
||||
</a>
|
||||
<main id="main-content">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ export default function SubscriptionHelpPage() {
|
||||
<Link
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
className="text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
|
||||
className="inline-flex min-h-9 items-center text-sm font-semibold text-blue-300 underline decoration-blue-500/50 underline-offset-4"
|
||||
>
|
||||
加入社群即可赚取积分
|
||||
</Link>
|
||||
|
||||
@@ -302,8 +302,8 @@
|
||||
|
||||
.mono {
|
||||
font-family: "Consolas", "SFMono-Regular", "Menlo", monospace;
|
||||
font-size: 12px !important;
|
||||
color: #c7d2fe !important;
|
||||
font-size: 12px;
|
||||
color: #c7d2fe;
|
||||
}
|
||||
|
||||
.hint {
|
||||
@@ -361,13 +361,7 @@
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
@media (max-width: 960px) {
|
||||
.topBar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -396,7 +390,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
@media (max-width: 640px) {
|
||||
.page {
|
||||
padding: 18px 12px 20px;
|
||||
}
|
||||
|
||||
@@ -285,15 +285,15 @@ const InfoRow = ({
|
||||
value,
|
||||
isPrimary = false,
|
||||
}: InfoRowProps) => (
|
||||
<div className="flex items-center justify-between p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||
<div className="flex min-w-0 flex-col gap-3 p-4 bg-white/5 rounded-2xl border border-white/5 hover:bg-white/10 transition-all group sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="shrink-0 p-2 bg-slate-800 rounded-lg text-slate-400 group-hover:text-blue-400 transition-colors">
|
||||
{Icon && <Icon size={18} />}
|
||||
</div>
|
||||
<span className="text-slate-400 text-sm font-medium">{label}</span>
|
||||
<span className="min-w-0 text-slate-400 text-sm font-medium leading-5">{label}</span>
|
||||
</div>
|
||||
<span
|
||||
className={`text-sm font-semibold font-mono ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||
className={`min-w-0 break-all text-left text-sm font-semibold font-mono sm:text-right ${isPrimary ? "text-blue-400" : "text-slate-200"}`}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
@@ -2772,7 +2772,7 @@ export function AccountCenter() {
|
||||
href={TELEGRAM_BOT_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-1.5 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-cyan-400/30 bg-cyan-500/10 px-3 py-2 text-xs font-semibold text-cyan-200 hover:bg-cyan-500/20"
|
||||
>
|
||||
{copy.telegramBotLink}
|
||||
<ExternalLink size={12} />
|
||||
@@ -2783,7 +2783,7 @@ export function AccountCenter() {
|
||||
href={TELEGRAM_MARKET_CHANNEL_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-1.5 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-3 py-2 text-xs font-semibold text-emerald-200 hover:bg-emerald-500/20"
|
||||
>
|
||||
{copy.telegramMarketChannelLink}
|
||||
<ExternalLink size={12} />
|
||||
@@ -2794,7 +2794,7 @@ export function AccountCenter() {
|
||||
href={TELEGRAM_GROUP_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 py-1.5 text-xs font-semibold text-blue-200 hover:bg-blue-500/20"
|
||||
className="inline-flex min-h-9 items-center gap-1 rounded-lg border border-blue-400/30 bg-blue-500/10 px-3 py-2 text-xs font-semibold text-blue-200 hover:bg-blue-500/20"
|
||||
>
|
||||
{copy.telegramGroupLink}
|
||||
<ExternalLink size={12} />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,838 @@
|
||||
/* ── Map ── */
|
||||
.root :global(.map) {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + 16px);
|
||||
right: 392px;
|
||||
bottom: 164px;
|
||||
left: calc(var(--sidebar-width) + 22px);
|
||||
z-index: 1;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(115, 137, 161, 0.16);
|
||||
border-radius: 24px;
|
||||
background: #040912;
|
||||
box-shadow:
|
||||
0 32px 100px rgba(0, 0, 0, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04),
|
||||
inset 0 0 0 1px rgba(0, 224, 164, 0.04);
|
||||
}
|
||||
|
||||
.weatherAura {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
opacity: 0.96;
|
||||
mix-blend-mode: screen;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.weatherAura :global(canvas) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
opacity: 0.94;
|
||||
filter: saturate(1.05) blur(0.3px);
|
||||
}
|
||||
|
||||
.weatherAuraScrim {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(3, 8, 19, 0.64) 0%,
|
||||
rgba(5, 10, 20, 0.16) 24%,
|
||||
rgba(4, 8, 18, 0.1) 54%,
|
||||
rgba(3, 6, 14, 0.42) 100%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 60%,
|
||||
rgba(0, 224, 164, 0.08) 0%,
|
||||
rgba(123, 97, 255, 0) 48%
|
||||
);
|
||||
}
|
||||
|
||||
.weatherAura[data-reduced-motion="true"] :global(canvas) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.map .leaflet-tile),
|
||||
.root :global(.map .leaflet-marker-icon),
|
||||
.root :global(.map .leaflet-marker-shadow),
|
||||
.root :global(.map .leaflet-container img),
|
||||
.root :global(.map .leaflet-container svg) {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.root :global(.map .leaflet-tile) {
|
||||
width: 256px !important;
|
||||
height: 256px !important;
|
||||
}
|
||||
|
||||
/* Remove Leaflet default styling for cleaner look */
|
||||
.root :global(.leaflet-control-attribution) {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-muted) !important;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--border-subtle) !important;
|
||||
font-size: 10px !important;
|
||||
border-radius: 6px !important;
|
||||
padding: 2px 8px !important;
|
||||
}
|
||||
.root :global(.leaflet-control-attribution a) {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
.root :global(.leaflet-control-zoom) {
|
||||
border: none !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
}
|
||||
.root :global(.leaflet-control-zoom a) {
|
||||
background: var(--bg-glass) !important;
|
||||
color: var(--text-primary) !important;
|
||||
backdrop-filter: blur(12px) !important;
|
||||
border: 1px solid var(--border-glass) !important;
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
line-height: 36px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 8px !important;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.leaflet-control-zoom a:hover) {
|
||||
background: rgba(99, 102, 241, 0.2) !important;
|
||||
border-color: var(--accent-blue) !important;
|
||||
}
|
||||
|
||||
/* ── Custom Map Markers ── */
|
||||
.root :global(.city-marker) {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
/* Remove transition: transform to avoid fighting with Leaflet's own positioning */
|
||||
}
|
||||
.root :global(.city-marker:hover) {
|
||||
transform: scale(1.15);
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble) {
|
||||
min-width: 44px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 14px;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
color: white;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid;
|
||||
border-top-color: inherit;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-high) {
|
||||
background: linear-gradient(135deg, #991b1b, #fb7185);
|
||||
border-color: rgba(251, 113, 133, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(251, 113, 133, 0.18);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-high::after) {
|
||||
border-top-color: var(--color-signal-danger);
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-medium) {
|
||||
background: linear-gradient(135deg, #9a3412, #f59e0b);
|
||||
border-color: rgba(245, 158, 11, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(245, 158, 11, 0.16);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-medium::after) {
|
||||
border-top-color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble.risk-low) {
|
||||
background: linear-gradient(135deg, #065f46, #22c55e);
|
||||
border-color: rgba(74, 222, 128, 0.42);
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 22px rgba(74, 222, 128, 0.14);
|
||||
}
|
||||
.root :global(.marker-bubble.risk-low::after) {
|
||||
border-top-color: var(--color-signal-success);
|
||||
}
|
||||
|
||||
.root :global(.marker-name) {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.8);
|
||||
margin-top: 2px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Marker glow animation for selected city */
|
||||
.root :global(.city-marker.selected .marker-bubble) {
|
||||
animation: markerGlow 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes markerGlow {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow:
|
||||
0 6px 18px rgba(0, 0, 0, 0.42),
|
||||
0 0 24px rgba(34, 211, 238, 0.12);
|
||||
}
|
||||
50% {
|
||||
box-shadow:
|
||||
0 6px 26px rgba(0, 0, 0, 0.46),
|
||||
0 0 20px rgba(34, 211, 238, 0.24),
|
||||
0 0 46px rgba(34, 211, 238, 0.14);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Loading Overlay ── */
|
||||
.root :global(.loading-overlay) {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000;
|
||||
background: radial-gradient(
|
||||
circle at 50% 50%,
|
||||
rgba(8, 14, 32, 0.76) 0%,
|
||||
rgba(4, 8, 22, 0.9) 44%,
|
||||
rgba(2, 6, 20, 0.96) 100%
|
||||
);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.root :global(.loading-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
min-width: 240px;
|
||||
padding: 28px 32px;
|
||||
border-radius: 28px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.78) 0%,
|
||||
rgba(8, 14, 30, 0.62) 100%
|
||||
);
|
||||
border: 1px solid rgba(92, 142, 255, 0.16);
|
||||
box-shadow:
|
||||
0 24px 90px rgba(0, 0, 0, 0.42),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.root :global(.loading-clouds) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud) {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 18px;
|
||||
border-radius: 999px;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 30% 50%,
|
||||
rgba(186, 230, 253, 0.22),
|
||||
transparent 54%
|
||||
),
|
||||
linear-gradient(
|
||||
180deg,
|
||||
rgba(148, 163, 184, 0.28) 0%,
|
||||
rgba(71, 85, 105, 0.1) 100%
|
||||
);
|
||||
filter: blur(0.3px);
|
||||
box-shadow:
|
||||
0 6px 24px rgba(56, 189, 248, 0.06),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||
animation: cloud-drift 6.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud-1) {
|
||||
left: 12px;
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
.root :global(.loading-cloud-2) {
|
||||
right: 8px;
|
||||
top: 7px;
|
||||
width: 98px;
|
||||
opacity: 0.75;
|
||||
animation-delay: -2.6s;
|
||||
}
|
||||
|
||||
.root :global(.loading-windfield) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline) {
|
||||
position: absolute;
|
||||
left: -28%;
|
||||
height: 1px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0) 0%,
|
||||
rgba(103, 232, 249, 0.5) 22%,
|
||||
rgba(125, 211, 252, 0.9) 55%,
|
||||
rgba(34, 211, 238, 0) 100%
|
||||
);
|
||||
filter: blur(0.2px);
|
||||
animation: wind-shift 2.8s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-1) {
|
||||
top: 4px;
|
||||
width: 124px;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-2) {
|
||||
top: 13px;
|
||||
width: 176px;
|
||||
animation-delay: -0.9s;
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.root :global(.loading-windline-3) {
|
||||
top: 22px;
|
||||
width: 142px;
|
||||
animation-delay: -1.6s;
|
||||
opacity: 0.66;
|
||||
}
|
||||
|
||||
.root :global(.loading-copy) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.loading-copy strong) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(103, 232, 249, 0.92);
|
||||
}
|
||||
|
||||
.root :global(.loading-copy span) {
|
||||
max-width: 240px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.55;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar) {
|
||||
position: relative;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(22, 78, 99, 0.16) 0%,
|
||||
rgba(7, 18, 36, 0.08) 48%,
|
||||
rgba(5, 10, 22, 0.02) 76%,
|
||||
transparent 100%
|
||||
);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(56, 189, 248, 0.12),
|
||||
0 0 40px rgba(34, 211, 238, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-core) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--color-accent-secondary) 0%, #0891b2 100%);
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow:
|
||||
0 0 12px rgba(34, 211, 238, 0.65),
|
||||
0 0 28px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(56, 189, 248, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring-1) {
|
||||
transform: scale(0.62);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-ring-2) {
|
||||
transform: scale(0.88);
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-sweep) {
|
||||
position: absolute;
|
||||
inset: 6px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 90deg,
|
||||
rgba(34, 211, 238, 0) 0deg,
|
||||
rgba(34, 211, 238, 0) 260deg,
|
||||
rgba(34, 211, 238, 0.3) 312deg,
|
||||
rgba(96, 165, 250, 0.06) 360deg
|
||||
);
|
||||
mask: radial-gradient(circle, transparent 0 22px, #000 23px);
|
||||
animation: radar-sweep 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip) {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(253, 224, 71, 0.92);
|
||||
box-shadow: 0 0 12px rgba(253, 224, 71, 0.45);
|
||||
animation: radar-blip 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip-1) {
|
||||
top: 26px;
|
||||
right: 24px;
|
||||
animation-delay: 0.15s;
|
||||
}
|
||||
|
||||
.root :global(.loading-radar-blip-2) {
|
||||
bottom: 25px;
|
||||
left: 20px;
|
||||
background: rgba(74, 222, 128, 0.9);
|
||||
box-shadow: 0 0 12px rgba(74, 222, 128, 0.4);
|
||||
animation-delay: 0.9s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermals) {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal) {
|
||||
width: 10px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(251, 191, 36, 0.15) 0%,
|
||||
rgba(251, 146, 60, 0.8) 55%,
|
||||
rgba(239, 68, 68, 0.9) 100%
|
||||
);
|
||||
box-shadow: 0 0 18px rgba(251, 146, 60, 0.18);
|
||||
animation: thermal-rise 1.5s ease-in-out infinite;
|
||||
transform-origin: center bottom;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-1) {
|
||||
height: 11px;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-2) {
|
||||
height: 22px;
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-3) {
|
||||
height: 16px;
|
||||
animation-delay: 0.42s;
|
||||
}
|
||||
|
||||
.root :global(.loading-thermal-4) {
|
||||
height: 25px;
|
||||
animation-delay: 0.68s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle) {
|
||||
position: relative;
|
||||
width: 220px;
|
||||
height: 26px;
|
||||
overflow: hidden;
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop) {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
width: 2px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(125, 211, 252, 0) 0%,
|
||||
rgba(125, 211, 252, 0.9) 100%
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(56, 189, 248, 0.12);
|
||||
animation: drizzle-fall 1.35s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-1) {
|
||||
left: 38px;
|
||||
animation-delay: -0.1s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-2) {
|
||||
left: 76px;
|
||||
height: 18px;
|
||||
animation-delay: -0.52s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-3) {
|
||||
left: 112px;
|
||||
height: 14px;
|
||||
animation-delay: -0.92s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-4) {
|
||||
left: 156px;
|
||||
height: 17px;
|
||||
animation-delay: -0.38s;
|
||||
}
|
||||
|
||||
.root :global(.loading-drizzle-drop-5) {
|
||||
left: 192px;
|
||||
height: 13px;
|
||||
animation-delay: -0.74s;
|
||||
}
|
||||
|
||||
.root :global(.loading-overlay.hidden) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.loading-spinner) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 2px solid rgba(34, 211, 238, 0.1);
|
||||
border-top-color: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
|
||||
box-shadow: 0 0 15px rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
|
||||
|
||||
@keyframes city-loading-pulse {
|
||||
0% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0.44);
|
||||
}
|
||||
70% {
|
||||
transform: scale(1);
|
||||
box-shadow: 0 0 0 10px rgba(34, 211, 238, 0);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.92);
|
||||
box-shadow: 0 0 0 0 rgba(34, 211, 238, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes radar-sweep {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes radar-blip {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
transform: scale(0.72);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
60% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes wind-shift {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 0;
|
||||
}
|
||||
18% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
82% {
|
||||
opacity: 0.78;
|
||||
}
|
||||
to {
|
||||
transform: translateX(145%);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes thermal-rise {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.78);
|
||||
opacity: 0.72;
|
||||
}
|
||||
50% {
|
||||
transform: scaleY(1.14);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cloud-drift {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateX(12px);
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drizzle-fall {
|
||||
0% {
|
||||
transform: translateY(-3px);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.9;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(18px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Nearby Stations (Premium Glassmorphism) ── */
|
||||
.root :global(.nearby-marker-premium) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
background: rgba(10, 14, 26, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 999px; /* Pill shape */
|
||||
padding: 5px 14px 5px 10px;
|
||||
box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1px 1px rgba(255, 255, 255, 0.05);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.nearby-marker-shell) {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes nearby-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9) translateY(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.nearby-pulse) {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.pulse-core) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 0 8px var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.pulse-ring) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1.5px solid var(--accent-cyan);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0.3;
|
||||
box-shadow: 0 0 10px rgba(34, 211, 238, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.nearby-content) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.root :global(.nearby-label) {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.root :global(.nearby-stats) {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.root :global(.nearby-temp-val) {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text-primary);
|
||||
text-shadow: 0 0 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.nearby-temp-unit) {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.nearby-time) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(203, 213, 225, 0.95);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.nearby-time span + span)::before {
|
||||
content: "·";
|
||||
margin-right: 4px;
|
||||
color: rgba(148, 163, 184, 0.55);
|
||||
}
|
||||
|
||||
.root :global(.nearby-time.is-stale) {
|
||||
color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-left: 10px;
|
||||
margin-left: 2px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind .wind-arrow) {
|
||||
font-size: 12px;
|
||||
color: var(--accent-cyan);
|
||||
text-shadow: 0 0 8px rgba(34, 211, 238, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.nearby-wind .wind-val) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ── Trend badge ── */
|
||||
.root :global(.trend-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.root :global(.trend-badge.rising) {
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: var(--accent-green);
|
||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.falling) {
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.stagnant) {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: var(--accent-yellow);
|
||||
border: 1px solid rgba(251, 191, 36, 0.25);
|
||||
}
|
||||
.root :global(.trend-badge.mixed) {
|
||||
background: rgba(167, 139, 250, 0.12);
|
||||
color: var(--accent-purple);
|
||||
border: 1px solid rgba(167, 139, 250, 0.25);
|
||||
}
|
||||
|
||||
.root :global(.dead-market) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
background: rgba(248, 113, 113, 0.15);
|
||||
color: var(--accent-red);
|
||||
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||
animation: deadPulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes deadPulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
.root :global(.scan-select) {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
|
||||
color: #000;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 12px rgba(0, 224, 164, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:hover:not(:disabled)) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 20px rgba(0, 224, 164, 0.4);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:active:not(:disabled)) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.root :global(.modal-overlay) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(2, 6, 23, 0.75);
|
||||
backdrop-filter: blur(12px);
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.root :global(.modal-content) {
|
||||
background: #16213A;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 700px;
|
||||
max-height: calc(100vh - 48px);
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.modal-header) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.root :global(.modal-header h2) {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.root :global(.future-modal-title-with-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn) {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn:hover) {
|
||||
color: var(--accent-cyan);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.future-refresh-btn.spinning svg) {
|
||||
animation: spin 1s linear infinite;
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.modal-close) {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.root :global(.modal-close:hover) {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.root :global(.modal-body) {
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Keep scroll behavior but hide visual scrollbar in today's/future analysis modal */
|
||||
.root :global(.modal-content.large.future-modal .modal-body) {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root
|
||||
:global(.modal-content.large.future-modal .modal-body::-webkit-scrollbar) {
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.modal-content) {
|
||||
border-radius: 14px;
|
||||
max-height: calc(100vh - 16px);
|
||||
}
|
||||
|
||||
.root :global(.modal-header) {
|
||||
padding: 14px 14px 12px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.modal-header h2) {
|
||||
font-size: 15px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.modal-body) {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* ── Info Button ── */
|
||||
.root :global(.account-btn) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.34);
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.account-btn:hover) {
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
border-color: rgba(34, 211, 238, 0.62);
|
||||
color: #f8fbff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(245, 158, 11, 0.34);
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: #fbbf24;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge:hover) {
|
||||
background: rgba(245, 158, 11, 0.18);
|
||||
border-color: rgba(245, 158, 11, 0.6);
|
||||
color: #fff6d5;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.account-renew-badge.expired) {
|
||||
border-color: rgba(239, 68, 68, 0.34);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.root :global(.trial-promo-badge) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.28);
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: #a5f3fc;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.trial-promo-badge:hover) {
|
||||
background: rgba(34, 211, 238, 0.16);
|
||||
border-color: rgba(34, 211, 238, 0.45);
|
||||
color: #ecfeff;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.info-btn) {
|
||||
background: rgba(99, 102, 241, 0.1);
|
||||
border: 1px solid rgba(99, 102, 241, 0.3);
|
||||
color: var(--accent-blue);
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.root :global(.info-btn:hover) {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.info-btn.active) {
|
||||
background: rgba(34, 211, 238, 0.14);
|
||||
border-color: rgba(34, 211, 238, 0.42);
|
||||
color: #e0fbff;
|
||||
}
|
||||
|
||||
/* ── Guide Modal Customizations ── */
|
||||
.root :global(.modal-content.large) {
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
|
||||
.root :global(.guide-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.root :global(.guide-card) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: var(--transition);
|
||||
}
|
||||
.root :global(.guide-card:hover) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-color: var(--border-glass);
|
||||
}
|
||||
.root :global(.guide-card h3) {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-cyan);
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.root :global(.guide-card p) {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.root :global(.guide-card b) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.guide-footer) {
|
||||
margin-top: 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
text-align: center;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -0,0 +1,819 @@
|
||||
/* ── Header ── */
|
||||
.root :global(.header) {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: var(--header-height);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 16px;
|
||||
background: rgba(7, 11, 18, 0.94);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-bottom: 1px solid var(--border-glass);
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.brand-mark) {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
||||
background: rgba(8, 15, 28, 0.78);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgba(148, 163, 184, 0.08),
|
||||
0 0 20px rgba(0, 224, 164, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.brand-mark img) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.root :global(.brand h1) {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
var(--accent-cyan) 0%,
|
||||
var(--accent-blue) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.root :global(.header-nav) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 10px;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link:hover) {
|
||||
color: #f8fafc;
|
||||
background: rgba(30, 41, 59, 0.44);
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link.active) {
|
||||
color: #f8fafc;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.root :global(.header-nav-link.active::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
right: 18px;
|
||||
bottom: 4px;
|
||||
height: 2px;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: var(--bg-glass);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
padding: 6px 8px;
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn:hover) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.lang-btn.active) {
|
||||
color: var(--text-primary);
|
||||
background: rgba(0, 224, 164, 0.12);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 224, 164, 0.08);
|
||||
border: 1px solid rgba(0, 224, 164, 0.22);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-green);
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.pulse-dot) {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-green);
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
box-shadow: 0 0 0 0 rgba(0, 224, 164, 0.4);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
box-shadow: 0 0 0 6px rgba(0, 224, 164, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-glass);
|
||||
background: rgba(13, 19, 33, 0.6);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.root :global(.refresh-btn:hover) {
|
||||
background: rgba(0, 224, 164, 0.1);
|
||||
border-color: rgba(0, 224, 164, 0.3);
|
||||
color: var(--accent-cyan);
|
||||
}
|
||||
.root :global(.refresh-btn.spinning) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.locale-switch) {
|
||||
height: 32px;
|
||||
padding: 3px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(0, 224, 164, 0.2);
|
||||
background: rgba(8, 15, 27, 0.82);
|
||||
color: rgba(148, 163, 184, 0.86);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.locale-switch span) {
|
||||
min-width: 32px;
|
||||
height: 24px;
|
||||
border-radius: 7px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root :global(.locale-switch span.active) {
|
||||
color: #f8fafc;
|
||||
background: rgba(0, 224, 164, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.locale-switch:hover) {
|
||||
border-color: rgba(0, 224, 164, 0.34);
|
||||
background: rgba(11, 22, 40, 0.94);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn) {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(115, 137, 161, 0.18);
|
||||
background: rgba(8, 15, 27, 0.82);
|
||||
color: rgba(226, 232, 240, 0.8);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn:hover) {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(0, 224, 164, 0.34);
|
||||
background: rgba(11, 22, 40, 0.94);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn.active) {
|
||||
color: #f8fafc;
|
||||
border-color: rgba(123, 97, 255, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.header-utility-btn.more) {
|
||||
padding: 0;
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
/* ── City List Sidebar ── */
|
||||
.root :global(.city-list) {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 12px);
|
||||
left: 12px;
|
||||
width: var(--sidebar-width);
|
||||
max-height: calc(100vh - var(--header-height) - 24px);
|
||||
z-index: 900;
|
||||
background: var(--bg-glass);
|
||||
backdrop-filter: blur(var(--glass-blur));
|
||||
border: 1px solid var(--border-glass);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px 10px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.root :global(.city-search) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0 14px 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(115, 137, 161, 0.14);
|
||||
background: rgba(6, 12, 22, 0.82);
|
||||
color: rgba(148, 163, 184, 0.86);
|
||||
}
|
||||
|
||||
.root :global(.city-search input) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: #f8fafc;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.city-search input::placeholder) {
|
||||
color: rgba(148, 163, 184, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.city-count) {
|
||||
background: rgba(123, 97, 255, 0.2);
|
||||
color: var(--accent-blue);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(123, 97, 255, 0.15);
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0 6px 8px;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar) {
|
||||
width: 4px;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar-track) {
|
||||
background: transparent;
|
||||
}
|
||||
.root :global(.city-list-items::-webkit-scrollbar-thumb) {
|
||||
background: var(--border-glass);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.root :global(.sidebar-footer) {
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
padding: 10px 12px;
|
||||
color: rgba(148, 163, 184, 0.72);
|
||||
font-size: 10px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.city-group) {
|
||||
border: 1px solid rgba(115, 137, 161, 0.12);
|
||||
border-radius: 14px;
|
||||
background: rgba(10, 17, 30, 0.48);
|
||||
margin-bottom: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.city-group:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header) {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: rgba(15, 28, 47, 0.56);
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 150ms ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header:hover) {
|
||||
background: rgba(18, 34, 55, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.city-group-indicator) {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-group-indicator.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.3);
|
||||
}
|
||||
.root :global(.city-group-indicator.other) {
|
||||
background: var(--text-muted);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.root :global(.city-group-meta) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-count) {
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9px;
|
||||
padding: 0 6px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(123, 97, 255, 0.26);
|
||||
color: var(--text-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.city-group-arrow) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.root :global(.city-group-arrow.expanded) {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.root :global(.city-group-items) {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.root :global(.city-group.collapsed .city-group-items) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.city-item) {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.root :global(.city-item:hover) {
|
||||
background: rgba(10, 26, 44, 0.84);
|
||||
border-color: rgba(0, 224, 164, 0.18);
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 224, 164, 0.03);
|
||||
}
|
||||
.root :global(.city-item.active) {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(0, 224, 164, 0.12), rgba(123, 97, 255, 0.1)),
|
||||
rgba(9, 18, 32, 0.92);
|
||||
border-color: rgba(0, 224, 164, 0.24);
|
||||
box-shadow:
|
||||
0 0 20px rgba(0, 224, 164, 0.08),
|
||||
inset 0 0 0 1px rgba(0, 224, 164, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-name-text) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-left: 20px; /* Align with name text, after the dot */
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-max-info) {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-info) {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-cold) {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-hot) {
|
||||
color: var(--color-signal-warning);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-normal) {
|
||||
color: #22d3ee;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-info.strong) {
|
||||
text-shadow: 0 0 10px rgba(56, 189, 248, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-deviation-hot.strong) {
|
||||
text-shadow: 0 0 10px rgba(245, 158, 11, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.city-item .risk-dot) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.root :global(.city-item .risk-dot.high) {
|
||||
background: var(--risk-high);
|
||||
box-shadow: 0 0 6px var(--risk-high);
|
||||
}
|
||||
.root :global(.city-item .risk-dot.medium) {
|
||||
background: var(--risk-medium);
|
||||
box-shadow: 0 0 6px var(--risk-medium);
|
||||
}
|
||||
.root :global(.city-item .risk-dot.low) {
|
||||
background: var(--risk-low);
|
||||
box-shadow: 0 0 6px var(--risk-low);
|
||||
}
|
||||
|
||||
.root :global(.city-clock-icon) {
|
||||
display: inline-block;
|
||||
vertical-align: -1px;
|
||||
margin-right: 3px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-temp) {
|
||||
margin-left: auto;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--accent-cyan);
|
||||
opacity: 0;
|
||||
transition: opacity 200ms ease;
|
||||
}
|
||||
.root :global(.city-item .city-temp.loaded) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Responsive ── */
|
||||
@media (max-width: 1024px) {
|
||||
.root {
|
||||
--panel-width: 460px;
|
||||
}
|
||||
.root :global(.hero-value) {
|
||||
font-size: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.root :global(.header) {
|
||||
height: auto;
|
||||
min-height: var(--header-height);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
flex: 1 1 auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.root :global(.account-btn),
|
||||
.root :global(.info-btn) {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
order: 4;
|
||||
padding: 4px 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
order: 5;
|
||||
}
|
||||
|
||||
.root :global(.city-list) {
|
||||
display: flex;
|
||||
top: auto;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: auto;
|
||||
max-height: min(36vh, 320px);
|
||||
border-radius: 14px;
|
||||
z-index: 920;
|
||||
}
|
||||
.root :global(.detail-panel) {
|
||||
width: 100%;
|
||||
}
|
||||
.root {
|
||||
--panel-width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.city-list-items) {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-header) {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.root :global(.city-item) {
|
||||
padding: 8px 9px;
|
||||
}
|
||||
|
||||
.root :global(.city-item .city-name-text),
|
||||
.root :global(.city-item .city-temp) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
padding-left: 18px;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 4px;
|
||||
}
|
||||
|
||||
.root :global(.panel-header) {
|
||||
padding: 16px 16px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.header) {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.root :global(.brand) {
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.subtitle) {
|
||||
display: none;
|
||||
}
|
||||
.root :global(.brand h1) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.header-right) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.lang-switch) {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.root :global(.account-btn),
|
||||
.root :global(.info-btn) {
|
||||
min-height: 34px;
|
||||
padding: 6px 9px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.account-btn span),
|
||||
.root :global(.info-btn span) {
|
||||
max-width: 88px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.live-badge) {
|
||||
padding: 4px 8px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.root :global(.refresh-btn) {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.hero-value) {
|
||||
font-size: 42px;
|
||||
}
|
||||
.root :global(.hero-details) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
.root :global(.hero-item .value) {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.city-list) {
|
||||
left: 6px;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
max-height: min(40vh, 300px);
|
||||
}
|
||||
|
||||
.root :global(.city-list-header) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-group-title) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-main) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.city-item-info) {
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.root :global(.panel-header) {
|
||||
padding: 14px 14px 12px;
|
||||
}
|
||||
|
||||
.root :global(.city-marker:hover) {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.root :global(.marker-bubble) {
|
||||
min-width: 48px;
|
||||
min-height: 32px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.root :global(.marker-name) {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.prob-price-grid) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.prob-price-head) {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import type { CityDetail } from "@/lib/dashboard-types";
|
||||
|
||||
export function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
const { locale } = useI18n();
|
||||
const chartData = useMemo(
|
||||
() => getTemperatureChartData(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const forecastLabel = locale === "en-US" ? "DEB baseline" : "DEB 原始路径";
|
||||
const calibratedLabel =
|
||||
locale === "en-US"
|
||||
? "METAR-calibrated path"
|
||||
: "METAR 修正路径";
|
||||
const observationLabel =
|
||||
chartData?.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
|
||||
const hasCalibratedPath = Boolean(
|
||||
chartData?.datasets.calibratedFuture.some((value) => value != null),
|
||||
);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [
|
||||
{
|
||||
borderColor: "rgba(100, 116, 139, 0.72)",
|
||||
borderDash: [6, 4],
|
||||
borderWidth: 1.5,
|
||||
data: chartData.datasets.debPast.map(
|
||||
(value, index) => value ?? chartData.datasets.debFuture[index],
|
||||
),
|
||||
fill: false,
|
||||
label: forecastLabel,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.28,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasCalibratedPath) {
|
||||
datasets.push({
|
||||
borderColor: "#38bdf8",
|
||||
borderWidth: 2.1,
|
||||
data: chartData.datasets.calibratedFuture,
|
||||
fill: false,
|
||||
label: calibratedLabel,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22c55e",
|
||||
borderColor: "#22c55e",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: observationLabel,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: 3.2,
|
||||
showLine: false,
|
||||
});
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
borderColor: "rgba(77, 163, 255, 0.28)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 4 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 6,
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 5,
|
||||
stepSize: chartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [
|
||||
calibratedLabel,
|
||||
chartData,
|
||||
detail.temp_symbol,
|
||||
forecastLabel,
|
||||
hasCalibratedPath,
|
||||
observationLabel,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="detail-mini-chart-wrap">
|
||||
<div className="detail-mini-chart">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{chartData ? (
|
||||
<div className="detail-mini-chart-legend">
|
||||
<span>
|
||||
<i className="forecast baseline" />
|
||||
{forecastLabel}
|
||||
</span>
|
||||
{hasCalibratedPath ? (
|
||||
<span>
|
||||
<i className="forecast calibrated" />
|
||||
{calibratedLabel}
|
||||
</span>
|
||||
) : null}
|
||||
<span>
|
||||
<i className="observation" />
|
||||
{observationLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ForecastTable } from "@/components/dashboard/PanelSections";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getOfficialSourceLinks } from "@/lib/dashboard-official-sources";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import { trackAppEvent } from "@/lib/app-analytics";
|
||||
import { getTodayPolymarketUrl } from "@/lib/polymarket-market-links";
|
||||
import {
|
||||
getCityProfileStats,
|
||||
getRiskBadgeLabel,
|
||||
getTemperatureChartData,
|
||||
} from "@/lib/dashboard-utils";
|
||||
import { getCityProfileStats } from "@/lib/dashboard-utils";
|
||||
import { normalizeObservationSourceLabel } from "@/lib/source-labels";
|
||||
import { getRiskBadgeLabel } from "@/lib/weather-summary-utils";
|
||||
|
||||
function DetailMiniTemperatureChart({ detail }: { detail: CityDetail }) {
|
||||
const { locale, t } = useI18n();
|
||||
const chartData = useMemo(
|
||||
() => getTemperatureChartData(detail, locale),
|
||||
[detail, locale],
|
||||
);
|
||||
const forecastLabel = chartData?.datasets.hasMgmHourly
|
||||
? locale === "en-US"
|
||||
? "MGM Forecast"
|
||||
: "MGM 预测"
|
||||
: locale === "en-US"
|
||||
? "DEB Forecast"
|
||||
: "DEB 预测";
|
||||
const observationLabel =
|
||||
chartData?.observationLabel ||
|
||||
(locale === "en-US" ? "METAR Observation" : "METAR 实况");
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!chartData) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const forecastPoints = chartData.datasets.hasMgmHourly
|
||||
? chartData.datasets.mgmHourlyPoints
|
||||
: chartData.datasets.debPast.map(
|
||||
(value, index) => value ?? chartData.datasets.debFuture[index],
|
||||
);
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: chartData.datasets.hasMgmHourly
|
||||
? "rgba(250, 204, 21, 0.92)"
|
||||
: "rgba(52, 211, 153, 0.86)",
|
||||
borderWidth: 1.8,
|
||||
data: forecastPoints,
|
||||
fill: false,
|
||||
label: forecastLabel,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "#4DA3FF",
|
||||
borderColor: "#4DA3FF",
|
||||
borderWidth: 0,
|
||||
data: chartData.datasets.metarPoints,
|
||||
fill: false,
|
||||
label: observationLabel,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: 3.2,
|
||||
showLine: false,
|
||||
},
|
||||
],
|
||||
labels: chartData.times,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
layout: { padding: { bottom: 0, left: 0, right: 6, top: 4 } },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.95)",
|
||||
borderColor: "rgba(77, 163, 255, 0.28)",
|
||||
borderWidth: 1,
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
ticks: {
|
||||
callback: (_value, index) =>
|
||||
typeof index === "number" && index % 4 === 0
|
||||
? chartData.times[index]
|
||||
: "",
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 6,
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.03)" },
|
||||
max: chartData.max,
|
||||
min: chartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(chartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { size: 10 },
|
||||
maxTicksLimit: 5,
|
||||
stepSize: chartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [chartData, detail.temp_symbol, forecastLabel, observationLabel]);
|
||||
|
||||
return (
|
||||
<div className="detail-mini-chart-wrap">
|
||||
<div className="detail-mini-chart">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{chartData ? (
|
||||
<div className="detail-mini-chart-legend">
|
||||
<span>
|
||||
<i className="forecast" />
|
||||
{forecastLabel}
|
||||
</span>
|
||||
<span>
|
||||
<i className="observation" />
|
||||
{observationLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const DetailMiniTemperatureChart = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/DetailMiniTemperatureChart").then(
|
||||
(module) => module.DetailMiniTemperatureChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="detail-mini-chart detail-mini-chart-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function DetailPanel({
|
||||
variant = "overlay",
|
||||
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
.root :global(.panel-action-button-primary) {
|
||||
color: #FFFFFF;
|
||||
background: linear-gradient(135deg, #4DA3FF, #3B82F6);
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #3B82F6);
|
||||
border-color: rgba(77, 163, 255, 0.34);
|
||||
}
|
||||
|
||||
@@ -210,4 +210,3 @@
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
/* Detail panel content styles. */
|
||||
|
||||
.root :global(.detail-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-card) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-list) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-note) {
|
||||
margin: 8px 0 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-main);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.18s ease,
|
||||
border-color 0.18s ease,
|
||||
transform 0.18s ease;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link:hover) {
|
||||
background: rgba(34, 211, 238, 0.08);
|
||||
border-color: rgba(34, 211, 238, 0.28);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.root :global(.detail-source-kind) {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
color: var(--accent-cyan);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-label) {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.detail-label) {
|
||||
display: block;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.root :global(.detail-value) {
|
||||
display: block;
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 1.5;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.root :global(.profile-lead) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-wrap) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart) {
|
||||
position: relative;
|
||||
height: 210px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-detail-rail .detail-mini-chart) {
|
||||
height: 230px;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend span) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i) {
|
||||
width: 16px;
|
||||
height: 3px;
|
||||
border-radius: 999px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.forecast) {
|
||||
background: rgba(100, 116, 139, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.forecast.calibrated) {
|
||||
background: #38bdf8;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart-legend i.observation) {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-meta) {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.insight-list) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.insight-item) {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.insight-title) {
|
||||
color: var(--accent-cyan);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.root :global(.insight-text) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-card) {
|
||||
position: relative;
|
||||
min-height: 210px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-subtle);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.18), rgba(15, 23, 42, 0.78)),
|
||||
rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-image) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-overlay) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
min-height: 210px;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(2, 6, 23, 0.08) 0%,
|
||||
rgba(2, 6, 23, 0.86) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-copy),
|
||||
.root :global(.detail-scenery-fallback) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-fallback) {
|
||||
min-height: 210px;
|
||||
padding: 18px;
|
||||
justify-content: flex-end;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at top left,
|
||||
rgba(34, 211, 238, 0.12),
|
||||
transparent 34%
|
||||
),
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(2, 6, 23, 0.96));
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-kicker) {
|
||||
color: rgba(226, 232, 240, 0.78);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-title) {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.03em;
|
||||
text-shadow: 0 6px 22px rgba(2, 6, 23, 0.42);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-subtitle) {
|
||||
max-width: 320px;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
text-shadow: 0 2px 10px rgba(2, 6, 23, 0.45);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-credit) {
|
||||
align-self: flex-start;
|
||||
color: rgba(226, 232, 240, 0.9);
|
||||
font-size: 11px;
|
||||
text-decoration: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(2, 6, 23, 0.35);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.root :global(.detail-scenery-credit:hover) {
|
||||
border-color: rgba(34, 211, 238, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.detail-grid) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.detail-mini-chart) {
|
||||
height: 170px;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-link) {
|
||||
padding: 10px 12px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root :global(.detail-source-label) {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { useI18n } from "@/hooks/useI18n";
|
||||
import type { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import {
|
||||
FutureModelForecastPanel,
|
||||
FutureProbabilityPanel,
|
||||
FutureTemperaturePathChart,
|
||||
} from "./FutureForecastModalPanels";
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
type FutureModalView = ReturnType<typeof getFutureModalView>;
|
||||
type TranslationFn = ReturnType<typeof useI18n>["t"];
|
||||
|
||||
export function FutureForecastForwardView({
|
||||
dateStr,
|
||||
detail,
|
||||
t,
|
||||
view,
|
||||
}: {
|
||||
dateStr: string;
|
||||
detail: DashboardDetail;
|
||||
t: TranslationFn;
|
||||
view: FutureModalView;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="future-forward-stats">
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.targetForecast")}</span>
|
||||
<span className="val">
|
||||
{view.forecastEntry?.max_temp ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.deb")}</span>
|
||||
<span className="val">
|
||||
{view.deb ?? "--"}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.mu")}</span>
|
||||
<span className="val">
|
||||
{view.mu != null
|
||||
? `${view.mu.toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="future-forward-stat-card">
|
||||
<span className="label">{t("future.score")}</span>
|
||||
<span className="val">
|
||||
{view.front.score > 0 ? "+" : ""}
|
||||
{view.front.score}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.targetTempTrend")}</h3>
|
||||
<FutureTemperaturePathChart dateStr={dateStr} forceToday={false} />
|
||||
</section>
|
||||
|
||||
<div className="future-modal-grid">
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.probability")}</h3>
|
||||
<FutureProbabilityPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
<section className="future-modal-section">
|
||||
<h3>{t("future.models")}</h3>
|
||||
<FutureModelForecastPanel detail={detail} targetDate={dateStr} hideTitle />
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,252 @@
|
||||
import type { IntradayMeteorologySignal } from "@/lib/dashboard-types";
|
||||
|
||||
export const TODAY_MARKET_SCAN_AUTO_REFRESH_MS = 5 * 60 * 1000;
|
||||
|
||||
export function normalizeMarketValue(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
export function formatMinuteAxisLabel(value: number) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const total = Math.max(0, Math.round(value));
|
||||
const hour = Math.floor(total / 60) % 24;
|
||||
const minute = total % 60;
|
||||
return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatMarketPercent(value?: number | null) {
|
||||
const normalized = normalizeMarketValue(value);
|
||||
if (normalized == null) return "--";
|
||||
return `${(normalized * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function formatBucketLabel(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return "--";
|
||||
const direct =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (direct) {
|
||||
let str = direct.toUpperCase().replace(/\s+/g, "");
|
||||
str = str.replace(/°?C($|\+|-)/g, "℃$1");
|
||||
if (!str.includes("℃") && /[0-9]/.test(str)) {
|
||||
str += "℃";
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
const temp = Number(bucket.value ?? bucket.temp);
|
||||
if (Number.isFinite(temp)) {
|
||||
return `${Math.round(temp)}℃`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
export function parseBucketBoundaries(
|
||||
bucket?: {
|
||||
label?: string | null;
|
||||
bucket?: string | null;
|
||||
range?: string | null;
|
||||
value?: number | null;
|
||||
temp?: number | null;
|
||||
} | null,
|
||||
) {
|
||||
if (!bucket) return null;
|
||||
const raw =
|
||||
String(bucket.label || "").trim() ||
|
||||
String(bucket.bucket || "").trim() ||
|
||||
String(bucket.range || "").trim();
|
||||
if (!raw) return null;
|
||||
const numbers = Array.from(raw.matchAll(/-?\d+(?:\.\d+)?/g)).map((match) =>
|
||||
Number(match[0]),
|
||||
);
|
||||
if (!numbers.length) return null;
|
||||
if (raw.includes("+")) {
|
||||
return {
|
||||
lower: numbers[0] ?? null,
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
if (numbers.length >= 2) {
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: numbers[1],
|
||||
boundaryLabel: null as string | null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
lower: numbers[0],
|
||||
upper: null as number | null,
|
||||
boundaryLabel: `${numbers[0]}°C`,
|
||||
};
|
||||
}
|
||||
|
||||
export function clamp(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
export function parseClockMinutes(value?: string | null) {
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hours = Number(match[1]);
|
||||
const minutes = Number(match[2]);
|
||||
if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
export function parseLeadingNumber(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
const text = String(value || "").trim();
|
||||
const match = text.match(/[-+]?\d+(?:\.\d+)?/);
|
||||
if (!match) return null;
|
||||
const numeric = Number(match[0]);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
export function parsePercentFromText(value?: string | number | null) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) {
|
||||
return clamp(value, 0, 100);
|
||||
}
|
||||
const text = String(value || "").trim();
|
||||
const percentMatch = text.match(/([-+]?\d+(?:\.\d+)?)\s*%/);
|
||||
if (percentMatch) {
|
||||
const numeric = Number(percentMatch[1]);
|
||||
return Number.isFinite(numeric) ? clamp(numeric, 0, 100) : null;
|
||||
}
|
||||
return parseLeadingNumber(text);
|
||||
}
|
||||
|
||||
export function formatConfidenceLabel(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "high") return locale === "en-US" ? "High" : "高";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
if (normalized === "low") return locale === "en-US" ? "Low" : "低";
|
||||
return locale === "en-US" ? "Pending" : "待确认";
|
||||
}
|
||||
|
||||
export function formatSignalDirection(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "support")
|
||||
return locale === "en-US" ? "Support" : "支持升温";
|
||||
if (normalized === "suppress")
|
||||
return locale === "en-US" ? "Suppress" : "压制峰值";
|
||||
return locale === "en-US" ? "Neutral" : "中性";
|
||||
}
|
||||
|
||||
export function formatSignalStrength(value?: string | null, locale = "zh-CN") {
|
||||
const normalized = String(value || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "strong") return locale === "en-US" ? "Strong" : "强";
|
||||
if (normalized === "medium") return locale === "en-US" ? "Medium" : "中";
|
||||
return locale === "en-US" ? "Weak" : "弱";
|
||||
}
|
||||
|
||||
export function signalTone(signal?: IntradayMeteorologySignal | null) {
|
||||
const direction = String(signal?.direction || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (direction === "support") return "cyan";
|
||||
if (direction === "suppress") return "amber";
|
||||
return "blue";
|
||||
}
|
||||
|
||||
export function localizedText(
|
||||
locale: string,
|
||||
primary?: string | null,
|
||||
english?: string | null,
|
||||
) {
|
||||
const en = String(english || "").trim();
|
||||
const value = String(primary || "").trim();
|
||||
if (locale === "en-US" && en) return en;
|
||||
return value || en;
|
||||
}
|
||||
|
||||
export function localizedList(
|
||||
locale: string,
|
||||
primary?: string[] | null,
|
||||
english?: string[] | null,
|
||||
) {
|
||||
const en = Array.isArray(english)
|
||||
? english.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
const value = Array.isArray(primary)
|
||||
? primary.filter((item) => String(item || "").trim())
|
||||
: [];
|
||||
if (locale === "en-US" && en.length) return en;
|
||||
return value.length ? value : en;
|
||||
}
|
||||
|
||||
export function getTrendMetricVisual(metric: {
|
||||
label?: string;
|
||||
value?: string;
|
||||
tone?: string;
|
||||
}) {
|
||||
const label = String(metric.label || "").toLowerCase();
|
||||
const value = String(metric.value || "");
|
||||
const numeric = parseLeadingNumber(value);
|
||||
|
||||
if (label.includes("降水") || label.includes("precip")) {
|
||||
const precipPercent = parsePercentFromText(value);
|
||||
if (precipPercent == null) return null;
|
||||
return {
|
||||
mode: "fill" as const,
|
||||
percent: precipPercent,
|
||||
tone: "cold" as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (numeric == null) return null;
|
||||
|
||||
if (label.includes("温度") || label.includes("temp")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("露点") || label.includes("dew")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 3) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("气压") || label.includes("pressure")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 4) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("warm" as const) : ("cold" as const),
|
||||
};
|
||||
}
|
||||
|
||||
if (label.includes("云量") || label.includes("cloud")) {
|
||||
return {
|
||||
mode: "center" as const,
|
||||
percent: clamp(50 + (numeric / 40) * 50, 0, 100),
|
||||
tone: numeric >= 0 ? ("cold" as const) : ("warm" as const),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getTemperatureChartData } from "@/lib/chart-utils";
|
||||
import { getFutureModalView } from "@/lib/dashboard-utils";
|
||||
import { formatMinuteAxisLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function DailyTemperatureChart({
|
||||
dateStr,
|
||||
forceToday = false,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday?: boolean;
|
||||
}) {
|
||||
const store = useDashboardStore();
|
||||
const { locale, t } = useI18n();
|
||||
const detail = store.selectedDetail;
|
||||
const view = detail ? getFutureModalView(detail, dateStr, locale) : null;
|
||||
const isToday =
|
||||
forceToday || (detail ? dateStr === detail.local_date : false);
|
||||
const todayChartData = useMemo(
|
||||
() => (detail && isToday ? getTemperatureChartData(detail, locale) : null),
|
||||
[detail, isToday, locale],
|
||||
);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
if (!detail || !view) {
|
||||
return {
|
||||
data: { datasets: [], labels: [] },
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
if (isToday && todayChartData) {
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [];
|
||||
|
||||
datasets.push({
|
||||
borderColor: "rgba(100, 116, 139, 0.72)",
|
||||
borderDash: [6, 4],
|
||||
borderWidth: 1.6,
|
||||
data: todayChartData.datasets.debSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "DEB baseline" : "DEB 原始路径",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.calibratedFutureSeries.length > 0) {
|
||||
datasets.push({
|
||||
borderColor: "#38bdf8",
|
||||
borderWidth: 2.4,
|
||||
data: todayChartData.datasets.calibratedFutureSeries,
|
||||
fill: false,
|
||||
label:
|
||||
locale === "en-US"
|
||||
? "METAR-calibrated path"
|
||||
: "METAR 修正路径",
|
||||
parsing: false,
|
||||
pointHoverRadius: 5,
|
||||
pointRadius: 0,
|
||||
tension: 0.32,
|
||||
});
|
||||
}
|
||||
|
||||
if (todayChartData.datasets.hasMgmHourly) {
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(234, 179, 8, 0.05)",
|
||||
borderColor: "rgba(234, 179, 8, 0.8)",
|
||||
borderDash: [3, 3],
|
||||
borderWidth: 1.2,
|
||||
data: todayChartData.datasets.mgmHourlySeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Forecast" : "MGM 预测",
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 0,
|
||||
spanGaps: true,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
|
||||
datasets.push({
|
||||
backgroundColor: "#22c55e",
|
||||
borderColor: "#22c55e",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.metarSeries,
|
||||
fill: false,
|
||||
label:
|
||||
todayChartData.observationLabel ||
|
||||
(locale === "en-US" ? "Observation" : "观测实况"),
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
showLine: false,
|
||||
});
|
||||
|
||||
if (todayChartData.datasets.airportMetarSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#86efac",
|
||||
borderColor: "#86efac",
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.airportMetarSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Airport METAR" : "机场 METAR",
|
||||
order: 0,
|
||||
parsing: false,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (todayChartData.datasets.mgmSeries?.length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#facc15",
|
||||
borderColor: "#facc15",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.mgmSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "MGM Observation" : "MGM 实测",
|
||||
order: -1,
|
||||
parsing: false,
|
||||
pointHoverRadius: 9,
|
||||
pointRadius: 7,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!todayChartData.datasets.hasMgmHourly &&
|
||||
Math.abs(todayChartData.datasets.offset) > 0.3
|
||||
) {
|
||||
datasets.push({
|
||||
borderColor: "rgba(77, 163, 255, 0.22)",
|
||||
borderDash: [2, 4],
|
||||
borderWidth: 1,
|
||||
data: todayChartData.datasets.tempsSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "OM Raw" : "OM 原始",
|
||||
parsing: false,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
});
|
||||
}
|
||||
if ((todayChartData.tafMarkers || []).length > 0) {
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafCurrentMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Current TAF" : "当前 TAF",
|
||||
order: -3,
|
||||
parsing: false,
|
||||
pointHoverRadius: 8,
|
||||
pointRadius: 6,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderColor: "rgba(250, 204, 21, 0.72)",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafPeakWindowMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Peak-window TAF" : "峰值窗口 TAF",
|
||||
order: -2,
|
||||
parsing: false,
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 4,
|
||||
showLine: false,
|
||||
});
|
||||
datasets.push({
|
||||
backgroundColor: "#f59e0b",
|
||||
borderColor: "#f59e0b",
|
||||
borderWidth: 0,
|
||||
data: todayChartData.datasets.tafMarkerSeries,
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "TAF Timing" : "TAF 时段",
|
||||
order: -4,
|
||||
parsing: false,
|
||||
pointHoverRadius: 0,
|
||||
pointRadius: 0,
|
||||
showLine: false,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: [],
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "nearest" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
filter: (legendItem, chartData) => {
|
||||
const text = String(legendItem.text || "");
|
||||
if (!text) return false;
|
||||
if (text === "TAF Timing" || text === "TAF 时段")
|
||||
return false;
|
||||
if (!text.includes("DEB")) return true;
|
||||
|
||||
const firstDebIndex = (chartData.datasets || []).findIndex(
|
||||
(dataset) => String(dataset.label || "").includes("DEB"),
|
||||
);
|
||||
return legendItem.datasetIndex === firstDebIndex;
|
||||
},
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: (items) => {
|
||||
const rawX = items?.[0]?.parsed?.x;
|
||||
return rawX != null
|
||||
? formatMinuteAxisLabel(Number(rawX))
|
||||
: "";
|
||||
},
|
||||
label: (ctx) => {
|
||||
const label = String(ctx.dataset.label || "");
|
||||
const raw = ctx.raw as
|
||||
| {
|
||||
marker?: {
|
||||
summary?: string;
|
||||
markerType?: string;
|
||||
displayType?: string;
|
||||
isCurrent?: boolean;
|
||||
isPeakWindow?: boolean;
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
if (
|
||||
label === "TAF Timing" ||
|
||||
label === "TAF 时段" ||
|
||||
label === "Current TAF" ||
|
||||
label === "当前 TAF" ||
|
||||
label === "Peak-window TAF" ||
|
||||
label === "峰值窗口 TAF"
|
||||
) {
|
||||
const marker = raw?.marker;
|
||||
if (!marker) return label;
|
||||
const markerType = String(marker.markerType || "");
|
||||
const displayType = String(
|
||||
marker.displayType || marker.markerType || "",
|
||||
);
|
||||
const summary = String(marker.summary || "");
|
||||
const prefix =
|
||||
marker.isCurrent && marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Current / peak-window TAF"
|
||||
: "当前 / 峰值窗口 TAF"
|
||||
: marker.isCurrent
|
||||
? locale === "en-US"
|
||||
? "Current TAF"
|
||||
: "当前 TAF"
|
||||
: marker.isPeakWindow
|
||||
? locale === "en-US"
|
||||
? "Peak-window TAF"
|
||||
: "峰值窗口 TAF"
|
||||
: label;
|
||||
return `${prefix}: ${
|
||||
markerType
|
||||
? summary.replace(markerType, displayType)
|
||||
: summary
|
||||
}`;
|
||||
}
|
||||
const value = ctx.parsed.y;
|
||||
if (value == null) return label;
|
||||
return `${label}: ${value.toFixed(1)}${detail.temp_symbol || "°C"}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
max: todayChartData.xMax,
|
||||
min: todayChartData.xMin,
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
type: "linear",
|
||||
ticks: {
|
||||
callback: (value) => {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return "";
|
||||
const minutes = Math.round(num);
|
||||
if (
|
||||
minutes !== todayChartData.xMin &&
|
||||
minutes !== todayChartData.xMax &&
|
||||
minutes % 120 !== 0
|
||||
) {
|
||||
return "";
|
||||
}
|
||||
return formatMinuteAxisLabel(minutes);
|
||||
},
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
max: todayChartData.max,
|
||||
min: todayChartData.min,
|
||||
ticks: {
|
||||
callback: (value) =>
|
||||
`${Number(value).toFixed(todayChartData.yTickStep < 1 ? 1 : 0)}${detail.temp_symbol || "°C"}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
stepSize: todayChartData.yTickStep,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}
|
||||
|
||||
const labels = view.slice.map((point) => point.label);
|
||||
const unit = detail.temp_symbol || "°C";
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: "rgba(77, 163, 255, 0.08)",
|
||||
borderColor: "#4DA3FF",
|
||||
data: view.slice.map((point) => point.temp),
|
||||
fill: false,
|
||||
label:
|
||||
locale === "en-US" ? "Open-Meteo Temperature" : "Open-Meteo 温度",
|
||||
pointRadius: 2,
|
||||
tension: 0.28,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#93C5FD",
|
||||
borderDash: [5, 4],
|
||||
data: view.slice.map((point) => point.dewPoint),
|
||||
fill: false,
|
||||
label: locale === "en-US" ? "Dew Point" : "露点",
|
||||
pointRadius: 0,
|
||||
tension: 0.24,
|
||||
},
|
||||
],
|
||||
labels,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: "#9FB2C7",
|
||||
font: { family: "Inter", size: 11 },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.96)",
|
||||
borderColor: "rgba(77, 163, 255, 0.24)",
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}${unit}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
maxRotation: 0,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
callback: (value) => `${value}${unit}`,
|
||||
color: "#6B7A90",
|
||||
font: { family: "Inter", size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [detail, isToday, locale, todayChartData, view]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="history-chart-wrapper future-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
{isToday && (
|
||||
<div className="chart-legend">
|
||||
{todayChartData?.legendText || t("future.chartLegendEmpty")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import type { useDashboardStore } from "@/hooks/useDashboardStore";
|
||||
import type { MarketScan } from "@/lib/dashboard-types";
|
||||
|
||||
const DailyTemperatureChart = dynamic(
|
||||
() =>
|
||||
import("./FutureForecastModalChart").then(
|
||||
(module) => module.DailyTemperatureChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="history-chart-wrapper future-chart-wrapper" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ProbabilityDistribution = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ProbabilityDistribution,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
const ModelForecast = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/PanelSections").then(
|
||||
(module) => module.ModelForecast,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="future-v2-panel-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
type DashboardDetail = NonNullable<
|
||||
ReturnType<typeof useDashboardStore>["selectedDetail"]
|
||||
>;
|
||||
|
||||
export function FutureTemperaturePathChart({
|
||||
dateStr,
|
||||
forceToday,
|
||||
}: {
|
||||
dateStr: string;
|
||||
forceToday: boolean;
|
||||
}) {
|
||||
return <DailyTemperatureChart dateStr={dateStr} forceToday={forceToday} />;
|
||||
}
|
||||
|
||||
export function FutureProbabilityPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
marketScan,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
marketScan?: MarketScan | null;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ProbabilityDistribution
|
||||
detail={detail}
|
||||
targetDate={targetDate}
|
||||
marketScan={marketScan}
|
||||
hideTitle={hideTitle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureModelForecastPanel({
|
||||
detail,
|
||||
targetDate,
|
||||
hideTitle = true,
|
||||
}: {
|
||||
detail: DashboardDetail;
|
||||
targetDate: string;
|
||||
hideTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ModelForecast detail={detail} targetDate={targetDate} hideTitle={hideTitle} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
export type FutureSyncStatusItem = {
|
||||
key: string;
|
||||
state: "ready" | "syncing";
|
||||
label: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function FutureRefreshLock({ locale }: { locale: string }) {
|
||||
return (
|
||||
<div
|
||||
className="future-v2-refresh-lock"
|
||||
role="status"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<span className="future-v2-refresh-spinner" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>
|
||||
{locale === "en-US"
|
||||
? "Refreshing latest intraday data"
|
||||
: "正在刷新最新日内数据"}
|
||||
</strong>
|
||||
<p>
|
||||
{locale === "en-US"
|
||||
? "Old cached readings are temporarily locked to prevent misjudgement. The analysis will unlock after the latest anchor observation, model layer, and probability layer are ready."
|
||||
: "旧缓存读数已临时锁定,避免误判。最新锚点观测、模型层和概率层就绪后会自动解锁。"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FutureSyncStatusStrip({
|
||||
items,
|
||||
compact = false,
|
||||
}: {
|
||||
items: readonly FutureSyncStatusItem[];
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className={clsx("future-v2-sync-strip", compact && "future-v2-sync-strip-compact")}
|
||||
aria-live="polite"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className={clsx(
|
||||
"future-v2-sync-chip",
|
||||
item.state === "syncing" && "syncing",
|
||||
)}
|
||||
>
|
||||
<span className="future-v2-sync-dot" aria-hidden="true" />
|
||||
<div className="future-v2-sync-copy">
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.note}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Cloud,
|
||||
CloudFog,
|
||||
CloudLightning,
|
||||
CloudRain,
|
||||
CloudSnow,
|
||||
CloudSun,
|
||||
Search,
|
||||
Sun,
|
||||
Wind,
|
||||
} from "lucide-react";
|
||||
|
||||
export function WeatherIcon({ emoji, size = 32 }: { emoji: string; size?: number }) {
|
||||
if (emoji === "☀️") return <Sun size={size} color="#facc15" />;
|
||||
if (emoji === "⛅" || emoji === "🌤️")
|
||||
return <CloudSun size={size} color="#4DA3FF" />;
|
||||
if (emoji === "☁️") return <Cloud size={size} color="#9FB2C7" />;
|
||||
if (emoji === "🌧️" || emoji === "🌦️")
|
||||
return <CloudRain size={size} color="#60a5fa" />;
|
||||
if (emoji === "⛈️") return <CloudLightning size={size} color="#c084fc" />;
|
||||
if (emoji === "❄️" || emoji === "🌨️")
|
||||
return <CloudSnow size={size} color="#7dd3fc" />;
|
||||
if (emoji === "🌫️") return <CloudFog size={size} color="#a1a1aa" />;
|
||||
if (emoji === "💨") return <Wind size={size} color="#cbd5e1" />;
|
||||
return <Search size={size} color="#6B7A90" />;
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import clsx from "clsx";
|
||||
import type { CSSProperties } from "react";
|
||||
import type { getTodayPaceView } from "@/lib/pace-utils";
|
||||
import { WeatherIcon } from "./FutureForecastModalWeatherIcon";
|
||||
|
||||
type Locale = string;
|
||||
type TodayPaceView = NonNullable<ReturnType<typeof getTodayPaceView>>;
|
||||
|
||||
type WeatherSummaryView = {
|
||||
weatherIcon: string;
|
||||
weatherText: string;
|
||||
};
|
||||
|
||||
type DaylightProgressView = {
|
||||
phase: string;
|
||||
percent: number;
|
||||
};
|
||||
|
||||
export type FuturePaceSignalItem = {
|
||||
label: string;
|
||||
tone: "cyan" | "blue" | "amber";
|
||||
status: string;
|
||||
value: string;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export function FutureAnchorStatusCard({
|
||||
locale,
|
||||
currentTempText,
|
||||
weatherSummary,
|
||||
obsTime,
|
||||
daylightProgress,
|
||||
sunrise,
|
||||
sunset,
|
||||
topObservedTemp,
|
||||
tempSymbol,
|
||||
gapToBaseBucket,
|
||||
pathStatus,
|
||||
}: {
|
||||
locale: Locale;
|
||||
currentTempText: string;
|
||||
weatherSummary: WeatherSummaryView;
|
||||
obsTime?: string | null;
|
||||
daylightProgress: DaylightProgressView | null;
|
||||
sunrise?: string | null;
|
||||
sunset?: string | null;
|
||||
topObservedTemp: number | string | null | undefined;
|
||||
tempSymbol: string;
|
||||
gapToBaseBucket: number | null;
|
||||
pathStatus: string;
|
||||
}) {
|
||||
const observedHigh = topObservedTemp ?? "--";
|
||||
|
||||
return (
|
||||
<section className="future-v2-card future-v2-hero-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h3 className="future-v2-hero-title">
|
||||
{locale === "en-US" ? "Anchor Status" : "锚点状态"}
|
||||
</h3>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Settlement anchor and current clock"
|
||||
: "结算锚点与当前时钟"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-main">
|
||||
<div className="future-v2-hero-temp">{currentTempText}</div>
|
||||
<div className="future-v2-hero-divider" />
|
||||
<div className="future-v2-hero-weather">
|
||||
<span className="future-v2-hero-icon">
|
||||
<WeatherIcon emoji={weatherSummary.weatherIcon} size={42} />
|
||||
</span>
|
||||
<span>{weatherSummary.weatherText}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-hero-obs">@{obsTime || "--"}</div>
|
||||
{daylightProgress ? (
|
||||
<div className="future-v2-daylight">
|
||||
<div className="future-v2-daylight-head">
|
||||
<span>{locale === "en-US" ? "Solar Window" : "昼夜进度"}</span>
|
||||
<strong>
|
||||
{daylightProgress.phase} {Math.round(daylightProgress.percent)}%
|
||||
</strong>
|
||||
</div>
|
||||
<div
|
||||
className="future-v2-daylight-bar"
|
||||
style={
|
||||
{
|
||||
"--daylight-progress": `${daylightProgress.percent}%`,
|
||||
} as CSSProperties & { "--daylight-progress": string }
|
||||
}
|
||||
/>
|
||||
<div className="future-v2-daylight-times">
|
||||
<span>{sunrise || "--"}</span>
|
||||
<span>{sunset || "--"}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="future-v2-mini-grid">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "High so far" : "日内已见高点"}
|
||||
value={`${observedHigh}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Anchor clock" : "锚点时钟"}
|
||||
value={obsTime || "--"}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Gap to base" : "距基准档"}
|
||||
value={
|
||||
gapToBaseBucket != null
|
||||
? `${gapToBaseBucket.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Path state" : "路径状态"}
|
||||
value={pathStatus}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Sunrise" : "日出时间"}
|
||||
value={sunrise || "--"}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Sunset" : "日落时间"}
|
||||
value={sunset || "--"}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceCard({
|
||||
locale,
|
||||
paceView,
|
||||
tempSymbol,
|
||||
signalItems,
|
||||
}: {
|
||||
locale: Locale;
|
||||
paceView: TodayPaceView;
|
||||
tempSymbol: string;
|
||||
signalItems: FuturePaceSignalItem[];
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-pace-card future-v2-focus-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Expected now vs airport anchor"
|
||||
: "此刻应到 vs 机场锚点"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-pace-head">
|
||||
<span className="future-v2-pace-kicker">{paceView.kicker}</span>
|
||||
<FutureSignalTag tone={paceView.biasTone}>{paceView.badge}</FutureSignalTag>
|
||||
</div>
|
||||
<div
|
||||
className={clsx(
|
||||
"future-v2-pace-delta",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
>
|
||||
{paceView.deltaText}
|
||||
</div>
|
||||
<div className="future-v2-pace-summary">{paceView.summary}</div>
|
||||
<div className="future-v2-pace-meter">
|
||||
<span className="future-v2-pace-meter-midline" />
|
||||
<span
|
||||
className={clsx(
|
||||
"future-v2-pace-meter-fill",
|
||||
paceView.biasTone === "cold" && "cold",
|
||||
paceView.biasTone === "neutral" && "neutral",
|
||||
paceView.biasTone === "warm" && "warm",
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--pace-left": `${paceView.meterLeft}%`,
|
||||
"--pace-width": `${paceView.meterWidth}%`,
|
||||
} as CSSProperties & {
|
||||
"--pace-left": string;
|
||||
"--pace-width": string;
|
||||
}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="future-v2-mini-grid future-v2-mini-grid-tight">
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Expected now" : "预期此刻"}
|
||||
value={`${paceView.expectedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.observedLabel}
|
||||
value={`${paceView.observedNow.toFixed(1)}${tempSymbol}`}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={paceView.paceAdjustedLabel}
|
||||
value={
|
||||
paceView.paceAdjustedHigh != null
|
||||
? `${paceView.paceAdjustedHigh.toFixed(1)}${tempSymbol}`
|
||||
: "--"
|
||||
}
|
||||
/>
|
||||
<FutureMiniMetric
|
||||
label={locale === "en-US" ? "Peak window" : "峰值窗口"}
|
||||
value={paceView.peakWindowText}
|
||||
/>
|
||||
</div>
|
||||
{signalItems.length ? (
|
||||
<div className="future-v2-pace-signal-grid">
|
||||
{signalItems.map((item) => (
|
||||
<div key={item.label} className="future-v2-pace-signal-card">
|
||||
<div className="future-v2-signal-head">
|
||||
<span>{item.label}</span>
|
||||
<FutureSignalTag tone={item.tone}>{item.status}</FutureSignalTag>
|
||||
</div>
|
||||
<strong>{item.value}</strong>
|
||||
<div className="future-v2-pace-signal-note">{item.note}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function FuturePaceLoadingCard({ locale }: { locale: Locale }) {
|
||||
return (
|
||||
<section className="future-v2-card future-v2-support-card">
|
||||
<div className="future-v2-card-head">
|
||||
<h4 className="future-v2-card-title">
|
||||
{locale === "en-US" ? "Current Pace" : "当前节奏"}
|
||||
</h4>
|
||||
<div className="future-v2-card-kicker">
|
||||
{locale === "en-US"
|
||||
? "Backfilling intraday pace context"
|
||||
: "正在补齐日内节奏上下文"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-trend-summary future-trend-summary-muted">
|
||||
{locale === "en-US"
|
||||
? "Expected-now pace, boundary risk, and airport-vs-network cues are loading in the background."
|
||||
: "预期此刻节奏、边界风险和机场对比站网信号正在后台补齐。"}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureMiniMetric({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="future-v2-mini-item">
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FutureSignalTag({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: string;
|
||||
children: string;
|
||||
}) {
|
||||
return (
|
||||
<em
|
||||
className={clsx(
|
||||
"future-v2-signal-tag",
|
||||
(tone === "cold" || tone === "cyan") && "cyan",
|
||||
(tone === "neutral" || tone === "blue") && "blue",
|
||||
(tone === "warm" || tone === "amber") && "amber",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</em>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { formatConfidenceLabel } from "./FutureForecastModal.utils";
|
||||
|
||||
export function FutureForecastTodayDecisionBrief({
|
||||
anchorRuleText,
|
||||
anchorSourceLabel,
|
||||
baseCaseBucket,
|
||||
confidence,
|
||||
displayName,
|
||||
downsideBucket,
|
||||
gapToBaseText,
|
||||
locale,
|
||||
meteorologyHeadline,
|
||||
nextObservationLabel,
|
||||
nextObservationTime,
|
||||
pathStatus,
|
||||
settlementStationName,
|
||||
upsideBucket,
|
||||
}: {
|
||||
anchorRuleText: string;
|
||||
anchorSourceLabel: string;
|
||||
baseCaseBucket: string;
|
||||
confidence: string | null | undefined;
|
||||
displayName: string;
|
||||
downsideBucket: string | null | undefined;
|
||||
gapToBaseText: string;
|
||||
locale: string;
|
||||
meteorologyHeadline: string;
|
||||
nextObservationLabel: string;
|
||||
nextObservationTime: string;
|
||||
pathStatus: string;
|
||||
settlementStationName: string;
|
||||
upsideBucket: string | null | undefined;
|
||||
}) {
|
||||
return (
|
||||
<section className="future-v2-meteorology-brief">
|
||||
<div className="future-v2-meteorology-copy">
|
||||
<div className="future-v2-anchor-row">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Professional meteorology read" : "专业气象判断"}
|
||||
</div>
|
||||
<span className="future-v2-anchor-source">{anchorSourceLabel}</span>
|
||||
</div>
|
||||
<h3>{meteorologyHeadline}</h3>
|
||||
<p className="future-v2-anchor-rule">{anchorRuleText}</p>
|
||||
<div className="future-v2-meteorology-meta">
|
||||
<span>
|
||||
{locale === "en-US" ? "Confidence" : "置信度"} ·{" "}
|
||||
{formatConfidenceLabel(confidence, locale)}
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Path state" : "路径状态"} · {pathStatus}
|
||||
</span>
|
||||
<span>
|
||||
{nextObservationLabel} · {nextObservationTime}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="future-v2-decision-rail" aria-label={displayName}>
|
||||
<div className="future-v2-decision-anchor">
|
||||
<span>{locale === "en-US" ? "Anchor" : "锚点"}</span>
|
||||
<strong>{settlementStationName}</strong>
|
||||
<small>{anchorSourceLabel}</small>
|
||||
</div>
|
||||
<div className="future-v2-decision-grid">
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Base" : "基准"}</span>
|
||||
<strong>{baseCaseBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Upside" : "上修"}</span>
|
||||
<strong>{upsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Downside" : "下修"}</span>
|
||||
<strong>{downsideBucket || "--"}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Gap to base" : "距基准还差"}</span>
|
||||
<strong>{gapToBaseText}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import {
|
||||
formatSignalDirection,
|
||||
formatSignalStrength,
|
||||
localizedText,
|
||||
signalTone,
|
||||
} from "./FutureForecastModal.utils";
|
||||
|
||||
type MeteorologySignal = {
|
||||
direction?: string | null;
|
||||
label?: string | null;
|
||||
label_en?: string | null;
|
||||
strength?: string | null;
|
||||
summary?: string | null;
|
||||
summary_en?: string | null;
|
||||
};
|
||||
|
||||
export function FutureForecastTodayEvidenceGrid({
|
||||
airportMetarAnchor,
|
||||
confirmationRules,
|
||||
invalidationRules,
|
||||
locale,
|
||||
meteorologySignals,
|
||||
modelSummary,
|
||||
}: {
|
||||
airportMetarAnchor: boolean;
|
||||
confirmationRules: string[];
|
||||
invalidationRules: string[];
|
||||
locale: string;
|
||||
meteorologySignals: MeteorologySignal[];
|
||||
modelSummary: string;
|
||||
}) {
|
||||
const fallbackInvalidationRule =
|
||||
locale === "en-US"
|
||||
? "If observations stop tracking the expected curve, wait for the next refresh."
|
||||
: "若实测不再贴近预期曲线,等待下一次刷新确认。";
|
||||
const fallbackConfirmationRule =
|
||||
locale === "en-US"
|
||||
? airportMetarAnchor
|
||||
? "Keep watching the next anchor METAR report."
|
||||
: "Keep watching the next official anchor observation."
|
||||
: airportMetarAnchor
|
||||
? "继续观察下一次锚点 METAR 报文。"
|
||||
: "继续观察下一次官方锚点观测。";
|
||||
|
||||
return (
|
||||
<div className="future-v2-meteorology-grid">
|
||||
<section className="future-modal-section future-v2-evidence-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Evidence chain" : "气象证据链"}
|
||||
</div>
|
||||
<h3>{locale === "en-US" ? "Signal Contributions" : "信号贡献"}</h3>
|
||||
</div>
|
||||
<div className="future-v2-evidence-list">
|
||||
{meteorologySignals.length > 0 ? (
|
||||
meteorologySignals.map((signal, index) => (
|
||||
<div
|
||||
key={`${signal.label || "signal"}-${index}`}
|
||||
className={clsx("future-v2-evidence-row", signalTone(signal))}
|
||||
>
|
||||
<div className="future-v2-evidence-head">
|
||||
<strong>
|
||||
{localizedText(locale, signal.label, signal.label_en) || "--"}
|
||||
</strong>
|
||||
<span>
|
||||
{formatSignalDirection(signal.direction, locale)} ·{" "}
|
||||
{formatSignalStrength(signal.strength, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
{localizedText(locale, signal.summary, signal.summary_en) ||
|
||||
"--"}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="future-text-block">
|
||||
{locale === "en-US"
|
||||
? "Meteorology signals are still loading."
|
||||
: "气象信号仍在加载。"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Failure modes" : "失效条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Downgrades the Read" : "什么会让判断降级"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(invalidationRules.length > 0
|
||||
? invalidationRules
|
||||
: [fallbackInvalidationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="future-modal-section future-v2-rule-panel">
|
||||
<div className="modal-section-heading">
|
||||
<div className="modal-section-kicker">
|
||||
{locale === "en-US" ? "Confirmation" : "确认条件"}
|
||||
</div>
|
||||
<h3>
|
||||
{locale === "en-US" ? "What Confirms the Path" : "什么会确认主路径"}
|
||||
</h3>
|
||||
</div>
|
||||
<ul className="future-v2-rule-list">
|
||||
{(confirmationRules.length > 0
|
||||
? confirmationRules
|
||||
: [fallbackConfirmationRule]
|
||||
).map((rule, index) => (
|
||||
<li key={`${rule}-${index}`}>{rule}</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="future-v2-model-note">{modelSummary}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { getHistorySummary } from "@/lib/dashboard-utils";
|
||||
|
||||
export function HistoryChart() {
|
||||
const store = useDashboardStore();
|
||||
const { locale } = useI18n();
|
||||
const { data } = useHistoryData();
|
||||
const isNoaaSettlement =
|
||||
store.selectedDetail?.current?.settlement_source === "noaa" ||
|
||||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
|
||||
const noaaStationCode = String(
|
||||
store.selectedDetail?.current?.station_code ||
|
||||
store.selectedDetail?.risk?.icao ||
|
||||
"NOAA",
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const summary = useMemo(
|
||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||
[data, store.selectedDetail?.local_date],
|
||||
);
|
||||
const hasMgm =
|
||||
store.selectedCity === "ankara" &&
|
||||
summary.mgmSeriesComplete &&
|
||||
summary.mgms.some((value) => value != null);
|
||||
const hasBestBaseline =
|
||||
Boolean(summary.bestModelName) &&
|
||||
summary.bestModelName !== "MGM" &&
|
||||
summary.bestModelSeries.some((value) => value != null);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [
|
||||
{
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderColor: "#f87171",
|
||||
borderWidth: 2,
|
||||
data: summary.actuals,
|
||||
label: isNoaaSettlement
|
||||
? locale === "en-US"
|
||||
? `NOAA Settled High (${noaaStationCode})`
|
||||
: `NOAA 结算最高温 (${noaaStationCode})`
|
||||
: locale === "en-US"
|
||||
? "Observed High"
|
||||
: "实测最高温",
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
tension: 0.2,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#34d399",
|
||||
borderDash: [5, 4],
|
||||
borderWidth: 2,
|
||||
data: summary.debs,
|
||||
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasMgm) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#fb923c",
|
||||
borderWidth: 2,
|
||||
data: summary.mgms,
|
||||
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBestBaseline) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#60a5fa",
|
||||
borderDash: [4, 3],
|
||||
borderWidth: 2,
|
||||
data: summary.bestModelSeries,
|
||||
label:
|
||||
locale === "en-US"
|
||||
? `Best Baseline (${summary.bestModelName})`
|
||||
: `最佳单模型 (${summary.bestModelName})`,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: summary.dates,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
boxHeight: 12,
|
||||
boxWidth: 34,
|
||||
color: "#94a3b8",
|
||||
font: { family: "Inter", size: 14 },
|
||||
padding: 18,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
bodyFont: { family: "Inter", size: 13 },
|
||||
titleFont: { family: "Inter", size: 13, weight: 600 },
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 12 },
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 12 },
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
|
||||
|
||||
if (!summary.recentData.length) return null;
|
||||
|
||||
return (
|
||||
<div className="history-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,530 @@
|
||||
/* History modal styles moved out of Dashboard.module.css. */
|
||||
|
||||
.root :global(.history-btn) {
|
||||
background: rgba(34, 211, 238, 0.1);
|
||||
color: var(--accent-cyan);
|
||||
border: 1px solid rgba(34, 211, 238, 0.3);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-btn:hover) {
|
||||
background: rgba(34, 211, 238, 0.2);
|
||||
border-color: var(--accent-cyan);
|
||||
}
|
||||
|
||||
.root :global(.history-btn:disabled) {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.root :global(.modal-content.history-modal) {
|
||||
width: min(96vw, 1180px);
|
||||
max-width: 1180px;
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .modal-body) {
|
||||
padding: 24px 28px 28px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-stats) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .h-stat-card) {
|
||||
min-width: 0;
|
||||
padding: 16px 18px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0.035) 0%,
|
||||
rgba(255, 255, 255, 0.02) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .h-stat-card .label) {
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .h-stat-card .val) {
|
||||
font-size: 32px;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-chart-wrapper) {
|
||||
height: 420px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
padding: 12px 14px 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-chart-wrapper canvas) {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-chart-loading) {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.025),
|
||||
rgba(96, 165, 250, 0.08),
|
||||
rgba(255, 255, 255, 0.025)
|
||||
);
|
||||
background-size: 220% 100%;
|
||||
animation: history-chart-loading 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes history-chart-loading {
|
||||
0% {
|
||||
background-position: 100% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -100% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-modal-loading) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-loading) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
width: min(100%, 420px);
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.16);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(10, 18, 36, 0.72) 0%,
|
||||
rgba(8, 14, 30, 0.5) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 18px 48px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-scan) {
|
||||
position: relative;
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(14, 165, 233, 0.08) 0%,
|
||||
rgba(14, 165, 233, 0.02) 62%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-ring) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(56, 189, 248, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-ring-1) {
|
||||
transform: scale(0.7);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-ring-2) {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-sweep) {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(
|
||||
from 120deg,
|
||||
rgba(34, 211, 238, 0) 0deg,
|
||||
rgba(34, 211, 238, 0.04) 220deg,
|
||||
rgba(56, 189, 248, 0.78) 300deg,
|
||||
rgba(253, 224, 71, 0.32) 332deg,
|
||||
rgba(34, 211, 238, 0) 360deg
|
||||
);
|
||||
animation: history-fetch-spin 2.2s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-core) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(103, 232, 249, 0.95);
|
||||
box-shadow: 0 0 18px rgba(34, 211, 238, 0.42);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bars) {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 7px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bar) {
|
||||
width: 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(250, 204, 21, 0.18) 0%,
|
||||
rgba(251, 146, 60, 0.9) 100%
|
||||
);
|
||||
animation: history-fetch-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bar-1) {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bar-2) {
|
||||
height: 18px;
|
||||
animation-delay: 0.18s;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bar-3) {
|
||||
height: 14px;
|
||||
animation-delay: 0.34s;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-bar-4) {
|
||||
height: 20px;
|
||||
animation-delay: 0.5s;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-lines) {
|
||||
position: relative;
|
||||
width: 180px;
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-line) {
|
||||
position: absolute;
|
||||
left: -26%;
|
||||
height: 1px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(34, 211, 238, 0) 0%,
|
||||
rgba(125, 211, 252, 0.72) 48%,
|
||||
rgba(34, 211, 238, 0) 100%
|
||||
);
|
||||
animation: history-fetch-flow 2.4s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-line-1) {
|
||||
top: 4px;
|
||||
width: 122px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-line-2) {
|
||||
top: 11px;
|
||||
width: 168px;
|
||||
animation-delay: -0.8s;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-line-3) {
|
||||
top: 18px;
|
||||
width: 138px;
|
||||
animation-delay: -1.4s;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-copy) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-copy strong) {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(103, 232, 249, 0.94);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-copy span) {
|
||||
max-width: 300px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
color: rgba(203, 213, 225, 0.82);
|
||||
}
|
||||
|
||||
@keyframes history-fetch-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes history-fetch-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.86);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1.08);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes history-fetch-flow {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(150%);
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference) {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference) {
|
||||
margin-top: 18px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
border-radius: 16px;
|
||||
background: rgba(8, 47, 73, 0.2);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-title) {
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-scroll),
|
||||
.root :global(.history-modal .history-peak-reference-scroll) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding-right: 6px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-scroll) {
|
||||
scrollbar-color: rgba(34, 211, 238, 0.5) rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll) {
|
||||
scrollbar-color: rgba(96, 165, 250, 0.5) rgba(15, 23, 42, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-track) {
|
||||
background: rgba(15, 23, 42, 0.32);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll::-webkit-scrollbar-thumb) {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(56, 189, 248, 0.72) 0%,
|
||||
rgba(96, 165, 250, 0.52) 100%
|
||||
);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-row),
|
||||
.root :global(.history-modal .history-model-reference-row) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(72px, 88px) 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-date) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-body) {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-summary) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px 14px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-summary strong) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-models) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-model) {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(15, 23, 42, 0.38);
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-name) {
|
||||
overflow: hidden;
|
||||
color: var(--accent-cyan);
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-error) {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-meta) {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.root :global(.history-modal .modal-body) {
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-stats) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-chart-wrapper) {
|
||||
height: 360px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-modal-loading) {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-loading) {
|
||||
width: min(100%, 360px);
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll),
|
||||
.root :global(.history-modal .history-model-reference-scroll) {
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-models) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.modal-content.history-modal) {
|
||||
width: min(100vw - 12px, 100%);
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-stats) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .h-stat-card .val) {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-chart-wrapper) {
|
||||
height: 300px;
|
||||
padding: 8px 10px 6px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-modal-loading) {
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-loading) {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-lines) {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-fetch-scan) {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference),
|
||||
.root :global(.history-modal .history-model-reference) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-scroll),
|
||||
.root :global(.history-modal .history-model-reference-scroll) {
|
||||
max-height: 260px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-peak-reference-row),
|
||||
.root :global(.history-modal .history-model-reference-row) {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.history-modal .history-model-reference-models) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,167 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import type { ChartConfiguration } from "chart.js";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useMemo } from "react";
|
||||
import { useChart } from "@/hooks/useChart";
|
||||
import { useDashboardStore, useHistoryData } from "@/hooks/useDashboardStore";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||||
import { getHistorySummary } from "@/lib/dashboard-utils";
|
||||
|
||||
function HistoryChart() {
|
||||
const store = useDashboardStore();
|
||||
const { locale } = useI18n();
|
||||
const { data } = useHistoryData();
|
||||
const isNoaaSettlement =
|
||||
store.selectedDetail?.current?.settlement_source === "noaa" ||
|
||||
store.selectedDetail?.current?.settlement_source_label === "NOAA";
|
||||
const noaaStationCode = String(
|
||||
store.selectedDetail?.current?.station_code ||
|
||||
store.selectedDetail?.risk?.icao ||
|
||||
"NOAA",
|
||||
)
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
const summary = useMemo(
|
||||
() => getHistorySummary(data, store.selectedDetail?.local_date),
|
||||
[data, store.selectedDetail?.local_date],
|
||||
);
|
||||
const hasMgm =
|
||||
store.selectedCity === "ankara" &&
|
||||
summary.mgmSeriesComplete &&
|
||||
summary.mgms.some((value) => value != null);
|
||||
const hasBestBaseline =
|
||||
Boolean(summary.bestModelName) &&
|
||||
summary.bestModelName !== "MGM" &&
|
||||
summary.bestModelSeries.some((value) => value != null);
|
||||
|
||||
const canvasRef = useChart(() => {
|
||||
const datasets: NonNullable<
|
||||
ChartConfiguration<"line">["data"]
|
||||
>["datasets"] = [
|
||||
{
|
||||
backgroundColor: "rgba(248, 113, 113, 0.1)",
|
||||
borderColor: "#f87171",
|
||||
borderWidth: 2,
|
||||
data: summary.actuals,
|
||||
label: isNoaaSettlement
|
||||
? locale === "en-US"
|
||||
? `NOAA Settled High (${noaaStationCode})`
|
||||
: `NOAA 结算最高温 (${noaaStationCode})`
|
||||
: locale === "en-US"
|
||||
? "Observed High"
|
||||
: "实测最高温",
|
||||
pointBackgroundColor: "#f87171",
|
||||
pointBorderColor: "#fff",
|
||||
pointHoverRadius: 7,
|
||||
pointRadius: 5,
|
||||
tension: 0.2,
|
||||
},
|
||||
{
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#34d399",
|
||||
borderDash: [5, 4],
|
||||
borderWidth: 2,
|
||||
data: summary.debs,
|
||||
label: locale === "en-US" ? "DEB Fusion" : "DEB 融合",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
if (hasMgm) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#fb923c",
|
||||
borderWidth: 2,
|
||||
data: summary.mgms,
|
||||
label: locale === "en-US" ? "MGM Official Forecast" : "MGM 官方预报",
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasBestBaseline) {
|
||||
datasets.push({
|
||||
backgroundColor: "transparent",
|
||||
borderColor: "#60a5fa",
|
||||
borderDash: [4, 3],
|
||||
borderWidth: 2,
|
||||
data: summary.bestModelSeries,
|
||||
label:
|
||||
locale === "en-US"
|
||||
? `Best Baseline (${summary.bestModelName})`
|
||||
: `最佳单模型 (${summary.bestModelName})`,
|
||||
pointHoverRadius: 6,
|
||||
pointRadius: 4,
|
||||
tension: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
datasets,
|
||||
labels: summary.dates,
|
||||
},
|
||||
options: {
|
||||
interaction: { intersect: false, mode: "index" },
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
boxHeight: 12,
|
||||
boxWidth: 34,
|
||||
color: "#94a3b8",
|
||||
font: { family: "Inter", size: 14 },
|
||||
padding: 18,
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
backgroundColor: "rgba(15, 23, 42, 0.9)",
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
borderWidth: 1,
|
||||
bodyFont: { family: "Inter", size: 13 },
|
||||
titleFont: { family: "Inter", size: 13, weight: 600 },
|
||||
callbacks: {
|
||||
label: (ctx) =>
|
||||
`${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}°`,
|
||||
},
|
||||
},
|
||||
},
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 12 },
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
grid: { color: "rgba(255,255,255,0.04)" },
|
||||
ticks: {
|
||||
color: "#64748b",
|
||||
font: { family: "Inter", size: 12 },
|
||||
padding: 8,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: "line",
|
||||
} satisfies ChartConfiguration<"line">;
|
||||
}, [hasBestBaseline, hasMgm, isNoaaSettlement, noaaStationCode, summary, locale]);
|
||||
|
||||
if (!summary.recentData.length) return null;
|
||||
|
||||
return (
|
||||
<div className="history-chart-wrapper">
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const HistoryChart = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/HistoryChart").then(
|
||||
(module) => module.HistoryChart,
|
||||
),
|
||||
{
|
||||
loading: () => <div className="history-chart-wrapper history-chart-loading" />,
|
||||
ssr: false,
|
||||
},
|
||||
);
|
||||
|
||||
export function HistoryModal() {
|
||||
const store = useDashboardStore();
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import { CityDetail } from "@/lib/dashboard-types";
|
||||
import { getModelView } from "@/lib/model-utils";
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ModelMetadata = NonNullable<
|
||||
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
||||
>["model_metadata"];
|
||||
|
||||
function getModelGroupMeta(
|
||||
name: string,
|
||||
metadata: ModelMetadata,
|
||||
locale: string,
|
||||
) {
|
||||
const meta = metadata?.[name] || {};
|
||||
const tier = String(meta.tier || "").toLowerCase();
|
||||
const upperName = String(name || "").toUpperCase();
|
||||
|
||||
if (tier.includes("aifs") || upperName.includes("AIFS")) {
|
||||
return {
|
||||
key: "aifs",
|
||||
label: locale === "en-US" ? "AIFS model" : "AIFS 模型",
|
||||
order: 1,
|
||||
tone: "blue",
|
||||
};
|
||||
}
|
||||
if (
|
||||
tier.includes("europe") ||
|
||||
upperName.includes("ICON-EU") ||
|
||||
upperName.includes("ICON-D2")
|
||||
) {
|
||||
return {
|
||||
key: "europe",
|
||||
label: locale === "en-US" ? "Europe high-resolution" : "欧洲高分辨率",
|
||||
order: 2,
|
||||
tone: "cyan",
|
||||
};
|
||||
}
|
||||
if (
|
||||
tier.includes("north_america") ||
|
||||
upperName === "RDPS" ||
|
||||
upperName === "HRDPS"
|
||||
) {
|
||||
return {
|
||||
key: "north-america",
|
||||
label:
|
||||
locale === "en-US" ? "North America high-resolution" : "北美高分辨率",
|
||||
order: 3,
|
||||
tone: "amber",
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: "global",
|
||||
label: locale === "en-US" ? "Global baseline" : "全球基准",
|
||||
order: 0,
|
||||
tone: "neutral",
|
||||
};
|
||||
}
|
||||
|
||||
function formatModelMetaLine(
|
||||
name: string,
|
||||
metadata: ModelMetadata,
|
||||
locale: string,
|
||||
) {
|
||||
const meta = metadata?.[name] || {};
|
||||
const provider = String(meta.provider || "").trim();
|
||||
const model = String(meta.model || "").trim();
|
||||
const horizon = String(meta.horizon || "").trim();
|
||||
const resolution = Number(meta.resolution_km);
|
||||
const parts = [
|
||||
provider,
|
||||
model && model !== name ? model : "",
|
||||
Number.isFinite(resolution)
|
||||
? `${resolution}${locale === "en-US" ? " km" : " 公里"}`
|
||||
: "",
|
||||
horizon,
|
||||
].filter(Boolean);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
|
||||
export function ModelForecast({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
}) {
|
||||
const { locale, t } = useI18n();
|
||||
const view = getModelView(detail, targetDate);
|
||||
const modelsMap = { ...view.models };
|
||||
const modelMetadata =
|
||||
detail.source_forecasts?.open_meteo_multi_model?.model_metadata || {};
|
||||
|
||||
const modelEntries = Object.entries(modelsMap).filter(
|
||||
([, value]) =>
|
||||
value !== null && value !== undefined && Number.isFinite(Number(value)),
|
||||
);
|
||||
const hasSingleModelOnly = modelEntries.length === 1;
|
||||
|
||||
// 如果没有任何数值,给出提示
|
||||
if (modelEntries.length === 0) {
|
||||
return (
|
||||
<section className="models-section">
|
||||
{!hideTitle && <h3>{t("section.models")}</h3>}
|
||||
<div className="model-bars">
|
||||
<EmptyState text={t("section.noModels")} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const numericValues = modelEntries.map(([, value]) => Number(value));
|
||||
const comparisonValues =
|
||||
view.deb != null ? [...numericValues, Number(view.deb)] : numericValues;
|
||||
const minValue = comparisonValues.length
|
||||
? Math.min(...comparisonValues) - 1
|
||||
: 0;
|
||||
const maxValue = comparisonValues.length
|
||||
? Math.max(...comparisonValues) + 1
|
||||
: 1;
|
||||
const range = Math.max(maxValue - minValue, 1);
|
||||
const sortedEntries = modelEntries.sort(
|
||||
(a, b) => Number(b[1] || 0) - Number(a[1] || 0),
|
||||
);
|
||||
const groupedEntries = sortedEntries
|
||||
.reduce(
|
||||
(acc, [name, value]) => {
|
||||
const group = getModelGroupMeta(name, modelMetadata, locale);
|
||||
const existing = acc.find((item) => item.key === group.key);
|
||||
const entry = {
|
||||
metaLine: formatModelMetaLine(name, modelMetadata, locale),
|
||||
name,
|
||||
value: Number(value),
|
||||
};
|
||||
if (existing) {
|
||||
existing.entries.push(entry);
|
||||
} else {
|
||||
acc.push({ ...group, entries: [entry] });
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[] as Array<{
|
||||
entries: Array<{ metaLine: string; name: string; value: number }>;
|
||||
key: string;
|
||||
label: string;
|
||||
order: number;
|
||||
tone: string;
|
||||
}>,
|
||||
)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
const spread =
|
||||
numericValues.length >= 2
|
||||
? Math.max(...numericValues) - Math.min(...numericValues)
|
||||
: null;
|
||||
const metadataSource =
|
||||
detail.source_forecasts?.open_meteo_multi_model?.provider === "open-meteo"
|
||||
? "Open-Meteo"
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="models-section">
|
||||
{!hideTitle && <h3>{t("section.models")}</h3>}
|
||||
<div className="model-bars">
|
||||
<div className="model-stack-summary">
|
||||
<span>
|
||||
{locale === "en-US" ? "Available models" : "可用模型"} ·{" "}
|
||||
<strong>{modelEntries.length}</strong>
|
||||
</span>
|
||||
<span>
|
||||
{locale === "en-US" ? "Spread" : "分歧"} ·{" "}
|
||||
<strong>
|
||||
{spread != null
|
||||
? `${spread.toFixed(1)}${detail.temp_symbol}`
|
||||
: "--"}
|
||||
</strong>
|
||||
</span>
|
||||
{metadataSource && (
|
||||
<span>
|
||||
{locale === "en-US" ? "API" : "接口"} ·{" "}
|
||||
<strong>{metadataSource}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{hasSingleModelOnly && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "11px",
|
||||
marginBottom: "8px",
|
||||
}}
|
||||
>
|
||||
{locale === "en-US"
|
||||
? "Single-model fallback: waiting for the rest of the model cluster."
|
||||
: "当前处于单模型回退,其他模型结果还没回传。"}
|
||||
</div>
|
||||
)}
|
||||
{groupedEntries.map((group) => (
|
||||
<div
|
||||
key={group.key}
|
||||
className={clsx("model-group", `model-group-${group.tone}`)}
|
||||
>
|
||||
<div className="model-group-heading">
|
||||
<span>{group.label}</span>
|
||||
<em>{group.entries.length}</em>
|
||||
</div>
|
||||
{group.entries.map(({ metaLine, name, value }) => {
|
||||
const width = ((value - minValue) / range) * 100;
|
||||
const debLine =
|
||||
view.deb != null
|
||||
? ((Number(view.deb) - minValue) / range) * 100
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div key={name} className="model-row model-row-rich">
|
||||
<div className="model-name" title={metaLine || name}>
|
||||
<strong>{name}</strong>
|
||||
{metaLine && <span>{metaLine}</span>}
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div
|
||||
className="model-bar-fill"
|
||||
style={{ width: `${width}%` }}
|
||||
/>
|
||||
<span className="model-bar-value">
|
||||
{value}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
{debLine != null && (
|
||||
<div
|
||||
className="model-deb-line"
|
||||
style={{ left: `${debLine}%` }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
{view.deb != null && (
|
||||
<div
|
||||
className="model-row"
|
||||
style={{
|
||||
borderTop: "1px solid rgba(255,255,255,0.06)",
|
||||
marginTop: "6px",
|
||||
paddingTop: "6px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="model-name"
|
||||
style={{ color: "var(--accent-cyan)", fontWeight: 700 }}
|
||||
>
|
||||
DEB
|
||||
</div>
|
||||
<div className="model-bar-track">
|
||||
<div
|
||||
className="model-bar-fill deb"
|
||||
style={{
|
||||
width: `${((Number(view.deb) - minValue) / range) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<span className="model-bar-value deb">
|
||||
{Number(view.deb)}
|
||||
{detail.temp_symbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,956 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import { useMemo } from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import {
|
||||
CityDetail,
|
||||
MarketScan,
|
||||
MarketTopBucket,
|
||||
ProbabilityBucket,
|
||||
} from "@/lib/dashboard-types";
|
||||
import { getModelView, getProbabilityView } from "@/lib/model-utils";
|
||||
|
||||
function EmptyState({ text }: { text: string }) {
|
||||
return (
|
||||
<div style={{ color: "var(--text-muted)", fontSize: "13px" }}>{text}</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toPercent(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
return `${(numeric * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function toPriceCents(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
const normalized = numeric > 1 ? numeric / 100 : numeric;
|
||||
const cents = normalized * 100;
|
||||
const rounded = Math.round(cents * 10) / 10;
|
||||
const text = Number.isInteger(rounded)
|
||||
? String(rounded.toFixed(0))
|
||||
: String(rounded);
|
||||
return `${text}c`;
|
||||
}
|
||||
|
||||
function parseTempFromText(value: unknown) {
|
||||
const text = String(value || "");
|
||||
const match = text.match(/(-?\d+(?:\.\d+)?)/);
|
||||
if (!match) return null;
|
||||
const numeric = Number(match[1]);
|
||||
return Number.isFinite(numeric) ? numeric : null;
|
||||
}
|
||||
|
||||
function getBucketTemp(bucket: ProbabilityBucket) {
|
||||
if (bucket.value != null) {
|
||||
const byValue = Number(bucket.value);
|
||||
if (Number.isFinite(byValue)) return byValue;
|
||||
}
|
||||
return parseTempFromText(bucket.label || bucket.bucket || bucket.range);
|
||||
}
|
||||
|
||||
function getMarketYesPrice(scan?: MarketScan | null) {
|
||||
if (scan?.market_price != null) {
|
||||
const preferred = Number(scan.market_price);
|
||||
if (Number.isFinite(preferred)) return preferred;
|
||||
}
|
||||
if (scan?.yes_token?.implied_probability != null) {
|
||||
const implied = Number(scan.yes_token.implied_probability);
|
||||
if (Number.isFinite(implied)) return implied;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isFahrenheitSymbol(symbol?: string | null) {
|
||||
return String(symbol || "")
|
||||
.toUpperCase()
|
||||
.includes("F");
|
||||
}
|
||||
|
||||
function displayTempToMarketCelsius(
|
||||
value: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (value == null || !Number.isFinite(value)) return null;
|
||||
if (isFahrenheitSymbol(detail.temp_symbol)) {
|
||||
return ((value - 32) * 5) / 9;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function formatBucketDisplayLabel(
|
||||
bucket: ProbabilityBucket,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
let bucketLabel = bucket.label || `${bucket.value}${detail.temp_symbol}`;
|
||||
if (!bucketLabel) return "";
|
||||
let str = String(bucketLabel).toUpperCase().replace(/\s+/g, "");
|
||||
const symbol = detail.temp_symbol || "°C";
|
||||
if (isFahrenheitSymbol(symbol)) {
|
||||
str = str.replace(/℃/g, "°F").replace(/°C/g, "°F");
|
||||
} else {
|
||||
str = str.replace(/℃/g, "°C").replace(/°F/g, "°C");
|
||||
}
|
||||
str = str.replace(/°?C($|\+|-)/g, "°C$1");
|
||||
str = str.replace(/°?F($|\+|-)/g, "°F$1");
|
||||
if (!/[°℃][CF]/.test(str) && /[0-9]/.test(str)) {
|
||||
str += symbol;
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
function getMarketBucketUnit(bucket?: MarketTopBucket | null) {
|
||||
return String(bucket?.unit || "").toUpperCase();
|
||||
}
|
||||
|
||||
function isMarketBucketAbove(bucket?: MarketTopBucket | null) {
|
||||
const text =
|
||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
return (
|
||||
text.includes("+") ||
|
||||
text.includes("orhigher") ||
|
||||
text.includes("or-higher")
|
||||
);
|
||||
}
|
||||
|
||||
function isMarketBucketBelow(bucket?: MarketTopBucket | null) {
|
||||
const text =
|
||||
`${bucket?.label || ""} ${bucket?.slug || ""} ${bucket?.question || ""}`
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, "");
|
||||
return (
|
||||
text.includes("<=") || text.includes("orlower") || text.includes("or-lower")
|
||||
);
|
||||
}
|
||||
|
||||
function findMarketBucketForDisplayTemp(
|
||||
buckets: MarketTopBucket[],
|
||||
displayTemp: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (displayTemp == null || !Number.isFinite(displayTemp)) return null;
|
||||
|
||||
let best: MarketTopBucket | null = null;
|
||||
let bestDelta = Number.POSITIVE_INFINITY;
|
||||
for (const bucket of buckets) {
|
||||
const bucketUnit = String(bucket.unit || "").toUpperCase();
|
||||
const compareTemp =
|
||||
bucketUnit === "F"
|
||||
? displayTemp
|
||||
: displayTempToMarketCelsius(displayTemp, detail);
|
||||
if (compareTemp == null) continue;
|
||||
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (
|
||||
lower != null &&
|
||||
upper != null &&
|
||||
Number.isFinite(lower) &&
|
||||
Number.isFinite(upper) &&
|
||||
compareTemp >= lower - 0.01 &&
|
||||
compareTemp <= upper + 0.01
|
||||
) {
|
||||
return bucket;
|
||||
}
|
||||
|
||||
const rawTemp = bucket.temp ?? bucket.value ?? null;
|
||||
if (rawTemp == null) continue;
|
||||
const candidateTemp = Number(rawTemp);
|
||||
if (!Number.isFinite(candidateTemp)) continue;
|
||||
const delta = Math.abs(candidateTemp - compareTemp);
|
||||
if (delta < bestDelta) {
|
||||
best = bucket;
|
||||
bestDelta = delta;
|
||||
}
|
||||
}
|
||||
const tolerance = isFahrenheitSymbol(detail.temp_symbol) ? 0.56 : 0.26;
|
||||
return best && bestDelta <= tolerance ? best : null;
|
||||
}
|
||||
|
||||
function marketBucketContainsDisplayTemp(
|
||||
bucket: MarketTopBucket | null,
|
||||
displayTemp: number | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (!bucket || displayTemp == null || !Number.isFinite(displayTemp))
|
||||
return false;
|
||||
|
||||
const bucketUnit = getMarketBucketUnit(bucket);
|
||||
const compareTemp =
|
||||
bucketUnit === "F"
|
||||
? displayTemp
|
||||
: displayTempToMarketCelsius(displayTemp, detail);
|
||||
if (compareTemp == null) return false;
|
||||
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (lower != null && !Number.isFinite(lower)) return false;
|
||||
if (upper != null && !Number.isFinite(upper)) return false;
|
||||
|
||||
if (lower != null && upper != null) {
|
||||
return compareTemp >= lower - 0.01 && compareTemp <= upper + 0.01;
|
||||
}
|
||||
if (lower != null && isMarketBucketAbove(bucket)) {
|
||||
return compareTemp >= lower - 0.01;
|
||||
}
|
||||
if (lower != null && isMarketBucketBelow(bucket)) {
|
||||
return compareTemp <= lower + 0.01;
|
||||
}
|
||||
const reference = bucket.temp ?? bucket.value ?? lower;
|
||||
const numeric = reference != null ? Number(reference) : null;
|
||||
if (numeric == null || !Number.isFinite(numeric)) return false;
|
||||
const tolerance = bucketUnit === "F" ? 0.56 : 0.26;
|
||||
return Math.abs(compareTemp - numeric) <= tolerance;
|
||||
}
|
||||
|
||||
function getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilities: ProbabilityBucket[],
|
||||
bucket: MarketTopBucket | null,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
if (!bucket) return null;
|
||||
|
||||
let total = 0;
|
||||
let matched = 0;
|
||||
for (const probabilityBucket of probabilities) {
|
||||
const temp = getBucketTemp(probabilityBucket);
|
||||
if (!marketBucketContainsDisplayTemp(bucket, temp, detail)) continue;
|
||||
const probability = Number(probabilityBucket.probability);
|
||||
if (!Number.isFinite(probability)) continue;
|
||||
total += probability;
|
||||
matched += 1;
|
||||
}
|
||||
|
||||
return matched > 0 ? Math.max(0, Math.min(1, total)) : null;
|
||||
}
|
||||
|
||||
type ProbabilityDisplayRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
probability: number;
|
||||
marketBucket?: MarketTopBucket | null;
|
||||
};
|
||||
|
||||
function formatMarketBucketDisplayLabel(
|
||||
bucket: MarketTopBucket,
|
||||
detail: Pick<CityDetail, "temp_symbol">,
|
||||
) {
|
||||
const label = String(bucket.label || "").trim();
|
||||
if (label) {
|
||||
const unit = getMarketBucketUnit(bucket);
|
||||
let normalized = label.toUpperCase().replace(/\s+/g, "");
|
||||
if (unit === "F" || isFahrenheitSymbol(detail.temp_symbol)) {
|
||||
normalized = normalized
|
||||
.replace(/ORHIGHER/g, "+")
|
||||
.replace(/ORLOWER/g, "-")
|
||||
.replace(/℃/g, "°F")
|
||||
.replace(/°C/g, "°F")
|
||||
.replace(/(?<=\d)F/g, "°F");
|
||||
} else {
|
||||
normalized = normalized
|
||||
.replace(/ORHIGHER/g, "+")
|
||||
.replace(/ORLOWER/g, "-")
|
||||
.replace(/℃/g, "°C")
|
||||
.replace(/°F/g, "°C")
|
||||
.replace(/(?<=\d)C/g, "°C");
|
||||
}
|
||||
return normalized.replace(/\+/g, "+");
|
||||
}
|
||||
|
||||
const unit =
|
||||
getMarketBucketUnit(bucket) === "F" ||
|
||||
isFahrenheitSymbol(detail.temp_symbol)
|
||||
? "°F"
|
||||
: "°C";
|
||||
const lower = bucket.lower != null ? Number(bucket.lower) : null;
|
||||
const upper = bucket.upper != null ? Number(bucket.upper) : null;
|
||||
if (
|
||||
lower != null &&
|
||||
upper != null &&
|
||||
Number.isFinite(lower) &&
|
||||
Number.isFinite(upper)
|
||||
) {
|
||||
return `${lower}-${upper}${unit}`;
|
||||
}
|
||||
const value = bucket.value ?? bucket.temp ?? lower;
|
||||
const numeric = value != null ? Number(value) : null;
|
||||
if (numeric != null && Number.isFinite(numeric)) {
|
||||
return isMarketBucketAbove(bucket)
|
||||
? `${numeric}${unit}+`
|
||||
: `${numeric}${unit}`;
|
||||
}
|
||||
return "--";
|
||||
}
|
||||
|
||||
type ModelMetadata = NonNullable<
|
||||
NonNullable<CityDetail["source_forecasts"]>["open_meteo_multi_model"]
|
||||
>["model_metadata"];
|
||||
|
||||
|
||||
function normalizeModelNameForVote(name: string) {
|
||||
return String(name || "")
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_/-]/g, "");
|
||||
}
|
||||
|
||||
function getModelVoteFamily(name: string) {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
if (["icon", "iconeu", "icond2"].includes(normalized)) return "dwd_icon";
|
||||
if (["gem", "gdps", "rdps", "hrdps"].includes(normalized)) return "eccc_gem";
|
||||
if (["ecmwfaifs", "aifs"].includes(normalized)) return "ecmwf_aifs";
|
||||
if (normalized === "ecmwf") return "ecmwf_ifs";
|
||||
return normalized || name;
|
||||
}
|
||||
|
||||
function getModelVotePriority(name: string) {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
return (
|
||||
{
|
||||
icond2: 40,
|
||||
iconeu: 30,
|
||||
icon: 20,
|
||||
hrdps: 40,
|
||||
rdps: 35,
|
||||
gdps: 30,
|
||||
gem: 20,
|
||||
ecmwfaifs: 30,
|
||||
ecmwf: 30,
|
||||
gfs: 30,
|
||||
jma: 30,
|
||||
mgm: 45,
|
||||
nws: 45,
|
||||
openmeteo: 15,
|
||||
}[normalized] || 10
|
||||
);
|
||||
}
|
||||
|
||||
function getRoundedModelVoteDistribution(
|
||||
detail: CityDetail,
|
||||
targetDate?: string | null,
|
||||
) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
const representatives = new Map<
|
||||
string,
|
||||
{ name: string; priority: number; value: number }
|
||||
>();
|
||||
|
||||
Object.entries(view.models || {}).forEach(([name, rawValue]) => {
|
||||
const normalized = normalizeModelNameForVote(name);
|
||||
if (normalized === "lgbm" || normalized.includes("meteoblue")) return;
|
||||
const value = Number(rawValue);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const family = getModelVoteFamily(name);
|
||||
const priority = getModelVotePriority(name);
|
||||
const current = representatives.get(family);
|
||||
if (!current || priority > current.priority) {
|
||||
representatives.set(family, { name, priority, value });
|
||||
}
|
||||
});
|
||||
|
||||
const bucketMap = new Map<number, { count: number; models: string[] }>();
|
||||
representatives.forEach(({ name, value }) => {
|
||||
const rounded = Math.round(value);
|
||||
const row = bucketMap.get(rounded) || { count: 0, models: [] };
|
||||
row.count += 1;
|
||||
row.models.push(name);
|
||||
bucketMap.set(rounded, row);
|
||||
});
|
||||
|
||||
const total = representatives.size;
|
||||
const rows = Array.from(bucketMap.entries())
|
||||
.map(([value, row]) => ({
|
||||
count: row.count,
|
||||
models: row.models,
|
||||
percent: total > 0 ? row.count / total : 0,
|
||||
value,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count || b.value - a.value);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMarketProbability(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
if (numeric > 1) return Math.max(0, Math.min(1, numeric / 100));
|
||||
return Math.max(0, Math.min(1, numeric));
|
||||
}
|
||||
|
||||
function normalizeSignedProbability(value?: number | null) {
|
||||
if (value == null) return null;
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return null;
|
||||
if (Math.abs(numeric) > 1) return numeric / 100;
|
||||
return numeric;
|
||||
}
|
||||
|
||||
function formatSignedPercent(value?: number | null, digits = 1) {
|
||||
const normalized = normalizeSignedProbability(value);
|
||||
if (normalized == null) return "--";
|
||||
const percent = normalized * 100;
|
||||
const sign = percent > 0 ? "+" : "";
|
||||
return `${sign}${percent.toFixed(digits)}%`;
|
||||
}
|
||||
|
||||
function getMarketTopBuckets(scan?: MarketScan | null) {
|
||||
const buckets = Array.isArray(scan?.top_buckets) ? scan.top_buckets : [];
|
||||
if (!buckets.length) return [];
|
||||
|
||||
return buckets
|
||||
.map((item) => ({
|
||||
...item,
|
||||
probability: normalizeMarketProbability(item.probability),
|
||||
}))
|
||||
.filter(
|
||||
(item): item is MarketTopBucket & { probability: number } =>
|
||||
item.probability != null,
|
||||
);
|
||||
}
|
||||
|
||||
function getMarketAllBuckets(scan?: MarketScan | null) {
|
||||
const buckets = Array.isArray(scan?.all_buckets)
|
||||
? scan.all_buckets
|
||||
: Array.isArray(scan?.top_buckets)
|
||||
? scan.top_buckets
|
||||
: [];
|
||||
if (!buckets.length) return [];
|
||||
|
||||
return buckets
|
||||
.map((item) => ({
|
||||
...item,
|
||||
probability: normalizeMarketProbability(item.probability),
|
||||
}))
|
||||
.filter(
|
||||
(item): item is MarketTopBucket & { probability: number } =>
|
||||
item.probability != null,
|
||||
);
|
||||
}
|
||||
|
||||
function getMarketTopBucketKey(bucket: MarketTopBucket) {
|
||||
if (bucket?.value != null) {
|
||||
const valueNum = Number(bucket.value);
|
||||
if (Number.isFinite(valueNum)) return `v:${valueNum.toFixed(2)}`;
|
||||
}
|
||||
|
||||
if (bucket?.temp != null) {
|
||||
const tempNum = Number(bucket.temp);
|
||||
if (Number.isFinite(tempNum)) return `t:${tempNum.toFixed(2)}`;
|
||||
}
|
||||
|
||||
const parsed = parseTempFromText(bucket?.label);
|
||||
if (parsed != null) return `l:${parsed.toFixed(2)}`;
|
||||
|
||||
return `s:${String(bucket?.slug || bucket?.question || bucket?.label || "")}`;
|
||||
}
|
||||
|
||||
function hasLgbmModel(detail: CityDetail, targetDate?: string | null) {
|
||||
const view = getModelView(detail, targetDate);
|
||||
return Object.keys(view.models || {}).some((name) =>
|
||||
normalizeModelNameForVote(name).includes("lgbm"),
|
||||
);
|
||||
}
|
||||
|
||||
function formatProbabilityEngineLabel(
|
||||
detail: CityDetail,
|
||||
targetDate: string | null | undefined,
|
||||
locale: string,
|
||||
) {
|
||||
const view = getProbabilityView(detail, targetDate);
|
||||
if (hasLgbmModel(detail, targetDate)) {
|
||||
return locale === "en-US" ? "LGBM-calibrated probability" : "LGBM 校准概率";
|
||||
}
|
||||
const engine = String(view.engine || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const calibrationMode = String(view.calibrationMode || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (engine === "emos" || calibrationMode.includes("emos")) {
|
||||
return locale === "en-US" ? "EMOS-calibrated probability" : "EMOS 校准概率";
|
||||
}
|
||||
return locale === "en-US" ? "Model probability" : "模型概率";
|
||||
}
|
||||
|
||||
|
||||
export function ProbabilityDistribution({
|
||||
detail,
|
||||
hideTitle = false,
|
||||
targetDate,
|
||||
marketScan,
|
||||
}: {
|
||||
detail: CityDetail;
|
||||
hideTitle?: boolean;
|
||||
targetDate?: string | null;
|
||||
marketScan?: MarketScan | null;
|
||||
}) {
|
||||
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;
|
||||
const probabilityEngineLabel = formatProbabilityEngineLabel(
|
||||
detail,
|
||||
targetDate,
|
||||
locale,
|
||||
);
|
||||
const hasLgbmProbability = hasLgbmModel(detail, targetDate);
|
||||
const modelVoteView = useMemo(
|
||||
() => getRoundedModelVoteDistribution(detail, targetDate),
|
||||
[detail, targetDate],
|
||||
);
|
||||
const modelVoteHint = modelVoteView.rows
|
||||
.slice(0, 2)
|
||||
.map(
|
||||
(row) =>
|
||||
`${row.value}${detail.temp_symbol} ${row.count}/${modelVoteView.total}`,
|
||||
)
|
||||
.join(" · ");
|
||||
const marketTopBuckets = isToday ? getMarketTopBuckets(marketScan) : [];
|
||||
const marketAllBuckets = isToday ? getMarketAllBuckets(marketScan) : [];
|
||||
const sortedMarketTopBuckets = useMemo(() => {
|
||||
const sorted = [...marketTopBuckets].sort(
|
||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
||||
);
|
||||
const deduped: Array<MarketTopBucket & { probability: number }> = [];
|
||||
const seenKeys = new Set<string>();
|
||||
for (const row of sorted) {
|
||||
const key = getMarketTopBucketKey(row);
|
||||
if (seenKeys.has(key)) continue;
|
||||
seenKeys.add(key);
|
||||
deduped.push(row);
|
||||
if (deduped.length >= 4) break;
|
||||
}
|
||||
return deduped;
|
||||
}, [marketTopBuckets]);
|
||||
const useMarketTopBuckets =
|
||||
marketScan?.available && sortedMarketTopBuckets.length >= 2;
|
||||
const topMarketBucketText = toPercent(sortedMarketTopBuckets[0]?.probability);
|
||||
const topProbability = [...(view.probabilities || [])].sort(
|
||||
(a, b) => Number(b.probability || 0) - Number(a.probability || 0),
|
||||
)[0];
|
||||
const topProbabilityText = toPercent(topProbability?.probability);
|
||||
const topProbabilityLabel = topProbability
|
||||
? formatBucketDisplayLabel(topProbability, detail)
|
||||
: null;
|
||||
const topProbabilityTemp = topProbability
|
||||
? getBucketTemp(topProbability)
|
||||
: null;
|
||||
const probabilitiesForMarketContracts =
|
||||
view.probabilitiesAll?.length > 0
|
||||
? view.probabilitiesAll
|
||||
: view.probabilities || [];
|
||||
const marketContractRows = useMemo<ProbabilityDisplayRow[]>(() => {
|
||||
if (!isToday || !marketScan?.available || marketAllBuckets.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows: ProbabilityDisplayRow[] = [];
|
||||
const seenKeys = new Set<string>();
|
||||
for (const marketBucket of marketAllBuckets) {
|
||||
const probability = getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilitiesForMarketContracts,
|
||||
marketBucket,
|
||||
detail,
|
||||
);
|
||||
|
||||
const key =
|
||||
marketBucket.slug ||
|
||||
marketBucket.label ||
|
||||
`${marketBucket.lower ?? marketBucket.value ?? marketBucket.temp}-${marketBucket.upper ?? ""}`;
|
||||
if (seenKeys.has(key)) continue;
|
||||
seenKeys.add(key);
|
||||
rows.push({
|
||||
key,
|
||||
label: formatMarketBucketDisplayLabel(marketBucket, detail),
|
||||
probability: probability ?? 0,
|
||||
marketBucket,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}, [
|
||||
detail,
|
||||
isToday,
|
||||
marketAllBuckets,
|
||||
marketScan?.available,
|
||||
probabilitiesForMarketContracts,
|
||||
]);
|
||||
const modelProbabilityRows = useMemo<ProbabilityDisplayRow[]>(
|
||||
() =>
|
||||
(view.probabilities || []).slice(0, 6).map((bucket, index) => {
|
||||
const bucketTemp = getBucketTemp(bucket);
|
||||
return {
|
||||
key: `${bucket.label || bucket.value || index}`,
|
||||
label: formatBucketDisplayLabel(bucket, detail),
|
||||
probability: Number(bucket.probability || 0),
|
||||
marketBucket: findMarketBucketForDisplayTemp(
|
||||
marketAllBuckets,
|
||||
bucketTemp,
|
||||
detail,
|
||||
),
|
||||
};
|
||||
}),
|
||||
[detail, marketAllBuckets, view.probabilities],
|
||||
);
|
||||
const probabilityRows =
|
||||
marketContractRows.length > 0
|
||||
? marketContractRows.slice(0, 8)
|
||||
: modelProbabilityRows;
|
||||
const topContractRow =
|
||||
marketContractRows.length > 0
|
||||
? marketContractRows.reduce((best, row) =>
|
||||
row.probability > best.probability ? row : best,
|
||||
)
|
||||
: null;
|
||||
const displayTopLabel = topContractRow?.label || topProbabilityLabel || null;
|
||||
const displayTopProbability =
|
||||
topContractRow?.probability ??
|
||||
(topProbability?.probability != null
|
||||
? Number(topProbability.probability)
|
||||
: null);
|
||||
const displayTopProbabilityText = toPercent(displayTopProbability);
|
||||
const displayUsesMarketBuckets = marketContractRows.length > 0;
|
||||
const linkedMarketBucket = useMemo(() => {
|
||||
if (topContractRow?.marketBucket) return topContractRow.marketBucket;
|
||||
if (topProbabilityTemp == null) return null;
|
||||
return findMarketBucketForDisplayTemp(
|
||||
marketAllBuckets,
|
||||
topProbabilityTemp,
|
||||
detail,
|
||||
);
|
||||
}, [detail, marketAllBuckets, topContractRow, topProbabilityTemp]);
|
||||
const priceAnalysis = marketScan?.price_analysis;
|
||||
const yesPriceView = priceAnalysis?.yes;
|
||||
const noPriceView = priceAnalysis?.no;
|
||||
const linkedMarketAsk =
|
||||
linkedMarketBucket?.yes_buy ??
|
||||
linkedMarketBucket?.market_price ??
|
||||
yesPriceView?.ask ??
|
||||
null;
|
||||
const linkedNoAsk = linkedMarketBucket?.no_buy ?? noPriceView?.ask ?? null;
|
||||
const linkedContractLabel =
|
||||
topContractRow?.label ||
|
||||
(linkedMarketBucket
|
||||
? formatMarketBucketDisplayLabel(linkedMarketBucket, detail)
|
||||
: null) ||
|
||||
topProbabilityLabel ||
|
||||
null;
|
||||
const aggregatedMarketProbability =
|
||||
getAggregatedModelProbabilityForMarketBucket(
|
||||
probabilitiesForMarketContracts,
|
||||
linkedMarketBucket,
|
||||
detail,
|
||||
);
|
||||
const linkedMarketProbability =
|
||||
topContractRow?.probability ??
|
||||
aggregatedMarketProbability ??
|
||||
(topProbability?.probability != null
|
||||
? Number(topProbability.probability)
|
||||
: null);
|
||||
const linkedMarketProbabilityText = toPercent(linkedMarketProbability);
|
||||
const linkedMarketEdge =
|
||||
linkedMarketProbability != null && linkedMarketAsk != null
|
||||
? linkedMarketProbability - Number(linkedMarketAsk)
|
||||
: null;
|
||||
const linkedNoEdge =
|
||||
linkedMarketProbability != null && linkedNoAsk != null
|
||||
? 1 - linkedMarketProbability - Number(linkedNoAsk)
|
||||
: null;
|
||||
const linkedBestSide =
|
||||
linkedMarketBucket && linkedNoEdge != null && linkedMarketEdge != null
|
||||
? linkedNoEdge > linkedMarketEdge
|
||||
? "no"
|
||||
: "yes"
|
||||
: null;
|
||||
const linkedBestAsk = linkedBestSide === "no" ? linkedNoAsk : linkedMarketAsk;
|
||||
const linkedBestEdge =
|
||||
linkedBestSide === "no" ? linkedNoEdge : linkedMarketEdge;
|
||||
const preferredPriceView = linkedMarketBucket
|
||||
? {
|
||||
ask: linkedBestAsk,
|
||||
edge: linkedBestEdge,
|
||||
}
|
||||
: priceAnalysis?.best_side === "no"
|
||||
? noPriceView
|
||||
: yesPriceView;
|
||||
const preferredSideLabel = linkedMarketBucket
|
||||
? linkedBestSide === "no"
|
||||
? "NO"
|
||||
: "YES"
|
||||
: priceAnalysis?.best_side === "no"
|
||||
? locale === "en-US"
|
||||
? "NO"
|
||||
: "NO"
|
||||
: locale === "en-US"
|
||||
? "YES"
|
||||
: "YES";
|
||||
const yesDisplayPrice = linkedMarketBucket
|
||||
? linkedMarketAsk
|
||||
: yesPriceView?.ask;
|
||||
const noDisplayPrice = linkedMarketBucket ? linkedNoAsk : noPriceView?.ask;
|
||||
const yesDisplayEdge = linkedMarketBucket
|
||||
? linkedMarketEdge
|
||||
: yesPriceView?.edge;
|
||||
const noDisplayEdge = linkedMarketBucket ? linkedNoEdge : noPriceView?.edge;
|
||||
const hasPriceAnalysis =
|
||||
isToday &&
|
||||
(Boolean(priceAnalysis?.available) ||
|
||||
Boolean(marketScan) ||
|
||||
Boolean(topProbability));
|
||||
const lockEdge = normalizeSignedProbability(priceAnalysis?.lock?.edge);
|
||||
const lockAvailable = Boolean(
|
||||
priceAnalysis?.lock?.available && lockEdge != null,
|
||||
);
|
||||
const quoteSource =
|
||||
linkedMarketBucket?.quote_source ||
|
||||
marketScan?.yes_token?.quote_source ||
|
||||
marketScan?.no_token?.quote_source ||
|
||||
null;
|
||||
const quoteAgeMs =
|
||||
linkedMarketBucket?.quote_age_ms ??
|
||||
marketScan?.yes_token?.quote_age_ms ??
|
||||
marketScan?.no_token?.quote_age_ms;
|
||||
const quoteSourceLabel =
|
||||
quoteSource === "polymarket_ws"
|
||||
? locale === "en-US"
|
||||
? `WS live${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}s` : ""}`
|
||||
: `WS 实时${quoteAgeMs != null ? ` · ${Math.max(0, Math.round(Number(quoteAgeMs) / 1000))}秒` : ""}`
|
||||
: locale === "en-US"
|
||||
? "CLOB fallback"
|
||||
: "CLOB 兜底";
|
||||
const actionableEdge = normalizeSignedProbability(preferredPriceView?.edge);
|
||||
const linkedContractOverpriced =
|
||||
Boolean(linkedMarketBucket) &&
|
||||
linkedBestSide === "no" &&
|
||||
linkedMarketProbability != null &&
|
||||
linkedMarketAsk != null &&
|
||||
linkedMarketEdge != null &&
|
||||
linkedMarketEdge < 0 &&
|
||||
linkedNoEdge != null &&
|
||||
linkedNoEdge > 0;
|
||||
const linkedContractOverpay =
|
||||
linkedContractOverpriced &&
|
||||
linkedMarketProbability != null &&
|
||||
linkedMarketAsk != null
|
||||
? Number(linkedMarketAsk) - linkedMarketProbability
|
||||
: null;
|
||||
const actionText = !marketScan
|
||||
? locale === "en-US"
|
||||
? "Waiting"
|
||||
: "等待"
|
||||
: !marketScan.available
|
||||
? locale === "en-US"
|
||||
? "No market"
|
||||
: "无盘口"
|
||||
: actionableEdge == null
|
||||
? locale === "en-US"
|
||||
? "No quote"
|
||||
: "无报价"
|
||||
: actionableEdge >= 0.02
|
||||
? linkedContractOverpriced
|
||||
? locale === "en-US"
|
||||
? "Overpriced"
|
||||
: "市场偏贵"
|
||||
: locale === "en-US"
|
||||
? `Watch ${preferredSideLabel}`
|
||||
: `可关注 ${preferredSideLabel}`
|
||||
: actionableEdge > 0
|
||||
? linkedContractOverpriced
|
||||
? locale === "en-US"
|
||||
? "Slightly overpriced"
|
||||
: "略偏贵"
|
||||
: locale === "en-US"
|
||||
? `Small ${preferredSideLabel}`
|
||||
: `${preferredSideLabel} 优势较小`
|
||||
: locale === "en-US"
|
||||
? "No clear edge"
|
||||
: "暂无优势";
|
||||
const actionNote =
|
||||
linkedContractOverpriced && linkedContractOverpay != null
|
||||
? locale === "en-US"
|
||||
? `YES above model by ${formatSignedPercent(linkedContractOverpay)}`
|
||||
: `YES 高于模型 ${formatSignedPercent(linkedContractOverpay)}`
|
||||
: actionableEdge != null && actionableEdge >= 0.02
|
||||
? locale === "en-US"
|
||||
? `${formatSignedPercent(actionableEdge)} vs ask`
|
||||
: `相对买价 ${formatSignedPercent(actionableEdge)}`
|
||||
: locale === "en-US"
|
||||
? `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`
|
||||
: `${preferredSideLabel} ${formatSignedPercent(actionableEdge)}`;
|
||||
|
||||
return (
|
||||
<section className="prob-section">
|
||||
{!hideTitle && <h3>{t("section.probability")}</h3>}
|
||||
<div className="prob-bars">
|
||||
<div className="prob-calibration-head">
|
||||
<div>
|
||||
<span className="prob-source-chip">{probabilityEngineLabel}</span>
|
||||
<strong>
|
||||
{displayTopLabel && displayTopProbabilityText
|
||||
? locale === "en-US"
|
||||
? displayUsesMarketBuckets
|
||||
? `${displayTopLabel} is the top displayed contract bucket at ${displayTopProbabilityText}`
|
||||
: `${displayTopLabel} is the top single bucket at ${displayTopProbabilityText}`
|
||||
: displayUsesMarketBuckets
|
||||
? `${displayTopLabel} 为当前显示分布最高,${displayTopProbabilityText}`
|
||||
: `${displayTopLabel} 单点最高,${displayTopProbabilityText}`
|
||||
: locale === "en-US"
|
||||
? "Awaiting calibrated buckets"
|
||||
: "等待校准概率桶"}
|
||||
</strong>
|
||||
</div>
|
||||
<p>
|
||||
{hasLgbmProbability
|
||||
? locale === "en-US"
|
||||
? "LGBM is the learned intraday adjustment; raw model points below are only diagnostic."
|
||||
: "LGBM 作为日内学习校准项;下方原始模型落点仅用于诊断。"
|
||||
: locale === "en-US"
|
||||
? "Using the calibrated probability distribution; raw model points below are not probabilities."
|
||||
: "使用校准后的概率分布;下方原始模型落点不是概率。"}
|
||||
</p>
|
||||
</div>
|
||||
{marketScan?.available && (topMarketBucketText || marketYesText) && (
|
||||
<div
|
||||
style={{
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: "11px",
|
||||
marginBottom: "6px",
|
||||
}}
|
||||
>
|
||||
{useMarketTopBuckets
|
||||
? locale === "en-US"
|
||||
? `Market reference only: top traded bucket ${topMarketBucketText}`
|
||||
: `市场仅作参考:最高交易温度桶 ${topMarketBucketText}`
|
||||
: locale === "en-US"
|
||||
? `Market reference only: this bucket ${marketYesText}`
|
||||
: `市场仅作参考:该温度桶 ${marketYesText}`}
|
||||
</div>
|
||||
)}
|
||||
<div className="prob-distribution-panel">
|
||||
<div className="prob-distribution-head">
|
||||
<span>
|
||||
{locale === "en-US"
|
||||
? "EMOS probability distribution"
|
||||
: "EMOS 概率分布"}
|
||||
</span>
|
||||
<em>
|
||||
{marketContractRows.length > 0
|
||||
? locale === "en-US"
|
||||
? "market buckets are aggregated from single-degree EMOS buckets"
|
||||
: "市场合约桶由单点 EMOS 概率聚合"
|
||||
: locale === "en-US"
|
||||
? "calibrated temperature buckets"
|
||||
: "校准后的温度桶"}
|
||||
</em>
|
||||
</div>
|
||||
{probabilityRows.length === 0 ? (
|
||||
<EmptyState text={t("section.noProb")} />
|
||||
) : (
|
||||
probabilityRows.map((row, index) => {
|
||||
const probability = Math.round(
|
||||
Number(row.probability || 0) * 100,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`${row.key || index}`} className="prob-row">
|
||||
<div className="prob-label">{row.label}</div>
|
||||
<div className="prob-bar-track">
|
||||
<div
|
||||
className={clsx("prob-bar-fill", `rank-${index}`)}
|
||||
style={{ width: `${Math.max(probability, 8)}%` }}
|
||||
>
|
||||
{probability}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{hasPriceAnalysis && (
|
||||
<div className="prob-price-card">
|
||||
<div className="prob-price-head">
|
||||
<span>
|
||||
{locale === "en-US" ? "Win-rate reference" : "胜率参考"}
|
||||
</span>
|
||||
<strong>
|
||||
{!marketScan
|
||||
? locale === "en-US"
|
||||
? "Waiting for market context"
|
||||
: "等待市场参照"
|
||||
: !marketScan.available
|
||||
? locale === "en-US"
|
||||
? "No matched active market"
|
||||
: "未匹配到活跃盘口"
|
||||
: 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" ? "Bucket" : "温度桶"}
|
||||
</span>
|
||||
<strong>
|
||||
{linkedContractLabel || topProbabilityLabel || "--"}
|
||||
</strong>
|
||||
<em>
|
||||
{linkedMarketProbabilityText || topProbabilityText || "--"}
|
||||
</em>
|
||||
</div>
|
||||
<div>
|
||||
<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" ? "Model support" : "模型支持"}</span>
|
||||
<strong>{modelVoteHint || "--"}</strong>
|
||||
<em>{locale === "en-US" ? "raw model agreement" : "原始模型一致性"}</em>
|
||||
</div>
|
||||
<div>
|
||||
<span>{locale === "en-US" ? "Market role" : "盘口角色"}</span>
|
||||
<strong>{locale === "en-US" ? "Reference only" : "仅作参考"}</strong>
|
||||
<em>{quoteSourceLabel}</em>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
{locale === "en-US"
|
||||
? "This card follows the same rule as AI forecast: DEB first, model agreement second, METAR conflict check before settlement."
|
||||
: "该卡片与 AI 预测口径一致:先看 DEB,再看模型支持,最后检查 METAR 是否冲突。"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{modelVoteHint && (
|
||||
<div className="prob-model-hint">
|
||||
<span>
|
||||
{locale === "en-US" ? "Raw model points" : "原始模型落点"}
|
||||
</span>
|
||||
<strong>{modelVoteHint}</strong>
|
||||
<em>
|
||||
{locale === "en-US"
|
||||
? "diagnostic only; EMOS and contract rows use calibrated probabilities"
|
||||
: "仅作诊断;EMOS 与合约行使用校准概率"}
|
||||
</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -81,7 +81,7 @@ export function ScanFilterPanel({
|
||||
<span>{isEn ? "Scan Mode" : "扫描模式"}</span>
|
||||
<Info size={14} />
|
||||
</div>
|
||||
<div className="scan-mode-tabs">
|
||||
<div className="scan-mode-tabs" role="tablist" aria-label={isEn ? "Scan mode" : "扫描模式"}>
|
||||
{SCAN_MODES.map((mode) => {
|
||||
const Icon = mode.icon;
|
||||
const isActive = value.scan_mode === mode.key;
|
||||
@@ -89,6 +89,8 @@ export function ScanFilterPanel({
|
||||
<button
|
||||
key={mode.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
className={`scan-mode-tab ${isActive ? "active" : ""}`}
|
||||
onClick={() => updateFilter("scan_mode", mode.key)}
|
||||
>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useI18n } from "@/hooks/useI18n";
|
||||
import type { ScanOpportunityRow, ScanTerminalResponse } from "@/lib/dashboard-types";
|
||||
|
||||
function formatTemperature(value?: number | null, unit?: string | null): string {
|
||||
if (value == null || Number.isNaN(Number(value))) return "--";
|
||||
return `${Number(value).toFixed(1)}${unit || "°C"}`;
|
||||
}
|
||||
|
||||
export function ScanKPIBar({
|
||||
response,
|
||||
rows,
|
||||
totalCities,
|
||||
loading: _loading,
|
||||
}: {
|
||||
response: ScanTerminalResponse | null;
|
||||
rows: ScanOpportunityRow[];
|
||||
totalCities: number;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const { locale } = useI18n();
|
||||
const isEn = locale === "en-US";
|
||||
const bestRow = rows[0] || null;
|
||||
|
||||
const cards = [
|
||||
{
|
||||
label: isEn ? "AI Workspace" : "AI 工作区",
|
||||
value: `${rows.length}/${totalCities || 0}`,
|
||||
note: isEn
|
||||
? "No automatic scan. Cities are added from map clicks."
|
||||
: "不主动扫描;地图点选后加入城市分析。",
|
||||
tone: response?.status === "failed" ? "red" : response?.status === "stale" ? "amber" : "cyan",
|
||||
},
|
||||
{
|
||||
label: isEn ? "City Pool" : "城市池",
|
||||
value: `${totalCities || 0}`,
|
||||
note: isEn ? "Available cities for forecast review" : "可点击进入 AI 预测的城市范围",
|
||||
tone: "blue",
|
||||
},
|
||||
{
|
||||
label: isEn ? "Forecast Center" : "预测中枢",
|
||||
value: bestRow ? formatTemperature(bestRow.deb_prediction, bestRow.temp_symbol || bestRow.target_unit) : "--",
|
||||
note: bestRow
|
||||
? `${isEn ? "Focus" : "焦点"} ${bestRow.city_display_name || bestRow.display_name || bestRow.city} · DEB / ${isEn ? "models" : "模型"} / METAR`
|
||||
: isEn
|
||||
? "Select a city to load DEB and observations"
|
||||
: "选择城市后展示 DEB 与实测路径",
|
||||
tone: "green",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="scan-kpi-bar">
|
||||
{cards.map((card) => (
|
||||
<article key={card.label} className={`scan-kpi-card ${card.tone}`}>
|
||||
<div className="scan-kpi-label">{card.label}</div>
|
||||
<div className="scan-kpi-value">{card.value}</div>
|
||||
<div className="scan-kpi-note">{card.note}</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,556 @@
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCAN TERMINAL — 3-Column Layout
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: calc(100vh - var(--header-height, 72px));
|
||||
margin-top: var(--header-height, 72px);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ── Left: Filter Panel ── */
|
||||
.root :global(.scan-filter-panel) {
|
||||
width: 220px;
|
||||
min-width: 220px;
|
||||
height: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-glass);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px 16px;
|
||||
gap: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-section) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Mode Tabs */
|
||||
.root :global(.scan-mode-tabs) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: left;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab:hover) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active) {
|
||||
background: rgba(0, 224, 164, 0.08);
|
||||
color: var(--color-accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active)::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 25%;
|
||||
height: 50%;
|
||||
width: 3px;
|
||||
background: var(--color-accent-primary);
|
||||
border-radius: 0 2px 2px 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab-indicator) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Filter Rows */
|
||||
.root :global(.scan-filter-row) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-row-label) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-range-display) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider) {
|
||||
flex: 1;
|
||||
appearance: none;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 2px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider::-webkit-slider-thumb) {
|
||||
appearance: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: var(--color-accent-primary);
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 2px solid var(--bg-secondary);
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.root :global(.scan-toggle) {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: all 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active) {
|
||||
background: rgba(0, 224, 164, 0.3);
|
||||
border-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle-knob) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-secondary);
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active .scan-toggle-knob) {
|
||||
left: 18px;
|
||||
background: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
/* Select */
|
||||
.root :global(.scan-select) {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.scan-select:focus) {
|
||||
border-color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
/* Scan CTA */
|
||||
.root :global(.scan-cta-button) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #00c48f);
|
||||
color: #030711;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: all 0.25s;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:hover) {
|
||||
background: linear-gradient(135deg, #00f0b0, #00d89a);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 20px rgba(0, 224, 164, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button:disabled) {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* ── Center: Data Grid ── */
|
||||
.root :global(.scan-data-grid) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 20px 24px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-header) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-title) {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-subtitle) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid-controls) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* View Tabs */
|
||||
.root :global(.scan-view-tabs) {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab) {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab:hover) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab.active) {
|
||||
color: var(--color-accent-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-tab.active)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 2px;
|
||||
background: var(--color-accent-primary);
|
||||
border-radius: 4px;
|
||||
opacity: 0.8;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* ── Right: Scan Detail Panel ── */
|
||||
.root :global(.scan-detail-panel) {
|
||||
width: 360px;
|
||||
min-width: 360px;
|
||||
height: 100%;
|
||||
border-left: 1px solid var(--border-glass);
|
||||
background: var(--bg-secondary);
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-header) {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 14px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-hero-img) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-hero-placeholder) {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 224, 164, 0.15),
|
||||
rgba(123, 97, 255, 0.15)
|
||||
);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-info) {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-name) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-sub) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-volume) {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 600;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section) {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section-title) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Conditions Table */
|
||||
.root :global(.scan-conditions-table) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-item) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-label) {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value) {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value.accent-green) {
|
||||
color: #22C55E;
|
||||
}
|
||||
|
||||
.root :global(.scan-condition-value.accent-red) {
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
/* Trade Cards */
|
||||
.root :global(.scan-trade-cards) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card) {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.yes) {
|
||||
border-top: 2px solid var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.no) {
|
||||
border-top: 2px solid #EF4444;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-title) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-price) {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge) {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge.positive) {
|
||||
color: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-edge.negative) {
|
||||
color: #EF4444;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-note) {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Score Gauge */
|
||||
.root :global(.scan-detail-score) {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-big) {
|
||||
font-size: 40px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-label) {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-suffix) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Confidence Dots */
|
||||
.root :global(.scan-confidence-dots) {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled) {
|
||||
background: var(--color-accent-primary);
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled.amber) {
|
||||
background: #F59E0B;
|
||||
}
|
||||
|
||||
.root :global(.scan-confidence-dot.filled.red) {
|
||||
background: #EF4444;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.root :global(.scan-detail-empty) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
SCAN TERMINAL OVERRIDES — EMOS DESK
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.root :global(.scan-terminal) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(380px, 420px);
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
height: auto;
|
||||
margin-top: 0;
|
||||
padding: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(77, 163, 255, 0.12), transparent 36%),
|
||||
linear-gradient(180deg, #0B1220 0%, #07101D 100%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-panel),
|
||||
.root :global(.scan-data-grid),
|
||||
.root :global(.scan-detail-panel) {
|
||||
min-height: calc(100vh - 32px);
|
||||
border: 1px solid rgba(159, 178, 199, 0.14);
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(17, 26, 46, 0.96), rgba(11, 18, 32, 0.96));
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid) {
|
||||
padding: 18px;
|
||||
gap: 18px;
|
||||
overflow: hidden;
|
||||
min-height: calc(100vh - 32px);
|
||||
max-height: calc(100vh - 32px);
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
/* Scan terminal scroll containers, map/calendar/settings views, table rows, and AI analysis workspace. */
|
||||
|
||||
.root :global(.scan-detail-panel) {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-map-view),
|
||||
.root :global(.scan-calendar-view),
|
||||
.root :global(.scan-settings-view) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-map-view) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
block-size: 100%;
|
||||
}
|
||||
|
||||
.root :global(.scan-map-shell) {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: clamp(560px, calc(100dvh - 292px), 760px);
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: rgba(8, 17, 30, 0.8);
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 16px 20px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(77, 163, 255, 0.08), transparent 36%),
|
||||
rgba(11, 18, 32, 0.66);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy) {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy span) {
|
||||
color: var(--color-accent-primary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy strong) {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy p) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement ul) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement li) {
|
||||
padding: 5px 10px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.08);
|
||||
color: #cfe5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.focus-view-active .scan-upgrade-announcement) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.focus-view-active .scan-data-grid) {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.focus-view-active .scan-list-section) {
|
||||
min-height: min(780px, calc(100dvh - 136px));
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.focus-view-active .scan-list-header) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal.focus-view-active .scan-topbar) {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1680px), (max-height: 900px) {
|
||||
.root :global(.scan-terminal.map-view-active > .detail-panel.scan-city-detail-rail) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.root :global(.scan-map-shell .map) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
border-radius: 18px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-map-shell .leaflet-container) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #060d18;
|
||||
}
|
||||
|
||||
.root :global(.scan-map-caption) {
|
||||
color: #8fa4c3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-card.wide) {
|
||||
grid-column: span 1;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-label) {
|
||||
font-size: 13px;
|
||||
color: #8fa4c3;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-value) {
|
||||
margin-top: 10px;
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
color: #eef7ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-settings-view) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-settings-copy) {
|
||||
margin-top: 10px;
|
||||
color: #c8d8ee;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-height: 132px;
|
||||
padding: 14px 18px;
|
||||
border: none;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.08);
|
||||
border-radius: 0;
|
||||
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row > *),
|
||||
.root :global(.scan-table-header > *) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row:hover) {
|
||||
transform: none;
|
||||
background: linear-gradient(180deg, rgba(11, 24, 40, 0.96), rgba(8, 18, 31, 0.96));
|
||||
}
|
||||
|
||||
.root :global(.scan-table-row.selected) {
|
||||
background: linear-gradient(180deg, rgba(7, 34, 35, 0.92), rgba(8, 19, 29, 0.92));
|
||||
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.48);
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-cell) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-cell) {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-copy) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-name) {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-city-sub) {
|
||||
font-size: 14px;
|
||||
color: #a5bad5;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
.root :global(.scan-time-cell) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-time-main) {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.scan-time-remaining) {
|
||||
font-size: 14px;
|
||||
color: #9eb3ce;
|
||||
}
|
||||
|
||||
.root :global(.scan-phase-badge),
|
||||
.root :global(.scan-status-badge) {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-phase-badge.red) {
|
||||
color: #ff7f7f;
|
||||
background: rgba(255, 93, 93, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-phase-badge.green) {
|
||||
color: #34e49f;
|
||||
background: rgba(23, 217, 139, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-phase-badge.blue) {
|
||||
color: #7cb5ff;
|
||||
background: rgba(63, 140, 255, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-phase-badge.amber) {
|
||||
color: #ffc14b;
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-distribution-preview) {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-compare) {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(86, 120, 168, 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgba(13, 27, 45, 0.7);
|
||||
}
|
||||
|
||||
.root :global(.scan-model-primary) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(76px, 1fr) auto auto;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-primary span),
|
||||
.root :global(.scan-model-rows b) {
|
||||
color: rgba(146, 168, 197, 0.92);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-primary strong) {
|
||||
color: #22eb98;
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-primary em) {
|
||||
color: #d7e7fb;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-rows) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-rows span) {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(54px, auto) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-rows strong) {
|
||||
color: #eef7ff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-model-rows em) {
|
||||
overflow: hidden;
|
||||
color: #9fb4cf;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-cell) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-main) {
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-main.buy) {
|
||||
color: #26e896;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-main.sell) {
|
||||
color: #ff6969;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-sub),
|
||||
.root :global(.scan-trade-note) {
|
||||
font-size: 14px;
|
||||
color: #a5bad5;
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-cell) {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-cell.positive) {
|
||||
color: #1de28f;
|
||||
}
|
||||
|
||||
.root :global(.scan-edge-cell.negative) {
|
||||
color: #ff6868;
|
||||
}
|
||||
|
||||
.root :global(.scan-row-fav) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-all-wrap) {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-view-all-button) {
|
||||
padding: 12px 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(68, 100, 150, 0.18);
|
||||
border-radius: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(9, 23, 38, 0.92), rgba(7, 17, 30, 0.92)),
|
||||
radial-gradient(circle at 0 0, rgba(34, 211, 238, 0.12), transparent 34%);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact) {
|
||||
flex: 0 0 auto;
|
||||
display: block;
|
||||
margin-top: 10px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
background: rgba(7, 17, 30, 0.72);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
color: #9fb4cf;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary span) {
|
||||
color: #c7d8ef;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact .scan-ai-log-summary strong) {
|
||||
color: #7fb4ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.compact[open] .scan-ai-log-list) {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-analysis-view) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-analysis-view.empty) {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-summary-card) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.22);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(8, 32, 52, 0.94), rgba(8, 21, 38, 0.94)),
|
||||
radial-gradient(circle at 0 0, rgba(45, 212, 191, 0.15), transparent 32%);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-summary-card strong) {
|
||||
color: #ecf8ff;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-summary-card p) {
|
||||
margin: 6px 0 0;
|
||||
color: #a6bbd7;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-inline-button) {
|
||||
min-height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid rgba(45, 212, 191, 0.3);
|
||||
border-radius: 12px;
|
||||
color: #d9fff6;
|
||||
background: linear-gradient(135deg, rgba(13, 148, 136, 0.36), rgba(8, 47, 73, 0.4));
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-inline-button:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-panel.in-tab) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-list) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-card) {
|
||||
border: 1px solid rgba(68, 100, 150, 0.2);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
background: rgba(10, 24, 42, 0.82);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
background: rgba(15, 34, 57, 0.9);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-head strong) {
|
||||
color: #f2f8ff;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-head p) {
|
||||
margin: 6px 0 0;
|
||||
color: #9fb4d2;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-head span) {
|
||||
flex: 0 0 auto;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
color: #68f3c8;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-cluster-note) {
|
||||
margin: 0 18px 14px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(34, 211, 238, 0.18);
|
||||
border-radius: 12px;
|
||||
color: #b7cbe6;
|
||||
background: rgba(6, 18, 32, 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contracts) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) auto;
|
||||
gap: 10px 16px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgba(68, 100, 150, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract b) {
|
||||
display: block;
|
||||
color: #edf7ff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract small) {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #8fa8c7;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract > span) {
|
||||
justify-self: end;
|
||||
padding: 5px 9px;
|
||||
border-radius: 999px;
|
||||
color: #a8bedc;
|
||||
background: rgba(68, 100, 150, 0.16);
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract.approve > span) {
|
||||
color: #23f0a8;
|
||||
background: rgba(16, 185, 129, 0.14);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract.veto > span) {
|
||||
color: #ff8a8a;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract.downgrade > span),
|
||||
.root :global(.scan-ai-contract.watchlist > span) {
|
||||
color: #ffd166;
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-contract p) {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
color: #9fb4d2;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-head) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-head strong) {
|
||||
color: #e9f5ff;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-head span) {
|
||||
color: #8fa8c7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-list) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-list::-webkit-scrollbar) {
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-list::-webkit-scrollbar-thumb) {
|
||||
background: rgba(88, 125, 180, 0.26);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item) {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(180px, 1fr);
|
||||
gap: 8px;
|
||||
min-width: 260px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(68, 100, 150, 0.16);
|
||||
border-radius: 12px;
|
||||
background: rgba(14, 29, 48, 0.76);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item b) {
|
||||
display: block;
|
||||
color: #edf7ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item small) {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #9fb4d2;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-time) {
|
||||
color: #7892b1;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item.info) {
|
||||
border-color: rgba(56, 189, 248, 0.22);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item.success) {
|
||||
border-color: rgba(16, 185, 129, 0.32);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item.warning) {
|
||||
border-color: rgba(245, 158, 11, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-item.error) {
|
||||
border-color: rgba(248, 113, 113, 0.34);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-log-empty) {
|
||||
color: #8fa8c7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/* Scan terminal calendar/action view styles. */
|
||||
|
||||
.root :global(.scan-calendar-view) {
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-group) {
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
border-radius: 18px;
|
||||
background: rgba(8, 17, 30, 0.8);
|
||||
overflow: hidden;
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: 460px;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-group-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-date) {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #eef7ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-count) {
|
||||
font-size: 13px;
|
||||
color: #8fa4c3;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-subtitle) {
|
||||
margin-top: 4px;
|
||||
color: #7f96b6;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card),
|
||||
.root :global(.scan-summary-card),
|
||||
.root :global(.scan-settings-card) {
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(180deg, rgba(10, 20, 35, 0.92), rgba(8, 17, 29, 0.92));
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card) {
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card.selected) {
|
||||
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.42);
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card.peak-active) {
|
||||
border-color: rgba(23, 217, 139, 0.34);
|
||||
background: linear-gradient(180deg, rgba(12, 42, 38, 0.9), rgba(8, 20, 30, 0.94));
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card.peak-next) {
|
||||
border-color: rgba(94, 234, 212, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-card.peak-past) {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-city) {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #eef7ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-action) {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: #dcecff;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-action span) {
|
||||
color: #8fa4c3;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-action b) {
|
||||
color: #eef7ff;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-action.buy) {
|
||||
color: #21e391;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-action.sell) {
|
||||
color: #ff6e6e;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-meta) {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #8fa4c3;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-countdown) {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid rgba(94, 234, 212, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(20, 184, 166, 0.1);
|
||||
color: #5fffe8;
|
||||
font-size: 15px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-countdown small) {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #9fb4d2;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-calendar-reason) {
|
||||
margin: 10px 0 0;
|
||||
color: #d6e4f5;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-grid) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-grid.monitor) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.root :global(.scan-summary-card),
|
||||
.root :global(.scan-settings-card) {
|
||||
padding: 18px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import clsx from "clsx";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
LogIn,
|
||||
@@ -17,11 +18,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import styles from "./Dashboard.module.css";
|
||||
import detailChromeStyles from "./DetailPanelChrome.module.css";
|
||||
import modalChromeStyles from "./ModalChrome.module.css";
|
||||
import { DetailPanel as CityDetailPanel } from "@/components/dashboard/DetailPanel";
|
||||
import { FutureForecastModal } from "@/components/dashboard/FutureForecastModal";
|
||||
import { MapCanvas } from "@/components/dashboard/MapCanvas";
|
||||
import { scanRootClass } from "./scan-root-styles";
|
||||
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
|
||||
import {
|
||||
DashboardStoreProvider,
|
||||
@@ -31,37 +28,49 @@ import { I18nProvider, useI18n } from "@/hooks/useI18n";
|
||||
import type {
|
||||
CityDetail,
|
||||
ScanOpportunityRow,
|
||||
ScanTerminalResponse,
|
||||
} from "@/lib/dashboard-types";
|
||||
import {
|
||||
buildBrowserBackendHeaders,
|
||||
fetchBackendApi,
|
||||
} from "@/lib/backend-api";
|
||||
import { getLocalizedCityName } from "@/lib/dashboard-home-copy";
|
||||
import { AiPinnedForecastView } from "@/components/dashboard/scan-terminal/AiPinnedForecastView";
|
||||
import { CalendarView } from "@/components/dashboard/scan-terminal/CalendarView";
|
||||
import { AiForecastKPIBar } from "@/components/dashboard/scan-terminal/AiForecastKPIBar";
|
||||
import { LoadingSignal } from "@/components/dashboard/scan-terminal/LoadingSignal";
|
||||
import { OpportunityOverview } from "@/components/dashboard/scan-terminal/OpportunityOverview";
|
||||
import {
|
||||
findDetailForCity,
|
||||
isFullEnoughForDeepAnalysis,
|
||||
waitForDeepAnalysisQueue,
|
||||
} from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import type { AiPinnedCity } from "@/components/dashboard/scan-terminal/types";
|
||||
import { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
|
||||
import {
|
||||
findRowForCity,
|
||||
formatUserLocalTime,
|
||||
normalizeCityKey,
|
||||
prettifyCityName,
|
||||
rowMatchesCity,
|
||||
sortRowsByUserTime,
|
||||
} from "@/components/dashboard/scan-terminal/decision-utils";
|
||||
import { useAiPinnedCityWorkspace } from "@/components/dashboard/scan-terminal/use-ai-pinned-city-workspace";
|
||||
import { useScanTerminalQuery } from "@/components/dashboard/scan-terminal/use-scan-terminal-query";
|
||||
import {
|
||||
useScanTerminalTheme,
|
||||
useUserLocalClock,
|
||||
} from "@/components/dashboard/scan-terminal/use-scan-terminal-ui-state";
|
||||
|
||||
type ContentView = "opportunities" | "analysis" | "map" | "calendar";
|
||||
type ThemeMode = "dark" | "light";
|
||||
const SCAN_TERMINAL_AUTO_REFRESH_MS = 10 * 60_000;
|
||||
const SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS = 2 * 60_000;
|
||||
type ContentView = "analysis" | "map" | "calendar";
|
||||
|
||||
const CityDetailPanel = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/DetailPanel").then(
|
||||
(module) => module.DetailPanel,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const FutureForecastModal = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/FutureForecastModal").then(
|
||||
(module) => module.FutureForecastModal,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
const MapCanvas = dynamic(
|
||||
() =>
|
||||
import("@/components/dashboard/MapCanvas").then(
|
||||
(module) => module.MapCanvas,
|
||||
),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
function ScanTerminalScreen() {
|
||||
const store = useDashboardStore();
|
||||
@@ -71,40 +80,56 @@ function ScanTerminalScreen() {
|
||||
const accountHref = store.proAccess.authenticated
|
||||
? "/account"
|
||||
: "/auth/login?next=%2Faccount";
|
||||
const [terminalData, setTerminalData] = useState<ScanTerminalResponse | null>(null);
|
||||
const [scanLoading, setScanLoading] = useState(false);
|
||||
const [scanError, setScanError] = useState<string | null>(null);
|
||||
const {
|
||||
refreshScanTerminalManually,
|
||||
scanError,
|
||||
scanLoading,
|
||||
terminalData,
|
||||
} = useScanTerminalQuery({
|
||||
isPro,
|
||||
proAccessLoading: store.proAccess.loading,
|
||||
});
|
||||
const [selectedRowId, setSelectedRowId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<ContentView>("opportunities");
|
||||
const [activeView, setActiveView] = useState<ContentView>("map");
|
||||
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
|
||||
const [aiPinnedCities, setAiPinnedCities] = useState<AiPinnedCity[]>([]);
|
||||
const [showScanPaywall, setShowScanPaywall] = useState(false);
|
||||
const [userLocalTime, setUserLocalTime] = useState("--");
|
||||
const [themeMode, setThemeMode] = useState<ThemeMode>("dark");
|
||||
const lastMapSelectedCityRef = useRef<string>("");
|
||||
const aiFullHydrationRef = useRef<Set<string>>(new Set());
|
||||
const aiHydrationQueueRef = useRef<string[]>([]);
|
||||
const aiHydrationRunningRef = useRef(false);
|
||||
const scanRequestSeqRef = useRef(0);
|
||||
const scanLoadingRef = useRef(false);
|
||||
const lastForcedScanRefreshAtRef = useRef(0);
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const hadLight = root.classList.contains("light");
|
||||
const hadDark = root.classList.contains("dark");
|
||||
root.classList.toggle("light", themeMode === "light");
|
||||
root.classList.toggle("dark", themeMode === "dark");
|
||||
return () => {
|
||||
root.classList.toggle("light", hadLight);
|
||||
root.classList.toggle("dark", hadDark);
|
||||
};
|
||||
}, [themeMode]);
|
||||
const key = "polyweather_v156_announcement_seen_at";
|
||||
const seen = localStorage.getItem(key);
|
||||
const now = Date.now();
|
||||
if (!seen) {
|
||||
localStorage.setItem(key, String(now));
|
||||
setShowAnnouncement(true);
|
||||
return;
|
||||
}
|
||||
const elapsed = now - Number(seen);
|
||||
setShowAnnouncement(elapsed < 3 * 24 * 60 * 60 * 1000);
|
||||
}, []);
|
||||
const userLocalTime = useUserLocalClock();
|
||||
const { setThemeMode, themeMode } = useScanTerminalTheme();
|
||||
const lastMapSelectedCityRef = useRef<string>("");
|
||||
const scanTerminalRootClassName = clsx(
|
||||
styles.root,
|
||||
scanRootClass,
|
||||
themeMode === "light" && "light",
|
||||
);
|
||||
|
||||
const timeSortedRows = useMemo(
|
||||
() => sortRowsByUserTime(terminalData?.rows || []),
|
||||
[terminalData?.rows],
|
||||
);
|
||||
const {
|
||||
addAiPinnedCity,
|
||||
aiPinnedCities,
|
||||
refreshAiPinnedCityDetail,
|
||||
removeAiPinnedCity,
|
||||
} = useAiPinnedCityWorkspace({
|
||||
locale,
|
||||
store,
|
||||
timeSortedRows,
|
||||
});
|
||||
const selectedRow = useMemo(() => {
|
||||
if (!timeSortedRows.length) return null;
|
||||
return timeSortedRows.find((row) => row.id === selectedRowId) || timeSortedRows[0] || null;
|
||||
@@ -122,19 +147,6 @@ function ScanTerminalScreen() {
|
||||
mapSelectedCityName || store.selectedCity,
|
||||
);
|
||||
}, [mapSelectedCityName, store.selectedCity, timeSortedRows]);
|
||||
const kpiCityName =
|
||||
mapSelectedCityName ||
|
||||
store.selectedCity ||
|
||||
aiPinnedCities[0]?.cityName ||
|
||||
null;
|
||||
const kpiDetail =
|
||||
findDetailForCity(store.cityDetailsByName, kpiCityName) ||
|
||||
(store.selectedDetail &&
|
||||
normalizeCityKey(store.selectedDetail.name) === normalizeCityKey(kpiCityName)
|
||||
? store.selectedDetail
|
||||
: null);
|
||||
const kpiRow = findRowForCity(timeSortedRows, kpiCityName);
|
||||
|
||||
const mapFallbackRow = useMemo(() => {
|
||||
const rawCityName = mapSelectedCityName || store.selectedCity;
|
||||
const cityKey = normalizeCityKey(rawCityName);
|
||||
@@ -229,130 +241,6 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
}, [activeView, isPro, store.proAccess.loading]);
|
||||
|
||||
const fetchScanTerminal = useCallback(
|
||||
async ({
|
||||
forceRefresh = false,
|
||||
showLoading = false,
|
||||
}: {
|
||||
forceRefresh?: boolean;
|
||||
showLoading?: boolean;
|
||||
} = {}) => {
|
||||
if (store.proAccess.loading || !isPro) return;
|
||||
const requestSeq = ++scanRequestSeqRef.current;
|
||||
const controller = new AbortController();
|
||||
if (forceRefresh) {
|
||||
lastForcedScanRefreshAtRef.current = Date.now();
|
||||
}
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = true;
|
||||
setScanLoading(true);
|
||||
}
|
||||
setScanError(null);
|
||||
const params = new URLSearchParams({
|
||||
scan_mode: "tradable",
|
||||
min_price: "0.05",
|
||||
max_price: "0.95",
|
||||
min_edge_pct: "2",
|
||||
min_liquidity: "500",
|
||||
market_type: "maxtemp",
|
||||
time_range: "today",
|
||||
limit: "36",
|
||||
force_refresh: String(forceRefresh),
|
||||
});
|
||||
if (forceRefresh) {
|
||||
params.set("_ts", String(Date.now()));
|
||||
}
|
||||
try {
|
||||
const headers = await buildBrowserBackendHeaders({
|
||||
Accept: "application/json",
|
||||
});
|
||||
const response = await fetchBackendApi(`/api/scan/terminal?${params.toString()}`, {
|
||||
cache: "no-store",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `HTTP ${response.status}`;
|
||||
try {
|
||||
const payload = await response.json();
|
||||
message = String(payload?.error || payload?.detail || message);
|
||||
} catch {
|
||||
// Keep HTTP status message.
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
const payload = (await response.json()) as ScanTerminalResponse;
|
||||
if (requestSeq !== scanRequestSeqRef.current) return;
|
||||
setTerminalData(payload);
|
||||
setScanError(null);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || requestSeq !== scanRequestSeqRef.current) return;
|
||||
setScanError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
scanLoadingRef.current = false;
|
||||
setScanLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isPro, store.proAccess.loading],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (store.proAccess.loading) return;
|
||||
if (!isPro) {
|
||||
scanLoadingRef.current = false;
|
||||
setScanLoading(false);
|
||||
setScanError(null);
|
||||
setTerminalData(null);
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: false, showLoading: true });
|
||||
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
|
||||
|
||||
const refreshScanTerminalManually = useCallback(() => {
|
||||
const now = Date.now();
|
||||
const lastForced = lastForcedScanRefreshAtRef.current;
|
||||
const withinCooldown =
|
||||
lastForced > 0 &&
|
||||
now - lastForced < SCAN_TERMINAL_MANUAL_REFRESH_COOLDOWN_MS &&
|
||||
terminalData;
|
||||
if (withinCooldown) {
|
||||
setScanError(null);
|
||||
return;
|
||||
}
|
||||
void fetchScanTerminal({ forceRefresh: true, showLoading: true });
|
||||
}, [fetchScanTerminal, terminalData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (store.proAccess.loading || !isPro) return;
|
||||
const intervalId = window.setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (scanLoadingRef.current) return;
|
||||
void fetchScanTerminal({ forceRefresh: true, showLoading: false });
|
||||
}, SCAN_TERMINAL_AUTO_REFRESH_MS);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [fetchScanTerminal, isPro, store.proAccess.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setUserLocalTime(formatUserLocalTime());
|
||||
const intervalId = window.setInterval(() => {
|
||||
setUserLocalTime(formatUserLocalTime());
|
||||
}, 10_000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = window.localStorage.getItem("polyweather_scan_theme");
|
||||
if (stored === "light") {
|
||||
setThemeMode("light");
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
window.localStorage.setItem("polyweather_scan_theme", themeMode);
|
||||
}, [themeMode]);
|
||||
|
||||
const resolvedView: ContentView = activeView;
|
||||
const mapFocusedCity = mapSelectedCityName || store.selectedCity;
|
||||
const activeDetailRow =
|
||||
@@ -369,115 +257,6 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
}, [activeDetailRow, store.cityDetailsByName, store.ensureCityDetail]);
|
||||
|
||||
const runAiHydrationQueue = useCallback(async () => {
|
||||
if (aiHydrationRunningRef.current) return;
|
||||
aiHydrationRunningRef.current = true;
|
||||
try {
|
||||
while (aiHydrationQueueRef.current.length > 0) {
|
||||
const nextCity = aiHydrationQueueRef.current.shift();
|
||||
const key = normalizeCityKey(nextCity || "");
|
||||
if (!nextCity || !key) continue;
|
||||
const existingDetail = findDetailForCity(store.cityDetailsByName, nextCity);
|
||||
try {
|
||||
const detail = await store.ensureCityDetail(
|
||||
nextCity,
|
||||
Boolean(existingDetail) && !isFullEnoughForDeepAnalysis(existingDetail),
|
||||
"full",
|
||||
);
|
||||
if (!isFullEnoughForDeepAnalysis(detail)) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
} catch {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
await waitForDeepAnalysisQueue(1200);
|
||||
}
|
||||
} finally {
|
||||
aiHydrationRunningRef.current = false;
|
||||
if (aiHydrationQueueRef.current.length > 0) {
|
||||
void runAiHydrationQueue();
|
||||
}
|
||||
}
|
||||
}, [store.cityDetailsByName, store.ensureCityDetail]);
|
||||
|
||||
const queueAiFullHydration = useCallback(
|
||||
(cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||
aiFullHydrationRef.current.add(key);
|
||||
aiHydrationQueueRef.current.push(cityName);
|
||||
void runAiHydrationQueue();
|
||||
},
|
||||
[runAiHydrationQueue],
|
||||
);
|
||||
|
||||
const addAiPinnedCity = useCallback((cityName: string) => {
|
||||
const cleanName = String(cityName || "").trim();
|
||||
const key = normalizeCityKey(cleanName);
|
||||
if (!key) return;
|
||||
const matchedRow = findRowForCity(timeSortedRows, cleanName);
|
||||
const prettyName = prettifyCityName(cleanName);
|
||||
const displayName =
|
||||
matchedRow?.city_display_name ||
|
||||
matchedRow?.display_name ||
|
||||
getLocalizedCityName(cleanName, prettyName || cleanName, locale) ||
|
||||
prettyName ||
|
||||
cleanName;
|
||||
setAiPinnedCities((current) => {
|
||||
const existing = current.findIndex(
|
||||
(item) => normalizeCityKey(item.cityName) === key,
|
||||
);
|
||||
const nextItem = {
|
||||
cityName: matchedRow?.city || cleanName,
|
||||
displayName,
|
||||
addedAt: Date.now(),
|
||||
};
|
||||
if (existing >= 0) {
|
||||
const next = [...current];
|
||||
next[existing] = { ...next[existing], ...nextItem };
|
||||
return [
|
||||
next[existing],
|
||||
...next.filter((_, index) => index !== existing),
|
||||
];
|
||||
}
|
||||
return [nextItem, ...current].slice(0, 8);
|
||||
});
|
||||
queueAiFullHydration(matchedRow?.city || cleanName);
|
||||
}, [locale, queueAiFullHydration, timeSortedRows]);
|
||||
|
||||
const removeAiPinnedCity = useCallback((cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
aiHydrationQueueRef.current = aiHydrationQueueRef.current.filter(
|
||||
(queuedCity) => normalizeCityKey(queuedCity) !== key,
|
||||
);
|
||||
setAiPinnedCities((current) =>
|
||||
current.filter((item) => normalizeCityKey(item.cityName) !== key),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshAiPinnedCityDetail = useCallback(
|
||||
async (cityName: string) => {
|
||||
const key = normalizeCityKey(cityName);
|
||||
if (key) {
|
||||
aiFullHydrationRef.current.delete(key);
|
||||
}
|
||||
await store.ensureCityDetail(cityName, true, "full");
|
||||
},
|
||||
[store.ensureCityDetail],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
aiPinnedCities.forEach((item) => {
|
||||
const key = normalizeCityKey(item.cityName);
|
||||
if (!key || aiFullHydrationRef.current.has(key)) return;
|
||||
const detail = findDetailForCity(store.cityDetailsByName, item.cityName);
|
||||
const needsFullHydration = !isFullEnoughForDeepAnalysis(detail);
|
||||
if (!needsFullHydration) return;
|
||||
queueAiFullHydration(item.cityName);
|
||||
});
|
||||
}, [aiPinnedCities, queueAiFullHydration, store.cityDetailsByName]);
|
||||
|
||||
const handleMapCitySelect = useCallback((cityName: string) => {
|
||||
setMapSelectedCityName(cityName);
|
||||
lastMapSelectedCityRef.current = normalizeCityKey(cityName);
|
||||
@@ -533,36 +312,6 @@ function ScanTerminalScreen() {
|
||||
}, []);
|
||||
|
||||
const renderMainView = () => {
|
||||
if (resolvedView === "opportunities") {
|
||||
if (!isPro) {
|
||||
return (
|
||||
<div className="scan-opportunity-overview empty">
|
||||
<strong>{isEn ? "Opportunity board is Pro" : "今日机会榜需 Pro 权限"}</strong>
|
||||
<p>
|
||||
{isEn
|
||||
? "Map exploration and city briefing are still available."
|
||||
: "地图探索和城市简报仍可使用。"}
|
||||
</p>
|
||||
<button type="button" onClick={openScanPaywall}>
|
||||
{isEn ? "Unlock opportunity board" : "解锁机会榜"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<OpportunityOverview
|
||||
rows={timeSortedRows}
|
||||
terminalData={terminalData}
|
||||
loading={scanLoading}
|
||||
error={scanError}
|
||||
locale={locale}
|
||||
selectedRowId={selectedRowId}
|
||||
onOpenDecision={handleOpenDecisionRow}
|
||||
onSelectRow={handleSelectRow}
|
||||
onOpenMap={() => setActiveView("map")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (resolvedView === "map") {
|
||||
return (
|
||||
<div className="scan-map-view">
|
||||
@@ -618,7 +367,7 @@ function ScanTerminalScreen() {
|
||||
|
||||
if (store.proAccess.loading) {
|
||||
return (
|
||||
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
|
||||
<div className={scanTerminalRootClassName}>
|
||||
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
||||
<main className="scan-data-grid">
|
||||
<div className="scan-loading-state">
|
||||
@@ -638,18 +387,29 @@ function ScanTerminalScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx(styles.root, detailChromeStyles.root, modalChromeStyles.root, themeMode === "light" && "light")}>
|
||||
<div className={clsx("scan-terminal", themeMode === "light" && "light")}>
|
||||
<div className={scanTerminalRootClassName}>
|
||||
<div
|
||||
className={clsx(
|
||||
"scan-terminal",
|
||||
resolvedView === "map" && "map-view-active",
|
||||
resolvedView !== "map" && "focus-view-active",
|
||||
resolvedView === "analysis" && "analysis-view-active",
|
||||
themeMode === "light" && "light",
|
||||
)}
|
||||
>
|
||||
<main className="scan-data-grid">
|
||||
<div className="scan-topbar">
|
||||
<div className="scan-topbar-title">
|
||||
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
|
||||
<div className="scan-topbar-brand">
|
||||
<div className="scan-topbar-logo" aria-hidden="true" />
|
||||
<div className="scan-topbar-title">
|
||||
<strong>{isEn ? "AI Weather Decision Terminal" : "AI 天气交易决策台"}</strong>
|
||||
<span>
|
||||
{isEn
|
||||
? "Start from opportunities, then open city cards to verify weather evidence"
|
||||
: "先看今日机会榜,再打开城市决策卡验证天气证据"}
|
||||
? "Start from the map, then open city cards to verify weather evidence"
|
||||
: "从地图选城市,再打开决策卡验证天气证据"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-topbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -701,28 +461,36 @@ function ScanTerminalScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiForecastKPIBar
|
||||
pinnedCount={aiPinnedCities.length}
|
||||
activeCityName={kpiCityName}
|
||||
activeDetail={kpiDetail}
|
||||
activeRow={kpiRow}
|
||||
locale={locale}
|
||||
/>
|
||||
{showAnnouncement ? (
|
||||
<section className="scan-upgrade-announcement" aria-label={isEn ? "Upgrade announcement" : "升级公告"}>
|
||||
<div className="scan-upgrade-announcement-copy">
|
||||
<span>{isEn ? "v1.5.6 upgrade" : "v1.5.6 升级公告"}</span>
|
||||
<strong>
|
||||
{isEn ? "Scan terminal is upgraded to v1.5.6" : "决策终端已升级到 v1.5.6"}
|
||||
</strong>
|
||||
<p>
|
||||
{isEn
|
||||
? "City decision cards have been redesigned with a compact hero layout and consistent DEB data source. Sticky headers are removed for smoother scrolling."
|
||||
: "城市决策卡 hero 布局重新设计,三指标并列对比更直观;DEB 数据源统一不再出现不一致;去除顶部固定效果滚动更流畅。"}
|
||||
</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>{isEn ? "Redesigned decision card hero" : "重设计城市决策卡 hero 布局"}</li>
|
||||
<li>{isEn ? "Unified DEB data source" : "统一 DEB 数据源"}</li>
|
||||
<li>{isEn ? "Light theme coverage" : "亮色主题补全覆盖"}</li>
|
||||
<li>{isEn ? "HKO observatory AI read" : "香港天文台观测 AI 解读"}</li>
|
||||
<li>{isEn ? "Smoother scrolling experience" : "滚动体验优化"}</li>
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="scan-list-section">
|
||||
<div className="scan-list-header">
|
||||
<div className="scan-list-tabs">
|
||||
<button
|
||||
type="button"
|
||||
className={resolvedView === "opportunities" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveView("opportunities");
|
||||
}}
|
||||
>
|
||||
{isEn ? "Opportunity Board" : "今日机会榜"}
|
||||
</button>
|
||||
<div className="scan-list-tabs" role="tablist" aria-label={isEn ? "Content view" : "内容视图"}>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolvedView === "map"}
|
||||
className={resolvedView === "map" ? "active" : ""}
|
||||
onClick={() => {
|
||||
lastMapSelectedCityRef.current = normalizeCityKey(store.selectedCity);
|
||||
@@ -733,6 +501,8 @@ function ScanTerminalScreen() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolvedView === "analysis"}
|
||||
className={resolvedView === "analysis" ? "active" : ""}
|
||||
onClick={() => {
|
||||
setActiveView("analysis");
|
||||
@@ -742,6 +512,8 @@ function ScanTerminalScreen() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={resolvedView === "calendar"}
|
||||
className={resolvedView === "calendar" ? "active" : ""}
|
||||
title={!isPro ? (isEn ? "Pro forecast calendar required" : "日历预测需 Pro") : undefined}
|
||||
onClick={() => {
|
||||
@@ -781,8 +553,8 @@ function ScanTerminalScreen() {
|
||||
disabled={scanLoading}
|
||||
title={
|
||||
isEn
|
||||
? "Force refresh opportunity board and calendar"
|
||||
: "强制刷新今日机会榜和日历视图"
|
||||
? "Force refresh decision cards and calendar"
|
||||
: "强制刷新决策卡和日历视图"
|
||||
}
|
||||
>
|
||||
<RefreshCw size={14} className={scanLoading ? "spin" : undefined} />
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
/* Scan terminal right-side detail panel styles for the current decision desk. */
|
||||
|
||||
.root :global(.scan-detail-panel) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding: 18px 16px;
|
||||
border-left: 1px solid rgba(82, 114, 161, 0.18);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-header) {
|
||||
padding: 0 0 16px;
|
||||
justify-content: space-between;
|
||||
border-bottom: none;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-top) {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-title-wrap) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-icon-button) {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-hero-placeholder) {
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #314d7b, #0d2038 60%, #08131f);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-name) {
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-city-sub) {
|
||||
font-size: 14px;
|
||||
color: #a4bad6;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-volume-row) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-volume-big) {
|
||||
font-size: 28px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-volume-caption) {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
color: #90a7c4;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-action-button) {
|
||||
padding: 11px 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-primary-actions) {
|
||||
display: flex;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-analysis-button) {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(23, 217, 139, 0.3);
|
||||
border-radius: 14px;
|
||||
background: rgba(23, 217, 139, 0.12);
|
||||
color: #27ea98;
|
||||
padding: 12px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-analysis-button:hover) {
|
||||
background: rgba(23, 217, 139, 0.18);
|
||||
border-color: rgba(23, 217, 139, 0.4);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section) {
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-section-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #e8f2ff;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv-list) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 14px;
|
||||
padding: 4px 0;
|
||||
color: #dbe7f8;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv span:first-child) {
|
||||
color: #91a8c6;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv strong) {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv strong.warn) {
|
||||
color: #ffbf39;
|
||||
}
|
||||
|
||||
.root :global(.scan-kv strong.danger) {
|
||||
color: #ff7f7f;
|
||||
}
|
||||
|
||||
.root :global(.scan-timeline-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: #a1b6d2;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-timeline-bar) {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
margin-top: 8px;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #e83c3c 0 72%, rgba(255, 255, 255, 0.18) 72% 100%);
|
||||
}
|
||||
|
||||
.root :global(.scan-timeline-knob) {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #dfe8f6;
|
||||
box-shadow: 0 0 0 4px rgba(223, 232, 246, 0.12);
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.root :global(.scan-timeline-caption) {
|
||||
margin-top: 8px;
|
||||
color: #a1b6d2;
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-legend) {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
color: #a8bdd8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-legend .dot) {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
margin-right: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-legend .dot.green) {
|
||||
background: #1ce393;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-legend .dot.blue) {
|
||||
background: #4ca2ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-bars) {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
height: 170px;
|
||||
padding: 8px 4px 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-group) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-group.highlighted .scan-chart-label) {
|
||||
color: #22eb98;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-col) {
|
||||
width: 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-col.model) {
|
||||
background: linear-gradient(180deg, #1ce393, #0b8d5a);
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-col.market) {
|
||||
background: linear-gradient(180deg, #4ca2ff, #2263cf);
|
||||
}
|
||||
|
||||
.root :global(.scan-chart-label) {
|
||||
position: absolute;
|
||||
bottom: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: #93a8c4;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-cards) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card) {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(11, 24, 42, 0.92);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.buy) {
|
||||
box-shadow: inset 0 0 0 1px rgba(23, 217, 139, 0.22);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.sell) {
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 93, 93, 0.22);
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card-title) {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.buy .scan-trade-card-title) {
|
||||
color: #20e391;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card.sell .scan-trade-card-title) {
|
||||
color: #ff7070;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card p) {
|
||||
margin: 10px 0 0;
|
||||
color: #d5e1f5;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card p.positive) {
|
||||
color: #20e391;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-card p.negative) {
|
||||
color: #ff7070;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-block) {
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-label-text) {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-meta) {
|
||||
margin-top: 6px;
|
||||
color: #89a0bf;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-value) {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-value.green) {
|
||||
color: #23e694;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-value.yellow) {
|
||||
color: #ffbe26;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-value.red) {
|
||||
color: #ff6f86;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-value span) {
|
||||
font-size: 22px;
|
||||
color: #9fb5d2;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-line) {
|
||||
height: 6px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-score-line span) {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #1be392, #12b46d);
|
||||
box-shadow: 0 0 18px rgba(27, 227, 146, 0.22);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/* Scan terminal sidebar filters, mode tabs, range controls, toggles, and CTA.
|
||||
Imported after ScanTerminal.module.css so these current-desk overrides win over legacy base rules. */
|
||||
|
||||
.root :global(.scan-filter-panel) {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
border-right: 1px solid rgba(159, 178, 199, 0.14);
|
||||
padding: 18px 14px;
|
||||
gap: 18px;
|
||||
overflow: hidden auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-sidebar-brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
padding: 8px 4px 18px;
|
||||
border-bottom: 1px solid rgba(118, 146, 188, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-sidebar-brand-mark) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 14px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #042113;
|
||||
background: radial-gradient(circle at 30% 30%, #29f2ab, #0da46a 60%, #073c28 100%);
|
||||
box-shadow: 0 0 24px rgba(23, 217, 139, 0.3);
|
||||
}
|
||||
|
||||
.root :global(.scan-sidebar-brand-name) {
|
||||
font-size: 30px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
color: #eef5ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-heading) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 6px 8px 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #b8c7dd;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tabs) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab) {
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(87, 119, 166, 0.14);
|
||||
background: rgba(11, 24, 42, 0.95);
|
||||
color: #dbe7f8;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab:hover) {
|
||||
background: rgba(13, 28, 48, 0.98);
|
||||
border-color: rgba(87, 119, 166, 0.24);
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active) {
|
||||
background: linear-gradient(180deg, rgba(15, 34, 59, 1), rgba(9, 19, 34, 1));
|
||||
border-color: rgba(78, 222, 151, 0.7);
|
||||
color: #f3fbff;
|
||||
box-shadow: inset 0 0 0 1px rgba(78, 222, 151, 0.35), 0 0 0 2px rgba(7, 199, 119, 0.08);
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active::before) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-icon) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 10px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
background: rgba(63, 140, 255, 0.12);
|
||||
color: #6cb1ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab.active .scan-mode-icon) {
|
||||
background: rgba(23, 217, 139, 0.14);
|
||||
color: #2df3a5;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-copy) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab-label) {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.root :global(.scan-mode-tab-sub) {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: #8fa4c3;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-card),
|
||||
.root :global(.scan-filter-row) {
|
||||
background: rgba(11, 24, 42, 0.92);
|
||||
border: 1px solid rgba(87, 119, 166, 0.12);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-card) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-row-title),
|
||||
.root :global(.scan-filter-row-label) {
|
||||
font-size: 14px;
|
||||
color: #d9e7f8;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-row) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-row.inline) {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-track-wrap) {
|
||||
position: relative;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-track-wrap::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 7px 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #1c2f4e, #2d4f84);
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider::-webkit-slider-thumb) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #4d93ff;
|
||||
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider::-moz-range-thumb) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: #4d93ff;
|
||||
box-shadow: 0 0 0 4px rgba(77, 147, 255, 0.16);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-slider::-webkit-slider-runnable-track),
|
||||
.root :global(.scan-range-slider::-moz-range-track) {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.root :global(.scan-range-labels) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #aec0d9;
|
||||
}
|
||||
|
||||
.root :global(.scan-select) {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
border-color: rgba(87, 119, 166, 0.12);
|
||||
background: rgba(10, 22, 38, 0.95);
|
||||
color: #d9e8fb;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle) {
|
||||
width: 42px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: linear-gradient(90deg, #0e2840, #12324d);
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active) {
|
||||
background: linear-gradient(90deg, #0e2840, #1f7f57);
|
||||
border-color: rgba(39, 234, 152, 0.28);
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle-knob) {
|
||||
top: 2px;
|
||||
left: 3px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #8ca5c8;
|
||||
}
|
||||
|
||||
.root :global(.scan-toggle.active .scan-toggle-knob) {
|
||||
left: 21px;
|
||||
background: #1bf1a2;
|
||||
box-shadow: 0 0 10px rgba(27, 241, 162, 0.45);
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-button) {
|
||||
border-radius: 14px;
|
||||
padding: 15px;
|
||||
margin-top: auto;
|
||||
background: linear-gradient(180deg, #1ddb8d, #0eb96d);
|
||||
box-shadow: 0 8px 22px rgba(14, 185, 109, 0.22);
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,520 @@
|
||||
/* Scan terminal KPI, list tabs, table rows, v4 analysis, and AI workspace.
|
||||
Kept separate from the shell/filter CSS so dense decision-board styles can evolve independently. */
|
||||
|
||||
.root :global(.scan-list-section) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-header) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs) {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs button) {
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #91a7c4;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs button:disabled) {
|
||||
opacity: 0.42;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs button.active) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-status) {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 126, 167, 0.22);
|
||||
background: rgba(10, 22, 38, 0.92);
|
||||
color: #c4d4e9;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.live) {
|
||||
border-color: rgba(55, 214, 255, 0.28);
|
||||
background: rgba(55, 214, 255, 0.1);
|
||||
color: #71e6ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.stale) {
|
||||
border-color: rgba(255, 184, 79, 0.24);
|
||||
background: rgba(255, 184, 79, 0.1);
|
||||
color: #ffc765;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.ai) {
|
||||
border-color: rgba(94, 234, 212, 0.28);
|
||||
background: rgba(20, 184, 166, 0.12);
|
||||
color: #87fff1;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip.focus) {
|
||||
border-color: rgba(255, 176, 32, 0.3);
|
||||
background: rgba(255, 176, 32, 0.12);
|
||||
color: #ffd078;
|
||||
}
|
||||
|
||||
.root :global(button.scan-status-chip.refresh) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(button.scan-status-chip.refresh:disabled) {
|
||||
cursor: wait;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip .spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-shell) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
border: 1px solid rgba(90, 123, 166, 0.12);
|
||||
border-radius: 18px;
|
||||
background: rgba(8, 17, 30, 0.8);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-shell.empty) {
|
||||
min-height: 340px;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgba(95, 124, 168, 0.12);
|
||||
background: linear-gradient(180deg, rgba(255, 184, 79, 0.08), rgba(255, 184, 79, 0.03));
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner strong) {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: #ffd27f;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-banner span) {
|
||||
font-size: 13px;
|
||||
color: #c7d4e7;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-header) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1.1fr) minmax(128px, 0.68fr) minmax(230px, 1.08fr) minmax(150px, 0.78fr) 92px;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 14px 18px;
|
||||
background: rgba(11, 23, 40, 0.98);
|
||||
border-bottom: 1px solid rgba(90, 123, 166, 0.12);
|
||||
color: #92a8c5;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-body) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.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: 800;
|
||||
}
|
||||
|
||||
.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: var(--color-accent-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
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: 800;
|
||||
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: 800;
|
||||
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: var(--color-accent-primary);
|
||||
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: 800;
|
||||
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-forecast-desk) {
|
||||
gap: 16px;
|
||||
padding: 16px 18px 20px;
|
||||
}
|
||||
|
||||
|
||||
.root :global(.scan-ai-workspace) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scroll-padding-top: 64px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.16);
|
||||
border-radius: 18px;
|
||||
background: var(--color-bg-base);
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace::-webkit-scrollbar) {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace::-webkit-scrollbar-thumb) {
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.28);
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace.empty) {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head) {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head span) {
|
||||
display: block;
|
||||
color: var(--color-accent-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head strong) {
|
||||
display: block;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head p) {
|
||||
max-width: 560px;
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-jumpbar) {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
padding: 8px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(77, 163, 255, 0.14);
|
||||
border-radius: 14px;
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.22);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-jumpbar::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-jumpbar button) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.26);
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.1);
|
||||
color: #cfe5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-jumpbar button:hover) {
|
||||
border-color: rgba(111, 183, 255, 0.62);
|
||||
background: rgba(77, 163, 255, 0.18);
|
||||
}
|
||||
|
||||
|
||||
.root :global(.scan-ai-city-card:not(.collapsed)) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,535 @@
|
||||
/* Scan terminal mobile action-card layout and responsive overrides. */
|
||||
|
||||
.root :global(.scan-mobile-decision-card) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-head) {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-head h3) {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 24px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-metrics) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-metrics span) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(77, 163, 255, 0.09);
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-metrics small) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-metrics b) {
|
||||
color: #f3f8ff;
|
||||
font-size: 13px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-reason) {
|
||||
margin: 0 14px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.24);
|
||||
border-radius: 13px;
|
||||
background: rgba(77, 163, 255, 0.1);
|
||||
color: #d8e7f8;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
line-height: 1.45;
|
||||
padding: 11px 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-city-status-tags),
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness),
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line),
|
||||
.root :global(.scan-mobile-decision-folds) {
|
||||
margin-right: 14px;
|
||||
margin-left: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-city-freshness) {
|
||||
grid-template-columns: 1fr;
|
||||
max-width: none;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-market-mobile-line) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-card .scan-ai-market-decision) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-decision-folds) {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-fold) {
|
||||
padding: 13px 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-fold summary) {
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-fold summary::-webkit-details-marker) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-fold[open] summary) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-mobile-fold .scan-ai-city-section.models) {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-collapse svg) {
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
.root :global(.scan-terminal) {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||
position: relative;
|
||||
top: auto;
|
||||
grid-column: 1 / -1;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-detail-panel) {
|
||||
grid-column: 1 / -1;
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.root :global(.scan-terminal) {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 100dvh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-filter-panel),
|
||||
.root :global(.scan-data-grid),
|
||||
.root :global(.scan-detail-panel),
|
||||
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid) {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-section),
|
||||
.root :global(.scan-table-shell),
|
||||
.root :global(.scan-ai-workspace) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace) {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-terminal > .detail-panel.scan-city-detail-rail) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement ul) {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-header),
|
||||
.root :global(.scan-table-row) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-table-header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-group-head),
|
||||
.root :global(.scan-opportunity-item) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-city-head),
|
||||
.root :global(.scan-forecast-row-main),
|
||||
.root :global(.scan-ai-brief-grid),
|
||||
.root :global(.scan-ai-city-analysis-grid),
|
||||
.root :global(.scan-ai-decision-band),
|
||||
.root :global(.scan-ai-evidence-line) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head),
|
||||
.root :global(.scan-opportunity-hero),
|
||||
.root :global(.scan-ai-city-hero),
|
||||
.root :global(.scan-ai-city-section-head) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-hero) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-workspace-head p),
|
||||
.root :global(.scan-ai-city-hero-side) {
|
||||
text-align: left;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-decision-metrics) {
|
||||
min-width: 0;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-card-grid),
|
||||
.root :global(.scan-opportunity-summary) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-city-read) {
|
||||
justify-items: start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-signals),
|
||||
.root :global(.scan-ai-temperature-line) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-phase) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-branch) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-stat.edge) {
|
||||
justify-self: start;
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-ai),
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.root :global(.scan-v4-analysis) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-trade-cards) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.root :global(.scan-terminal) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid) {
|
||||
padding: 12px;
|
||||
gap: 14px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-actions) {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-title strong) {
|
||||
font-size: 23px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-title span) {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tabs) {
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement) {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy strong) {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement ul) {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement li) {
|
||||
font-size: 11px;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-hero) {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-hero h3) {
|
||||
margin-bottom: 10px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-mobile-priority) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-mobile-priority span) {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(77, 163, 255, 0.09);
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-mobile-priority small) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-mobile-priority b) {
|
||||
color: #f3f8ff;
|
||||
font-size: 13px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-pills) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-freshness) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-decision-band) {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-decision-band strong) {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-mobile-line) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.18);
|
||||
border-radius: 12px;
|
||||
background: rgba(11, 18, 32, 0.48);
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-mobile-line span) {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-mobile-line b) {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 13px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-market-decision) {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-city-ai-read:not([open])) {
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-header) {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs) {
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs::-webkit-scrollbar) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs button) {
|
||||
flex: 0 0 auto;
|
||||
min-height: 36px;
|
||||
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||
border-radius: 999px;
|
||||
background: rgba(8, 19, 34, 0.76);
|
||||
padding: 0 12px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.root :global(.scan-list-tabs button.active) {
|
||||
border-color: rgba(77, 163, 255, 0.38);
|
||||
background: rgba(77, 163, 255, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.scan-list-status) {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-groups) {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-opportunity-models span) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-city-title strong) {
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-row-main),
|
||||
.root :global(.scan-ai-analysis) {
|
||||
padding: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-forecast-ai-line) {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 0 13px 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.root :global(.scan-terminal) {
|
||||
box-sizing: border-box;
|
||||
max-width: 100vw;
|
||||
overflow-x: clip;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.root :global(.scan-data-grid) {
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-tabs) {
|
||||
max-width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement) {
|
||||
margin: -2px 0 2px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy p) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement ul) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-status-chip) {
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.root :global(.scan-primary-button) {
|
||||
min-height: 38px;
|
||||
padding: 9px 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.root :global(.scan-account-button) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
.root :global(.scan-theme-button) {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
/* Scan terminal shell, topbar, upgrade announcement, and shared action buttons.
|
||||
Extracted from ScanTerminal.module.css to keep terminal layout CSS maintainable. */
|
||||
|
||||
/* ── Button Hierarchy ──
|
||||
Primary: .scan-primary-button — filled gradient, main CTA
|
||||
Secondary: .scan-ai-button — tinted background, alternative action
|
||||
Ghost: .scan-theme-button — no border/background, icon-only
|
||||
Chrome: .scan-account-button — bordered icon, navigation
|
||||
Chrome: .scan-locale-switch — segmented control
|
||||
Chrome: .scan-ghost-button — bordered + bg, utility action
|
||||
────────────────────────────────────────────── */
|
||||
|
||||
.root :global(.scan-topbar) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 6px 0 18px;
|
||||
border-bottom: 1px solid rgba(118, 146, 188, 0.1);
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-brand) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-logo) {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--color-accent-primary), #22C55E);
|
||||
box-shadow: 0 4px 12px rgba(77, 163, 255, 0.28);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-logo::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 6px;
|
||||
background:
|
||||
radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.6), transparent 60%);
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-title) {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-title strong) {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.04em;
|
||||
color: #f3f8ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-title span) {
|
||||
color: #8fa4c3;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-actions) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
color: #b4c8e2;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.root :global(.scan-locale-switch) {
|
||||
height: 36px;
|
||||
padding: 3px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||
background: rgba(8, 19, 34, 0.9);
|
||||
color: #a8bdd8;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-locale-switch span) {
|
||||
min-width: 34px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-locale-switch span.active) {
|
||||
color: #eef7ff;
|
||||
background: rgba(63, 140, 255, 0.16);
|
||||
box-shadow: inset 0 0 0 1px rgba(63, 140, 255, 0.22);
|
||||
}
|
||||
|
||||
.root :global(.scan-topbar-time) {
|
||||
color: #b4c8e2;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
margin: -4px 0 6px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.2);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(77, 163, 255, 0.18), transparent 36%),
|
||||
linear-gradient(135deg, rgba(18, 33, 58, 0.96), rgba(15, 23, 42, 0.92));
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.2);
|
||||
padding: 16px 18px;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy span) {
|
||||
color: #86efac;
|
||||
font-size: 12px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy strong) {
|
||||
display: block;
|
||||
margin-top: 5px;
|
||||
color: #f3f8ff;
|
||||
font-size: 18px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement-copy p) {
|
||||
margin: 6px 0 0;
|
||||
color: #b4c8e2;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement ul) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-upgrade-announcement li) {
|
||||
border: 1px solid rgba(77, 163, 255, 0.22);
|
||||
border-radius: 999px;
|
||||
background: rgba(77, 163, 255, 0.1);
|
||||
color: #d7e5f7;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
padding: 7px 10px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ghost-button),
|
||||
.root :global(.scan-cta-ghost),
|
||||
.root :global(.scan-theme-button),
|
||||
.root :global(.scan-detail-action-button),
|
||||
.root :global(.scan-detail-icon-button),
|
||||
.root :global(.scan-view-all-button) {
|
||||
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||
background: rgba(8, 19, 34, 0.9);
|
||||
color: #d7e5f7;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-ghost-button),
|
||||
.root :global(.scan-cta-ghost) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.root :global(.scan-primary-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 11px 16px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.42);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, var(--color-accent-primary), #3B82F6);
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 10px 24px rgba(59, 130, 246, 0.22);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-primary-button:hover:not(:disabled)) {
|
||||
filter: brightness(1.06);
|
||||
}
|
||||
|
||||
.root :global(.scan-primary-button:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-button) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 11px 14px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, rgba(77, 163, 255, 0.18), rgba(59, 130, 246, 0.12));
|
||||
color: #CFE6FF;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-button:hover:not(:disabled)) {
|
||||
border-color: rgba(111, 183, 255, 0.5);
|
||||
background: linear-gradient(180deg, rgba(77, 163, 255, 0.26), rgba(59, 130, 246, 0.18));
|
||||
}
|
||||
|
||||
.root :global(.scan-ai-button:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-cta-ghost) {
|
||||
color: var(--color-accent-secondary);
|
||||
border-color: rgba(23, 217, 139, 0.3);
|
||||
background: rgba(23, 217, 139, 0.12);
|
||||
}
|
||||
|
||||
.root :global(.scan-account-button) {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #d7e5f7;
|
||||
text-decoration: none;
|
||||
border: 1px solid rgba(99, 132, 180, 0.18);
|
||||
background: rgba(8, 19, 34, 0.9);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-account-button:hover) {
|
||||
border-color: rgba(63, 140, 255, 0.28);
|
||||
background: rgba(12, 26, 44, 0.96);
|
||||
}
|
||||
|
||||
.root :global(.scan-theme-button) {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
color: #d7e5f7;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-ghost-button .spin) {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/* Scan terminal empty, loading, error, and skeleton signal states. */
|
||||
|
||||
/* ── Empty state ── */
|
||||
.root :global(.scan-empty-state) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-empty-icon) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: rgba(77, 163, 255, 0.1);
|
||||
border: 1px solid rgba(77, 163, 255, 0.16);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-accent-secondary);
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-empty-title) {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #e8f2ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-empty-copy) {
|
||||
max-width: 360px;
|
||||
font-size: 14px;
|
||||
color: #8fa4c3;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Error state ── */
|
||||
.root :global(.scan-error-state) {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-error-icon) {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 14px;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #FCA5A5;
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.root :global(.scan-error-title) {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
color: #e8f2ff;
|
||||
}
|
||||
|
||||
.root :global(.scan-error-copy) {
|
||||
max-width: 360px;
|
||||
font-size: 14px;
|
||||
color: #8fa4c3;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.root :global(.scan-retry-button) {
|
||||
margin-top: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid rgba(77, 163, 255, 0.3);
|
||||
border-radius: 12px;
|
||||
background: rgba(77, 163, 255, 0.12);
|
||||
color: #9ecbff;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root :global(.scan-retry-button:hover) {
|
||||
background: rgba(77, 163, 255, 0.18);
|
||||
border-color: rgba(111, 183, 255, 0.48);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-state) {
|
||||
flex: 1;
|
||||
min-height: 520px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 48px 32px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal) {
|
||||
position: relative;
|
||||
width: min(100%, 420px);
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 18px;
|
||||
padding: 28px 30px;
|
||||
border: 1px solid rgba(125, 211, 252, 0.14);
|
||||
border-radius: 28px;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(77, 163, 255, 0.18), transparent 34%),
|
||||
linear-gradient(145deg, rgba(12, 20, 36, 0.92), rgba(5, 11, 22, 0.74));
|
||||
box-shadow:
|
||||
0 22px 70px rgba(0, 0, 0, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal::before) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(125, 211, 252, 0.08) 42%,
|
||||
rgba(34, 197, 94, 0.1) 50%,
|
||||
transparent 58%
|
||||
);
|
||||
transform: translateX(-120%);
|
||||
animation: scan-loading-sweep 2.6s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal.compact) {
|
||||
width: min(100%, 340px);
|
||||
gap: 12px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 22px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(77, 163, 255, 0.14), transparent 36%),
|
||||
rgba(10, 18, 32, 0.62);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-decision-flow) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 16px minmax(70px, 1fr) 16px minmax(70px, 1fr) 16px;
|
||||
align-items: center;
|
||||
width: min(100%, 300px);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.16);
|
||||
border-radius: 999px;
|
||||
background: #0b1220;
|
||||
box-shadow: 0 0 0 5px rgba(77, 163, 255, 0.05);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node.hot) {
|
||||
background: linear-gradient(135deg, #f97316, #fde047);
|
||||
box-shadow: 0 0 24px rgba(251, 146, 60, 0.26);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node.market) {
|
||||
background: linear-gradient(135deg, #38bdf8, var(--color-accent-primary));
|
||||
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-node.action) {
|
||||
background: linear-gradient(135deg, #22c55e, #1be392);
|
||||
box-shadow: 0 0 24px rgba(34, 197, 94, 0.3);
|
||||
animation: scan-loading-node-breathe 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail) {
|
||||
position: relative;
|
||||
height: 2px;
|
||||
overflow: hidden;
|
||||
background: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail i) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 48%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, transparent, #7dd3fc, #22c55e);
|
||||
transform: translateX(-110%);
|
||||
animation: scan-loading-rail-flow 1.55s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-rail:nth-of-type(4) i) {
|
||||
animation-delay: 0.45s;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy-block) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy-block strong) {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-copy-block span) {
|
||||
max-width: 320px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: 5px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span) {
|
||||
width: 7px;
|
||||
min-height: 8px;
|
||||
border-radius: 999px 999px 4px 4px;
|
||||
background: linear-gradient(180deg, #7dd3fc, var(--color-accent-primary) 52%, #1be392);
|
||||
opacity: 0.42;
|
||||
transform-origin: center bottom;
|
||||
animation: scan-loading-bars 1.35s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(1)) {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(2)) {
|
||||
height: 22px;
|
||||
animation-delay: 0.16s;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(3)) {
|
||||
height: 16px;
|
||||
animation-delay: 0.32s;
|
||||
}
|
||||
|
||||
.root :global(.scan-loading-signal-bars span:nth-child(4)) {
|
||||
height: 25px;
|
||||
animation-delay: 0.48s;
|
||||
}
|
||||
|
||||
@keyframes scan-loading-sweep {
|
||||
0% {
|
||||
transform: translateX(-130%);
|
||||
}
|
||||
46%,
|
||||
100% {
|
||||
transform: translateX(130%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-rail-flow {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
opacity: 0.15;
|
||||
}
|
||||
45% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateX(220%);
|
||||
opacity: 0.15;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-node-breathe {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(0.92);
|
||||
filter: saturate(0.9);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
filter: saturate(1.25);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes scan-loading-bars {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.38;
|
||||
transform: scaleY(0.72);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.95;
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ScanOpportunityRow } from "@/lib/dashboard-types";
|
||||
import { formatQuoteCents } from "./opportunity-format";
|
||||
import { getLocalizedRowText } from "./opportunity-copy";
|
||||
import { getModelSourceSummary } from "./opportunity-model-summary";
|
||||
|
||||
export function getOpportunityStrength(edgePercent?: number | null, locale = "zh-CN") {
|
||||
const edge = Number(edgePercent);
|
||||
const normalized = Number.isFinite(edge) ? edge : 0;
|
||||
if (normalized >= 20) {
|
||||
return {
|
||||
label: locale === "en-US" ? "High confidence" : "高胜率",
|
||||
tone: "strong",
|
||||
};
|
||||
}
|
||||
if (normalized >= 10) {
|
||||
return {
|
||||
label: locale === "en-US" ? "Medium confidence" : "中等胜率",
|
||||
tone: "medium",
|
||||
};
|
||||
}
|
||||
return {
|
||||
label: locale === "en-US" ? "Watch" : "观察",
|
||||
tone: "watch",
|
||||
};
|
||||
}
|
||||
|
||||
export function getShortAiConclusion(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
_edgePercent?: number | null,
|
||||
strengthLabel?: string,
|
||||
) {
|
||||
const directReason =
|
||||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||||
getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_watchlist_reason_zh,
|
||||
row.ai_watchlist_reason_en,
|
||||
);
|
||||
if (directReason) return directReason;
|
||||
const cityThesis = getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_city_thesis_zh,
|
||||
row.ai_city_thesis_en,
|
||||
);
|
||||
if (cityThesis) return cityThesis;
|
||||
|
||||
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
||||
if (locale === "en-US") {
|
||||
return `${strengthLabel || "Watch"}: AI should validate against ${modelBasis}.`;
|
||||
}
|
||||
return `${strengthLabel || "观察"}:AI 需结合${modelBasis}确认。`;
|
||||
}
|
||||
|
||||
export function getRiskHints(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
modelProbability?: number | null,
|
||||
) {
|
||||
const hints: string[] = [];
|
||||
const spread = Number(row.spread);
|
||||
if (Number.isFinite(spread) && spread > 0.03) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? `Wide spread ${formatQuoteCents(spread)} may distort the displayed market price.`
|
||||
: `盘口价差 ${formatQuoteCents(spread)} 偏宽,可能扭曲市场价格参考。`,
|
||||
);
|
||||
}
|
||||
const quoteAgeSeconds =
|
||||
row.quote_age_ms != null && Number.isFinite(Number(row.quote_age_ms))
|
||||
? Math.round(Number(row.quote_age_ms) / 1000)
|
||||
: null;
|
||||
if (quoteAgeSeconds != null && quoteAgeSeconds > 60) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? `Quote age ${quoteAgeSeconds}s; refresh before acting.`
|
||||
: `报价已 ${quoteAgeSeconds}s,执行前需要刷新。`,
|
||||
);
|
||||
}
|
||||
if (row.trend_alignment === false) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Intraday trend does not fully support this direction."
|
||||
: "日内趋势未完全支持该方向。",
|
||||
);
|
||||
}
|
||||
if (row.cluster_adjusted) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Tail bucket was cluster-adjusted; bucket confidence may be overstated."
|
||||
: "尾部桶已做模型集群折扣,温度桶信心可能偏乐观。",
|
||||
);
|
||||
}
|
||||
if (modelProbability != null && modelProbability < 10) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Low model probability makes the setup sensitive to calibration error."
|
||||
: "模型概率偏低,校准误差会显著影响判断。",
|
||||
);
|
||||
}
|
||||
if (!hints.length) {
|
||||
hints.push(
|
||||
locale === "en-US"
|
||||
? "Main residual risk is late observation updates or a shifted peak window."
|
||||
: "主要残余风险是后续实测升温或峰值窗口漂移。",
|
||||
);
|
||||
}
|
||||
return hints;
|
||||
}
|
||||
|
||||
export function getRecommendationReasons(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
_edgePercent?: number | null,
|
||||
price?: number | null,
|
||||
) {
|
||||
const reasons: string[] = [];
|
||||
const aiReason = getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en);
|
||||
if (aiReason && String(row.ai_decision || "").toLowerCase() === "approve") {
|
||||
reasons.push(aiReason);
|
||||
}
|
||||
const modelBasis = getModelSourceSummary(row, locale, row.target_unit || row.temp_symbol);
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? `AI uses ${modelBasis} with market ask ${formatQuoteCents(price)} only as downstream bucket context.`
|
||||
: `AI 以${modelBasis}为主,市场买价 ${formatQuoteCents(price)} 只作下游温度桶参考。`,
|
||||
);
|
||||
if (row.peak_alignment_score != null) {
|
||||
reasons.push(
|
||||
locale === "en-US"
|
||||
? `Peak alignment score ${Number(row.peak_alignment_score).toFixed(2)} supports checking this bucket.`
|
||||
: `峰值对齐分 ${Number(row.peak_alignment_score).toFixed(2)},支持把该桶纳入检查。`,
|
||||
);
|
||||
}
|
||||
return reasons.slice(0, 3);
|
||||
}
|
||||
|
||||
export function getExclusionReasons(
|
||||
row: ScanOpportunityRow,
|
||||
locale: string,
|
||||
edgePercent?: number | null,
|
||||
) {
|
||||
const decision = String(row.ai_decision || "").toLowerCase();
|
||||
const aiReason =
|
||||
getLocalizedRowText(row, locale, row.ai_reason_zh, row.ai_reason_en) ||
|
||||
getLocalizedRowText(
|
||||
row,
|
||||
locale,
|
||||
row.ai_watchlist_reason_zh,
|
||||
row.ai_watchlist_reason_en,
|
||||
);
|
||||
if (decision === "veto" || decision === "downgrade" || decision === "watchlist") {
|
||||
return [
|
||||
aiReason ||
|
||||
(locale === "en-US"
|
||||
? "AI did not classify this row as the primary forecast bucket."
|
||||
: "AI 未把该合约列为主预测桶。"),
|
||||
];
|
||||
}
|
||||
if (edgePercent != null && Number(edgePercent) < 10) {
|
||||
return [
|
||||
locale === "en-US"
|
||||
? "This bucket is not the current forecast center."
|
||||
: "该桶不是当前预测中枢。",
|
||||
];
|
||||
}
|
||||
return [
|
||||
locale === "en-US"
|
||||
? "No hard veto in the current AI/rule snapshot."
|
||||
: "当前 AI/规则快照没有硬性排除项。",
|
||||
];
|
||||
}
|
||||
|
||||
export function getAiMeta(row: ScanOpportunityRow, locale: string) {
|
||||
const decision = String(row.ai_decision || "").toLowerCase();
|
||||
if (decision === "veto") {
|
||||
return {
|
||||
label: locale === "en-US" ? "AI veto" : "AI 排除",
|
||||
tone: "veto",
|
||||
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
||||
};
|
||||
}
|
||||
if (decision === "downgrade") {
|
||||
return {
|
||||
label: locale === "en-US" ? "AI downgrade" : "AI 降级",
|
||||
tone: "downgrade",
|
||||
reason: locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en,
|
||||
};
|
||||
}
|
||||
if (row.ai_rank != null || decision === "approve") {
|
||||
return {
|
||||
label: locale === "en-US" ? `AI pick ${row.ai_rank || ""}`.trim() : `AI 推荐 ${row.ai_rank || ""}`.trim(),
|
||||
tone: "approve",
|
||||
reason:
|
||||
(locale === "en-US" ? row.ai_reason_en || row.ai_reason_zh : row.ai_reason_zh || row.ai_reason_en) ||
|
||||
row.ai_model_cluster_note ||
|
||||
null,
|
||||
};
|
||||
}
|
||||
if (decision === "watchlist") {
|
||||
return {
|
||||
label: locale === "en-US" ? "AI watch" : "AI 观察",
|
||||
tone: "downgrade",
|
||||
reason:
|
||||
locale === "en-US"
|
||||
? row.ai_watchlist_reason_en || row.ai_watchlist_reason_zh
|
||||
: row.ai_watchlist_reason_zh || row.ai_watchlist_reason_en,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user