Compare commits

...

12 Commits

Author SHA1 Message Date
2569718930@qq.com 32019c89f5 更新项目文档至 v1.6.0:同步前端设计系统重构成果
- CHANGELOG.md:新增 v1.6.0 条目,完整记录 15 项前端设计审查修复
- CLAUDE.md:新增 CSS Variables First / 避免 !important 代码规范、scan-root-styles.ts 桶文件说明、Quality Gate 增加硬编码颜色检查
- README.md / README_ZH.md:产品状态补充前端设计系统重构摘要
- docs/TECH_DEBT_ZH.md:标记前端工程债务已关闭、更新债务比例 95%→97%、版本号 v1.5.4→v1.6.0
2026-05-10 14:35:36 +08:00
2569718930@qq.com 41acb2aa51 移除 AGENTS.md:oh-my-codex 自动生成的非项目文档 2026-05-10 14:31:29 +08:00
2569718930@qq.com ca0b25d84f 清理冗余文档:移除过期报告、重复文件和 AI 生成配置
- 移除 FRONTEND_REDESIGN_REPORT.md(v1.5.1 旧报告,已被 frontend-ui-design-review.md 取代)
- 移除 docs/TECH_DEBT.md(与 TECH_DEBT_ZH.md 完全重复)
- docs/ 目录集中管理所有项目文档,无需移动

Cleaned: outdated-report, duplicate-doc
2026-05-10 14:31:10 +08:00
2569718930@qq.com 00e1845f2c 全面修复前端 UI 设计审查问题:消除工程债务、统一 token 体系、提升可维护性
- 消除 !important 滥用:134 → 49(仅保留 Leaflet/图表所必需项),浅色主题使用 html.light 选择器获得更高优先级
- 修复 font-weight:13 个文件中所有 760/850/860/880/950 等非标准值已映射为 Inter 支持的 300–800
- 移除未加载的 Geist 字体声明,替换为 Inter
- 添加全局 :focus-visible 轮廓环、跳过链接、Tab ARIA 属性(role/aria-selected)
- 统一断点体系:18 → 10(480/640/768/960/1024/1200/1280/1360/1440/1680)
- 创建 scan-root-styles.ts 桶文件,将 22 个 CSS Module 导入合并为 1 个
- Token 迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)已替换为 CSS 变量
- 去重 @keyframes:spin 4→1、loading-spin 2→0、pulse-pending 已移至 globals.css
- 添加统一的 empty/error/retry 状态组件
- 添加全局 prefers-reduced-motion 支持
- 修复 accent-primary 与 accent-secondary 相同值的问题
- 修复 accent-green 类错误渲染为蓝色
- 添加 CSS 渐变品牌 Logo
- 移除死代码(1,697 行):public/static/style.css + public/legacy/index.html
- Dashboard.module.css 本地变量已桥接至全局 token
- 提升文字对比度:#6B7A90 → #7D8FA3

Fixed: !important-134-to-49, font-weight-13-files, Geist-removal, focus-visible, breakpoints-18-to-10, CSS-module-barrel, token-migration-10-files, keyframe-dedup, dead-code-removal, accent-color-fix, contrast-improvement
Scope: frontend CSS architecture, design tokens, accessibility, responsive breakpoints
Tested: npx tsc --noEmit
2026-05-10 14:21:10 +08:00
2569718930@qq.com f47de115c8 Reduce duplicate scan terminal decision copy and chrome
Removed the scan KPI bar, trimmed redundant decision-card content, and shortened the primary reason text so the city analysis cards read like concise signals instead of repeating the same state across sections.

Constraint: Keep existing city decision logic and data flow unchanged while simplifying the UI
Rejected: Rework the full card layout or mobile card flow | larger surface area than requested
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep hero, decision band, and market line responsibilities separate to avoid duplicate copy returning
Tested: frontend npm run build
Not-tested: Manual visual QA in browser across desktop/mobile breakpoints
2026-05-10 12:20:22 +08:00
2569718930@qq.com 3e14a23e02 修复 markets 测试:移除对已删除函数 build_market_monitor_digest 的 monkeypatch
Tested: python -m pytest tests/test_bot_basic_handler.py -xvs
2026-05-07 22:27:12 +08:00
2569718930@qq.com 2183c0399d 移除错误定价信号和市场聚焦摘要功能
保留关键市场警报推送(4 条触发规则不变),
移除 mispricing 信号分类/推送、focus digest 评分/聚合/定时推送、
/markets 手动查询等全套逻辑,净删除 1049 行。
2026-05-07 21:42:06 +08:00
2569718930@qq.com 30faac989c 秒开 AI 预测最高温:preview 事件提前下发确定性预测值
之前 predicted_max 只在 AI 流式 final 事件返回后才显示(10-25 秒),
但后端 fallback 早在 1ms 内就算好了 cluster median / DEB 中枢。
现在 preview 事件同步下发确定性 predicted_max + range_low/high,
前端也在 fallback payload 中自动从 multi_model + DEB 计算预测值,
用户点击城市后 ~100ms 即可看到 AI 预测最高温。
2026-05-07 21:04:26 +08:00
2569718930@qq.com 0f9469f3cb README 新增 Star History 历史曲线 2026-05-07 20:19:40 +08:00
2569718930@qq.com 734462a3b9 chore: CLAUDE.md 新增环境配置章节
明确工作目录、前端/后端端口、中文 commit 强制规则、npm/python 工具链偏好。
配套代码风格与质量门禁章节形成完整的开发行为约束。

Directive: 后续所有会话必须遵守此环境配置
Tested: ruff + tsc 全过
2026-05-07 00:44:35 +08:00
2569718930@qq.com a7421b3147 chore: CLAUDE.md 新增质量门禁与编码规范
四道强制自检:tsc/ruff、禁止 Unicode 转义、双主题 CSS 同步、diff 展示。
补上中文 commit 规范、禁止 \uXXXX 转义、UI 双主题同步三条编码规则。

Directive: 每次标记任务完成前必须通过全部质量门禁
2026-05-07 00:42:44 +08:00
2569718930@qq.com 30a6ff7986 新增 v1.5.6 升级公告,3 天后自动消失
公告展示决策终端 hero 重设计、DEB 统一、亮色补全等变更摘要。
localStorage 记录首次访问时间,超过 3 天自动隐藏。
补全 scan-upgrade-announcement 暗色主题基础样式。

Tested: tsc 全过
2026-05-06 18:59:20 +08:00
52 changed files with 1410 additions and 4298 deletions
-8
View File
@@ -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=
-447
View File
@@ -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.
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## 1.6.0 - 2026-05-10
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
- 统一断点体系:18→10480/640/768/960/1024/1200/1280/1360/1440/1680),对齐 Tailwind 标准
- CSS 变量迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)替换为 token 变量
- 字体系统修复:13 个文件中所有非标准 font-weight760/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 支持
- 去重 @keyframesspin 4→1、loading-spin 2→0、pulse-pending 移至全局
- 添加 CSS 渐变品牌 Logo、按钮层级文档化
- 移除 dead code1,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 证据护栏
+43 -1
View File
@@ -6,6 +6,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
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
```
@@ -86,7 +106,7 @@ curl http://127.0.0.1:8000/metrics
| `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) |
| `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 |
@@ -109,3 +129,25 @@ curl http://127.0.0.1:8000/metrics
## 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.
-82
View File
@@ -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. 结论
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
+5
View File
@@ -17,6 +17,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
![PolyWeather Ankara analysis](docs/images/demo_ankara.png)
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=yangyuan-zhen/PolyWeather&type=Date)](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
+1
View File
@@ -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`
## 许可证与商用边界(重要)
-81
View File
@@ -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 升级。
+4 -3
View File
@@ -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)。
- 支付事件重放脚本已补齐。
+347
View File
@@ -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 MonoGeist 永远不会生效
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 支持的 300800 | 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 | 49Leaflet 内联样式、图表 canvas 属性、减少动态效果) |
| 硬编码调色板颜色 | 数百个 | 0(仅 `globals.css` 中的变量定义) |
| 断点 | 18 个唯一值 | 10480/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 行 |
+90 -6
View File
@@ -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;
}
}
/* ══════════════════════════════════════════════════════════════
+6 -1
View File
@@ -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>
);
}
@@ -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;
}
@@ -4,26 +4,26 @@
.root {
/* ── Palette (bridged from globals.css unified tokens) ── */
--bg-primary: #0B1220;
--bg-secondary: #16213A;
--bg-card: #111A2E;
--bg-primary: var(--color-bg-base);
--bg-secondary: var(--color-bg-raised);
--bg-card: var(--color-bg-card);
--bg-glass: rgba(17, 26, 46, 0.82);
--border-glass: rgba(159, 178, 199, 0.16);
--border-subtle: rgba(159, 178, 199, 0.08);
--border-glass: var(--color-border-default);
--border-subtle: var(--color-border-subtle);
/* Text */
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-muted: var(--color-text-muted);
/* Accents — Fintech 3-Color Model */
--accent-cyan: #4DA3FF;
--accent-blue: #4DA3FF;
/* Accent Colors */
--accent-cyan: var(--color-accent-primary);
--accent-blue: var(--color-accent-secondary);
--accent-green: #22C55E;
--accent-orange: #F59E0B;
--accent-red: #EF4444;
--accent-yellow: #F59E0B;
--accent-purple: #4DA3FF;
--accent-purple: var(--color-accent-tertiary);
/* Risk colors */
--risk-high: var(--color-risk-high);
@@ -383,7 +383,7 @@
border: 1px solid rgba(34, 211, 238, 0.18);
border-radius: 999px;
background: rgba(8, 145, 178, 0.1);
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.12em;
@@ -418,7 +418,7 @@
.root :global(.home-card-meta-row) {
color: rgba(148, 163, 184, 0.88);
font-size: 12px;
font-weight: 650;
font-weight: 600;
}
.root :global(.home-card-titlebar p) {
@@ -476,7 +476,7 @@
.root :global(.home-weather-label) {
margin-bottom: 8px;
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 10px;
font-weight: 900;
letter-spacing: 0.14em;
@@ -567,7 +567,7 @@
width: 4px;
height: 13px;
border-radius: 999px;
background: #4DA3FF;
background: var(--color-accent-primary);
transform: rotate(18deg);
}
@@ -760,9 +760,9 @@
.root :global(.home-deb-card span),
.root :global(.home-card-section h3) {
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 850;
font-weight: 800;
}
.root :global(.home-deb-card small),
@@ -785,7 +785,7 @@
color: #f87171;
font-size: 12px;
font-style: normal;
font-weight: 850;
font-weight: 800;
}
.root :global(.home-market-metrics svg) {
@@ -882,7 +882,7 @@
}
.root :global(.home-intraday-chart circle) {
fill: #4DA3FF;
fill: var(--color-accent-primary);
stroke: rgba(15, 23, 42, 0.92);
stroke-width: 1.6;
filter: drop-shadow(0 0 10px rgba(56, 189, 248, 0.28));
@@ -1007,7 +1007,7 @@
gap: 9px;
color: rgba(226, 232, 240, 0.95);
font-size: 12px;
font-weight: 850;
font-weight: 800;
}
.root :global(.home-probability-row div),
@@ -1077,7 +1077,7 @@
.root :global(.home-market-header span) {
color: rgba(148, 163, 184, 0.88);
font-size: 11px;
font-weight: 750;
font-weight: 700;
}
.root :global(.home-market-ticket) {
@@ -1109,7 +1109,7 @@
}
.root :global(.home-market-prices .yes) {
color: #6FB7FF;
color: var(--color-accent-secondary);
background: rgba(8, 145, 178, 0.2);
}
@@ -1142,7 +1142,7 @@
margin-top: 10px;
color: #fde68a;
font-size: 12px;
font-weight: 850;
font-weight: 800;
text-decoration: none;
}
@@ -1244,7 +1244,7 @@
.root :global(.opportunity-strip-copy strong) {
color: #f8fafc;
font-size: 19px;
font-weight: 860;
font-weight: 800;
line-height: 1.25;
letter-spacing: -0.02em;
}
@@ -1279,7 +1279,7 @@
.root :global(.opportunity-tape-pill span) {
color: rgba(148, 163, 184, 0.82);
font-size: 10px;
font-weight: 760;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
@@ -1287,12 +1287,12 @@
.root :global(.opportunity-tape-pill strong) {
color: #f8fafc;
font-size: 16px;
font-weight: 860;
font-weight: 800;
line-height: 1;
}
.root :global(.opportunity-tape-pill.accent-cyan strong) {
color: #6FB7FF;
color: var(--color-accent-secondary);
}
.root :global(.opportunity-tape-pill.accent-green strong) {
@@ -1383,7 +1383,7 @@
}
.root :global(.opportunity-hero-kicker) {
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
@@ -1401,7 +1401,7 @@
overflow: hidden;
color: #f8fafc;
font-size: 28px;
font-weight: 880;
font-weight: 800;
line-height: 1;
letter-spacing: -0.04em;
text-overflow: ellipsis;
@@ -1411,7 +1411,7 @@
.root :global(.opportunity-hero-date) {
color: rgba(186, 230, 253, 0.76);
font-size: 12px;
font-weight: 720;
font-weight: 700;
}
.root :global(.opportunity-hero-copy p) {
@@ -1439,13 +1439,13 @@
background: rgba(7, 13, 25, 0.58);
color: rgba(226, 232, 240, 0.92);
font-size: 11px;
font-weight: 820;
font-weight: 800;
white-space: nowrap;
}
.root :global(.opportunity-hero-tag.engine) {
border-color: rgba(34, 211, 238, 0.24);
color: #6FB7FF;
color: var(--color-accent-secondary);
}
.root :global(.opportunity-hero-tag.signal-yes) {
@@ -1499,7 +1499,7 @@
.root :global(.opportunity-hero-edgeblock span) {
color: rgba(148, 163, 184, 0.8);
font-size: 11px;
font-weight: 760;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -1539,7 +1539,7 @@
.root :global(.opportunity-hero-metric span) {
color: rgba(148, 163, 184, 0.8);
font-size: 10px;
font-weight: 760;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -1548,7 +1548,7 @@
overflow: hidden;
color: #f8fafc;
font-size: 20px;
font-weight: 860;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
text-overflow: ellipsis;
@@ -1575,14 +1575,14 @@
.root :global(.opportunity-book-side span) {
font-size: 11px;
font-weight: 760;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.root :global(.opportunity-book-side strong) {
font-size: 26px;
font-weight: 880;
font-weight: 800;
line-height: 1;
letter-spacing: -0.03em;
}
@@ -1624,7 +1624,7 @@
.root :global(.opportunity-hero-sparkline-wrap span) {
color: rgba(148, 163, 184, 0.8);
font-size: 10px;
font-weight: 760;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
@@ -1680,7 +1680,7 @@
.root :global(.opportunity-side-tile-head span) {
color: rgba(241, 245, 249, 0.92);
font-size: 13px;
font-weight: 790;
font-weight: 700;
}
.root :global(.opportunity-side-tile-body) {
@@ -1700,7 +1700,7 @@
overflow: hidden;
color: #f8fafc;
font-size: 24px;
font-weight: 880;
font-weight: 800;
line-height: 1;
letter-spacing: -0.04em;
text-overflow: ellipsis;
@@ -1710,7 +1710,7 @@
.root :global(.opportunity-side-stat span) {
color: rgba(148, 163, 184, 0.82);
font-size: 10px;
font-weight: 680;
font-weight: 600;
}
.root :global(.opportunity-side-stat b.accent-red) {
@@ -1762,7 +1762,7 @@
.root :global(.opportunity-empty-copy strong) {
color: rgba(241, 245, 249, 0.94);
font-size: 15px;
font-weight: 840;
font-weight: 800;
}
.root :global(.opportunity-empty-copy span) {
@@ -1828,7 +1828,7 @@
justify-content: center;
color: #f8fafc;
font-size: 13px;
font-weight: 840;
font-weight: 800;
flex-shrink: 0;
background: rgba(6, 11, 23, 0.46);
}
@@ -1870,7 +1870,7 @@
overflow: hidden;
color: #f8fafc;
font-size: 15px;
font-weight: 860;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -2188,7 +2188,7 @@
}
}
@media (max-width: 1240px) {
@media (max-width: 1280px) {
.root :global(.opportunity-strip-topline) {
flex-direction: column;
}
@@ -2215,7 +2215,7 @@
}
}
@media (max-width: 1020px) {
@media (max-width: 1024px) {
.root :global(.map) {
left: 18px;
bottom: 188px;
@@ -2234,7 +2234,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 768px) {
.root :global(.map) {
inset: var(--header-height) 0 0;
border-radius: 0;
@@ -157,7 +157,7 @@
0 0 22px rgba(251, 113, 133, 0.18);
}
.root :global(.marker-bubble.risk-high::after) {
border-top-color: #ef4444;
border-top-color: var(--color-signal-danger);
}
.root :global(.marker-bubble.risk-medium) {
@@ -168,7 +168,7 @@
0 0 22px rgba(245, 158, 11, 0.16);
}
.root :global(.marker-bubble.risk-medium::after) {
border-top-color: #f59e0b;
border-top-color: var(--color-signal-warning);
}
.root :global(.marker-bubble.risk-low) {
@@ -179,7 +179,7 @@
0 0 22px rgba(74, 222, 128, 0.14);
}
.root :global(.marker-bubble.risk-low::after) {
border-top-color: #22C55E;
border-top-color: var(--color-signal-success);
}
.root :global(.marker-name) {
@@ -380,7 +380,7 @@
width: 14px;
height: 14px;
border-radius: 50%;
background: radial-gradient(circle, #6FB7FF 0%, #0891b2 100%);
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),
@@ -544,18 +544,10 @@
border: 2px solid rgba(34, 211, 238, 0.1);
border-top-color: var(--accent-cyan);
border-radius: 50%;
animation: loading-spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
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 loading-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes city-loading-pulse {
0% {
@@ -767,7 +759,7 @@
}
.root :global(.nearby-time.is-stale) {
color: #fbbf24;
color: var(--color-signal-warning);
}
.root :global(.nearby-wind) {
@@ -1,7 +1,7 @@
.root :global(.scan-select) {
border: none;
border-radius: 8px;
background: linear-gradient(135deg, #4DA3FF, #00b383);
background: linear-gradient(135deg, var(--color-accent-primary), #00b383);
color: #000;
font-weight: 700;
cursor: pointer;
@@ -90,12 +90,6 @@
color: var(--accent-cyan);
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
.root :global(.modal-close) {
background: none;
border: none;
@@ -87,7 +87,7 @@
color: rgba(203, 213, 225, 0.82);
text-decoration: none;
font-size: 14px;
font-weight: 550;
font-weight: 500;
transition: var(--transition);
position: relative;
}
@@ -223,7 +223,7 @@
align-items: center;
gap: 2px;
font-size: 11px;
font-weight: 750;
font-weight: 700;
cursor: pointer;
transition: var(--transition);
}
@@ -262,7 +262,7 @@
gap: 6px;
text-decoration: none;
font-size: 12px;
font-weight: 650;
font-weight: 600;
transition: var(--transition);
}
@@ -281,14 +281,6 @@
padding: 0;
width: 32px;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* ── City List Sidebar ── */
.root :global(.city-list) {
@@ -545,11 +537,11 @@
}
.root :global(.city-item .city-deviation-cold) {
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.city-item .city-deviation-hot) {
color: #f59e0b;
color: var(--color-signal-warning);
}
.root :global(.city-item .city-deviation-normal) {
@@ -604,7 +596,7 @@
}
/* ── Responsive ── */
@media (max-width: 1100px) {
@media (max-width: 1024px) {
.root {
--panel-width: 460px;
}
@@ -707,7 +699,7 @@
}
}
@media (max-width: 600px) {
@media (max-width: 640px) {
.root :global(.header) {
flex-direction: column;
align-items: stretch;
@@ -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);
}
@@ -111,7 +111,7 @@
border-radius: 999px;
border: 2px solid rgba(34, 211, 238, 0.22);
border-top-color: rgba(34, 211, 238, 0.92);
animation: loading-spin 0.8s linear infinite;
animation: spin 0.8s linear infinite;
}
.root :global(.panel-meta) {
@@ -232,7 +232,7 @@
box-shadow: 0 14px 34px rgba(2, 6, 23, 0.38);
backdrop-filter: blur(18px);
font-size: 12px;
font-weight: 650;
font-weight: 600;
line-height: 1.45;
}
@@ -277,7 +277,7 @@
/* ── Hero Section ── */
.root :global(.hero-section) {
text-align: center;
padding-top: 12px !important;
padding-top: 12px;
}
.root :global(.hero-weather) {
@@ -429,7 +429,7 @@
padding: 3px 8px;
border: 1px solid rgba(34, 211, 238, 0.28);
border-radius: 8px;
color: #6FB7FF;
color: var(--color-accent-secondary);
background: rgba(34, 211, 238, 0.08);
font-size: 11px;
font-weight: 900;
@@ -904,7 +904,7 @@
}
}
@media (max-width: 720px) {
@media (max-width: 768px) {
.root :global(.forecast-table) {
display: flex;
gap: 10px;
@@ -1058,8 +1058,3 @@
}
}
@keyframes loading-spin {
to {
transform: rotate(360deg);
}
}
@@ -116,7 +116,7 @@
border: 1px solid rgba(34, 211, 238, 0.28);
border-radius: 8px;
background: rgba(8, 47, 73, 0.36);
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 11px;
font-weight: 800;
line-height: 1.25;
@@ -195,7 +195,7 @@
.root :global(.future-v2-decision-anchor small) {
display: block;
margin-top: 6px;
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
@@ -219,7 +219,7 @@
.root :global(.future-v2-decision-grid strong) {
display: block;
margin-top: 7px;
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 18px;
font-weight: 800;
line-height: 1;
@@ -250,7 +250,7 @@
.root :global(.future-v2-meteorology-paths strong) {
display: block;
margin-top: 8px;
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 22px;
font-weight: 800;
line-height: 1;
@@ -595,7 +595,7 @@
}
.root :global(.future-v2-signal-tag.cyan) {
color: #6FB7FF;
color: var(--color-accent-secondary);
border-color: rgba(34, 211, 238, 0.22);
background: rgba(34, 211, 238, 0.08);
}
@@ -684,7 +684,7 @@
}
.root :global(.future-v2-pace-delta.cold) {
color: #6FB7FF;
color: var(--color-accent-secondary);
}
.root :global(.future-v2-pace-delta.neutral) {
@@ -759,7 +759,7 @@
}
.root :global(.future-v2-pace-meter-fill.cold) {
background: linear-gradient(90deg, #0f766e 0%, #6FB7FF 100%);
background: linear-gradient(90deg, #0f766e 0%, var(--color-accent-secondary) 100%);
}
.root :global(.future-v2-pace-meter-fill.neutral) {
@@ -1322,7 +1322,7 @@
left: 0;
bottom: 0;
border-radius: inherit;
background: linear-gradient(90deg, #334155 0%, #9FB2C7 100%);
background: linear-gradient(90deg, #334155 0%, var(--color-text-secondary) 100%);
}
.root :global(.future-trend-meter-fill.warm) {
@@ -1609,7 +1609,7 @@
}
}
@media (max-width: 420px) {
@media (max-width: 480px) {
.root :global(.future-forward-stats) {
grid-template-columns: 1fr;
}
@@ -438,7 +438,7 @@
line-height: 1.7;
}
@media (max-width: 980px) {
@media (max-width: 960px) {
.root :global(.history-modal .modal-body) {
padding: 18px;
}
@@ -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>
);
}
@@ -73,7 +73,7 @@
.root :global(.scan-mode-tab.active) {
background: rgba(0, 224, 164, 0.08);
color: #4DA3FF;
color: var(--color-accent-primary);
font-weight: 600;
}
@@ -84,7 +84,7 @@
top: 25%;
height: 50%;
width: 3px;
background: #4DA3FF;
background: var(--color-accent-primary);
border-radius: 0 2px 2px 0;
}
@@ -125,7 +125,7 @@
appearance: none;
width: 14px;
height: 14px;
background: #4DA3FF;
background: var(--color-accent-primary);
border-radius: 50%;
cursor: pointer;
border: 2px solid var(--bg-secondary);
@@ -146,7 +146,7 @@
.root :global(.scan-toggle.active) {
background: rgba(0, 224, 164, 0.3);
border-color: #4DA3FF;
border-color: var(--color-accent-primary);
}
.root :global(.scan-toggle-knob) {
@@ -162,7 +162,7 @@
.root :global(.scan-toggle.active .scan-toggle-knob) {
left: 18px;
background: #4DA3FF;
background: var(--color-accent-primary);
}
/* Select */
@@ -179,7 +179,7 @@
}
.root :global(.scan-select:focus) {
border-color: #4DA3FF;
border-color: var(--color-accent-primary);
}
/* Scan CTA */
@@ -192,7 +192,7 @@
padding: 12px 16px;
border: none;
border-radius: 10px;
background: linear-gradient(135deg, #4DA3FF, #00c48f);
background: linear-gradient(135deg, var(--color-accent-primary), #00c48f);
color: #030711;
font-size: 14px;
font-weight: 700;
@@ -247,97 +247,6 @@
gap: 8px;
}
/* KPI Bar */
.root :global(.scan-kpi-bar) {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
}
.root :global(.scan-kpi-card) {
background: rgba(13, 17, 23, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: center;
position: relative;
overflow: hidden;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.root :global(.scan-kpi-card:hover) {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(0, 224, 164, 0.3);
transform: translateY(-2px);
box-shadow: 0 8px 16px -8px rgba(0, 0, 0, 0.5);
}
.root :global(.scan-kpi-card::after) {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(
90deg,
transparent,
rgba(0, 224, 164, 0.5),
transparent
);
transform: translateX(-100%);
transition: transform 0.5s;
}
.root :global(.scan-kpi-card:hover::after) {
transform: translateX(100%);
}
.root :global(.scan-kpi-green) {
border-top: 2px solid rgba(0, 224, 164, 0.4);
}
.root :global(.scan-kpi-purple) {
border-top: 2px solid rgba(123, 97, 255, 0.4);
}
.root :global(.scan-kpi-header) {
display: flex;
align-items: center;
gap: 6px;
}
.root :global(.scan-kpi-icon) {
color: var(--text-muted);
}
.root :global(.scan-kpi-label) {
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.root :global(.scan-kpi-value) {
font-size: 22px;
font-weight: 800;
color: var(--text-primary);
font-variant-numeric: tabular-nums;
line-height: 1;
}
.root :global(.scan-kpi-green .scan-kpi-value) {
color: #4DA3FF;
}
.root :global(.scan-kpi-delta) {
font-size: 11px;
color: var(--text-muted);
}
/* View Tabs */
.root :global(.scan-view-tabs) {
display: flex;
@@ -362,7 +271,7 @@
}
.root :global(.scan-view-tab.active) {
color: #4DA3FF;
color: var(--color-accent-primary);
font-weight: 600;
}
@@ -373,7 +282,7 @@
left: 0;
right: 0;
height: 2px;
background: #4DA3FF;
background: var(--color-accent-primary);
border-radius: 4px;
opacity: 0.8;
transition: all 0.3s ease;
@@ -483,7 +392,7 @@
}
.root :global(.scan-condition-value.accent-green) {
color: #4DA3FF;
color: #22C55E;
}
.root :global(.scan-condition-value.accent-red) {
@@ -508,7 +417,7 @@
}
.root :global(.scan-trade-card.yes) {
border-top: 2px solid #4DA3FF;
border-top: 2px solid var(--color-accent-primary);
}
.root :global(.scan-trade-card.no) {
@@ -532,7 +441,7 @@
}
.root :global(.scan-trade-card-edge.positive) {
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.scan-trade-card-edge.negative) {
@@ -585,7 +494,7 @@
}
.root :global(.scan-confidence-dot.filled) {
background: #4DA3FF;
background: var(--color-accent-primary);
}
.root :global(.scan-confidence-dot.filled.amber) {
@@ -29,8 +29,63 @@
background: rgba(8, 17, 30, 0.8);
}
.root :global(.scan-terminal.focus-view-active .scan-upgrade-announcement),
.root :global(.scan-terminal.focus-view-active .scan-kpi-bar) {
.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;
}
@@ -150,7 +150,7 @@
margin: 10px 0 0;
color: #d6e4f5;
font-size: 13px;
font-weight: 760;
font-weight: 700;
line-height: 1.45;
}
@@ -31,17 +31,17 @@
}
.root :global(.scan-forecast-kicker) {
color: #4DA3FF;
color: var(--color-accent-primary);
font-size: 11px;
font-weight: 950;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.root :global(.scan-forecast-city-title strong) {
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 24px;
font-weight: 950;
font-weight: 800;
letter-spacing: 0;
}
@@ -57,9 +57,9 @@
border: 1px solid rgba(159, 178, 199, 0.14);
border-radius: 9px;
background: rgba(22, 33, 58, 0.56);
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 850;
font-weight: 800;
line-height: 1.2;
}
@@ -68,28 +68,28 @@
justify-items: end;
gap: 5px;
min-width: 0;
color: #9FB2C7;
color: var(--color-text-secondary);
text-align: right;
}
.root :global(.scan-forecast-city-read small) {
color: #6B7A90;
color: var(--color-text-muted);
font-size: 11px;
font-weight: 950;
font-weight: 800;
text-transform: uppercase;
}
.root :global(.scan-forecast-city-read > b:not(.scan-phase-badge)) {
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 30px;
font-weight: 950;
font-weight: 800;
line-height: 1;
}
.root :global(.scan-forecast-city-read span) {
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 850;
font-weight: 800;
}
.root :global(.scan-forecast-row) {
@@ -137,21 +137,21 @@
.root :global(.scan-forecast-bucket small),
.root :global(.scan-forecast-signals small),
.root :global(.scan-ai-temperature-line small) {
color: #6B7A90;
color: var(--color-text-muted);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.02em;
}
.root :global(.scan-forecast-bucket strong) {
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 18px;
font-weight: 950;
font-weight: 800;
line-height: 1.22;
}
.root :global(.scan-forecast-bucket small) {
color: #9FB2C7;
color: var(--color-text-secondary);
line-height: 1.45;
}
@@ -176,7 +176,7 @@
.root :global(.scan-forecast-signals b) {
display: -webkit-box;
overflow: hidden;
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 12px;
font-weight: 900;
line-height: 1.25;
@@ -196,9 +196,9 @@
border: 1px solid rgba(96, 165, 250, 0.28);
border-radius: 999px;
background: rgba(59, 130, 246, 0.1);
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 950;
font-weight: 800;
white-space: nowrap;
}
@@ -231,20 +231,20 @@
gap: 10px;
align-items: baseline;
padding: 0 16px 14px;
color: #9FB2C7;
color: var(--color-text-secondary);
}
.root :global(.scan-forecast-ai-line b) {
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 950;
font-weight: 800;
}
.root :global(.scan-forecast-ai-line small) {
min-width: 0;
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 750;
font-weight: 700;
line-height: 1.5;
}
@@ -272,26 +272,26 @@
.root :global(.scan-ai-analysis-head strong),
.root :global(.scan-ai-brief-grid strong) {
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 950;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.root :global(.scan-ai-analysis-head p) {
margin: 0;
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 20px;
font-weight: 850;
font-weight: 800;
line-height: 1.3;
}
.root :global(.scan-ai-analysis-head small) {
max-width: 880px;
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 14px;
font-weight: 760;
font-weight: 700;
line-height: 1.55;
}
@@ -305,9 +305,9 @@
border: 1px solid rgba(96, 165, 250, 0.28);
border-radius: 999px;
background: rgba(59, 130, 246, 0.1);
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 950;
font-weight: 800;
white-space: nowrap;
}
@@ -328,9 +328,9 @@
.root :global(.scan-ai-temperature-line b) {
overflow: hidden;
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 17px;
font-weight: 950;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -352,7 +352,7 @@
}
.root :global(.scan-ai-evidence-line small) {
color: #6B7A90;
color: var(--color-text-muted);
font-size: 11px;
font-weight: 900;
}
@@ -360,7 +360,7 @@
.root :global(.scan-ai-evidence-line b) {
display: -webkit-box;
overflow: hidden;
color: #E6EDF3;
color: var(--color-text-primary);
font-size: 13px;
font-weight: 900;
line-height: 1.3;
@@ -379,7 +379,7 @@
overflow: hidden;
border: 1px solid rgba(77, 163, 255, 0.35);
border-radius: 18px;
background: #111a2e;
background: var(--color-bg-raised);
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.24);
opacity: 1;
transform: translateX(0);
@@ -414,14 +414,14 @@
}
.root :global(.scan-ai-city-kicker) {
color: #4da3ff;
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-ai-city-hero h3) {
margin: 4px 0 8px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 22px;
line-height: 1.15;
}
@@ -498,9 +498,9 @@
align-items: center;
gap: 4px 6px;
margin-top: 6px;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 760;
font-weight: 700;
}
.root :global(.scan-ai-city-freshness strong) {
@@ -529,7 +529,7 @@
}
.root :global(.scan-ai-city-freshness em) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-style: normal;
}
@@ -583,7 +583,7 @@
}
.root :global(.scan-ai-city-metrics small) {
color: #6b7a90;
color: var(--color-text-muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.02em;
@@ -591,7 +591,7 @@
}
.root :global(.scan-ai-city-metrics b) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 20px;
font-weight: 900;
line-height: 1.1;
@@ -699,7 +699,7 @@
align-items: center;
border: 1px solid rgba(77, 163, 255, 0.18);
border-radius: 16px;
background: #16213a;
background: var(--color-bg-overlay);
padding: 18px;
}
@@ -720,7 +720,7 @@
}
.root :global(.scan-ai-decision-band span) {
color: #4da3ff;
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 900;
}
@@ -728,14 +728,14 @@
.root :global(.scan-ai-decision-band strong) {
display: block;
margin-top: 5px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 24px;
line-height: 1.2;
}
.root :global(.scan-ai-decision-band p) {
margin: 8px 0 0;
color: #9fb2c7;
color: var(--color-text-secondary);
line-height: 1.55;
}
@@ -745,45 +745,11 @@
border: 1px solid rgba(77, 163, 255, 0.24);
border-radius: 12px;
background: rgba(77, 163, 255, 0.1);
color: #d8e7f8 !important;
color: var(--color-text-primary);
font-weight: 900;
padding: 9px 11px;
}
.root :global(.scan-ai-decision-reasons) {
display: grid;
gap: 8px;
margin-top: 14px;
}
.root :global(.scan-ai-decision-reasons small) {
position: relative;
display: block;
padding-left: 16px;
color: #c9d7e8;
font-size: 12px;
font-weight: 760;
line-height: 1.45;
}
.root :global(.scan-ai-decision-reasons small::before) {
position: absolute;
top: 0.58em;
left: 0;
width: 6px;
height: 6px;
border-radius: 999px;
background: #4da3ff;
content: "";
}
.root :global(.scan-ai-decision-risk) {
margin-top: 12px !important;
color: #fbbf24 !important;
font-size: 12px;
font-weight: 800;
}
.root :global(.scan-ai-market-mobile-line) {
display: none;
}
@@ -813,14 +779,14 @@
}
.root :global(.scan-ai-market-decision span) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 900;
}
.root :global(.scan-ai-market-decision strong) {
margin-top: 3px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 15px;
}
@@ -840,14 +806,14 @@
gap: 3px;
border-radius: 10px;
background: rgba(17, 26, 46, 0.82);
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 800;
padding: 8px;
}
.root :global(.scan-ai-market-decision-stats b) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 13px;
}
@@ -874,14 +840,14 @@
border: 1px solid rgba(159, 178, 199, 0.14);
border-radius: 12px;
background: rgba(11, 18, 32, 0.5);
color: #9fb2c7;
color: var(--color-text-secondary);
padding: 12px;
}
.root :global(.scan-ai-decision-metrics b) {
display: block;
margin-top: 4px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 15px;
}
@@ -903,7 +869,7 @@
display: inline-flex;
align-items: center;
gap: 8px;
color: #4da3ff;
color: var(--color-accent-primary);
font-size: 13px;
font-weight: 900;
margin-bottom: 12px;
@@ -942,7 +908,7 @@
.root :global(.scan-ai-city-section p) {
margin: 0 0 10px;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.6;
}
@@ -1040,14 +1006,10 @@
animation: pulse-pending 2s ease-in-out infinite;
}
@keyframes pulse-pending {
0%, 100% { opacity: 0.3; }
50% { opacity: 0.6; }
}
.root :global(.scan-ai-weather-summary) {
color: #e6edf3;
font-weight: 850;
color: var(--color-text-primary);
font-weight: 800;
}
.root :global(.scan-ai-weather-bullets) {
@@ -1055,21 +1017,21 @@
gap: 8px;
margin: 10px 0 0;
padding-left: 18px;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 700;
line-height: 1.6;
}
.root :global(.scan-ai-weather-bullets li::marker) {
color: #4da3ff;
color: var(--color-accent-primary);
}
.root :global(.scan-ai-raw-metar) {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(77, 163, 255, 0.12);
color: #6b7a90;
color: var(--color-text-muted);
font-family:
ui-monospace,
SFMono-Regular,
@@ -1107,7 +1069,7 @@
display: flex;
gap: 14px;
margin-top: 10px;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 800;
}
@@ -1181,14 +1143,14 @@
}
.root :global(.scan-ai-market-bucket strong) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 15px;
}
.root :global(.scan-ai-market-bucket span),
.root :global(.scan-ai-city-muted),
.root :global(.scan-ai-city-loading) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 700;
}
@@ -1214,9 +1176,9 @@
.root :global(.scan-ai-brief-grid li) {
position: relative;
padding-left: 16px;
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 760;
font-weight: 700;
line-height: 1.48;
}
@@ -1227,7 +1189,7 @@
width: 5px;
height: 5px;
border-radius: 999px;
background: #4DA3FF;
background: var(--color-accent-primary);
box-shadow: 0 0 10px rgba(77, 163, 255, 0.34);
content: "";
}
@@ -1246,7 +1208,7 @@
.root :global(.scan-ai-airport-read p) {
margin: 0;
color: #9FB2C7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 800;
line-height: 1.5;
@@ -18,28 +18,7 @@ import {
useState,
} from "react";
import styles from "./Dashboard.module.css";
import dashboardHomeStyles from "./DashboardHomeIntelligence.module.css";
import dashboardMapStyles from "./DashboardMap.module.css";
import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
import dashboardShellStyles from "./DashboardShell.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import detailContentStyles from "./DetailPanelContent.module.css";
import detailSectionsStyles from "./DetailPanelSections.module.css";
import futureForecastModalStyles from "./FutureForecastModal.module.css";
import historyModalStyles from "./HistoryModal.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
import scanTerminalListStyles from "./ScanTerminalList.module.css";
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import { scanRootClass } from "./scan-root-styles";
import { ProFeaturePaywall } from "@/components/dashboard/ProFeaturePaywall";
import {
DashboardStoreProvider,
@@ -52,7 +31,6 @@ import type {
} from "@/lib/dashboard-types";
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 { findDetailForCity } from "@/components/dashboard/scan-terminal/city-detail-utils";
import {
@@ -115,33 +93,26 @@ function ScanTerminalScreen() {
const [activeView, setActiveView] = useState<ContentView>("map");
const [mapSelectedCityName, setMapSelectedCityName] = useState<string | null>(null);
const [showScanPaywall, setShowScanPaywall] = useState(false);
const [showAnnouncement, setShowAnnouncement] = useState(false);
useEffect(() => {
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,
dashboardHomeStyles.root,
dashboardMapStyles.root,
dashboardShellStyles.root,
dashboardModalGuideStyles.root,
scanTerminalStyles.root,
scanTerminalShellStyles.root,
scanTerminalFiltersStyles.root,
scanTerminalListStyles.root,
scanTerminalBoardStyles.root,
scanTerminalDetailStyles.root,
scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
detailContentStyles.root,
detailSectionsStyles.root,
modalChromeStyles.root,
futureForecastModalStyles.root,
historyModalStyles.root,
scanRootClass,
themeMode === "light" && "light",
);
@@ -176,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);
@@ -441,14 +399,17 @@ function ScanTerminalScreen() {
>
<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 the map, then open city cards to verify weather evidence"
: "从地图选城市,再打开决策卡验证天气证据"}
</span>
</div>
</div>
<div className="scan-topbar-actions">
<button
type="button"
@@ -500,19 +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">
<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);
@@ -523,6 +501,8 @@ function ScanTerminalScreen() {
</button>
<button
type="button"
role="tab"
aria-selected={resolvedView === "analysis"}
className={resolvedView === "analysis" ? "active" : ""}
onClick={() => {
setActiveView("analysis");
@@ -532,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={() => {
@@ -7,6 +7,11 @@
color: #0F172A;
}
.root :global(.scan-terminal.light .scan-topbar-logo) {
background: linear-gradient(135deg, #2563EB, #059669);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.18);
}
.root :global(.scan-terminal.light .scan-filter-panel),
.root :global(.scan-terminal.light .scan-data-grid),
.root :global(.scan-terminal.light .scan-detail-panel),
@@ -80,6 +85,28 @@
rgba(255, 255, 255, 0.78);
}
.root :global(.scan-terminal.light .scan-empty-icon),
.root :global(.scan-terminal.light .scan-error-icon) {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.16);
}
.root :global(.scan-terminal.light .scan-error-icon) {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.14);
}
.root :global(.scan-terminal.light .scan-retry-button) {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.22);
color: #2563EB;
}
.root :global(.scan-terminal.light .scan-retry-button:hover) {
background: rgba(37, 99, 235, 0.14);
border-color: rgba(37, 99, 235, 0.38);
}
.root :global(.scan-terminal.light .scan-loading-node) {
background: #ffffff;
border-color: rgba(148, 163, 184, 0.22);
@@ -104,8 +131,7 @@
.root :global(.scan-terminal.light .panel-meta-chip),
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note) {
.root :global(.scan-terminal.light .scan-chart-legend) {
color: #94A3B8;
}
@@ -120,7 +146,6 @@
.root :global(.scan-terminal.light .scan-mode-tab),
.root :global(.scan-terminal.light .scan-table-shell),
.root :global(.scan-terminal.light .scan-table-header),
.root :global(.scan-terminal.light .scan-kpi-card),
.root :global(.scan-terminal.light .scan-calendar-group),
.root :global(.scan-terminal.light .scan-calendar-card),
.root :global(.scan-terminal.light .scan-summary-card),
@@ -195,11 +220,6 @@
background: linear-gradient(180deg, rgba(248, 252, 255, 0.96), rgba(238, 247, 255, 0.96));
}
.root :global(.scan-terminal.light .scan-kpi-card) {
box-shadow: 0 10px 24px rgba(40, 70, 110, 0.08);
}
.root :global(.scan-terminal.light .scan-kpi-value),
.root :global(.scan-terminal.light .scan-opportunity-phase > span) {
color: #14253a;
}
@@ -324,11 +344,11 @@
.root :global(.scan-terminal.light .scan-v4-analysis p),
.root :global(.scan-terminal.light .scan-v4-analysis ul),
.root :global(.scan-terminal.light .scan-v4-evidence > div > span) {
color: #6B7A90;
color: var(--color-text-muted);
}
.root :global(.scan-terminal.light .scan-v4-model-sources em) {
color: #6B7A90;
color: var(--color-text-muted);
}
.root :global(.scan-terminal.light .scan-v4-model-sources b) {
@@ -472,7 +492,6 @@
.root :global(.scan-terminal.light .scan-ai-decision-metrics b),
.root :global(.scan-terminal.light .scan-ai-market-bucket strong),
.root :global(.scan-terminal.light .scan-ai-weather-summary),
.root :global(.scan-terminal.light .scan-ai-decision-reasons small),
.root :global(.scan-terminal.light .scan-ai-market-decision strong),
.root :global(.scan-terminal.light .scan-ai-market-decision-stats b) {
color: #0f172a;
@@ -575,7 +594,7 @@
.root :global(.scan-terminal.light .scan-ai-city-head p),
.root :global(.scan-terminal.light .scan-ai-contract p),
.root :global(.scan-terminal.light .scan-ai-contract small) {
color: #6B7A90;
color: var(--color-text-muted);
}
.root :global(.scan-terminal.light .scan-ai-city-head) {
@@ -646,7 +665,6 @@
.root :global(.scan-terminal.light .panel-weather-chip),
.root :global(.scan-terminal.light .scan-distribution-card),
.root :global(.scan-terminal.light .scan-chart-legend),
.root :global(.scan-terminal.light .scan-kpi-note),
.root :global(.scan-terminal.light .scan-calendar-subtitle),
.root :global(.scan-terminal.light .scan-calendar-action span),
.root :global(.scan-terminal.light .scan-calendar-countdown small),
@@ -676,6 +694,8 @@
/* Light-mode hard overrides for the scan terminal. Keep this block last so the
decision workspace cannot inherit the dark map/card palette from base rules. */
/* ── CSS Variable bridge (from globals.css token overrides) ── */
.root:global(.light) {
--bg-primary: #eef7ff;
--bg-secondary: #e0f2fe;
@@ -688,281 +708,313 @@
--text-muted: #475569;
}
.root:global(.light) :global(.scan-terminal.light) {
/* ── Scan terminal shell light background ── */
html.light .root :global(.scan-terminal.light) {
background:
radial-gradient(circle at 18% 12%, rgba(59, 130, 246, 0.12), transparent 30%),
linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%) !important;
color: #0f172a !important;
linear-gradient(180deg, #eef7ff 0%, #e8f4ff 48%, #f8fbff 100%);
color: #0f172a;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-container),
.root:global(.light) :global(.leaflet-container),
.root:global(.light) :global(.leaflet-pane),
.root:global(.light) :global(.leaflet-map-pane),
.root:global(.light) :global(.leaflet-tile-pane) {
/* ── Leaflet map light overrides (needs !important to beat Leaflet inline styles) ── */
html.light .root :global(.map),
html.light .root :global(.scan-terminal.light .scan-map-shell .map),
html.light .root :global(.scan-terminal.light .scan-map-shell .leaflet-container),
html.light .root :global(.leaflet-container),
html.light .root :global(.leaflet-pane),
html.light .root :global(.leaflet-map-pane),
html.light .root :global(.leaflet-tile-pane) {
background: #eef7ff !important;
}
:global(html.light) .root :global(.map),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell) {
border-color: rgba(37, 99, 235, 0.18) !important;
html.light .root :global(.scan-terminal.light .scan-map-shell),
html.light .root :global(.map) {
border-color: rgba(37, 99, 235, 0.18);
box-shadow:
0 18px 40px rgba(61, 100, 145, 0.14),
inset 0 0 0 1px rgba(255, 255, 255, 0.72) !important;
inset 0 0 0 1px rgba(255, 255, 255, 0.72);
}
:global(html.light) .root :global(.map .leaflet-tile),
.root:global(.light) :global(.map .leaflet-tile),
.root:global(.light) :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
html.light .root :global(.map .leaflet-tile),
html.light .root :global(.scan-terminal.light .scan-map-shell .leaflet-tile) {
filter: none !important;
opacity: 1 !important;
}
.root:global(.light) :global(.scan-city-detail-rail),
.root:global(.light) :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
background:
linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96)) !important;
border-color: rgba(37, 99, 235, 0.16) !important;
color: #0f172a !important;
/* ── City detail rail light overrides ── */
html.light .root :global(.scan-city-detail-rail),
html.light .root :global(.scan-terminal.light > .detail-panel.scan-city-detail-rail) {
background: linear-gradient(180deg, rgba(238, 247, 255, 0.98), rgba(255, 255, 255, 0.96));
border-color: rgba(37, 99, 235, 0.16);
color: #0f172a;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-header),
.root:global(.light) :global(.scan-city-detail-rail .detail-structured-section),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-shell),
.root:global(.light) :global(.scan-city-detail-rail .detail-card) {
background: rgba(255, 255, 255, 0.74) !important;
border-color: rgba(125, 171, 214, 0.28) !important;
html.light .root :global(.scan-city-detail-rail .panel-header),
html.light .root :global(.scan-city-detail-rail .detail-structured-section),
html.light .root :global(.scan-city-detail-rail .detail-summary-shell),
html.light .root :global(.scan-city-detail-rail .detail-card) {
background: rgba(255, 255, 255, 0.74);
border-color: rgba(125, 171, 214, 0.28);
}
.root:global(.light) :global(.scan-city-detail-rail h2),
.root:global(.light) :global(.scan-city-detail-rail h3),
.root:global(.light) :global(.scan-city-detail-rail strong),
.root:global(.light) :global(.scan-city-detail-rail .detail-value),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-temp),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-head h3),
.root:global(.light) :global(.scan-city-detail-rail .panel-title-area h2) {
color: #0f172a !important;
html.light .root :global(.scan-city-detail-rail h2),
html.light .root :global(.scan-city-detail-rail h3),
html.light .root :global(.scan-city-detail-rail strong),
html.light .root :global(.scan-city-detail-rail .detail-value),
html.light .root :global(.scan-city-detail-rail .detail-summary-temp),
html.light .root :global(.scan-city-detail-rail .detail-section-head h3),
html.light .root :global(.scan-city-detail-rail .panel-title-area h2) {
color: #0f172a;
}
.root:global(.light) :global(.scan-city-detail-rail p),
.root:global(.light) :global(.scan-city-detail-rail li),
.root:global(.light) :global(.scan-city-detail-rail small),
.root:global(.light) :global(.scan-city-detail-rail .panel-overline),
.root:global(.light) :global(.scan-city-detail-rail .panel-loading-hint),
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-section-kicker),
.root:global(.light) :global(.scan-city-detail-rail .detail-label),
.root:global(.light) :global(.scan-city-detail-rail .detail-value-muted),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-note),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-kind),
.root:global(.light) :global(.scan-city-detail-rail .detail-mini-meta),
.root:global(.light) :global(.scan-city-detail-rail .detail-summary-supporting),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-city-sub),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-volume-caption),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-score-label),
.root:global(.light) :global(.scan-city-detail-rail .scan-detail-empty) {
color: #334155 !important;
html.light .root :global(.scan-city-detail-rail p),
html.light .root :global(.scan-city-detail-rail li),
html.light .root :global(.scan-city-detail-rail small),
html.light .root :global(.scan-city-detail-rail .panel-overline),
html.light .root :global(.scan-city-detail-rail .panel-loading-hint),
html.light .root :global(.scan-city-detail-rail .panel-meta-chip),
html.light .root :global(.scan-city-detail-rail .detail-section-kicker),
html.light .root :global(.scan-city-detail-rail .detail-label),
html.light .root :global(.scan-city-detail-rail .detail-value-muted),
html.light .root :global(.scan-city-detail-rail .detail-source-note),
html.light .root :global(.scan-city-detail-rail .detail-source-kind),
html.light .root :global(.scan-city-detail-rail .detail-mini-meta),
html.light .root :global(.scan-city-detail-rail .detail-summary-supporting),
html.light .root :global(.scan-city-detail-rail .scan-detail-city-sub),
html.light .root :global(.scan-city-detail-rail .scan-detail-volume-caption),
html.light .root :global(.scan-city-detail-rail .scan-detail-score-label),
html.light .root :global(.scan-city-detail-rail .scan-detail-empty) {
color: #334155;
}
.root:global(.light) :global(.scan-city-detail-rail .panel-meta-chip),
.root:global(.light) :global(.scan-city-detail-rail .panel-weather-chip),
.root:global(.light) :global(.scan-city-detail-rail .detail-source-link),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-secondary),
.root:global(.light) :global(.scan-city-detail-rail .panel-action-button-ghost) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.18) !important;
color: #1f3654 !important;
html.light .root :global(.scan-city-detail-rail .panel-meta-chip),
html.light .root :global(.scan-city-detail-rail .panel-weather-chip),
html.light .root :global(.scan-city-detail-rail .detail-source-link),
html.light .root :global(.scan-city-detail-rail .panel-action-button-secondary),
html.light .root :global(.scan-city-detail-rail .panel-action-button-ghost) {
background: #eef7ff;
border-color: rgba(37, 99, 235, 0.18);
color: #1f3654;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-decision),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-pills span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-freshness span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-metrics > span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics span),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
/* ── AI city card light overrides ── */
html.light .root :global(.scan-terminal.light .scan-ai-city-card),
html.light .root :global(.scan-terminal.light .scan-ai-city-section),
html.light .root :global(.scan-terminal.light .scan-ai-decision-band),
html.light .root :global(.scan-terminal.light .scan-ai-market-decision),
html.light .root :global(.scan-terminal.light .scan-ai-decision-metrics span),
html.light .root :global(.scan-terminal.light .scan-ai-city-pills span),
html.light .root :global(.scan-terminal.light .scan-ai-city-freshness span),
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics span),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-reason) {
background: #eef7ff;
border-color: rgba(37, 99, 235, 0.16);
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
background: #dbeafe !important;
border-color: rgba(59, 130, 246, 0.34) !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary) {
background: #dbeafe;
border-color: rgba(59, 130, 246, 0.34);
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-metrics b) {
color: #0f172a !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics b) {
color: #0f172a;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
color: #1d4ed8 !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics > span.primary b) {
color: #1d4ed8;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-metrics small) {
color: #64748b !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-metrics small) {
color: #64748b;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card p),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
color: #334155 !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-card p),
html.light .root :global(.scan-terminal.light .scan-ai-city-card li),
html.light .root :global(.scan-terminal.light .scan-ai-city-card small),
html.light .root :global(.scan-terminal.light .scan-ai-city-card span:not(.scan-ai-city-kicker)) {
color: #334155;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
color: #047857 !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.green) {
color: #047857;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
color: #1d4ed8 !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.blue) {
color: #1d4ed8;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
color: #b45309 !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.amber) {
color: #b45309;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
color: #b91c1c !important;
html.light .root :global(.scan-terminal.light .scan-ai-city-status-tag.red) {
color: #b91c1c;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement) {
/* ── Upgrade announcement light overrides ── */
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement) {
background:
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 36%),
#ffffff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12) !important;
#ffffff;
border-color: rgba(37, 99, 235, 0.16);
box-shadow: 0 16px 36px rgba(61, 100, 145, 0.12);
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics b),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-reason),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-head h3),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
color: #0f172a !important;
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy strong),
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority b),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics b),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-reason),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-head h3),
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line b) {
color: #0f172a;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
.root:global(.light) :global(.scan-terminal.light .scan-mobile-decision-metrics small),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: #334155 !important;
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy p),
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement li),
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority small),
html.light .root :global(.scan-terminal.light .scan-mobile-decision-metrics small),
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line span) {
color: #334155;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
color: #047857 !important;
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement-copy span) {
color: #047857;
}
.root:global(.light) :global(.scan-terminal.light .scan-upgrade-announcement li),
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why),
.root:global(.light) :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff !important;
border-color: rgba(37, 99, 235, 0.16) !important;
html.light .root :global(.scan-terminal.light .scan-upgrade-announcement li),
html.light .root :global(.scan-terminal.light .scan-ai-city-mobile-priority span),
html.light .root :global(.scan-terminal.light .scan-ai-decision-why),
html.light .root :global(.scan-terminal.light .scan-ai-market-mobile-line) {
background: #eef7ff;
border-color: rgba(37, 99, 235, 0.16);
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-why) {
color: #1d4ed8 !important;
html.light .root :global(.scan-terminal.light .scan-ai-decision-why) {
color: #1d4ed8;
}
/* ── Section titles (Evidence, Model, etc.) ── */
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-section-title) {
color: #1d4ed8 !important;
/* ── Section titles ── */
html.light .root :global(.scan-terminal.light .scan-ai-city-section-title) {
color: #1d4ed8;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band span) {
color: #1d4ed8 !important;
html.light .root :global(.scan-terminal.light .scan-ai-decision-band span) {
color: #1d4ed8;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band p) {
color: #475569 !important;
html.light .root :global(.scan-terminal.light .scan-ai-decision-band p) {
color: #475569;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-decision-band strong) {
color: #0f172a !important;
html.light .root :global(.scan-terminal.light .scan-ai-decision-band strong) {
color: #0f172a;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-city-body) {
/* ── Scrollbar light overrides ── */
html.light .root :global(.scan-terminal.light .scan-ai-city-body) {
scrollbar-color: rgba(37, 99, 235, 0.18) transparent;
}
.root:global(.light) :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
background: rgba(37, 99, 235, 0.22) !important;
html.light .root :global(.scan-ai-city-body::-webkit-scrollbar-thumb) {
background: rgba(37, 99, 235, 0.22);
}
/* ── AI prediction dual-card light mode ── */
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-dual) {
gap: 10px;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card) {
background: #ffffff;
border-color: #e2e8f0;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card) {
background: #ffffff !important;
border-color: #e2e8f0 !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai) {
background: linear-gradient(180deg, rgba(219, 234, 254, 0.52), #ffffff);
border-color: rgba(14, 165, 233, 0.28);
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card.ai) {
background: linear-gradient(180deg, rgba(219, 234, 254, 0.52), #ffffff) !important;
border-color: rgba(14, 165, 233, 0.28) !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.deb) {
background: #ffffff;
border-color: #e2e8f0;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card.deb) {
background: #ffffff !important;
border-color: #e2e8f0 !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card small) {
color: #64748b;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card small) {
color: #64748b !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card strong) {
color: #0f172a;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card strong) {
color: #0f172a !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai strong) {
color: #0369a1;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card.ai strong) {
color: #0369a1 !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card em) {
color: #64748b;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card em) {
color: #64748b !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending) {
background: rgba(219, 234, 254, 0.28);
border-color: rgba(14, 165, 233, 0.16);
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending) {
background: rgba(219, 234, 254, 0.28) !important;
border-color: rgba(14, 165, 233, 0.16) !important;
html.light .root :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending strong) {
color: #94a3b8;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-prediction-card.ai.pending strong) {
color: #94a3b8 !important;
html.light .root :global(.scan-terminal.light .scan-ai-confidence) {
background: rgba(59, 130, 246, 0.1);
color: #2563eb;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-confidence) {
background: rgba(59, 130, 246, 0.1) !important;
color: #2563eb !important;
html.light .root :global(.scan-terminal.light .scan-ai-confidence.high) {
background: rgba(16, 185, 129, 0.12);
color: #047857;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-confidence.high) {
background: rgba(16, 185, 129, 0.12) !important;
color: #047857 !important;
html.light .root :global(.scan-terminal.light .scan-ai-confidence.medium) {
background: rgba(245, 158, 11, 0.12);
color: #b45309;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-confidence.medium) {
background: rgba(245, 158, 11, 0.12) !important;
color: #b45309 !important;
html.light .root :global(.scan-terminal.light .scan-ai-confidence.low) {
background: rgba(148, 163, 184, 0.12);
color: #64748b;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-confidence.low) {
background: rgba(148, 163, 184, 0.12) !important;
color: #64748b !important;
html.light .root :global(.scan-terminal.light .scan-ai-confidence.neutral) {
background: rgba(148, 163, 184, 0.08);
color: #64748b;
}
.root:global(.light) :global(.scan-terminal.light .scan-ai-confidence.neutral) {
background: rgba(148, 163, 184, 0.08) !important;
color: #64748b !important;
/* ── Empty / Error / Retry light overrides ── */
html.light .root :global(.scan-terminal.light .scan-empty-title),
html.light .root :global(.scan-terminal.light .scan-error-title) {
color: #0f172a;
}
html.light .root :global(.scan-terminal.light .scan-empty-copy),
html.light .root :global(.scan-terminal.light .scan-error-copy) {
color: #475569;
}
html.light .root :global(.scan-terminal.light .scan-empty-icon) {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.2);
color: #2563EB;
}
html.light .root :global(.scan-terminal.light .scan-error-icon) {
background: rgba(239, 68, 68, 0.06);
border-color: rgba(239, 68, 68, 0.18);
color: #DC2626;
}
html.light .root :global(.scan-terminal.light .scan-retry-button) {
background: rgba(37, 99, 235, 0.08);
border-color: rgba(37, 99, 235, 0.24);
color: #2563EB;
}
html.light .root :global(.scan-terminal.light .scan-retry-button:hover) {
background: rgba(37, 99, 235, 0.14);
}
@@ -1,73 +1,6 @@
/* 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-kpi-bar) {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.root :global(.scan-kpi-card) {
min-height: 124px;
padding: 18px;
border-radius: 16px;
border: 1px solid rgba(92, 124, 170, 0.12);
background: linear-gradient(180deg, rgba(14, 28, 48, 0.92), rgba(11, 21, 37, 0.94));
transform: none;
}
.root :global(.scan-kpi-card:hover) {
transform: translateY(-2px);
}
.root :global(.scan-kpi-card::after) {
display: none;
}
.root :global(.scan-kpi-card.green .scan-kpi-label) {
color: #17d98b;
}
.root :global(.scan-kpi-card.cyan .scan-kpi-label) {
color: #37d6ff;
}
.root :global(.scan-kpi-card.purple .scan-kpi-label) {
color: #a35cff;
}
.root :global(.scan-kpi-card.blue .scan-kpi-label) {
color: #49a3ff;
}
.root :global(.scan-kpi-card.red .scan-kpi-label) {
color: #ff647c;
}
.root :global(.scan-kpi-card.amber .scan-kpi-label) {
color: #ffb84f;
}
.root :global(.scan-kpi-card.orange .scan-kpi-label) {
color: #F59E0B;
}
.root :global(.scan-kpi-label) {
font-size: 13px;
text-transform: none;
letter-spacing: 0;
}
.root :global(.scan-kpi-value) {
margin-top: 10px;
font-size: 28px;
}
.root :global(.scan-kpi-note) {
margin-top: 8px;
font-size: 14px;
color: #a8bedb;
}
.root :global(.scan-list-section) {
display: flex;
flex-direction: column;
@@ -272,7 +205,7 @@
.root :global(.scan-v4-decision p b) {
color: #ecfeff;
font-weight: 950;
font-weight: 800;
}
.root :global(.scan-v4-metar-summary) {
@@ -330,9 +263,9 @@
.root :global(.scan-v4-heading strong),
.root :global(.scan-v4-current b),
.root :global(.scan-v4-brief-grid strong) {
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 950;
font-weight: 800;
letter-spacing: 0.02em;
}
@@ -356,7 +289,7 @@
background: rgba(20, 184, 166, 0.12);
color: #7dfbe7;
font-size: 12px;
font-weight: 950;
font-weight: 800;
white-space: nowrap;
}
@@ -383,7 +316,7 @@
.root :global(.scan-v4-current span) {
color: #c8d8ec;
font-size: 13px;
font-weight: 850;
font-weight: 800;
line-height: 1.5;
}
@@ -429,7 +362,7 @@
width: 5px;
height: 5px;
border-radius: 999px;
background: #4DA3FF;
background: var(--color-accent-primary);
box-shadow: 0 0 10px rgba(45, 212, 191, 0.45);
}
@@ -454,7 +387,7 @@
background: rgba(10, 30, 58, 0.52);
color: #8fbdff;
font-size: 12px;
font-weight: 950;
font-weight: 800;
cursor: pointer;
list-style: none;
}
@@ -492,7 +425,7 @@
scroll-padding-top: 64px;
border: 1px solid rgba(77, 163, 255, 0.16);
border-radius: 18px;
background: #0b1220;
background: var(--color-bg-base);
padding: 14px;
}
@@ -521,7 +454,7 @@
.root :global(.scan-ai-workspace-head span) {
display: block;
color: #4da3ff;
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
@@ -529,7 +462,7 @@
.root :global(.scan-ai-workspace-head strong) {
display: block;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 20px;
line-height: 1.2;
margin-top: 4px;
@@ -538,7 +471,7 @@
.root :global(.scan-ai-workspace-head p) {
max-width: 560px;
margin: 0;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.55;
text-align: right;
@@ -16,7 +16,7 @@
.root :global(.scan-mobile-decision-head h3) {
margin: 6px 0 0;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 24px;
line-height: 1.08;
}
@@ -38,7 +38,7 @@
}
.root :global(.scan-mobile-decision-metrics small) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 10px;
font-weight: 900;
}
@@ -121,7 +121,7 @@
}
@media (max-width: 1480px) {
@media (max-width: 1440px) {
.root :global(.scan-terminal) {
grid-template-columns: minmax(0, 1fr);
}
@@ -141,7 +141,7 @@
}
}
@media (max-width: 1100px) {
@media (max-width: 1024px) {
.root :global(.scan-terminal) {
grid-template-columns: 1fr;
min-height: 100dvh;
@@ -176,10 +176,6 @@
max-width: 100%;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.root :global(.scan-topbar) {
flex-direction: column;
align-items: flex-start;
@@ -285,7 +281,7 @@
}
}
@media (max-width: 820px) {
@media (max-width: 768px) {
.root :global(.scan-terminal) {
padding: 10px;
}
@@ -310,10 +306,6 @@
font-size: 13px;
}
.root :global(.scan-kpi-bar) {
grid-template-columns: 1fr;
}
.root :global(.scan-topbar-tabs) {
gap: 14px;
flex-wrap: wrap;
@@ -362,7 +354,7 @@
}
.root :global(.scan-ai-city-mobile-priority small) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 10px;
font-weight: 900;
}
@@ -389,12 +381,6 @@
font-size: 20px;
}
.root :global(.scan-ai-decision-long),
.root :global(.scan-ai-decision-reasons),
.root :global(.scan-ai-decision-risk) {
display: none;
}
.root :global(.scan-ai-market-mobile-line) {
display: flex;
align-items: center;
@@ -408,13 +394,13 @@
}
.root :global(.scan-ai-market-mobile-line span) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 900;
}
.root :global(.scan-ai-market-mobile-line b) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 13px;
text-align: right;
}
@@ -489,7 +475,7 @@
}
}
@media (max-width: 520px) {
@media (max-width: 640px) {
.root :global(.scan-terminal) {
box-sizing: border-box;
max-width: 100vw;
@@ -71,7 +71,7 @@
font-size: 12px;
font-weight: 700;
background: rgba(0, 224, 164, 0.12);
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.rank-amber .scan-rank-circle) {
@@ -81,7 +81,7 @@
.root :global(.rank-purple .scan-rank-circle) {
background: rgba(123, 97, 255, 0.12);
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.rank-neutral .scan-rank-circle) {
@@ -168,7 +168,7 @@
.root :global(.scan-status-badge.tone-green) {
background: rgba(0, 224, 164, 0.12);
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.scan-status-badge.tone-amber) {
@@ -178,7 +178,7 @@
.root :global(.scan-status-badge.tone-purple) {
background: rgba(123, 97, 255, 0.12);
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.scan-status-badge.tone-neutral) {
@@ -233,7 +233,7 @@
.root :global(.scan-action-text) {
font-size: 12px;
font-weight: 600;
color: #4DA3FF;
color: var(--color-accent-primary);
white-space: nowrap;
}
@@ -245,7 +245,7 @@
}
.root :global(.scan-edge-value.positive) {
color: #4DA3FF;
color: var(--color-accent-primary);
}
.root :global(.scan-edge-value.neutral) {
@@ -447,7 +447,7 @@
}
.root :global(.scan-opportunity-item.ai-veto .scan-opportunity-action) {
color: #9FB2C7;
color: var(--color-text-secondary);
}
.root :global(.scan-opportunity-branch) {
@@ -702,7 +702,7 @@
.root :global(.scan-v4-analysis strong) {
display: block;
color: #6FB7FF;
color: var(--color-accent-secondary);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.02em;
@@ -892,11 +892,11 @@
align-content: center;
gap: 12px;
text-align: center;
color: #9fb2c7;
color: var(--color-text-secondary);
}
.root :global(.scan-opportunity-overview.empty strong) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 22px;
}
@@ -926,7 +926,7 @@
}
.root :global(.scan-opportunity-hero span) {
color: #4da3ff;
color: var(--color-accent-primary);
font-size: 12px;
font-weight: 900;
}
@@ -934,7 +934,7 @@
.root :global(.scan-opportunity-hero strong) {
display: block;
margin-top: 4px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 26px;
line-height: 1.15;
}
@@ -942,7 +942,7 @@
.root :global(.scan-opportunity-hero p) {
max-width: 680px;
margin: 9px 0 0;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1.6;
}
@@ -959,12 +959,12 @@
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 14px;
background: rgba(13, 17, 23, 0.52);
color: #9fb2c7;
color: var(--color-text-secondary);
padding: 12px;
}
.root :global(.scan-opportunity-summary b) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 18px;
}
@@ -990,13 +990,13 @@
}
.root :global(.scan-opportunity-lane-head strong) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 17px;
}
.root :global(.scan-opportunity-lane-head p) {
margin: 3px 0 0;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 12px;
}
@@ -1060,7 +1060,7 @@
.root :global(.scan-opportunity-decision-head span),
.root :global(.scan-opportunity-decision-primary span),
.root :global(.scan-opportunity-decision-foot small) {
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 11px;
font-weight: 800;
}
@@ -1068,7 +1068,7 @@
.root :global(.scan-opportunity-decision-head strong) {
display: block;
margin-top: 3px;
color: #e6edf3;
color: var(--color-text-primary);
font-size: 18px;
}
@@ -1108,7 +1108,7 @@
}
.root :global(.scan-opportunity-decision-primary b) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 14px;
}
@@ -1,6 +1,15 @@
/* 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;
@@ -10,6 +19,32 @@
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;
@@ -135,7 +170,7 @@
background: rgba(77, 163, 255, 0.1);
color: #d7e5f7;
font-size: 12px;
font-weight: 850;
font-weight: 800;
padding: 7px 10px;
}
@@ -168,7 +203,7 @@
padding: 11px 16px;
border: 1px solid rgba(77, 163, 255, 0.42);
border-radius: 12px;
background: linear-gradient(180deg, #4DA3FF, #3B82F6);
background: linear-gradient(180deg, var(--color-accent-primary), #3B82F6);
color: #FFFFFF;
font-size: 14px;
font-weight: 900;
@@ -212,7 +247,7 @@
}
.root :global(.scan-cta-ghost) {
color: #6FB7FF;
color: var(--color-accent-secondary);
border-color: rgba(23, 217, 139, 0.3);
background: rgba(23, 217, 139, 0.12);
}
@@ -1,5 +1,6 @@
/* Scan terminal empty, loading, and skeleton signal states. */
/* Scan terminal empty, loading, error, and skeleton signal states. */
/* ── Empty state ── */
.root :global(.scan-empty-state) {
flex: 1;
display: flex;
@@ -8,6 +9,20 @@
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) {
@@ -17,12 +32,71 @@
}
.root :global(.scan-empty-copy) {
margin-top: 10px;
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;
@@ -101,7 +175,7 @@
}
.root :global(.scan-loading-node.market) {
background: linear-gradient(135deg, #38bdf8, #4da3ff);
background: linear-gradient(135deg, #38bdf8, var(--color-accent-primary));
box-shadow: 0 0 24px rgba(56, 189, 248, 0.26);
}
@@ -141,7 +215,7 @@
}
.root :global(.scan-loading-copy-block strong) {
color: #e6edf3;
color: var(--color-text-primary);
font-size: 18px;
font-weight: 900;
letter-spacing: -0.02em;
@@ -149,9 +223,9 @@
.root :global(.scan-loading-copy-block span) {
max-width: 320px;
color: #9fb2c7;
color: var(--color-text-secondary);
font-size: 13px;
font-weight: 650;
font-weight: 600;
line-height: 1.55;
}
@@ -168,7 +242,7 @@
width: 7px;
min-height: 8px;
border-radius: 999px 999px 4px 4px;
background: linear-gradient(180deg, #7dd3fc, #4da3ff 52%, #1be392);
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;
@@ -0,0 +1,53 @@
/* Barrel: pre-combined scan-terminal root class name.
Consolidates 22 CSS Modules that are always co-imported into
a single className, keeping ScanTerminalDashboard.tsx lean. */
import clsx from "clsx";
import dashboardHomeStyles from "./DashboardHomeIntelligence.module.css";
import dashboardMapStyles from "./DashboardMap.module.css";
import dashboardModalGuideStyles from "./DashboardModalGuide.module.css";
import dashboardShellStyles from "./DashboardShell.module.css";
import detailChromeStyles from "./DetailPanelChrome.module.css";
import detailContentStyles from "./DetailPanelContent.module.css";
import detailSectionsStyles from "./DetailPanelSections.module.css";
import futureForecastModalStyles from "./FutureForecastModal.module.css";
import historyModalStyles from "./HistoryModal.module.css";
import modalChromeStyles from "./ModalChrome.module.css";
import scanTerminalStyles from "./ScanTerminal.module.css";
import scanTerminalBoardStyles from "./ScanTerminalBoard.module.css";
import scanTerminalCalendarStyles from "./ScanTerminalCalendar.module.css";
import scanTerminalCardStyles from "./ScanTerminalCard.module.css";
import scanTerminalDetailStyles from "./ScanTerminalDetail.module.css";
import scanTerminalFiltersStyles from "./ScanTerminalFilters.module.css";
import scanTerminalLightThemeStyles from "./ScanTerminalLightTheme.module.css";
import scanTerminalListStyles from "./ScanTerminalList.module.css";
import scanTerminalMobileStyles from "./ScanTerminalMobile.module.css";
import scanTerminalOpportunityStyles from "./ScanTerminalOpportunity.module.css";
import scanTerminalShellStyles from "./ScanTerminalShell.module.css";
import scanTerminalStateStyles from "./ScanTerminalState.module.css";
export const scanRootClass = clsx(
dashboardHomeStyles.root,
dashboardMapStyles.root,
dashboardShellStyles.root,
dashboardModalGuideStyles.root,
scanTerminalStyles.root,
scanTerminalShellStyles.root,
scanTerminalFiltersStyles.root,
scanTerminalListStyles.root,
scanTerminalBoardStyles.root,
scanTerminalDetailStyles.root,
scanTerminalStateStyles.root,
scanTerminalOpportunityStyles.root,
scanTerminalCardStyles.root,
scanTerminalCalendarStyles.root,
scanTerminalMobileStyles.root,
scanTerminalLightThemeStyles.root,
detailChromeStyles.root,
detailContentStyles.root,
detailSectionsStyles.root,
modalChromeStyles.root,
futureForecastModalStyles.root,
historyModalStyles.root,
);
@@ -1,77 +0,0 @@
import type { CityDetail, ScanOpportunityRow } from "@/lib/dashboard-types";
import { getTodayPaceView } from "@/lib/pace-utils";
import { formatTemperatureValue } from "@/lib/temperature-utils";
import { getPeakWindowLabel } from "@/components/dashboard/scan-terminal/decision-utils";
export function AiForecastKPIBar({
pinnedCount,
activeCityName,
activeDetail,
activeRow,
locale,
}: {
pinnedCount: number;
activeCityName?: string | null;
activeDetail?: CityDetail | null;
activeRow?: ScanOpportunityRow | null;
locale: string;
}) {
const isEn = locale === "en-US";
const tempSymbol = activeDetail?.temp_symbol || activeRow?.temp_symbol || "°C";
const displayName =
activeDetail?.display_name ||
activeRow?.city_display_name ||
activeRow?.display_name ||
activeCityName ||
"--";
const deb = activeDetail?.deb?.prediction ?? activeRow?.deb_prediction ?? null;
const paceView = activeDetail
? getTodayPaceView(activeDetail, locale as "zh-CN" | "en-US")
: null;
const peakWindow =
paceView?.peakWindowText ||
(activeRow ? getPeakWindowLabel(activeRow) : null) ||
"--";
const cards = [
{
label: isEn ? "Decision Cards" : "决策卡",
value: String(pinnedCount),
note: isEn ? "Cities opened from opportunities or map" : "从机会榜或地图加入",
tone: "green",
},
{
label: isEn ? "Current City" : "当前城市",
value: displayName,
note: isEn ? "City briefing stays in the right rail" : "右侧城市简报同步显示,不自动切页",
tone: "blue",
},
{
label: isEn ? "Forecast Center" : "预测中枢",
value:
deb != null
? formatTemperatureValue(deb, tempSymbol, { digits: 1 })
: "--",
note: isEn ? "Weather center before price mapping" : "先定天气中枢,再映射价格",
tone: "cyan",
},
{
label: isEn ? "Peak Window" : "峰值窗口",
value: peakWindow,
note: isEn ? "Key window for daily high judgment" : "用于判断是否接近今日最高温",
tone: "amber",
},
];
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>
);
}
@@ -542,6 +542,7 @@ export function AiPinnedCityCard({
collapsed && !isCompactCard && "collapsed",
removing && "removing",
)}
tabIndex={-1}
data-ai-status={decisionState.aiStatus}
data-evidence-quality={decisionState.evidenceQuality}
data-market-status={decisionState.marketStatus}
@@ -613,15 +614,12 @@ export function AiPinnedCityCard({
{detail && !collapsed ? (
<div className="scan-ai-city-body" id={collapseId}>
<WeatherDecisionBand
currentTempText={currentTempText}
decisionView={decisionView}
decisionWhyText={decisionState.primaryReason}
isEn={isEn}
longText={localizedFinalJudgment || paceText}
marketDecisionView={marketDecisionView}
marketLineText={marketLineText}
paceDeltaText={paceView?.deltaText || "--"}
peakWindow={peakWindow}
/>
<div className="scan-ai-city-analysis-grid">
@@ -8,25 +8,19 @@ import type {
import { MarketDecisionLine } from "@/components/dashboard/scan-terminal/MarketDecisionLine";
export function WeatherDecisionBand({
currentTempText,
decisionView,
decisionWhyText,
isEn,
longText,
marketDecisionView,
marketLineText,
paceDeltaText,
peakWindow,
}: {
currentTempText: string;
decisionView: WeatherDecisionView;
decisionWhyText: string;
isEn: boolean;
longText: string;
marketDecisionView: MarketDecisionView;
marketLineText: string;
paceDeltaText: string;
peakWindow: string;
}) {
return (
<section className={clsx("scan-ai-decision-band", decisionView.tone)}>
@@ -34,13 +28,6 @@ export function WeatherDecisionBand({
<span>{decisionView.kicker}</span>
<strong>{decisionView.action}</strong>
<p className="scan-ai-decision-why">{decisionWhyText}</p>
<p className="scan-ai-decision-long">{longText}</p>
<div className="scan-ai-decision-reasons">
{decisionView.reasons.map((reason, index) => (
<small key={`${reason}-${index}`}>{reason}</small>
))}
</div>
<p className="scan-ai-decision-risk">{decisionView.risk}</p>
<MarketDecisionLine
isEn={isEn}
marketDecisionView={marketDecisionView}
@@ -48,10 +35,6 @@ export function WeatherDecisionBand({
/>
</div>
<div className="scan-ai-decision-metrics">
<span>
{isEn ? "Expected high" : "预计高点"}
<b>{decisionView.expectedHigh}</b>
</span>
<span>
{isEn ? "Weather range" : "天气区间"}
<b>{decisionView.targetRange}</b>
@@ -60,22 +43,9 @@ export function WeatherDecisionBand({
{isEn ? "Confidence" : "信心"}
<b>{decisionView.confidence}</b>
</span>
<span>
{isEn ? "Observed" : "实测"}
<b>{currentTempText}</b>
</span>
<span>
{isEn ? "Path delta" : "路径偏差"} <b>{paceDeltaText}</b>
</span>
<span>
{isEn ? "Peak window" : "峰值窗口"} <b>{peakWindow}</b>
</span>
<span>
{isEn ? "Market implied" : "市场隐含"} <b>{marketDecisionView.impliedText}</b>
</span>
<span>
{isEn ? "Model prob" : "模型概率"} <b>{marketDecisionView.modelText}</b>
</span>
<span>
{isEn ? "Quote status" : "报价状态"}{" "}
<b>{marketDecisionView.status === "ready" ? (isEn ? "Ready" : "已同步") : marketDecisionView.status === "loading" ? (isEn ? "Loading" : "同步中") : (isEn ? "Unavailable" : "不可用")}</b>
@@ -146,6 +146,40 @@ function getAiCityStreamProgressText(
return "";
}
function computeFallbackPredictedMax(detail: CityDetail | null): number | null {
if (!detail) return null;
const multiModel = detail.multi_model ?? {};
const entries = Object.entries(multiModel).filter(
([, v]) => v != null && Number.isFinite(v),
) as [string, number][];
const values = entries.map(([, v]) => v);
const debValue = detail.deb?.prediction ?? null;
const nonDebValues = entries
.filter(([name]) => !name.toLowerCase().includes("deb"))
.map(([, v]) => v);
const sorted = [...nonDebValues].sort((a, b) => a - b);
const clusterMedian =
sorted.length > 0 ? sorted[Math.floor(sorted.length / 2)] : null;
let predicted = clusterMedian;
if (predicted == null && debValue != null && Number.isFinite(debValue))
predicted = debValue;
if (predicted == null && values.length > 0)
predicted = values.reduce((a, b) => a + b, 0) / values.length;
if (predicted == null) {
const isHkoObservation = isHkoObservationCity(detail);
const currentTemp =
(isHkoObservation
? detail?.current?.temp
: detail?.airport_current?.temp ??
detail?.airport_primary?.temp ??
detail?.current?.temp) ?? null;
predicted = currentTemp != null && Number.isFinite(currentTemp)
? currentTemp
: null;
}
return predicted;
}
export function buildAiCityFallbackPayload({
detail,
error,
@@ -195,6 +229,12 @@ export function buildAiCityFallbackPayload({
const reasonZh = `DEB、多模型集合和最新${sourceZh}已足够给出当前方向判断;页面会在 DeepSeek 返回后合并完整机场报文解读。`;
const reasonEn = `DEB, the model cluster and latest ${sourceEn} are enough for the current directional read; the page will merge the full airport-bulletin read when DeepSeek returns.`;
const fallbackPredictedMax = computeFallbackPredictedMax(detail);
const multiModel = detail?.multi_model ?? {};
const modelValues = Object.values(multiModel).filter(
(v): v is number => v != null && Number.isFinite(v),
);
return {
city_forecast: {
confidence: "low",
@@ -204,9 +244,9 @@ export function buildAiCityFallbackPayload({
metar_read_zh: metarZh,
model_cluster_note_en: "",
model_cluster_note_zh: "",
predicted_max: null,
range_high: null,
range_low: null,
predicted_max: fallbackPredictedMax,
range_high: modelValues.length > 0 ? Math.max(...modelValues) : null,
range_low: modelValues.length > 0 ? Math.min(...modelValues) : null,
reasoning_en: reasonEn,
reasoning_zh: reasonZh,
risks_en: [],
@@ -251,6 +291,60 @@ export function buildAiCityLoadingForecastState({
return loadingState;
}
function hasPreviewQuantFields(progress: AiCityStreamProgress) {
return (
progress.predicted_max != null ||
progress.range_low != null ||
progress.range_high != null
);
}
function mergePreviewQuantPayload(
current: AiCityForecastState,
progress: AiCityStreamProgress,
): AiCityForecastPayload {
const prev = current.payload ?? {};
const prevCf = prev.city_forecast ?? {};
return {
...prev,
status: prev.status ?? "ready",
city_forecast: {
...prevCf,
...(progress.predicted_max != null
? { predicted_max: progress.predicted_max }
: {}),
...(progress.range_low != null
? { range_low: progress.range_low }
: {}),
...(progress.range_high != null
? { range_high: progress.range_high }
: {}),
...(progress.confidence != null
? { confidence: progress.confidence }
: {}),
...(progress.unit != null ? { unit: progress.unit } : {}),
...(progress.metar_read_zh != null
? { metar_read_zh: progress.metar_read_zh }
: {}),
...(progress.metar_read_en != null
? { metar_read_en: progress.metar_read_en }
: {}),
...(progress.final_judgment_zh != null
? { final_judgment_zh: progress.final_judgment_zh }
: {}),
...(progress.final_judgment_en != null
? { final_judgment_en: progress.final_judgment_en }
: {}),
...(progress.model_cluster_note_zh != null
? { model_cluster_note_zh: progress.model_cluster_note_zh }
: {}),
...(progress.model_cluster_note_en != null
? { model_cluster_note_en: progress.model_cluster_note_en }
: {}),
},
};
}
export function buildAiCityProgressForecastState({
cacheKey,
current,
@@ -263,25 +357,36 @@ export function buildAiCityProgressForecastState({
progress: AiCityStreamProgress;
}) {
const progressText = getAiCityStreamProgressText(progress, isEn);
if (!progressText) return null;
const hasQuant = hasPreviewQuantFields(progress);
if (!progressText && !hasQuant) return null;
const cachedProgressState = readCachedAiForecastState(cacheKey);
const nextStreamText =
progress.stage === "calling_ai" && cachedProgressState?.streamText
? cachedProgressState.streamText
: progressText;
: progressText || cachedProgressState?.streamText || "";
const nextPayload = hasQuant
? mergePreviewQuantPayload(
cachedProgressState ?? current,
progress,
)
: cachedProgressState?.payload ?? current.payload;
const cachedNextState: AiCityForecastState = {
...cachedProgressState,
status: "loading",
streamText: nextStreamText,
payload: nextPayload,
};
writeCachedAiForecastState(cacheKey, cachedNextState);
return {
...current,
status: "loading",
status: "loading" as const,
streamText:
progress.stage === "calling_ai" && current.streamText
? current.streamText
: progressText,
: progressText || current.streamText || "",
payload: hasQuant
? mergePreviewQuantPayload(current, progress)
: current.payload,
} satisfies AiCityForecastState;
}
@@ -142,31 +142,31 @@ export function buildCityDecisionState({
: "watch";
const primaryReason = observedHighBreak
? isEn
? "Worth watching now: observation has broken above the model range."
: "当前值得关注:实测已突破模型上沿。"
? "Observation has broken above the model range."
: "实测已突破模型上沿。"
: peakHasPassed
? isEn
? "Avoid chasing now: peak window has passed; wait to confirm no new high."
: "当前不宜追高:峰值窗口已过,等待确认是否还新高。"
? "Peak window has passed; confirm whether a new high can still form."
: "峰值窗口已过,确认是否还会出现新高。"
: observationStale
? isEn
? "Use as background only: observation is stale and needs the next report."
: "当前仅作背景:观测已过旧,需要下一报文确认。"
? "Observation is stale and needs the next report."
: "观测已过旧,需要下一报文确认。"
: marketStatus === "unavailable"
? isEn
? "Weather evidence remains usable, but no tradable quote is available yet."
: "当前可参考天气:暂无可交易价格。"
? "Weather evidence is usable, but no tradable quote is available yet."
: "天气证据可参考,但暂无可交易价格。"
: modelHighlyConsistent
? isEn
? "Worth watching now: models agree; wait for observation confirmation."
: "当前值得关注:模型高度一致,等待实测确认。"
? "Models are aligned; wait for observation confirmation."
: "模型高度一致,等待实测确认。"
: needsNextBulletin
? isEn
? "Wait for confirmation: the next bulletin should decide direction."
: "当前建议等待:下一报文更适合决定方向。"
? "The next bulletin is more likely to decide direction."
: "下一报文更可能决定方向。"
: isEn
? "Watch the peak window and compare observations against the expected high."
: "当前重点:盯住峰值窗口,把实测与预计高点对照。";
? "Compare new observations with the expected high through the peak window."
: "在峰值窗口内继续对照实测与预计高点。";
const badges = uniqueStatusBadges([
observedHighBreak
@@ -30,7 +30,14 @@ export type AiCityStreamProgress = {
final_judgment_zh?: string | null;
metar_read_en?: string | null;
metar_read_zh?: string | null;
model_cluster_note_en?: string | null;
model_cluster_note_zh?: string | null;
raw_length?: number | null;
predicted_max?: number | null;
range_low?: number | null;
range_high?: number | null;
confidence?: string | null;
unit?: string | null;
};
export const scanTerminalQueryPolicy = {
@@ -160,7 +160,7 @@
.sectionTitle {
margin: 0 0 16px;
font-size: 1.65rem;
font-weight: 750;
font-weight: 700;
}
.paragraph {
@@ -283,7 +283,7 @@
}
}
@media (max-width: 900px) {
@media (max-width: 960px) {
.docsHeader {
align-items: flex-start;
flex-direction: column;
-238
View File
@@ -1,238 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PolyWeather - 天气衍生品智能地图</title>
<meta name="description" content="PolyWeather 天气衍生品智能地图,聚合 METAR、MGM、DEB、多模型预报与历史对账分析。">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap"
rel="stylesheet">
<link rel="stylesheet" href="/static/style.css?v=legacy-v19">
</head>
<body>
<div id="map"></div>
<header id="header">
<div class="brand">
<h1>PolyWeather</h1>
<span class="subtitle">天气衍生品智能分析</span>
</div>
<button class="info-btn" id="btnShowGuide" title="查看系统技术说明">技术说明</button>
<div class="live-badge" id="liveBadge">
<span class="pulse-dot"></span>
<span>实时</span>
</div>
<button class="refresh-btn" id="refreshAllBtn" title="刷新所有数据" aria-label="刷新所有数据"></button>
</header>
<nav id="cityList" class="city-list">
<div class="city-list-header">
<span>监控城市</span>
<span class="city-count" id="cityCount">0</span>
</div>
<div id="cityListItems" class="city-list-items">
<!-- Dynamically populated -->
</div>
</nav>
<aside id="panel" class="detail-panel hidden">
<div class="panel-header">
<button class="panel-close" id="panelClose"></button>
<div class="panel-title-area">
<h2 id="panelCityName"></h2>
<div class="panel-meta">
<span id="panelRiskBadge" class="risk-badge"></span>
<span id="panelLocalTime" class="local-time"></span>
<button class="history-btn" id="btnShowHistory" title="查看历史记录与准确率">历史对账</button>
</div>
</div>
</div>
<div id="panelContent" class="panel-body">
<section class="hero-section">
<div class="hero-weather" id="heroWeather"></div>
<div class="hero-temp">
<span class="hero-value" id="heroTemp"></span>
<span class="hero-unit" id="heroUnit">°C</span>
</div>
<div class="hero-max-time" id="heroMaxTime"></div>
<div class="hero-details">
<div class="hero-item">
<span class="label">当前实测</span>
<span class="value" id="heroCurrent"></span>
</div>
<div class="hero-item">
<span class="label">结算参考</span>
<span class="value highlight" id="heroWU"></span>
</div>
<div class="hero-item">
<span class="label">DEB 预测</span>
<span class="value" id="heroDEB"></span>
</div>
</div>
<div class="hero-sub" id="heroSub"></div>
</section>
<section class="chart-section">
<h3>今日温度走势</h3>
<div class="chart-wrapper">
<canvas id="tempChart"></canvas>
</div>
<div class="chart-legend" id="chartLegend"></div>
</section>
<section class="prob-section">
<h3>结算概率分布</h3>
<div id="probBars" class="prob-bars"></div>
</section>
<section class="models-section">
<h3>多模型预报</h3>
<div id="modelBars" class="model-bars"></div>
</section>
<section class="forecast-section">
<h3>多日预报</h3>
<div id="forecastTable" class="forecast-table"></div>
<div class="sun-info" id="sunInfo"></div>
</section>
<section class="ai-section">
<h3>机场报文解读</h3>
<div id="aiAnalysis" class="ai-box">
<span class="ai-placeholder">点击城市后加载...</span>
</div>
</section>
<section class="risk-section">
<h3>数据偏差风险</h3>
<div id="riskInfo" class="risk-info"></div>
</section>
</div>
</aside>
<div id="loading" class="loading-overlay hidden">
<div class="loading-spinner"></div>
<span>正在获取气象数据,请稍候...</span>
</div>
<!-- 鈹€鈹€ Technical Guide Modal 鈹€鈹€ -->
<div id="guideModal" class="modal-overlay hidden">
<div class="modal-content large">
<div class="modal-header">
<h2>&#128218; PolyWeather &#x7CFB;&#x7EDF;&#x6280;&#x672F;&#x8BF4;&#x660E;</h2>
<button class="modal-close" id="guideModalClose">&#x2715;</button>
</div>
<div class="modal-body">
<div class="guide-grid">
<div class="guide-card">
<h3>&#129518; DEB &#x52A8;&#x6001;&#x878D;&#x5408;&#x9884;&#x6D4B;</h3>
<p><b>Dynamic Ensemble Blending</b> &#x662F;&#x7CFB;&#x7EDF;&#x7684;&#x6838;&#x5FC3;&#x9884;&#x6D4B;&#x5C42;&#x3002;&#x5B83;&#x4E0D;&#x662F;&#x5BF9; ECMWF&#x3001;GFS&#x3001;ICON&#x3001;GEM&#x3001;JMA &#x7B49;&#x6A21;&#x578B;&#x7684;&#x7B80;&#x5355;&#x5E73;&#x5747;&#xFF0C;&#x800C;&#x662F;&#x7ED3;&#x5408;&#x6700;&#x8FD1;&#x6837;&#x672C;&#x8868;&#x73B0;&#x3001;&#x5F53;&#x524D;&#x5B9E;&#x51B5;&#x4E0E;&#x57CE;&#x5E02;&#x504F;&#x7F6E;&#x540E;&#x5F97;&#x5230;&#x7684;&#x52A8;&#x6001;&#x52A0;&#x6743;&#x7ED3;&#x679C;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#127919; &#x7ED3;&#x7B97;&#x6982;&#x7387;&#x5F15;&#x64CE;</h3>
<p>&#x53F3;&#x4FA7;&#x7684;&#x7ED3;&#x7B97;&#x6982;&#x7387;&#x5206;&#x5E03;&#x57FA;&#x4E8E; DEB &#x9884;&#x6D4B;&#x503C;&#x4E0E;&#x591A;&#x6A21;&#x578B;&#x79BB;&#x6563;&#x5EA6;&#x52A8;&#x6001;&#x8BA1;&#x7B97;&#x3002;&#x03BC;&#x4EE3;&#x8868;&#x5F53;&#x524D;&#x5206;&#x5E03;&#x4E2D;&#x5FC3;&#xFF0C;&#x4F1A;&#x968F;&#x7740;&#x6A21;&#x578B;&#x3001;&#x5B9E;&#x51B5;&#x548C;&#x65F6;&#x95F4;&#x53D8;&#x5316;&#x800C;&#x53D8;&#x52A8;&#xFF0C;&#x4E0D;&#x662F;&#x56FA;&#x5B9A;&#x9884;&#x62A5;&#x503C;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#9992;&#65039; &#x7ED3;&#x7B97;&#x70B9;&#x4E0E;&#x4E3B;&#x89C2;&#x6D4B;&#x6E90;</h3>
<p>Polymarket &#x7ED3;&#x7B97;&#x903B;&#x8F91;&#x4EE5;&#x673A;&#x573A; METAR &#x4E3A;&#x4E3B;&#x3002;&#x7CFB;&#x7EDF;&#x73B0;&#x5728;&#x4F18;&#x5148;&#x4F7F;&#x7528; Aviation Weather API &#x7684;&#x673A;&#x573A;&#x62A5;&#x6587;&#x4E0E;&#x539F;&#x59CB; METAR&#xFF0C;&#x5E76;&#x533A;&#x5206;&#x89C2;&#x6D4B;&#x65F6;&#x95F4;&#x4E0E;&#x63A5;&#x6536;&#x65F6;&#x95F4;&#xFF0C;&#x907F;&#x514D;&#x628A;&#x53D1;&#x5E03;&#x5EF6;&#x8FDF;&#x8BEF;&#x8BA4;&#x6210;&#x6E29;&#x5EA6;&#x53D8;&#x5316;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#128205; Ankara &#x4E13;&#x5C5E;&#x589E;&#x5F3A;</h3>
<p>Ankara &#x4E0D;&#x8D70;&#x901A;&#x7528;&#x57CE;&#x5E02;&#x903B;&#x8F91;&#x3002;&#x7ED3;&#x7B97;&#x4E3B;&#x7AD9;&#x4EE5; LTAC / Esenbo&#287;a &#x4E3A;&#x51C6;&#xFF0C;&#x5468;&#x8FB9;&#x9886;&#x5148;&#x4FE1;&#x53F7;&#x4F18;&#x5148;&#x53C2;&#x8003; Turkish MGM &#x7AD9;&#x7F51;&#xFF0C;&#x5176;&#x4E2D; <b>Ankara (B&#246;lge/Center)</b> &#x662F;&#x91CD;&#x70B9;&#x76D1;&#x63A7;&#x7AD9;&#xFF0C;&#x4E0D;&#x7531; Etimesgut &#x4EE3;&#x66FF;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#128198; &#x672A;&#x6765;&#x65E5;&#x671F;&#x5206;&#x6790;</h3>
<p>&#x70B9;&#x51FB;&#x591A;&#x65E5;&#x9884;&#x62A5;&#x540E;&#x6253;&#x5F00;&#x7684;&#x6A21;&#x6001;&#x6846;&#xFF0C;&#x4E3B;&#x8981;&#x7528;&#x4E8E;&#x5206;&#x6790;&#x4E0B;&#x4E00;&#x4E2A;&#x4EA4;&#x6613;&#x65E5;&#x3002; 6-48 &#x5C0F;&#x65F6;&#x8D8B;&#x52BF;&#x4EE5; weather.gov &#x548C; Open-Meteo &#x4E3A;&#x4E3B;&#xFF1B; 0-2 &#x5C0F;&#x65F6;&#x4E34;&#x8FD1;&#x5224;&#x65AD;&#x5219;&#x4F18;&#x5148;&#x770B; METAR &#x4E0E;&#x5468;&#x8FB9;&#x7AD9;&#x3002;</p>
</div>
<div class="guide-card">
<h3>&#128202; &#x5386;&#x53F2;&#x5BF9;&#x8D26;&#x89C4;&#x5219;</h3>
<p>&#x5386;&#x53F2;&#x51C6;&#x786E;&#x7387;&#x5BF9;&#x8D26;&#x53EA;&#x7EDF;&#x8BA1;&#x5DF2;&#x7ED3;&#x7B97;&#x7684;&#x6837;&#x672C;&#x3002;&#x7F51;&#x9875;&#x7AEF;&#x91C7;&#x7528;&#x8FD1;15&#x5929;&#x6EDA;&#x52A8;&#x89C6;&#x56FE;&#xFF0C;&#x673A;&#x5668;&#x4EBA; /deb &#x62A5;&#x544A;&#x91C7;&#x7528;&#x8FD1;7&#x5929;&#x7A97;&#x53E3;&#xFF0C;&#x4E0D;&#x628A;&#x5F53;&#x5929;&#x672A;&#x7ED3;&#x7B97;&#x7684;&#x6837;&#x672C;&#x7B97;&#x5165;&#x80DC;&#x7387;&#x548C; MAE&#x3002;</p>
</div>
</div>
<div class="guide-footer">
<p>&#x203B; &#x6570;&#x636E;&#x6E90;&#x4EE5; Aviation Weather / METAR&#x3001;Turkish MGM&#x3001;Open-Meteo&#x3001;weather.gov &#x4E3A;&#x4E3B;&#x3002;</p>
</div>
</div>
</div>
</div>
<!-- 鈹€鈹€ History Chart Modal 鈹€鈹€ -->
<div id="historyModal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h2>&#128202; <span id="historyModalTitle">&#x5386;&#x53F2;&#x51C6;&#x786E;&#x7387;&#x5BF9;&#x8D26;</span></h2>
<button class="modal-close" id="historyModalClose">&#x2715;</button>
</div>
<div class="modal-body">
<div class="history-stats" id="historyStats"></div>
<div class="history-chart-wrapper">
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
</div>
<div id="futureForecastModal" class="modal-overlay hidden">
<div class="modal-content large future-modal">
<div class="modal-header">
<h2>&#128198; <span id="futureForecastTitle">&#x672A;&#x6765;&#x65E5;&#x671F;&#x5206;&#x6790;</span></h2>
<button class="modal-close" id="futureForecastClose" aria-label="&#x5173;&#x95ED;&#x672A;&#x6765;&#x65E5;&#x671F;&#x5206;&#x6790;">&#x2715;</button>
</div>
<div class="modal-body future-modal-body">
<div class="history-stats" id="futureForecastStats"></div>
<section class="future-modal-section">
<h3>&#128200; &#x6E29;&#x5EA6;&#x8D70;&#x52BF;</h3>
<div class="history-chart-wrapper future-chart-wrapper">
<canvas id="futureForecastChart"></canvas>
</div>
</section>
<div class="future-modal-grid">
<section class="future-modal-section">
<h3>&#127919; &#x7ED3;&#x7B97;&#x6982;&#x7387;&#x5206;&#x5E03;</h3>
<div id="futureProbBars" class="prob-bars future-prob-bars"></div>
</section>
<section class="future-modal-section">
<h3>&#129518; &#x591A;&#x6A21;&#x578B;&#x9884;&#x62A5;</h3>
<div id="futureModelBars" class="model-bars"></div>
</section>
</div>
<div class="future-modal-grid">
<section class="future-modal-section">
<h3><span class="section-inline-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19V5"/><path d="M10 19V10"/><path d="M16 19V7"/><path d="M22 19V13"/></svg></span>&#x672A;&#x6765; 6-48 &#x5C0F;&#x65F6;&#x8D8B;&#x52BF;</h3>
<div id="futureTrendGrid" class="future-trend-grid"></div>
</section>
<section class="future-modal-section">
<h3 id="futureNowcastTitle">&#9201; &#x76EE;&#x6807;&#x65E5;&#x5348;&#x540E;&#x5CF0;&#x503C;&#x7A97;&#x53E3;</h3>
<div id="futureNowcast" class="future-text-block"></div>
</section>
</div>
</div>
</div>
</div>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Leaflet Heatmap Plugin -->
<script src="https://leaflet.github.io/Leaflet.heat/dist/leaflet-heat.js"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script src="/static/app.js?v=legacy-v19"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+12 -208
View File
@@ -812,57 +812,6 @@ def _build_advice_cn(
return "".join(parts) + ""
def _classify_low_yes_signal(
rules: Dict[str, Dict[str, Any]],
market_snapshot: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
snapshot = market_snapshot or {}
forecast_bucket = snapshot.get("forecast_bucket") or {}
yes_buy = _norm_probability(forecast_bucket.get("yes_buy"))
center_deb = rules.get("ankara_center_deb_hit", {}) or {}
momentum = rules.get("momentum_spike", {}) or {}
advection = rules.get("advection", {}) or {}
breakthrough = rules.get("forecast_breakthrough", {}) or {}
cheap_yes = yes_buy is not None and yes_buy < 0.10
center_hit = bool(center_deb.get("triggered"))
momentum_direction = str(momentum.get("direction") or "neutral").lower()
momentum_up = bool(momentum.get("triggered")) and momentum_direction == "up"
momentum_down = bool(momentum.get("triggered")) and momentum_direction == "down"
warm_flow = bool(advection.get("triggered"))
model_break = bool(breakthrough.get("triggered"))
bullish_confirmation = momentum_up or warm_flow or model_break
bearish_conflict = momentum_down and not warm_flow and not model_break
if cheap_yes and center_hit and bullish_confirmation:
return {
"label": "undervalued",
"should_push": True,
"reason_cn": "该桶 Yes 价格 < 10c,且天气信号与上行方向一致,疑似低估",
}
if cheap_yes and center_hit and bearish_conflict:
return {
"label": "conflicted",
"should_push": False,
"reason_cn": "该桶 Yes 价格虽 < 10c,但短时动量转弱,暂不判定低估",
}
if cheap_yes:
return {
"label": "watch",
"should_push": False,
"reason_cn": "该桶 Yes 价格 < 10c,但方向确认不足,先观察",
}
return {
"label": "none",
"should_push": False,
"reason_cn": "当前未出现明确的低价错配信号",
}
def _build_telegram_messages(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
@@ -1057,143 +1006,6 @@ def _build_telegram_messages(
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
def _build_telegram_messages_mispricing(
city_weather: Dict[str, Any],
rules: Dict[str, Dict[str, Any]],
market_snapshot: Optional[Dict[str, Any]] = None,
suppression: Optional[Dict[str, Any]] = None,
map_url: Optional[str] = None,
) -> Dict[str, str]:
temp_symbol = str(city_weather.get("temp_symbol") or "°C")
city_name = city_weather.get("display_name") or city_weather.get("name", "").title()
current = city_weather.get("current") or {}
current_temp = _sf(current.get("temp"))
if current_temp is None:
return {"zh": "", "en": ""}
snapshot = market_snapshot or _extract_market_snapshot(city_weather)
momentum = rules.get("momentum_spike", {})
center_deb = rules.get("ankara_center_deb_hit", {})
signal_state = _classify_low_yes_signal(rules, snapshot)
local_time = str(city_weather.get("local_time") or "").strip()
obs_time = str(current.get("obs_time") or "").strip()
suppressed = bool((suppression or {}).get("suppressed"))
has_active_trigger = any(rule.get("triggered") for rule in rules.values())
types_cn = "高温已过(暂停推送)" if suppressed else (_join_trigger_types_cn(rules) or "天气状态快照")
delta_temp = _sf(momentum.get("delta_temp"))
delta_min = momentum.get("delta_minutes")
momentum_emoji = "➡️"
if delta_temp is not None:
momentum_emoji = "🚀" if delta_temp > 0 else ("📉" if delta_temp < 0 else "➡️")
dynamic_text = f"实测 {current_temp:.1f}{temp_symbol}"
if delta_temp is not None and delta_min is not None:
dynamic_text = (
f"实测 {current_temp:.1f}{temp_symbol} "
f"({int(delta_min)}min 内 {delta_temp:+.1f}{temp_symbol}) {momentum_emoji}"
)
anchor_high_c = _sf(snapshot.get("anchor_today_high_c"))
if anchor_high_c is None:
anchor_high_c = _sf(snapshot.get("open_meteo_today_high_c"))
anchor_settle = snapshot.get("anchor_settlement")
if anchor_settle is None:
anchor_settle = snapshot.get("open_meteo_settlement")
anchor_model = str(snapshot.get("anchor_model") or "").strip()
forecast_bucket = snapshot.get("forecast_bucket") or {}
match_bucket_label = str(forecast_bucket.get("label") or "--").strip() or "--"
match_bucket_yes_prob = _norm_probability(forecast_bucket.get("yes_buy"))
match_bucket_yes = (
_fmt_cents(match_bucket_yes_prob)
if match_bucket_yes_prob is not None and match_bucket_yes_prob > 0.0
else "--"
)
market_url = str(
snapshot.get("market_url")
or snapshot.get("primary_market_url")
or ""
).strip()
final_map = map_url or "https://polyweather-pro.vercel.app/"
advice = _build_advice_cn(rules, temp_symbol, suppression=suppression)
center_line = ""
if center_deb.get("triggered"):
center_station = center_deb.get("center_station") or {}
center_name = center_station.get("name") or "Ankara Center"
center_temp = _sf(center_station.get("temp"))
deb_prediction = _sf(center_deb.get("deb_prediction"))
airport_temp = _sf(center_deb.get("airport_temp"))
lead_gap = _sf(center_deb.get("center_lead_vs_airport"))
if center_temp is not None and deb_prediction is not None:
center_line = (
f"Center信号:{center_name} {center_temp:.1f}{temp_symbol} 已达到 DEB {deb_prediction:.1f}{temp_symbol}"
)
if airport_temp is not None:
center_line += f" | 机场 {airport_temp:.1f}{temp_symbol}"
if lead_gap is not None:
center_line += f" | 领先 {lead_gap:+.1f}{temp_symbol}"
if snapshot.get("available") and signal_state.get("should_push"):
title_zh = "🚨 PolyWeather 市场监控"
elif snapshot.get("available"):
title_zh = "📍 PolyWeather 市场观察"
else:
title_zh = "🚨 PolyWeather 市场提醒" if (has_active_trigger or suppressed) else "📍 PolyWeather 状态快照"
lines_zh = [f"{title_zh} [{city_name}]"]
lines_zh.append("")
lines_zh.append(f"类型:{types_cn}")
if anchor_high_c is not None and anchor_settle is not None:
if anchor_model:
lines_zh.append(
f"基准:多模型最高温 {anchor_model} {anchor_high_c:.1f}°C (结算参考 {anchor_settle}°C "
)
else:
lines_zh.append(
f"基准:多模型最高温 {anchor_high_c:.1f}C(结算参考 {anchor_settle}C"
)
else:
lines_zh.append("基准:多模型最高温 --(结算参考 --)")
lines_zh.append(f"命中桶:{match_bucket_label} | Yes: {match_bucket_yes}")
lines_zh.append(f"触发:{signal_state.get('reason_cn')}")
if center_line:
lines_zh.append(center_line)
lines_zh.append("")
lines_zh.append(f"动态:{dynamic_text}")
if local_time or obs_time:
if local_time and obs_time:
lines_zh.append(f"时间:当地 {local_time} | 观测 {obs_time}")
elif local_time:
lines_zh.append(f"时间:当地 {local_time}")
else:
lines_zh.append(f"时间:观测 {obs_time}")
lines_zh.append(f"建议:{advice}")
lines_zh.append("")
if market_url:
lines_zh.append(f"市场链接:{market_url}")
lines_zh.append(f"地图:{final_map}")
title_en = (
"🚨 PolyWeather Market Monitor"
if snapshot.get("available") and signal_state.get("should_push")
else "📍 PolyWeather Market Watch"
if snapshot.get("available")
else "🚨 PolyWeather Alert"
)
lines_en = [
f"{title_en} [{city_name}]",
"",
f"Type: {types_cn}",
f"Now: {dynamic_text}",
]
if market_url:
lines_en.append(f"Market link: {market_url}")
lines_en.append(f"Map: {final_map}")
return {"zh": "\n".join(lines_zh), "en": "\n".join(lines_en)}
def _select_rule_evidence(rule: Dict[str, Any], keys: List[str]) -> Dict[str, Any]:
out: Dict[str, Any] = {}
for key in keys:
@@ -1234,7 +1046,6 @@ def _build_alert_evidence(
trigger_types = [row.get("type") for row in triggered if row.get("type")]
forecast_bucket = market_snapshot.get("forecast_bucket") or {}
low_yes_signal = _classify_low_yes_signal(rules, market_snapshot)
return {
"version": 1,
@@ -1315,7 +1126,11 @@ def _build_alert_evidence(
},
"market": {
"available": bool(market_snapshot.get("available")),
"low_yes_signal": low_yes_signal,
"low_yes_signal": {
"label": "removed",
"should_push": False,
"reason_cn": "mispricing signals have been removed",
},
"market_prob": market_snapshot.get("market_prob"),
"model_prob": market_snapshot.get("model_prob"),
"edge_percent": market_snapshot.get("edge_percent"),
@@ -1358,7 +1173,6 @@ def build_trading_alerts(
city = city_weather.get("name", "")
now = datetime.now(timezone.utc).isoformat()
market_snapshot = _extract_market_snapshot(city_weather)
low_yes_signal = _classify_low_yes_signal({}, {})
rules: Dict[str, Dict[str, Any]] = {
"ankara_center_deb_hit": _calc_ankara_center_deb_alert(city_weather, temp_symbol),
@@ -1366,7 +1180,6 @@ def build_trading_alerts(
"forecast_breakthrough": _calc_forecast_breakthrough_alert(city_weather, temp_symbol),
"advection": _calc_advection_alert(city_weather, temp_symbol),
}
low_yes_signal = _classify_low_yes_signal(rules, market_snapshot)
triggered = [
{
@@ -1396,22 +1209,13 @@ def build_trading_alerts(
if force_push and severity == "none":
severity = "medium"
if market_snapshot.get("available") and low_yes_signal.get("should_push"):
telegram = _build_telegram_messages_mispricing(
city_weather=city_weather,
rules=rules,
market_snapshot=market_snapshot,
suppression=suppression,
map_url=map_url,
)
else:
telegram = _build_telegram_messages(
city_weather=city_weather,
rules=rules,
map_url=map_url,
market_snapshot=market_snapshot,
suppression=suppression,
)
telegram = _build_telegram_messages(
city_weather=city_weather,
rules=rules,
map_url=map_url,
market_snapshot=market_snapshot,
suppression=suppression,
)
evidence = _build_alert_evidence(
city_weather=city_weather,
rules=rules,
+6 -32
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import threading
from typing import Any
from typing import Callable
@@ -252,42 +251,17 @@ class BasicCommandHandler:
if chat_type and chat_type != "private":
self.bot.reply_to(
message,
"️ `/markets` 仅支持私聊机器人查询。\n频道继续接收自动推送;如需手动查看当前市场概览,请私聊 bot 发送 `/markets`。",
"️ `/markets` 仅支持私聊机器人查询。",
parse_mode="Markdown",
)
trace.set_status("blocked", f"unsupported_chat_type:{chat_type}")
return
chat_id = getattr(getattr(message, "chat", None), "id", None)
from src.utils.telegram_push import (
build_market_monitor_digest,
load_cached_market_monitor_digest,
self.bot.reply_to(
message,
"️ 市场概览 (Focus Digest) 功能已移除。\n频道继续接收关键市场警报推送;如需查看当前市场状态,请访问 https://polyweather-pro.vercel.app/",
disable_web_page_preview=True,
)
cached_summary = load_cached_market_monitor_digest()
if cached_summary:
self.bot.reply_to(message, cached_summary, disable_web_page_preview=True)
else:
self.bot.reply_to(message, "⏳ 正在生成当前市场概览,请稍候...")
def _worker() -> None:
try:
summary = build_market_monitor_digest(
self.config,
slot_label="当前市场概览",
force_refresh=False,
)
if not cached_summary or summary.strip() != cached_summary.strip():
self.bot.send_message(chat_id, summary, disable_web_page_preview=True)
except Exception:
if not cached_summary:
self.bot.send_message(chat_id, "❌ 当前市场概览生成失败,请稍后重试。")
threading.Thread(
target=_worker,
name="telegram-markets-manual-query",
daemon=True,
).start()
trace.set_status("accepted")
trace.set_status("ok", "removed")
finally:
trace.emit()
+1 -24
View File
@@ -157,7 +157,7 @@ class StartupCoordinator:
interval = max(60, _env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 1800))
cities_count = _parse_csv_count(os.getenv("TELEGRAM_ALERT_CITIES"))
details = {
"mode": "critical-alerts-and-focus-digest",
"mode": "critical-alerts",
"interval_sec": interval,
"cities_count": cities_count,
"chat_targets": len(chat_ids),
@@ -165,31 +165,8 @@ class StartupCoordinator:
60,
_env_int("TELEGRAM_ALERT_PUSH_COOLDOWN_SEC", 21600),
),
"mispricing_interval_sec": max(
max(60, _env_int("TELEGRAM_ALERT_PUSH_COOLDOWN_SEC", 21600)),
_env_int("TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC", 43200),
),
"min_trigger_count": max(1, _env_int("TELEGRAM_ALERT_MIN_TRIGGER_COUNT", 3)),
"min_severity": str(os.getenv("TELEGRAM_ALERT_MIN_SEVERITY") or "high").strip().lower(),
"mispricing_only": _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True),
"focus_digest_enabled": _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True),
"focus_digest_interval_sec": max(
300,
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 21600),
),
"focus_push_start_local_hour": min(
23,
max(0, _env_int("TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR", 8)),
),
"focus_push_before_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN", 480),
),
"focus_push_after_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", 300),
),
"schedule_basis": "city_local_time_for_push_window",
"daily_signal_cap": "none",
}
validation_error = None if chat_ids else "missing_TELEGRAM_CHAT_IDS"
+5 -612
View File
@@ -234,7 +234,7 @@ def _save_state(path: str, state: Dict[str, Any]) -> None:
def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400) -> None:
for bucket_name in ("by_signature", "focus_digest_slots"):
for bucket_name in ("by_signature",):
bucket = state.get(bucket_name, {})
if not isinstance(bucket, dict):
state[bucket_name] = {}
@@ -256,528 +256,6 @@ def _cleanup_state(state: Dict[str, Any], now_ts: int, keep_sec: int = 7 * 86400
last_by_city.pop(city, None)
def _cache_market_monitor_digest(
state: Dict[str, Any],
*,
message: str,
slot_label: str,
generated_at_ts: Optional[int] = None,
) -> None:
state["last_market_monitor_digest"] = {
"message": str(message or "").strip(),
"slot_label": str(slot_label or "").strip(),
"generated_at_ts": int(generated_at_ts or time.time()),
}
def load_cached_market_monitor_digest() -> str:
state = _load_state(_state_file())
cached = state.get("last_market_monitor_digest") or {}
if not isinstance(cached, dict):
return ""
return str(cached.get("message") or "").strip()
def _minute_of_day(text: Optional[str]) -> Optional[int]:
raw = str(text or "").strip()
if not raw or ":" not in raw:
return None
try:
hour_s, minute_s = raw.split(":", 1)
hour = int(hour_s)
minute = int(minute_s[:2])
except Exception:
return None
if not (0 <= hour <= 23 and 0 <= minute <= 59):
return None
return hour * 60 + minute
def _format_minutes_window(delta_minutes: int) -> str:
total = abs(int(delta_minutes))
hours = total // 60
minutes = total % 60
if hours > 0 and minutes > 0:
text = f"{hours}h{minutes:02d}m"
elif hours > 0:
text = f"{hours}h"
else:
text = f"{minutes}m"
return text
def _format_interval_brief(seconds: int) -> str:
total = max(1, int(seconds))
if total % 3600 == 0:
hours = total // 3600
return f"{hours}小时"
if total % 60 == 0:
minutes = total // 60
return f"{minutes}分钟"
return f"{total}"
def _focus_push_window_config() -> Dict[str, int]:
return {
"start_local_hour": min(
23,
max(0, _env_int("TELEGRAM_MARKET_FOCUS_PUSH_START_LOCAL_HOUR", 8)),
),
"before_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_BEFORE_PEAK_MIN", 480),
),
"after_peak_min": max(
0,
_env_int("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", 300),
),
}
def _focus_push_window_label() -> str:
config = _focus_push_window_config()
return (
f"当地 {config['start_local_hour']:02d}:00 后;"
f"有日内高点参考时,保留高点前 {_format_minutes_window(config['before_peak_min'])} / "
f"高点后 {_format_minutes_window(config['after_peak_min'])}"
)
def _local_peak_context(alert_payload: Dict[str, Any]) -> Dict[str, Any]:
evidence = alert_payload.get("evidence") or {}
generated_local_time = str(evidence.get("generated_local_time") or "").strip()
trigger_summary = evidence.get("trigger_summary") or {}
suppression_snapshot = trigger_summary.get("suppression_snapshot") or {}
peak_time = str(suppression_snapshot.get("max_temp_time") or "").strip()
local_min = _minute_of_day(generated_local_time)
peak_min = _minute_of_day(peak_time)
if local_min is None or peak_min is None:
return {
"local_time": generated_local_time,
"peak_time": peak_time,
"minutes_to_peak": None,
"score_adjustment": 0.0,
"window_label": "",
}
delta = peak_min - local_min
score = 0.0
window_label = ""
if 0 <= delta <= 120:
score = 18.0
window_label = f"峰值前 {_format_minutes_window(delta)}"
elif 120 < delta <= 360:
score = 10.0
window_label = f"距峰值 {_format_minutes_window(delta)}"
elif -90 <= delta < 0:
score = 6.0
window_label = f"峰值后 {_format_minutes_window(delta)}"
elif delta < -90:
score = -8.0
window_label = "峰值已过较久"
return {
"local_time": generated_local_time,
"peak_time": peak_time,
"minutes_to_peak": delta,
"score_adjustment": score,
"window_label": window_label,
}
def _market_monitor_score(alert_payload: Dict[str, Any]) -> float:
severity = str(alert_payload.get("severity") or "none").lower()
severity_score = {"high": 36.0, "medium": 24.0, "none": 0.0}.get(severity, 0.0)
trigger_count = int(alert_payload.get("trigger_count") or 0)
trigger_score = min(18.0, float(trigger_count) * 9.0)
snapshot = alert_payload.get("market_snapshot") or {}
if not isinstance(snapshot, dict):
snapshot = {}
if not snapshot.get("available"):
return 0.0
edge_percent = abs(_safe_float(snapshot.get("edge_percent")) or 0.0)
edge_score = min(22.0, edge_percent * 2.5)
yes_buy = _norm_prob(snapshot.get("yes_buy"))
if yes_buy is None:
forecast_bucket = snapshot.get("forecast_bucket") or {}
if isinstance(forecast_bucket, dict):
yes_buy = _norm_prob(forecast_bucket.get("yes_buy"))
pricing_score = 0.0
if yes_buy is not None:
if yes_buy < 0.10:
pricing_score = 14.0
elif yes_buy < 0.20:
pricing_score = 9.0
elif yes_buy < 0.35:
pricing_score = 5.0
confidence = str(snapshot.get("confidence") or "").strip().lower()
confidence_score = {"high": 10.0, "medium": 6.0, "low": 2.0}.get(confidence, 0.0)
suppression = alert_payload.get("suppression") or {}
suppressed_penalty = -20.0 if bool(suppression.get("suppressed")) else 0.0
peak_context = _local_peak_context(alert_payload)
return max(
0.0,
severity_score
+ trigger_score
+ edge_score
+ pricing_score
+ confidence_score
+ suppressed_penalty
+ float(peak_context.get("score_adjustment") or 0.0),
)
def _priority_label(score: float) -> str:
if score >= 72:
return "高优先级"
if score >= 48:
return "重点观察"
return "继续观察"
def _join_trigger_types_cn_local(rules: Dict[str, Dict[str, Any]]) -> str:
label_map = {
"ankara_center_deb_hit": "中心站触及 DEB",
"momentum_spike": "短时动量异动",
"forecast_breakthrough": "实测击穿模型",
"advection": "暖平流信号",
}
parts: List[str] = []
for key, label in label_map.items():
row = rules.get(key) or {}
if row.get("triggered"):
parts.append(label)
return " + ".join(parts)
def _focus_trigger_summary(alert_payload: Dict[str, Any]) -> str:
rules = alert_payload.get("rules") or {}
if not isinstance(rules, dict):
return "市场与天气分歧待观察"
return _join_trigger_types_cn_local(rules) or "市场与天气分歧待观察"
def _market_signal_brief(snapshot: Dict[str, Any]) -> str:
if not isinstance(snapshot, dict):
return ""
signal = str(snapshot.get("signal_label") or "").strip().upper()
signal_label = {
"BUY YES": "方向 BUY YES",
"BUY NO": "方向 BUY NO",
"MONITOR": "方向 MONITOR",
}.get(signal, f"方向 {signal}" if signal else "")
edge = _safe_float(snapshot.get("edge_percent"))
confidence = str(snapshot.get("confidence") or "").strip()
forecast_bucket = snapshot.get("forecast_bucket") or {}
if not isinstance(forecast_bucket, dict):
forecast_bucket = {}
yes_buy = _fmt_cents(forecast_bucket.get("yes_buy"))
yes_sell = _fmt_cents(forecast_bucket.get("yes_sell"))
parts: List[str] = []
if signal_label:
parts.append(signal_label)
if edge is not None:
parts.append(f"edge {edge:+.1f}%")
if confidence:
parts.append(f"置信 {confidence}")
if yes_buy:
parts.append(f"Yes买 {yes_buy}")
if yes_sell:
parts.append(f"Yes卖 {yes_sell}")
return " | ".join(parts)
def _shortlist_focus_payloads(
payloads: List[Dict[str, Any]],
*,
top_n: int,
for_push: bool = False,
) -> List[Dict[str, Any]]:
ranked = sorted(
payloads,
key=lambda item: _market_monitor_score(item),
reverse=True,
)
shortlisted: List[Dict[str, Any]] = []
for item in ranked:
if not bool((item.get("market_snapshot") or {}).get("available")):
continue
if not _market_price_cap_ok(item, require_actionable_quote=True):
continue
if for_push:
window_config = _focus_push_window_config()
peak_context = _local_peak_context(item)
local_min = _minute_of_day(peak_context.get("local_time"))
minutes_to_peak = peak_context.get("minutes_to_peak")
if local_min is not None and local_min < window_config["start_local_hour"] * 60:
continue
if isinstance(minutes_to_peak, (int, float)):
if minutes_to_peak > window_config["before_peak_min"]:
continue
if minutes_to_peak < -window_config["after_peak_min"]:
continue
shortlisted.append(item)
if len(shortlisted) >= top_n:
break
return shortlisted
def _build_focus_digest_message(
payloads: List[Dict[str, Any]],
*,
slot_label: str,
top_n: int,
) -> str:
scan_interval = _format_interval_brief(_env_int("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", 300))
digest_interval = _format_interval_brief(
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 1800),
)
shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=True)
if not shortlisted:
return ""
lines = [
f"🌐 PolyWeather 市场监控 · {slot_label}",
"",
]
for idx, payload in enumerate(shortlisted, start=1):
city = str(payload.get("city") or "").strip().lower()
city_name = (CITY_REGISTRY.get(city) or {}).get("display_name") or city.title() or "--"
snapshot = payload.get("market_snapshot") or {}
evidence = payload.get("evidence") or {}
inputs = evidence.get("inputs") or {}
bucket = str(
(snapshot.get("forecast_bucket") or {}).get("label")
or snapshot.get("top_bucket")
or "--"
).strip()
current_temp = _safe_float(inputs.get("current_temp"))
deb_prediction = _safe_float(inputs.get("deb_prediction"))
market_url = str(snapshot.get("market_url") or snapshot.get("primary_market_url") or "").strip()
peak_context = _local_peak_context(payload)
score = _market_monitor_score(payload)
lines.append(f"{idx}. {city_name} | {_priority_label(score)}")
lines.append(" " + f"关注桶 {bucket}")
signal_brief = _market_signal_brief(snapshot)
if signal_brief:
lines.append(" " + signal_brief)
local_time = str(peak_context.get("local_time") or "").strip()
peak_time = str(peak_context.get("peak_time") or "").strip()
window_label = str(peak_context.get("window_label") or "").strip()
if local_time or peak_time or window_label:
context_parts: List[str] = []
if local_time:
context_parts.append(f"当地 {local_time}")
if peak_time:
context_parts.append(f"日内高点参考 {peak_time}")
if window_label:
context_parts.append(window_label)
lines.append(" " + " | ".join(context_parts))
if current_temp is not None or deb_prediction is not None:
lines.append(
" "
+ (f"实测 {current_temp:.1f}°C" if current_temp is not None else "实测 --")
+ " | "
+ (
f"DEB 预报 {deb_prediction:.1f}°C"
if deb_prediction is not None
else "DEB 预报 --"
)
)
lines.append(f" 触发:{_focus_trigger_summary(payload)}")
if market_url:
lines.append(f" 链接:{market_url}")
lines.append("")
frequency_parts = [
f"后台扫描:约每{scan_interval}一次",
f"主动推送:约每{digest_interval}一次",
f"观察窗口:{_focus_push_window_label()}",
"没有每日信号次数上限",
]
lines.append("更新频率:" + "".join(frequency_parts))
return "\n".join(lines).strip()
def _maybe_send_focus_digest(
bot: Any,
chat_ids: List[str],
payloads: List[Dict[str, Any]],
state: Dict[str, Any],
*,
digest_interval_sec: int,
top_n: int,
) -> bool:
if not chat_ids or not payloads or digest_interval_sec <= 0:
logger.info(
"market focus digest skipped reason=invalid_runtime chat_targets={} payloads={} interval_sec={}",
len(chat_ids),
len(payloads),
digest_interval_sec,
)
return False
now_ts = int(time.time())
last_digest_ts = int(state.get("last_focus_digest_ts") or 0)
available_count = sum(
1 for item in payloads
if bool((item.get("market_snapshot") or {}).get("available"))
)
actionable_count = sum(
1 for item in payloads
if bool((item.get("market_snapshot") or {}).get("available"))
and _market_price_cap_ok(item, require_actionable_quote=True)
)
shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=True)
reference_shortlisted = _shortlist_focus_payloads(payloads, top_n=top_n, for_push=False)
high_priority_count = sum(1 for item in shortlisted if _market_monitor_score(item) >= 72)
logger.info(
"market focus digest evaluate payloads={} available={} actionable={} shortlisted={} reference_shortlisted={} high_priority={} interval_sec={} top_n={} push_window={}",
len(payloads),
available_count,
actionable_count,
len(shortlisted),
len(reference_shortlisted),
high_priority_count,
digest_interval_sec,
top_n,
_focus_push_window_label(),
)
if last_digest_ts and now_ts - last_digest_ts < digest_interval_sec:
logger.info(
"market focus digest skipped reason=cooldown elapsed_sec={} required_sec={} shortlisted={} high_priority={}",
now_ts - last_digest_ts,
digest_interval_sec,
len(shortlisted),
high_priority_count,
)
return False
if not shortlisted:
logger.info(
"market focus digest skipped reason=no_candidates payloads={} available={} actionable={} top_n={}",
len(payloads),
available_count,
actionable_count,
top_n,
)
return False
# Tighten channel pushes: require either multiple candidates or one truly high-priority market.
if len(shortlisted) < 2 and high_priority_count < 1:
first_city = str(shortlisted[0].get("city") or "--") if shortlisted else "--"
first_score = _market_monitor_score(shortlisted[0]) if shortlisted else 0
logger.info(
"market focus digest skipped reason=too_few_candidates shortlisted={} high_priority={} first_city={} first_score={}",
len(shortlisted),
high_priority_count,
first_city,
first_score,
)
return False
local_now = datetime.now().astimezone()
hour = local_now.hour
if 6 <= hour < 15:
slot_label = "白天关注"
elif 15 <= hour < 23:
slot_label = "今晚关注"
else:
slot_label = "夜间关注"
message = _build_focus_digest_message(
shortlisted,
slot_label=slot_label,
top_n=top_n,
)
if not message:
logger.info(
"market focus digest skipped reason=empty_message shortlisted={} slot_label={}",
len(shortlisted),
slot_label,
)
return False
_cache_market_monitor_digest(
state,
message=message,
slot_label=slot_label,
)
sent_count = 0
for chat_id in chat_ids:
try:
bot.send_message(chat_id, message, disable_web_page_preview=True)
sent_count += 1
except Exception as exc:
logger.warning(
"market focus digest push failed interval_sec={} chat_id={} error={}",
digest_interval_sec,
chat_id,
exc,
)
if sent_count <= 0:
return False
state["last_focus_digest_ts"] = now_ts
logger.info(
"market focus digest pushed interval_sec={} shortlisted={} payloads={} chat_targets={} slot_label={}",
digest_interval_sec,
len(shortlisted),
len(payloads),
sent_count,
slot_label,
)
return True
def build_market_monitor_digest(
config: Dict[str, Any],
*,
slot_label: str = "当前概览",
top_n: Optional[int] = None,
force_refresh: bool = False,
) -> str:
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
digest_top_n = top_n if top_n is not None else max(
3,
min(8, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N", 5)),
)
payloads: List[Dict[str, Any]] = []
for city in cities:
try:
payloads.append(build_trade_alert_for_city(city, config, force_refresh=force_refresh))
except Exception as exc:
logger.warning("market monitor digest build skipped city={} error={}", city, exc)
message = _build_focus_digest_message(
payloads,
slot_label=slot_label,
top_n=digest_top_n,
)
if message:
state = _load_state(_state_file())
_cache_market_monitor_digest(
state,
message=message,
slot_label=slot_label,
)
_save_state(_state_file(), state)
return message
return "️ 当前没有可用的市场监控摘要。"
def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_count: int) -> bool:
triggered_alerts = alert_payload.get("triggered_alerts") or []
if any(alert.get("force_push") for alert in triggered_alerts):
@@ -792,17 +270,9 @@ def _severity_ok(alert_payload: Dict[str, Any], min_severity: str, min_trigger_c
def _market_price_cap_ok(
alert_payload: Dict[str, Any],
require_actionable_quote: bool = False,
) -> bool:
market = alert_payload.get("market_snapshot") or {}
if not isinstance(market, dict) or not market.get("available"):
if require_actionable_quote:
logger.info(
"trade alert skipped: market snapshot unavailable city={}".format(
alert_payload.get("city"),
)
)
return False
return True
primary_market = market.get("primary_market") or {}
@@ -1057,18 +527,12 @@ def _maybe_send_alert(
cooldown_sec: int,
min_severity: str,
min_trigger_count: int,
mispricing_only: bool,
) -> bool:
now_ts = int(time.time())
last_by_city = state.setdefault("last_by_city", {})
last_city = last_by_city.get(city) or {}
is_active = _severity_ok(alert_payload, min_severity, min_trigger_count)
if mispricing_only and not _is_market_mispricing_signal(alert_payload):
is_active = False
if not _market_price_cap_ok(
alert_payload,
require_actionable_quote=mispricing_only,
):
if not _market_price_cap_ok(alert_payload):
is_active = False
message = ((alert_payload.get("telegram") or {}).get("zh") or "").strip()
@@ -1129,27 +593,6 @@ def _maybe_send_alert(
return True
def _is_market_mispricing_signal(alert_payload: Dict[str, Any]) -> bool:
market = alert_payload.get("market_snapshot") or {}
if not isinstance(market, dict) or not market.get("available"):
return False
evidence = alert_payload.get("evidence") or {}
evidence_market = evidence.get("market") if isinstance(evidence, dict) else {}
low_yes_signal = (
evidence_market.get("low_yes_signal")
if isinstance(evidence_market, dict)
else None
)
if isinstance(low_yes_signal, dict) and bool(low_yes_signal.get("should_push")):
return True
signal = str(market.get("signal_label") or "").strip().upper()
if signal in {"BUY YES", "BUY NO"}:
return True
return False
def _run_market_monitor_cycle(
bot: Any,
config: Dict[str, Any],
@@ -1157,64 +600,32 @@ def _run_market_monitor_cycle(
chat_ids: List[str],
cities: List[str],
state: Dict[str, Any],
focus_digest_enabled: bool,
focus_digest_interval_sec: int,
focus_digest_top_n: int,
alert_cooldown_sec: int,
mispricing_interval_sec: int,
min_severity: str,
min_trigger_count: int,
mispricing_only: bool,
sleep_between_cities_sec: float = 1.0,
) -> bool:
state_dirty = False
cycle_payloads: List[Dict[str, Any]] = []
critical_pushed_cities: set[str] = set()
for city in cities:
try:
alert_payload = build_trade_alert_for_city(city, config)
cycle_payloads.append(alert_payload)
is_mispricing = _is_market_mispricing_signal(alert_payload)
cooldown_sec = mispricing_interval_sec if is_mispricing else alert_cooldown_sec
if _maybe_send_alert(
bot=bot,
chat_ids=chat_ids,
city=city,
alert_payload=alert_payload,
state=state,
cooldown_sec=cooldown_sec,
cooldown_sec=alert_cooldown_sec,
min_severity=min_severity,
min_trigger_count=min_trigger_count,
mispricing_only=mispricing_only,
):
state_dirty = True
critical_pushed_cities.add(str(city).strip().lower())
except Exception:
logger.exception(f"telegram market monitor loop failed for city={city}")
if sleep_between_cities_sec > 0:
time.sleep(sleep_between_cities_sec)
if focus_digest_enabled:
try:
digest_payloads = [
payload
for payload in cycle_payloads
if str(payload.get("city") or "").strip().lower()
not in critical_pushed_cities
]
if _maybe_send_focus_digest(
bot=bot,
chat_ids=chat_ids,
payloads=digest_payloads,
state=state,
digest_interval_sec=focus_digest_interval_sec,
top_n=focus_digest_top_n,
):
state_dirty = True
except Exception:
logger.exception("failed to push market focus digest")
return state_dirty
@@ -1232,33 +643,20 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
cities = _parse_city_list(os.getenv("TELEGRAM_ALERT_CITIES"))
state_path = _state_file()
alert_cooldown_sec = max(60, _env_int("TELEGRAM_ALERT_PUSH_COOLDOWN_SEC", 21600))
mispricing_interval_sec = max(
alert_cooldown_sec,
_env_int("TELEGRAM_ALERT_MISPRICING_INTERVAL_SEC", 43200),
)
min_trigger_count = max(1, _env_int("TELEGRAM_ALERT_MIN_TRIGGER_COUNT", 3))
min_severity = str(os.getenv("TELEGRAM_ALERT_MIN_SEVERITY") or "high").strip().lower()
if min_severity not in SEVERITY_RANK:
min_severity = "high"
mispricing_only = _env_bool("TELEGRAM_ALERT_MISPRICING_ONLY", True)
focus_digest_enabled = _env_bool("TELEGRAM_MARKET_FOCUS_DIGEST_ENABLED", True)
focus_digest_top_n = max(3, min(8, _env_int("TELEGRAM_MARKET_FOCUS_DIGEST_TOP_N", 5)))
focus_digest_interval_sec = max(
300,
_env_int("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", 21600),
)
def _runner() -> None:
try:
_save_state(state_path, _load_state(state_path))
except Exception:
logger.exception(f"failed to initialize market monitor state path={state_path}")
logger.info(
f"telegram market monitor loop started mode=critical-alerts-and-focus-digest "
f"telegram market monitor loop started mode=critical-alerts "
f"cities={len(cities)} interval={interval_sec}s chat_targets={len(chat_ids)} "
f"alert_cooldown={alert_cooldown_sec}s mispricing_interval={mispricing_interval_sec}s "
f"alert_cooldown={alert_cooldown_sec}s "
f"min_severity={min_severity} min_trigger_count={min_trigger_count} "
f"mispricing_only={mispricing_only} "
f"focus_digest_enabled={focus_digest_enabled} focus_interval={focus_digest_interval_sec}s "
f"state_path={state_path}"
)
while True:
@@ -1271,14 +669,9 @@ def start_trade_alert_push_loop(bot: Any, config: Dict[str, Any]) -> Optional[th
chat_ids=chat_ids,
cities=cities,
state=state,
focus_digest_enabled=focus_digest_enabled,
focus_digest_interval_sec=focus_digest_interval_sec,
focus_digest_top_n=focus_digest_top_n,
alert_cooldown_sec=alert_cooldown_sec,
mispricing_interval_sec=mispricing_interval_sec,
min_severity=min_severity,
min_trigger_count=min_trigger_count,
mispricing_only=mispricing_only,
):
_save_state(state_path, state)
+3 -17
View File
@@ -76,7 +76,7 @@ def test_basic_handler_diag_returns_html():
assert "Bot 启动诊断" in bot.replies[0]["text"]
def test_basic_handler_markets_returns_summary(monkeypatch):
def test_basic_handler_markets_returns_summary():
bot = DummyBot()
io_layer = SimpleNamespace(
build_welcome_text=lambda: "WELCOME",
@@ -95,25 +95,11 @@ def test_basic_handler_markets_returns_summary(monkeypatch):
config={},
)
monkeypatch.setattr(
"src.utils.telegram_push.build_market_monitor_digest",
lambda config, slot_label="当前概览", top_n=None, force_refresh=False: "MARKET DIGEST",
)
monkeypatch.setattr(
"src.utils.telegram_push.load_cached_market_monitor_digest",
lambda: "",
)
monkeypatch.setattr(
"src.bot.handlers.basic.threading.Thread",
lambda target, name=None, daemon=None: SimpleNamespace(start=target),
)
handler.handle_markets(_message("/markets"))
assert len(bot.replies) == 1
assert "正在生成当前市场概览" in bot.replies[0]["text"]
assert len(bot.sent_messages) == 1
assert bot.sent_messages[0]["text"] == "MARKET DIGEST"
assert "市场概览" in bot.replies[0]["text"]
assert "已移除" in bot.replies[0]["text"]
def test_basic_handler_markets_rejects_channel_chat():
+1 -165
View File
@@ -1,10 +1,5 @@
from src.analysis.market_alert_engine import build_trading_alerts
from src.utils.telegram_push import (
_build_focus_digest_message,
_run_market_monitor_cycle,
_shortlist_focus_payloads,
build_market_monitor_digest,
)
from src.utils.telegram_push import _run_market_monitor_cycle
def _sample_weather_payload():
@@ -181,27 +176,6 @@ def test_peak_passed_guard_suppresses_late_day_cooldown_alerts():
assert "暂停主动推送" in out["telegram"]["zh"]
def test_market_monitor_digest_skips_non_tradable_market(monkeypatch):
payload = build_trading_alerts(
city_weather=_sample_weather_payload(),
map_url="https://example.com/map",
)
payload["market_snapshot"]["available"] = True
payload["market_snapshot"]["market_closed"] = True
payload["market_snapshot"]["market_tradable"] = False
payload["market_snapshot"]["market_accepting_orders"] = False
payload["market_snapshot"]["market_tradable_reason"] = "in_review"
monkeypatch.setattr(
"src.utils.telegram_push.build_trade_alert_for_city",
lambda city, config, force_refresh=False: payload,
)
monkeypatch.setenv("TELEGRAM_ALERT_CITIES", "ankara")
digest = build_market_monitor_digest({}, slot_label="当前市场概览")
assert digest == "️ 当前没有可用的市场监控摘要。"
class _DigestBot:
def __init__(self):
self.messages = []
@@ -285,95 +259,6 @@ def _focus_payload(
}
def test_focus_digest_push_window_does_not_cut_off_after_2pm_spanish_time(monkeypatch):
monkeypatch.delenv("TELEGRAM_MARKET_FOCUS_PUSH_AFTER_PEAK_MIN", raising=False)
shortlisted = _shortlist_focus_payloads(
[_focus_payload(local_time="14:00", peak_time="12:00")],
top_n=5,
for_push=True,
)
assert len(shortlisted) == 1
def test_focus_digest_footer_explains_no_daily_signal_cap(monkeypatch):
monkeypatch.setenv("TELEGRAM_ALERT_PUSH_INTERVAL_SEC", "300")
monkeypatch.setenv("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", "1800")
message = _build_focus_digest_message(
[_focus_payload(local_time="14:00", peak_time="12:00")],
slot_label="白天关注",
top_n=5,
)
assert "没有每日信号次数上限" in message
assert "观察窗口" in message
def test_focus_digest_message_shows_market_direction_and_signed_edge():
message = _build_focus_digest_message(
[
_focus_payload(
city="new york",
signal_label="BUY NO",
edge_percent=-9.25,
)
],
slot_label="白天关注",
top_n=5,
)
assert "方向 BUY NO" in message
assert "edge -9.2%" in message
def test_market_monitor_cycle_sends_digest_after_full_city_scan(monkeypatch):
payloads = {
"early a": _focus_payload(city="early a", edge_percent=4.0, yes_buy=0.2),
"early b": _focus_payload(city="early b", edge_percent=4.0, yes_buy=0.2),
"late best": _focus_payload(
city="late best",
severity="high",
trigger_count=3,
edge_percent=20.0,
signal_label="BUY YES",
yes_buy=0.05,
),
}
monkeypatch.setattr(
"src.utils.telegram_push.build_trade_alert_for_city",
lambda city, config: payloads[city],
)
monkeypatch.setenv("TELEGRAM_MARKET_FOCUS_DIGEST_INTERVAL_SEC", "1800")
bot = _DigestBot()
state = {}
dirty = _run_market_monitor_cycle(
bot=bot,
config={},
chat_ids=["chat"],
cities=["early a", "early b", "late best"],
state=state,
focus_digest_enabled=True,
focus_digest_interval_sec=1800,
focus_digest_top_n=3,
alert_cooldown_sec=1800,
mispricing_interval_sec=7200,
min_severity="high",
min_trigger_count=99,
mispricing_only=True,
sleep_between_cities_sec=0,
)
assert dirty is True
assert len(bot.messages) == 1
assert "Late Best" in bot.messages[0]["text"]
assert "Early A" in bot.messages[0]["text"]
assert state.get("last_focus_digest_ts")
def test_market_monitor_cycle_restores_critical_alert_push(monkeypatch):
payload = _focus_payload(
city="ankara",
@@ -396,14 +281,9 @@ def test_market_monitor_cycle_restores_critical_alert_push(monkeypatch):
chat_ids=["chat"],
cities=["ankara"],
state=state,
focus_digest_enabled=False,
focus_digest_interval_sec=1800,
focus_digest_top_n=5,
alert_cooldown_sec=1800,
mispricing_interval_sec=7200,
min_severity="medium",
min_trigger_count=2,
mispricing_only=True,
sleep_between_cities_sec=0,
)
@@ -412,47 +292,3 @@ def test_market_monitor_cycle_restores_critical_alert_push(monkeypatch):
assert state["last_by_city"]["ankara"]["active"] is True
def test_market_monitor_cycle_excludes_critical_alert_city_from_digest(monkeypatch):
payloads = {
"critical": _focus_payload(
city="critical",
severity="high",
trigger_count=2,
signal_label="BUY YES",
yes_buy=0.05,
edge_percent=15.0,
),
"digest a": _focus_payload(city="digest a", edge_percent=5.0, yes_buy=0.18),
"digest b": _focus_payload(city="digest b", edge_percent=4.0, yes_buy=0.2),
}
monkeypatch.setattr(
"src.utils.telegram_push.build_trade_alert_for_city",
lambda city, config: payloads[city],
)
bot = _DigestBot()
state = {}
dirty = _run_market_monitor_cycle(
bot=bot,
config={},
chat_ids=["chat"],
cities=["critical", "digest a", "digest b"],
state=state,
focus_digest_enabled=True,
focus_digest_interval_sec=1800,
focus_digest_top_n=3,
alert_cooldown_sec=1800,
mispricing_interval_sec=7200,
min_severity="medium",
min_trigger_count=2,
mispricing_only=True,
sleep_between_cities_sec=0,
)
assert dirty is True
assert len(bot.messages) == 2
assert bot.messages[0]["text"] == "CRITICAL critical"
digest_text = bot.messages[1]["text"]
assert "Critical" not in digest_text
assert "Digest A" in digest_text
assert "Digest B" in digest_text
+5
View File
@@ -635,6 +635,11 @@ def stream_scan_city_ai_forecast_payload(
"final_judgment_en": preview_raw.get("final_judgment_en"),
"model_cluster_note_zh": preview_raw.get("model_cluster_note_zh"),
"model_cluster_note_en": preview_raw.get("model_cluster_note_en"),
"predicted_max": preview_raw.get("predicted_max"),
"range_low": preview_raw.get("range_low"),
"range_high": preview_raw.get("range_high"),
"confidence": preview_raw.get("confidence"),
"unit": preview_raw.get("unit"),
},
)
yield _sse_event(