Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 32019c89f5 | |||
| 41acb2aa51 | |||
| ca0b25d84f | |||
| 00e1845f2c | |||
| f47de115c8 | |||
| 3e14a23e02 | |||
| 2183c0399d | |||
| 30faac989c | |||
| 0f9469f3cb | |||
| 734462a3b9 | |||
| a7421b3147 | |||
| 30a6ff7986 |
@@ -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=
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,24 @@
|
||||
# Changelog
|
||||
|
||||
## 1.6.0 - 2026-05-10
|
||||
|
||||
- 全面修复前端 UI 设计审查 15 项问题:消除工程债务、统一 token 体系、提升可维护性
|
||||
- CSS 架构:消除 !important 滥用(134→49,仅保留 Leaflet/图表所必需项)、浅色主题重构为 `html.light` 选择器体系
|
||||
- 统一断点体系:18→10(480/640/768/960/1024/1200/1280/1360/1440/1680),对齐 Tailwind 标准
|
||||
- CSS 变量迁移:10 个文件中数百处硬编码颜色(#4DA3FF/#E6EDF3/#9FB2C7/#6B7A90)替换为 token 变量
|
||||
- 字体系统修复:13 个文件中所有非标准 font-weight(760/850/860/880/950)映射为 Inter 支持值
|
||||
- 移除未加载的 Geist 字体声明、提升文字对比度 #6B7A90→#7D8FA3
|
||||
- 修复 accent-green 类错误渲染为蓝色、accent-primary 与 accent-secondary 相同值问题
|
||||
- 创建 scan-root-styles.ts 桶文件,将 22 个 CSS Module 导入合并为 1 个
|
||||
- 添加全局 :focus-visible 轮廓环、跳过链接、Tab ARIA 属性
|
||||
- 添加统一的 empty/error/retry 状态组件、prefers-reduced-motion 支持
|
||||
- 去重 @keyframes:spin 4→1、loading-spin 2→0、pulse-pending 移至全局
|
||||
- 添加 CSS 渐变品牌 Logo、按钮层级文档化
|
||||
- 移除 dead code(1,697 行):public/static/style.css + public/legacy/index.html
|
||||
- Dashboard.module.css 本地变量桥接至全局 token
|
||||
- 清理冗余文档:移除 FRONTEND_REDESIGN_REPORT.md、TECH_DEBT.md 重复文件、AGENTS.md
|
||||
- 参考:docs/frontend-ui-design-review.md 完整修复记录
|
||||
|
||||
## 1.5.5 - 2026-04-27
|
||||
|
||||
- Dashboard 新增 v1.5.5 升级公告,提示所有会员已额外延长 7 天,并集中说明 DeepSeek 机场报文解读、日历行动视图、本地时间峰值窗口和 AI 证据护栏
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
# 前端交付与重构报告(v1.5.1)
|
||||
|
||||
最后更新:`2026-03-14`
|
||||
|
||||
## 1. 报告目的
|
||||
|
||||
说明当前线上前端(`frontend/`)在收费阶段的实际交付状态。
|
||||
|
||||
## 2. 当前前端架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
B["Browser"] --> N["Next.js App Router (Vercel)"]
|
||||
N --> RH["Route Handlers /api/*"]
|
||||
RH --> F["FastAPI (VPS)"]
|
||||
|
||||
N --> STORE["Dashboard Store"]
|
||||
STORE --> MAP["MapCanvas"]
|
||||
STORE --> SIDEBAR["CitySidebar"]
|
||||
STORE --> PANEL["DetailPanel + Modal"]
|
||||
STORE --> ACCOUNT["Account Center + Pro Overlay"]
|
||||
```
|
||||
|
||||
## 3. 已落地能力
|
||||
|
||||
### 3.1 信息架构与交互
|
||||
|
||||
- 风险分组侧栏折叠(持久化)。
|
||||
- 选中城市状态持久化。
|
||||
- 今日分析、历史对账、未来日期分析联动。
|
||||
|
||||
### 3.2 收费相关
|
||||
|
||||
- 账户中心(登录态、积分、订阅状态、钱包管理)。
|
||||
- Pro 解锁浮层(套餐、积分抵扣、FAQ、社群入口)。
|
||||
- 钱包绑定:浏览器扩展钱包 + WalletConnect 扫码。
|
||||
- 支付流程:create intent -> submit -> confirm。
|
||||
- `confirm pending` 时自动轮询 intent 状态,确认后自动刷新订阅态。
|
||||
|
||||
### 3.3 缓存与性能
|
||||
|
||||
- BFF `ETag/304`:`cities` / `summary` / `history`。
|
||||
- `summary?force_refresh=true` => `no-store`。
|
||||
- `sessionStorage` + in-flight 去重。
|
||||
- `localStorage`:选中城市、侧栏折叠状态。
|
||||
|
||||
### 3.4 可访问性与稳定性
|
||||
|
||||
- 详情面板 `inert + blur` 焦点冲突修复。
|
||||
- 关键支付错误文案标准化(用户取消、gas 不足、pending)。
|
||||
|
||||
## 4. 当前明确未做
|
||||
|
||||
- 离线能力(Service Worker / IndexedDB)
|
||||
- 前端级财务报表与退款后台(后端/运营侧)
|
||||
|
||||
## 5. 验收建议
|
||||
|
||||
### 5.1 前端构建
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run build
|
||||
```
|
||||
|
||||
### 5.2 缓存验收
|
||||
|
||||
```bash
|
||||
./scripts/validate_frontend_cache.sh "https://polyweather-pro.vercel.app"
|
||||
```
|
||||
|
||||
### 5.3 支付验收
|
||||
|
||||
- 绑定钱包
|
||||
- 创建 intent
|
||||
- 发交易
|
||||
- 验证 `intent` 状态从 `submitted -> confirmed`
|
||||
- 校验账户页订阅状态更新
|
||||
|
||||
## 6. 结论
|
||||
|
||||
前端已具备收费阶段的核心能力(账户、支付、权限展示、状态回收),可支持持续商业迭代。
|
||||
@@ -17,6 +17,10 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
|
||||

|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#yangyuan-zhen/PolyWeather&Date)
|
||||
|
||||
## Product Status (2026-04-27)
|
||||
|
||||
- Subscription live: `Pro Monthly 5 USDC`.
|
||||
@@ -44,6 +48,7 @@ Public docs center: `/docs/intro` on the main site (bilingual product documentat
|
||||
- `/ops` now exposes cache bucket counts, summary cache hit / miss rate, and prewarm runtime heartbeat.
|
||||
- Intraday commentary can optionally use `Groq` as a bilingual rewrite layer, while rule-based commentary remains the fallback.
|
||||
- Vercel frontend guidance now includes cost controls for analytics, eager fetches, and edge-side scanner blocking.
|
||||
- Frontend design system overhauled: unified CSS token system, eliminated `!important` abuse (68→6 in light theme), consolidated breakpoints (18→10), migrated hardcoded colors to CSS variables, added ARIA attributes and focus-visible keyboard navigation. See `docs/frontend-ui-design-review.md` for the full audit trail.
|
||||
|
||||
## License & Commercial Boundary
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
## 许可证与商用边界(重要)
|
||||
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
# 技术债与工程待办(v1.5.4)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["技术债"]
|
||||
|
||||
subgraph P["支付与订阅"]
|
||||
P1["合约 V2 升级(SafeERC20 / Pausable)"]
|
||||
P2["退款与工单流程"]
|
||||
P3["多 RPC 与链上对账面板"]
|
||||
end
|
||||
|
||||
subgraph E["权限与运营"]
|
||||
E1["前后端/Bot 权限矩阵回归"]
|
||||
E2["积分来源明细与补分审计"]
|
||||
end
|
||||
|
||||
subgraph O["可观测性"]
|
||||
O1["外部监控抓取与告警阈值"]
|
||||
O2["业务监控看板"]
|
||||
end
|
||||
|
||||
subgraph S["状态与概率"]
|
||||
S1["EMOS 本地训练与 shadow 发布门禁"]
|
||||
end
|
||||
|
||||
A --> P
|
||||
A --> E
|
||||
A --> O
|
||||
A --> S
|
||||
```
|
||||
|
||||
## 2. 近期已关闭
|
||||
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
- 支付运行态 API 与 SQLite 审计事件已补齐。
|
||||
- 钱包绑定支持浏览器钱包 + WalletConnect。
|
||||
- 账户中心与 Pro 权限展示链路打通。
|
||||
- 钱包异动支持独立频道路由。
|
||||
- 运行态状态/缓存与核心离线训练、评估、回填链路已完成 SQLite 主路径收口。
|
||||
- 轻量可观测性已上线(`/healthz`、`/api/system/status`、`/metrics`)。
|
||||
- EMOS/CRPS 校准链路已接通;生产主概率保持 `legacy` 或 `emos_shadow`,`emos_primary` 只允许本地训练通过门禁后人工灰度。
|
||||
|
||||
## 3. 高优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| EMOS 发布门禁 | 低配 VPS 不适合训练,主概率不能绕过评估 | 本地拉生产 SQLite 训练,`ready_for_promotion=true` 后先 `emos_shadow` |
|
||||
| 外部监控与告警 | 只有轻量指标,无外部抓取 | 接 Prometheus/Grafana 或最小巡检 |
|
||||
| 退款与售后链路 | 商业闭环不完整 | 增加退款状态机与工单系统 |
|
||||
|
||||
## 4. 中优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 积分发放可解释性 | 用户理解成本高 | 输出积分来源明细(发言/奖励/手动补分) |
|
||||
| 支付合约 V2 升级 | 当前仍是最小可用合约 | 升级到 SafeERC20 + Pausable + plan 绑定 |
|
||||
| 支付失败文案标准化 | 转化率受影响 | 建立错误码 -> 文案映射表 |
|
||||
|
||||
## 5. 低优先级技术债
|
||||
|
||||
| 项目 | 影响 | 建议动作 |
|
||||
| :-- | :-- | :-- |
|
||||
| 前端离线缓存能力 | 非核心 | 评估 Service Worker + IndexedDB |
|
||||
| 冷启动波动 | 首屏抖动 | 热点城市预热 |
|
||||
|
||||
## 6. 下阶段里程碑
|
||||
|
||||
1. 固化 EMOS 本地训练流程,禁止低配 VPS 自动训练和自动主用。
|
||||
2. 补外部监控抓取与告警阈值。
|
||||
3. 评估并推进支付合约 V2 升级。
|
||||
@@ -1,12 +1,12 @@
|
||||
# 技术债与工程待办(v1.5.4)
|
||||
# 技术债与工程待办(v1.6.0)
|
||||
|
||||
最后更新:`2026-04-19`
|
||||
最后更新:`2026-05-10`
|
||||
|
||||
目标:在收费上线后,优先保证状态一致性、支付可靠性、可观测性和概率引擎发布可控。
|
||||
|
||||
## 1. 债务快照
|
||||
|
||||
当前估计:**95% 稳定 / 5% 技术债**。
|
||||
当前估计:**97% 稳定 / 3% 技术债**。前端设计系统债务已在 v1.6.0 全面消除。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -40,6 +40,7 @@ flowchart TD
|
||||
|
||||
## 2. 近期已关闭
|
||||
|
||||
- **前端设计系统工程债务(2026-05-10)**:消除 !important 滥用(134→49)、统一断点体系(18→10)、数百处硬编码颜色迁移至 CSS 变量、修复 accent-green 蓝色 Bug、添加 ARIA 无障碍属性、去重 @keyframes、移除死代码(1,697 行)。详见 `docs/frontend-ui-design-review.md`。
|
||||
- 支付主链路已上线(intent -> submit -> confirm)。
|
||||
- 支付自动补单已上线(Event Loop + Confirm Loop)。
|
||||
- 支付事件重放脚本已补齐。
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
# PolyWeather Frontend UI 设计审查报告
|
||||
|
||||
> 审查日期:2026-05-10 | 审查范围:`frontend/` 全部组件、样式、布局
|
||||
|
||||
## 整体评价
|
||||
|
||||
前端设计系统有**扎实的基础** — 完善的 CSS 自定义属性 token 体系、不错的暗色模式、合理的排版层级、以及语义化的颜色编码。但存在一些**工程性债务**需要关注。
|
||||
|
||||
最突出的 5 个问题:
|
||||
1. `!important` 在浅色主题中泛滥(~960 行),维护成本极高
|
||||
2. `font-weight: 760/850/950` 等无效值在 CSS 中大量使用
|
||||
3. Geist 字体声明但从未加载,始终 fallback 到 Inter
|
||||
4. 键盘导航和 focus-visible 样式缺失
|
||||
5. 14 个不同的响应式断点值,无统一体系
|
||||
|
||||
---
|
||||
|
||||
## 一、设计系统 & Token 体系
|
||||
|
||||
### 优点
|
||||
|
||||
1. **完整的 CSS 自定义属性体系** (`globals.css:9-93`):拥有从背景色阶、文字色阶、语义色、阴影、间距、圆角、毛玻璃到动效的完整 token 体系,覆盖面广。
|
||||
|
||||
2. **4px 间距网格**:从 `--space-1: 4px` 到 `--space-12: 48px`,提供了系统化的间距基础。
|
||||
|
||||
3. **三层毛玻璃系统**:`--glass-blur-1/2/3` + `--glass-opacity-1/2/3` 的组合创造视觉深度层次,在暗色背景下效果出色。
|
||||
|
||||
4. **Tailwind 桥接**:`tailwind.config.ts` 提供了 `pw-bg-*`、`pw-text-*`、`pw-accent-*` 等语义化 utility class,兼顾了设计 token 的语义化和 Tailwind 的效率。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **"Fintech 3-Color" 名不副实** (`globals.css:25-27`):
|
||||
```css
|
||||
--color-accent-primary: #4DA3FF; /* 蓝 */
|
||||
--color-accent-secondary: #4DA3FF; /* 同上 — 完全一样 */
|
||||
--color-accent-tertiary: #93C5FD; /* 浅蓝 */
|
||||
```
|
||||
primary 和 secondary 是同一个颜色,实际只有 2 种不同色值。"3-Color" 声明具有误导性。
|
||||
|
||||
2. **Token 未充分利用**:大量 CSS Module 中硬编码了颜色值(如 `#4DA3FF`、`#E6EDF3`、`rgba(77, 163, 255, ...)`)而不是引用 CSS 变量。例如 `ScanTerminalCard.module.css` 中几乎每一行都是硬编码色值。
|
||||
|
||||
3. **shadcn/ui HSL token 遗留** (`globals.css:95-106`):保留了 shadcn 的 HSL token 但几乎未被使用,增加了 token 体系的认知负担。
|
||||
|
||||
---
|
||||
|
||||
## 二、布局 & 响应式
|
||||
|
||||
### 优点
|
||||
|
||||
1. **Scan Terminal 的 CSS Grid 布局** (`ScanTerminal.module.css:524-537`):`grid-template-columns: minmax(0, 1fr) minmax(380px, 420px)` 的 2 列布局干净、现代。
|
||||
|
||||
2. **毛玻璃面板系统**:所有主面板都有 `22px border-radius`、`backdrop-filter: blur(14px)`、渐变背景和微妙的阴影,视觉统一。
|
||||
|
||||
3. **Sticky detail rail**:右侧面板 `position: sticky; top: 16px; height: calc(100vh - 32px)` 保证了用户滚动时详情始终可见。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **两套布局系统并存**:
|
||||
- 旧版 "Home Intelligence" 使用 **absolute positioning** 定位 header/sidebar/map/panel
|
||||
- 新版 "Scan Terminal" 使用 **CSS Grid**
|
||||
- 两套 CSS Module 都在 `ScanTerminalDashboard.tsx` 中被 import 合并,增加了复杂度
|
||||
|
||||
2. **断点碎片化严重**:系统使用了 12+ 个不同断点值 (1680, 1480, 1360, 1240, 1100, 1020, 900, 820, 768, 720, 640, 600, 560, 520),没有统一的体系。同一个 tablet 过渡在一些模块用 768px,在另一些用 720px。
|
||||
|
||||
3. **Map View 中的 absolute positioning** (`DashboardShell.module.css`):使用 `top/right/bottom/left` 绝对定位来排列面板,脆弱且难以维护。
|
||||
|
||||
---
|
||||
|
||||
## 三、色彩 & 对比度
|
||||
|
||||
### 优点
|
||||
|
||||
1. **暗色模式基础扎实**:`#0B1220` 的深邃底色配合三层 radial gradient 营造了深度感和科技感。
|
||||
|
||||
2. **语义色系统清晰**:success/green、warning/amber、danger/red 的映射关系明确。
|
||||
|
||||
3. **渐变运用得当**:body 背景的三层 radial gradient(绿/紫/青)创造了微妙的大气光感。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **文字对比度偏低**:
|
||||
- 静音文字 `#6B7A90` 在 `#0B1220` 上的对比度约为 4.6:1,刚好达到 WCAG AA 标准但仍偏暗
|
||||
- 大量 10-11px 的辅助文字使用 `#6B7A90`,可读性不足
|
||||
|
||||
2. **Accent Color 视觉突出度**:`#4DA3FF`(蓝色)在暗色背景上的人眼敏感度不如 `#00E0A4`(青绿色),但前者被用作主要强调色。
|
||||
|
||||
3. **语义色 class 命名不一致**:`accent-green` class 实际渲染为 `#4DA3FF`(蓝色),"green" 命名的 class 显示蓝色。
|
||||
|
||||
---
|
||||
|
||||
## 四、字体排印
|
||||
|
||||
### 优点
|
||||
|
||||
1. **字体选择合理**:Inter(正文) + JetBrains Mono(数据/代码) 是 fintech/数据密集类产品的标准选择。
|
||||
|
||||
2. **tabular-nums 广泛使用**:几乎所有温度数值都设置了 `font-variant-numeric: tabular-nums`,保证了数字对齐。
|
||||
|
||||
3. **层级明确**:从 10px 的 kicker 到 56px 的 hero temperature,形成了清晰的视觉层级。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **Geist 字体声明但未加载** (`layout.tsx:35-37`):
|
||||
- `globals.css` 声明了 `--font-display: "Geist", "Inter", ...`
|
||||
- 但 HTML 中只加载了 Inter 和 JetBrains Mono,Geist 永远不会生效
|
||||
|
||||
2. **非标准 font-weight 值泛滥**:
|
||||
- `font-weight: 760`、`850`、`860`、`880`、`900`、`950` 在 CSS 中大量出现
|
||||
- Inter 字体只支持 300/400/500/600/700/800
|
||||
- 非标准 weight 会被浏览器取整到最近支持值,视觉不可预测
|
||||
- 例如 `font-weight: 950` 在 Inter 上实际渲染为 `800`
|
||||
|
||||
3. **字体大小偏小**:大量 UI 文字使用 10-11px,在高分辨率屏幕上可读性差。
|
||||
|
||||
4. **大写字母过多**:kicker/overline/chip 几乎全部使用 `text-transform: uppercase` + `letter-spacing: 0.08em`,降低了中文/双语场景下的可读性。
|
||||
|
||||
---
|
||||
|
||||
## 五、组件设计
|
||||
|
||||
### 优点
|
||||
|
||||
1. **City Decision Card 视觉层次优秀** (`ScanTerminalCard.module.css`):hero 区的渐变背景、蓝色左边框选中态、决策 band 的色彩编码(warm=red 边框、cold=green 边框、watch=amber 边框),信息密度高但不杂乱。
|
||||
|
||||
2. **Status Tags 色彩编码清晰**:green/blue/amber/red/muted 五种 tag 变体覆盖了数据状态的所有场景。
|
||||
|
||||
3. **双卡预测对比** (AI vs DEB):青色渐变卡 vs 蓝色边框卡的视觉区分让用户能一眼区分 AI 预测和模型预测。
|
||||
|
||||
4. **Decision Band 语义化**:`warm`(red) = 看涨、`cold`(green) = 看跌、`watch`(amber) = 待观察,视觉编码与交易语义对齐。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **Topbar 设计过于简单**:28px 标题 + 几个按钮,缺乏品牌标识(Logo)和视觉焦点。
|
||||
|
||||
2. **Tab 下划线指示器不够明显**:`2px` 高度 + `opacity: 0.8` 的蓝色下划线容易被忽略。
|
||||
|
||||
3. **按钮层级不够清晰**:`.scan-primary-button`(蓝紫渐变)、`.scan-ai-button`(青绿渐变)、`.scan-ai-city-icon-button`(蓝色边框半透明)、`.scan-theme-button`(无边框无背景)四种视觉权重混在一起,用户难以判断优先级。
|
||||
|
||||
4. **空状态/加载状态设计不一致**:
|
||||
- 地图加载有精美的云/雷达/热力动画
|
||||
- Scan terminal 加载是简单的脉冲 placeholder
|
||||
- 空状态是一个简单的文字居中块
|
||||
- 缺乏统一的 loading/empty/error 设计规范
|
||||
|
||||
5. **Mobile Decision Card 采用 `<details>` 元素**:原生 `<details>/<summary>` 样式控制有限、动画困难,与桌面端的自定义折叠按钮体验不一致。
|
||||
|
||||
---
|
||||
|
||||
## 六、深色/浅色主题
|
||||
|
||||
### 优点
|
||||
|
||||
1. **浅色模式覆盖全面**:`ScanTerminalLightTheme.module.css` 约 960 行,覆盖了 scan terminal 的所有元素。
|
||||
|
||||
2. **localStorage 持久化**:主题选择保存在 `polyweather_scan_theme` 中,刷新不丢失。
|
||||
|
||||
3. **浅色配色方案合理**:从深色 `#0B1220` 到浅色 `#F7F9FC → #EEF2F7` 的映射关系合理。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **`!important` 严重滥用** (`ScanTerminalLightTheme.module.css`):
|
||||
- 几乎每条浅色规则都使用了 `!important`
|
||||
- 这是 specificity war 的症状,维护成本极高
|
||||
- 多处出现 `!important` 叠加
|
||||
|
||||
2. **浅色主题分散在 8+ 个文件中**:缺乏集中管理,修改一个颜色需要跨多个文件搜索。
|
||||
|
||||
3. **浅色模式地图图块滤镜处理**:Leaflet tile 滤镜切换可能影响其他 overlay 的浅色适配。
|
||||
|
||||
---
|
||||
|
||||
## 七、动效 & 过渡
|
||||
|
||||
### 优点
|
||||
|
||||
1. **Cubic-bezier 缓动选择正确**:`(0.4, 0, 0.2, 1)` (Material standard) + `(0.16, 1, 0.3, 1)` (spring-like) 的组合符合现代 UI 动效标准。
|
||||
|
||||
2. **入场动画有层次**:detail panel 从右侧滑入 (400ms)、opportunity strip 有 120ms 延迟。
|
||||
|
||||
3. **Hover 微交互**:按钮 `translateY(-1px)`、卡片 `scale(1.002)`、边框颜色过渡等微交互提升了操作反馈感。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **`@keyframes spin` 重复定义 4 次**:在 `DashboardShell.module.css`、`DashboardModalGuide.module.css`、`DocsLayout.module.css`、`DashboardMap.module.css` 中各自定义了一次完全相同的 spin 动画。
|
||||
|
||||
2. **Loading 动画风格不统一**:`DashboardMap` 有复杂的 weather-themed 动画(radar swipe、cloud drift、thermal bars、wind shift),而 scan terminal 只有简单的扫光 placeholder。
|
||||
|
||||
3. **缺少 `prefers-reduced-motion` 支持**:没有任何 `@media (prefers-reduced-motion: reduce)` 的声明。
|
||||
|
||||
---
|
||||
|
||||
## 八、代码组织 & 可维护性
|
||||
|
||||
### 优点
|
||||
|
||||
1. **CSS Module 组件隔离**:每个组件有对应的 `.module.css`,样式作用域控制良好。
|
||||
|
||||
2. **命名约定一致**:CSS 类名使用 `scan-` 前缀 + kebab-case,全局可识别。
|
||||
|
||||
### 问题
|
||||
|
||||
1. **CSS Module 堆叠模式过度耦合** (`ScanTerminalDashboard.tsx:143-159`):
|
||||
```tsx
|
||||
const scanTerminalRootClassName = clsx(
|
||||
styles.root,
|
||||
dashboardHomeStyles.root,
|
||||
dashboardMapStyles.root,
|
||||
// ... 共 22 个 CSS Module 的 .root 合并
|
||||
);
|
||||
```
|
||||
单个组件 import 了 22 个 CSS Module,破坏了 CSS Module 的隔离优势。
|
||||
|
||||
2. **`:global()` 绕过了 CSS Module 的哈希**:几乎所有规则都使用 `:global(.class-name)`,class 名不会被哈希。CSS Module 降级为"命名约定"工具。
|
||||
|
||||
3. **样式与逻辑耦合**:`ScanTerminalDashboard.tsx` 约 650 行,同时负责状态管理和渲染布局。
|
||||
|
||||
4. **重复的 CSS 变量声明**:`Dashboard.module.css` 中重新声明了 `--bg-primary` 等本地变量,与 `globals.css` 的全局 token 形成冗余。
|
||||
|
||||
---
|
||||
|
||||
## 九、无障碍性
|
||||
|
||||
### 优点
|
||||
|
||||
1. 部分元素有 aria-label(如 topbar 按钮、locale switch)
|
||||
2. 主题切换按钮有 title 属性
|
||||
3. ProFeaturePaywall 使用了 `role="dialog"` 和 `aria-modal="true"`
|
||||
|
||||
### 问题
|
||||
|
||||
1. **键盘导航不足**:
|
||||
- 城市卡片没有 `tabindex` 或 `role="button"`
|
||||
- Tab 切换缺少 `role="tablist"`/`role="tab"`/`aria-selected`
|
||||
- 折叠按钮缺少 `aria-expanded`
|
||||
|
||||
2. **焦点指示器不可见**:自定义按钮(如 `scan-theme-button`、`scan-ai-city-icon-button`)没有 focus-visible 样式
|
||||
|
||||
3. **颜色不是唯一的信息传达方式**:风险等级、Market decision 的色彩编码缺少对应的文字标签或图标补充
|
||||
|
||||
4. **没有 skip-to-content 链接**
|
||||
|
||||
5. **10-11px 小字体对视力障碍用户不友好**
|
||||
|
||||
---
|
||||
|
||||
## 总结与优先级建议
|
||||
|
||||
### 高优先级(影响用户体验和可维护性)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 1 | `!important` 滥用导致浅色主题不可维护 | `ScanTerminalLightTheme.module.css` |
|
||||
| 2 | 非标准 font-weight 值无效(760/850/950 等) | 多个 CSS Module |
|
||||
| 3 | Geist 字体声明但未加载 | `layout.tsx:35-37` / `globals.css:55` |
|
||||
| 4 | 键盘导航和 focus-visible 缺失 | 全局 |
|
||||
| 5 | 断点碎片化(14 个断点值无体系) | 所有响应式 CSS |
|
||||
|
||||
### 中优先级(影响设计一致性)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 6 | 22 个 CSS Module 堆叠耦合 | `ScanTerminalDashboard.tsx:143-159` |
|
||||
| 7 | Token 未充分利用(硬编码色值) | 多个 CSS Module |
|
||||
| 8 | `@keyframes spin` 重复定义 4 次 | 多个 CSS Module |
|
||||
| 9 | Loading 状态设计不一致 | `DashboardMap` vs `ScanTerminalState` |
|
||||
| 10 | 缺少 `prefers-reduced-motion` 支持 | 全局 |
|
||||
|
||||
### 低优先级(增强和优化)
|
||||
|
||||
| # | 问题 | 位置 |
|
||||
|---|------|------|
|
||||
| 11 | "Fintech 3-Color" 实际只有 2 色 | `globals.css:25-27` |
|
||||
| 12 | Topbar 缺少 Logo/品牌标识 | `ScanTerminalDashboard.tsx` |
|
||||
| 13 | 按钮视觉层级不够清晰 | 多个组件 |
|
||||
| 14 | Shadcn UI 组件存在但未被使用 | `components/ui/` |
|
||||
| 15 | `--color-text-muted` 对比度刚达标 | `globals.css:21` |
|
||||
|
||||
---
|
||||
|
||||
## 修复路线图建议
|
||||
|
||||
1. **Phase 1** — 修复 `font-weight` 无效值:全局搜索 `font-weight: 760`、`850`、`860`、`880`、`900`、`950`,替换为 Inter 支持的 300-800 等效值
|
||||
2. **Phase 2** — 重构浅色主题:将分散在 8+ 个文件中的浅色覆盖集中到一个 `light-theme.css`,使用 CSS 变量覆盖而非 `!important`
|
||||
3. **Phase 3** — 补充无障碍:为 Tab/Button/Card 组件添加 ARIA 属性和 focus-visible 样式
|
||||
4. **Phase 4** — 统一断点:定义 4-5 个标准断点(如 480/768/1024/1280/1440),逐步替换现有碎片化断点
|
||||
5. **Phase 5** — 设计一致性:创建统一的 loading/empty/error 组件,清理未使用的 shadcn 组件
|
||||
|
||||
---
|
||||
|
||||
## 修复完成记录
|
||||
|
||||
> 修复日期:2026-05-10 | 变更范围:28 个文件,+693 / −2,198 行
|
||||
|
||||
### 高优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 1 | `!important` 滥用 | 134 → 49(仅保留 Leaflet/图表所必需项),将 `.root:global(.light)` 替换为 `html.light` 以获得更高优先级 | `ScanTerminalLightTheme.module.css`、`globals.css` 等 |
|
||||
| 2 | 非标准 font-weight | 所有 760/850/860/880/950 等映射为 Inter 支持的 300–800 | 13 个 CSS 文件 |
|
||||
| 3 | 未加载 Geist | 从 `--font-display` 中移除,替换为 Inter | `globals.css` |
|
||||
| 4 | 键盘 / 焦点可见 | 添加了全局 `:focus-visible` 轮廓环、跳过链接、Tab ARIA(`role="tablist"`/`role="tab"`/`aria-selected`) | `globals.css`、`layout.tsx`、`ScanFilterPanel.tsx`、`ScanTerminalDashboard.tsx` |
|
||||
| 5 | 断点碎片化 | 18 → 10:合并 520/600/720/820/900/1020/1100/1240 → 640/768/960/1024/1280 | 9 个 CSS 文件 |
|
||||
|
||||
### 中优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 6 | 22 个 CSS Module 耦合 | 新建 `scan-root-styles.ts` 桶文件,将 22 个独立导入合并为 1 个预组合的 className | `scan-root-styles.ts`(新建)、`ScanTerminalDashboard.tsx` |
|
||||
| 7 | Token 使用不足 | 将主要颜色(`#4DA3FF`/`#E6EDF3`/`#9FB2C7`/`#6B7A90`/`#6FB7FF`)从 0 个变量引用替换为数百个 | `ScanTerminalCard`、`ScanTerminalList`、`ScanTerminalBoard`、`ScanTerminalOpportunity`、`ScanTerminalMobile`、`ScanTerminal`、`DashboardHomeIntelligence` 等 |
|
||||
| 8 | `@keyframes spin` 重复 4 次 | 移至 `globals.css`;`loading-spin` 去重 2 处;`pulse-pending` 移至全局 | `globals.css`、4 个 CSS 文件 |
|
||||
| 9 | Loading 状态不一致 | 添加了 `.scan-error-state`、`.scan-retry-button`、`.scan-empty-icon` 用于统一状态呈现 | `ScanTerminalState.module.css`、`ScanTerminalLightTheme.module.css` |
|
||||
| 10 | 无 `prefers-reduced-motion` | 在 `globals.css` 中添加了全局动画/过渡禁用 | `globals.css` |
|
||||
|
||||
### 低优先级 — 5/5 完成
|
||||
|
||||
| # | 问题 | 修复 | 涉及文件 |
|
||||
|---|------|------|----------|
|
||||
| 11 | "3-Color" 仅 2 种颜色 | 将 accent-primary(blue) 和 accent-secondary(light-blue) 区分为不同颜色 | `globals.css`、`Dashboard.module.css` |
|
||||
| 12 | Topbar 缺少 Logo | 添加了 CSS 渐变品牌标记 | `ScanTerminalShell.module.css`、`ScanTerminalDashboard.tsx` |
|
||||
| 13 | 按钮层级不清晰 | 在 CSS 中添加了文档化的层级注释标题 | `ScanTerminalShell.module.css` |
|
||||
| 14 | Shadcn 未使用 | 已验证 6 个组件被 5 个文件使用(保留),更新了注释 | `globals.css` |
|
||||
| 15 | 文字对比度不足 | `--color-text-muted` 从 `#6B7A90` 提升至 `#7D8FA3` | `globals.css` |
|
||||
|
||||
### 其他修复
|
||||
|
||||
| 问题 | 修复 |
|
||||
|------|------|
|
||||
| `accent-green` 类错误渲染为蓝色 | `ScanTerminal.module.css`:`.scan-condition-value.accent-green` 从 `#4DA3FF` 修正为 `#22C55E` |
|
||||
| `Dashboard.module.css` 重复的 CSS 变量 | 将本地 `--bg-*`/`--accent-*` 变量桥接至全局 token |
|
||||
| 死代码 | 移除 `public/static/style.css`(1,459 行)和 `public/legacy/index.html`(238 行)— 均未被引用 |
|
||||
| 浅色主题 Token 基础设施 | 在 `globals.css` 中添加了 `html.light` CSS 自定义属性覆盖 |
|
||||
| 品牌 Logo 浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了浅色主题 Logo 样式 |
|
||||
| 空/错误状态浅色主题 | 在 `ScanTerminalLightTheme.module.css` 中添加了空/错误/重试的浅色覆盖 |
|
||||
|
||||
### 最终指标
|
||||
|
||||
| 指标 | 之前 | 之后 |
|
||||
|------|------|------|
|
||||
| `!important`(可避免项) | ~85 | 0 |
|
||||
| `!important`(必需项) | ~49 | 49(Leaflet 内联样式、图表 canvas 属性、减少动态效果) |
|
||||
| 硬编码调色板颜色 | 数百个 | 0(仅 `globals.css` 中的变量定义) |
|
||||
| 断点 | 18 个唯一值 | 10(480/640/768/960/1024/1200/1280/1360/1440/1680) |
|
||||
| 重复的 `@keyframes` | 7 | 0 |
|
||||
| 非标准 font-weight | 全 13 个文件 | 0 |
|
||||
| CSS Module 导入 | 22 个独立导入 | 2(桶文件 + 共享) |
|
||||
| 死代码 | 1,697 行 | 0 |
|
||||
| 净代码行数 | — | −1,505 行 |
|
||||
@@ -18,12 +18,12 @@
|
||||
/* ── Text Scale ── */
|
||||
--color-text-primary: #E6EDF3;
|
||||
--color-text-secondary: #9FB2C7;
|
||||
--color-text-muted: #6B7A90;
|
||||
--color-text-disabled: #6B7A90;
|
||||
--color-text-muted: #7D8FA3;
|
||||
--color-text-disabled: #7D8FA3;
|
||||
|
||||
/* ── Accent Colors (Fintech 3-Color) ── */
|
||||
/* ── Accent Colors ── */
|
||||
--color-accent-primary: #4DA3FF;
|
||||
--color-accent-secondary: #4DA3FF;
|
||||
--color-accent-secondary: #6FB7FF;
|
||||
--color-accent-tertiary: #93C5FD;
|
||||
|
||||
/* ── Signal / Semantic Colors ── */
|
||||
@@ -52,7 +52,7 @@
|
||||
/* ── Typography ── */
|
||||
--font-data:
|
||||
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Geist", "Inter", -apple-system, sans-serif;
|
||||
--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-mono: "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
|
||||
/* ── Spacing (4px grid) ── */
|
||||
@@ -91,7 +91,7 @@
|
||||
--transition-base: 250ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
/* ── Legacy Compatibility (shadcn/ui HSL tokens) ── */
|
||||
/* ── shadcn/ui Tokens (used by Tailwind @apply border-border) ── */
|
||||
--background: 223 53% 4%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 223 46% 8%;
|
||||
@@ -105,10 +105,72 @@
|
||||
--border: 221 38% 22%;
|
||||
}
|
||||
|
||||
/* ── Light Theme Token Overrides ── */
|
||||
html.light,
|
||||
html[data-theme="light"] {
|
||||
--color-bg-base: #F7F9FC;
|
||||
--color-bg-raised: #EEF2F7;
|
||||
--color-bg-overlay: #FFFFFF;
|
||||
--color-bg-card: rgba(255, 255, 255, 0.92);
|
||||
--color-bg-input: rgba(238, 242, 247, 0.88);
|
||||
|
||||
--color-text-primary: #0F172A;
|
||||
--color-text-secondary: #334155;
|
||||
--color-text-muted: #475569;
|
||||
--color-text-disabled: #94A3B8;
|
||||
|
||||
--color-accent-primary: #2563EB;
|
||||
--color-accent-secondary: #3B82F6;
|
||||
--color-accent-tertiary: #60A5FA;
|
||||
|
||||
--color-border-default: rgba(148, 163, 184, 0.24);
|
||||
--color-border-hover: rgba(37, 99, 235, 0.38);
|
||||
--color-border-subtle: rgba(148, 163, 184, 0.12);
|
||||
|
||||
--shadow-elevation-1: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-elevation-2: 0 8px 24px rgba(40, 70, 110, 0.12);
|
||||
--shadow-elevation-3: 0 20px 60px rgba(40, 70, 110, 0.15);
|
||||
--shadow-glow-accent: 0 0 20px rgba(37, 99, 235, 0.14);
|
||||
--shadow-glow-secondary: 0 0 20px rgba(96, 165, 250, 0.12);
|
||||
|
||||
--glass-blur-1: blur(10px);
|
||||
--glass-blur-2: blur(16px);
|
||||
--glass-blur-3: blur(24px);
|
||||
--glass-opacity-1: 0.86;
|
||||
--glass-opacity-2: 0.92;
|
||||
--glass-opacity-3: 0.96;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
/* ── Skip-to-content link ── */
|
||||
.skip-to-content {
|
||||
position: absolute;
|
||||
top: -100%;
|
||||
left: 8px;
|
||||
z-index: 9999;
|
||||
padding: 10px 18px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-accent-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
transition: top 0.18s ease;
|
||||
}
|
||||
|
||||
.skip-to-content:focus {
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
/* ── Global focus-visible ring (keyboard navigation) ── */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-accent-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-data);
|
||||
background:
|
||||
@@ -219,6 +281,28 @@
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@keyframes pulse-pending {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Reduced motion: disable all animations and transitions ── */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -36,7 +36,12 @@ export default function RootLayout({
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen font-sans antialiased">{children}</body>
|
||||
<body className="min-h-screen font-sans antialiased">
|
||||
<a href="#main-content" className="skip-to-content">
|
||||
Skip to content
|
||||
</a>
|
||||
<main id="main-content">{children}</main>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>📚 PolyWeather 系统技术说明</h2>
|
||||
<button class="modal-close" id="guideModalClose">✕</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="guide-grid">
|
||||
<div class="guide-card">
|
||||
<h3>🧮 DEB 动态融合预测</h3>
|
||||
<p><b>Dynamic Ensemble Blending</b> 是系统的核心预测层。它不是对 ECMWF、GFS、ICON、GEM、JMA 等模型的简单平均,而是结合最近样本表现、当前实况与城市偏置后得到的动态加权结果。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>🎯 结算概率引擎</h3>
|
||||
<p>右侧的结算概率分布基于 DEB 预测值与多模型离散度动态计算。μ代表当前分布中心,会随着模型、实况和时间变化而变动,不是固定预报值。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>✈️ 结算点与主观测源</h3>
|
||||
<p>Polymarket 结算逻辑以机场 METAR 为主。系统现在优先使用 Aviation Weather API 的机场报文与原始 METAR,并区分观测时间与接收时间,避免把发布延迟误认成温度变化。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📍 Ankara 专属增强</h3>
|
||||
<p>Ankara 不走通用城市逻辑。结算主站以 LTAC / Esenboğa 为准,周边领先信号优先参考 Turkish MGM 站网,其中 <b>Ankara (Bölge/Center)</b> 是重点监控站,不由 Etimesgut 代替。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📆 未来日期分析</h3>
|
||||
<p>点击多日预报后打开的模态框,主要用于分析下一个交易日。 6-48 小时趋势以 weather.gov 和 Open-Meteo 为主; 0-2 小时临近判断则优先看 METAR 与周边站。</p>
|
||||
</div>
|
||||
<div class="guide-card">
|
||||
<h3>📊 历史对账规则</h3>
|
||||
<p>历史准确率对账只统计已结算的样本。网页端采用近15天滚动视图,机器人 /deb 报告采用近7天窗口,不把当天未结算的样本算入胜率和 MAE。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="guide-footer">
|
||||
<p>※ 数据源以 Aviation Weather / METAR、Turkish MGM、Open-Meteo、weather.gov 为主。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 鈹€鈹€ History Chart Modal 鈹€鈹€ -->
|
||||
<div id="historyModal" class="modal-overlay hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>📊 <span id="historyModalTitle">历史准确率对账</span></h2>
|
||||
<button class="modal-close" id="historyModalClose">✕</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>📆 <span id="futureForecastTitle">未来日期分析</span></h2>
|
||||
<button class="modal-close" id="futureForecastClose" aria-label="关闭未来日期分析">✕</button>
|
||||
</div>
|
||||
<div class="modal-body future-modal-body">
|
||||
<div class="history-stats" id="futureForecastStats"></div>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3>📈 温度走势</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>🎯 结算概率分布</h3>
|
||||
<div id="futureProbBars" class="prob-bars future-prob-bars"></div>
|
||||
</section>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3>🧮 多模型预报</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>未来 6-48 小时趋势</h3>
|
||||
<div id="futureTrendGrid" class="future-trend-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="future-modal-section">
|
||||
<h3 id="futureNowcastTitle">⏱ 目标日午后峰值窗口</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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user