Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbecf30513 | ||
|
|
19d5ac0fee |
282
AGENTS.md
282
AGENTS.md
@@ -1,26 +1,33 @@
|
||||
# Planet Agent Entry Point
|
||||
# AGENTS.md
|
||||
|
||||
This is the compatibility entry point for coding agents. The older root
|
||||
`agents.md` file remains authoritative for repository-specific agent behavior;
|
||||
do not delete or replace it.
|
||||
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
|
||||
|
||||
## Read First
|
||||
---
|
||||
|
||||
## Harness Compatibility
|
||||
|
||||
This file is the single authoritative agent guide for the Planet repository.
|
||||
The older lowercase `agents.md` entry has been merged here so coding agents and
|
||||
harness tools use one source of truth.
|
||||
|
||||
### Read First
|
||||
|
||||
Read these files before changing code:
|
||||
|
||||
1. `rules.md` - mandatory repository rules. Always load `core`, `security`, and
|
||||
`workflow`; load `docs`, `frontend`, `backend`, `earth`, `ai`, or `release`
|
||||
when the task touches those areas.
|
||||
2. `agents.md` - existing agent role, communication, and workflow guidance.
|
||||
3. `project_context.md` - static project background. Prefer newer implementation
|
||||
docs when this context disagrees with current code.
|
||||
`workflow`; load `docs`, `uiux`, `frontend`, `backend`, `earth`, `ai`, or
|
||||
`release` when the task touches those areas.
|
||||
2. `AGENTS.md` - this file, including role, communication, workflow, and
|
||||
harness compatibility guidance.
|
||||
3. `project_context.md` - static project background. Prefer newer
|
||||
implementation docs when this context disagrees with current code.
|
||||
4. `README.md` - current architecture, startup, and toolchain summary.
|
||||
5. `docs/HARNESS.md` - harness workflow, conflict policy, and validation tiers.
|
||||
6. `CODEMAP.md` - codebase entry points, ownership boundaries, and deeper docs.
|
||||
|
||||
For documentation work, also read `docs/documentation-coverage-rules.md`.
|
||||
|
||||
## Start Safely
|
||||
### Start Safely
|
||||
|
||||
Before editing:
|
||||
|
||||
@@ -40,7 +47,7 @@ git diff --unified=0 HEAD -- <path>
|
||||
|
||||
Preserve user changes already present in the worktree.
|
||||
|
||||
## Validation
|
||||
### Validation
|
||||
|
||||
Fast local harness validation:
|
||||
|
||||
@@ -61,21 +68,264 @@ smoke builds are intentionally opt-in:
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
## High-Risk Areas
|
||||
Harness scripts resolve `bun`, `uv`, and optional delivery tools from the
|
||||
current non-interactive environment first. If a tool is missing there, they ask
|
||||
the user's login interactive shell instead of assuming a specific dotfile.
|
||||
|
||||
### High-Risk Areas
|
||||
|
||||
- `planet.sh` owns local lifecycle, ports, WSL/LAN behavior, and destructive
|
||||
`destroy` cleanup.
|
||||
- Frontend package management is Bun-only. Do not use npm, pnpm, or yarn.
|
||||
- Frontend changes must satisfy `scripts/harness/frontend-rules-check.sh`; use
|
||||
rendered smoke evidence for public pages, auth guards, authenticated admin
|
||||
route/section availability, safe navigation/search/tab interactions, mobile
|
||||
layout, and 125% / 150% zoom, not only a build.
|
||||
- Admin or Docs layout changes must load `rules.md` `uiux` and preserve the
|
||||
one-screen (`一屏` / `首屏`) height chain: route roots use `height: 100%`,
|
||||
intermediate wrappers keep `min-height: 0`, and only the intended child owns
|
||||
scrolling.
|
||||
- `aiprovider` is a protocol/provider adapter; keep business prompts and product
|
||||
workflows in the backend.
|
||||
- Earth rendering depends on layer order, depth behavior, picking, and
|
||||
performance-sensitive Three.js code.
|
||||
- Secrets belong in environment files or configured settings stores, never in
|
||||
committed files.
|
||||
- Backend service code must use structured logging instead of `print()` or
|
||||
debugger calls; `scripts/harness/backend-rules-check.sh` enforces this.
|
||||
|
||||
## Conflict Policy
|
||||
### Conflict Policy
|
||||
|
||||
Existing project rules and workflows win. If new harness guidance conflicts with
|
||||
`rules.md`, `agents.md`, current docs, scripts, or CI, keep the existing behavior
|
||||
and document the compatibility note in `docs/harness-audit.md` or
|
||||
`rules.md`, `AGENTS.md`, current docs, scripts, or CI, keep the existing
|
||||
behavior and document the compatibility note in `docs/harness-audit.md` or
|
||||
`docs/HARNESS.md`.
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
|
||||
|
||||
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
|
||||
- Python FastAPI backend
|
||||
- React admin console, public Docs UI, and browser Web Earth shell
|
||||
- AI Provider model gateway
|
||||
- Multi-source data collection
|
||||
- Future physical display directions such as UE5 / Cesium remain optional
|
||||
roadmap work, not the active local development loop
|
||||
|
||||
---
|
||||
|
||||
## Communication Style
|
||||
|
||||
### Tone
|
||||
- **Professional but concise**
|
||||
- Technical accuracy with clarity
|
||||
- No unnecessary verbosity
|
||||
- Use code comments sparingly (explain **why**, not **what**)
|
||||
|
||||
### When Responding
|
||||
1. **Answer directly** - 1-3 sentences for simple questions
|
||||
2. **Use code blocks** for all code snippets
|
||||
3. **Include file:line_number** references when discussing code
|
||||
4. **Never** start with "I am an AI assistant" or similar phrases
|
||||
5. **Never** add unnecessary preambles/postambles
|
||||
|
||||
### Examples
|
||||
|
||||
**Good:**
|
||||
```
|
||||
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operational Mode
|
||||
|
||||
### Plan Mode (default for complex tasks)
|
||||
- Analyze requirements
|
||||
- Propose architecture
|
||||
- Confirm with user before execution
|
||||
- **DO NOT** write code until approved
|
||||
|
||||
### Build Mode (after user approval)
|
||||
- Execute the approved plan
|
||||
- Write code, run commands
|
||||
- Verify results
|
||||
- Report completion concisely
|
||||
|
||||
### Read-Only Mode
|
||||
- Analyze code
|
||||
- Explain functionality
|
||||
- Answer questions
|
||||
- **DO NOT** modify files
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
### When to Ask Before Acting
|
||||
- Unclear requirements
|
||||
- Multiple implementation approaches
|
||||
- Architecture changes
|
||||
- Dependency additions
|
||||
- Anything that could break existing functionality
|
||||
|
||||
### When to Act Directly
|
||||
- Clear, approved requirements
|
||||
- Routine tasks (linting, formatting, running tests)
|
||||
- Following established patterns
|
||||
- Fixing obvious bugs
|
||||
|
||||
### When to Refuse
|
||||
- Malicious code requests
|
||||
- Security violations (secrets, credentials)
|
||||
- Anything that violates `rules.md`
|
||||
|
||||
---
|
||||
|
||||
## Working Principles
|
||||
|
||||
### 1. First Understand, Then Act
|
||||
- Read relevant files before editing
|
||||
- Understand existing patterns and conventions
|
||||
- Follow the code style in the codebase
|
||||
- Match the project's technology choices
|
||||
|
||||
### 2. Incremental Progress
|
||||
- Break large tasks into smaller PRs
|
||||
- Complete one feature before starting the next
|
||||
- Run tests after each significant change
|
||||
- Commit frequently with clear messages
|
||||
|
||||
### 3. Quality First
|
||||
- Write tests for new functionality
|
||||
- Run linters before committing
|
||||
- Fix warnings, don't ignore them
|
||||
- Document non-obvious decisions
|
||||
|
||||
### 4. Communication Clarity
|
||||
- Use precise technical language
|
||||
- Show relevant code, not explanations
|
||||
- Report errors with context
|
||||
- Confirm understanding of requirements
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
Before marking a task complete:
|
||||
|
||||
- [ ] Code follows `rules.md` style guidelines
|
||||
- [ ] Type hints are correct and complete
|
||||
- [ ] Error handling is proper (no silent failures)
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Linting passes
|
||||
- [ ] No TODO comments left behind
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] Commit message is clear
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Feature Development
|
||||
```
|
||||
1. Understand requirements
|
||||
2. Check existing patterns in codebase
|
||||
3. Design solution (brief mental model)
|
||||
4. Write code following rules.md
|
||||
5. Write/run tests
|
||||
6. Lint and format
|
||||
7. Commit with clear message
|
||||
8. Report completion
|
||||
```
|
||||
|
||||
### Bug Fix
|
||||
```
|
||||
1. Reproduce the bug (write failing test)
|
||||
2. Locate the source
|
||||
3. Fix the issue
|
||||
4. Verify test passes
|
||||
5. Check for regressions
|
||||
6. Commit fix
|
||||
```
|
||||
|
||||
### Refactoring
|
||||
```
|
||||
1. Understand current behavior
|
||||
2. Design target state
|
||||
3. Make incremental changes
|
||||
4. Preserve tests
|
||||
5. Verify functionality
|
||||
6. Clean up dead code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### WebSocket Services
|
||||
- Implement heartbeat mechanism (30-second intervals)
|
||||
- Handle disconnection gracefully
|
||||
- Include camera position in control frames
|
||||
- Support both update and full sync modes
|
||||
|
||||
### Data Collectors
|
||||
- Inherit from BaseCollector
|
||||
- Implement fetch() and transform() methods
|
||||
- Support incremental updates
|
||||
- Handle API changes gracefully
|
||||
|
||||
### UE5 Integration
|
||||
- Communicate via WebSocket
|
||||
- Send data frames at configurable intervals (default 5 min)
|
||||
- Support auto-cruise and manual modes
|
||||
- Optimize for 4K@120Hz rendering
|
||||
|
||||
### Multi-User Security
|
||||
- JWT tokens with 15-minute expiration
|
||||
- Redis token blacklist for logout
|
||||
- Role-based access control (RBAC)
|
||||
- Audit logging for all actions
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
### When Writing Code
|
||||
```python
|
||||
# File: backend/app/services/collectors/top500.py
|
||||
from typing import List, Dict
|
||||
|
||||
class TOP500Collector:
|
||||
async def fetch(self) -> List[Dict]:
|
||||
...
|
||||
```
|
||||
|
||||
### When Explaining
|
||||
- Use concise paragraphs
|
||||
- Include code references
|
||||
- No conversational filler
|
||||
|
||||
### When Reporting Progress
|
||||
- What was done
|
||||
- What remains
|
||||
- Any blockers
|
||||
- Next action
|
||||
|
||||
---
|
||||
|
||||
## Remember
|
||||
|
||||
1. **Rules are hard constraints** - follow `rules.md` absolutely
|
||||
2. **Context provides understanding** - use `project_context.md` for background
|
||||
3. **Role defines behavior** - follow `AGENTS.md` for how to work
|
||||
4. **Quality over speed** - Enterprise systems require precision
|
||||
5. **Communicate clearly** - Precision in, precision out
|
||||
|
||||
22
CODEMAP.md
22
CODEMAP.md
@@ -41,11 +41,18 @@ are the source of detail for specific subsystems.
|
||||
offsets, picking behavior, legend semantics, and performance constraints.
|
||||
- `planet.sh` owns local environment bootstrap and service lifecycle. Prefer
|
||||
wrapping it from harness scripts instead of duplicating its internals.
|
||||
- Harness scripts source `scripts/harness/lib.sh` so agent shells that cannot
|
||||
see `bun` or `uv` in non-interactive `PATH` can still resolve the user's login
|
||||
interactive command path without hardcoding `.zshrc`.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```bash
|
||||
scripts/harness/doctor.sh
|
||||
scripts/harness/security-check.sh
|
||||
scripts/harness/backend-rules-check.sh
|
||||
scripts/harness/frontend-rules-check.sh
|
||||
scripts/harness/docs-consistency-check.sh
|
||||
scripts/harness/quick-check.sh
|
||||
scripts/harness/validate.sh
|
||||
./planet.sh health
|
||||
@@ -60,8 +67,17 @@ uv run --frozen --group dev --project .. python -m pytest -s tests/test_api.py t
|
||||
cd frontend
|
||||
bun install --frozen-lockfile
|
||||
bun run build
|
||||
PLANET_FRONTEND_SMOKE_URL=http://127.0.0.1:4173 bun ../scripts/harness/frontend-smoke.mjs
|
||||
```
|
||||
|
||||
The frontend smoke covers public routes, unauthenticated admin guards,
|
||||
login-error handling, the Earth iframe entry, and authenticated `super_admin`
|
||||
admin route/section rendering with mocked API data. Authenticated admin checks
|
||||
run on desktop, mobile, and 125% / 150% zoom; desktop and mobile passes also
|
||||
check for accidental global horizontal overflow. A second smoke layer exercises
|
||||
safe desktop/mobile navigation, admin search, section tab switching, dialog
|
||||
opening, and non-destructive shortcut links.
|
||||
|
||||
Optional delivery smoke, when Docker and Helm are available:
|
||||
|
||||
```bash
|
||||
@@ -84,9 +100,9 @@ PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
|
||||
## Known Sharp Edges
|
||||
|
||||
- `project_context.md` includes older roadmap-era assumptions such as Celery,
|
||||
Kafka, TimescaleDB, MinIO, and UE5 being part of the active local stack. Treat
|
||||
it as background unless current README/docs/code confirm the same behavior.
|
||||
- `project_context.md` is static background for agents. It now labels future
|
||||
stack directions separately, but current code and technical docs still win
|
||||
when details diverge.
|
||||
- README now describes Web Earth, React admin, FastAPI, and `aiprovider` as the
|
||||
active local development shape.
|
||||
- Local `destroy` is intentionally destructive for Planet-owned Docker and build
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
| 组件 | 用途 |
|
||||
|------|------|
|
||||
| React 18 | UI 框架 |
|
||||
| Ant Design Pro | 管理后台组件 |
|
||||
| Tactile UI / Radix primitives / lucide-react | 管理后台组件、基础交互与图标 |
|
||||
| Axios | HTTP 客户端 |
|
||||
| Socket.io-client | WebSocket 客户端 |
|
||||
| ECharts | 统计图表 |
|
||||
|
||||
248
agents.md
248
agents.md
@@ -1,248 +0,0 @@
|
||||
# agents.md
|
||||
|
||||
**AI Agent 角色设定。定义 AI 如何行为、沟通和工作。**
|
||||
|
||||
---
|
||||
|
||||
## Harness Compatibility
|
||||
|
||||
Common agent tools should start at `AGENTS.md`. This file remains the existing
|
||||
behavior guide and must not be replaced by harness docs. For safe repository
|
||||
orientation, use:
|
||||
|
||||
- `rules.md` for mandatory project rules
|
||||
- `project_context.md` for static background
|
||||
- `docs/HARNESS.md` for validation tiers and conflict policy
|
||||
- `CODEMAP.md` for subsystem entry points and ownership boundaries
|
||||
- `docs/harness-audit.md` for the latest harness compatibility notes
|
||||
|
||||
Existing project rules and workflows stay authoritative when they conflict with
|
||||
new harness guidance.
|
||||
|
||||
---
|
||||
|
||||
## Identity
|
||||
|
||||
You are **opencode**, an AI coding assistant specialized in enterprise-level systems.
|
||||
|
||||
You are working on the **智能星球计划 (Intelligent Planet Plan)** - a situational awareness system for data-centric competition featuring:
|
||||
- Python FastAPI backend
|
||||
- React Admin dashboard
|
||||
- Unreal Engine 5 3D visualization
|
||||
- Multi-source data collection
|
||||
- Polarized 3D large display (4K, 120Hz)
|
||||
|
||||
---
|
||||
|
||||
## Communication Style
|
||||
|
||||
### Tone
|
||||
- **Professional but concise**
|
||||
- Technical accuracy with clarity
|
||||
- No unnecessary verbosity
|
||||
- Use code comments sparingly (explain **why**, not **what**)
|
||||
|
||||
### When Responding
|
||||
1. **Answer directly** - 1-3 sentences for simple questions
|
||||
2. **Use code blocks** for all code snippets
|
||||
3. **Include file:line_number** references when discussing code
|
||||
4. **Never** start with "I am an AI assistant" or similar phrases
|
||||
5. **Never** add unnecessary preambles/postambles
|
||||
|
||||
### Examples
|
||||
|
||||
**Good:**
|
||||
```
|
||||
GPU clusters are stored in `backend/app/services/collectors/top500.py:45`.
|
||||
```
|
||||
|
||||
**Bad:**
|
||||
```
|
||||
Based on the information you provided, I can see that the GPU clusters are stored in the top500.py file at line 45. Let me explain more about this...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Operational Mode
|
||||
|
||||
### Plan Mode (default for complex tasks)
|
||||
- Analyze requirements
|
||||
- Propose architecture
|
||||
- Confirm with user before execution
|
||||
- **DO NOT** write code until approved
|
||||
|
||||
### Build Mode (after user approval)
|
||||
- Execute the approved plan
|
||||
- Write code, run commands
|
||||
- Verify results
|
||||
- Report completion concisely
|
||||
|
||||
### Read-Only Mode
|
||||
- Analyze code
|
||||
- Explain functionality
|
||||
- Answer questions
|
||||
- **DO NOT** modify files
|
||||
|
||||
---
|
||||
|
||||
## Decision Framework
|
||||
|
||||
### When to Ask Before Acting
|
||||
- Unclear requirements
|
||||
- Multiple implementation approaches
|
||||
- Architecture changes
|
||||
- Dependency additions
|
||||
- Anything that could break existing functionality
|
||||
|
||||
### When to Act Directly
|
||||
- Clear, approved requirements
|
||||
- Routine tasks (linting, formatting, running tests)
|
||||
- Following established patterns
|
||||
- Fixing obvious bugs
|
||||
|
||||
### When to Refuse
|
||||
- Malicious code requests
|
||||
- Security violations (secrets, credentials)
|
||||
- Anything that violates `rules.md`
|
||||
|
||||
---
|
||||
|
||||
## Working Principles
|
||||
|
||||
### 1. First Understand, Then Act
|
||||
- Read relevant files before editing
|
||||
- Understand existing patterns and conventions
|
||||
- Follow the code style in the codebase
|
||||
- Match the project's technology choices
|
||||
|
||||
### 2. Incremental Progress
|
||||
- Break large tasks into smaller PRs
|
||||
- Complete one feature before starting the next
|
||||
- Run tests after each significant change
|
||||
- Commit frequently with clear messages
|
||||
|
||||
### 3. Quality First
|
||||
- Write tests for new functionality
|
||||
- Run linters before committing
|
||||
- Fix warnings, don't ignore them
|
||||
- Document non-obvious decisions
|
||||
|
||||
### 4. Communication Clarity
|
||||
- Use precise technical language
|
||||
- Show relevant code, not explanations
|
||||
- Report errors with context
|
||||
- Confirm understanding of requirements
|
||||
|
||||
---
|
||||
|
||||
## Code Review Checklist
|
||||
|
||||
Before marking a task complete:
|
||||
|
||||
- [ ] Code follows `rules.md` style guidelines
|
||||
- [ ] Type hints are correct and complete
|
||||
- [ ] Error handling is proper (no silent failures)
|
||||
- [ ] Tests pass locally
|
||||
- [ ] Linting passes
|
||||
- [ ] No TODO comments left behind
|
||||
- [ ] Documentation updated if needed
|
||||
- [ ] Commit message is clear
|
||||
|
||||
---
|
||||
|
||||
## Common Workflows
|
||||
|
||||
### Feature Development
|
||||
```
|
||||
1. Understand requirements
|
||||
2. Check existing patterns in codebase
|
||||
3. Design solution (brief mental model)
|
||||
4. Write code following rules.md
|
||||
5. Write/run tests
|
||||
6. Lint and format
|
||||
7. Commit with clear message
|
||||
8. Report completion
|
||||
```
|
||||
|
||||
### Bug Fix
|
||||
```
|
||||
1. Reproduce the bug (write failing test)
|
||||
2. Locate the source
|
||||
3. Fix the issue
|
||||
4. Verify test passes
|
||||
5. Check for regressions
|
||||
6. Commit fix
|
||||
```
|
||||
|
||||
### Refactoring
|
||||
```
|
||||
1. Understand current behavior
|
||||
2. Design target state
|
||||
3. Make incremental changes
|
||||
4. Preserve tests
|
||||
5. Verify functionality
|
||||
6. Clean up dead code
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Special Considerations
|
||||
|
||||
### WebSocket Services
|
||||
- Implement heartbeat mechanism (30-second intervals)
|
||||
- Handle disconnection gracefully
|
||||
- Include camera position in control frames
|
||||
- Support both update and full sync modes
|
||||
|
||||
### Data Collectors
|
||||
- Inherit from BaseCollector
|
||||
- Implement fetch() and transform() methods
|
||||
- Support incremental updates
|
||||
- Handle API changes gracefully
|
||||
|
||||
### UE5 Integration
|
||||
- Communicate via WebSocket
|
||||
- Send data frames at configurable intervals (default 5 min)
|
||||
- Support auto-cruise and manual modes
|
||||
- Optimize for 4K@120Hz rendering
|
||||
|
||||
### Multi-User Security
|
||||
- JWT tokens with 15-minute expiration
|
||||
- Redis token blacklist for logout
|
||||
- Role-based access control (RBAC)
|
||||
- Audit logging for all actions
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
### When Writing Code
|
||||
```python
|
||||
# File: backend/app/services/collectors/top500.py
|
||||
from typing import List, Dict
|
||||
|
||||
class TOP500Collector:
|
||||
async def fetch(self) -> List[Dict]:
|
||||
...
|
||||
```
|
||||
|
||||
### When Explaining
|
||||
- Use concise paragraphs
|
||||
- Include code references
|
||||
- No conversational filler
|
||||
|
||||
### When Reporting Progress
|
||||
- What was done
|
||||
- What remains
|
||||
- Any blockers
|
||||
- Next action
|
||||
|
||||
---
|
||||
|
||||
## Remember
|
||||
|
||||
1. **Rules are hard constraints** - follow `rules.md` absolutely
|
||||
2. **Context provides understanding** - use `project_context.md` for background
|
||||
3. **Role defines behavior** - follow `agents.md` for how to work
|
||||
4. **Quality over speed** - Enterprise systems require precision
|
||||
5. **Communicate clearly** - Precision in, precision out
|
||||
@@ -11,17 +11,20 @@ To get higher limits, set PEERINGDB_API_KEY environment variable.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from typing import Dict, Any, List
|
||||
from datetime import UTC, datetime
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.services.collectors.base import HTTPCollector
|
||||
|
||||
|
||||
# PeeringDB API key - read from environment variable
|
||||
PEERINGDB_API_KEY = os.environ.get("PEERINGDB_API_KEY", "")
|
||||
logger = get_logger(__name__, service="collector")
|
||||
|
||||
|
||||
class PeeringDBIXPCollector(HTTPCollector):
|
||||
@@ -39,6 +42,7 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -61,7 +65,11 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
if response.status_code == 429:
|
||||
# Rate limited - wait and retry with exponential backoff
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -72,13 +80,21 @@ class PeeringDBIXPCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
@@ -146,6 +162,7 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -167,7 +184,11 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
|
||||
if response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -178,13 +199,21 @@ class PeeringDBNetworkCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
@@ -254,6 +283,7 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
"User-Agent": "Planet-Intelligence-System/1.0 (Python/collector)",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
@property
|
||||
def request_url(self) -> str:
|
||||
base = self._resolved_url or self.base_url
|
||||
@@ -275,7 +305,11 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
|
||||
if response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
@@ -286,13 +320,21 @@ class PeeringDBFacilityCollector(HTTPCollector):
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 429:
|
||||
delay = base_delay * (2**attempt)
|
||||
print(f"PeeringDB rate limited, waiting {delay}s before retry...")
|
||||
logger.warning_event(
|
||||
"PeeringDB rate limited; retrying after delay",
|
||||
event="collector.peeringdb.rate_limited",
|
||||
context={"delay_seconds": delay, "attempt": attempt + 1},
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
last_error = "Rate limited"
|
||||
continue
|
||||
raise
|
||||
|
||||
print(f"Warning: PeeringDB collection failed after {max_retries} retries: {last_error}")
|
||||
logger.warning_event(
|
||||
"PeeringDB collection failed after retries",
|
||||
event="collector.peeringdb.retries_exhausted",
|
||||
context={"max_retries": max_retries, "last_error": last_error},
|
||||
)
|
||||
return {}
|
||||
|
||||
async def fetch(self) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""Space-Track TLE Collector
|
||||
"""Space-Track TLE Collector.
|
||||
|
||||
Collects satellite TLE (Two-Line Element) data from Space-Track.org.
|
||||
API documentation: https://www.space-track.org/documentation
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Dict, Any, List
|
||||
import httpx
|
||||
from typing import Any, Dict, List
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from app.services.collectors.base import BaseCollector
|
||||
import httpx
|
||||
|
||||
from app.core.data_sources import get_data_sources_config
|
||||
from app.core.logging import get_logger
|
||||
from app.core.satellite_tle import build_tle_lines_from_elements
|
||||
from app.services.collectors.base import BaseCollector
|
||||
|
||||
|
||||
logger = get_logger(__name__, service="collector")
|
||||
|
||||
|
||||
class SpaceTrackTLECollector(BaseCollector):
|
||||
@@ -53,10 +57,16 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
password = settings.SPACETRACK_PASSWORD
|
||||
|
||||
if not username or not password:
|
||||
print("SPACETRACK: No credentials configured, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track credentials are not configured; using sample data",
|
||||
event="collector.spacetrack.credentials_missing",
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
logger.info_event(
|
||||
"Space-Track TLE fetch started",
|
||||
event="collector.spacetrack.fetch.start",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
@@ -78,11 +88,17 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
logger.info_event(
|
||||
"Space-Track login response received",
|
||||
event="collector.spacetrack.login.response",
|
||||
context={"status_code": login_response.status_code},
|
||||
)
|
||||
|
||||
if login_response.status_code == 403:
|
||||
print("SPACETRACK: Trying alternate login method...")
|
||||
logger.warning_event(
|
||||
"Space-Track login returned forbidden; trying alternate method",
|
||||
event="collector.spacetrack.login.forbidden",
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
@@ -90,11 +106,6 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
) as alt_client:
|
||||
await alt_client.get(f"{self.site_root}/")
|
||||
|
||||
form_data = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"query": "class/gp/NORAD_CAT_ID/25544/format/json",
|
||||
}
|
||||
alt_login = await alt_client.post(
|
||||
self.login_url,
|
||||
data={
|
||||
@@ -102,77 +113,59 @@ class SpaceTrackTLECollector(BaseCollector):
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
print(f"SPACETRACK: Alt login status: {alt_login.status_code}")
|
||||
logger.info_event(
|
||||
"Space-Track alternate login response received",
|
||||
event="collector.spacetrack.alt_login.response",
|
||||
context={"status_code": alt_login.status_code},
|
||||
)
|
||||
|
||||
if alt_login.status_code == 200:
|
||||
tle_response = await alt_client.get(self.probe_url)
|
||||
if tle_response.status_code == 200:
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records via alt method")
|
||||
logger.info_event(
|
||||
"Space-Track alternate query completed",
|
||||
event="collector.spacetrack.alt_query.completed",
|
||||
context={"record_count": len(data)},
|
||||
)
|
||||
return data
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track login failed; using sample data",
|
||||
event="collector.spacetrack.login.failed",
|
||||
context={"status_code": login_response.status_code},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
tle_response = await client.get(self.probe_url)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
print(f"SPACETRACK: Attempting to fetch TLE data with username: {username}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
timeout=120.0,
|
||||
follow_redirects=True,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
"Accept": "application/json, text/html, */*",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
},
|
||||
) as client:
|
||||
# First, visit the main page to get any cookies
|
||||
await client.get(f"{self.site_root}/")
|
||||
|
||||
# Login to get session cookie
|
||||
login_response = await client.post(
|
||||
self.login_url,
|
||||
data={
|
||||
"identity": username,
|
||||
"password": password,
|
||||
},
|
||||
logger.info_event(
|
||||
"Space-Track TLE query response received",
|
||||
event="collector.spacetrack.query.response",
|
||||
context={"status_code": tle_response.status_code},
|
||||
)
|
||||
print(f"SPACETRACK: Login response status: {login_response.status_code}")
|
||||
print(f"SPACETRACK: Login response URL: {login_response.url}")
|
||||
print(f"SPACETRACK: Login response body: {login_response.text[:500]}")
|
||||
|
||||
if login_response.status_code != 200:
|
||||
print(f"SPACETRACK: Login failed, using sample data")
|
||||
return self._get_sample_data()
|
||||
|
||||
# Query for TLE data (get first 1000 satellites)
|
||||
tle_response = await client.get(self.query_url)
|
||||
print(f"SPACETRACK: TLE query status: {tle_response.status_code}")
|
||||
|
||||
if tle_response.status_code != 200:
|
||||
print(f"SPACETRACK: Query failed, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track TLE query failed; using sample data",
|
||||
event="collector.spacetrack.query.failed",
|
||||
context={"status_code": tle_response.status_code},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
data = tle_response.json()
|
||||
print(f"SPACETRACK: Received {len(data)} records")
|
||||
logger.info_event(
|
||||
"Space-Track TLE fetch completed",
|
||||
event="collector.spacetrack.fetch.completed",
|
||||
context={"record_count": len(data)},
|
||||
)
|
||||
return data
|
||||
except Exception as e:
|
||||
print(f"SPACETRACK: Error - {e}, using sample data")
|
||||
logger.warning_event(
|
||||
"Space-Track TLE fetch failed; using sample data",
|
||||
event="collector.spacetrack.fetch.failed",
|
||||
context={"error": str(e)},
|
||||
)
|
||||
return self._get_sample_data()
|
||||
|
||||
def transform(self, raw_data: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -45,7 +45,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata("earth-bgp-context.md", "earth-bgp-context", "docs_developer", "Earth", 14, "BGP 态势上下文", "BGP Context"),
|
||||
DocsMetadata("earth-interactable-usage.md", "earth-interactable-usage", "docs_developer", "Earth", 16, "智能星球可交互图标接入", "Intelligent Planet Interactable Usage"),
|
||||
DocsMetadata("earth-interactable-clustering.md", "earth-interactable-clustering", "docs_developer", "Earth", 17, "智能星球可交互图标聚类策略", "Intelligent Planet Interactable Clustering"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 17, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("earth-toolbar-overlay-coordination.md", "earth-toolbar-overlay-coordination", "docs_developer", "Earth", 18, "智能星球工具栏与浮层协同", "Intelligent Planet Toolbar and Overlay Coordination"),
|
||||
DocsMetadata("earth-news-sources.md", "earth-news-sources", "docs_developer", "Earth", 19, "智能星球新闻源配置", "Intelligent Planet News Source Configuration"),
|
||||
DocsMetadata("frontend-admin-frontend-context.md", "frontend-admin-frontend-context", "docs_developer", "Frontend", 20, "控制台前端结构", "Admin Frontend Context"),
|
||||
DocsMetadata("frontend-layout-guidelines.md", "frontend-layout-guidelines", "docs_developer", "Frontend", 21, "前端布局指南", "Frontend Layout Guidelines"),
|
||||
@@ -56,7 +56,7 @@ DOCS_METADATA: tuple[DocsMetadata, ...] = (
|
||||
DocsMetadata("backend-datasources-api-performance.md", "backend-datasources-api-performance", "docs_developer", "Backend", 33, "数据源 API 性能", "Datasource API Performance"),
|
||||
DocsMetadata("data-job-earth-sync-architecture.md", "data-job-earth-sync-architecture", "docs_developer", "Backend", 34, "数据作业与 Outbox 技术架构", "Data Jobs and Outbox Architecture"),
|
||||
DocsMetadata("backend-enum-contracts.md", "backend-enum-contracts", "docs_developer", "Backend", 35, "后端枚举与字符串兼容契约", "Backend Enum and String Compatibility Contract"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 36, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("location-pipeline-development.md", "location-pipeline-development", "docs_developer", "Backend", 35, "通用位置估算管线开发说明", "Shared Location Resolution Pipeline Development Guide"),
|
||||
DocsMetadata("earth-news-live-streams-collector-format.md", "earth-news-live-streams-collector-format", "docs_developer", "Backend", 36, "新闻直播采集格式", "News Live Streams Collector Format"),
|
||||
DocsMetadata("docs-gatekeeper-development.md", "docs-gatekeeper-development", "docs_developer", "Backend", 37, "Docs Gatekeeper 开发说明", "Docs Gatekeeper Development Guide"),
|
||||
DocsMetadata("agents-aiprovider.md", "agents-aiprovider", "docs_developer", "Agents", 40, "AI Provider 指南", "AI Provider Guide"),
|
||||
|
||||
@@ -8,6 +8,42 @@ This project follows the repository versioning rule:
|
||||
- `improvement` -> `+0.0.1`(bugfix + 小功能混合)
|
||||
- `bugfix` -> `+0.0.1`
|
||||
|
||||
## [0.73.0] — 2026-06-29
|
||||
|
||||
Released: 2026-06-29
|
||||
|
||||
### Highlights
|
||||
- 新增前端统一 i18n 基础设施,让认证页、Docs UI、控制台外壳、导航、搜索和核心共享组件共用 `zh-CN` / `en-US` 语言状态。
|
||||
- 控制台侧边栏偏好面板接入语言与主题切换,并修复一屏高度链、账号区、状态指示器和英文态文案裁切问题。
|
||||
- 扩展 harness 与 smoke 覆盖,确保 admin shell 高度、移动/缩放布局、语言切换、搜索、Docs 和核心控制台交互在发布前被验证。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `frontend/src/i18n/`,用 `i18next` / `react-i18next` 维护资源、locale 映射、Docs 兼容和过渡期 legacy UI 翻译桥。
|
||||
- 将 AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast、MarkdownRenderer 和 Users 页迁移到统一翻译资源。
|
||||
- 补齐 Planet Content、Collected Data、System Logs、Datasources、Settings 和 Collection Management 等英文态残留翻译,并覆盖动态计数字符串。
|
||||
- 改进控制台侧边栏账号区、语言 switch、状态 pill 自适应宽度和 admin shell overflow ownership,避免首屏溢出和状态词裁切。
|
||||
- 更新 i18n 计划、控制台前端上下文、harness 文档和规则,记录语言迁移边界、状态指示器布局约束和一屏验证要求。
|
||||
|
||||
---
|
||||
|
||||
## [0.72.0] — 2026-06-29
|
||||
|
||||
Released: 2026-06-29
|
||||
|
||||
### Highlights
|
||||
- 将 agent 入口收敛到单一 `AGENTS.md`,并让 harness 明确阻止小写入口再次分叉。
|
||||
- 新增完整本地 harness 验证层,覆盖 backend/frontend/docs/security 静态规则、前端 build 和 Playwright 路由/交互 smoke。
|
||||
- 扩展 Earth News 与控制台 smoke,确保新闻源测试、新增取消、手动新闻组创建、桌面/移动菜单和 zoom 布局都在发布前验证。
|
||||
|
||||
### Added / Fixed / Improved
|
||||
- 新增 `scripts/harness/*` 规则检查、doctor、validate 和前端 smoke 脚本,并将未跟踪 harness 设施纳入发布。
|
||||
- 清理 SpaceTrack 与 PeeringDB collector 的 stdout/debug 输出,改用结构化日志并移除 SpaceTrack 不可达重复 fetch 路径。
|
||||
- 强化控制台布局、auth 表单、Docs 页面、Earth shell 和 Earth toolbar 的响应式与无障碍细节。
|
||||
- 同步 README、CODEMAP、HARNESS、harness audit、用户手册、快速开始和开发者文档,明确当前 Web Earth / React admin / FastAPI / aiprovider 边界。
|
||||
- 将 backend、frontend、docs 和 Earth News 检查纳入 `scripts/harness/quick-check.sh` 与 `scripts/harness/validate.sh` 的稳定验证面。
|
||||
|
||||
---
|
||||
|
||||
## [0.71.1] — 2026-06-26
|
||||
|
||||
Released: 2026-06-26
|
||||
|
||||
@@ -9,7 +9,7 @@ or release workflows.
|
||||
Existing project rules are authoritative:
|
||||
|
||||
1. `rules.md`
|
||||
2. `agents.md`
|
||||
2. `AGENTS.md`
|
||||
3. Current implementation docs under `docs/technical/`
|
||||
4. Existing scripts, especially `planet.sh`
|
||||
5. Existing Gitea workflow files under `.gitea/workflows/`
|
||||
@@ -18,6 +18,17 @@ When harness guidance conflicts with any of the above, keep the existing rule,
|
||||
do not overwrite the existing workflow, and add a compatibility note here or in
|
||||
`docs/harness-audit.md`.
|
||||
|
||||
For frontend or documentation audits, also read the Rules Coverage Evidence
|
||||
section in `docs/harness-audit.md`. It maps `rules.md` clauses to the current
|
||||
static checks, Playwright smoke coverage, and remaining manual review areas, so
|
||||
an agent can distinguish a proved harness pass from a rule that still needs
|
||||
human-quality inspection.
|
||||
|
||||
When the user describes work with product words rather than module names, use
|
||||
the `rules.md` **Agent Discovery Index** before deciding which modules to load.
|
||||
It maps Chinese phrases such as `一屏`, `高度没控住`, `文档`, `数据源`,
|
||||
`地球`, `模型供应商`, and `发版` to the required rule modules.
|
||||
|
||||
## Starting Work
|
||||
|
||||
Recommended startup flow:
|
||||
@@ -71,8 +82,12 @@ git diff --unified=0 HEAD -- <path>
|
||||
| Tier | Command | What It Does |
|
||||
| --- | --- | --- |
|
||||
| Doctor | `scripts/harness/doctor.sh` | Checks required files, required tools, optional delivery tools, and forbidden frontend lockfiles. |
|
||||
| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, and CI backend smoke tests. |
|
||||
| Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, optional Helm checks, and opt-in Docker image smoke builds. |
|
||||
| Security | `scripts/harness/security-check.sh` | Checks that environment/private-key files are not tracked and scans for high-confidence committed secret tokens. |
|
||||
| Backend Rules | `scripts/harness/backend-rules-check.sh` | Checks backend app Python for direct `print()`, `breakpoint()`, and `pdb.set_trace()` debug calls so service code uses structured logging. |
|
||||
| Frontend Rules | `scripts/harness/frontend-rules-check.sh` | Checks Bun-only scripts, admin route manifest coherence, literal internal route links, admin search route targets, frontend debug output, native button safety, icon-button accessibility, no nested Cards, no AntD/Space layout primitives, ConnectionTestInput usage, admin/docs shell height-chain sizing, viewport-scaled font sizes, zero letter spacing, and high-signal UI rule warnings. |
|
||||
| Docs Consistency | `scripts/harness/docs-consistency-check.sh` | Checks frontend Docs metadata against backend Gatekeeper metadata, public Docs registration, full technical-doc bilingual file pairs, public doc links, readable link titles, language-scoped technical links, README/project-context admin stack drift, supported credential collector contracts, manual console route coverage against the actual admin manifest, documented UI route drift, documented `?section=` deep-link validity against the actual admin section config in technical docs and active plan docs, and the harness rules-coverage notes. |
|
||||
| Quick | `scripts/harness/quick-check.sh` | Runs doctor, whitespace diff check, shell syntax checks, security scan, backend/frontend/doc consistency checks, and CI backend smoke tests. |
|
||||
| Full | `scripts/harness/validate.sh` | Runs quick check, frontend Bun install/build, Playwright route smoke, optional Helm checks, and opt-in Docker image smoke builds. |
|
||||
|
||||
Docker image smoke builds are expensive and are off by default:
|
||||
|
||||
@@ -80,6 +95,41 @@ Docker image smoke builds are expensive and are off by default:
|
||||
PLANET_HARNESS_DOCKER_SMOKE=1 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
Frontend Playwright smoke runs by default in full validation after the frontend
|
||||
build. It starts a local Vite preview and checks the `/` to Earth redirect,
|
||||
public pages, unknown-route login fallback, protected admin route login
|
||||
fallback, authenticated unknown-route fallback to `/admin`, Docs loading with
|
||||
mocked API content, Docs detail page
|
||||
language/theme/search interactions, every Docs catalog slug exposed by the
|
||||
frontend/backend metadata, the Earth iframe entry point, login error handling,
|
||||
register + email verification, password reset, standalone email verification,
|
||||
and authenticated `super_admin` rendering for every admin route plus core
|
||||
`section` deep links derived from the actual admin route and section config.
|
||||
Authenticated admin
|
||||
checks run at desktop size, mobile size, and 125% / 150% zoom; desktop and
|
||||
mobile passes also fail on global horizontal overflow so table/detail panels
|
||||
must keep overflow ownership inside their own scroll regions. To enforce the
|
||||
existing `rules.md` `uiux` one-screen workspace rule, admin shell pages have a
|
||||
hard rendered check: the shell must resolve to the viewport height through the
|
||||
root 100% height chain, `#root`/document/body must not gain vertical overflow,
|
||||
and the desktop sidebar account/preferences area must remain inside the first
|
||||
viewport while the nav owns any excess scrolling. The smoke also
|
||||
derives the sidebar menu from the actual admin route manifest and clicks every
|
||||
visible `super_admin` menu entry on both desktop and mobile viewports, then
|
||||
exercises safe interaction paths for admin search, section tabs, the AI settings
|
||||
shortcut, logs view switching, user dialog opening, and data distribution toggles.
|
||||
It also exercises Earth News source testing, add/cancel source draft behavior,
|
||||
and manual news group creation against mocked `/earth/news-*` APIs.
|
||||
Documented AI and collector
|
||||
deep links such as `/ai?section=integrations`, `/ai?section=playground`, and
|
||||
`/collection-management?section=collector_credentials` are part of the rendered
|
||||
smoke surface:
|
||||
|
||||
```bash
|
||||
PLANET_HARNESS_FRONTEND_SMOKE=0 scripts/harness/validate.sh
|
||||
PLANET_HARNESS_FRONTEND_SMOKE_PORT=4174 scripts/harness/validate.sh
|
||||
```
|
||||
|
||||
## Environment Requirements
|
||||
|
||||
Required for normal development:
|
||||
@@ -89,6 +139,12 @@ Required for normal development:
|
||||
- `bun` for frontend dependency and build execution
|
||||
- Python resolved by `uv` from the root `pyproject.toml`
|
||||
|
||||
Harness command lookup first checks the current non-interactive `PATH`. If a
|
||||
required tool is not visible there, `scripts/harness/lib.sh` asks the user's
|
||||
login interactive shell (`$SHELL`, then `zsh`, then `bash`) for the command
|
||||
path. This avoids hardcoding a dotfile while still covering agent environments
|
||||
that do not inherit the user's normal shell setup.
|
||||
|
||||
Required for full local stack operation:
|
||||
|
||||
- Docker and Docker Compose
|
||||
@@ -121,6 +177,14 @@ No automatic hooks are installed in this phase. Manual reminders:
|
||||
|
||||
- Run `scripts/harness/quick-check.sh` before handing off small changes.
|
||||
- Run `scripts/harness/validate.sh` before larger cross-subsystem changes.
|
||||
- Run `scripts/harness/security-check.sh` after touching config, auth,
|
||||
credentials, docs examples, or generated fixtures.
|
||||
- Run `scripts/harness/backend-rules-check.sh` after backend service edits to
|
||||
catch direct stdout/debugger calls before they reach runtime logs.
|
||||
- Run `scripts/harness/frontend-rules-check.sh` after frontend edits to expose
|
||||
route, package-manager, debug-output, and UI rule warnings.
|
||||
- Run `scripts/harness/docs-consistency-check.sh` after docs edits or feature
|
||||
route changes.
|
||||
- Add focused tests before modifying backend service behavior or frontend
|
||||
workflows.
|
||||
- For docs changes, run the checks listed in
|
||||
@@ -136,6 +200,9 @@ No automatic hooks are installed in this phase. Manual reminders:
|
||||
4. Make the smallest behavior-preserving or feature-scoped change.
|
||||
5. Run `scripts/harness/quick-check.sh` or a narrower documented command.
|
||||
6. Update relevant docs when behavior, workflow, or operations change.
|
||||
7. For rendered frontend changes, verify the affected route with Playwright or
|
||||
the full harness smoke, because `bun run build` alone does not prove page
|
||||
usability.
|
||||
|
||||
### Bug Fix
|
||||
|
||||
@@ -149,7 +216,8 @@ No automatic hooks are installed in this phase. Manual reminders:
|
||||
|
||||
1. Read `docs/documentation-coverage-rules.md`.
|
||||
2. Route docs by audience: UI users, operations, or second-party developers.
|
||||
3. Keep Chinese and English public docs consistent when a public doc pair exists.
|
||||
3. Keep Chinese and English technical docs paired by filename; public Docs also
|
||||
need matching frontend/backend metadata when exposed in the product Docs UI.
|
||||
4. Run the repository-specific docs checks that match the changed files.
|
||||
|
||||
### Release Or Delivery Change
|
||||
@@ -161,7 +229,15 @@ release process.
|
||||
## Implementation Notes
|
||||
|
||||
- `docs/harness-audit.md` records the discovery pass that led to this harness.
|
||||
- `AGENTS.md` is a compatibility entry point for tools that expect the uppercase
|
||||
filename. The existing `agents.md` file remains in place.
|
||||
- `AGENTS.md` is the single authoritative agent guide. The older lowercase
|
||||
`agents.md` entry has been merged into it and should remain absent.
|
||||
- `CODEMAP.md` is intentionally high level; deeper subsystem docs stay in
|
||||
`docs/technical/{zh,en}/`.
|
||||
- `scripts/harness/frontend-smoke.mjs` is a lightweight route/section smoke
|
||||
with mocked API data. It proves route shells, auth guards, and primary admin
|
||||
sections render, but it is not a replacement for feature-specific browser QA
|
||||
against a real backend.
|
||||
- Frontend smoke prints phase-level progress by default. Use
|
||||
`PLANET_FRONTEND_SMOKE_PROGRESS=verbose` to print each route/menu/doc item
|
||||
when diagnosing a slow or failing smoke run, or set it to `0` to suppress
|
||||
progress lines.
|
||||
|
||||
@@ -25,12 +25,11 @@ compatibility note, not a replacement for existing rules or architecture docs.
|
||||
|
||||
| File | Status | Notes |
|
||||
| --- | --- | --- |
|
||||
| `agents.md` | Present | Existing root agent behavior guide. It references `rules.md` and `project_context.md`. |
|
||||
| `AGENTS.md` | Present | Single authoritative agent behavior guide. It references `rules.md`, `project_context.md`, harness validation, and high-risk areas. |
|
||||
| `rules.md` | Present | Mandatory modular rules. Always load `core`, `security`, and `workflow`; load topic modules as needed. |
|
||||
| `project_context.md` | Present | Static context. Some roadmap-era stack details are older than the current README/docs. |
|
||||
| `.claude/commands/*.md` | Present | Existing command docs for cleanup, docs, goal-driven, and release workflows. |
|
||||
| `.codex/skills/*.md` | Present | Existing local skills for cleanup, docs, goal-driven, and release. |
|
||||
| `AGENTS.md` | Added by harness | Compatibility entry point that points to existing rules and harness docs. |
|
||||
|
||||
## Existing CI Gates
|
||||
|
||||
@@ -69,13 +68,12 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
|
||||
|
||||
## Missing Or Unclear Areas
|
||||
|
||||
- README previously listed `AGENTS.md` in the project tree while only lowercase
|
||||
`agents.md` existed. The harness adds uppercase `AGENTS.md` as a compatibility
|
||||
wrapper and preserves `agents.md`.
|
||||
- `project_context.md` includes older roadmap assumptions such as Celery, Kafka,
|
||||
TimescaleDB, MinIO, and UE5 as active stack elements. The current README and
|
||||
technical docs describe Web Earth, React admin, FastAPI, PostgreSQL/Redis, and
|
||||
`aiprovider` as the active local development shape.
|
||||
- The older lowercase `agents.md` entry has been merged into uppercase
|
||||
`AGENTS.md` so coding agents and harness tools use one source of truth.
|
||||
- `project_context.md` originally included older roadmap assumptions such as
|
||||
Celery, Kafka, TimescaleDB, MinIO, and UE5 as active stack elements. The
|
||||
harness pass updated it to separate active stack facts from future directions;
|
||||
current code and technical docs still remain authoritative when details drift.
|
||||
- No safe automatic hook system was already configured. This phase documents
|
||||
manual reminders instead of adding hooks.
|
||||
- `.github/workflows/` is absent by design; CI is under `.gitea/workflows/`.
|
||||
@@ -84,19 +82,77 @@ The repository uses `.gitea/workflows/`, not `.github/workflows/`.
|
||||
|
||||
| Conflict Or Tension | Resolution |
|
||||
| --- | --- |
|
||||
| Prompt suggested `AGENTS.md`; repository already had `agents.md`. | Added a minimal uppercase compatibility entry and preserved the existing lowercase guide. |
|
||||
| Prompt suggested `AGENTS.md`; repository already had `agents.md`. | Merged the lowercase guide into uppercase `AGENTS.md`; harness doctor now requires `AGENTS.md` and keeps `agents.md` absent to prevent split authority. |
|
||||
| Harness validation could duplicate CI. | Added wrapper scripts that call existing commands and mirror current CI gates where practical. |
|
||||
| Full Docker smoke builds are expensive locally. | Kept them opt-in with `PLANET_HARNESS_DOCKER_SMOKE=1`. |
|
||||
| Internal harness docs could clutter public Docs UI. | Kept `docs/HARNESS.md` and `docs/harness-audit.md` as repository docs, not product Docs entries. |
|
||||
| Existing frontend toolchain is Bun-only. | Harness scripts and docs use Bun only and flag npm/pnpm/yarn lockfiles as failures. |
|
||||
| Agents often miss user-installed Bun or uv in non-interactive shells. | Added `scripts/harness/lib.sh` to resolve tools from current `PATH` first and then the user's login interactive shell without hardcoding a dotfile. |
|
||||
| Always-loaded security rules had no standalone harness gate. | Added `scripts/harness/security-check.sh` to block tracked `.env` / key files and scan for high-confidence committed private keys or provider tokens; quick-check now runs it. |
|
||||
| Build success does not prove frontend page usability. | Added static frontend rules/doc checks and a Playwright route smoke for public pages, protected admin fallback, Docs loading and detail interactions, Earth iframe entry, login/register/verification/password-reset interactions, authenticated admin route/section rendering with mocked API data across desktop, mobile, and 125% / 150% zoom, plus manifest-derived desktop/mobile menu navigation and safe search/tab/dialog/Earth News interactions. |
|
||||
| Route fallback behavior can regress even when every named page renders. | Extended the frontend smoke to verify `/` redirects to Earth, unauthenticated unknown routes show the login page, and authenticated unknown routes navigate back to `/admin`. |
|
||||
| Frontend smoke route lists can drift from `AdminRoutes` and resource-page sections. | Updated the smoke to derive protected route checks and authenticated section deep-link checks from `AdminRoutes.tsx` and `PlainResourcePages.tsx`, including redirect-only `/alerts`. |
|
||||
| Docs smoke mocks can drift from the product Docs catalog. | Updated the frontend smoke to derive mocked Docs catalog/content from `frontend/src/pages/Docs/docs-content.ts` plus backend Gatekeeper access metadata, then open every Chinese Docs catalog slug. |
|
||||
| User manuals can miss a real console menu entry after route changes. | Added a docs consistency check that compares the manual console overview tables with `frontend/src/admin/routes/manifest.tsx`; fixed the missing `/docs` row in both user manuals. |
|
||||
| Rendered pages can still contain broken internal shortcuts. | Added literal internal route-link checks and an interaction smoke for the AI settings shortcut; this caught and fixed a stale `/admin/settings` link that should point to `/settings`. |
|
||||
| Global search entries can drift because their route targets live in data objects rather than JSX links. | Added a frontend rules check that validates every admin search `routePath` against the actual frontend route set. |
|
||||
| Responsive styling fixes can satisfy one viewport by breaking the no-viewport-font rule. | Added a frontend rules failure for `font-size` values that use viewport or container query width units, and replaced public auth shell `vw` font sizing with fixed desktop/mobile sizes. |
|
||||
| Typography polish can accidentally reintroduce squeezed non-zero letter spacing. | Normalized active frontend `letter-spacing` values to `0` and made the frontend rules check fail non-zero `letter-spacing` / `letterSpacing` declarations, with only inherit/default-zero forms allowed. |
|
||||
| Native buttons can accidentally submit forms or keep controls clickable while loading after a props-spread reorder. | Added a frontend rules failure for TSX `<button>` elements without explicit `type` and for buttons whose `disabled` state can be overridden by a later props spread; fixed the data distribution buttons and auth button disabled ordering. |
|
||||
| Admin/docs shell layouts can reintroduce brittle viewport sizing after a responsive fix. | Changed the admin and Docs route shells to use the existing `html/body/#root` 100% height chain, and added a frontend rules failure for exact `100vh` / `100vw` shell sizing in those CSS files. |
|
||||
| Compact workspaces can drift back into card-in-card layouts or implicit AntD `Space` wrappers. | Added frontend rules failures for nested `Card` components, AntD imports, and `<Space>` layout primitives in active frontend source. |
|
||||
| Connection-test controls can drift back into detached toolbar buttons. | Added a shared `ConnectionTestInput` suffix pattern for AI Provider and WebSearch Base URL fields, disabled WebSearch configuration/test controls when the tool is off, and made the frontend rules check fail detached AI/WebSearch connection-test buttons. |
|
||||
| Public docs can reference stale admin section URLs. | Added docs consistency validation for documented `?section=` links and rendered smoke coverage for documented AI / collector deep links. |
|
||||
| Active plan docs can preserve old admin deep-link assumptions after the technical docs are corrected. | Extended docs consistency checks to active `docs/plans/*.md` files for stale admin tab-query terms and actual `?section=` validity; corrected the docs audience split plan to current section routes. |
|
||||
| Top-level README can drift from the actual frontend stack while technical docs stay current. | Updated README from Ant Design Pro to Tactile UI / Radix primitives / lucide-react and added README stale admin-stack terms to docs consistency checks. |
|
||||
| Agent background context can reintroduce inactive stack assumptions. | Updated `project_context.md` and the root agent guide to label current stack facts versus future directions, then added exact stale-stack patterns for them to docs consistency checks. |
|
||||
| Docs `?section=` validation can drift if the harness owns its own route/section table. | Changed the docs consistency check to derive section keys from `AdminRoutes.tsx` and `PlainResourcePages.tsx` resource configs before validating documented deep links. |
|
||||
| Public Docs can drift between frontend catalog metadata and backend Gatekeeper authorization metadata. | Added a docs consistency check that compares filename, slug, group, order, and bilingual titles across both metadata sources; aligned existing order drift for toolbar overlay and location pipeline docs. |
|
||||
| Non-public technical docs can silently become Chinese-only or English-only. | Added a full `docs/technical/{zh,en}` filename-pair check so every technical Markdown file has a same-named counterpart before docs consistency passes. |
|
||||
| Credentialed collector docs can drift from backend support wiring. | Added docs consistency validation for every built-in collector marked `requires_credentials=true` and `credential_status=supported`: it must have a provider, default credential guide, supported connectivity provider, frontend credential UI/guidance, a regression test, and zh/en connectivity documentation. |
|
||||
| Backend collectors can leak debug output or credential-adjacent context through stdout. | Replaced SpaceTrack and PeeringDB collector `print()` calls with structured logger events, removed unreachable duplicate SpaceTrack fetch code, and added `scripts/harness/backend-rules-check.sh` to block future backend app `print()`, `breakpoint()`, or `pdb.set_trace()` calls. |
|
||||
|
||||
## Rules Coverage Evidence
|
||||
|
||||
This matrix records how the current harness checks the `rules.md` modules that
|
||||
matter for this frontend and documentation pass. "Automated" means the listed
|
||||
command fails when the rule regresses. "Smoke" means the rendered product route
|
||||
or interaction is opened with Playwright. "Manual" means the rule is still a
|
||||
judgment call and must be inspected during review.
|
||||
|
||||
Before using this matrix, start from `rules.md`'s **Agent Discovery Index** when
|
||||
the user describes work with Chinese/product terms instead of module names. The
|
||||
index is the routing layer; this table is the coverage/evidence layer.
|
||||
|
||||
| `rules.md` Area | Rule Surface | Harness Evidence | Remaining Review |
|
||||
| --- | --- | --- | --- |
|
||||
| `core` | Remove stale transitional paths, duplicated helpers, and naming drift after large changes. | `scripts/harness/docs-consistency-check.sh` blocks known stale stack terms, old `?tab=` links, public Docs metadata drift, and README/project context drift. `scripts/harness/frontend-rules-check.sh` blocks repeated detached AI/WebSearch connection-test buttons by requiring `ConnectionTestInput`. | Naming quality, function size, and whether a new abstraction is worth keeping remain manual review items. |
|
||||
| `core` | Keep one source of truth for route, Docs, and section state. | Frontend route, admin manifest, admin search targets, Docs catalog metadata, backend Gatekeeper metadata, manual route tables, and documented `?section=` links are all parsed from source and compared by `frontend-rules-check.sh`, `docs-consistency-check.sh`, and `frontend-smoke.mjs`. | Business-state ownership inside feature components still needs focused review when behavior changes. |
|
||||
| `security` | Do not commit secrets, tracked env files, private keys, or exposed tokens. | `scripts/harness/security-check.sh` fails on tracked `.env` / private-key files and high-confidence provider tokens. `backend-rules-check.sh` blocks backend stdout/debugger calls, and `frontend-rules-check.sh` fails frontend console output that includes token material. | Whether a newly added setting should be masked or stored server-side still requires feature-specific review. |
|
||||
| `workflow` | Frontend package management must stay Bun-only. | `scripts/harness/doctor.sh` and `frontend-rules-check.sh` fail forbidden frontend lockfiles and `npm` / `pnpm` / `yarn` script usage. `validate.sh` uses Bun for install, build, preview, and smoke. | New dependency legitimacy and maintenance quality are manual unless a dependency is actually added. |
|
||||
| `workflow` | Agents should find `bun` and `uv` even when non-interactive `PATH` is incomplete. | `scripts/harness/lib.sh` checks the current `PATH`, then asks `$SHELL`, `zsh`, and `bash` login interactive shells for the command path without hardcoding a dotfile. `doctor.sh`, `quick-check.sh`, and `validate.sh` all source it. | System package installation remains outside harness scope and should be reported instead of auto-fixed. |
|
||||
| `docs` | Keep public Docs whitelist-driven and synchronized with backend authorization metadata. | `docs-consistency-check.sh` compares frontend Docs metadata against backend Gatekeeper metadata, verifies files exist for both languages, checks public link titles, and blocks missing zh/en technical doc pairs. `frontend-smoke.mjs` opens every Chinese Docs catalog slug plus detail/search/language/theme interactions. | Quality of prose, examples, and whether a doc should be public are still editorial review items. |
|
||||
| `docs` | User manuals must match real console routes and deep links. | `docs-consistency-check.sh` compares manual console tables with `frontend/src/admin/routes/manifest.tsx` and validates documented `?section=` links from actual `AdminRoutes.tsx` plus `PlainResourcePages.tsx` section config. | Screenshots and UI-copy nuance are not exhaustively validated. |
|
||||
| `uiux` | Admin pages are compact single-screen workspaces with explicit overflow ownership. | `frontend-rules-check.sh` fails missing admin shell height-chain declarations (`.admin-theme-root`, `.admin`, `.admin__sider`, `.admin__nav-scroll`, `.admin__account`, `.admin__content`, `.admin__content-inner`), warns on suspicious `overflow: hidden`, and blocks exact `100vh` / `100vw` shell sizing in admin/Docs CSS. `frontend-smoke.mjs` checks every admin route at desktop/mobile and verifies `.admin` equals viewport height, `#root`/document/body have no vertical overflow, and desktop sidebar account/preferences stay in the first viewport. Zoom passes still cover 125% / 150% rendering. | Visual density, hierarchy, and whether a scroll owner feels ergonomic remain manual QA. |
|
||||
| `uiux` | Controls use expected patterns and accessible icon buttons. | `frontend-rules-check.sh` blocks icon `Button` without `aria-label` and `title`, native `<button>` without explicit `type`, nested Cards, AntD imports, `<Space>`, and detached connection-test buttons. Smoke exercises search, tabs, dialogs, data toggles, and connection-test actions. | Native buttons with visible text are not treated as icon-only by static checks; semantics still need review when adding custom controls. |
|
||||
| `uiux` | Text should fit, avoid viewport-scaled font sizes, and keep letter spacing at zero. | `frontend-rules-check.sh` fails viewport/container-width font-size units and non-zero `letter-spacing` / `letterSpacing`. `frontend-smoke.mjs` checks rendered routes for global overflow across desktop/mobile. | Per-element text clipping without page-level overflow is not exhaustively detected and needs visual review for changed screens. |
|
||||
| `frontend` | Keep shared behavior in reusable components and existing project patterns. | `frontend-rules-check.sh` enforces shared `ConnectionTestInput`, route/link/search consistency, no debug output, native button safety, Tactile/Radix/lucide direction instead of AntD/Space, and whitelist-driven public Docs. `bun x tsc --noEmit` and `bun run build` verify TypeScript/build health. | Broad casts, inline styles, and overflow issues are warnings when context may be legitimate; review changed lines before accepting them. |
|
||||
| `frontend` | Responsive adaptations must preserve the primary action path. | `frontend-smoke.mjs` clicks every visible admin menu entry on desktop and mobile, opens protected routes unauthenticated and authenticated, verifies root/unknown route fallback, and exercises core auth flows. | Deep feature workflows beyond smoke data, such as destructive or long-running actions, require targeted tests before behavior changes. |
|
||||
| `earth` | Earth render work needs real rendering checks. | Full smoke opens `/earth` and verifies the `3D Earth` iframe entry point. Earth News settings routes are included through the admin manifest/menu smoke, mocked `/earth/news-*` API responses, source test, add/cancel source draft, and manual news group creation checks. The broader Earth-specific layer/depth rules remain in `rules.md` and Earth docs. | The harness still does not claim full 3D layer visual verification; layer-depth and picking changes need targeted browser/canvas QA. |
|
||||
|
||||
## Harness Files Added
|
||||
|
||||
| File | Purpose |
|
||||
| --- | --- |
|
||||
| `AGENTS.md` | Compatibility agent entry point. |
|
||||
| `AGENTS.md` | Single authoritative agent guide and coding-agent entry point. |
|
||||
| `docs/HARNESS.md` | Harness workflow, validation tiers, conflict policy, and manual reminders. |
|
||||
| `CODEMAP.md` | High-level codebase map and validation references. |
|
||||
| `scripts/harness/lib.sh` | Shared command lookup and run helpers. |
|
||||
| `scripts/harness/doctor.sh` | Environment and repository-shape check. |
|
||||
| `scripts/harness/security-check.sh` | High-confidence secret and tracked environment/key file check. |
|
||||
| `scripts/harness/backend-rules-check.sh` | Backend app debug-call guard for direct stdout/debugger usage. |
|
||||
| `scripts/harness/frontend-rules-check.sh` | Bun-only, route manifest, literal internal link, admin-search route target, debug-output, native-button safety, icon-button accessibility, Card nesting, AntD/Space avoidance, ConnectionTestInput, admin shell one-screen height-chain declarations, admin/docs shell viewport sizing, viewport-font, zero-letter-spacing, and UI rules static check. |
|
||||
| `scripts/harness/docs-consistency-check.sh` | Frontend/backend Docs metadata alignment, public Docs metadata, full technical-doc bilingual pair, link-title, language-scoped technical link, supported credential collector contracts, manual console route coverage, documented route, admin-config-derived section deep-link consistency, and harness rules-coverage note check. |
|
||||
| `scripts/harness/frontend-smoke.mjs` | Playwright route, Docs detail/language/theme/search interaction, public auth form interaction, desktop/mobile/zoom rendering, admin shell one-screen/overflow checks, safe admin navigation/search/tab/dialog/Earth News interactions, and authenticated admin route/section smoke for the built frontend preview. |
|
||||
| `scripts/harness/quick-check.sh` | Fast deterministic local validation. |
|
||||
| `scripts/harness/validate.sh` | Full local validation wrapper with optional delivery smoke. |
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
|
||||
当前重点入口:
|
||||
|
||||
- [控制台 i18n 接入计划](/home/ray/dev/linkong/planet/docs/plans/admin-console-i18n-plan.md)
|
||||
- [Earth Mobile Drawer UI Plan](/home/ray/dev/linkong/planet/docs/plans/earth-mobile-drawer-ui-plan.md)
|
||||
- [Earth Compute Center BGP Style Plan](/home/ray/dev/linkong/planet/docs/plans/earth-compute-center-bgp-style-plan.md)
|
||||
- [Earth Renderer Architecture Separation Plan](/home/ray/dev/linkong/planet/docs/plans/earth-renderer-architecture-separation-plan.md)
|
||||
|
||||
62
docs/plans/admin-console-i18n-plan.md
Normal file
62
docs/plans/admin-console-i18n-plan.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 控制台 i18n 接入计划
|
||||
|
||||
**状态**:基础设施已落地,大型业务页迁移继续进行
|
||||
**创建日期**:2026-06-29
|
||||
**核心目标**:把 Docs 已有的中英文文档能力提升为前端统一 i18n 体系,让未登录认证页、Docs UI、控制台外壳、导航、搜索和核心工作台文案共用同一个语言状态。
|
||||
|
||||
## 背景
|
||||
|
||||
Docs 站点已经有 `zh` / `en` 文档目录、Gatekeeper 权限和 `/api/v1/docs/{lang}/{slug}` 内容接口,但语言状态只保存在 `docs-lang`,不影响控制台。控制台页面、搜索索引、toast、dialog、表格和认证页仍以中文硬编码为主,导致用户切到英文文档后,控制台仍是中文。
|
||||
|
||||
本计划把前端语言偏好收敛到 `planet-locale`,默认 `zh-CN`,支持 `en-US`。Docs 继续使用后端现有 `zh` / `en` 文档接口,通过前端映射与全局 locale 对齐。
|
||||
|
||||
## 设计决策
|
||||
|
||||
- 使用 `i18next` 和 `react-i18next` 作为统一 i18n 层,避免长期维护自研插值、hook 和资源加载逻辑。
|
||||
- 前端统一语言枚举为 `zh-CN` / `en-US`;Docs 请求继续转换为 `zh` / `en`,新闻接口继续使用已有 `zh-CN` / `en-US` 口径。
|
||||
- 语言偏好首版只保存在浏览器 `localStorage`,不新增后端用户设置字段。
|
||||
- `docs-lang` 保留为兼容读取和写入项,让已访问过 Docs 的浏览器能平滑迁移。
|
||||
- 静态路由、导航、搜索目标和通用组件使用显式翻译 key;大型业务页在迁移期间通过 legacy UI 翻译桥补足常见硬编码文案。
|
||||
|
||||
## 分期
|
||||
|
||||
### P1:统一语言基础设施
|
||||
|
||||
- 在 `frontend/src/i18n/` 下维护 locale 类型、资源、初始化和 `useLocale()`。
|
||||
- 在 `frontend/src/main.tsx` 里初始化 i18n,并同步 `document.documentElement.lang`。
|
||||
- 在认证页和控制台侧边栏偏好面板提供语言切换入口。
|
||||
|
||||
### P2:高复用界面迁移
|
||||
|
||||
- 迁移 Docs UI、AdminLayout、route manifest、admin search、Auth、DataTable、Dialog、Toast 和 MarkdownRenderer。
|
||||
- 搜索索引按当前语言展示,同时保留中英文关键词以免降低可发现性。
|
||||
- 用户管理页作为独立业务页示范,迁移表头、按钮、toast、校验提示、角色和 Gatekeeper 标签。
|
||||
|
||||
当前已完成统一 `planet-locale`、Docs 兼容映射、认证页和控制台外壳语言入口、共享组件 key 化,以及 legacy UI 翻译桥。后续工作集中在把大型业务页从过渡桥迁移到显式 key。
|
||||
|
||||
### P3:大型业务页收敛
|
||||
|
||||
- 分批把 Dashboard、DataList、Logs 和 PlainResourcePages 的配置块改为显式翻译 key。
|
||||
- 过渡期保留 legacy UI 翻译桥,只处理 admin/auth 容器里的精确静态文本和属性。
|
||||
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥。
|
||||
|
||||
### P4:移除过渡桥
|
||||
|
||||
- 当 `rg -n "[\\p{Han}]" frontend/src/admin frontend/src/pages frontend/src/components` 只剩业务数据示例、中文文档标题或必须保留的中文品牌词时,删除 legacy UI 翻译桥。
|
||||
- 增加 key 完整性检查,确保 `zh-CN` 和 `en-US` 资源结构一致。
|
||||
|
||||
## 验证
|
||||
|
||||
- `cd frontend && bun run build`
|
||||
- `scripts/harness/frontend-rules-check.sh`
|
||||
- `scripts/harness/docs-consistency-check.sh`
|
||||
- `scripts/harness/quick-check.sh`
|
||||
- 前端 smoke 需要覆盖登录页、Docs、Admin 侧边栏语言切换、侧边栏和搜索结果在中英文下渲染。
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `frontend/src/i18n/`:统一 locale、资源和过渡桥。
|
||||
- `frontend/src/pages/Docs/Docs.tsx`:Docs 语言状态改为读取全局 locale。
|
||||
- `frontend/src/admin/components/layout/AdminLayout.tsx`:控制台侧边栏语言切换、导航和搜索文案。
|
||||
- `frontend/src/admin/search/indexers.ts`:Admin 搜索目标本地化。
|
||||
- `docs/technical/{zh,en}/frontend-admin-frontend-context.md`:当前实现上下文。
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**状态**:待实施
|
||||
**创建日期**:2026-05-12
|
||||
**校正日期**:2026-06-26,控制台深链已从旧 tab 查询口径更新为当前 `?section=` 口径。
|
||||
**核心目标**:把 `docs/technical/{zh,en}/manual.md` 拆成"纯客户视角"的使用手册,把 `planet.sh`、日志、LAN、故障排查这类运维内容迁到独立 `ops-runbook.md`,并把分层规则写进 `documentation-coverage-rules.md` 和 `.claude/commands/docs.md`,让以后写文档时自动按受众归档。
|
||||
|
||||
## 背景
|
||||
@@ -31,12 +32,12 @@
|
||||
3. **登录与找回密码** — 登录页、忘记密码流程
|
||||
4. **账户设置** — 修改密码、修改邮箱(需重新验证)、查看权限组、登出
|
||||
5. **Console 总览** — 左侧菜单结构、各路由用途
|
||||
6. **配置数据采集器** — `/collection-management?tab=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?tab=providers`:默认 provider、模型、Base URL、API Key、本地代理;工具 tab(WebSearch、OCR)
|
||||
6. **配置数据采集器** — `/collection-management?section=collector_credentials`:选择 collector、连接测试、保存凭证;BarentsWatch / AISStream 两个典型例子
|
||||
7. **配置 AI 凭证** — `/ai?section=integrations`:默认 provider、模型、Base URL、API Key、本地代理;工具 section(WebSearch、OCR)位于 `/ai?section=tools`
|
||||
8. **系统设置** — `/settings` 其他子 tab(系统设置、电视直播源、SMTP 邮件)
|
||||
9. **用户管理(管理员)** — `/users`:创建、删除、改角色、Gatekeeper 权限组
|
||||
10. **数据探索** — `/datasources`、`/data`、`/bgp`、`/alerts/*`
|
||||
11. **AI 测试台** — `/ai?tab=playground`
|
||||
11. **AI 测试台** — `/ai?section=playground`
|
||||
12. **Earth 公开页面** — 现 manual.md 的 Earth 章节原样保留(图层、图例、搜索、位置候选、设置、视角、动捕、巡航、移动端)
|
||||
13. **Docs 文档站** — 当前 Docs 章节保留(权限组说明)
|
||||
|
||||
@@ -48,7 +49,7 @@
|
||||
|
||||
- 打开管理员给你的 URL
|
||||
- 注册账号 + 邮箱验证
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?tab=collector_credentials` 配一个 collector,再到 `/ai` 配模型)
|
||||
- 登录后第一次做什么(建议先到 `/collection-management?section=collector_credentials` 配一个 collector,再到 `/ai?section=integrations` 配模型)
|
||||
- 看 Earth
|
||||
|
||||
部署/开发的 quickstart 内容并入 `ops-runbook.md` 的"首次部署"小节,**不**再单独出 `ops-quickstart.md`,避免新增维护点。
|
||||
|
||||
@@ -8,7 +8,7 @@ The console now separates the "data source catalog" from "collector configuratio
|
||||
- Lists all data sources, including built-in and custom sources.
|
||||
- Clicking a name only opens an information drawer.
|
||||
- Focuses on status, manual collection, and running collection tasks.
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- `/collection-management?section=collector_credentials`
|
||||
- Displays as "Collectors".
|
||||
- Owns endpoint, headers, base parameters, and credentials.
|
||||
- Every collector exposes a connection button for health checks.
|
||||
|
||||
@@ -352,7 +352,7 @@ For future Earth changes:
|
||||
|
||||
The Earth frontend and the console frontend are not the same UI system:
|
||||
|
||||
- Console frontend: React + Ant Design workbench
|
||||
- Console frontend: React + Tactile UI / Radix primitives / lucide workbench
|
||||
- Earth frontend: native HUD + Three.js display under `public/earth`
|
||||
|
||||
Therefore:
|
||||
|
||||
@@ -98,6 +98,8 @@ Admin status labels should use [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
`StatusText` is an indicator-light pill: the pill background and border stay on the component base color, while only the dot and text use the status color. `Badge` does not carry the indicator-light meaning, so it may use a light same-tone background and border for stronger hierarchy.
|
||||
|
||||
Status indicators must show the full state word. In lists, hierarchy groups, and detail headers, the title/description area should shrink or wrap while the status pill keeps content-sized width and does not get compressed by flex/grid layout; do not truncate state words such as `Configured` or `Available` just to save horizontal space.
|
||||
|
||||
| Tone | Color variable | Meaning | Examples |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | available, successful, connected, enabled | log source `Available`, collection `Success` |
|
||||
@@ -155,7 +157,6 @@ Purpose:
|
||||
Current usage:
|
||||
|
||||
- Admin data sources, collected data, collection management, logs, alerts, and BGP pages
|
||||
- Old AntD legacy pages continue using shared scrolling behavior through compatibility wrappers
|
||||
|
||||
### 3. `TableScrollRegion`
|
||||
|
||||
@@ -217,7 +218,31 @@ Current constraints:
|
||||
- Prefer CSS variable overrides for colors instead of hard-coding theme colors in feature components
|
||||
- Best for a small set of mutually exclusive choices; do not use it as a long list, navigation menu, or select replacement
|
||||
|
||||
### 6. `MarkdownRenderer`
|
||||
### 6. Console i18n
|
||||
|
||||
Files:
|
||||
|
||||
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
|
||||
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
|
||||
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
|
||||
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
|
||||
|
||||
Purpose:
|
||||
|
||||
- Share one `zh-CN` / `en-US` language state across the console, auth pages, and Docs UI
|
||||
- Store the language preference in `planet-locale` while keeping compatibility with the old `docs-lang`
|
||||
- Keep Docs API requests mapped to the backend's existing `zh` / `en` document interface
|
||||
- Provide language switchers in the console sidebar preferences panel and auth panel
|
||||
|
||||
Current constraints:
|
||||
|
||||
- New console copy should be added to `resources.ts`, then consumed with `useTranslation()` or `useLocale()`
|
||||
- Routes, menus, search indexes, and shared components must use explicit translation keys
|
||||
- `LegacyI18nBridge` is transitional and only handles exact static text and attributes inside admin/auth containers
|
||||
- Business data, raw logs, API field names, provider ids, commands, and Markdown body content are not translated by the legacy bridge
|
||||
- Future large-page migrations should shrink the legacy dictionary rather than grow it
|
||||
|
||||
### 7. `MarkdownRenderer`
|
||||
|
||||
File:
|
||||
|
||||
@@ -275,17 +300,16 @@ File:
|
||||
|
||||
Responsibilities:
|
||||
|
||||
- `/ai` now owns LLM Provider, AI Tool configuration, and the testbench instead of nesting them under `/settings`
|
||||
- The `模型供应商` tab manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
|
||||
- The `工具` tab first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
|
||||
- `/ai` now owns LLM Provider, AI Tool configuration, and Playground instead of nesting them under `/settings`
|
||||
- The `模型供应商` section manages default provider, model, base URL, provider key, local `aiprovider` proxy, and connection test; provider and model fields use editable comboboxes so users can manually enter new providers/models if the models.dev catalog stops updating
|
||||
- The `工具调用` section first selects a tool from a dropdown menu, then renders that tool's configuration; it currently includes WebSearch and OCR
|
||||
- WebSearch configuration includes provider, search key, base URL, timeout, result count, and advanced provider options
|
||||
- OCR configuration includes provider, Base URL, API key, model/engine, languages, timeout, file-size limit, and output format
|
||||
- The `测试台` tab embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||
- The page reuses the Settings single-screen tabs, panel card, and internal scrolling style
|
||||
- The `Playground` section embeds the former Playground real session, preset prompts, and AI Provider status debugging
|
||||
- The page reuses the Settings single-screen section, panel card, and internal scrolling style
|
||||
- AI Provider and WebSearch connection tests use `ConnectionTestInput`, with the connector icon fixed at the end of the Base URL input; when WebSearch is disabled, every configuration field and the test entry point are greyed out except the switch
|
||||
|
||||
Legacy `/settings?tab=ai` should redirect to `/ai?tab=providers`.
|
||||
Legacy `/playground` should redirect to `/ai?tab=playground`.
|
||||
AI configuration no longer lives under `/settings`; `/playground` should redirect to `/ai?section=playground`.
|
||||
|
||||
### 3. Business Data Gateway
|
||||
|
||||
@@ -342,11 +366,11 @@ Current page boundary:
|
||||
- Endpoint, headers, and config are displayed here, not edited.
|
||||
- Credential-bearing collectors point users to `Collection Management -> Collectors`.
|
||||
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?tab=collector_credentials`.
|
||||
Keep this boundary: do not put custom datasource editing, built-in endpoint overrides, or credential forms back into `/datasources`. Those configuration entry points live at `/collection-management?section=collector_credentials`.
|
||||
|
||||
### Collectors Page
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` tab is shown as `Collectors` under `/collection-management`.
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) has three route modes: `/settings` for System Settings, `/earth-content` for Earth Content, and `/collection-management` for Collection Management. The `collector_credentials` section is shown as `Collectors` under `/collection-management`.
|
||||
|
||||
The `System Display` section under `/settings` includes the `Demo Mode` switch. When enabled, Earth OOBE ignores existing current collected data and the local `browse first` temporary skip state, then opens the initialization guide directly. This switch is only for demos and acceptance checks; it does not change datasources, collection queues, or Earth content resources.
|
||||
|
||||
@@ -391,6 +415,7 @@ These principles have been repeatedly validated in the project:
|
||||
4. Do not use `overflow: hidden` to mask structural issues
|
||||
5. Do not compress the main work area to make summary cards show completely
|
||||
6. Custom scrollbars must be floating overlays; they must not squeeze content width
|
||||
7. The Admin shell relies on the root `height: 100%` chain and should not use exact `100vh` sizing at the workspace root
|
||||
|
||||
For detailed experience, see:
|
||||
|
||||
@@ -411,7 +436,7 @@ Do not write local CSS patches first, then retrofit the structure.
|
||||
|
||||
The console frontend and the Earth frontend are not the same system:
|
||||
|
||||
- Console frontend: React + Ant Design workbench
|
||||
- Console frontend: React + Tactile UI / Radix primitives / lucide workbench
|
||||
- Earth frontend: independent native HUD system under `public/earth`
|
||||
|
||||
Therefore:
|
||||
|
||||
@@ -210,6 +210,11 @@ Inspection order:
|
||||
3. Does the real scroll node explicitly use `overflow: auto`?
|
||||
4. Have intermediate wrapper layers silently changed layout semantics?
|
||||
|
||||
The current Admin and Docs root shells rely on the `html` / `body` / `#root`
|
||||
`height: 100%` chain. Do not reintroduce exact `100vh` / `100vw` sizing on
|
||||
these embedded workspace shells; modals, overlays, and narrow-screen safety
|
||||
boundaries may still use `calc(100vh - ...)` as a maximum-size constraint.
|
||||
|
||||
### 5. UI State and Display State Out of Sync
|
||||
|
||||
Repeated in Earth-related changes:
|
||||
|
||||
@@ -22,7 +22,7 @@ URLs below use the local default `http://localhost:3000`. Replace the prefix wit
|
||||
1. Open `http://localhost:3000/login` and click "Register" under the form.
|
||||
2. On `/register`, fill in:
|
||||
- **Username**: 3–50 characters, used to log in
|
||||
- **Email**: receives the verification code; editable later in account settings
|
||||
- **Email**: receives the verification code; in the current version, ask an administrator to maintain email changes in user management
|
||||
- **Password**: at least 8 characters
|
||||
3. After submission you are taken to the verify page. A 6-digit code is sent to your email. It expires in 10 minutes.
|
||||
4. Enter the code and click "Verify and Sign In". On success the system stores a session and sends you to the console.
|
||||
@@ -52,23 +52,23 @@ If you see "Email not verified", the page automatically redirects to `/verify-em
|
||||
3. After receiving the code, enter it together with a new password (≥ 8 characters) and click "Reset Password".
|
||||
4. The system sends you back to `/login` — sign in with the new password.
|
||||
|
||||
## Account Settings
|
||||
## Account Area And Sign Out
|
||||
|
||||
Click your username at the top-right of the console to open account settings:
|
||||
The account area at the bottom of the console sidebar shows the current username, version, and theme control. The current version does not include a signed-in self-service account settings page:
|
||||
|
||||
- Change password: enter current password + new password
|
||||
- Change email: the system sends a verification code to the new address; the change applies only after verification
|
||||
- View Gatekeeper groups: lists current groups (`docs_user` / `docs_developer` / `docs_admin`)
|
||||
- Log out: clears the current session
|
||||
- Use `/forgot-password` for password reset through email verification
|
||||
- Administrators maintain email, role, and Gatekeeper groups at `/users`
|
||||
- The sign-out icon in the account area clears the current session and returns to the login page
|
||||
|
||||
## Console Overview
|
||||
|
||||
The console at `http://localhost:3000/admin` is built with React + Ant Design. The left menu is organized by work domain.
|
||||
The console at `http://localhost:3000/admin` is built with React plus Tactile UI / Radix primitives and lucide icons. The left menu is organized by work domain.
|
||||
|
||||
| Page | Route | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Dashboard | `/admin` | System overview |
|
||||
| Earth | `/earth` | Open the public Earth page |
|
||||
| Docs | `/docs` | Open the docs site and show documents allowed by Gatekeeper permissions |
|
||||
| Datasources | `/datasources` | Source directory and collection triggers |
|
||||
| Collected Data | `/data` | Data already ingested |
|
||||
| BGP | `/bgp` | BGP situational view |
|
||||
@@ -86,7 +86,7 @@ Menu items hide automatically when you lack permission. If a menu is missing, ch
|
||||
|
||||
## Configure Data Collectors
|
||||
|
||||
`/collection-management?tab=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials. Legacy `/settings?tab=collector_credentials` redirects here; the datasource directory remains at `/datasources`.
|
||||
`/collection-management?section=collector_credentials` is the "Collectors" page. It manages connection configuration for every collector, not just credentials; the datasource directory remains at `/datasources`.
|
||||
|
||||
Steps:
|
||||
|
||||
@@ -127,7 +127,7 @@ The default guide follows the BarentsWatch official tutorial and reminds you to
|
||||
|
||||
Steps:
|
||||
|
||||
1. Open `/collection-management?tab=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
1. Open `/collection-management?section=collector_credentials` and select `AISStream Realtime Vessels : aisstream_vessels`
|
||||
2. Fill the AISStream API Key
|
||||
3. Keep the default endpoint `wss://stream.aisstream.io/v0/stream`
|
||||
4. Click the plug icon to test; confirm it reports `Reachable`
|
||||
@@ -141,11 +141,12 @@ Steps:
|
||||
|
||||
## Configure AI Credentials
|
||||
|
||||
`/ai?tab=providers` is the AI management entry. Three key sub-tabs:
|
||||
`/ai?section=integrations` is the AI management entry. Key sections:
|
||||
|
||||
- `Model Providers`: default LLM provider, model, base URL, API key, local `aiprovider` proxy, connection test
|
||||
- `Tools`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
- `Tool Calls`: a dropdown for specific tools — currently WebSearch and OCR
|
||||
- `Prompts`: a task dropdown for news localization, alert analysis, BGP briefs, and other LLM tasks. Operators can edit the prompt or reset it to the default
|
||||
- `Playground`: real session, preset request, and AI Provider status debugging
|
||||
|
||||
### Model Providers
|
||||
|
||||
@@ -170,7 +171,7 @@ The plug icon at the end of the Base URL input runs a connection test. A passing
|
||||
|
||||
After selecting a task, the page shows the effective prompt, whether it is customized, the shipped default version, and a reset button. Saving affects only that task. Reset restores the default prompt from the current release package. Business facts, context, and output schemas are still assembled by the backend for each task.
|
||||
|
||||
The legacy link `/settings?tab=ai` redirects to `/ai?tab=providers`.
|
||||
AI configuration no longer lives in System Settings; `/playground` redirects to `/ai?section=playground`.
|
||||
|
||||
## Datasources and Task Logs
|
||||
|
||||
@@ -245,7 +246,7 @@ To let a regular user read developer or operations docs, add `docs_developer` or
|
||||
|
||||
## AI Testbench
|
||||
|
||||
`/ai?tab=playground` is for real-pipeline debugging:
|
||||
`/ai?section=playground` is for real-pipeline debugging:
|
||||
|
||||
- Pick the active provider
|
||||
- Run preset requests or custom prompts
|
||||
|
||||
@@ -32,8 +32,8 @@ The default role is `viewer`: you can sign in but only see public pages. For col
|
||||
|
||||
After landing on the `/admin` dashboard, here's a recommended walk-through:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?tab=providers`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
1. `/collection-management?section=collector_credentials`: pick a collector and click the plug icon to test connectivity. Free collectors (e.g. open BGP) usually work right away; credential-bearing ones like `AISStream` or `BarentsWatch` need an API key / client secret first
|
||||
2. `/ai?section=integrations`: fill an LLM provider (e.g. `minimax` / `openai`), model, base URL, API key, and click the plug at the end of the base URL to test. WebSearch / OCR tools are optional
|
||||
3. `/datasources` or `/data`: check whether collectors have produced data. Use `/datasources -> Built-in Sources` for finite collectors: with no rows selected, click `Trigger All`; after selecting rows, the primary button becomes `Trigger Selected N`. The top-right queue button shows progress. Use `/datasources -> Realtime Sources` for AISStream / WebSocket health and counters
|
||||
4. `/alerts/system`: verify system alerts look right
|
||||
5. `/users` (super_admin only): open accounts for teammates or adjust their groups
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
- 展示所有数据源,包括内置和自定义。
|
||||
- 点击名称只打开信息抽屉。
|
||||
- 负责查看状态、触发采集和查看采集中任务。
|
||||
- `/collection-management?tab=collector_credentials`
|
||||
- `/collection-management?section=collector_credentials`
|
||||
- 显示为“采集器”。
|
||||
- 负责 endpoint、请求头、基础参数和凭证配置。
|
||||
- 所有采集器都提供连接按钮,用于健康检查。
|
||||
|
||||
@@ -98,6 +98,8 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
`StatusText` 是带圆点的指示灯:胶囊背景和边框保持组件原色,只让圆点和文字变成状态色。`Badge` 不带指示灯语义,可以使用同 tone 的浅色背景和边框强化信息层级。
|
||||
|
||||
状态指示器必须完整显示状态词。列表、树形组和详情栏里的状态列应让标题/描述区域收缩或换行,状态 pill 本身使用内容自适应宽度并禁止被 flex/grid 挤压;不要为了紧凑把 `Configured` / `Available` 这类状态裁成省略号。
|
||||
|
||||
| Tone | 颜色变量 | 语义 | 示例 |
|
||||
| --- | --- | --- | --- |
|
||||
| `success` | `--an-success` | 可用、成功、已连接、已启用 | 日志源 `可用`、采集 `成功` |
|
||||
@@ -216,7 +218,31 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- 颜色优先通过 CSS 变量覆盖,避免在业务组件里硬编码主题色
|
||||
- 适合少量互斥选项,不适合用作长列表、导航菜单或表单下拉
|
||||
|
||||
### 6. `MarkdownRenderer`
|
||||
### 6. 控制台 i18n
|
||||
|
||||
文件:
|
||||
|
||||
- [i18n/index.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/index.ts)
|
||||
- [i18n/locale.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/locale.ts)
|
||||
- [i18n/resources.ts](/home/ray/dev/linkong/planet/frontend/src/i18n/resources.ts)
|
||||
- [LegacyI18nBridge.tsx](/home/ray/dev/linkong/planet/frontend/src/i18n/LegacyI18nBridge.tsx)
|
||||
|
||||
用途:
|
||||
|
||||
- 控制台、认证页和 Docs UI 共用 `zh-CN` / `en-US` 语言状态
|
||||
- 语言偏好保存在 `planet-locale`,同时兼容旧的 `docs-lang`
|
||||
- Docs 请求仍映射到后端现有 `zh` / `en` 文档接口
|
||||
- 控制台侧边栏偏好面板和认证页面板提供语言切换入口
|
||||
|
||||
当前约束:
|
||||
|
||||
- 新增控制台文案优先写入 `resources.ts`,组件使用 `useTranslation()` 或 `useLocale()`
|
||||
- 路由、菜单、搜索索引和通用组件必须使用显式翻译 key
|
||||
- `LegacyI18nBridge` 只作为过渡层,负责 admin/auth 容器内未迁移的精确静态文本和属性
|
||||
- 业务数据、日志原文、API 字段名、provider id、命令和 Markdown 正文不走 legacy 翻译桥
|
||||
- 后续迁移大型业务页时应减少 legacy 字典,而不是继续扩大它
|
||||
|
||||
### 7. `MarkdownRenderer`
|
||||
|
||||
文件:
|
||||
|
||||
@@ -274,17 +300,16 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
职责:
|
||||
|
||||
- `/ai` 独立承载 LLM Provider、AI Tool 配置和测试台,不再放在 `/settings` 的系统配置 tabs 中
|
||||
- `模型供应商` tab 管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试;provider 和模型输入使用可输入组合框,models.dev 目录停更时用户仍可手动填新 provider/model
|
||||
- `工具` tab 先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR
|
||||
- `/ai` 独立承载 LLM Provider、AI Tool 配置和 Playground,不再放在 `/settings` 的系统配置分区中
|
||||
- `模型供应商` 分区管理默认 provider、模型、base URL、provider key、本地 `aiprovider` 代理和连接测试;provider 和模型输入使用可输入组合框,models.dev 目录停更时用户仍可手动填新 provider/model
|
||||
- `工具调用` 分区先通过下拉菜单选择工具,再管理对应配置;当前包含 WebSearch 和 OCR
|
||||
- WebSearch 配置包含 provider、搜索 key、base URL、超时、结果数和高级 provider 参数
|
||||
- OCR 配置包含 provider、Base URL、API Key、模型/engine、语言、超时、文件大小上限和输出格式
|
||||
- `测试台` tab 嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
|
||||
- 页面复用 Settings 的单屏 tabs、panel card 和内部滚动样式
|
||||
- `Playground` 分区嵌入原 Playground 的真实会话、预设请求和 AI Provider 状态调试
|
||||
- 页面复用 Settings 的单屏分区、panel card 和内部滚动样式
|
||||
- AI Provider / WebSearch 的连接测试使用 `ConnectionTestInput`,连接器图标固定在 Base URL 输入框末端;WebSearch 未启用时,除开关外的配置项和测试入口都置灰
|
||||
|
||||
旧的 `/settings?tab=ai` 应跳转到 `/ai?tab=providers`。
|
||||
旧的 `/playground` 应跳转到 `/ai?tab=playground`。
|
||||
AI 配置不再挂在 `/settings` 下;`/playground` 应跳转到 `/ai?section=playground`。
|
||||
|
||||
### 3. 业务数据网关
|
||||
|
||||
@@ -342,7 +367,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- endpoint、headers、config 只展示,不在这里编辑。
|
||||
- 需要凭证的采集器提示用户到“采集管理 -> 采集器”维护。
|
||||
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?tab=collector_credentials`。
|
||||
这个边界很重要:后续不要把自定义数据源编辑、内置 endpoint 覆盖或凭证表单再塞回 `/datasources`。这些配置入口统一放在 `/collection-management?section=collector_credentials`。
|
||||
|
||||
页面顶部的总进度区域新增 `采集中 N` 标签:
|
||||
|
||||
@@ -355,7 +380,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
|
||||
### 采集器设置页
|
||||
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是智能星球内容,`/collection-management` 是采集管理。`collector_credentials` tab 当前在 `/collection-management` 下显示为“采集器”。
|
||||
[Settings.tsx](/home/ray/dev/linkong/planet/frontend/src/admin/pages/PlainResourcePages.tsx) 会按路由进入三种模式:`/settings` 是系统设置,`/earth-content` 是智能星球内容,`/collection-management` 是采集管理。`collector_credentials` section 当前在 `/collection-management` 下显示为“采集器”。
|
||||
|
||||
`/settings` 的“系统显示”分区包含 `演示模式` 开关。开启后,智能星球的 OOBE 会忽略“已有当前采集数据”和本地“先浏览”临时跳过状态,直接展示初始化引导;该开关仅用于演示/验收流程,不改变数据源、采集队列或智能星球内容资源配置。
|
||||
|
||||
@@ -368,7 +393,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
- 不需要凭证的采集器只显示基础配置:endpoint、默认 endpoint、请求头、timeout、retry。
|
||||
- `BarentsWatch AIS` 使用专用凭证表单。
|
||||
|
||||
连接图标使用内联 `PlugConnectIcon`,视觉语义来自 Tabler `plug-connected`。后续如果控制台重写图标体系,应迁移到 Tabler Icons,而不是继续使用 Ant Design 刷新图标表达连接。
|
||||
连接测试入口使用现有 `Button icon="test"` 图标语义。后续如果控制台重写图标体系,应迁移到现有 lucide / Tactile UI 图标体系,而不是用普通刷新图标表达连接。
|
||||
|
||||
`Client Secret` 的表单语义:
|
||||
|
||||
@@ -421,6 +446,7 @@ Admin 的状态标签统一走 [StatusText](/home/ray/dev/linkong/planet/fronten
|
||||
4. 不要用 `overflow: hidden` 掩盖结构问题
|
||||
5. 不要为了摘要卡完整显示去压缩主工作区
|
||||
6. 自定义滚动条必须是浮层,不得挤压内容宽度
|
||||
7. Admin shell 依赖 root `height: 100%` 高度链,不在根工作区重新写精确 `100vh`
|
||||
|
||||
详细经验见:
|
||||
|
||||
|
||||
@@ -210,6 +210,10 @@
|
||||
3. 真正滚动节点是否明确 `overflow: auto`
|
||||
4. 中间包装层是否偷偷改了布局语义
|
||||
|
||||
当前 Admin 和 Docs 根 shell 依赖 `html` / `body` / `#root` 的 `height: 100%`
|
||||
链路。不要在这些嵌入式工作区根容器上重新写精确 `100vh` / `100vw`;
|
||||
弹窗、浮层和窄屏安全边界可以继续使用 `calc(100vh - ...)` 作为最大尺寸约束。
|
||||
|
||||
### 5. UI 状态和显示状态不同步
|
||||
|
||||
Earth 相关改动里反复出现:
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
1. 打开 `http://localhost:3000/login`,点击表单下方"注册账户"。
|
||||
2. 在 `/register` 填写:
|
||||
- **用户名**:3–50 位字符,登录时使用
|
||||
- **邮箱**:用于接收验证码,可在账户设置中修改
|
||||
- **邮箱**:用于接收验证码;当前版本如需修改邮箱,请联系管理员在用户管理中维护
|
||||
- **密码**:至少 8 位
|
||||
3. 提交后会跳到验证页,已将 6 位验证码发到你的邮箱。10 分钟内有效。
|
||||
4. 输入验证码,点击"验证并登录"。验证通过后系统会自动写入登录态并跳到控制台。
|
||||
@@ -52,23 +52,23 @@
|
||||
3. 收到验证码后,在下一步填入验证码 + 新密码(至少 8 位),点击"重置密码"。
|
||||
4. 系统会跳回 `/login`,用新密码登录即可。
|
||||
|
||||
## 账户设置
|
||||
## 账户区与退出
|
||||
|
||||
控制台右上角点击你的用户名进入账户设置,可以:
|
||||
控制台左侧底部的账户区会显示当前用户名、版本号和主题切换。当前版本还没有登录后的自助账户设置页:
|
||||
|
||||
- 修改密码:输入当前密码 + 新密码
|
||||
- 修改邮箱:输入新邮箱后系统会发验证码到新地址,验证通过后才生效
|
||||
- 查看权限组:列出你目前拥有的 Gatekeeper 权限组(`docs_user` / `docs_developer` / `docs_admin`)
|
||||
- 登出:清除当前会话
|
||||
- 忘记密码或需要重置密码时,使用 `/forgot-password` 邮件验证码流程
|
||||
- 邮箱、角色和 Gatekeeper 权限组由管理员在 `/users` 维护
|
||||
- 点击账户区的退出图标会清除当前会话并返回登录页
|
||||
|
||||
## 控制台总览
|
||||
|
||||
控制台 `http://localhost:3000/admin` 使用 React + Ant Design,左侧菜单按工作域组织。
|
||||
控制台 `http://localhost:3000/admin` 使用 React + Tactile UI / Radix 基础组件和 lucide 图标,左侧菜单按工作域组织。
|
||||
|
||||
| 页面 | 路由 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 仪表盘 | `/admin` | 系统概览 |
|
||||
| 智能星球 | `/earth` | 跳到公开智能星球页面 |
|
||||
| 文档 | `/docs` | 打开文档站并按 Gatekeeper 权限查看可见文档 |
|
||||
| 数据源 | `/datasources` | 数据源目录、触发采集 |
|
||||
| 采集数据 | `/data` | 已落库的数据 |
|
||||
| BGP 观测 | `/bgp` | BGP 专题观测 |
|
||||
@@ -86,7 +86,7 @@
|
||||
|
||||
## 配置数据采集器
|
||||
|
||||
`/collection-management?tab=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证。旧链接 `/settings?tab=collector_credentials` 会自动跳转到这个入口;数据源目录仍保留在 `/datasources`。
|
||||
`/collection-management?section=collector_credentials` 是"采集器"页。这里统一维护所有采集器的连接配置,不仅是凭证;数据源目录仍保留在 `/datasources`。
|
||||
|
||||
操作步骤:
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
1. `/collection-management?section=collector_credentials` 选择 `AISStream 实时船舶 : aisstream_vessels`
|
||||
2. 在 `AISStream 凭证` 填入 API Key
|
||||
3. Endpoint 保持默认 `wss://stream.aisstream.io/v0/stream`
|
||||
4. 点击插头图标进行连接测试,确认显示 `可用`
|
||||
@@ -144,11 +144,12 @@
|
||||
|
||||
## 配置 AI 凭证
|
||||
|
||||
`/ai?tab=providers` 是 AI 模型管理入口。包含三个核心子 tab:
|
||||
`/ai?section=integrations` 是 AI 模型管理入口。主要分区包括:
|
||||
|
||||
- `模型供应商`:默认 LLM provider、模型、Base URL、API Key、本地 `aiprovider` 代理和连接测试
|
||||
- `工具`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `工具调用`:通过下拉菜单选择具体工具,当前支持 WebSearch 和 OCR
|
||||
- `提示词`:通过功能入口下拉菜单选择新闻汉化、告警研判、BGP 简报等 LLM 任务,手动调整提示词或重置为缺省
|
||||
- `Playground`:真实会话、预设请求和 AI Provider 状态调试
|
||||
|
||||
### 模型供应商
|
||||
|
||||
@@ -173,7 +174,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
选择功能入口后,页面会显示当前提示词、是否已自定义、缺省版本和重置按钮。保存只影响该功能入口;重置会恢复当前发布包中的缺省提示词。业务事实、上下文和输出 schema 仍由后端按功能入口自动传入。
|
||||
|
||||
旧链接 `/settings?tab=ai` 会跳到 `/ai?tab=providers`。
|
||||
旧的 AI 配置入口不再放在系统设置里;`/playground` 会跳到 `/ai?section=playground`。
|
||||
|
||||
## 系统设置
|
||||
|
||||
@@ -244,7 +245,7 @@ Base URL 输入框尾端的插头图标会触发连接测试。测试通过会
|
||||
|
||||
## AI 测试台
|
||||
|
||||
`/ai?tab=playground` 用于真实分析链路调试。可以:
|
||||
`/ai?section=playground` 用于真实分析链路调试。可以:
|
||||
|
||||
- 选择当前 provider
|
||||
- 用预设请求或自定义 prompt 触发分析
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
|
||||
进入 `/admin` 仪表盘后,建议按这个顺序熟悉控制台:
|
||||
|
||||
1. `/collection-management?tab=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?tab=providers`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
1. `/collection-management?section=collector_credentials`:选一个采集器,点插头图标做连接测试。免费 collector(开源 BGP 等)通常直接可用;像 `AISStream`、`BarentsWatch` 这类需要凭证的,需要先填 API Key/Client Secret
|
||||
2. `/ai?section=integrations`:填一个 LLM provider(例如 `minimax` / `openai`)、模型名、Base URL、API Key,点 Base URL 末端的插头测试连接。WebSearch / OCR 工具可选
|
||||
3. `/datasources` 或 `/data`:看采集器是否已经产出数据。有限采集器看 `/datasources -> 内置源`,不勾选时点“触发全部”,勾选后主按钮会变成“触发已选 N”;右上角队列按钮可查看进度。AISStream / WebSocket 长连接看 `/datasources -> 实时源` 的健康状态和计数
|
||||
4. `/alerts/system`:看系统告警是否正常
|
||||
5. `/users`(仅 `super_admin`):根据需要给同事开账号或调权限组
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
## Current Version
|
||||
|
||||
- `main` 当前主线历史推导到:`0.16.5`
|
||||
- `dev` 当前开发分支历史推导到:`0.71.1`
|
||||
- `dev` 当前开发分支历史推导到:`0.73.0`
|
||||
|
||||
## Timeline
|
||||
|
||||
| Version | Type | Branch | Commit | Summary |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `0.73.0` | feature | `dev` | `pending` | 新增前端统一 i18n、控制台语言/主题偏好入口、英文态 legacy 过渡翻译和 admin 一屏/状态指示器布局验证 |
|
||||
| `0.72.0` | feature | `dev` | `pending` | 新增完整 agent harness、单一 AGENTS 入口、Earth News smoke 覆盖和 collector 结构化日志清理,并同步控制台/Earth/Docs 响应式维护文档 |
|
||||
| `0.71.1` | bugfix | `dev` | `pending` | 修复 Earth 新闻区域切换、滚动条/面板/巡航一致性和新闻精修队列饿死问题,并补充 agent harness 与双语维护文档 |
|
||||
| `0.71.0` | feature | `dev` | `pending` | Motion Agent 升级为 Web/UE 共用双向控制与真实识别服务,新增 Earth 手动新闻工作流、来源多样化,并完善启动/测试 harness 与双语文档 |
|
||||
| `0.70.0` | feature | `dev` | `pending` | 新增后端枚举契约治理、Earth 新闻分类/Breaking 链路和船只当前状态快照,清理错误视口刷新逻辑并同步双语文档 |
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -29,6 +30,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
@@ -84,6 +86,8 @@
|
||||
|
||||
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
|
||||
|
||||
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
@@ -556,6 +560,10 @@
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="],
|
||||
|
||||
"i18next": ["i18next@26.3.3", "", { "peerDependencies": { "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-aYVegyBdXSO93CMMihvr47jI7GHSOcIahMpJX+qzUXDzW4xDJf2uenIA+45vDU+YhiVdcfsql70AC9RVdMNrHg=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
|
||||
@@ -632,6 +640,8 @@
|
||||
|
||||
"react-hook-form": ["react-hook-form@7.76.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eKtLGgFeSgkHqQD8J59AMZ9a4uD1D83iSIzt4YlTGD7liDen5rrjcUO1rVIGd9yC1gofryjtHbv+4ny4hkLWlw=="],
|
||||
|
||||
"react-i18next": ["react-i18next@17.0.8", "", { "dependencies": { "@babel/runtime": "^7.29.2", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 26.2.0", "react": ">= 16.8.0", "typescript": "^5 || ^6" }, "optionalPeers": ["typescript"] }, "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw=="],
|
||||
|
||||
"react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
|
||||
@@ -702,6 +712,8 @@
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="],
|
||||
|
||||
"ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
|
||||
|
||||
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planet-frontend",
|
||||
"version": "0.71.1",
|
||||
"version": "0.73.0",
|
||||
"private": true,
|
||||
"packageManager": "bun@1",
|
||||
"dependencies": {
|
||||
@@ -20,6 +20,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"echarts": "^6.0.0",
|
||||
"i18next": "26.3.3",
|
||||
"lucide-react": "^1.16.0",
|
||||
"mermaid": "^11.15.0",
|
||||
"pbf": "^4.0.1",
|
||||
@@ -28,6 +29,7 @@
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.76.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-resizable": "^3.1.3",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"simplex-noise": "^4.0.1",
|
||||
|
||||
@@ -254,19 +254,31 @@
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
z-index: 4;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .icon {
|
||||
.earth-toolbar-btn .icon,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded,
|
||||
.earth-zoom-btn > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.16s ease, opacity 0.16s ease;
|
||||
color: rgba(243, 244, 246, 0.9);
|
||||
text-shadow: 0 1.5px 3px rgba(0, 0, 0, 0.5);
|
||||
transition:
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 0.3s ease,
|
||||
opacity 0.16s ease;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.earth-zoom-btn > span {
|
||||
position: relative;
|
||||
z-index: 4;
|
||||
}
|
||||
|
||||
.earth-toolbar-btn .material-symbols-rounded,
|
||||
.earth-toolbar-hub-btn .material-symbols-rounded {
|
||||
font-size: calc(21px * var(--toolbar-scale));
|
||||
@@ -299,22 +311,34 @@
|
||||
--btn-scale: 1;
|
||||
--press-offset: 0px;
|
||||
--float-offset: 0px;
|
||||
--mouse-x: 0.5;
|
||||
--mouse-y: 0.5;
|
||||
--toolbar-atmosphere-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.25);
|
||||
--toolbar-atmosphere-hover: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.55);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
transform-style: preserve-3d;
|
||||
transform-origin: center center;
|
||||
will-change: transform, box-shadow;
|
||||
z-index: 2;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: none;
|
||||
outline: none;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.085), transparent 38%),
|
||||
rgba(255, 255, 255, var(--toolbar-glass-opacity));
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.16) 0%,
|
||||
rgba(255, 255, 255, 0.02) 40%,
|
||||
rgba(15, 23, 42, 0.35) 75%,
|
||||
rgba(3, 7, 18, 0.85) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 8px 28px rgba(0, 0, 0, 0.38),
|
||||
inset 0 1.5px 2px rgba(255, 255, 255, 0.22),
|
||||
inset 0 -1.5px 2px rgba(0, 0, 0, 0.28);
|
||||
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
|
||||
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(108%) brightness(0.92);
|
||||
0 8px 24px -4px rgba(0, 0, 0, 0.65),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.15),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.45),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.03),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.06);
|
||||
backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
-webkit-backdrop-filter: blur(var(--toolbar-glass-blur, 16px)) saturate(130%);
|
||||
|
||||
transform:
|
||||
translate3d(
|
||||
@@ -325,121 +349,182 @@
|
||||
scale(var(--btn-scale));
|
||||
|
||||
transition:
|
||||
transform 0.22s ease,
|
||||
box-shadow 0.22s ease,
|
||||
background 0.22s ease,
|
||||
transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 0.4s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
top: 4%;
|
||||
left: 15%;
|
||||
width: 70%;
|
||||
height: 32%;
|
||||
border-radius: 50% 50% 45% 45% / 65% 65% 35% 35%;
|
||||
background:
|
||||
radial-gradient(circle at 32% 20%, rgba(255, 255, 255, 0.13), transparent 34%),
|
||||
radial-gradient(circle at 54% 54%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.035), transparent 58%);
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.38) 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
filter: blur(0.4px);
|
||||
opacity: 1;
|
||||
pointer-events: none;
|
||||
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08), 0);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * 5px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * 3px)
|
||||
);
|
||||
transform-origin: top center;
|
||||
transition: transform 0.25s ease-out;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.liquid-glass-surface::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: inherit;
|
||||
bottom: -10%;
|
||||
left: 12%;
|
||||
width: 76%;
|
||||
height: 35%;
|
||||
border-radius: 50%;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%, transparent 58%, rgba(0, 0, 0, 0.1) 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 0.5px rgba(255, 255, 255, 0.06);
|
||||
opacity: 1;
|
||||
radial-gradient(
|
||||
ellipse at bottom,
|
||||
var(--toolbar-atmosphere-color) 0%,
|
||||
rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.02) 70%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
filter: blur(1px);
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, box-shadow 0.18s ease;
|
||||
transform:
|
||||
translate(
|
||||
calc((var(--mouse-x, 0.5) - 0.5) * -3px),
|
||||
calc((var(--mouse-y, 0.5) - 0.5) * -2px)
|
||||
);
|
||||
transition:
|
||||
transform 0.25s ease-out,
|
||||
opacity 0.3s ease;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.095), transparent 40%),
|
||||
rgba(255, 255, 255, 0.07);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.18) 0%,
|
||||
rgba(255, 255, 255, 0.025) 42%,
|
||||
rgba(15, 23, 42, 0.32) 74%,
|
||||
rgba(3, 7, 18, 0.82) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 9px 30px rgba(0, 0, 0, 0.4),
|
||||
inset 0 1.5px 2px rgba(255, 255, 255, 0.24),
|
||||
inset 0 -1.5px 2px rgba(0, 0, 0, 0.3);
|
||||
0 9px 26px -4px rgba(0, 0, 0, 0.68),
|
||||
inset 0 0 1.8px 1.3px rgba(255, 255, 255, 0.17),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.5),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover {
|
||||
--btn-scale: 1.03;
|
||||
--press-offset: -1px;
|
||||
--btn-scale: 1.05;
|
||||
--press-offset: -3px;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.12), transparent 62%),
|
||||
rgba(255, 255, 255, 0.085);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.22) 0%,
|
||||
rgba(255, 255, 255, 0.04) 40%,
|
||||
rgba(15, 23, 42, 0.25) 75%,
|
||||
rgba(3, 7, 18, 0.8) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.44),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.36),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover::before {
|
||||
opacity: 1;
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.2), transparent 36%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.14), transparent 62%);
|
||||
transform: translate3d(calc(var(--elastic-x) * 0.08), calc(var(--elastic-y) * 0.08 - 1px), 0);
|
||||
0 14px 28px -6px rgba(0, 0, 0, 0.8),
|
||||
0 0 15px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.8px 1.2px rgba(255, 255, 255, 0.22),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.65),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover::after {
|
||||
opacity: 1;
|
||||
box-shadow:
|
||||
inset 0 0 0 0.75px rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.16);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:hover .icon,
|
||||
.earth-toolbar-hub-btn.liquid-glass-surface:hover > .material-symbols-rounded,
|
||||
.earth-zoom-btn.liquid-glass-surface:hover > span {
|
||||
color: #ffffff;
|
||||
transform: scale(1.06);
|
||||
}
|
||||
|
||||
.liquid-glass-surface:active,
|
||||
.liquid-glass-surface.is-pressed {
|
||||
--btn-scale: 0.94;
|
||||
--press-offset: 3px;
|
||||
--btn-scale: 0.95;
|
||||
--press-offset: -1px;
|
||||
transition: transform 0.1s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
box-shadow:
|
||||
0 4px 14px rgba(0, 0, 0, 0.28),
|
||||
inset 0 1px 2px rgba(255, 255, 255, 0.18),
|
||||
inset 0 -1px 3px rgba(0, 0, 0, 0.34);
|
||||
0 5px 12px -3px rgba(0, 0, 0, 0.9),
|
||||
0 0 8px -2px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.5px 1.2px rgba(255, 255, 255, 0.18),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.55),
|
||||
inset 0 -1px 4px rgba(255, 255, 255, 0.01);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.14), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.13), transparent 62%),
|
||||
rgba(255, 255, 255, 0.09);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.42);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(0, 0, 0, 0.46),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.38),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.2);
|
||||
0 13px 28px -6px rgba(0, 0, 0, 0.78),
|
||||
0 0 16px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 1.9px 1.25px rgba(255, 255, 255, 0.24),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.66),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.04),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.11);
|
||||
}
|
||||
|
||||
.liquid-glass-surface.active:hover {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.16), transparent 40%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.17), transparent 64%),
|
||||
rgba(255, 255, 255, 0.1);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.28) 0%,
|
||||
rgba(255, 255, 255, 0.055) 40%,
|
||||
rgba(15, 23, 42, 0.22) 75%,
|
||||
rgba(3, 7, 18, 0.76) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 42px rgba(0, 0, 0, 0.48),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.42),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
|
||||
0 15px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 18px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.27),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.7),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 6px 12px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-toolbar-cluster.is-expanded .earth-toolbar-hub-btn.liquid-glass-surface {
|
||||
background:
|
||||
radial-gradient(circle at 32% 22%, rgba(255, 255, 255, 0.15), transparent 42%),
|
||||
radial-gradient(circle at 52% 52%, rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.15), transparent 64%),
|
||||
rgba(255, 255, 255, 0.095);
|
||||
border-color: rgba(var(--toolbar-light-rgb, 91, 186, 255), 0.44);
|
||||
radial-gradient(
|
||||
circle at 35% 30%,
|
||||
rgba(255, 255, 255, 0.24) 0%,
|
||||
rgba(255, 255, 255, 0.045) 40%,
|
||||
rgba(15, 23, 42, 0.24) 75%,
|
||||
rgba(3, 7, 18, 0.78) 100%
|
||||
);
|
||||
box-shadow:
|
||||
0 12px 42px rgba(0, 0, 0, 0.48),
|
||||
inset 0 1.5px 3px rgba(255, 255, 255, 0.4),
|
||||
inset 0 -1.5px 3px rgba(0, 0, 0, 0.18);
|
||||
0 14px 30px -6px rgba(0, 0, 0, 0.82),
|
||||
0 0 17px -1px var(--toolbar-atmosphere-hover),
|
||||
inset 0 0 2px 1.25px rgba(255, 255, 255, 0.26),
|
||||
inset 0 1px 0.5px 0.2px rgba(255, 255, 255, 0.68),
|
||||
inset 0 -3px 8px rgba(255, 255, 255, 0.05),
|
||||
inset 0 7px 13px rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.earth-rotate-toggle .icon-play,
|
||||
|
||||
6
frontend/public/earth/js/controls.js
vendored
6
frontend/public/earth/js/controls.js
vendored
@@ -5600,6 +5600,8 @@ function setupLiquidGlassInteractions() {
|
||||
surface.style.setProperty("--tilt-y", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-x", "0deg");
|
||||
surface.style.setProperty("--panel-tilt-y", "0deg");
|
||||
surface.style.setProperty("--mouse-x", "0.5");
|
||||
surface.style.setProperty("--mouse-y", "0.5");
|
||||
surface.style.setProperty("--dock-scale", "1");
|
||||
surface.style.setProperty("--dock-lift", "0px");
|
||||
surface.style.setProperty("--dock-shift-x", "0px");
|
||||
@@ -5616,13 +5618,15 @@ function setupLiquidGlassInteractions() {
|
||||
|
||||
surfaces.forEach((surface) => {
|
||||
resetSurface(surface);
|
||||
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar-items"));
|
||||
const isToolbarSurface = Boolean(surface.closest(".earth-toolbar"));
|
||||
const isPanelSurface = surface.classList.contains("hud-panel");
|
||||
|
||||
bindListener(surface, "pointermove", (event) => {
|
||||
const rect = surface.getBoundingClientRect();
|
||||
const px = (event.clientX - rect.left) / rect.width;
|
||||
const py = (event.clientY - rect.top) / rect.height;
|
||||
surface.style.setProperty("--mouse-x", `${px.toFixed(3)}`);
|
||||
surface.style.setProperty("--mouse-y", `${py.toFixed(3)}`);
|
||||
if (isPanelSurface) {
|
||||
const panelTiltX = (0.5 - py) * 5;
|
||||
const panelTiltY = (px - 0.5) * 6;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Suspense, lazy } from 'react'
|
||||
import { Suspense, lazy, useEffect } from 'react'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import Login from './pages/Login/Login'
|
||||
import { AdminErrorBoundary } from './admin/components/AdminErrorBoundary'
|
||||
import LegacyI18nBridge from './i18n/LegacyI18nBridge'
|
||||
|
||||
const Register = lazy(() => import('./pages/Register/Register'))
|
||||
const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail'))
|
||||
@@ -26,35 +28,43 @@ function isPublicPath(pathname: string) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { t } = useTranslation()
|
||||
const { token } = useAuthStore()
|
||||
const { pathname } = useLocation()
|
||||
const isPublicRoute = isPublicPath(pathname)
|
||||
|
||||
useEffect(() => {
|
||||
document.title = t('app.title')
|
||||
}, [t])
|
||||
|
||||
if (!token && !isPublicRoute) {
|
||||
return <Login />
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__spinner" aria-label="正在加载" />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
|
||||
<Route path={EARTH_ROUTE} element={<Earth />} />
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
<>
|
||||
<LegacyI18nBridge />
|
||||
<Suspense
|
||||
fallback={(
|
||||
<div className="app-route-loading">
|
||||
<div className="app-route-loading__spinner" aria-label={t('app.routeLoading')} />
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path={ROOT_ROUTE} element={<Navigate to={EARTH_ROUTE} replace />} />
|
||||
<Route path={EARTH_ROUTE} element={<Earth />} />
|
||||
<Route path={DOCS_ROUTE} element={<Docs />} />
|
||||
<Route path={DOCS_ROUTE_PATTERN} element={<Docs />} />
|
||||
<Route path="/playground" element={<Navigate to="/ai?section=playground" replace />} />
|
||||
<Route path="/*" element={<AdminErrorBoundary><AdminRoutes /></AdminErrorBoundary>} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from '@tanstack/react-table'
|
||||
import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion'
|
||||
import { Button } from '../ui/button'
|
||||
|
||||
@@ -37,11 +38,12 @@ export function DataTable<TData>({
|
||||
getRowClassName,
|
||||
selection,
|
||||
loading = false,
|
||||
emptyText = '暂无数据',
|
||||
emptyText,
|
||||
className = '',
|
||||
footer,
|
||||
onRowClick,
|
||||
}: DataTableProps<TData>) {
|
||||
const { t } = useTranslation()
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const memoizedColumns = useMemo(() => columns, [columns])
|
||||
|
||||
@@ -74,7 +76,7 @@ export function DataTable<TData>({
|
||||
<th className="an-data-table__selection-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label="选择当前可见数据"
|
||||
aria-label={t('common.selectVisibleRows')}
|
||||
checked={allVisibleSelected}
|
||||
disabled={!visibleSelectableIds.length}
|
||||
ref={(element) => {
|
||||
@@ -114,7 +116,7 @@ export function DataTable<TData>({
|
||||
<td colSpan={columnCount}>
|
||||
<div className="an-data-table__state">
|
||||
<span className="an-spinner" />
|
||||
加载中
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,7 +133,7 @@ export function DataTable<TData>({
|
||||
<td className="an-data-table__selection-cell">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={selection.getCheckboxLabel?.(row.original) || '选择行'}
|
||||
aria-label={selection.getCheckboxLabel?.(row.original) || t('common.selectRow')}
|
||||
checked={selection.selectedRowIds.has(row.id)}
|
||||
disabled={selection.isRowSelectable ? !selection.isRowSelectable(row.original) : false}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
@@ -149,7 +151,7 @@ export function DataTable<TData>({
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={columnCount}>
|
||||
<div className="an-data-table__state">{emptyText}</div>
|
||||
<div className="an-data-table__state">{emptyText || t('common.noData')}</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -173,18 +175,19 @@ export function DataTablePager({
|
||||
total: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize))
|
||||
return (
|
||||
<div className="an-data-table__pager">
|
||||
<span>
|
||||
第 {page} / {totalPages} 页,共 {total.toLocaleString()} 条
|
||||
{t('common.page', { page, totalPages, total: total.toLocaleString() })}
|
||||
</span>
|
||||
<div className="an-data-table__pager-actions">
|
||||
<Button size="sm" variant="subtle" disabled={page <= 1} onClick={() => onPageChange(page - 1)}>
|
||||
上一页
|
||||
{t('common.previousPage')}
|
||||
</Button>
|
||||
<Button size="sm" variant="subtle" disabled={page >= totalPages} onClick={() => onPageChange(page + 1)}>
|
||||
下一页
|
||||
{t('common.nextPage')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import {
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Monitor,
|
||||
Search,
|
||||
Settings,
|
||||
Sun,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import packageJson from '../../../../package.json'
|
||||
import Scrollbar from '../../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../../components/SegmentedControl/SegmentedControl'
|
||||
import { localeOptions, useLocale, type SupportedLocale } from '../../../i18n/locale'
|
||||
import { useAuthStore } from '../../../stores/auth'
|
||||
import { useAdminTheme, type AdminThemeMode } from '../../design/theme'
|
||||
import { cn } from '../../utils'
|
||||
@@ -28,6 +32,8 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const adminSearch = useAdminSearch()
|
||||
const { t } = useTranslation()
|
||||
const { locale, setLocale } = useLocale()
|
||||
const { user, logout } = useAuthStore()
|
||||
const { mode, setMode } = useAdminTheme()
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
@@ -35,25 +41,39 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(cachedOpenKeys)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [preferencesOpen, setPreferencesOpen] = useState(false)
|
||||
const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0)
|
||||
const menuViewportRef = useRef<HTMLDivElement>(null)
|
||||
const searchInputRef = useRef<HTMLInputElement>(null)
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const username = user?.username || '-'
|
||||
const userInitial = username.trim().charAt(0).toUpperCase() || '?'
|
||||
const preferencesLabel = preferencesOpen ? t('admin.collapsePreferences') : t('admin.expandPreferences')
|
||||
const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin])
|
||||
const navGroups = useMemo(() => {
|
||||
return adminRouteGroups.map((group) => ({
|
||||
...group,
|
||||
children: visibleRoutes.filter((route) => route.group === group.key),
|
||||
label: t(group.labelKey),
|
||||
children: visibleRoutes
|
||||
.filter((route) => route.group === group.key)
|
||||
.map((route) => ({ ...route, label: t(route.labelKey) })),
|
||||
})).filter((group) => group.children.length > 0)
|
||||
}, [visibleRoutes])
|
||||
}, [t, visibleRoutes])
|
||||
const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '')
|
||||
const activeRoute = visibleRoutes.find((route) => route.path === selectedKey)
|
||||
const activeRouteLabel = activeRoute ? t(activeRoute.labelKey) : ''
|
||||
const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery])
|
||||
const themeOptions = useMemo(() => [
|
||||
{ value: 'light' as const, label: '浅色', title: '浅色', icon: <Sun /> },
|
||||
{ value: 'system' as const, label: '系统', title: '跟随系统', icon: <Monitor /> },
|
||||
{ value: 'dark' as const, label: '深色', title: '深色', icon: <Moon /> },
|
||||
], [])
|
||||
{ value: 'light' as const, label: t('common.themeLight'), title: t('common.themeLight'), icon: <Sun /> },
|
||||
{ value: 'system' as const, label: t('common.themeSystem'), title: t('common.themeFollowSystem'), icon: <Monitor /> },
|
||||
{ value: 'dark' as const, label: t('common.themeDark'), title: t('common.themeDark'), icon: <Moon /> },
|
||||
], [t])
|
||||
const languageOptions = useMemo(() => localeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
title: t(option.titleKey),
|
||||
icon: <Languages />,
|
||||
})), [t])
|
||||
|
||||
const updateOpenKeys = (nextKeys: string[]) => {
|
||||
cachedOpenKeys = nextKeys
|
||||
@@ -116,14 +136,15 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
event.stopPropagation()
|
||||
setCollapsed((value) => !value)
|
||||
}}
|
||||
aria-label={collapsed ? '展开菜单' : '折叠菜单'}
|
||||
title={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
|
||||
aria-label={collapsed ? t('admin.expandMenu') : t('admin.collapseMenu')}
|
||||
>
|
||||
{collapsed ? <Menu size={18} /> : <X size={18} />}
|
||||
</Button>
|
||||
{!collapsed ? (
|
||||
<div className="admin__brand-copy">
|
||||
<span className="admin__brand-text">智能星球</span>
|
||||
<span className="admin__brand-subtitle">控制台</span>
|
||||
<span className="admin__brand-text">{t('admin.brandTitle')}</span>
|
||||
<span className="admin__brand-subtitle">{t('admin.brandSubtitle')}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -185,36 +206,61 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
|
||||
{!collapsed ? (
|
||||
<div className="admin__account">
|
||||
<div className="admin__account-row">
|
||||
<div>
|
||||
<strong>Hi, {user?.username || '-'}</strong>
|
||||
<div className="admin__account-row admin__account-row--primary">
|
||||
<div className="admin__account-profile">
|
||||
<span className="admin__account-avatar" aria-hidden="true">{userInitial}</span>
|
||||
<div>
|
||||
<strong>{t('admin.greeting', { name: username })}</strong>
|
||||
<span>{t('admin.version')} v{packageJson.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin__account-actions">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={cn('admin__account-preferences', preferencesOpen && 'is-active')}
|
||||
onClick={() => setPreferencesOpen((value) => !value)}
|
||||
title={preferencesLabel}
|
||||
aria-label={preferencesLabel}
|
||||
aria-expanded={preferencesOpen}
|
||||
>
|
||||
<Settings size={15} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="admin__account-logout"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
title={t('admin.logout')}
|
||||
aria-label={t('admin.logout')}
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="admin__account-logout"
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
aria-label="退出登录"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin__account-row">
|
||||
<span>版本号</span>
|
||||
<strong>v{packageJson.version}</strong>
|
||||
<div className={cn('admin__preferences-drawer', preferencesOpen && 'is-open')} aria-hidden={!preferencesOpen}>
|
||||
<div className="admin__preferences-panel">
|
||||
<SegmentedControl<SupportedLocale>
|
||||
ariaLabel={t('admin.languageControl')}
|
||||
className="admin__language-control admin__language-control--sider"
|
||||
options={languageOptions}
|
||||
scale={0.86}
|
||||
value={locale}
|
||||
onChange={setLocale}
|
||||
/>
|
||||
<SegmentedControl<AdminThemeMode>
|
||||
ariaLabel={t('admin.themeControl')}
|
||||
className="admin__theme-control admin__theme-control--sider"
|
||||
options={themeOptions}
|
||||
scale={0.86}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SegmentedControl<AdminThemeMode>
|
||||
ariaLabel="控制台主题"
|
||||
className="admin__theme-control admin__theme-control--sider"
|
||||
options={themeOptions}
|
||||
scale={0.72}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -249,22 +295,22 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
{mobileNavOpen ? (
|
||||
<div className="admin__mobile-nav">
|
||||
<div className="admin__mobile-nav-panel">{nav}</div>
|
||||
<button className="admin__mobile-nav-backdrop" type="button" aria-label="关闭导航" onClick={() => setMobileNavOpen(false)} />
|
||||
<button className="admin__mobile-nav-backdrop" type="button" aria-label={t('admin.closeNav')} onClick={() => setMobileNavOpen(false)} />
|
||||
</div>
|
||||
) : null}
|
||||
<main className="admin__content">
|
||||
<header className="admin__topbar">
|
||||
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label="打开导航">
|
||||
<Button size="icon" variant="ghost" className="admin__mobile-menu" onClick={() => setMobileNavOpen(true)} aria-label={t('admin.openNav')} title={t('admin.openNav')}>
|
||||
<Menu size={18} />
|
||||
</Button>
|
||||
<div className="admin__search" onBlur={handleSearchBlur}>
|
||||
<Search className="admin__search-icon" size={16} />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
aria-label="搜索功能、配置和文字"
|
||||
aria-label={t('admin.search.label')}
|
||||
autoComplete="off"
|
||||
value={searchQuery}
|
||||
placeholder={activeRoute ? `搜索功能、配置和文字,当前:${activeRoute.label}` : '搜索功能、配置和文字'}
|
||||
placeholder={activeRouteLabel ? `${t('admin.search.placeholder')},${t('admin.search.current', { label: activeRouteLabel })}` : t('admin.search.placeholder')}
|
||||
onChange={(event) => {
|
||||
setSearchQuery(event.target.value)
|
||||
setSearchOpen(true)
|
||||
@@ -274,7 +320,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{searchOpen ? (
|
||||
<div className="admin__search-results" role="listbox" aria-label="Admin 搜索结果">
|
||||
<div className="admin__search-results" role="listbox" aria-label={t('admin.search.results')}>
|
||||
{searchResults.length > 0 ? searchResults.map((target, index) => {
|
||||
const ResultIcon = target.icon || Search
|
||||
return (
|
||||
@@ -296,7 +342,7 @@ export function AdminLayout({ children }: { children: ReactNode }) {
|
||||
</button>
|
||||
)
|
||||
}) : (
|
||||
<div className="admin__search-empty">{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}</div>
|
||||
<div className="admin__search-empty">{adminSearch.loading ? t('admin.search.loading') : t('admin.search.empty')}</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Scrollbar from '../../../components/Scrollbar/Scrollbar'
|
||||
import { Button } from './button'
|
||||
|
||||
@@ -15,6 +16,8 @@ interface DialogProps {
|
||||
}
|
||||
|
||||
export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
|
||||
<DialogPrimitive.Portal>
|
||||
@@ -30,7 +33,7 @@ export function Dialog({ open, onOpenChange, title, description, children, foote
|
||||
) : null}
|
||||
</div>
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button size="icon" variant="ghost" aria-label="关闭">
|
||||
<Button size="icon" variant="ghost" aria-label={t('common.close')} title={t('common.close')}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
@@ -60,12 +63,16 @@ export function ConfirmDialog({
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = '确认',
|
||||
cancelLabel = '取消',
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
danger = false,
|
||||
loading = false,
|
||||
onConfirm,
|
||||
}: ConfirmDialogProps) {
|
||||
const { t } = useTranslation()
|
||||
const resolvedCancelLabel = cancelLabel || t('common.cancel')
|
||||
const resolvedConfirmLabel = confirmLabel || t('common.confirm')
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
@@ -76,15 +83,15 @@ export function ConfirmDialog({
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="subtle" onClick={() => onOpenChange(false)} disabled={loading}>
|
||||
{cancelLabel}
|
||||
{resolvedCancelLabel}
|
||||
</Button>
|
||||
<Button variant={danger ? 'danger' : 'primary'} onClick={onConfirm} loading={loading}>
|
||||
{confirmLabel}
|
||||
{resolvedConfirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">{description || '请确认本次操作。'}</span>
|
||||
<span className="sr-only">{description || t('common.confirm')}</span>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as ToastPrimitive from '@radix-ui/react-toast'
|
||||
import { X } from 'lucide-react'
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ToastTone = 'default' | 'success' | 'error'
|
||||
|
||||
@@ -18,6 +19,7 @@ interface ToastContextValue {
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation()
|
||||
const [items, setItems] = useState<ToastItem[]>([])
|
||||
|
||||
const toast = useCallback((item: Omit<ToastItem, 'id' | 'tone'> & { tone?: ToastTone }) => {
|
||||
@@ -46,7 +48,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
{item.description}
|
||||
</ToastPrimitive.Description>
|
||||
) : null}
|
||||
<ToastPrimitive.Close className="an-toast__close" aria-label="关闭通知">
|
||||
<ToastPrimitive.Close className="an-toast__close" aria-label={t('common.close')} title={t('common.close')}>
|
||||
<X size={14} />
|
||||
</ToastPrimitive.Close>
|
||||
</ToastPrimitive.Root>
|
||||
|
||||
@@ -410,8 +410,8 @@ export default function DataList() {
|
||||
<CardHeader>
|
||||
<CardTitle>数据概览</CardTitle>
|
||||
<div className="an-segmented">
|
||||
<button className={distributionDimension === 'source' ? 'is-active' : ''} onClick={() => setDistributionDimension('source')}>按数据源</button>
|
||||
<button className={distributionDimension === 'type' ? 'is-active' : ''} onClick={() => setDistributionDimension('type')}>按类型</button>
|
||||
<button type="button" className={distributionDimension === 'source' ? 'is-active' : ''} onClick={() => setDistributionDimension('source')}>按数据源</button>
|
||||
<button type="button" className={distributionDimension === 'type' ? 'is-active' : ''} onClick={() => setDistributionDimension('type')}>按类型</button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
@@ -34,7 +34,7 @@ import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import { useWebSocket } from '../../hooks/useWebSocket'
|
||||
import { AdminLayout } from '../components/layout/AdminLayout'
|
||||
import { DataTable } from '../components/data-table/DataTable'
|
||||
import { Button } from '../components/ui/button'
|
||||
import { Button, type ButtonProps } from '../components/ui/button'
|
||||
import { ConfirmDialog, Dialog } from '../components/ui/dialog'
|
||||
import { Textarea } from '../components/ui/input'
|
||||
import { AdminSwitch } from '../components/ui/switch'
|
||||
@@ -177,7 +177,7 @@ interface PlaygroundActionResponse {
|
||||
session?: PlaygroundThreadResponse['session']
|
||||
}
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
function apiPath(path: string) {
|
||||
if (path.startsWith('/api/')) return path
|
||||
@@ -813,11 +813,43 @@ type FieldConfig = {
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
help?: string
|
||||
inputAction?: {
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
icon?: ButtonProps['icon']
|
||||
loading?: boolean
|
||||
onClick: () => void
|
||||
title: string
|
||||
}
|
||||
wide?: boolean
|
||||
secretVisible?: boolean
|
||||
onToggleSecret?: (visible: boolean) => void
|
||||
}
|
||||
|
||||
function ConnectionTestInput({
|
||||
action,
|
||||
children,
|
||||
}: {
|
||||
action: NonNullable<FieldConfig['inputAction']>
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="an-connection-test-input">
|
||||
{children}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
icon={action.icon || 'connect'}
|
||||
title={action.title}
|
||||
aria-label={action.ariaLabel || action.title}
|
||||
disabled={action.disabled}
|
||||
loading={action.loading}
|
||||
onClick={action.onClick}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldLabels: Record<string, string> = {
|
||||
key: '键',
|
||||
label: '标签',
|
||||
@@ -2298,19 +2330,37 @@ function FieldGrid({
|
||||
return (
|
||||
<label key={field.key} className={className} data-admin-search-target={searchTarget} data-admin-search-field={field.key} data-admin-search-text={searchText}>
|
||||
<span>{field.label}</span>
|
||||
<input
|
||||
className="an-input"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
record,
|
||||
field.key,
|
||||
field.type === 'number' ? Number(event.target.value) : event.target.value,
|
||||
))}
|
||||
/>
|
||||
{field.inputAction ? (
|
||||
<ConnectionTestInput action={{ ...field.inputAction, disabled: field.disabled || field.inputAction.disabled }}>
|
||||
<input
|
||||
className="an-input an-connection-test-input__control"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
record,
|
||||
field.key,
|
||||
field.type === 'number' ? Number(event.target.value) : event.target.value,
|
||||
))}
|
||||
/>
|
||||
</ConnectionTestInput>
|
||||
) : (
|
||||
<input
|
||||
className="an-input"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
value={text(value, '')}
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.disabled}
|
||||
onChange={(event) => onDraftChange(setNestedDraftField(
|
||||
draft,
|
||||
record,
|
||||
field.key,
|
||||
field.type === 'number' ? Number(event.target.value) : event.target.value,
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
{field.help ? <small>{field.help}</small> : null}
|
||||
</label>
|
||||
)
|
||||
@@ -4761,7 +4811,21 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
{ value: 'anthropic-messages', label: 'Anthropic Messages' },
|
||||
{ value: 'ollama-generate', label: 'Ollama Generate' },
|
||||
] },
|
||||
{ key: 'base_url', label: 'LLM 基础地址', wide: true },
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'LLM 基础地址',
|
||||
wide: true,
|
||||
inputAction: activeGroup ? {
|
||||
title: '测试 AI Provider 连通性',
|
||||
icon: 'test',
|
||||
loading: actionLoading,
|
||||
onClick: () => void testConnection(
|
||||
'AI Provider',
|
||||
'/settings/integrations/ai-provider/connect',
|
||||
sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')),
|
||||
),
|
||||
} : undefined,
|
||||
},
|
||||
{ key: 'model', label: '默认模型' },
|
||||
{
|
||||
key: 'api_key',
|
||||
@@ -4785,28 +4849,47 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
onToggleSecret: toggleActiveSecret('service_token', 'ai_provider'),
|
||||
},
|
||||
]
|
||||
const webSearchDisabled = activeGroup?.key.startsWith('web_search:') && !Boolean(record.enabled)
|
||||
const webFields: FieldConfig[] = [
|
||||
{ key: 'provider', label: '搜索供应商' },
|
||||
{ key: 'base_url', label: 'API 基础地址', wide: true },
|
||||
{ key: 'enabled', label: '启用 WebSearch', type: 'boolean' },
|
||||
{ key: 'provider', label: '搜索供应商', disabled: webSearchDisabled },
|
||||
{
|
||||
key: 'base_url',
|
||||
label: 'API 基础地址',
|
||||
wide: true,
|
||||
disabled: webSearchDisabled,
|
||||
inputAction: activeGroup ? {
|
||||
title: '测试 Web Search 连通性',
|
||||
icon: 'test',
|
||||
loading: actionLoading,
|
||||
disabled: webSearchDisabled,
|
||||
onClick: () => void testConnection(
|
||||
'Web Search',
|
||||
'/settings/integrations/web-search/connect',
|
||||
sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key'], revealedFor('web_search')),
|
||||
),
|
||||
} : undefined,
|
||||
},
|
||||
{
|
||||
key: 'api_key',
|
||||
label: 'WebSearch API Key',
|
||||
type: 'secret',
|
||||
placeholder: '输入新的 WebSearch API Key',
|
||||
disabled: webSearchDisabled,
|
||||
secretVisible: Boolean(visibleSecretFields[secretFieldKey('api_key')]),
|
||||
onToggleSecret: toggleActiveSecret('api_key', 'web_search'),
|
||||
},
|
||||
{ key: 'max_results', label: '最大结果数', type: 'number' },
|
||||
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number' },
|
||||
{ key: 'endpoint_path', label: '接口路径' },
|
||||
{ key: 'search_depth', label: '搜索深度' },
|
||||
{ key: 'engine', label: 'SerpAPI 引擎' },
|
||||
{ key: 'categories', label: 'SearXNG 分类' },
|
||||
{ key: 'search_path', label: 'Firecrawl 搜索路径' },
|
||||
{ key: 'scrape_path', label: 'Firecrawl 抓取路径' },
|
||||
{ key: 'include_answer', label: '包含答案', type: 'boolean' },
|
||||
{ key: 'include_raw_content', label: '包含原始内容', type: 'boolean' },
|
||||
{ key: 'include_text', label: '包含正文', type: 'boolean' },
|
||||
{ key: 'max_results', label: '最大结果数', type: 'number', disabled: webSearchDisabled },
|
||||
{ key: 'timeout_seconds', label: '超时(秒)', type: 'number', disabled: webSearchDisabled },
|
||||
{ key: 'endpoint_path', label: '接口路径', disabled: webSearchDisabled },
|
||||
{ key: 'search_depth', label: '搜索深度', disabled: webSearchDisabled },
|
||||
{ key: 'engine', label: 'SerpAPI 引擎', disabled: webSearchDisabled },
|
||||
{ key: 'categories', label: 'SearXNG 分类', disabled: webSearchDisabled },
|
||||
{ key: 'search_path', label: 'Firecrawl 搜索路径', disabled: webSearchDisabled },
|
||||
{ key: 'scrape_path', label: 'Firecrawl 抓取路径', disabled: webSearchDisabled },
|
||||
{ key: 'include_answer', label: '包含答案', type: 'boolean', disabled: webSearchDisabled },
|
||||
{ key: 'include_raw_content', label: '包含原始内容', type: 'boolean', disabled: webSearchDisabled },
|
||||
{ key: 'include_text', label: '包含正文', type: 'boolean', disabled: webSearchDisabled },
|
||||
]
|
||||
const ocrFields: FieldConfig[] = [
|
||||
{ key: 'provider', label: 'OCR 供应商', type: 'select', options: [
|
||||
@@ -4959,14 +5042,12 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
<>
|
||||
<Button size="icon" variant="subtle" title={isActiveDefault ? '当前已是默认' : '设为默认'} aria-label={isActiveDefault ? '当前已是默认' : '设为默认'} onClick={() => void setActiveAsDefault()} loading={actionLoading} disabled={isActiveDefault}><CheckCircle2 size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" title="恢复默认配置" aria-label="恢复默认配置" onClick={restoreActiveDefaults}><Redo2 size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" icon="test" title="测试 Web Search 连通性" aria-label="测试 Web Search 连通性" onClick={() => void testConnection('Web Search', '/settings/integrations/web-search/connect', sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key'], revealedFor('web_search')))} loading={actionLoading} />
|
||||
</>
|
||||
) : null}
|
||||
{activeSection.key === 'integrations' ? (
|
||||
<>
|
||||
<Button size="icon" variant="subtle" title={isActiveDefault ? '当前已是默认' : '设为默认'} aria-label={isActiveDefault ? '当前已是默认' : '设为默认'} onClick={() => void setActiveAsDefault()} loading={actionLoading} disabled={isActiveDefault}><CheckCircle2 size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" title="恢复默认配置" aria-label="恢复默认配置" onClick={restoreActiveDefaults}><Redo2 size={15} /></Button>
|
||||
<Button size="icon" variant="subtle" icon="test" title="测试 AI Provider 连通性" aria-label="测试 AI Provider 连通性" onClick={() => void testConnection('AI Provider', '/settings/integrations/ai-provider/connect', sanitizeSecretDrafts(stripInternalFields(record), activeGroup.record, ['api_key', 'service_token'], revealedFor('ai_provider')))} loading={actionLoading} />
|
||||
</>
|
||||
) : null}
|
||||
{activeGroup?.key.startsWith('ocr:') ? (
|
||||
@@ -6276,20 +6357,6 @@ function ModuleConsole({ config }: { config: ModuleConfig }) {
|
||||
actions.push(<Button key="refresh-preset" variant="primary" onClick={() => void requestAction('刷新模型列表', 'post', `/settings/integrations/ai-provider/presets/${encodeURIComponent(id)}/refresh`, undefined, { refresh: false, successDescription: '模型预设已刷新,未保存 provider 表单。' })} loading={actionLoading}><RefreshCw size={15} />刷新模型</Button>)
|
||||
}
|
||||
|
||||
if (config === configs.ai && endpointKey === 'integrations') {
|
||||
const integrationKey = pick(selected, ['__title', 'key', 'provider'], '')
|
||||
if (integrationKey === 'ai_provider') {
|
||||
actions.push(
|
||||
<Button key="connect-ai" size="icon" variant="subtle" icon="test" title="测试 AI Provider 连通性" aria-label="测试 AI Provider 连通性" onClick={() => void testConnection('AI Provider', '/settings/integrations/ai-provider/connect', cleanRecord(selected))} loading={actionLoading} />,
|
||||
)
|
||||
}
|
||||
if (integrationKey === 'web_search') {
|
||||
actions.push(
|
||||
<Button key="connect-web" size="icon" variant="subtle" icon="test" title="测试 Web Search 连通性" aria-label="测试 Web Search 连通性" onClick={() => void testConnection('Web Search', '/settings/integrations/web-search/connect', cleanRecord(selected))} loading={actionLoading} />,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (config === configs.ai && endpointKey === 'prompts' && id) {
|
||||
actions.push(<Button key="reset-prompt" variant="danger" onClick={() => setConfirmAction({
|
||||
title: '重置 Prompt',
|
||||
@@ -7150,7 +7217,7 @@ const configs = {
|
||||
{ key: 'prompts', label: '提示词', url: '/settings/ai-prompts', map: (payload) => dataArray(payload).map((row) => ({ ...row, __module: '提示词', __status: pick(row, ['source'], 'prompt') })) },
|
||||
{ key: 'playground', label: 'Playground', url: '/ai/playground/session', map: (payload) => isObjectRecord(payload) ? [{ ...payload, __title: 'Playground 会话', __module: 'Playground', __status: 'saved' }] : [] },
|
||||
],
|
||||
actions: [makeAction('设置', <Settings2 size={15} />, `/admin/${'settings'}`)],
|
||||
actions: [makeAction('设置', <Settings2 size={15} />, '/settings')],
|
||||
detailTitle: 'AI 配置详情',
|
||||
},
|
||||
earthContent: {
|
||||
@@ -7274,7 +7341,7 @@ const configs = {
|
||||
detailTitle: '日志详情',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
title: '系统设置',
|
||||
description: '管理系统显示、通知策略、安全策略和 SMTP 邮件。',
|
||||
listTitle: '设置分区',
|
||||
listDescription: '仅展示系统设置分区;AI 集成和采集器调度分别在对应模块管理。',
|
||||
|
||||
@@ -4,6 +4,7 @@ import axios from 'axios'
|
||||
import { Edit, Plus, Search, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { z } from 'zod'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { DataTable } from '../components/data-table/DataTable'
|
||||
@@ -25,28 +26,17 @@ interface User {
|
||||
created_at: string
|
||||
}
|
||||
|
||||
const userSchema = z.object({
|
||||
username: z.string().min(1, '请输入用户名'),
|
||||
email: z.string().email('请输入有效邮箱'),
|
||||
password: z.string().optional(),
|
||||
role: z.string().min(1, '请选择角色'),
|
||||
gatekeeper_groups: z.array(z.string()).optional(),
|
||||
})
|
||||
interface UserFormValues {
|
||||
username: string
|
||||
email: string
|
||||
password?: string
|
||||
role: string
|
||||
gatekeeper_groups?: string[]
|
||||
}
|
||||
|
||||
type UserFormValues = z.infer<typeof userSchema>
|
||||
const roleValues = ['super_admin', 'admin', 'operator', 'viewer'] as const
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'super_admin', label: '超级管理员' },
|
||||
{ value: 'admin', label: '管理员' },
|
||||
{ value: 'operator', label: '操作员' },
|
||||
{ value: 'viewer', label: '只读用户' },
|
||||
]
|
||||
|
||||
const gatekeeperOptions = [
|
||||
{ value: 'docs_user', label: '文档:用户文档' },
|
||||
{ value: 'docs_developer', label: '文档:开发文档' },
|
||||
{ value: 'docs_admin', label: '文档:管理/运维文档' },
|
||||
]
|
||||
const gatekeeperValues = ['docs_user', 'docs_developer', 'docs_admin'] as const
|
||||
|
||||
function roleTone(role: string) {
|
||||
if (role === 'super_admin') return 'red'
|
||||
@@ -56,15 +46,8 @@ function roleTone(role: string) {
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function roleLabel(role: string) {
|
||||
return roleOptions.find((option) => option.value === role)?.label || role
|
||||
}
|
||||
|
||||
function gatekeeperLabel(group: string) {
|
||||
return gatekeeperOptions.find((option) => option.value === group)?.label || group
|
||||
}
|
||||
|
||||
export default function Users() {
|
||||
const { t } = useTranslation()
|
||||
const { user: currentUser } = useAuthStore()
|
||||
const { toast } = useToast()
|
||||
const [users, setUsers] = useState<User[]>([])
|
||||
@@ -74,6 +57,23 @@ export default function Users() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const isSuperAdmin = currentUser?.role === 'super_admin'
|
||||
const userSchema = useMemo(() => z.object({
|
||||
username: z.string().min(1, t('auth.username')),
|
||||
email: z.string().email(t('auth.email')),
|
||||
password: z.string().optional(),
|
||||
role: z.string().min(1, t('users.role')),
|
||||
gatekeeper_groups: z.array(z.string()).optional(),
|
||||
}), [t])
|
||||
const roleOptions = useMemo(() => roleValues.map((value) => ({
|
||||
value,
|
||||
label: t(`users.roles.${value}`),
|
||||
})), [t])
|
||||
const gatekeeperOptions = useMemo(() => gatekeeperValues.map((value) => ({
|
||||
value,
|
||||
label: t(`users.gatekeeper.${value}`),
|
||||
})), [t])
|
||||
const roleLabel = (role: string) => roleOptions.find((option) => option.value === role)?.label || role
|
||||
const gatekeeperLabel = (group: string) => gatekeeperOptions.find((option) => option.value === group)?.label || group
|
||||
|
||||
const form = useForm<UserFormValues>({
|
||||
resolver: zodResolver(userSchema),
|
||||
@@ -125,17 +125,17 @@ export default function Users() {
|
||||
if (!isSuperAdmin) delete payload.gatekeeper_groups
|
||||
if (editingUser) {
|
||||
await axios.put(`/api/v1/users/${editingUser.id}`, payload)
|
||||
toast({ tone: 'success', title: '更新成功' })
|
||||
toast({ tone: 'success', title: t('users.updateSuccess') })
|
||||
} else {
|
||||
const createPayload = { ...payload, password: values.password || '' }
|
||||
await axios.post('/api/v1/users', createPayload)
|
||||
toast({ tone: 'success', title: '创建成功' })
|
||||
toast({ tone: 'success', title: t('users.createSuccess') })
|
||||
}
|
||||
setModalVisible(false)
|
||||
void fetchUsers()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
toast({ tone: 'error', title: '操作失败', description: err.response?.data?.detail || '请稍后重试' })
|
||||
toast({ tone: 'error', title: t('common.operationFailed'), description: err.response?.data?.detail || t('users.retryLater') })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,22 +143,22 @@ export default function Users() {
|
||||
if (!deleteTarget) return
|
||||
try {
|
||||
await axios.delete(`/api/v1/users/${deleteTarget.id}`)
|
||||
toast({ tone: 'success', title: '删除成功' })
|
||||
toast({ tone: 'success', title: t('users.deleteSuccess') })
|
||||
setDeleteTarget(null)
|
||||
void fetchUsers()
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { data?: { detail?: string } } }
|
||||
toast({ tone: 'error', title: '删除失败', description: err.response?.data?.detail || '请稍后重试' })
|
||||
toast({ tone: 'error', title: t('users.deleteFailed'), description: err.response?.data?.detail || t('users.retryLater') })
|
||||
}
|
||||
}
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<User>>>(() => [
|
||||
{ accessorKey: 'id', header: 'ID', size: 80 },
|
||||
{ accessorKey: 'username', header: '用户名', size: 180 },
|
||||
{ accessorKey: 'email', header: '邮箱', size: 260 },
|
||||
{ accessorKey: 'username', header: t('auth.username'), size: 180 },
|
||||
{ accessorKey: 'email', header: t('auth.email'), size: 260 },
|
||||
{
|
||||
accessorKey: 'role',
|
||||
header: '角色',
|
||||
header: t('users.role'),
|
||||
size: 140,
|
||||
cell: ({ row }) => <Badge tone={roleTone(row.original.role)} title={row.original.role}>{roleLabel(row.original.role)}</Badge>,
|
||||
},
|
||||
@@ -175,30 +175,30 @@ export default function Users() {
|
||||
<Badge key={group} tone={group === 'docs_admin' ? 'red' : group === 'docs_developer' ? 'blue' : 'green'}>
|
||||
{gatekeeperLabel(group)}
|
||||
</Badge>
|
||||
)) : <Badge tone="slate">未配置</Badge>}
|
||||
)) : <Badge tone="slate">{t('users.unconfigured')}</Badge>}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'is_active',
|
||||
header: '状态',
|
||||
header: t('users.status'),
|
||||
size: 120,
|
||||
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? '活跃' : '禁用'}</Badge>,
|
||||
cell: ({ row }) => <Badge tone={row.original.is_active ? 'green' : 'red'}>{row.original.is_active ? t('users.active') : t('users.disabled')}</Badge>,
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '操作',
|
||||
header: t('users.actions'),
|
||||
size: 148,
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div className="an-row-actions">
|
||||
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} />编辑</Button>
|
||||
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} />删除</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => handleEdit(row.original)}><Edit size={14} />{t('users.edit')}</Button>
|
||||
<Button size="sm" variant="ghost" className="is-danger" onClick={() => setDeleteTarget(row.original)}><Trash2 size={14} />{t('common.delete')}</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [])
|
||||
], [gatekeeperOptions, roleOptions, t])
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase()
|
||||
@@ -218,8 +218,8 @@ export default function Users() {
|
||||
<div className="an-page">
|
||||
<div className="an-page__header">
|
||||
<div>
|
||||
<h1>用户管理</h1>
|
||||
<p>维护后台账号、角色与文档权限组。</p>
|
||||
<h1>{t('admin.routes.users')}</h1>
|
||||
<p>{t('users.description')}</p>
|
||||
</div>
|
||||
<div className="an-toolbar">
|
||||
<div className="an-search-box">
|
||||
@@ -227,15 +227,15 @@ export default function Users() {
|
||||
<Input
|
||||
value={searchText}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
placeholder="搜索用户、邮箱、角色"
|
||||
placeholder={t('users.searchPlaceholder')}
|
||||
/>
|
||||
{searchText ? (
|
||||
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label="清空搜索" title="清空搜索">
|
||||
<Button size="icon" variant="ghost" onClick={() => setSearchText('')} aria-label={t('users.clearSearch')} title={t('users.clearSearch')}>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button variant="primary" onClick={handleAdd}><Plus size={16} />添加用户</Button>
|
||||
<Button variant="primary" onClick={handleAdd}><Plus size={16} />{t('users.addUser')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="an-page__body">
|
||||
@@ -244,40 +244,40 @@ export default function Users() {
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
title={editingUser ? '编辑用户' : '添加用户'}
|
||||
title={editingUser ? t('users.editUser') : t('users.addUser')}
|
||||
open={modalVisible}
|
||||
onOpenChange={setModalVisible}
|
||||
footer={(
|
||||
<>
|
||||
<Button variant="subtle" onClick={() => setModalVisible(false)}>取消</Button>
|
||||
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}>提交</Button>
|
||||
<Button variant="subtle" onClick={() => setModalVisible(false)}>{t('common.cancel')}</Button>
|
||||
<Button variant="primary" loading={form.formState.isSubmitting} onClick={form.handleSubmit(handleSubmit)}>{t('users.submit')}</Button>
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<form className="an-form" onSubmit={form.handleSubmit(handleSubmit)}>
|
||||
<label className="an-field">
|
||||
<span>用户名</span>
|
||||
<span>{t('auth.username')}</span>
|
||||
<Input {...form.register('username')} />
|
||||
{form.formState.errors.username ? <em>{form.formState.errors.username.message}</em> : null}
|
||||
</label>
|
||||
<label className="an-field">
|
||||
<span>邮箱</span>
|
||||
<span>{t('auth.email')}</span>
|
||||
<Input {...form.register('email')} />
|
||||
{form.formState.errors.email ? <em>{form.formState.errors.email.message}</em> : null}
|
||||
</label>
|
||||
{!editingUser ? (
|
||||
<label className="an-field">
|
||||
<span>密码</span>
|
||||
<span>{t('auth.password')}</span>
|
||||
<Input type="password" {...form.register('password', { required: true, minLength: 8 })} />
|
||||
{form.formState.errors.password ? <em>密码至少 8 位</em> : null}
|
||||
{form.formState.errors.password ? <em>{t('auth.passwordHint')}</em> : null}
|
||||
</label>
|
||||
) : null}
|
||||
<label className="an-field">
|
||||
<span>角色</span>
|
||||
<span>{t('users.role')}</span>
|
||||
<Select value={form.watch('role')} onValueChange={(value) => form.setValue('role', value)} options={roleOptions} />
|
||||
</label>
|
||||
<div className="an-field">
|
||||
<span>Gatekeeper 权限组</span>
|
||||
<span>{t('users.gatekeeperGroups')}</span>
|
||||
<div className="an-checkbox-list" aria-disabled={!isSuperAdmin}>
|
||||
{gatekeeperOptions.map((option) => (
|
||||
<label key={option.value}>
|
||||
@@ -301,14 +301,14 @@ export default function Users() {
|
||||
</Dialog>
|
||||
|
||||
<ConfirmDialog
|
||||
title="确认删除"
|
||||
title={t('users.confirmDelete')}
|
||||
open={Boolean(deleteTarget)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDeleteTarget(null)
|
||||
}}
|
||||
description={`确定要删除用户 ${deleteTarget?.username || ''} 吗?`}
|
||||
description={t('users.confirmDeleteDescription', { username: deleteTarget?.username || '' })}
|
||||
danger
|
||||
confirmLabel="删除"
|
||||
confirmLabel={t('common.delete')}
|
||||
onConfirm={() => void handleDelete()}
|
||||
/>
|
||||
</AdminLayout>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
export interface AdminRouteItem {
|
||||
path: string
|
||||
label: string
|
||||
labelKey: string
|
||||
group: string
|
||||
icon: LucideIcon
|
||||
keywords: string[]
|
||||
@@ -26,33 +27,34 @@ export interface AdminRouteItem {
|
||||
export interface AdminRouteGroup {
|
||||
key: string
|
||||
label: string
|
||||
labelKey: string
|
||||
icon: LucideIcon
|
||||
}
|
||||
|
||||
export const adminRouteGroups: AdminRouteGroup[] = [
|
||||
{ key: 'overview', label: '总览', icon: CircleGauge },
|
||||
{ key: 'collection', label: '采集与数据', icon: HardDrive },
|
||||
{ key: 'observability', label: '专题观测', icon: AppWindow },
|
||||
{ key: 'alerts', label: '告警与研判', icon: ShieldAlert },
|
||||
{ key: 'ops', label: '运维与配置', icon: Settings },
|
||||
{ key: 'overview', label: '总览', labelKey: 'admin.groups.overview', icon: CircleGauge },
|
||||
{ key: 'collection', label: '采集与数据', labelKey: 'admin.groups.collection', icon: HardDrive },
|
||||
{ key: 'observability', label: '专题观测', labelKey: 'admin.groups.observability', icon: AppWindow },
|
||||
{ key: 'alerts', label: '告警与研判', labelKey: 'admin.groups.alerts', icon: ShieldAlert },
|
||||
{ key: 'ops', label: '运维与配置', labelKey: 'admin.groups.ops', icon: Settings },
|
||||
]
|
||||
|
||||
export const adminRoutes: AdminRouteItem[] = [
|
||||
{ path: '/admin', label: '仪表盘', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
|
||||
{ path: '/earth', label: '智能星球', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
|
||||
{ path: '/docs', label: '文档', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
|
||||
{ path: '/datasources', label: '数据源', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
|
||||
{ path: '/data', label: '采集数据', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
|
||||
{ path: '/bgp', label: 'BGP观测', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
|
||||
{ path: '/alerts/system', label: '系统告警', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] },
|
||||
{ path: '/alerts/bgp', label: 'BGP 告警', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
|
||||
{ path: '/alerts/situational', label: '态势告警', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
|
||||
{ path: '/ai', label: 'AI', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
|
||||
{ path: '/earth-content', label: '智能星球内容', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
|
||||
{ path: '/collection-management', label: '采集管理', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
|
||||
{ path: '/logs', label: '系统日志', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
|
||||
{ path: '/users', label: '用户管理', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
|
||||
{ path: '/settings', label: '系统设置', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] },
|
||||
{ path: '/admin', label: '仪表盘', labelKey: 'admin.routes.dashboard', group: 'overview', icon: CircleGauge, keywords: ['dashboard', '总览', '驾驶舱'] },
|
||||
{ path: '/earth', label: '智能星球', labelKey: 'admin.routes.earth', group: 'overview', icon: Globe2, keywords: ['earth', '地球', '智能星球'] },
|
||||
{ path: '/docs', label: '文档', labelKey: 'admin.routes.docs', group: 'overview', icon: FileText, keywords: ['docs', '文档', 'manual', '使用手册'] },
|
||||
{ path: '/datasources', label: '数据源', labelKey: 'admin.routes.datasources', group: 'collection', icon: Database, keywords: ['datasource', '采集', '目录'] },
|
||||
{ path: '/data', label: '采集数据', labelKey: 'admin.routes.data', group: 'collection', icon: AppWindow, keywords: ['data', 'records', '采集数据'] },
|
||||
{ path: '/bgp', label: 'BGP观测', labelKey: 'admin.routes.bgp', group: 'observability', icon: Network, keywords: ['bgp', '观测', '网络'] },
|
||||
{ path: '/alerts/system', label: '系统告警', labelKey: 'admin.routes.systemAlerts', group: 'alerts', icon: AlertTriangle, keywords: ['alert', 'system', '告警'] },
|
||||
{ path: '/alerts/bgp', label: 'BGP 告警', labelKey: 'admin.routes.bgpAlerts', group: 'alerts', icon: Network, keywords: ['alert', 'bgp', '风险'] },
|
||||
{ path: '/alerts/situational', label: '态势告警', labelKey: 'admin.routes.situationalAlerts', group: 'alerts', icon: Globe2, keywords: ['situational', '态势', '研判'] },
|
||||
{ path: '/ai', label: 'AI', labelKey: 'admin.routes.ai', group: 'ops', icon: Bot, keywords: ['ai', 'provider', 'playground', 'prompt'] },
|
||||
{ path: '/earth-content', label: '智能星球内容', labelKey: 'admin.routes.earthContent', group: 'ops', icon: Globe2, keywords: ['earth', '地球', '智能星球', 'tv', 'boundary', 'brand', 'news', 'rss', '新闻源'] },
|
||||
{ path: '/collection-management', label: '采集管理', labelKey: 'admin.routes.collectionManagement', group: 'ops', icon: Database, keywords: ['collector', 'mapping', 'custom source'] },
|
||||
{ path: '/logs', label: '系统日志', labelKey: 'admin.routes.logs', group: 'ops', icon: FileText, keywords: ['log', '日志', 'tail'], superAdminOnly: true },
|
||||
{ path: '/users', label: '用户管理', labelKey: 'admin.routes.users', group: 'ops', icon: Users, keywords: ['users', 'role', 'gatekeeper'] },
|
||||
{ path: '/settings', label: '系统设置', labelKey: 'admin.routes.settings', group: 'ops', icon: Settings, keywords: ['settings', 'smtp', 'security'] },
|
||||
]
|
||||
|
||||
export function getVisibleAdminRoutes(isSuperAdmin: boolean) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import { buildDynamicAdminTargets, buildStaticAdminTargets, searchAdminTargets } from './indexers'
|
||||
@@ -26,13 +27,14 @@ function targetSearchParams(target: AdminSearchTarget) {
|
||||
export function AdminSearchProvider({ children }: { children: ReactNode }) {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const { i18n } = useTranslation()
|
||||
const { user } = useAuthStore()
|
||||
const isSuperAdmin = user?.role === 'super_admin'
|
||||
const [dynamicTargets, setDynamicTargets] = useState<AdminSearchTarget[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const loadedRef = useRef(false)
|
||||
const loadingRef = useRef<Promise<void> | null>(null)
|
||||
const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [isSuperAdmin])
|
||||
const staticTargets = useMemo(() => buildStaticAdminTargets(isSuperAdmin), [i18n.language, isSuperAdmin])
|
||||
const targets = useMemo(() => {
|
||||
const byId = new Map<string, AdminSearchTarget>()
|
||||
staticTargets.forEach((target) => byId.set(target.id, target))
|
||||
@@ -44,7 +46,7 @@ export function AdminSearchProvider({ children }: { children: ReactNode }) {
|
||||
loadedRef.current = false
|
||||
loadingRef.current = null
|
||||
setDynamicTargets([])
|
||||
}, [isSuperAdmin])
|
||||
}, [i18n.language, isSuperAdmin])
|
||||
|
||||
const ensureDynamicIndex = useCallback(async (query: string) => {
|
||||
if (query.trim().length < 2 || loadedRef.current) return
|
||||
|
||||
@@ -14,10 +14,11 @@ import {
|
||||
ShieldAlert,
|
||||
Users,
|
||||
} from 'lucide-react'
|
||||
import i18n from '../../i18n'
|
||||
import { adminRoutes, getVisibleAdminRoutes } from '../routes/manifest'
|
||||
import type { AdminSearchTarget } from './types'
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
function apiPath(path: string) {
|
||||
if (path.startsWith('/api/')) return path
|
||||
@@ -50,11 +51,68 @@ function targetId(parts: Array<string | undefined>) {
|
||||
return parts.filter(Boolean).join(':')
|
||||
}
|
||||
|
||||
const labelKeys: Record<string, string> = {
|
||||
'AI': 'admin.routes.ai',
|
||||
'BGP观测': 'admin.routes.bgp',
|
||||
'BGP': 'admin.sections.bgpOverview',
|
||||
'BGP 事故': 'admin.sections.alerts',
|
||||
'BGP 告警': 'admin.routes.bgpAlerts',
|
||||
'Playground': 'admin.sections.aiPlayground',
|
||||
'SMTP 邮件': 'admin.sections.smtp',
|
||||
'工具调用': 'admin.sections.aiTools',
|
||||
'提示词': 'admin.sections.aiPrompts',
|
||||
'日志': 'admin.routes.logs',
|
||||
'日志源': 'admin.sections.logsSources',
|
||||
'智能星球内容': 'admin.routes.earthContent',
|
||||
'模型供应商': 'admin.sections.aiIntegrations',
|
||||
'模型预设': 'admin.sections.aiIntegrations',
|
||||
'电视直播': 'admin.sections.tv',
|
||||
'系统告警': 'admin.routes.systemAlerts',
|
||||
'系统显示': 'admin.sections.settingsSystem',
|
||||
'系统设置': 'admin.routes.settings',
|
||||
'采集历史': 'admin.sections.collectionHistory',
|
||||
'采集历史 / 快照': 'admin.sections.collectionHistory',
|
||||
'采集器': 'admin.sections.collectorCredentials',
|
||||
'采集数据': 'admin.routes.data',
|
||||
'采集管理': 'admin.routes.collectionManagement',
|
||||
'采集调度': 'admin.sections.collectors',
|
||||
'数据源': 'admin.routes.datasources',
|
||||
'用户': 'admin.routes.users',
|
||||
'用户管理': 'admin.routes.users',
|
||||
'告警记录': 'admin.sections.alerts',
|
||||
'国界精度': 'admin.sections.earthAssets',
|
||||
'品牌标识': 'admin.sections.earthBrand',
|
||||
'态势告警': 'admin.routes.situationalAlerts',
|
||||
'通知策略': 'admin.sections.notifications',
|
||||
'安全策略': 'admin.sections.security',
|
||||
'新闻源': 'admin.sections.newsSources',
|
||||
'页面': 'admin.search.pageContext',
|
||||
}
|
||||
|
||||
function translateLabel(label: string | undefined): string | undefined {
|
||||
if (!label) return label
|
||||
const key = labelKeys[label]
|
||||
return key ? i18n.t(key) : label
|
||||
}
|
||||
|
||||
function makeTarget(target: Omit<AdminSearchTarget, 'id'> & { id?: string }): AdminSearchTarget {
|
||||
const routeLabel = translateLabel(target.routeLabel) || target.routeLabel
|
||||
const sectionLabel = translateLabel(target.sectionLabel) || target.sectionLabel
|
||||
const label = translateLabel(target.label) || target.label
|
||||
const contextLabel = translateLabel(target.contextLabel) || target.contextLabel
|
||||
|
||||
return {
|
||||
...target,
|
||||
contextLabel,
|
||||
id: target.id || targetId([target.routePath, target.sectionKey, target.groupKey, target.fieldKey, target.label]),
|
||||
label,
|
||||
routeLabel,
|
||||
sectionLabel,
|
||||
terms: Array.from(new Set([
|
||||
label,
|
||||
routeLabel,
|
||||
sectionLabel,
|
||||
contextLabel,
|
||||
target.routeLabel,
|
||||
target.sectionLabel,
|
||||
target.contextLabel,
|
||||
@@ -88,7 +146,7 @@ const sectionTargets = [
|
||||
{ key: 'collectors', label: '采集调度', terms: ['schedule', 'frequency'] },
|
||||
{ key: 'collection_history', label: '采集历史 / 快照', terms: ['history', 'snapshot'] },
|
||||
] },
|
||||
{ routePath: '/settings', routeLabel: '设置', icon: Settings, sections: [
|
||||
{ routePath: '/settings', routeLabel: '系统设置', icon: Settings, sections: [
|
||||
{ key: 'system', label: '系统显示', terms: ['system', 'display'] },
|
||||
{ key: 'notifications', label: '通知策略', terms: ['notification', 'email'] },
|
||||
{ key: 'security', label: '安全策略', terms: ['security', 'password'] },
|
||||
@@ -117,7 +175,7 @@ const fieldTargets = [
|
||||
{ routePath: '/ai', routeLabel: 'AI', sectionKey: 'prompts', sectionLabel: '提示词', labels: ['System Prompt', '任务提示词', '重置 Prompt'] },
|
||||
{ routePath: '/collection-management', routeLabel: '采集管理', sectionKey: 'collector_credentials', sectionLabel: '采集器', labels: ['凭证教程', '生成凭证教程', '采集器配置', '映射模板', '目标 Schema'] },
|
||||
{ routePath: '/earth-content', routeLabel: '智能星球内容', sectionKey: 'tv', sectionLabel: '电视直播', labels: ['默认频道', '自动回退', '直播源', '频道', '主页地址'] },
|
||||
{ routePath: '/settings', routeLabel: '设置', sectionKey: 'smtp', sectionLabel: 'SMTP 邮件', labels: ['主机', '端口', '用户名', '密码', '使用 TLS', '发件邮箱'] },
|
||||
{ routePath: '/settings', routeLabel: '系统设置', sectionKey: 'smtp', sectionLabel: 'SMTP 邮件', labels: ['主机', '端口', '用户名', '密码', '使用 TLS', '发件邮箱'] },
|
||||
]
|
||||
|
||||
const curatedDynamicLikeTargets = [
|
||||
@@ -153,9 +211,9 @@ export function buildStaticAdminTargets(isSuperAdmin: boolean): AdminSearchTarge
|
||||
.filter((route) => visiblePaths.has(route.path))
|
||||
.map((route) => makeTarget({
|
||||
routePath: route.path,
|
||||
routeLabel: route.label,
|
||||
label: route.label,
|
||||
contextLabel: '页面',
|
||||
routeLabel: i18n.t(route.labelKey),
|
||||
label: i18n.t(route.labelKey),
|
||||
contextLabel: i18n.t('admin.search.pageContext'),
|
||||
terms: route.keywords,
|
||||
icon: route.icon,
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
.admin-theme-root {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
--an-page-padding: 16px;
|
||||
--an-section-gap: 16px;
|
||||
--an-panel-gap: 12px;
|
||||
@@ -117,8 +120,8 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 236px minmax(0, 1fr);
|
||||
background: var(--an-bg);
|
||||
@@ -128,7 +131,10 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
.admin__sider {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--an-surface);
|
||||
border-right: 1px solid var(--an-border);
|
||||
}
|
||||
@@ -171,8 +177,9 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin__nav-scroll {
|
||||
flex: 1;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin__nav {
|
||||
@@ -237,10 +244,12 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin__account {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--an-border);
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--an-bg) 42%, var(--an-surface));
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.admin__account-row {
|
||||
@@ -251,21 +260,93 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin__account-row--primary {
|
||||
min-height: 34px;
|
||||
}
|
||||
|
||||
.admin__account-row > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin__account-profile {
|
||||
display: flex !important;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.admin__account-row .admin__account-profile {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.admin__account-profile > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.admin__account-avatar {
|
||||
flex: 0 0 30px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--an-accent);
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
box-shadow: 0 6px 16px color-mix(in srgb, var(--an-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.admin__account-row .admin__account-avatar {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.admin__account-row strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin__account-logout {
|
||||
.admin__account-actions {
|
||||
display: flex !important;
|
||||
grid-template-columns: none;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin__account-logout,
|
||||
.admin__account-preferences {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.admin__account-preferences {
|
||||
color: var(--an-muted);
|
||||
}
|
||||
|
||||
.admin__account-preferences svg {
|
||||
transition: color 0.18s ease, transform 0.28s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
.admin__account-preferences.is-active {
|
||||
color: var(--an-accent);
|
||||
background: color-mix(in srgb, var(--an-accent) 10%, var(--an-surface));
|
||||
}
|
||||
|
||||
.admin__account-preferences.is-active svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.admin__account-logout {
|
||||
color: var(--an-danger);
|
||||
}
|
||||
|
||||
@@ -277,11 +358,46 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin__preferences-drawer {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-5px);
|
||||
transition:
|
||||
max-height 0.24s ease,
|
||||
opacity 0.18s ease,
|
||||
transform 0.24s ease,
|
||||
visibility 0s linear 0.24s;
|
||||
}
|
||||
|
||||
.admin__preferences-drawer.is-open {
|
||||
max-height: 146px;
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
transition:
|
||||
max-height 0.28s ease,
|
||||
opacity 0.18s ease,
|
||||
transform 0.28s ease,
|
||||
visibility 0s;
|
||||
}
|
||||
|
||||
.admin__preferences-panel {
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border-top: 1px solid color-mix(in srgb, var(--an-border) 78%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--an-bg) 62%, var(--an-surface));
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin__theme-control--sider {
|
||||
--segmented-control-radius: 8px;
|
||||
--segmented-control-slider-radius: 6px;
|
||||
--segmented-control-button-gap: 0;
|
||||
--segmented-control-icon-size: calc(17px * var(--segmented-control-scale, 1));
|
||||
--segmented-control-icon-size: 13px;
|
||||
}
|
||||
|
||||
.admin__theme-control--sider .segmented-control__button {
|
||||
@@ -309,6 +425,21 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.admin__language-control--sider {
|
||||
width: 100%;
|
||||
--segmented-control-radius: 8px;
|
||||
--segmented-control-slider-radius: 6px;
|
||||
--segmented-control-button-gap: 0;
|
||||
--segmented-control-font-size: calc(10px * var(--segmented-control-scale, 1));
|
||||
--segmented-control-font-weight: 800;
|
||||
}
|
||||
|
||||
.admin__language-control--sider .segmented-control__button {
|
||||
font-size: calc(10px * var(--segmented-control-scale, 1));
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin__logout {
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -597,9 +728,11 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
|
||||
.admin__content {
|
||||
min-width: 0;
|
||||
height: 100vh;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-rows: 48px minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin__topbar {
|
||||
@@ -652,6 +785,25 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
color: var(--an-muted);
|
||||
}
|
||||
|
||||
.admin__language-control {
|
||||
width: 112px;
|
||||
--segmented-control-radius: 8px;
|
||||
--segmented-control-slider-radius: 6px;
|
||||
--segmented-control-button-gap: 0;
|
||||
}
|
||||
|
||||
.admin__language-control .segmented-control__button {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.admin__language-control .segmented-control__icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin__language-control.admin__language-control--sider {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin__search-results {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
@@ -754,6 +906,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
.admin__content-inner {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
padding: var(--an-page-padding);
|
||||
}
|
||||
@@ -1633,7 +1786,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
color: var(--an-text);
|
||||
padding: 8px 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 104px;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
@@ -1683,19 +1836,22 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.an-hierarchy-group__meta {
|
||||
width: 104px;
|
||||
max-width: 104px;
|
||||
width: max-content;
|
||||
max-width: none;
|
||||
min-width: max-content;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
overflow: hidden;
|
||||
justify-self: end;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.an-hierarchy-group__meta .an-status-pill {
|
||||
flex: 0 0 74px;
|
||||
width: 74px;
|
||||
max-width: 74px;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: max-content;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.an-hierarchy-group__meta em {
|
||||
@@ -2038,7 +2194,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
font-size: calc(0.74rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2048,7 +2204,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
color: var(--hud-text-soft);
|
||||
font-size: calc(0.6rem * var(--hud-scale) * var(--brand-scale));
|
||||
line-height: 1.3;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
@@ -2183,7 +2339,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
font-size: calc(0.62rem * var(--hud-scale));
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.an-tv-earth-preview .tv-panel-tag--status {
|
||||
@@ -2221,7 +2377,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
|
||||
.an-tv-earth-preview .tv-panel-catalog {
|
||||
font-size: calc(0.66rem * var(--hud-scale));
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@@ -2371,6 +2527,24 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.an-connection-test-input {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.an-connection-test-input__control {
|
||||
padding-right: 38px;
|
||||
}
|
||||
|
||||
.an-connection-test-input > .tui-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 4px;
|
||||
width: 28px;
|
||||
height: 24px;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.an-news-feed-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -4066,7 +4240,7 @@ body:has(.admin-theme-root[data-theme='dark']) .an-toast {
|
||||
}
|
||||
|
||||
.admin__content {
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.admin__topbar {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { memo, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from 'react'
|
||||
|
||||
import Scrollbar from '../Scrollbar/Scrollbar'
|
||||
@@ -167,6 +168,7 @@ function isMermaidTextTarget(target: EventTarget | null): boolean {
|
||||
}
|
||||
|
||||
function MarkdownCodeBlock({ code, language }: { code: string; language?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const label = language?.trim() || 'text'
|
||||
const codeClassName = language
|
||||
@@ -187,8 +189,8 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
|
||||
type="button"
|
||||
className="markdown-renderer__code-copy"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? '已复制代码' : '复制代码'}
|
||||
title={copied ? '已复制' : '复制代码'}
|
||||
aria-label={copied ? t('markdown.copiedCode') : t('markdown.copyCode')}
|
||||
title={copied ? t('markdown.copied') : t('markdown.copyCode')}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
@@ -201,6 +203,7 @@ function MarkdownCodeBlock({ code, language }: { code: string; language?: string
|
||||
}
|
||||
|
||||
function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
const { t } = useTranslation()
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [svg, setSvg] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
@@ -266,7 +269,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
} catch (renderError) {
|
||||
if (!cancelled) {
|
||||
setSvg('')
|
||||
setError(renderError instanceof Error ? renderError.message : 'Mermaid 渲染失败')
|
||||
setError(renderError instanceof Error ? renderError.message : t('markdown.mermaidRenderFailed'))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -276,7 +279,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [blockId, code, themeMode])
|
||||
}, [blockId, code, t, themeMode])
|
||||
|
||||
const handleCopy = async () => {
|
||||
await copyToClipboard(code)
|
||||
@@ -339,8 +342,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
type="button"
|
||||
className="markdown-renderer__code-copy"
|
||||
onClick={handleCopy}
|
||||
aria-label={copied ? '已复制图表源码' : '复制图表源码'}
|
||||
title={copied ? '已复制' : '复制图表源码'}
|
||||
aria-label={copied ? t('markdown.copiedChartSource') : t('markdown.copyChartSource')}
|
||||
title={copied ? t('markdown.copied') : t('markdown.copyChartSource')}
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||
</button>
|
||||
@@ -361,8 +364,8 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
openExpanded()
|
||||
}
|
||||
}}
|
||||
aria-label="放大查看 Mermaid 图表"
|
||||
title="点击放大查看"
|
||||
aria-label={t('markdown.expandMermaid')}
|
||||
title={t('markdown.clickToExpand')}
|
||||
>
|
||||
<span className="markdown-renderer__mermaid-canvas-inner" dangerouslySetInnerHTML={{ __html: svg }} />
|
||||
</div>
|
||||
@@ -381,15 +384,15 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
className="markdown-renderer__mermaid-viewer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Mermaid 图表查看器"
|
||||
aria-label={t('markdown.mermaidViewer')}
|
||||
onClick={closeExpanded}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="markdown-renderer__mermaid-viewer-close"
|
||||
onClick={closeExpanded}
|
||||
aria-label="关闭 Mermaid 图表查看器"
|
||||
title="关闭"
|
||||
aria-label={t('markdown.closeMermaid')}
|
||||
title={t('common.close')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
@@ -411,7 +414,7 @@ function MarkdownMermaidBlock({ code }: { code: string }) {
|
||||
/>
|
||||
</div>
|
||||
<div className="markdown-renderer__mermaid-viewer-hint">
|
||||
拖拽移动 · 滚轮缩放 · 点击空白关闭
|
||||
{t('markdown.viewerHint')}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
font: inherit;
|
||||
font-size: var(--segmented-control-font-size, calc(10px * var(--segmented-control-scale, 1)));
|
||||
font-weight: var(--segmented-control-font-weight, 800);
|
||||
letter-spacing: var(--segmented-control-letter-spacing, 0.04em);
|
||||
letter-spacing: var(--segmented-control-letter-spacing, 0);
|
||||
cursor: pointer;
|
||||
transition: color 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@ const DEFAULT_WS_URL = (() => {
|
||||
return `${protocol}//${window.location.host}/ws`
|
||||
})()
|
||||
|
||||
const WS_URL = (import.meta as any).env?.VITE_WS_URL || DEFAULT_WS_URL
|
||||
const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL
|
||||
const WS_CONNECT_TIMEOUT_MS = 4500
|
||||
|
||||
function buildWebSocketCandidates(): string[] {
|
||||
if ((import.meta as any).env?.VITE_WS_URL) {
|
||||
if (import.meta.env.VITE_WS_URL) {
|
||||
return [WS_URL]
|
||||
}
|
||||
|
||||
|
||||
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal file
159
frontend/src/i18n/LegacyI18nBridge.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { legacyUiTextEnUS } from './legacy-ui'
|
||||
import { normalizeLocale } from './locale'
|
||||
|
||||
const attributeNames = ['aria-label', 'placeholder', 'title']
|
||||
const selector = '.admin-theme-root, .auth-shell'
|
||||
const reverseLegacyUiText = Object.fromEntries(
|
||||
Object.entries(legacyUiTextEnUS).map(([source, target]) => [target, source]),
|
||||
)
|
||||
|
||||
type LegacyTextPattern = {
|
||||
match: RegExp
|
||||
replace: (match: RegExpMatchArray) => string
|
||||
}
|
||||
|
||||
const legacyTextPatternsEnUS: LegacyTextPattern[] = [
|
||||
{ match: /^结果\s+(.+)\s+条$/, replace: (match) => `Results ${match[1]}` },
|
||||
{ match: /^筛选\s+(.+)\s+项$/, replace: (match) => `${match[1]} filters` },
|
||||
{ match: /^共\s+(.+)\s+条结果$/, replace: (match) => `${match[1]} results` },
|
||||
{ match: /^(.+)\s+条新闻。$/, replace: (match) => `${match[1]} news items.` },
|
||||
{ match: /^(.+)\s+个历史快照,选择后查看该版本详情。$/, replace: (match) => `${match[1]} historical snapshots. Select one to view that version.` },
|
||||
{ match: /^(.+)\s+字段$/, replace: (match) => `${match[1]} fields` },
|
||||
{ match: /^(.+)\s+个源 \/ (.+)\s+个类型$/, replace: (match) => `${match[1]} sources / ${match[2]} categories` },
|
||||
{ match: /^(.+)\s+个来源$/, replace: (match) => `${match[1]} sources` },
|
||||
{ match: /^(.+)\s+个聚合项$/, replace: (match) => `${match[1]} aggregations` },
|
||||
{ match: /^(.+)\s+条$/, replace: (match) => `${match[1]} items` },
|
||||
{ match: /^(.+)\s+行$/, replace: (match) => `${match[1]} lines` },
|
||||
{ match: /^(.+)\s+次$/, replace: (match) => `${match[1]} times` },
|
||||
{ match: /^最后更新:\s*(.+)$/, replace: (match) => `Last updated: ${match[1]}` },
|
||||
{ match: /^任务已创建:\s*(.+)$/, replace: (match) => `Task created: ${match[1]}` },
|
||||
{ match: /^执行命令\s+(.+)$/, replace: (match) => `Command ${match[1]}` },
|
||||
{ match: /^任务 ID\s+(.+)$/, replace: (match) => `Task ID ${match[1]}` },
|
||||
{ match: /^触发已选\s+(.+)$/, replace: (match) => `Trigger selected ${match[1]}` },
|
||||
{ match: /^新闻直播源\s+(.+)$/, replace: (match) => `News stream source ${match[1]}` },
|
||||
{ match: /^新增新闻源\s+(.+)$/, replace: (match) => `New news source ${match[1]}` },
|
||||
{ match: /^最终指标:(.+)$/, replace: (match) => `Final metric: ${match[1]}` },
|
||||
{ match: /^指纹\s+(.+)$/, replace: (match) => `Fingerprint ${match[1]}` },
|
||||
{ match: /^首次\s+(.+)\s+·\s+最近\s+(.+)$/, replace: (match) => `First ${match[1]} · Latest ${match[2]}` },
|
||||
{ match: /^已导出\s+(.+)$/, replace: (match) => `Exported ${match[1]}` },
|
||||
{ match: /^(.+)\s+采集失败$/, replace: (match) => `${match[1]} collection failed` },
|
||||
{ match: /^(.+)\s+采集已取消$/, replace: (match) => `${match[1]} collection cancelled` },
|
||||
]
|
||||
|
||||
const legacyTextPatternsZhCN: LegacyTextPattern[] = [
|
||||
{ match: /^Results\s+(.+)$/, replace: (match) => `结果 ${match[1]} 条` },
|
||||
{ match: /^(.+)\s+filters$/, replace: (match) => `筛选 ${match[1]} 项` },
|
||||
{ match: /^(.+)\s+results$/, replace: (match) => `共 ${match[1]} 条结果` },
|
||||
{ match: /^(.+)\s+news items\.$/, replace: (match) => `${match[1]} 条新闻。` },
|
||||
{ match: /^(.+)\s+historical snapshots\. Select one to view that version\.$/, replace: (match) => `${match[1]} 个历史快照,选择后查看该版本详情。` },
|
||||
{ match: /^(.+)\s+fields$/, replace: (match) => `${match[1]} 字段` },
|
||||
{ match: /^(.+)\s+sources \/ (.+)\s+categories$/, replace: (match) => `${match[1]} 个源 / ${match[2]} 个类型` },
|
||||
{ match: /^(.+)\s+sources$/, replace: (match) => `${match[1]} 个来源` },
|
||||
{ match: /^(.+)\s+aggregations$/, replace: (match) => `${match[1]} 个聚合项` },
|
||||
{ match: /^(.+)\s+items$/, replace: (match) => `${match[1]} 条` },
|
||||
{ match: /^(.+)\s+lines$/, replace: (match) => `${match[1]} 行` },
|
||||
{ match: /^(.+)\s+times$/, replace: (match) => `${match[1]} 次` },
|
||||
{ match: /^Last updated:\s*(.+)$/, replace: (match) => `最后更新: ${match[1]}` },
|
||||
{ match: /^Task created:\s*(.+)$/, replace: (match) => `任务已创建: ${match[1]}` },
|
||||
{ match: /^Command\s+(.+)$/, replace: (match) => `执行命令 ${match[1]}` },
|
||||
{ match: /^Task ID\s+(.+)$/, replace: (match) => `任务 ID ${match[1]}` },
|
||||
{ match: /^Trigger selected\s+(.+)$/, replace: (match) => `触发已选 ${match[1]}` },
|
||||
{ match: /^News stream source\s+(.+)$/, replace: (match) => `新闻直播源 ${match[1]}` },
|
||||
{ match: /^New news source\s+(.+)$/, replace: (match) => `新增新闻源 ${match[1]}` },
|
||||
{ match: /^Final metric:\s*(.+)$/, replace: (match) => `最终指标:${match[1]}` },
|
||||
{ match: /^Fingerprint\s+(.+)$/, replace: (match) => `指纹 ${match[1]}` },
|
||||
{ match: /^First\s+(.+)\s+·\s+Latest\s+(.+)$/, replace: (match) => `首次 ${match[1]} · 最近 ${match[2]}` },
|
||||
{ match: /^Exported\s+(.+)$/, replace: (match) => `已导出 ${match[1]}` },
|
||||
{ match: /^(.+)\s+collection failed$/, replace: (match) => `${match[1]} 采集失败` },
|
||||
{ match: /^(.+)\s+collection cancelled$/, replace: (match) => `${match[1]} 采集已取消` },
|
||||
]
|
||||
|
||||
function preserveOuterWhitespace(source: string, replacement: string) {
|
||||
const leading = source.match(/^\s*/)?.[0] || ''
|
||||
const trailing = source.match(/\s*$/)?.[0] || ''
|
||||
return `${leading}${replacement}${trailing}`
|
||||
}
|
||||
|
||||
function translatePatternText(value: string, locale: string) {
|
||||
const text = value.trim()
|
||||
if (!text || text.length > 160) return value
|
||||
const patterns = normalizeLocale(locale) === 'en-US' ? legacyTextPatternsEnUS : legacyTextPatternsZhCN
|
||||
for (const pattern of patterns) {
|
||||
const matched = text.match(pattern.match)
|
||||
if (matched) return preserveOuterWhitespace(value, pattern.replace(matched))
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function translateText(value: string, locale: string) {
|
||||
const text = value.trim()
|
||||
if (!text) return value
|
||||
const dictionary = normalizeLocale(locale) === 'en-US' ? legacyUiTextEnUS : reverseLegacyUiText
|
||||
const replacement = dictionary[text]
|
||||
if (replacement) return preserveOuterWhitespace(value, replacement)
|
||||
return translatePatternText(value, locale)
|
||||
}
|
||||
|
||||
function translateElementAttributes(element: Element, locale: string) {
|
||||
attributeNames.forEach((attributeName) => {
|
||||
const value = element.getAttribute(attributeName)
|
||||
if (!value) return
|
||||
const translated = translateText(value, locale)
|
||||
if (translated !== value) element.setAttribute(attributeName, translated)
|
||||
})
|
||||
}
|
||||
|
||||
function translateNodeText(root: Element, locale: string) {
|
||||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
while (node) {
|
||||
const value = node.textContent || ''
|
||||
const translated = translateText(value, locale)
|
||||
if (translated !== value) node.textContent = translated
|
||||
node = walker.nextNode()
|
||||
}
|
||||
}
|
||||
|
||||
function translateRoot(root: Element, locale: string) {
|
||||
translateElementAttributes(root, locale)
|
||||
root.querySelectorAll('*').forEach((element) => translateElementAttributes(element, locale))
|
||||
translateNodeText(root, locale)
|
||||
}
|
||||
|
||||
export default function LegacyI18nBridge() {
|
||||
const { i18n } = useTranslation()
|
||||
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
|
||||
|
||||
useEffect(() => {
|
||||
let frameId = 0
|
||||
const translate = () => {
|
||||
document.querySelectorAll(selector).forEach((root) => translateRoot(root, locale))
|
||||
}
|
||||
const scheduleTranslate = () => {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
frameId = window.requestAnimationFrame(translate)
|
||||
}
|
||||
|
||||
scheduleTranslate()
|
||||
const observer = new MutationObserver(scheduleTranslate)
|
||||
if (document.body) {
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: attributeNames,
|
||||
characterData: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frameId)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [locale])
|
||||
|
||||
return null
|
||||
}
|
||||
27
frontend/src/i18n/index.ts
Normal file
27
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import { readStoredLocale, syncDocumentLocale } from './locale'
|
||||
import { resources } from './resources'
|
||||
|
||||
const initialLocale = readStoredLocale()
|
||||
|
||||
syncDocumentLocale(initialLocale)
|
||||
|
||||
void i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'zh-CN',
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
lng: initialLocale,
|
||||
resources,
|
||||
returnEmptyString: false,
|
||||
})
|
||||
|
||||
i18n.on('languageChanged', (locale) => {
|
||||
syncDocumentLocale(locale === 'en-US' ? 'en-US' : 'zh-CN')
|
||||
})
|
||||
|
||||
export default i18n
|
||||
589
frontend/src/i18n/legacy-ui.ts
Normal file
589
frontend/src/i18n/legacy-ui.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
export const legacyUiTextEnUS: Record<string, string> = {
|
||||
'3D 模型': '3D models',
|
||||
'AI 生成教程': 'AI-generated guide',
|
||||
'AI 分区': 'AI sections',
|
||||
'AI 配置详情': 'AI configuration details',
|
||||
'AI 集成': 'AI integrations',
|
||||
'AI 简报': 'AI brief',
|
||||
'BGP 事故': 'BGP incident',
|
||||
'BGP 事件与简报': 'BGP events and briefs',
|
||||
'BGP 告警列表': 'BGP alert list',
|
||||
'BGP 告警详情': 'BGP alert details',
|
||||
'BGP 异常': 'BGP anomaly',
|
||||
'BGP 概览': 'BGP overview',
|
||||
'BGP 简报': 'BGP brief',
|
||||
'BGP 详情': 'BGP details',
|
||||
'BGP 观测': 'BGP Observatory',
|
||||
'BGP 告警': 'BGP alert',
|
||||
'BGP观测': 'BGP Observatory',
|
||||
'Feed 信息页': 'Feed info page',
|
||||
'Feed 地址': 'Feed URL',
|
||||
'Feed 子项': 'Feed entries',
|
||||
'Feed 标签': 'Feed tags',
|
||||
'Feed 类型': 'Feed type',
|
||||
'Feed 名称': 'Feed name',
|
||||
'Feed ID': 'Feed ID',
|
||||
'Gatekeeper 权限组': 'Gatekeeper groups',
|
||||
'RSS 来源': 'RSS source',
|
||||
'RSS 来源只读,新闻由抓取与增强链路维护。': 'RSS sources are read-only. News is maintained by the collection and enrichment pipeline.',
|
||||
'RSS 订阅中心或 Feed 聚合页,只用于人工查看,不参与抓取。': 'RSS directory or feed aggregation page for manual review only. It is not fetched.',
|
||||
'SMTP 邮件': 'SMTP email',
|
||||
'System Prompt': 'System prompt',
|
||||
'Time Capsule': 'Time Capsule',
|
||||
'Web Search 预设': 'Web Search presets',
|
||||
'不可用': 'Unavailable',
|
||||
'事故': 'Incident',
|
||||
'交互正常': 'Interactive',
|
||||
'个来源': 'sources',
|
||||
'个聚合项': 'aggregations',
|
||||
'任务提示词': 'Task prompt',
|
||||
'仪表盘': 'Dashboard',
|
||||
'任务': 'Task',
|
||||
'任务 ID': 'Task ID',
|
||||
'任务已取消': 'Task cancelled',
|
||||
'任务状态': 'Task status',
|
||||
'今日任务': 'Tasks today',
|
||||
'供应商': 'Provider',
|
||||
'供应商状态': 'Provider status',
|
||||
'供应商配置': 'Provider configuration',
|
||||
'保存': 'Save',
|
||||
'保存并重试': 'Save and retry',
|
||||
'保存后会进入清洗、翻译、分类和定位队列。': 'After saving, the item enters the cleaning, translation, classification, and geocoding queue.',
|
||||
'保存后才会固化到新闻源配置。': 'Changes are persisted to the news source configuration only after saving.',
|
||||
'保存新闻': 'Save news item',
|
||||
'修改邮箱': 'Change email',
|
||||
'停止': 'Stop',
|
||||
'停止采集': 'Stop collection',
|
||||
'停止生成': 'Stop generation',
|
||||
'停用': 'Disabled',
|
||||
'关闭': 'Close',
|
||||
'关于': 'About',
|
||||
'关于配置': 'About configuration',
|
||||
'内置源': 'Built-in sources',
|
||||
'全部区域': 'All regions',
|
||||
'全部国家/地区': 'All countries / regions',
|
||||
'全部层级': 'All levels',
|
||||
'全部级别': 'All levels',
|
||||
'全部产品域': 'All product domains',
|
||||
'全部执行状态': 'All execution statuses',
|
||||
'全部数据源': 'All datasources',
|
||||
'全部数据状态': 'All data statuses',
|
||||
'全部源属性': 'All source attributes',
|
||||
'全部状态': 'All statuses',
|
||||
'全部类型': 'All types',
|
||||
'其他': 'Other',
|
||||
'刷新': 'Refresh',
|
||||
'刷新当前 Provider 的模型配置': 'Refresh current provider model configuration',
|
||||
'刷新模型': 'Refresh models',
|
||||
'刷新模型列表': 'Refresh model list',
|
||||
'刷新线程': 'Refresh thread',
|
||||
'分类': 'Category',
|
||||
'删除 Feed 子项': 'Delete feed entry',
|
||||
'删除': 'Delete',
|
||||
'删除失败': 'Delete failed',
|
||||
'删除完成': 'Deletion complete',
|
||||
'删除成功': 'Deleted',
|
||||
'删除已取消': 'Deletion cancelled',
|
||||
'删除新闻': 'Delete news item',
|
||||
'删除新闻源': 'Delete news source',
|
||||
'删除中': 'Deleting',
|
||||
'删除直播源': 'Delete stream source',
|
||||
'删除品牌配置': 'Delete brand configuration',
|
||||
'前往登录': 'Go to login',
|
||||
'加载中': 'Loading',
|
||||
'加载日志内容失败': 'Failed to load log content',
|
||||
'加载日志源失败': 'Failed to load log sources',
|
||||
'加载重复日志详情失败': 'Failed to load duplicate log details',
|
||||
'加载重复日志统计失败': 'Failed to load duplicate log stats',
|
||||
'启动采集': 'Start collection',
|
||||
'启动实时源': 'Start realtime source',
|
||||
'启动边界构建': 'Start boundary build',
|
||||
'启用': 'Enabled',
|
||||
'启用抓取': 'Enable fetching',
|
||||
'启用映射': 'Enable mapping',
|
||||
'启用筛选': 'Active filters',
|
||||
'告警': 'Alert',
|
||||
'告警记录': 'Alert records',
|
||||
'告警统计': 'Alert stats',
|
||||
'名称': 'Name',
|
||||
'后台账号': 'Console account',
|
||||
'回到首页': 'Back to overview',
|
||||
'国界精度': 'Boundary accuracy',
|
||||
'国家': 'Country',
|
||||
'地址': 'Address',
|
||||
'基础字段': 'Basic fields',
|
||||
'基础信息': 'Basic information',
|
||||
'城市': 'City',
|
||||
'字段': 'Fields',
|
||||
'安全策略': 'Security policy',
|
||||
'完成': 'Complete',
|
||||
'密码': 'Password',
|
||||
'导航': 'Navigation',
|
||||
'已加载分区': 'Loaded section',
|
||||
'已加载分区汇总': 'Loaded section total',
|
||||
'工具调用': 'Tool calls',
|
||||
'已保存': 'Saved',
|
||||
'已取消': 'Cancelled',
|
||||
'已处理': 'Resolved',
|
||||
'已提交': 'Submitted',
|
||||
'已读取': 'Loaded',
|
||||
'已启用': 'Enabled',
|
||||
'已定位': 'Located',
|
||||
'已配置': 'Configured',
|
||||
'已停止': 'Stopped',
|
||||
'已停用': 'Disabled',
|
||||
'已就绪': 'Ready',
|
||||
'已跳过': 'Skipped',
|
||||
'已有新闻': 'Existing news',
|
||||
'已有任务运行': 'Task already running',
|
||||
'已有账号,去登录': 'Already have an account? Log in',
|
||||
'开始日期': 'Start date',
|
||||
'底图资源': 'Basemap assets',
|
||||
'开启后访问智能星球会直接显示 OOBE 引导,不再要求首次采集条件,也会忽略本机“先浏览”临时跳过。': 'When enabled, Intelligent Planet opens directly into the OOBE guide, skips first-collection requirements, and ignores local temporary browse-first skips.',
|
||||
'态势告警': 'Situational alerts',
|
||||
'态势告警列表': 'Situational alert list',
|
||||
'态势详情': 'Situational details',
|
||||
'态势统计': 'Situational stats',
|
||||
'快速访问地球可视化页面': 'Quickly open the Earth visualization page',
|
||||
'恢复当前表单': 'Restore current form',
|
||||
'恢复默认关于信息': 'Restore default about information',
|
||||
'恢复默认配置': 'Restore defaults',
|
||||
'恢复超时,请手动检查服务状态。': 'Recovery timed out. Please check service status manually.',
|
||||
'成功': 'Success',
|
||||
'成功率': 'Success rate',
|
||||
'手动新闻组': 'Manual news group',
|
||||
'手动新闻': 'Manual news',
|
||||
'打开智能星球内容': 'Open Intelligent Planet content',
|
||||
'打开智能星球': 'Open Intelligent Planet',
|
||||
'处理告警': 'Resolve alert',
|
||||
'播放与扩展': 'Playback and extensions',
|
||||
'接入在线': 'Endpoints online',
|
||||
'接口在线': 'Endpoints online',
|
||||
'接口失败': 'Endpoint failed',
|
||||
'接口请求失败': 'Endpoint request failed',
|
||||
'提示': 'Info',
|
||||
'控制台不混入其他配置或假数据。': 'The console does not mix in unrelated configuration or mock data.',
|
||||
'控制台发生错误,请刷新页面重试。': 'The console encountered an error. Please refresh and try again.',
|
||||
'控制台渲染错误': 'Console render error',
|
||||
'拖动调整数据概览宽度': 'Drag to resize data overview',
|
||||
'提交': 'Submit',
|
||||
'提交失败': 'Submission failed',
|
||||
'提交重启任务失败': 'Failed to submit restart task',
|
||||
'提示词': 'Prompts',
|
||||
'搜索日志正文': 'Search log text',
|
||||
'搜索名称、描述、元数据等': 'Search name, description, metadata, and more',
|
||||
'搜索': 'Search',
|
||||
'搜索供应商': 'Search provider',
|
||||
'搜索深度': 'Search depth',
|
||||
'搜索用户、邮箱、角色': 'Search users, email, or role',
|
||||
'数据源': 'Datasources',
|
||||
'数据源列表': 'Datasource list',
|
||||
'数据源详情': 'Datasource details',
|
||||
'数据源总数': 'Total datasources',
|
||||
'数据源状态': 'Datasource status',
|
||||
'数据概览': 'Data overview',
|
||||
'数据列表': 'Data list',
|
||||
'数据类型': 'Data type',
|
||||
'数据集': 'Dataset',
|
||||
'数据状态': 'Data status',
|
||||
'采集数据': 'Collected data',
|
||||
'采集配置列表': 'Collection configuration list',
|
||||
'采集器': 'Collectors',
|
||||
'采集器详情': 'Collector details',
|
||||
'采集器配置': 'Collector configuration',
|
||||
'采集快照': 'Collection snapshot',
|
||||
'采集历史 / 快照': 'Collection history / snapshots',
|
||||
'采集时间': 'Collected at',
|
||||
'采集已取消': 'Collection cancelled',
|
||||
'采集失败': 'Collection failed',
|
||||
'采集完成': 'Collection complete',
|
||||
'采集中': 'Collecting',
|
||||
'采集管理': 'Collection Management',
|
||||
'采集调度': 'Collection schedule',
|
||||
'采样': 'Sample',
|
||||
'采样数据': 'Sample data',
|
||||
'重新处理': 'Reprocess',
|
||||
'重新处理新闻': 'Reprocess news item',
|
||||
'新增 Feed 子项': 'Add feed entry',
|
||||
'新增新闻': 'Add news item',
|
||||
'新建': 'Create',
|
||||
'新增新闻源': 'Add news source',
|
||||
'新增新闻组': 'Add news group',
|
||||
'新增采集器配置': 'Add collector configuration',
|
||||
'新增 Schema 映射': 'Add schema mapping',
|
||||
'新增直播源': 'Add stream source',
|
||||
'新闻内容': 'News content',
|
||||
'新闻条目': 'News items',
|
||||
'新闻源': 'News sources',
|
||||
'新闻源 ID 不能为空。': 'News source ID is required.',
|
||||
'新闻源名称不能为空。': 'News source name is required.',
|
||||
'新闻源配置': 'News source configuration',
|
||||
'新闻源详情': 'News source details',
|
||||
'新闻源测试失败': 'News source test failed',
|
||||
'新闻组': 'News group',
|
||||
'新闻类型': 'News category',
|
||||
'新闻直播源': 'News stream source',
|
||||
'无权访问': 'Permission required',
|
||||
'无权限': 'No permission',
|
||||
'无效': 'Invalid',
|
||||
'暂无 Feed 子项': 'No feed entries',
|
||||
'暂无会话': 'No conversation',
|
||||
'暂无分组': 'No groups',
|
||||
'暂无快照': 'No snapshots',
|
||||
'暂无数据': 'No data',
|
||||
'暂无新闻': 'No news items',
|
||||
'暂无发生明细': 'No occurrences',
|
||||
'暂无日志': 'No logs',
|
||||
'暂无日志内容': 'No log content',
|
||||
'暂无重复日志': 'No duplicate logs',
|
||||
'暂无上报': 'No reports',
|
||||
'暂无摘要': 'No summary',
|
||||
'日志': 'Logs',
|
||||
'日志源': 'Log sources',
|
||||
'日志源不可用': 'Log source unavailable',
|
||||
'日志详情': 'Log details',
|
||||
'日志视图': 'Log views',
|
||||
'日志跟随连接失败,可暂停后使用手动刷新。': 'Log follow connection failed. Pause it and refresh manually.',
|
||||
'日志已复制': 'Logs copied',
|
||||
'明细': 'Details',
|
||||
'是否启用': 'Enabled',
|
||||
'实时同步中': 'Syncing live',
|
||||
'实时连接': 'Live connection',
|
||||
'旧密码': 'Old password',
|
||||
'映射模板': 'Mapping templates',
|
||||
'映射预览': 'Mapping preview',
|
||||
'显示名称': 'Display name',
|
||||
'显示 LLM API Key / Service Token': 'Show LLM API Key / Service Token',
|
||||
'显示': 'Display',
|
||||
'智能星球': 'Intelligent Planet',
|
||||
'智能星球内容配置': 'Intelligent Planet content configuration',
|
||||
'智能星球配置详情': 'Intelligent Planet configuration details',
|
||||
'智能星球内容': 'Planet Content',
|
||||
'未知': 'Unknown',
|
||||
'未配置': 'Not configured',
|
||||
'未启用': 'Not enabled',
|
||||
'未测试': 'Untested',
|
||||
'未选择记录': 'No record selected',
|
||||
'查看日志': 'View logs',
|
||||
'最近': 'Latest',
|
||||
'最后更新:': 'Last updated:',
|
||||
'最大 Token': 'Max tokens',
|
||||
'最大并发任务数': 'Max concurrent tasks',
|
||||
'最大登录尝试次数': 'Max login attempts',
|
||||
'最大结果数': 'Max results',
|
||||
'最大文件(MB)': 'Max file size (MB)',
|
||||
'标签': 'Tags',
|
||||
'标题': 'Title',
|
||||
'模型': 'Model',
|
||||
'模型供应商': 'Model providers',
|
||||
'模型预设': 'Model presets',
|
||||
'清理数据库数据': 'Clear database data',
|
||||
'清理智能星球图层缓存': 'Clear planet layer cache',
|
||||
'清理缓存': 'Clear cache',
|
||||
'测试 Web Search 连通性': 'Test Web Search connectivity',
|
||||
'测试 AI Provider 连通性': 'Test AI Provider connectivity',
|
||||
'测试当前 Feed': 'Test current feed',
|
||||
'测试当前新闻源': 'Test current news source',
|
||||
'测试收件人': 'Test recipient',
|
||||
'测试 SMTP': 'Test SMTP',
|
||||
'状态': 'Status',
|
||||
'活跃数据源': 'Active datasources',
|
||||
'海底光缆': 'Submarine cable',
|
||||
'海缆': 'Cable',
|
||||
'海缆登陆关系': 'Cable landing relation',
|
||||
'海缆系统': 'Cable system',
|
||||
'后端已停止响应,正在等待服务恢复。': 'Backend stopped responding. Waiting for service recovery.',
|
||||
'源 ID': 'Source ID',
|
||||
'源名称': 'Source name',
|
||||
'源属性标签': 'Source attribute tags',
|
||||
'源类型': 'Source type',
|
||||
'源配置': 'Source configuration',
|
||||
'源属性': 'Source attributes',
|
||||
'源详情': 'Source details',
|
||||
'源健康': 'Source health',
|
||||
'区域': 'Region',
|
||||
'按数据源': 'By datasource',
|
||||
'按类型': 'By type',
|
||||
'排序': 'Sort order',
|
||||
'单条添加': 'Add one item',
|
||||
'单源配置': 'Single-source configuration',
|
||||
'上传': 'Upload',
|
||||
'上传 JSON': 'Upload JSON',
|
||||
'用户管理': 'User Management',
|
||||
'电商': 'E-commerce',
|
||||
'电视直播': 'TV streams',
|
||||
'直播源': 'Stream sources',
|
||||
'直播源详情': 'Stream source details',
|
||||
'目标 Schema': 'Target schema',
|
||||
'直达': 'Open',
|
||||
'确认': 'Confirm',
|
||||
'确认删除': 'Confirm deletion',
|
||||
'确认告警': 'Confirm alert',
|
||||
'确认操作': 'Confirm action',
|
||||
'禁用': 'Disabled',
|
||||
'空': 'Empty',
|
||||
'空闲': 'Idle',
|
||||
'等待中': 'Pending',
|
||||
'简报': 'Brief',
|
||||
'结果': 'Results',
|
||||
'系统告警列表': 'System alert list',
|
||||
'系统告警': 'System Alerts',
|
||||
'系统日志': 'System Logs',
|
||||
'系统显示': 'System display',
|
||||
'系统设置': 'System Settings',
|
||||
'系统总览与实时态势': 'System overview and realtime status',
|
||||
'设置': 'Settings',
|
||||
'设置详情': 'Settings details',
|
||||
'设置分区': 'Settings sections',
|
||||
'记录数': 'Records',
|
||||
'计算中心': 'Compute center',
|
||||
'选择 JSON 文件': 'Select JSON file',
|
||||
'选择一条记录': 'Select a record',
|
||||
'选择一组重复日志': 'Select a duplicate log group',
|
||||
'选择左侧父级后编辑它的子配置。': 'Select a parent item on the left to edit its child configuration.',
|
||||
'选择日志源后读取快照。': 'Select a log source to read its snapshot.',
|
||||
'选择新闻直播源': 'Select news stream source',
|
||||
'纬度': 'Latitude',
|
||||
'经度': 'Longitude',
|
||||
'统计': 'Stats',
|
||||
'组内可按条添加,也可以上传 JSON 数组批量导入。': 'You can add items one by one or upload a JSON array for bulk import.',
|
||||
'编辑': 'Edit',
|
||||
'编辑新闻': 'Edit news',
|
||||
'缺失': 'Missing',
|
||||
'免费': 'Free',
|
||||
'网络': 'Network',
|
||||
'自定义': 'Custom',
|
||||
'自定义源': 'Custom sources',
|
||||
'自治系统统计': 'Autonomous system stats',
|
||||
'自动回退': 'Auto fallback',
|
||||
'英文标题': 'English title',
|
||||
'英文摘要': 'English summary',
|
||||
'英文正文': 'English content',
|
||||
'英文分类': 'English category',
|
||||
'草稿': 'Draft',
|
||||
'警告': 'Warning',
|
||||
'设备统计': 'Device stats',
|
||||
'触发全部': 'Trigger all',
|
||||
'触发采集': 'Trigger collection',
|
||||
'访问智能星球': 'Open Intelligent Planet',
|
||||
'访问官网': 'Open website',
|
||||
'详 情': 'Details',
|
||||
'详情': 'Details',
|
||||
'详情/统计': 'Details / stats',
|
||||
'详情会在右侧完整显示,不会挤压主表区域。': 'Details appear in the right pane without compressing the main table.',
|
||||
'调试': 'Debug',
|
||||
'请稍后重试': 'Please try again later',
|
||||
'连接失败': 'Connection failed',
|
||||
'连接中': 'Connecting',
|
||||
'连接测试': 'Connection test',
|
||||
'连通性': 'Connectivity',
|
||||
'连通正常': 'Connectivity normal',
|
||||
'连通性失败': 'Connectivity failed',
|
||||
'运行': 'Run',
|
||||
'运行中': 'Running',
|
||||
'运行状态': 'Runtime status',
|
||||
'运维与配置': 'Operations and Settings',
|
||||
'过滤': 'Filters',
|
||||
'跟随中': 'Following',
|
||||
'跟随日志': 'Follow logs',
|
||||
'输入': 'Input',
|
||||
'返回上一级详情': 'Back to parent details',
|
||||
'返回列表': 'Back to list',
|
||||
'通知策略': 'Notification policy',
|
||||
'配置错误': 'Configuration error',
|
||||
'配置源': 'Configuration source',
|
||||
'重要度与健康策略': 'Importance and health policy',
|
||||
'重启': 'Restart',
|
||||
'重启 AI Provider': 'Restart AI Provider',
|
||||
'重启后端': 'Restart backend',
|
||||
'重启服务': 'Restart service',
|
||||
'重启前端': 'Restart frontend',
|
||||
'重启数据库': 'Restart database',
|
||||
'重启动作': 'Restart action',
|
||||
'重启任务失败': 'Restart task failed',
|
||||
'重复日志详情': 'Duplicate log details',
|
||||
'重复日志统计': 'Duplicate log stats',
|
||||
'重复统计': 'Duplicate stats',
|
||||
'重启实时源': 'Restart realtime source',
|
||||
'重置': 'Reset',
|
||||
'重置 Prompt': 'Reset prompt',
|
||||
'重置为默认内容': 'Reset to default content',
|
||||
'重置为默认教程': 'Reset to default guide',
|
||||
'重置品牌配置': 'Reset brand configuration',
|
||||
'重试': 'Retry',
|
||||
'重试次数': 'Retries',
|
||||
'错误': 'Error',
|
||||
'覆盖类型': 'Covered types',
|
||||
'覆盖数据源': 'Covered datasources',
|
||||
'执行命令': 'Command',
|
||||
'暂停日志跟随': 'Pause log follow',
|
||||
'隐藏 LLM API Key / Service Token': 'Hide LLM API Key / Service Token',
|
||||
'隐藏': 'Hide',
|
||||
'首页地址': 'Homepage URL',
|
||||
'主页地址': 'Homepage URL',
|
||||
'默认新闻类型': 'Default news category',
|
||||
'默认': 'Default',
|
||||
'默认教程': 'Default guide',
|
||||
'默认模型': 'Default model',
|
||||
'默认频道': 'Default channel',
|
||||
'高亮命中': 'Highlighted match',
|
||||
'AIS 船舶': 'AIS vessels',
|
||||
'BGP 更新': 'BGP updates',
|
||||
'BGP 路由': 'BGP route',
|
||||
'BGP 路由表': 'BGP RIB',
|
||||
'BGP 事件': 'BGP event',
|
||||
'Docker 不可用': 'Docker unavailable',
|
||||
'GPU 集群': 'GPU clusters',
|
||||
'HTTP 失败': 'HTTP failed',
|
||||
'当前分区没有可用后端能力,控制台不混入其他配置或假数据。': 'This section has no backend capability yet; the console does not mix in unrelated configuration or fake data.',
|
||||
'当前分区没有可配置项。': 'This section has no configurable items.',
|
||||
'当前模块暂无数据': 'No data in this module',
|
||||
'当前已是默认': 'Already default',
|
||||
'当前已是默认频道': 'Already the default channel',
|
||||
'当前采集源没有可查看的历史版本。': 'This collection source has no historical versions.',
|
||||
'待定位': 'Pending location',
|
||||
'后端能力未提供': 'Backend capability unavailable',
|
||||
'只展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary build, and TV content configuration are shown.',
|
||||
'只展示数据源相关接口,不混入其他设置对象。': 'Only datasource-related endpoints are shown; unrelated settings are not mixed in.',
|
||||
'只展示系统设置分区;AI 集成和采集器调度分别在对应模块管理。': 'Only system settings sections are shown. AI integrations and collector schedules are managed in their own modules.',
|
||||
'只展示 BGP 事故、异常与简报。': 'Only BGP incidents, anomalies, and briefs are shown.',
|
||||
'只记录官网、报告页或未来采集器线索,不参与 RSS/Atom 抓取;如需抓取,请改为 RSS、Atom 或 Aggregated。': 'Records official sites, reports, or future collector leads only. It does not participate in RSS/Atom fetching. Use RSS, Atom, or Aggregated to fetch.',
|
||||
'只编辑当前新闻源;保存后才会固化到新闻源配置。': 'Only edits the current news source. Save to persist it into the news source configuration.',
|
||||
'只重启 AI Provider 适配服务,前端页面通常保持在线。': 'Restart only the AI Provider adapter. The frontend usually stays online.',
|
||||
'只重启后端服务,页面通常会短暂失联后自动恢复。': 'Restart only the backend service. The page may briefly disconnect and recover automatically.',
|
||||
'只重启前端开发服务,页面会短暂不可用,恢复后自动刷新。': 'Restart only the frontend dev service. The page will be briefly unavailable and refresh after recovery.',
|
||||
'失败时页面仍可操作': 'Page remains usable when requests fail',
|
||||
'打开': 'Open',
|
||||
'描述来源属性,不是媒体来源名;多个标签用逗号分隔,例如 business_news, ecommerce, china。': 'Describe source attributes, not media source names. Separate multiple tags with commas, for example business_news, ecommerce, china.',
|
||||
'浏览采集结果、筛选数据源和查看原始元数据。': 'Browse collected results, filter datasources, and inspect raw metadata.',
|
||||
'管理采集器、采集调度和采集历史 / 快照。': 'Manage collectors, collection schedules, and collection history / snapshots.',
|
||||
'管理智能星球品牌、边界、电视内容和内容资产。': 'Manage Intelligent Planet branding, boundaries, TV content, and content assets.',
|
||||
'管理模型供应商、工具调用、提示词和 Playground。': 'Manage model providers, tool calls, prompts, and Playground.',
|
||||
'管理系统显示、通知策略、安全策略和 SMTP 邮件。': 'Manage system display, notification policy, security policy, and SMTP email.',
|
||||
'统一查看内置源、自定义源、实时源与任务状态,保留触发、启停和连接状态入口。': 'View built-in, custom, and realtime sources plus task status in one place, with trigger, start/stop, and connectivity entries.',
|
||||
'严重告警': 'Critical alerts',
|
||||
'查看日志源、读取快照、复制原始输出,按控制台阅读方式组织。': 'View log sources, read snapshots, and copy raw output in a console-friendly layout.',
|
||||
'查看日志源、按级别/日期/搜索条件读取快照,并复制原始输出。': 'View log sources, read snapshots by level, date, and search filters, then copy raw output.',
|
||||
'显示系统告警记录和统计,不混入 BGP 概览以外的数据。': 'Shows system alert records and stats without mixing in data outside the BGP overview.',
|
||||
'显示态势统计与告警记录。': 'Shows situational stats and alert records.',
|
||||
'查看态势统计、严重度、AI 简报入口和处理状态。': 'View situational stats, severity, AI brief entry points, and handling status.',
|
||||
'系统告警、确认处理、AI 摘要和处置状态集中到一张低噪声列表。': 'System alerts, acknowledgements, AI summaries, and resolution status are collected into one low-noise list.',
|
||||
'聚合 BGP 事故、异常和 AI 简报,突出严重度、影响范围和事件链路。': 'Aggregates BGP incidents, anomalies, and AI briefs, highlighting severity, affected scope, and event chains.',
|
||||
'查看采集器、事故、异常、事件与 AI 简报;这是信息观测页,采用列表加详情。': 'View collectors, incidents, anomalies, events, and AI briefs in an information page with list plus detail.',
|
||||
'按 BGP 实体聚合展示,保留事件、异常、事故和简报语义。': 'Aggregates by BGP entity while preserving event, anomaly, incident, and brief semantics.',
|
||||
'配置类页面采用分层结构:先选父级,再编辑子配置。': 'Configuration pages use a hierarchy: select a parent first, then edit child configuration.',
|
||||
'仅展示系统设置分区;AI 集成和采集器调度分别在对应模块管理。': 'Only system setting sections are shown; AI integrations and collector schedules are managed in their own modules.',
|
||||
'仅展示数据源相关接口,不混入其他设置对象。': 'Only datasource endpoints are shown; unrelated settings objects are not mixed in.',
|
||||
'仅展示智能星球品牌、边界构建和电视内容配置。': 'Only Intelligent Planet branding, boundary builds, and TV content configuration are shown.',
|
||||
'读取快照': 'Read snapshot',
|
||||
'输入要发送给 AI 的内容': 'Enter content to send to AI',
|
||||
'发送': 'Send',
|
||||
'会话': 'Conversation',
|
||||
'会话写入后端,刷新后保留线程状态。': 'Conversation state is stored in the backend and persists after refresh.',
|
||||
'Playground 设置': 'Playground settings',
|
||||
'预设': 'Preset',
|
||||
'目标': 'Objective',
|
||||
'约束': 'Constraints',
|
||||
'描述': 'Description',
|
||||
'优先级': 'Priority',
|
||||
'边界状态': 'Boundary status',
|
||||
'边界构建': 'Boundary build',
|
||||
'边界构建任务': 'Boundary build task',
|
||||
'品牌': 'Brand',
|
||||
'品牌标识': 'Branding',
|
||||
'品牌配置': 'Brand configuration',
|
||||
'异常接口': 'Failing endpoints',
|
||||
'图层资源': 'Layer resources',
|
||||
'采集源': 'Collected sources',
|
||||
'实时源': 'Realtime sources',
|
||||
'重复日志': 'Duplicate logs',
|
||||
'原始日志': 'Raw logs',
|
||||
'审计事件': 'Audit events',
|
||||
'审计日志': 'Audit logs',
|
||||
'审计来源': 'Audit sources',
|
||||
'原始ID': 'Raw ID',
|
||||
'原始元数据': 'Raw metadata',
|
||||
'扩展字段': 'Extended fields',
|
||||
'参考日期': 'Reference date',
|
||||
'快捷入口': 'Quick links',
|
||||
'行': 'lines',
|
||||
'次': 'times',
|
||||
'首次': 'First',
|
||||
'指纹': 'Fingerprint',
|
||||
'离线': 'Offline',
|
||||
'等待创建': 'Waiting to create',
|
||||
'等待操作': 'Waiting for action',
|
||||
'将重启服务。': 'The service will restart.',
|
||||
'完全重启': 'Full restart',
|
||||
'重启 PostgreSQL 和 Redis 容器,前端页面保持在线。': 'Restart the PostgreSQL and Redis containers while the frontend stays online.',
|
||||
'重启前后端和相关服务,页面会短暂不可用,恢复后自动刷新。': 'Restart frontend, backend, and related services. The page will be briefly unavailable and refresh after recovery.',
|
||||
'已发送重启指令,正在等待服务进入重启流程。': 'Restart command sent. Waiting for services to enter the restart flow.',
|
||||
'服务已恢复,正在刷新页面。': 'Service recovered. Refreshing the page.',
|
||||
'前端已恢复,正在刷新页面。': 'Frontend recovered. Refreshing the page.',
|
||||
'前端正在重启,正在等待页面入口恢复访问。': 'Frontend is restarting. Waiting for the page entry to recover.',
|
||||
'获取数据失败': 'Failed to load data',
|
||||
'最后更新': 'Last updated',
|
||||
'总记录': 'Total records',
|
||||
'筛选结果': 'Filtered results',
|
||||
'清空': 'Clear',
|
||||
'导出失败': 'Export failed',
|
||||
'导出 JSON': 'Export JSON',
|
||||
'导出 CSV': 'Export CSV',
|
||||
'数据详情': 'Data details',
|
||||
'按级别/日期/搜索条件读取快照': 'Read snapshots by level, date, and search filters',
|
||||
'点击左侧聚合项查看每次发生时间。': 'Click an aggregation on the left to view each occurrence time.',
|
||||
'管理员敏感操作和安全审计记录。': 'Sensitive admin operations and security audit records.',
|
||||
'当前账号没有系统日志访问权限。': 'This account does not have system log access.',
|
||||
'仅超级管理员可查看系统日志。': 'Only super admins can view system logs.',
|
||||
'左侧展示按 fingerprint 聚合后的运行时错误。': 'The left side shows runtime errors grouped by fingerprint.',
|
||||
'调整筛选条件或刷新日志源。': 'Adjust filters or refresh log sources.',
|
||||
'复制日志': 'Copy logs',
|
||||
'刷新日志': 'Refresh logs',
|
||||
'刷新日志源': 'Refresh log sources',
|
||||
'结束日期': 'End date',
|
||||
'信息': 'Info',
|
||||
'可用': 'Available',
|
||||
'可读取': 'Readable',
|
||||
'可编辑': 'Editable',
|
||||
'只读': 'Read-only',
|
||||
'暂无日志源': 'No log sources',
|
||||
'登陆点': 'Landing point',
|
||||
'算力中心': 'Compute center',
|
||||
'互联网交换点': 'Internet exchange point',
|
||||
'前缀地理位置': 'Prefix geography',
|
||||
'卫星轨道根数': 'Satellite TLE',
|
||||
'空间': 'Space',
|
||||
'超算': 'Supercomputer',
|
||||
'通用数据': 'Generic data',
|
||||
'通用记录': 'Generic records',
|
||||
'船舶': 'Vessel',
|
||||
'设施': 'Facility',
|
||||
'流量统计': 'Traffic stats',
|
||||
'条': 'items',
|
||||
'项': 'items',
|
||||
'条结果': 'results',
|
||||
'筛选': 'Filters',
|
||||
'共': 'Total',
|
||||
'智能星球计划': 'Intelligent Planet Plan',
|
||||
'智能星球计划品牌标识': 'Intelligent Planet Plan branding',
|
||||
'现实层宇宙全息感知系统': 'Reality-layer holographic awareness system',
|
||||
'卫星 · 海底光缆 · 算力基础设施': 'Satellites · Submarine cables · Computing infrastructure',
|
||||
'选择/拖入资产': 'Select / drop asset',
|
||||
'元数据 / 原始字段': 'Metadata / raw fields',
|
||||
'全部启用状态': 'All enabled states',
|
||||
'失败': 'Failed',
|
||||
'未执行': 'Not run',
|
||||
'已采集': 'Collected',
|
||||
'未采集': 'Not collected',
|
||||
'当前分区暂无记录': 'No records in this section',
|
||||
'切换上方分区可精准查看不同配置和接口。': 'Switch sections above to inspect different configurations and endpoints.',
|
||||
'详情会在右侧完整滚动显示,不会挤压主表区域。': 'Details scroll fully on the right without compressing the main table.',
|
||||
'连接、采样、运行和凭证配置': 'Connection, sampling, runtime, and credential configuration',
|
||||
'采样 payload 到目标 Schema 的字段映射': 'Field mapping from sample payload to target schema',
|
||||
'采集数据落库目标结构': 'Target schema for persisted collected data',
|
||||
'标识': 'Identifier',
|
||||
'更新时间': 'Updated at',
|
||||
'卫星': 'Satellite',
|
||||
'算力': 'Compute',
|
||||
'媒体': 'Media',
|
||||
}
|
||||
68
frontend/src/i18n/locale.ts
Normal file
68
frontend/src/i18n/locale.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type SupportedLocale = 'zh-CN' | 'en-US'
|
||||
export type DocsLang = 'zh' | 'en'
|
||||
|
||||
export const defaultLocale: SupportedLocale = 'zh-CN'
|
||||
export const localeStorageKey = 'planet-locale'
|
||||
const legacyDocsLangStorageKey = 'docs-lang'
|
||||
|
||||
export const localeOptions: Array<{ value: SupportedLocale; labelKey: string; titleKey: string }> = [
|
||||
{ value: 'zh-CN', labelKey: 'common.zh', titleKey: 'common.zh' },
|
||||
{ value: 'en-US', labelKey: 'common.en', titleKey: 'common.en' },
|
||||
]
|
||||
|
||||
export function normalizeLocale(value: string | null | undefined): SupportedLocale {
|
||||
if (!value) return defaultLocale
|
||||
const normalized = value.toLowerCase()
|
||||
if (normalized === 'en' || normalized === 'en-us' || normalized.startsWith('en-')) return 'en-US'
|
||||
if (normalized === 'zh' || normalized === 'zh-cn' || normalized.startsWith('zh-')) return 'zh-CN'
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
export function docsLangFromLocale(locale: SupportedLocale): DocsLang {
|
||||
return locale === 'en-US' ? 'en' : 'zh'
|
||||
}
|
||||
|
||||
export function localeFromDocsLang(lang: DocsLang): SupportedLocale {
|
||||
return lang === 'en' ? 'en-US' : 'zh-CN'
|
||||
}
|
||||
|
||||
export function readStoredLocale(): SupportedLocale {
|
||||
if (typeof window === 'undefined') return defaultLocale
|
||||
const storedLocale = window.localStorage.getItem(localeStorageKey)
|
||||
if (storedLocale) return normalizeLocale(storedLocale)
|
||||
|
||||
const legacyDocsLang = window.localStorage.getItem(legacyDocsLangStorageKey)
|
||||
if (legacyDocsLang === 'en' || legacyDocsLang === 'zh') {
|
||||
return localeFromDocsLang(legacyDocsLang)
|
||||
}
|
||||
|
||||
return defaultLocale
|
||||
}
|
||||
|
||||
export function persistLocale(locale: SupportedLocale) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.localStorage.setItem(localeStorageKey, locale)
|
||||
window.localStorage.setItem(legacyDocsLangStorageKey, docsLangFromLocale(locale))
|
||||
}
|
||||
|
||||
export function syncDocumentLocale(locale: SupportedLocale) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.lang = locale
|
||||
}
|
||||
|
||||
export function useLocale() {
|
||||
const { i18n } = useTranslation()
|
||||
const locale = normalizeLocale(i18n.resolvedLanguage || i18n.language)
|
||||
const docsLang = docsLangFromLocale(locale)
|
||||
|
||||
const setLocale = useCallback((nextLocale: SupportedLocale) => {
|
||||
persistLocale(nextLocale)
|
||||
syncDocumentLocale(nextLocale)
|
||||
void i18n.changeLanguage(nextLocale)
|
||||
}, [i18n])
|
||||
|
||||
return { docsLang, locale, setLocale }
|
||||
}
|
||||
430
frontend/src/i18n/resources.ts
Normal file
430
frontend/src/i18n/resources.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
export const zhCN = {
|
||||
app: {
|
||||
title: '智能星球计划',
|
||||
routeLoading: '正在加载',
|
||||
},
|
||||
common: {
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
confirm: '确认',
|
||||
delete: '删除',
|
||||
language: '语言',
|
||||
loading: '加载中',
|
||||
noData: '暂无数据',
|
||||
operationFailed: '操作失败',
|
||||
page: '第 {{page}} / {{totalPages}} 页,共 {{total}} 条',
|
||||
previousPage: '上一页',
|
||||
nextPage: '下一页',
|
||||
selectRow: '选择行',
|
||||
selectVisibleRows: '选择当前可见数据',
|
||||
theme: '主题',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
themeSystem: '系统',
|
||||
themeFollowSystem: '跟随系统',
|
||||
zh: '中文',
|
||||
en: 'EN',
|
||||
},
|
||||
admin: {
|
||||
brandTitle: '智能星球',
|
||||
brandSubtitle: '控制台',
|
||||
collapseMenu: '折叠菜单',
|
||||
expandMenu: '展开菜单',
|
||||
openNav: '打开导航',
|
||||
closeNav: '关闭导航',
|
||||
logout: '退出登录',
|
||||
greeting: '您好,{{name}}',
|
||||
version: '版本号',
|
||||
themeControl: '控制台主题',
|
||||
languageControl: '控制台语言',
|
||||
expandPreferences: '展开偏好设置',
|
||||
collapsePreferences: '收起偏好设置',
|
||||
search: {
|
||||
label: '搜索功能、配置和文字',
|
||||
placeholder: '搜索功能、配置和文字',
|
||||
current: '当前:{{label}}',
|
||||
results: 'Admin 搜索结果',
|
||||
loading: '正在加载搜索索引…',
|
||||
empty: '没有找到匹配内容',
|
||||
pageContext: '页面',
|
||||
},
|
||||
groups: {
|
||||
overview: '总览',
|
||||
collection: '采集与数据',
|
||||
observability: '专题观测',
|
||||
alerts: '告警与研判',
|
||||
ops: '运维与配置',
|
||||
},
|
||||
routes: {
|
||||
dashboard: '仪表盘',
|
||||
earth: '智能星球',
|
||||
docs: '文档',
|
||||
datasources: '数据源',
|
||||
data: '采集数据',
|
||||
bgp: 'BGP观测',
|
||||
systemAlerts: '系统告警',
|
||||
bgpAlerts: 'BGP 告警',
|
||||
situationalAlerts: '态势告警',
|
||||
ai: 'AI',
|
||||
earthContent: '智能星球内容',
|
||||
collectionManagement: '采集管理',
|
||||
logs: '系统日志',
|
||||
users: '用户管理',
|
||||
settings: '系统设置',
|
||||
},
|
||||
sections: {
|
||||
alerts: '告警记录',
|
||||
aiIntegrations: '模型供应商',
|
||||
aiTools: '工具调用',
|
||||
aiPrompts: '提示词',
|
||||
aiPlayground: 'Playground',
|
||||
bgpOverview: 'BGP',
|
||||
collectionHistory: '采集历史 / 快照',
|
||||
collectorCredentials: '采集器',
|
||||
collectors: '采集调度',
|
||||
earthAssets: '国界精度',
|
||||
earthBrand: '品牌标识',
|
||||
logsSources: '日志源',
|
||||
newsSources: '新闻源',
|
||||
notifications: '通知策略',
|
||||
security: '安全策略',
|
||||
settingsSystem: '系统显示',
|
||||
smtp: 'SMTP 邮件',
|
||||
tv: '电视直播',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
accountRecovery: '账号恢复',
|
||||
alreadyHaveAccount: '已有账号,去登录',
|
||||
backToLogin: '返回登录',
|
||||
code: '验证码',
|
||||
codeSent: '验证码已发送至 <strong>{{email}}</strong>,10 分钟内有效。',
|
||||
createAccount: '创建账号',
|
||||
email: '邮箱',
|
||||
emailVerification: '邮箱验证',
|
||||
emailNotVerified: '邮箱未验证,请先完成邮箱验证。',
|
||||
forgotPassword: '忘记密码?',
|
||||
forgotPasswordDescription: '通过邮箱验证码重置后台账号密码。',
|
||||
forgotPasswordTitle: '找回密码',
|
||||
loginButton: '登录',
|
||||
loginDescription: '使用你的后台账号进入运维工作台。',
|
||||
loginFailed: '登录失败,请检查账号或密码。',
|
||||
loginSuccess: '登录成功,正在进入控制台。',
|
||||
loginTitle: '登录 Planet 控制台',
|
||||
newPassword: '新密码',
|
||||
password: '密码',
|
||||
passwordHint: '至少 8 位',
|
||||
passwordResetSuccess: '密码已重置,请用新密码登录。',
|
||||
recoveryCodeSent: '若该邮箱已注册,验证码已发送。请到邮箱查收。',
|
||||
register: '注册',
|
||||
registerAccount: '注册账户',
|
||||
registerDescription: '创建账号后需要完成邮箱验证,验证成功会自动进入控制台。',
|
||||
resend: '重新发送验证码',
|
||||
resendCountdown: '重发 ({{seconds}}s)',
|
||||
resendSuccess: '验证码已重发。',
|
||||
resetPassword: '重置密码',
|
||||
sendCode: '发送验证码',
|
||||
updateEmail: '修改邮箱',
|
||||
username: '用户名',
|
||||
usernameHint: '3-50 个字符',
|
||||
verificationSent: '验证码已发送到邮箱。',
|
||||
verifyAndLogin: '验证并登录',
|
||||
verifyEmail: '验证邮箱',
|
||||
verifyEmailDescription: '输入邮箱验证码后会自动登录并进入控制台。',
|
||||
welcomeBack: '欢迎回来',
|
||||
shell: {
|
||||
product: 'Planet',
|
||||
subtitle: 'Operations Console',
|
||||
kicker: '现代控制台',
|
||||
title: '把数据、告警、AI 和 Earth 运维放在同一个清爽工作台。',
|
||||
description: '控制台默认进入现代化工作流,登录后直接使用 `/admin` 即可。',
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
brandTitle: '智能星球文档',
|
||||
brandSubtitle: '开发者和用户手册',
|
||||
documentUnavailable: '文档不可用',
|
||||
docs: '文档',
|
||||
footerLanguage: 'Language',
|
||||
footerTheme: 'Theme',
|
||||
loading: '加载中...',
|
||||
loginRequired: '需要登录',
|
||||
loginRequiredDescription: '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。',
|
||||
goToLogin: '前往登录',
|
||||
forbidden: '无权访问',
|
||||
forbiddenDescription: '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。',
|
||||
notFound: '文档未找到',
|
||||
notFoundDescription: '请求的文档不存在,或当前语言没有对应内容。',
|
||||
returnOverview: '返回文档首页',
|
||||
searchLabel: '搜索文档',
|
||||
searchPlaceholder: '搜索文档...',
|
||||
searchEmpty: '未找到匹配文档',
|
||||
toc: '本页目录',
|
||||
tocEmpty: '暂无章节',
|
||||
},
|
||||
markdown: {
|
||||
copyCode: '复制代码',
|
||||
copied: '已复制',
|
||||
copiedCode: '已复制代码',
|
||||
copyChartSource: '复制图表源码',
|
||||
copiedChartSource: '已复制图表源码',
|
||||
expandMermaid: '放大查看 Mermaid 图表',
|
||||
clickToExpand: '点击放大查看',
|
||||
closeMermaid: '关闭 Mermaid 图表查看器',
|
||||
mermaidViewer: 'Mermaid 图表查看器',
|
||||
mermaidRenderFailed: 'Mermaid 渲染失败',
|
||||
viewerHint: '拖拽移动 · 滚轮缩放 · 点击空白关闭',
|
||||
},
|
||||
users: {
|
||||
actions: '操作',
|
||||
active: '活跃',
|
||||
addUser: '添加用户',
|
||||
clearSearch: '清空搜索',
|
||||
confirmDelete: '确认删除',
|
||||
confirmDeleteDescription: '确定要删除用户 {{username}} 吗?',
|
||||
createSuccess: '创建成功',
|
||||
deleteFailed: '删除失败',
|
||||
deleteSuccess: '删除成功',
|
||||
description: '维护后台账号、角色与文档权限组。',
|
||||
disabled: '禁用',
|
||||
edit: '编辑',
|
||||
editUser: '编辑用户',
|
||||
gatekeeperGroups: 'Gatekeeper 权限组',
|
||||
retryLater: '请稍后重试',
|
||||
role: '角色',
|
||||
searchPlaceholder: '搜索用户、邮箱、角色',
|
||||
status: '状态',
|
||||
submit: '提交',
|
||||
unconfigured: '未配置',
|
||||
updateSuccess: '更新成功',
|
||||
roles: {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
operator: '操作员',
|
||||
viewer: '只读用户',
|
||||
},
|
||||
gatekeeper: {
|
||||
docs_user: '文档:用户文档',
|
||||
docs_developer: '文档:开发文档',
|
||||
docs_admin: '文档:管理/运维文档',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const enUS = {
|
||||
app: {
|
||||
title: 'Intelligent Planet Plan',
|
||||
routeLoading: 'Loading',
|
||||
},
|
||||
common: {
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
confirm: 'Confirm',
|
||||
delete: 'Delete',
|
||||
language: 'Language',
|
||||
loading: 'Loading',
|
||||
noData: 'No data',
|
||||
operationFailed: 'Operation failed',
|
||||
page: 'Page {{page}} / {{totalPages}}, {{total}} total',
|
||||
previousPage: 'Previous',
|
||||
nextPage: 'Next',
|
||||
selectRow: 'Select row',
|
||||
selectVisibleRows: 'Select visible rows',
|
||||
theme: 'Theme',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
themeSystem: 'System',
|
||||
themeFollowSystem: 'Follow system',
|
||||
zh: '中文',
|
||||
en: 'EN',
|
||||
},
|
||||
admin: {
|
||||
brandTitle: 'Intelligent Planet',
|
||||
brandSubtitle: 'Console',
|
||||
collapseMenu: 'Collapse menu',
|
||||
expandMenu: 'Expand menu',
|
||||
openNav: 'Open navigation',
|
||||
closeNav: 'Close navigation',
|
||||
logout: 'Log out',
|
||||
greeting: 'Hi, {{name}}',
|
||||
version: 'Version',
|
||||
themeControl: 'Console theme',
|
||||
languageControl: 'Console language',
|
||||
expandPreferences: 'Expand preferences',
|
||||
collapsePreferences: 'Collapse preferences',
|
||||
search: {
|
||||
label: 'Search features, settings, and text',
|
||||
placeholder: 'Search features, settings, and text',
|
||||
current: 'Current: {{label}}',
|
||||
results: 'Admin search results',
|
||||
loading: 'Loading search index...',
|
||||
empty: 'No matching content',
|
||||
pageContext: 'Page',
|
||||
},
|
||||
groups: {
|
||||
overview: 'Overview',
|
||||
collection: 'Collection and Data',
|
||||
observability: 'Observability',
|
||||
alerts: 'Alerts and Analysis',
|
||||
ops: 'Operations and Settings',
|
||||
},
|
||||
routes: {
|
||||
dashboard: 'Dashboard',
|
||||
earth: 'Intelligent Planet',
|
||||
docs: 'Docs',
|
||||
datasources: 'Datasources',
|
||||
data: 'Collected Data',
|
||||
bgp: 'BGP Observatory',
|
||||
systemAlerts: 'System Alerts',
|
||||
bgpAlerts: 'BGP Alerts',
|
||||
situationalAlerts: 'Situational Alerts',
|
||||
ai: 'AI',
|
||||
earthContent: 'Planet Content',
|
||||
collectionManagement: 'Collection Management',
|
||||
logs: 'System Logs',
|
||||
users: 'User Management',
|
||||
settings: 'System Settings',
|
||||
},
|
||||
sections: {
|
||||
alerts: 'Alert Records',
|
||||
aiIntegrations: 'Model Providers',
|
||||
aiTools: 'Tool Calls',
|
||||
aiPrompts: 'Prompts',
|
||||
aiPlayground: 'Playground',
|
||||
bgpOverview: 'BGP',
|
||||
collectionHistory: 'Collection History / Snapshots',
|
||||
collectorCredentials: 'Collectors',
|
||||
collectors: 'Collection Schedule',
|
||||
earthAssets: 'Boundary Accuracy',
|
||||
earthBrand: 'Branding',
|
||||
logsSources: 'Log Sources',
|
||||
newsSources: 'News Sources',
|
||||
notifications: 'Notification Policy',
|
||||
security: 'Security Policy',
|
||||
settingsSystem: 'System Display',
|
||||
smtp: 'SMTP Email',
|
||||
tv: 'TV Streams',
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
accountRecovery: 'Account recovery',
|
||||
alreadyHaveAccount: 'Already have an account? Log in',
|
||||
backToLogin: 'Back to login',
|
||||
code: 'Verification code',
|
||||
codeSent: 'A 6-digit code was sent to <strong>{{email}}</strong>. It is valid for 10 minutes.',
|
||||
createAccount: 'Create account',
|
||||
email: 'Email',
|
||||
emailVerification: 'Email verification',
|
||||
emailNotVerified: 'Email is not verified. Please verify your email first.',
|
||||
forgotPassword: 'Forgot password?',
|
||||
forgotPasswordDescription: 'Reset your console password with an email verification code.',
|
||||
forgotPasswordTitle: 'Reset password',
|
||||
loginButton: 'Log in',
|
||||
loginDescription: 'Use your admin account to enter the operations workspace.',
|
||||
loginFailed: 'Login failed. Check your account or password.',
|
||||
loginSuccess: 'Login succeeded. Opening the console.',
|
||||
loginTitle: 'Log in to Planet Console',
|
||||
newPassword: 'New password',
|
||||
password: 'Password',
|
||||
passwordHint: 'At least 8 characters',
|
||||
passwordResetSuccess: 'Password reset. Log in with your new password.',
|
||||
recoveryCodeSent: 'If this email is registered, a code has been sent. Please check your inbox.',
|
||||
register: 'Register',
|
||||
registerAccount: 'Register account',
|
||||
registerDescription: 'Create an account, verify your email, then enter the console automatically.',
|
||||
resend: 'Resend code',
|
||||
resendCountdown: 'Resend ({{seconds}}s)',
|
||||
resendSuccess: 'Verification code resent.',
|
||||
resetPassword: 'Reset password',
|
||||
sendCode: 'Send code',
|
||||
updateEmail: 'Change email',
|
||||
username: 'Username',
|
||||
usernameHint: '3-50 characters',
|
||||
verificationSent: 'Verification code sent to your email.',
|
||||
verifyAndLogin: 'Verify and log in',
|
||||
verifyEmail: 'Verify email',
|
||||
verifyEmailDescription: 'Enter the email verification code to log in and open the console.',
|
||||
welcomeBack: 'Welcome back',
|
||||
shell: {
|
||||
product: 'Planet',
|
||||
subtitle: 'Operations Console',
|
||||
kicker: 'Modern console',
|
||||
title: 'Bring data, alerts, AI, and Earth operations into one focused workspace.',
|
||||
description: 'The console opens the modern workflow by default. Use `/admin` after login.',
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
brandTitle: 'Intelligent Planet Docs',
|
||||
brandSubtitle: 'Developer & User Guide',
|
||||
documentUnavailable: 'Document unavailable',
|
||||
docs: 'Docs',
|
||||
footerLanguage: 'Language',
|
||||
footerTheme: 'Theme',
|
||||
loading: 'Loading document...',
|
||||
loginRequired: 'Login required',
|
||||
loginRequiredDescription: 'This document requires login and the matching Gatekeeper permission group.',
|
||||
goToLogin: 'Go to login',
|
||||
forbidden: 'Permission required',
|
||||
forbiddenDescription: 'Your account does not have the Gatekeeper permission group required for this document.',
|
||||
notFound: 'Document not found',
|
||||
notFoundDescription: 'The requested guide does not exist or is not available in the current language.',
|
||||
returnOverview: 'Return to docs overview',
|
||||
searchLabel: 'Search docs',
|
||||
searchPlaceholder: 'Search guides, APIs, layers...',
|
||||
searchEmpty: 'No matching docs',
|
||||
toc: 'On this page',
|
||||
tocEmpty: 'No sections',
|
||||
},
|
||||
markdown: {
|
||||
copyCode: 'Copy code',
|
||||
copied: 'Copied',
|
||||
copiedCode: 'Code copied',
|
||||
copyChartSource: 'Copy chart source',
|
||||
copiedChartSource: 'Chart source copied',
|
||||
expandMermaid: 'Expand Mermaid diagram',
|
||||
clickToExpand: 'Click to expand',
|
||||
closeMermaid: 'Close Mermaid diagram viewer',
|
||||
mermaidViewer: 'Mermaid diagram viewer',
|
||||
mermaidRenderFailed: 'Mermaid render failed',
|
||||
viewerHint: 'Drag to pan · Scroll to zoom · Click blank space to close',
|
||||
},
|
||||
users: {
|
||||
actions: 'Actions',
|
||||
active: 'Active',
|
||||
addUser: 'Add user',
|
||||
clearSearch: 'Clear search',
|
||||
confirmDelete: 'Confirm deletion',
|
||||
confirmDeleteDescription: 'Delete user {{username}}?',
|
||||
createSuccess: 'Created',
|
||||
deleteFailed: 'Delete failed',
|
||||
deleteSuccess: 'Deleted',
|
||||
description: 'Maintain console accounts, roles, and Docs permission groups.',
|
||||
disabled: 'Disabled',
|
||||
edit: 'Edit',
|
||||
editUser: 'Edit user',
|
||||
gatekeeperGroups: 'Gatekeeper groups',
|
||||
retryLater: 'Please try again later',
|
||||
role: 'Role',
|
||||
searchPlaceholder: 'Search users, email, or role',
|
||||
status: 'Status',
|
||||
submit: 'Submit',
|
||||
unconfigured: 'Not configured',
|
||||
updateSuccess: 'Updated',
|
||||
roles: {
|
||||
super_admin: 'Super admin',
|
||||
admin: 'Admin',
|
||||
operator: 'Operator',
|
||||
viewer: 'Viewer',
|
||||
},
|
||||
gatekeeper: {
|
||||
docs_user: 'Docs: user docs',
|
||||
docs_developer: 'Docs: developer docs',
|
||||
docs_admin: 'Docs: admin / ops docs',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const resources = {
|
||||
'zh-CN': { translation: zhCN },
|
||||
'en-US': { translation: enUS },
|
||||
} as const
|
||||
@@ -63,6 +63,7 @@ select {
|
||||
}
|
||||
|
||||
.auth-shell__panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -70,6 +71,13 @@ select {
|
||||
padding: clamp(28px, 5vw, 56px);
|
||||
}
|
||||
|
||||
.auth-shell__language {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 34px;
|
||||
width: 118px;
|
||||
}
|
||||
|
||||
.auth-shell__brand {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
@@ -117,13 +125,13 @@ select {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-shell__heading h1 {
|
||||
margin-top: 10px;
|
||||
font-size: clamp(28px, 4vw, 38px);
|
||||
font-size: 38px;
|
||||
line-height: 1.12;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -240,7 +248,7 @@ select {
|
||||
max-width: 720px;
|
||||
margin-top: 14px;
|
||||
color: #081424;
|
||||
font-size: clamp(30px, 5vw, 52px);
|
||||
font-size: 52px;
|
||||
line-height: 1.05;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
@@ -266,4 +274,8 @@ select {
|
||||
min-height: calc(100vh - 20px);
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.auth-shell__heading h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { registerAdminRuntimeErrorHandlers } from './admin/runtimeLogs'
|
||||
import './i18n'
|
||||
import './index.css'
|
||||
|
||||
registerAdminRuntimeErrorHandlers()
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ArrowLeft, Loader2, Sparkles } from 'lucide-react'
|
||||
import { type FormEvent, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
|
||||
import { localeOptions, useLocale, type SupportedLocale } from '../../i18n/locale'
|
||||
|
||||
interface AuthShellProps {
|
||||
eyebrow: string
|
||||
@@ -11,14 +14,30 @@ interface AuthShellProps {
|
||||
}
|
||||
|
||||
export function AuthShell({ eyebrow, title, description, children, aside }: AuthShellProps) {
|
||||
const { t } = useTranslation()
|
||||
const { locale, setLocale } = useLocale()
|
||||
const languageOptions = localeOptions.map((option) => ({
|
||||
value: option.value,
|
||||
label: t(option.labelKey),
|
||||
title: t(option.titleKey),
|
||||
}))
|
||||
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-shell__panel">
|
||||
<SegmentedControl<SupportedLocale>
|
||||
ariaLabel={t('common.language')}
|
||||
className="auth-shell__language"
|
||||
options={languageOptions}
|
||||
scale={0.78}
|
||||
value={locale}
|
||||
onChange={setLocale}
|
||||
/>
|
||||
<div className="auth-shell__brand">
|
||||
<span className="auth-shell__logo"><Sparkles size={20} /></span>
|
||||
<div>
|
||||
<strong>Planet</strong>
|
||||
<span>Operations Console</span>
|
||||
<strong>{t('auth.shell.product')}</strong>
|
||||
<span>{t('auth.shell.subtitle')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-shell__heading">
|
||||
@@ -31,9 +50,9 @@ export function AuthShell({ eyebrow, title, description, children, aside }: Auth
|
||||
<aside className="auth-shell__aside">
|
||||
{aside || (
|
||||
<>
|
||||
<span className="auth-shell__aside-kicker">现代控制台</span>
|
||||
<h2>把数据、告警、AI 和 Earth 运维放在同一个清爽工作台。</h2>
|
||||
<p>控制台默认进入现代化工作流,登录后直接使用 `/admin` 即可。</p>
|
||||
<span className="auth-shell__aside-kicker">{t('auth.shell.kicker')}</span>
|
||||
<h2>{t('auth.shell.title')}</h2>
|
||||
<p>{t('auth.shell.description')}</p>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
@@ -71,10 +90,15 @@ export function AuthButton({
|
||||
loading,
|
||||
children,
|
||||
variant = 'primary',
|
||||
type = 'button',
|
||||
disabled,
|
||||
className,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean; variant?: 'primary' | 'secondary' | 'ghost' }) {
|
||||
const buttonClassName = ['auth-button', `auth-button--${variant}`, className].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<button className={`auth-button auth-button--${variant}`} disabled={props.disabled || loading} {...props}>
|
||||
<button {...props} type={type} className={buttonClassName} disabled={disabled || loading}>
|
||||
{loading ? <Loader2 className="auth-button__spinner" size={16} /> : null}
|
||||
{children}
|
||||
</button>
|
||||
@@ -90,10 +114,12 @@ export function AuthLinks({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function BackToLogin() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Link className="auth-link auth-link--back" to="/login">
|
||||
<ArrowLeft size={15} />
|
||||
返回登录
|
||||
{t('auth.backToLogin')}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -148,7 +148,7 @@
|
||||
/* ─── Page shell ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.docs-page {
|
||||
height: 100vh;
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
background: var(--d-bg);
|
||||
@@ -887,7 +887,7 @@
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.docs-toc__nav {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import axios from 'axios'
|
||||
|
||||
import MarkdownRenderer from '../../components/MarkdownRenderer/MarkdownRenderer'
|
||||
import Scrollbar from '../../components/Scrollbar/Scrollbar'
|
||||
import SegmentedControl from '../../components/SegmentedControl/SegmentedControl'
|
||||
import { localeFromDocsLang, useLocale } from '../../i18n/locale'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
import {
|
||||
createHeadingIdResolver,
|
||||
@@ -46,11 +48,6 @@ function getHashFromHref(href: string): string {
|
||||
return hashIndex >= 0 ? href.slice(hashIndex) : ''
|
||||
}
|
||||
|
||||
function readStoredLang(): DocsLang {
|
||||
const stored = localStorage.getItem('docs-lang')
|
||||
return stored === 'en' ? 'en' : 'zh'
|
||||
}
|
||||
|
||||
function readStoredThemeMode(): DocsThemeMode {
|
||||
const stored = localStorage.getItem('docs-theme')
|
||||
if (stored === 'system' || stored === 'light' || stored === 'dark') {
|
||||
@@ -69,9 +66,11 @@ function getSystemTheme(): 'light' | 'dark' {
|
||||
export default function Docs() {
|
||||
const { slug } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { t } = useTranslation()
|
||||
const { docsLang, setLocale } = useLocale()
|
||||
const { token } = useAuthStore()
|
||||
const lang = docsLang
|
||||
|
||||
const [lang, setLang] = useState<DocsLang>(readStoredLang)
|
||||
const [themeMode, setThemeMode] = useState<DocsThemeMode>(readStoredThemeMode)
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(getSystemTheme)
|
||||
const [catalogItems, setCatalogItems] = useState<DocsCatalogItem[]>([])
|
||||
@@ -94,14 +93,14 @@ export default function Docs() {
|
||||
const groupedEntries = useMemo(() => groupDocsEntries(docsEntries), [docsEntries])
|
||||
const effectiveTheme = themeMode === 'system' ? systemTheme : themeMode
|
||||
const langOptions = useMemo(() => [
|
||||
{ value: 'zh' as const, label: '中文' },
|
||||
{ value: 'en' as const, label: 'EN' },
|
||||
], [])
|
||||
{ value: 'zh' as const, label: t('common.zh') },
|
||||
{ value: 'en' as const, label: t('common.en') },
|
||||
], [t])
|
||||
const themeOptions = useMemo(() => [
|
||||
{
|
||||
value: 'light' as const,
|
||||
label: '浅色',
|
||||
title: '浅色',
|
||||
label: t('common.themeLight'),
|
||||
title: t('common.themeLight'),
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
@@ -111,8 +110,8 @@ export default function Docs() {
|
||||
},
|
||||
{
|
||||
value: 'system' as const,
|
||||
label: '系统',
|
||||
title: '跟随系统',
|
||||
label: t('common.themeSystem'),
|
||||
title: t('common.themeFollowSystem'),
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
|
||||
@@ -122,20 +121,19 @@ export default function Docs() {
|
||||
},
|
||||
{
|
||||
value: 'dark' as const,
|
||||
label: '深色',
|
||||
title: '深色',
|
||||
label: t('common.themeDark'),
|
||||
title: t('common.themeDark'),
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
], [])
|
||||
], [t])
|
||||
|
||||
const handleLangChange = useCallback((newLang: DocsLang) => {
|
||||
setLang(newLang)
|
||||
localStorage.setItem('docs-lang', newLang)
|
||||
}, [])
|
||||
setLocale(localeFromDocsLang(newLang))
|
||||
}, [setLocale])
|
||||
|
||||
const handleThemeModeChange = useCallback((nextMode: DocsThemeMode) => {
|
||||
setThemeMode(nextMode)
|
||||
@@ -313,10 +311,10 @@ export default function Docs() {
|
||||
<span className="docs-brand__mark">智</span>
|
||||
<span>
|
||||
<span className="docs-brand__title">
|
||||
{lang === 'zh' ? '智能星球文档' : 'Intelligent Planet Docs'}
|
||||
{t('docs.brandTitle')}
|
||||
</span>
|
||||
<span className="docs-brand__subtitle">
|
||||
{lang === 'zh' ? '开发者和用户手册' : 'Developer & User Guide'}
|
||||
{t('docs.brandSubtitle')}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
@@ -359,7 +357,7 @@ export default function Docs() {
|
||||
<footer className="docs-sidebar-footer">
|
||||
<div className="docs-footer-row docs-footer-row--language">
|
||||
<SegmentedControl
|
||||
ariaLabel="Language"
|
||||
ariaLabel={t('docs.footerLanguage')}
|
||||
className="docs-lang-toggle"
|
||||
options={langOptions}
|
||||
scale={FOOTER_CONTROL_SCALE}
|
||||
@@ -370,7 +368,7 @@ export default function Docs() {
|
||||
|
||||
<div className="docs-footer-row">
|
||||
<SegmentedControl
|
||||
ariaLabel="Theme"
|
||||
ariaLabel={t('docs.footerTheme')}
|
||||
className="docs-theme-toggle"
|
||||
options={themeOptions}
|
||||
scale={FOOTER_CONTROL_SCALE}
|
||||
@@ -385,16 +383,16 @@ export default function Docs() {
|
||||
<header className="docs-header">
|
||||
<div>
|
||||
<p className="docs-header__eyebrow">
|
||||
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : lang === 'zh' ? '文档' : 'Docs'}
|
||||
{activeHeaderEntry ? getDocsGroupLabel(activeHeaderEntry.group, lang) : t('docs.docs')}
|
||||
</p>
|
||||
<h1 className="docs-header__title">
|
||||
{activeHeaderEntry?.title || (lang === 'zh' ? '文档不可用' : 'Document unavailable')}
|
||||
{activeHeaderEntry?.title || t('docs.documentUnavailable')}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="docs-search" ref={searchRef}>
|
||||
<label className="docs-search__label" htmlFor="docs-search-input">
|
||||
{lang === 'zh' ? '搜索文档' : 'Search docs'}
|
||||
{t('docs.searchLabel')}
|
||||
</label>
|
||||
<input
|
||||
id="docs-search-input"
|
||||
@@ -409,7 +407,7 @@ export default function Docs() {
|
||||
setIsSearchOpen(true)
|
||||
}
|
||||
}}
|
||||
placeholder={lang === 'zh' ? '搜索文档...' : 'Search guides, APIs, layers...'}
|
||||
placeholder={t('docs.searchPlaceholder')}
|
||||
type="search"
|
||||
/>
|
||||
{shouldShowSearchResults && (
|
||||
@@ -432,7 +430,7 @@ export default function Docs() {
|
||||
))
|
||||
) : (
|
||||
<div className="docs-search__empty">
|
||||
{lang === 'zh' ? '未找到匹配文档' : 'No matching docs'}
|
||||
{t('docs.searchEmpty')}
|
||||
</div>
|
||||
)}
|
||||
</Scrollbar>
|
||||
@@ -445,7 +443,7 @@ export default function Docs() {
|
||||
<Scrollbar className="docs-article" viewportRef={articleRef}>
|
||||
{isCatalogLoading || isLoading ? (
|
||||
<div className="docs-state">
|
||||
{lang === 'zh' ? '加载中...' : 'Loading document...'}
|
||||
{t('docs.loading')}
|
||||
</div>
|
||||
) : docError === 'none' ? (
|
||||
<MarkdownRenderer
|
||||
@@ -458,33 +456,27 @@ export default function Docs() {
|
||||
<div className="docs-not-found">
|
||||
{docError === 'unauthenticated' ? (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '需要登录' : 'Login required'}</h2>
|
||||
<h2>{t('docs.loginRequired')}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '这份文档需要登录并具备对应 Gatekeeper 权限组后才能阅读。'
|
||||
: 'This document requires login and the matching Gatekeeper permission group.'}
|
||||
{t('docs.loginRequiredDescription')}
|
||||
</p>
|
||||
<Link to="/admin">{lang === 'zh' ? '前往登录' : 'Go to login'}</Link>
|
||||
<Link to="/admin">{t('docs.goToLogin')}</Link>
|
||||
</>
|
||||
) : docError === 'forbidden' ? (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '无权访问' : 'Permission required'}</h2>
|
||||
<h2>{t('docs.forbidden')}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '当前账号没有阅读这份文档所需的 Gatekeeper 权限组。'
|
||||
: 'Your account does not have the Gatekeeper permission group required for this document.'}
|
||||
{t('docs.forbiddenDescription')}
|
||||
</p>
|
||||
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
|
||||
<Link to="/docs">{t('docs.returnOverview')}</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h2>{lang === 'zh' ? '文档未找到' : 'Document not found'}</h2>
|
||||
<h2>{t('docs.notFound')}</h2>
|
||||
<p>
|
||||
{lang === 'zh'
|
||||
? '请求的文档不存在,或当前语言没有对应内容。'
|
||||
: 'The requested guide does not exist or is not available in the current language.'}
|
||||
{t('docs.notFoundDescription')}
|
||||
</p>
|
||||
<Link to="/docs">{lang === 'zh' ? '返回文档首页' : 'Return to docs overview'}</Link>
|
||||
<Link to="/docs">{t('docs.returnOverview')}</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -494,7 +486,7 @@ export default function Docs() {
|
||||
<aside className="docs-toc" aria-label="Document table of contents">
|
||||
<Scrollbar className="docs-toc__inner">
|
||||
<h2 className="docs-toc__title">
|
||||
{lang === 'zh' ? '本页目录' : 'On this page'}
|
||||
{t('docs.toc')}
|
||||
</h2>
|
||||
{headings.length > 0 ? (
|
||||
<nav className="docs-toc__nav">
|
||||
@@ -515,7 +507,7 @@ export default function Docs() {
|
||||
</nav>
|
||||
) : (
|
||||
<p className="docs-toc__empty">
|
||||
{lang === 'zh' ? '暂无章节' : 'No sections'}
|
||||
{t('docs.tocEmpty')}
|
||||
</p>
|
||||
)}
|
||||
</Scrollbar>
|
||||
|
||||
6
frontend/src/pages/Earth/Earth.css
Normal file
6
frontend/src/pages/Earth/Earth.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.earth-page-frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
@@ -1,16 +1,13 @@
|
||||
import './Earth.css'
|
||||
|
||||
function Earth() {
|
||||
return (
|
||||
<iframe
|
||||
className="earth-page-frame"
|
||||
src="/earth/index.html"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
border: "none",
|
||||
display: "block",
|
||||
}}
|
||||
title="3D Earth"
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default Earth;
|
||||
export default Earth
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
|
||||
|
||||
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
interface ErrorBody {
|
||||
response?: {
|
||||
@@ -10,15 +11,16 @@ interface ErrorBody {
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown): string {
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
|
||||
return '操作失败'
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
const [step, setStep] = useState<'request' | 'reset'>('request')
|
||||
const [email, setEmail] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
@@ -41,9 +43,9 @@ function ForgotPassword() {
|
||||
await axios.post(`${API_URL}/auth/forgot-password`, { email })
|
||||
setStep('reset')
|
||||
setCooldown(60)
|
||||
setFeedback({ tone: 'success', text: '若该邮箱已注册,验证码已发送。请到邮箱查收。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.recoveryCodeSent') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -58,9 +60,9 @@ function ForgotPassword() {
|
||||
setStep('request')
|
||||
setCode('')
|
||||
setNewPassword('')
|
||||
setFeedback({ tone: 'success', text: '密码已重置,请用新密码登录。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.passwordResetSuccess') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -71,38 +73,38 @@ function ForgotPassword() {
|
||||
try {
|
||||
await axios.post(`${API_URL}/auth/forgot-password`, { email })
|
||||
setCooldown(60)
|
||||
setFeedback({ tone: 'success', text: '验证码已重发。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="Account recovery" title="找回密码" description="通过邮箱验证码重置后台账号密码。">
|
||||
<AuthShell eyebrow={t('auth.accountRecovery')} title={t('auth.forgotPasswordTitle')} description={t('auth.forgotPasswordDescription')}>
|
||||
{step === 'request' ? (
|
||||
<AuthForm onSubmit={onRequest}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthField label="邮箱">
|
||||
<AuthField label={t('auth.email')}>
|
||||
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading}>发送验证码</AuthButton>
|
||||
<AuthButton type="submit" loading={loading}>{t('auth.sendCode')}</AuthButton>
|
||||
<AuthLinks><BackToLogin /></AuthLinks>
|
||||
</AuthForm>
|
||||
) : (
|
||||
<AuthForm onSubmit={onReset}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthNotice>验证码已发送至 <strong>{email}</strong>,10 分钟内有效。</AuthNotice>
|
||||
<AuthField label="验证码">
|
||||
<AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
|
||||
<AuthField label={t('auth.code')}>
|
||||
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
|
||||
</AuthField>
|
||||
<AuthField label="新密码">
|
||||
<AuthField label={t('auth.newPassword')}>
|
||||
<AuthInput value={newPassword} onChange={(event) => setNewPassword(event.target.value)} type="password" minLength={8} autoComplete="new-password" required />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading}>重置密码</AuthButton>
|
||||
<AuthButton type="submit" loading={loading}>{t('auth.resetPassword')}</AuthButton>
|
||||
<AuthLinks>
|
||||
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}>修改邮箱</button>
|
||||
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('request')}>{t('auth.updateEmail')}</button>
|
||||
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0} onClick={onResend}>
|
||||
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
|
||||
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
|
||||
</button>
|
||||
</AuthLinks>
|
||||
</AuthForm>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
@@ -10,6 +11,7 @@ interface LoginError {
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const { t } = useTranslation()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
@@ -23,38 +25,38 @@ function Login() {
|
||||
setFeedback(null)
|
||||
try {
|
||||
await login(username.trim(), password)
|
||||
setFeedback({ tone: 'success', text: '登录成功,正在进入控制台。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.loginSuccess') })
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (error: unknown) {
|
||||
const err = error as LoginError
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.code === 'EMAIL_NOT_VERIFIED') {
|
||||
setFeedback({ tone: 'warning', text: '邮箱未验证,请先完成邮箱验证。' })
|
||||
setFeedback({ tone: 'warning', text: t('auth.emailNotVerified') })
|
||||
const email = detail.email ? `?email=${encodeURIComponent(detail.email)}` : ''
|
||||
navigate(`/verify-email${email}`)
|
||||
return
|
||||
}
|
||||
const fallback = typeof detail === 'string' ? detail : detail?.message
|
||||
setFeedback({ tone: 'error', text: fallback || '登录失败,请检查账号或密码。' })
|
||||
setFeedback({ tone: 'error', text: fallback || t('auth.loginFailed') })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="Welcome back" title="登录 Planet 控制台" description="使用你的后台账号进入运维工作台。">
|
||||
<AuthShell eyebrow={t('auth.welcomeBack')} title={t('auth.loginTitle')} description={t('auth.loginDescription')}>
|
||||
<AuthForm onSubmit={onSubmit}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthField label="用户名">
|
||||
<AuthField label={t('auth.username')}>
|
||||
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} autoComplete="username" required autoFocus />
|
||||
</AuthField>
|
||||
<AuthField label="密码">
|
||||
<AuthField label={t('auth.password')}>
|
||||
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" autoComplete="current-password" required />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading}>登录</AuthButton>
|
||||
<AuthButton type="submit" loading={loading}>{t('auth.loginButton')}</AuthButton>
|
||||
<AuthLinks>
|
||||
<Link className="auth-link" to="/register">注册账户</Link>
|
||||
<Link className="auth-link" to="/forgot-password">忘记密码?</Link>
|
||||
<Link className="auth-link" to="/register">{t('auth.registerAccount')}</Link>
|
||||
<Link className="auth-link" to="/forgot-password">{t('auth.forgotPassword')}</Link>
|
||||
</AuthLinks>
|
||||
</AuthForm>
|
||||
</AuthShell>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell } from '../Auth/AuthShell'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
const RESEND_COOLDOWN_SECONDS = 60
|
||||
|
||||
interface ErrorBody {
|
||||
@@ -15,15 +16,16 @@ interface ErrorBody {
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown): string {
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
|
||||
return '操作失败'
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function Register() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [step, setStep] = useState<'register' | 'verify'>('register')
|
||||
const [username, setUsername] = useState('')
|
||||
@@ -49,9 +51,9 @@ function Register() {
|
||||
await axios.post(`${API_URL}/auth/register`, { username: username.trim(), email: email.trim(), password })
|
||||
setStep('verify')
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
setFeedback({ tone: 'success', text: '验证码已发送到邮箱。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.verificationSent') })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -68,7 +70,7 @@ function Register() {
|
||||
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -81,46 +83,46 @@ function Register() {
|
||||
try {
|
||||
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS)
|
||||
setFeedback({ tone: 'success', text: '验证码已重发。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
|
||||
} catch (error) {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="Create account" title={step === 'register' ? '注册账户' : '验证邮箱'} description="创建账号后需要完成邮箱验证,验证成功会自动进入控制台。">
|
||||
<AuthShell eyebrow={t('auth.createAccount')} title={step === 'register' ? t('auth.registerAccount') : t('auth.verifyEmail')} description={t('auth.registerDescription')}>
|
||||
{step === 'register' ? (
|
||||
<AuthForm onSubmit={onRegister}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthField label="用户名" hint="3-50 个字符">
|
||||
<AuthField label={t('auth.username')} hint={t('auth.usernameHint')}>
|
||||
<AuthInput value={username} onChange={(event) => setUsername(event.target.value)} minLength={3} maxLength={50} required autoComplete="username" />
|
||||
</AuthField>
|
||||
<AuthField label="邮箱">
|
||||
<AuthField label={t('auth.email')}>
|
||||
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" required autoComplete="email" />
|
||||
</AuthField>
|
||||
<AuthField label="密码" hint="至少 8 位">
|
||||
<AuthField label={t('auth.password')} hint={t('auth.passwordHint')}>
|
||||
<AuthInput value={password} onChange={(event) => setPassword(event.target.value)} type="password" minLength={8} required autoComplete="new-password" />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading}>注册</AuthButton>
|
||||
<AuthLinks><Link className="auth-link" to="/login">已有账号,去登录</Link></AuthLinks>
|
||||
<AuthButton type="submit" loading={loading}>{t('auth.register')}</AuthButton>
|
||||
<AuthLinks><Link className="auth-link" to="/login">{t('auth.alreadyHaveAccount')}</Link></AuthLinks>
|
||||
</AuthForm>
|
||||
) : (
|
||||
<AuthForm onSubmit={onVerify}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthNotice>已将 6 位验证码发送至 <strong>{email}</strong>,10 分钟内有效。</AuthNotice>
|
||||
<AuthField label="验证码">
|
||||
<AuthNotice><Trans i18nKey="auth.codeSent" values={{ email }} components={{ strong: <strong /> }} /></AuthNotice>
|
||||
<AuthField label={t('auth.code')}>
|
||||
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading}>验证并登录</AuthButton>
|
||||
<AuthButton type="submit" loading={loading}>{t('auth.verifyAndLogin')}</AuthButton>
|
||||
<AuthLinks>
|
||||
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}>修改邮箱</button>
|
||||
<button className="auth-link auth-link--button" type="button" onClick={() => setStep('register')}>{t('auth.updateEmail')}</button>
|
||||
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending} onClick={onResend}>
|
||||
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
|
||||
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
|
||||
</button>
|
||||
</AuthLinks>
|
||||
</AuthForm>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { AuthButton, AuthField, AuthForm, AuthInput, AuthLinks, AuthNotice, AuthShell, BackToLogin } from '../Auth/AuthShell'
|
||||
import { useAuthStore } from '../../stores/auth'
|
||||
|
||||
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
interface ErrorBody {
|
||||
response?: {
|
||||
@@ -12,15 +13,16 @@ interface ErrorBody {
|
||||
}
|
||||
}
|
||||
|
||||
function extractDetail(error: unknown): string {
|
||||
function extractDetail(error: unknown, fallback: string): string {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || '操作失败'
|
||||
return '操作失败'
|
||||
if (detail && typeof detail === 'object') return detail.message || detail.code || fallback
|
||||
return fallback
|
||||
}
|
||||
|
||||
function VerifyEmail() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [search] = useSearchParams()
|
||||
const [email, setEmail] = useState(search.get('email') || '')
|
||||
@@ -47,7 +49,7 @@ function VerifyEmail() {
|
||||
axios.defaults.headers.common.Authorization = `Bearer ${access_token}`
|
||||
navigate('/admin', { replace: true })
|
||||
} catch (error) {
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -60,32 +62,32 @@ function VerifyEmail() {
|
||||
try {
|
||||
await axios.post(`${API_URL}/auth/resend-code`, { email, purpose: 'register' })
|
||||
setCooldown(60)
|
||||
setFeedback({ tone: 'success', text: '验证码已重发。' })
|
||||
setFeedback({ tone: 'success', text: t('auth.resendSuccess') })
|
||||
} catch (error) {
|
||||
const err = error as ErrorBody
|
||||
const detail = err.response?.data?.detail
|
||||
if (detail && typeof detail === 'object' && detail.retry_after_seconds) setCooldown(detail.retry_after_seconds)
|
||||
setFeedback({ tone: 'error', text: extractDetail(error) })
|
||||
setFeedback({ tone: 'error', text: extractDetail(error, t('common.operationFailed')) })
|
||||
} finally {
|
||||
setResending(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthShell eyebrow="Email verification" title="验证邮箱" description="输入邮箱验证码后会自动登录并进入控制台。">
|
||||
<AuthShell eyebrow={t('auth.emailVerification')} title={t('auth.verifyEmail')} description={t('auth.verifyEmailDescription')}>
|
||||
<AuthForm onSubmit={onVerify}>
|
||||
{feedback ? <AuthNotice tone={feedback.tone}>{feedback.text}</AuthNotice> : null}
|
||||
<AuthField label="邮箱">
|
||||
<AuthField label={t('auth.email')}>
|
||||
<AuthInput value={email} onChange={(event) => setEmail(event.target.value)} type="email" autoComplete="email" required />
|
||||
</AuthField>
|
||||
<AuthField label="验证码">
|
||||
<AuthField label={t('auth.code')}>
|
||||
<AuthInput value={code} onChange={(event) => setCode(event.target.value)} maxLength={6} inputMode="numeric" required />
|
||||
</AuthField>
|
||||
<AuthButton type="submit" loading={loading} disabled={!email}>验证并登录</AuthButton>
|
||||
<AuthButton type="submit" loading={loading} disabled={!email}>{t('auth.verifyAndLogin')}</AuthButton>
|
||||
<AuthLinks>
|
||||
<BackToLogin />
|
||||
<button className="auth-link auth-link--button" type="button" disabled={cooldown > 0 || resending || !email} onClick={onResend}>
|
||||
{cooldown > 0 ? `重发 (${cooldown}s)` : '重新发送验证码'}
|
||||
{cooldown > 0 ? t('auth.resendCountdown', { seconds: cooldown }) : t('auth.resend')}
|
||||
</button>
|
||||
</AuthLinks>
|
||||
</AuthForm>
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
Summary,
|
||||
} from './types'
|
||||
|
||||
const API_BASE_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
export class HttpSituationalAwarenessGateway implements SituationalAwarenessGateway {
|
||||
private bgpSummaryPromise: Promise<BGPSummarySnapshot> | null = null
|
||||
|
||||
@@ -17,7 +17,7 @@ interface AuthState {
|
||||
clearAuth: () => void
|
||||
}
|
||||
|
||||
const API_URL = (import.meta as any).env?.VITE_API_URL || '/api/v1'
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api/v1'
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
|
||||
9
frontend/src/vite-env.d.ts
vendored
9
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,10 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL?: string
|
||||
readonly VITE_WS_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
@@ -25,39 +25,43 @@
|
||||
|
||||
## Technology Stack
|
||||
|
||||
Current implementation truth lives in `README.md`, `CODEMAP.md`, and
|
||||
`docs/technical/{zh,en}/`. This file is background context; when it conflicts
|
||||
with current code or harness checks, prefer the current implementation docs.
|
||||
|
||||
### Backend
|
||||
- **Framework:** Python FastAPI 0.109+
|
||||
- **ORM:** SQLAlchemy 2.0+
|
||||
- **Database Migration:** Alembic
|
||||
- **Task Queue:** Celery 5.3+
|
||||
- **Message Queue:** Kafka 3.0+
|
||||
- **Caching:** Redis 7.0+
|
||||
- **Database Migration:** Alembic / SQLAlchemy migrations
|
||||
- **Task Runtime:** APScheduler, backend background tasks, and database job state
|
||||
- **Runtime Coordination:** Redis 7.0+ cache/streams
|
||||
- **Package Manager / Runner:** uv
|
||||
|
||||
### Frontend
|
||||
- **Framework:** React 18
|
||||
- **UI Library:** Ant Design Pro
|
||||
- **UI Layer:** Tactile UI, Radix primitives, lucide-react
|
||||
- **HTTP Client:** Axios
|
||||
- **State Management:** React Query
|
||||
- **State Management:** Zustand and local React state
|
||||
- **Real-time:** Socket.io-client
|
||||
- **Charts:** ECharts
|
||||
- **Package Manager / Runner:** Bun 1
|
||||
- **3D Earth:** Three.js Web Earth
|
||||
|
||||
### Visualization (UE5)
|
||||
- **Engine:** Unreal Engine 5.3+
|
||||
- **Geospatial:** Cesium for Unreal 1.5+
|
||||
- **Particles:** Niagara
|
||||
- **3D Rendering:** Nanite + Lumen
|
||||
### Visualization Direction
|
||||
- **Current:** Browser Web Earth with Three.js assets under `frontend/public/earth/`
|
||||
- **Future / optional:** UE5, Cesium for Unreal, and Niagara remain physical
|
||||
big-screen exploration directions, not required local development dependencies.
|
||||
|
||||
### Database
|
||||
- **Relational:** PostgreSQL 15+ (users, config)
|
||||
- **Time-series:** TimescaleDB
|
||||
- **Cache:** Redis 7+ (sessions, cache)
|
||||
- **Storage:** MinIO (S3-compatible)
|
||||
- **Cache / Streams:** Redis 7+ (sessions, cache, runtime events)
|
||||
- **Future / optional:** TimescaleDB, MinIO, Kafka, and Celery are evolution
|
||||
boundaries only; do not assume they exist in the active local stack.
|
||||
|
||||
### Deployment
|
||||
- **Container:** Docker 24+
|
||||
- **Orchestration:** Docker Compose
|
||||
- **Reverse Proxy:** Nginx
|
||||
- **Packaging:** Helm chart under `deploy/helm/planet/`
|
||||
|
||||
---
|
||||
|
||||
@@ -71,7 +75,7 @@
|
||||
│ │ │ │ ├── auth.py
|
||||
│ │ │ │ ├── users.py
|
||||
│ │ │ │ ├── datasources.py
|
||||
│ │ │ │ ├── tasks.py
|
||||
│ │ │ │ ├── docs.py
|
||||
│ │ │ │ └── websocket.py
|
||||
│ │ │ └── endpoints/
|
||||
│ │ ├── core/
|
||||
@@ -93,50 +97,43 @@
|
||||
│ │ │ │ └── huggingface.py
|
||||
│ │ │ └── analysis.py
|
||||
│ │ ├── tasks/
|
||||
│ │ │ └── scheduler.py
|
||||
│ │ ├── services/
|
||||
│ │ │ ├── collectors/
|
||||
│ │ │ └── docs_gatekeeper.py
|
||||
│ │ └── db/
|
||||
│ │ └── session.py
|
||||
│ │ └── database.py
|
||||
│ ├── tests/
|
||||
│ │ ├── api/
|
||||
│ │ ├── unit/
|
||||
│ │ └── conftest.py
|
||||
│ ├── pyproject.toml
|
||||
│ ├── uv.lock
|
||||
│ │ ├── test_api.py
|
||||
│ │ └── test_realtime_sources.py
|
||||
│ └── alembic/
|
||||
│
|
||||
├── frontend/ # React Admin
|
||||
├── frontend/ # React Admin, Docs UI, and Web Earth shell
|
||||
│ ├── src/
|
||||
│ │ ├── api/ # Axios instances
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── pages/ # Route pages
|
||||
│ │ ├── admin/ # Console routes, pages, Tactile/Radix wrappers
|
||||
│ │ ├── components/ # Reusable UI and Web Earth components
|
||||
│ │ ├── pages/ # Public route pages
|
||||
│ │ │ ├── Login/
|
||||
│ │ │ ├── Dashboard/
|
||||
│ │ │ ├── Users/
|
||||
│ │ │ ├── DataSources/
|
||||
│ │ │ └── Settings/
|
||||
│ │ ├── stores/ # Zustand/Redux
|
||||
│ │ │ ├── Register/
|
||||
│ │ │ ├── Docs/
|
||||
│ │ │ └── Earth/
|
||||
│ │ ├── stores/ # Zustand stores
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── types/
|
||||
│ │ └── App.tsx
|
||||
│ ├── package.json
|
||||
│ └── tests/
|
||||
│ └── public/earth/ # Web Earth static assets
|
||||
│
|
||||
├── unreal/ # UE5 Project
|
||||
│ ├── Content/
|
||||
│ │ ├── Maps/
|
||||
│ │ ├── Blueprints/
|
||||
│ │ └── Materials/
|
||||
│ ├── Plugins/
|
||||
│ └── Source/
|
||||
│
|
||||
├── data/ # Static data files
|
||||
├── aiprovider/ # Model provider/protocol adapter service
|
||||
├── motion_agent/ # Motion capture protocol service
|
||||
├── deploy/helm/planet/ # Helm chart
|
||||
├── docs/ # Documentation
|
||||
├── scripts/ # Utility scripts
|
||||
├── scripts/ # Utility and harness scripts
|
||||
├── docker-compose.yml
|
||||
├── pyproject.toml
|
||||
├── uv.lock
|
||||
├── .env.example
|
||||
├── rules.md
|
||||
├── project_context.md
|
||||
├── agents.md
|
||||
├── AGENTS.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "planet"
|
||||
version = "0.71.1"
|
||||
version = "0.73.0"
|
||||
description = "智能星球计划 - 态势感知系统"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
|
||||
60
rules.md
60
rules.md
@@ -31,6 +31,24 @@ Load selectively:
|
||||
|
||||
Do not load the entire file by default for small tasks. Use `rg -n "^## Module:" rules.md` to find module boundaries, then read only the needed block.
|
||||
|
||||
### Agent Discovery Index
|
||||
|
||||
Use these shortcuts when the user describes work in product language instead of
|
||||
module names. Matching one of these phrases means the related module is
|
||||
relevant, even if the exact module name is not mentioned.
|
||||
|
||||
| User wording / touched surface | Load modules |
|
||||
| --- | --- |
|
||||
| `一屏`, `首屏`, `高度没控住`, page grows past viewport, scroll/overflow, spacing/breathing, responsive, accessibility | `uiux`, `frontend` |
|
||||
| Admin console, 控制台, sidebar/menu/search/tabs/dialog/table, theme/language switch, i18n, auth pages, public Docs UI | `frontend`, plus `uiux` for rendered/layout changes |
|
||||
| Docs, 文档, 中英文, public Docs, manual, quickstart, README, plan status, stale route/section links | `docs`, plus the implementation module for the changed behavior |
|
||||
| API, FastAPI, database, SQLAlchemy, collector, datasource, scheduler, credential/connectivity, pagination, slow endpoint | `backend` |
|
||||
| Earth/地球, 3D/canvas/Three.js, BGP/vessel/satellite/cable, terrain/clouds, marker/icon, hover/lock/tooltip, flicker/z-fighting | `earth`, plus `uiux` for visible controls |
|
||||
| AI Provider, model provider, Playground, prompt, mapping, custom collector, base URL, service token, OCR/WebSearch/Tavily integration | `ai`, plus `security` for credentials |
|
||||
| Version/changelog/history/tag/release, `发版`, `发布`, `打包`, `版本号`, commit/push release work | `release`, `workflow` |
|
||||
| Secrets, `.env`, API key, token, password, credential masking, auth/JWT/logout, logs containing sensitive data | `security`, plus touched implementation module |
|
||||
| Dependency/package-manager/tooling, Bun/uv, npm/pnpm/yarn, lockfiles, dirty worktree, generated output | `workflow` |
|
||||
|
||||
---
|
||||
|
||||
## Module: core
|
||||
@@ -174,6 +192,10 @@ rg -n "<pattern>" <path>
|
||||
### Load When
|
||||
|
||||
Writing, translating, linking, restructuring, or publishing docs.
|
||||
Also load this module when the user mentions docs in Chinese or product terms:
|
||||
`文档`, `中英文`, `双语`, `public Docs`, `手册`, `quickstart`,
|
||||
`README`, `计划`, `plan`, `过期说明`, `路由文档`, `section 链接`,
|
||||
or asks to make rules easier to discover.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -230,6 +252,27 @@ PY
|
||||
### Load When
|
||||
|
||||
Changing layout, visual hierarchy, controls, interaction states, responsive behavior, or accessibility.
|
||||
Also load this module when the user mentions Chinese layout terms such as
|
||||
`一屏`, `首屏`, `高度没控住`, `页面撑出`, `滚动`, `溢出`, `呼吸感`,
|
||||
`间距`, or `响应式`.
|
||||
|
||||
### Hard Rule: Admin One-Screen Workspaces
|
||||
|
||||
Backend/admin pages are compact single-screen workspaces (`一屏` / `首屏`):
|
||||
|
||||
- The route shell must resolve to the viewport through the existing
|
||||
`html`, `body`, `#root`, route-root, and page-shell `height: 100%` chain.
|
||||
- Intermediate shell nodes such as theme providers must not break the height
|
||||
chain; they need `height: 100%`, `min-height: 0`, and explicit overflow
|
||||
ownership when they wrap the page shell.
|
||||
- Header, summary/controls, main work area, and fixed sidebar account/actions
|
||||
must remain inside the first viewport on common desktop sizes.
|
||||
- Long menus, tables, detail panels, logs, Markdown, JSON, and forms scroll
|
||||
inside their intended region; the document/body/root must not become the
|
||||
scroll owner.
|
||||
- A build is not enough for height-critical changes. Use rendered validation
|
||||
on the affected admin routes, and include 125% / 150% zoom when the change
|
||||
touches shell, sidebar, panel, table, or scroll ownership.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -276,6 +319,10 @@ changed=$(git diff --name-only HEAD -- frontend/src)
|
||||
### Load When
|
||||
|
||||
Editing React, TypeScript, CSS, Vite, Bun, admin console, public Docs UI, or client-side services.
|
||||
Also load this module when the user mentions `控制台`, `Admin`, `Docs 页面`,
|
||||
`登录/注册/忘记密码`, `i18n`, `语言切换`, `主题切换`, `搜索`, `菜单`,
|
||||
`Tab`, `弹窗`, `表格`, `Markdown`, frontend build, or any file under
|
||||
`frontend/src`.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -318,6 +365,9 @@ rg -n "npm|pnpm|yarn" frontend package.json
|
||||
### Load When
|
||||
|
||||
Editing FastAPI, SQLAlchemy, collectors, database models, migrations, services, API routes, or performance-sensitive code.
|
||||
Also load this module when the user mentions `后端`, `接口`, `API`, `数据库`,
|
||||
`迁移`, `采集器`, `数据源`, `凭证`, `连通性`, `调度`, `分页`, `性能`,
|
||||
`慢查询`, `国家/地区`, or files under `backend/`.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -371,6 +421,10 @@ rg -n "normalize_country|COUNTRY_ENTRIES" backend/app
|
||||
### Load When
|
||||
|
||||
Editing `frontend/public/earth`, 3D Earth, canvas/Three.js rendering, BGP/vessel/satellite/cable layers, geographic boundaries, or Earth marker icons.
|
||||
Also load this module when the user mentions `地球`, `三维`, `图层`, `船只`,
|
||||
`卫星`, `海缆`, `BGP`, `算力中心`, `云图`, `地形`, `marker`, `图标`,
|
||||
`hover`, `locked`, `tooltip`, `闪烁`, `黑块`, `雪花`, `z-fighting`,
|
||||
or files under `frontend/public/earth`.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -444,6 +498,9 @@ ls frontend/public/earth/assets/icons/
|
||||
### Load When
|
||||
|
||||
Editing AI Provider, LLM gateway, AI Playground, prompt templates, model selection, custom collector mapping generation, or LLM-assisted data transformation.
|
||||
Also load this module when the user mentions `AI Provider`, `模型供应商`,
|
||||
`Playground`, `提示词`, `prompt`, `mapping`, `自定义采集器`, `base_url`,
|
||||
`service_token`, `OCR`, `WebSearch`, `Tavily`, or provider credentials.
|
||||
|
||||
### Must
|
||||
|
||||
@@ -472,6 +529,9 @@ git diff --check -- backend aiprovider frontend/src
|
||||
### Load When
|
||||
|
||||
The user asks to `发版`, bump version, release, commit/push release work, or update changelog/version history as part of a release.
|
||||
Also load this module when the user mentions `发布`, `打包`, `版本号`,
|
||||
`CHANGELOG`, `version-history`, tag, release branch, or asks to commit/push a
|
||||
release-oriented change.
|
||||
|
||||
### Must
|
||||
|
||||
|
||||
60
scripts/harness/backend-rules-check.sh
Executable file
60
scripts/harness/backend-rules-check.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
local uv_bin
|
||||
uv_bin="$(harness_require_tool uv)"
|
||||
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python - <<'PY'
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
root = Path.cwd()
|
||||
failures: list[str] = []
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
class BackendDebugCallVisitor(ast.NodeVisitor):
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = path
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
if isinstance(node.func, ast.Name) and node.func.id in {"print", "breakpoint"}:
|
||||
fail(f"{self.path}:{node.lineno}: backend app code must use structured logging, not {node.func.id}()")
|
||||
if (
|
||||
isinstance(node.func, ast.Attribute)
|
||||
and node.func.attr == "set_trace"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "pdb"
|
||||
):
|
||||
fail(f"{self.path}:{node.lineno}: remove pdb.set_trace() from backend app code")
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
for path in sorted((root / "backend/app").rglob("*.py")):
|
||||
rel = path.relative_to(root)
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(rel))
|
||||
except SyntaxError as exc:
|
||||
fail(f"{rel}:{exc.lineno}: Python syntax error: {exc.msg}")
|
||||
continue
|
||||
BackendDebugCallVisitor(rel).visit(tree)
|
||||
|
||||
if failures:
|
||||
for message in failures:
|
||||
print(f"fail: {message}")
|
||||
raise SystemExit(f"backend rules check failed: {len(failures)} failure(s)")
|
||||
|
||||
print("backend rules check passed")
|
||||
PY
|
||||
}
|
||||
|
||||
main "$@"
|
||||
645
scripts/harness/docs-consistency-check.sh
Executable file
645
scripts/harness/docs-consistency-check.sh
Executable file
@@ -0,0 +1,645 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
local uv_bin
|
||||
uv_bin="$(harness_require_tool uv)"
|
||||
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python - <<'PY'
|
||||
import ast
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
root = Path.cwd()
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
warnings.append(message)
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
return (root / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def literal_assignment(path: str, name: str) -> object:
|
||||
tree = ast.parse(read_text(path))
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign):
|
||||
continue
|
||||
if not any(isinstance(target, ast.Name) and target.id == name for target in node.targets):
|
||||
continue
|
||||
return ast.literal_eval(node.value)
|
||||
raise ValueError(f"could not find literal assignment {name} in {path}")
|
||||
|
||||
|
||||
def slug_from_filename(filename: str) -> str:
|
||||
return "overview" if filename == "README.md" else filename.removesuffix(".md")
|
||||
|
||||
|
||||
def frontend_docs_metadata() -> dict[str, dict[str, object]]:
|
||||
text = read_text("frontend/src/pages/Docs/docs-content.ts")
|
||||
entries: dict[str, dict[str, object]] = {}
|
||||
pattern = re.compile(
|
||||
r"(?:\[(?P<constant>DOCS_README_FILENAME)\]|'(?P<filename>[^']+\.md)'):\s*\{\s*"
|
||||
r"zh:\s*\{\s*title:\s*'(?P<zh_title>[^']+)',\s*group:\s*'(?P<zh_group>[^']+)',\s*order:\s*(?P<zh_order>\d+)\s*\},\s*"
|
||||
r"en:\s*\{\s*title:\s*'(?P<en_title>[^']+)',\s*group:\s*'(?P<en_group>[^']+)',\s*order:\s*(?P<en_order>\d+)\s*\}",
|
||||
re.S,
|
||||
)
|
||||
for match in pattern.finditer(text):
|
||||
filename = "README.md" if match.group("constant") else match.group("filename")
|
||||
if filename in entries:
|
||||
fail(f"frontend Docs metadata registers {filename} more than once")
|
||||
continue
|
||||
entries[filename] = {
|
||||
"slug": slug_from_filename(filename),
|
||||
"zh_title": match.group("zh_title"),
|
||||
"en_title": match.group("en_title"),
|
||||
"zh_group": match.group("zh_group"),
|
||||
"en_group": match.group("en_group"),
|
||||
"zh_order": int(match.group("zh_order")),
|
||||
"en_order": int(match.group("en_order")),
|
||||
}
|
||||
return entries
|
||||
|
||||
|
||||
def backend_docs_metadata() -> dict[str, dict[str, object]]:
|
||||
tree = ast.parse(read_text("backend/app/services/docs_gatekeeper.py"))
|
||||
constants: dict[str, object] = {}
|
||||
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
||||
continue
|
||||
target = node.targets[0]
|
||||
if isinstance(target, ast.Name) and isinstance(node.value, ast.Constant):
|
||||
constants[target.id] = node.value.value
|
||||
|
||||
def read_arg(node: ast.AST) -> object:
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name) and node.id in constants:
|
||||
return constants[node.id]
|
||||
raise ValueError(f"unsupported DocsMetadata argument: {ast.dump(node)}")
|
||||
|
||||
entries: dict[str, dict[str, object]] = {}
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
if not isinstance(node.func, ast.Name) or node.func.id != "DocsMetadata":
|
||||
continue
|
||||
if len(node.args) != 7:
|
||||
fail("backend DocsMetadata entries must keep the expected seven positional fields")
|
||||
continue
|
||||
filename, slug, access, group, order, zh_title, en_title = [read_arg(arg) for arg in node.args]
|
||||
if not isinstance(filename, str):
|
||||
fail("backend DocsMetadata filename must be a string")
|
||||
continue
|
||||
if filename in entries:
|
||||
fail(f"backend Docs metadata registers {filename} more than once")
|
||||
continue
|
||||
entries[filename] = {
|
||||
"slug": slug,
|
||||
"access": access,
|
||||
"group": group,
|
||||
"order": order,
|
||||
"zh_title": zh_title,
|
||||
"en_title": en_title,
|
||||
}
|
||||
return entries
|
||||
|
||||
|
||||
def docs_metadata_filenames() -> set[str]:
|
||||
return set(frontend_docs_metadata())
|
||||
|
||||
|
||||
def check_docs_metadata_alignment() -> None:
|
||||
frontend = frontend_docs_metadata()
|
||||
backend = backend_docs_metadata()
|
||||
|
||||
if not frontend:
|
||||
fail("frontend Docs metadata must expose at least one document")
|
||||
if not backend:
|
||||
fail("backend Gatekeeper Docs metadata must expose at least one document")
|
||||
|
||||
frontend_only = sorted(set(frontend) - set(backend))
|
||||
backend_only = sorted(set(backend) - set(frontend))
|
||||
if frontend_only:
|
||||
fail("frontend Docs metadata has files missing from backend Gatekeeper metadata: " + ", ".join(frontend_only))
|
||||
if backend_only:
|
||||
fail("backend Gatekeeper metadata has files missing from frontend Docs metadata: " + ", ".join(backend_only))
|
||||
|
||||
for filename in sorted(set(frontend) & set(backend)):
|
||||
frontend_entry = frontend[filename]
|
||||
backend_entry = backend[filename]
|
||||
if frontend_entry["zh_group"] != frontend_entry["en_group"]:
|
||||
fail(f"frontend Docs metadata group differs by language for {filename}")
|
||||
if frontend_entry["zh_order"] != frontend_entry["en_order"]:
|
||||
fail(f"frontend Docs metadata order differs by language for {filename}")
|
||||
|
||||
expected = {
|
||||
"slug": frontend_entry["slug"],
|
||||
"group": frontend_entry["zh_group"],
|
||||
"order": frontend_entry["zh_order"],
|
||||
"zh_title": frontend_entry["zh_title"],
|
||||
"en_title": frontend_entry["en_title"],
|
||||
}
|
||||
actual = {key: backend_entry[key] for key in expected}
|
||||
if actual != expected:
|
||||
fail(f"{filename}: frontend Docs metadata and backend Gatekeeper metadata differ; frontend={expected}, backend={actual}")
|
||||
|
||||
|
||||
def check_public_docs_registry() -> None:
|
||||
filenames = docs_metadata_filenames()
|
||||
for filename in sorted(filenames):
|
||||
for lang in ("zh", "en"):
|
||||
path = root / "docs/technical" / lang / filename
|
||||
if not path.exists():
|
||||
fail(f"public docs metadata references missing file: {path.relative_to(root)}")
|
||||
|
||||
for readme in (root / "docs/technical/zh/README.md", root / "docs/technical/en/README.md"):
|
||||
if not readme.exists():
|
||||
continue
|
||||
for href in re.findall(r"\]\(([^)]+\.md)\)", readme.read_text(encoding="utf-8")):
|
||||
if "docs/technical/" not in href:
|
||||
continue
|
||||
filename = Path(href).name
|
||||
if filename not in filenames:
|
||||
fail(f"{readme.relative_to(root)} links {filename}, but DOCS_METADATA does not expose it")
|
||||
|
||||
|
||||
def check_all_technical_docs_have_bilingual_pairs() -> None:
|
||||
zh_dir = root / "docs/technical/zh"
|
||||
en_dir = root / "docs/technical/en"
|
||||
zh_files = {path.name for path in zh_dir.glob("*.md")}
|
||||
en_files = {path.name for path in en_dir.glob("*.md")}
|
||||
|
||||
zh_only = sorted(zh_files - en_files)
|
||||
en_only = sorted(en_files - zh_files)
|
||||
if zh_only:
|
||||
fail("technical docs missing English counterparts: " + ", ".join(zh_only))
|
||||
if en_only:
|
||||
fail("technical docs missing Chinese counterparts: " + ", ".join(en_only))
|
||||
|
||||
|
||||
def check_bilingual_docs_are_not_copies() -> None:
|
||||
same: list[str] = []
|
||||
for en in sorted((root / "docs/technical/en").glob("*.md")):
|
||||
zh = root / "docs/technical/zh" / en.name
|
||||
if zh.exists() and en.read_text(encoding="utf-8") == zh.read_text(encoding="utf-8"):
|
||||
same.append(en.name)
|
||||
if same:
|
||||
fail("identical en/zh technical docs: " + ", ".join(same))
|
||||
|
||||
|
||||
def check_public_doc_links_exist() -> None:
|
||||
repo_prefix = f"{root}/"
|
||||
for doc in sorted((root / "docs/technical").glob("*/*.md")):
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for href in re.findall(r"\]\(([^)#]+\.md)(?:#[^)]+)?\)", text):
|
||||
if not href.startswith(repo_prefix):
|
||||
continue
|
||||
target = Path(href)
|
||||
if not target.exists():
|
||||
fail(f"{doc.relative_to(root)} links missing markdown file: {href}")
|
||||
|
||||
|
||||
def check_language_scoped_technical_links() -> None:
|
||||
technical_docs_prefix = re.escape(str(root / "docs/technical"))
|
||||
pattern = re.compile(rf"{technical_docs_prefix}/(?!zh/|en/)[^)#\s]+")
|
||||
for doc in sorted((root / "docs/technical").glob("*/*.md")):
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for match in pattern.finditer(text):
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
fail(
|
||||
f"{doc.relative_to(root)}:{line_no}: technical doc link must include "
|
||||
f"the language directory: {match.group(0)}"
|
||||
)
|
||||
|
||||
|
||||
def check_public_doc_link_titles() -> None:
|
||||
pattern = re.compile(r"\[([^]\n]+\.md)\]\(")
|
||||
for doc in sorted((root / "docs/technical").glob("*/*.md")):
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for match in pattern.finditer(text):
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
fail(
|
||||
f"{doc.relative_to(root)}:{line_no}: public docs should use readable "
|
||||
f"link text instead of raw filename {match.group(1)!r}"
|
||||
)
|
||||
|
||||
|
||||
def check_credential_collector_contracts() -> None:
|
||||
defaults = literal_assignment("backend/app/core/datasource_defaults.py", "DEFAULT_DATASOURCES")
|
||||
if not isinstance(defaults, dict):
|
||||
fail("backend/app/core/datasource_defaults.py DEFAULT_DATASOURCES must stay a dict")
|
||||
return
|
||||
|
||||
supported_sources: dict[str, str] = {}
|
||||
for source, info in defaults.items():
|
||||
if not isinstance(info, dict):
|
||||
fail(f"DEFAULT_DATASOURCES entry {source!r} must be a dict")
|
||||
continue
|
||||
if not info.get("requires_credentials"):
|
||||
continue
|
||||
if info.get("credential_status") != "supported":
|
||||
continue
|
||||
provider = info.get("credential_provider")
|
||||
if not isinstance(provider, str) or not provider.strip():
|
||||
fail(f"{source}: supported credential collector is missing credential_provider")
|
||||
continue
|
||||
supported_sources[str(source)] = provider
|
||||
|
||||
if not supported_sources:
|
||||
return
|
||||
|
||||
guides_text = read_text("backend/app/services/credential_guides.py")
|
||||
default_guides = set(re.findall(r"CredentialGuideDefault\(\s*provider=\"([^\"]+)\"", guides_text))
|
||||
connectivity_providers = literal_assignment(
|
||||
"backend/app/services/datasource_connectivity.py",
|
||||
"SUPPORTED_CREDENTIAL_PROVIDERS",
|
||||
)
|
||||
if not isinstance(connectivity_providers, set):
|
||||
fail("SUPPORTED_CREDENTIAL_PROVIDERS must stay a literal set")
|
||||
connectivity_providers = set()
|
||||
|
||||
frontend_text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
|
||||
tests_text = read_text("backend/tests/test_collectors.py")
|
||||
zh_doc = read_text("docs/technical/zh/datasource-collector-settings-connectivity.md")
|
||||
en_doc = read_text("docs/technical/en/datasource-collector-settings-connectivity.md")
|
||||
|
||||
if "test_supported_credential_collectors_have_guides_and_connectivity_provider" not in tests_text:
|
||||
fail("backend/tests/test_collectors.py must keep the supported credential collector contract test")
|
||||
|
||||
if "loadCredentialGuide" not in frontend_text or "credentialGuideProvider" not in frontend_text:
|
||||
fail("collector credential UI must keep guide-loading and provider-normalization helpers")
|
||||
|
||||
for source, provider in sorted(supported_sources.items()):
|
||||
if provider not in default_guides:
|
||||
fail(f"{source}: missing default credential guide for provider {provider}")
|
||||
if provider not in connectivity_providers:
|
||||
fail(f"{source}: missing supported connectivity provider {provider}")
|
||||
if provider not in frontend_text and source not in frontend_text:
|
||||
fail(f"{source}: collector credential UI does not mention provider/source {provider}")
|
||||
for doc_path, doc_text in (
|
||||
("docs/technical/zh/datasource-collector-settings-connectivity.md", zh_doc),
|
||||
("docs/technical/en/datasource-collector-settings-connectivity.md", en_doc),
|
||||
):
|
||||
if provider not in doc_text and source not in doc_text:
|
||||
fail(f"{doc_path}: missing supported credential collector provider/source {provider}/{source}")
|
||||
|
||||
|
||||
def known_frontend_routes() -> set[str]:
|
||||
app_text = read_text("frontend/src/App.tsx")
|
||||
admin_text = read_text("frontend/src/admin/AdminRoutes.tsx")
|
||||
manifest_text = read_text("frontend/src/admin/routes/manifest.tsx")
|
||||
routes = set(re.findall(r'<Route\s+path="([^"*][^"]*)"', app_text))
|
||||
routes.update(re.findall(r'<Route\s+path="([^"*][^"]*)"', admin_text))
|
||||
routes.update(re.findall(r"path:\s*'([^']+)'", manifest_text))
|
||||
routes.update({"/", "/playground", "/docs/:slug"})
|
||||
routes.discard("*")
|
||||
return routes
|
||||
|
||||
|
||||
def admin_manifest_routes() -> dict[str, str]:
|
||||
manifest_text = read_text("frontend/src/admin/routes/manifest.tsx")
|
||||
routes: dict[str, str] = {}
|
||||
pattern = re.compile(r"\{\s*path:\s*'([^']+)',\s*label:\s*'([^']+)'", re.S)
|
||||
for path, label in pattern.findall(manifest_text):
|
||||
routes[path] = label
|
||||
if not routes:
|
||||
fail("could not parse admin route manifest for manual coverage checks")
|
||||
return routes
|
||||
|
||||
|
||||
def manual_console_route_rows(doc_path: Path, heading: str) -> dict[str, str]:
|
||||
text = doc_path.read_text(encoding="utf-8")
|
||||
heading_index = text.find(heading)
|
||||
if heading_index == -1:
|
||||
fail(f"{doc_path.relative_to(root)} is missing {heading!r}")
|
||||
return {}
|
||||
next_heading = re.search(r"\n##\s+", text[heading_index + len(heading):])
|
||||
section = text[heading_index:] if not next_heading else text[heading_index:heading_index + len(heading) + next_heading.start()]
|
||||
rows: dict[str, str] = {}
|
||||
for line in section.splitlines():
|
||||
match = re.match(r"\|\s*([^|`][^|]*?)\s*\|\s*`([^`]+)`\s*\|", line)
|
||||
if not match:
|
||||
continue
|
||||
label = match.group(1).strip()
|
||||
path = match.group(2).strip()
|
||||
rows[path] = label
|
||||
return rows
|
||||
|
||||
|
||||
def normalize_zh_label(label: str) -> str:
|
||||
return re.sub(r"\s+", "", label)
|
||||
|
||||
|
||||
def check_manual_console_route_tables() -> None:
|
||||
manifest = admin_manifest_routes()
|
||||
zh_manual = root / "docs/technical/zh/manual.md"
|
||||
en_manual = root / "docs/technical/en/manual.md"
|
||||
manual_rows = {
|
||||
"zh": manual_console_route_rows(zh_manual, "## 控制台总览"),
|
||||
"en": manual_console_route_rows(en_manual, "## Console Overview"),
|
||||
}
|
||||
|
||||
for lang, rows in manual_rows.items():
|
||||
missing = sorted(set(manifest) - set(rows))
|
||||
extra = sorted(set(rows) - set(manifest))
|
||||
if missing:
|
||||
fail(f"docs/technical/{lang}/manual.md console overview misses admin manifest route(s): " + ", ".join(missing))
|
||||
if extra:
|
||||
fail(f"docs/technical/{lang}/manual.md console overview lists route(s) missing from admin manifest: " + ", ".join(extra))
|
||||
|
||||
zh_rows = manual_rows["zh"]
|
||||
for path, expected_label in manifest.items():
|
||||
actual = zh_rows.get(path)
|
||||
if actual is None:
|
||||
continue
|
||||
if normalize_zh_label(actual) != normalize_zh_label(expected_label):
|
||||
fail(
|
||||
"docs/technical/zh/manual.md console overview label mismatch for "
|
||||
f"{path}: expected {expected_label!r}, got {actual!r}"
|
||||
)
|
||||
|
||||
|
||||
def extract_balanced(text: str, start: int, open_char: str, close_char: str) -> str:
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
escape = False
|
||||
for index in range(start, len(text)):
|
||||
char = text[index]
|
||||
if quote:
|
||||
if escape:
|
||||
escape = False
|
||||
elif char == "\\":
|
||||
escape = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
continue
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
continue
|
||||
if char == open_char:
|
||||
depth += 1
|
||||
elif char == close_char:
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return text[start:index + 1]
|
||||
raise ValueError(f"could not find balanced {open_char}{close_char} block")
|
||||
|
||||
|
||||
def find_balanced_after(text: str, marker: str, open_char: str, close_char: str) -> str:
|
||||
marker_index = text.index(marker)
|
||||
start = text.index(open_char, marker_index)
|
||||
return extract_balanced(text, start, open_char, close_char)
|
||||
|
||||
|
||||
def nesting_depth(text: str, stop: int, open_char: str, close_char: str) -> int:
|
||||
depth = 0
|
||||
quote: str | None = None
|
||||
escape = False
|
||||
for char in text[:stop]:
|
||||
if quote:
|
||||
if escape:
|
||||
escape = False
|
||||
elif char == "\\":
|
||||
escape = True
|
||||
elif char == quote:
|
||||
quote = None
|
||||
continue
|
||||
if char in ("'", '"', "`"):
|
||||
quote = char
|
||||
continue
|
||||
if char == open_char:
|
||||
depth += 1
|
||||
elif char == close_char:
|
||||
depth -= 1
|
||||
return depth
|
||||
|
||||
|
||||
def top_level_config_blocks(configs_block: str) -> dict[str, str]:
|
||||
blocks: dict[str, str] = {}
|
||||
index = 1
|
||||
while index < len(configs_block) - 1:
|
||||
match = re.search(r"\b([A-Za-z][A-Za-z0-9_]*)\s*:\s*\{", configs_block[index:])
|
||||
if not match:
|
||||
break
|
||||
name = match.group(1)
|
||||
start = index + match.end() - 1
|
||||
if nesting_depth(configs_block, start, "{", "}") != 1:
|
||||
index = start + 1
|
||||
continue
|
||||
block = extract_balanced(configs_block, start, "{", "}")
|
||||
blocks[name] = block
|
||||
index = start + len(block)
|
||||
return blocks
|
||||
|
||||
|
||||
def direct_section_keys(config_block: str) -> set[str]:
|
||||
sections_match = re.search(r"\bsections\s*:\s*\[", config_block)
|
||||
if not sections_match:
|
||||
return set()
|
||||
sections_block = extract_balanced(config_block, sections_match.end() - 1, "[", "]")
|
||||
keys: set[str] = set()
|
||||
index = 1
|
||||
while index < len(sections_block) - 1:
|
||||
match = re.search(r"\{\s*key\s*:\s*'([^']+)'", sections_block[index:])
|
||||
if not match:
|
||||
break
|
||||
start = index + match.start()
|
||||
if (
|
||||
nesting_depth(sections_block, start, "[", "]") == 1
|
||||
and nesting_depth(sections_block, start, "{", "}") == 0
|
||||
):
|
||||
keys.add(match.group(1))
|
||||
block = extract_balanced(sections_block, start, "{", "}")
|
||||
index = start + len(block)
|
||||
else:
|
||||
index = start + 1
|
||||
return keys
|
||||
|
||||
|
||||
def known_admin_sections() -> dict[str, set[str]]:
|
||||
plain_text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
|
||||
admin_text = read_text("frontend/src/admin/AdminRoutes.tsx")
|
||||
|
||||
configs_block = find_balanced_after(plain_text, "const configs =", "{", "}")
|
||||
configs = top_level_config_blocks(configs_block)
|
||||
component_to_config = dict(re.findall(
|
||||
r"export function (\w+)\(\) \{\s*return <ModuleConsole config=\{configs\.([A-Za-z0-9_]+)\} />\s*\}",
|
||||
plain_text,
|
||||
))
|
||||
path_to_component = dict(re.findall(
|
||||
r'<Route\s+path="([^"]+)"\s+element=\{<([A-Za-z0-9_]+)\s*/>\}',
|
||||
admin_text,
|
||||
))
|
||||
|
||||
section_map: dict[str, set[str]] = {}
|
||||
for path, component in path_to_component.items():
|
||||
config_key = component_to_config.get(component)
|
||||
if not config_key:
|
||||
continue
|
||||
if config_key not in configs:
|
||||
fail(f"Admin route {path} uses unknown PlainResourcePages config: {config_key}")
|
||||
continue
|
||||
section_map[path] = direct_section_keys(configs[config_key])
|
||||
return section_map
|
||||
|
||||
|
||||
def check_documented_section_deep_links() -> None:
|
||||
section_map = known_admin_sections()
|
||||
pattern = re.compile(r"/[a-z][a-z0-9/-]*\?section=[a-z0-9_/-]+")
|
||||
docs = [
|
||||
*sorted((root / "docs/technical").glob("*/*.md")),
|
||||
*sorted((root / "docs/plans").glob("*.md")),
|
||||
]
|
||||
for doc in docs:
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for match in pattern.finditer(text):
|
||||
value = match.group(0)
|
||||
parsed = urlsplit(value)
|
||||
section = parse_qs(parsed.query).get("section", [""])[0]
|
||||
if parsed.path not in section_map:
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
fail(f"{doc.relative_to(root)}:{line_no}: documented section link uses a route with no known sections: {value}")
|
||||
continue
|
||||
if section not in section_map[parsed.path]:
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
known = ", ".join(sorted(section_map[parsed.path]))
|
||||
fail(f"{doc.relative_to(root)}:{line_no}: documented section link {value} is not a known section; expected one of: {known}")
|
||||
|
||||
|
||||
def check_documented_ui_routes() -> None:
|
||||
routes = known_frontend_routes()
|
||||
allowed_prefixes = (
|
||||
"/api/",
|
||||
"/ws",
|
||||
"/health",
|
||||
"/legacy/",
|
||||
"/earth/",
|
||||
"/docs/",
|
||||
"/assets/",
|
||||
"/components/",
|
||||
"/dev/",
|
||||
"/etc/",
|
||||
"/home/",
|
||||
"/tmp/",
|
||||
"/root",
|
||||
"/app/",
|
||||
"/planet",
|
||||
"/localhost",
|
||||
"/example",
|
||||
"/your-",
|
||||
)
|
||||
route_docs = [
|
||||
root / "docs/technical/zh/README.md",
|
||||
root / "docs/technical/en/README.md",
|
||||
root / "docs/technical/zh/manual.md",
|
||||
root / "docs/technical/en/manual.md",
|
||||
root / "docs/technical/zh/quickstart.md",
|
||||
root / "docs/technical/en/quickstart.md",
|
||||
root / "docs/technical/zh/frontend-admin-frontend-context.md",
|
||||
root / "docs/technical/en/frontend-admin-frontend-context.md",
|
||||
]
|
||||
route_pattern = re.compile(r"(?<![\w:_}-])/(?:[a-z][a-z0-9-]*)(?:/[a-z0-9:_-]+)*")
|
||||
for doc in route_docs:
|
||||
if not doc.exists():
|
||||
continue
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for candidate in sorted(set(route_pattern.findall(text))):
|
||||
if candidate in routes:
|
||||
continue
|
||||
if candidate.startswith(allowed_prefixes):
|
||||
continue
|
||||
if re.search(r"/(start|stop|restart|reset|status|stream|generate|cancel|tasks?|task-status|events)$", candidate):
|
||||
continue
|
||||
if re.fullmatch(r"/[a-z]{2,3}", candidate):
|
||||
continue
|
||||
if candidate == "/admin-next" or candidate.startswith("/admin-next/"):
|
||||
fail(f"{doc.relative_to(root)} references stale admin-next route {candidate}")
|
||||
elif candidate.startswith("/") and candidate.count("/") <= 2:
|
||||
warn(f"{doc.relative_to(root)} references route-like path not in current frontend routes: {candidate}")
|
||||
|
||||
|
||||
def check_stale_admin_doc_terms() -> None:
|
||||
stale_patterns = [
|
||||
(re.compile(r"React\s*\+\s*Ant Design"), "console frontend is no longer React + Ant Design"),
|
||||
(re.compile(r"Ant Design Pro"), "console frontend is no longer Ant Design Pro"),
|
||||
(re.compile(r"Old AntD legacy pages"), "old AntD legacy pages are not part of the active frontend"),
|
||||
(re.compile(r"Task Queue:\s*Celery"), "Celery is not part of the active local task stack"),
|
||||
(re.compile(r"Message Queue:\s*Kafka"), "Kafka is not part of the active local message stack"),
|
||||
(re.compile(r"UI Library:\s*Ant Design Pro"), "console frontend is no longer Ant Design Pro"),
|
||||
(re.compile(r"├── unreal/"), "UE5 is not an active checked-in local project tree"),
|
||||
(re.compile(r"Unreal Engine 5 3D visualization"), "UE5 is future/optional, not the active local visualization shell"),
|
||||
(re.compile(r"Polarized 3D large display \(4K, 120Hz\)"), "physical display work is future/optional, not the active local loop"),
|
||||
(re.compile(r"\?tab="), "admin deep links use ?section=, not ?tab="),
|
||||
]
|
||||
docs = [
|
||||
root / "AGENTS.md",
|
||||
root / "README.md",
|
||||
root / "project_context.md",
|
||||
*sorted((root / "docs/technical").glob("*/*.md")),
|
||||
*sorted((root / "docs/plans").glob("*.md")),
|
||||
]
|
||||
for doc in docs:
|
||||
text = doc.read_text(encoding="utf-8")
|
||||
for pattern, reason in stale_patterns:
|
||||
for match in pattern.finditer(text):
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
fail(f"{doc.relative_to(root)}:{line_no}: stale admin documentation: {reason}")
|
||||
|
||||
|
||||
def check_harness_rule_coverage_notes() -> None:
|
||||
harness_doc = read_text("docs/HARNESS.md")
|
||||
audit_doc = read_text("docs/harness-audit.md")
|
||||
if "Rules Coverage Evidence" not in harness_doc:
|
||||
fail("docs/HARNESS.md must point frontend/docs audits to the Rules Coverage Evidence section")
|
||||
if "## Rules Coverage Evidence" not in audit_doc:
|
||||
fail("docs/harness-audit.md must keep the Rules Coverage Evidence section")
|
||||
|
||||
for module in ("core", "security", "workflow", "docs", "uiux", "frontend", "earth"):
|
||||
if f"| `{module}` |" not in audit_doc:
|
||||
fail(f"docs/harness-audit.md Rules Coverage Evidence must include the `{module}` rules module")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_docs_metadata_alignment()
|
||||
check_public_docs_registry()
|
||||
check_all_technical_docs_have_bilingual_pairs()
|
||||
check_bilingual_docs_are_not_copies()
|
||||
check_public_doc_links_exist()
|
||||
check_language_scoped_technical_links()
|
||||
check_public_doc_link_titles()
|
||||
check_credential_collector_contracts()
|
||||
check_manual_console_route_tables()
|
||||
check_documented_section_deep_links()
|
||||
check_documented_ui_routes()
|
||||
check_stale_admin_doc_terms()
|
||||
check_harness_rule_coverage_notes()
|
||||
|
||||
for message in warnings:
|
||||
print(f"warn: {message}")
|
||||
if failures:
|
||||
for message in failures:
|
||||
print(f"fail: {message}")
|
||||
raise SystemExit(f"docs consistency check failed: {len(failures)} failure(s), {len(warnings)} warning(s)")
|
||||
print(f"docs consistency check passed: {len(warnings)} warning(s)")
|
||||
|
||||
|
||||
main()
|
||||
PY
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -3,6 +3,8 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
failures=0
|
||||
warnings=0
|
||||
|
||||
@@ -22,8 +24,10 @@ fail() {
|
||||
|
||||
check_cmd() {
|
||||
local cmd="$1"
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
ok "found command: $cmd"
|
||||
local found
|
||||
if found="$(harness_find_cmd "$cmd")"; then
|
||||
harness_prepend_tool_dir "$found"
|
||||
ok "found command: $cmd ($found)"
|
||||
else
|
||||
fail "missing required command: $cmd"
|
||||
fi
|
||||
@@ -32,8 +36,10 @@ check_cmd() {
|
||||
check_optional_cmd() {
|
||||
local cmd="$1"
|
||||
local reason="$2"
|
||||
if command -v "$cmd" >/dev/null 2>&1; then
|
||||
ok "found optional command: $cmd"
|
||||
local found
|
||||
if found="$(harness_find_cmd "$cmd")"; then
|
||||
harness_prepend_tool_dir "$found"
|
||||
ok "found optional command: $cmd ($found)"
|
||||
else
|
||||
warn "missing optional command: $cmd ($reason)"
|
||||
fi
|
||||
@@ -64,7 +70,6 @@ main() {
|
||||
check_file README.md
|
||||
check_file rules.md
|
||||
check_file project_context.md
|
||||
check_file agents.md
|
||||
check_file AGENTS.md
|
||||
check_file CODEMAP.md
|
||||
check_file docs/HARNESS.md
|
||||
@@ -73,6 +78,11 @@ main() {
|
||||
check_file planet.sh
|
||||
check_file pyproject.toml
|
||||
check_file frontend/package.json
|
||||
check_file scripts/harness/security-check.sh
|
||||
check_file scripts/harness/backend-rules-check.sh
|
||||
check_file scripts/harness/frontend-rules-check.sh
|
||||
check_file scripts/harness/docs-consistency-check.sh
|
||||
check_file scripts/harness/frontend-smoke.mjs
|
||||
check_file .gitea/workflows/ci.yaml
|
||||
|
||||
check_cmd git
|
||||
@@ -86,6 +96,7 @@ main() {
|
||||
check_absent frontend/package-lock.json "frontend package management is Bun-only"
|
||||
check_absent frontend/pnpm-lock.yaml "frontend package management is Bun-only"
|
||||
check_absent frontend/yarn.lock "frontend package management is Bun-only"
|
||||
check_absent agents.md "AGENTS.md is the single agent guide"
|
||||
|
||||
if [ ! -x "$ROOT_DIR/planet.sh" ]; then
|
||||
warn "planet.sh is not executable; run it with zsh or restore executable bit"
|
||||
|
||||
536
scripts/harness/frontend-rules-check.sh
Executable file
536
scripts/harness/frontend-rules-check.sh
Executable file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
local uv_bin
|
||||
uv_bin="$(harness_require_tool uv)"
|
||||
|
||||
harness_run "$uv_bin" run --frozen --project "$ROOT_DIR" python - <<'PY'
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
root = Path.cwd()
|
||||
failures: list[str] = []
|
||||
warnings: list[str] = []
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
def warn(message: str) -> None:
|
||||
warnings.append(message)
|
||||
|
||||
|
||||
def read_text(path: str) -> str:
|
||||
return (root / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def check_package_manager() -> None:
|
||||
for path in ("package.json", "frontend/package.json"):
|
||||
payload = json.loads(read_text(path))
|
||||
package_manager = payload.get("packageManager", "")
|
||||
if not str(package_manager).startswith("bun@"):
|
||||
fail(f"{path}: packageManager must stay Bun-only, got {package_manager!r}")
|
||||
for script_name, command in payload.get("scripts", {}).items():
|
||||
if re.search(r"\b(npm|pnpm|yarn)\b", command):
|
||||
fail(f"{path} script {script_name!r} uses a forbidden package manager: {command}")
|
||||
|
||||
forbidden_lockfiles = [
|
||||
"frontend/package-lock.json",
|
||||
"frontend/pnpm-lock.yaml",
|
||||
"frontend/yarn.lock",
|
||||
]
|
||||
for path in forbidden_lockfiles:
|
||||
if (root / path).exists():
|
||||
fail(f"{path}: forbidden frontend lockfile; frontend package management is Bun-only")
|
||||
|
||||
|
||||
def extract_admin_routes() -> tuple[set[str], set[str]]:
|
||||
admin_routes_text = read_text("frontend/src/admin/AdminRoutes.tsx")
|
||||
manifest_text = read_text("frontend/src/admin/routes/manifest.tsx")
|
||||
route_paths = set(re.findall(r'<Route\s+path="([^"*][^"]*)"', admin_routes_text))
|
||||
route_paths.discard("*")
|
||||
manifest_paths = set(re.findall(r"path:\s*'([^']+)'", manifest_text))
|
||||
return route_paths, manifest_paths
|
||||
|
||||
|
||||
def check_route_manifest_consistency() -> None:
|
||||
route_paths, manifest_paths = extract_admin_routes()
|
||||
public_manifest_paths = {"/earth", "/docs"}
|
||||
redirect_only_routes = {"/alerts"}
|
||||
|
||||
missing_routes = sorted(manifest_paths - route_paths - public_manifest_paths)
|
||||
if missing_routes:
|
||||
fail(
|
||||
"admin route manifest points to paths that AdminRoutes does not render: "
|
||||
+ ", ".join(missing_routes)
|
||||
)
|
||||
|
||||
missing_manifest = sorted(route_paths - manifest_paths - redirect_only_routes)
|
||||
if missing_manifest:
|
||||
fail(
|
||||
"AdminRoutes renders paths that are missing from admin route manifest: "
|
||||
+ ", ".join(missing_manifest)
|
||||
)
|
||||
|
||||
|
||||
def known_frontend_routes() -> set[str]:
|
||||
app_text = read_text("frontend/src/App.tsx")
|
||||
admin_text = read_text("frontend/src/admin/AdminRoutes.tsx")
|
||||
manifest_text = read_text("frontend/src/admin/routes/manifest.tsx")
|
||||
routes = set(re.findall(r'<Route\s+path="([^"*][^"]*)"', app_text))
|
||||
routes.update(re.findall(r'<Route\s+path="([^"*][^"]*)"', admin_text))
|
||||
routes.update(re.findall(r"path:\s*'([^']+)'", manifest_text))
|
||||
routes.update({"/", "/docs/:slug"})
|
||||
routes.discard("*")
|
||||
routes.discard("/*")
|
||||
return routes
|
||||
|
||||
|
||||
def check_literal_internal_links() -> None:
|
||||
routes = known_frontend_routes()
|
||||
allowed_prefixes = (
|
||||
"/api/",
|
||||
"/ws",
|
||||
"/assets/",
|
||||
"/earth/",
|
||||
)
|
||||
literal_link_pattern = re.compile(r"\b(?:to|href)\s*=\s*['\"](/[^'\"#]+(?:#[^'\"]*)?)['\"]")
|
||||
for path in (root / "frontend/src").rglob("*.tsx"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for match in literal_link_pattern.finditer(text):
|
||||
href = match.group(1)
|
||||
route_path = urlsplit(href).path
|
||||
if route_path in routes:
|
||||
continue
|
||||
if route_path.startswith("/docs/"):
|
||||
continue
|
||||
if any(route_path.startswith(prefix) for prefix in allowed_prefixes):
|
||||
continue
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(f"{rel}:{line_no}: literal internal link points to an unknown frontend route: {href}")
|
||||
|
||||
|
||||
def check_admin_search_route_targets() -> None:
|
||||
routes = known_frontend_routes()
|
||||
text = read_text("frontend/src/admin/search/indexers.ts")
|
||||
for match in re.finditer(r"\broutePath:\s*'([^']+)'", text):
|
||||
route_path = match.group(1)
|
||||
if route_path in routes:
|
||||
continue
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(
|
||||
"frontend/src/admin/search/indexers.ts:"
|
||||
f"{line_no}: admin search routePath points to an unknown frontend route: {route_path}"
|
||||
)
|
||||
|
||||
|
||||
def iter_frontend_src_files() -> list[Path]:
|
||||
return [
|
||||
path
|
||||
for path in (root / "frontend/src").rglob("*")
|
||||
if path.is_file() and path.suffix in {".ts", ".tsx", ".js", ".jsx", ".css"}
|
||||
]
|
||||
|
||||
|
||||
def collect_inline_style_context(lines: list[str], start_index: int) -> str:
|
||||
block = []
|
||||
for line in lines[start_index:start_index + 8]:
|
||||
block.append(line.strip())
|
||||
if "}}" in line or "} as CSSProperties" in line:
|
||||
break
|
||||
return " ".join(block)
|
||||
|
||||
|
||||
def inline_style_is_dynamic(context: str) -> bool:
|
||||
dynamic_needles = (
|
||||
"--",
|
||||
"CSSProperties",
|
||||
"transform:",
|
||||
"translate",
|
||||
"scale(",
|
||||
"width:",
|
||||
"height:",
|
||||
"thumbSize",
|
||||
"thumbOffset",
|
||||
"trackSize",
|
||||
"header.getSize",
|
||||
"summaryWidth",
|
||||
"detailWidth",
|
||||
"progress",
|
||||
"pan.x",
|
||||
"pan.y",
|
||||
"zoom",
|
||||
)
|
||||
return any(needle in context for needle in dynamic_needles)
|
||||
|
||||
|
||||
def iter_css_blocks(text: str) -> list[tuple[int, str, str]]:
|
||||
text = re.sub(r"/\*.*?\*/", "", text, flags=re.S)
|
||||
blocks = []
|
||||
start_line = 1
|
||||
selector_lines = []
|
||||
body_lines = []
|
||||
in_block = False
|
||||
for line_no, line in enumerate(text.splitlines(), 1):
|
||||
if not in_block:
|
||||
selector_lines.append(line)
|
||||
if "{" in line:
|
||||
in_block = True
|
||||
start_line = line_no
|
||||
before, after = line.split("{", 1)
|
||||
selector_lines[-1] = before
|
||||
body_lines = [after]
|
||||
continue
|
||||
|
||||
if "}" in line:
|
||||
before, _after = line.split("}", 1)
|
||||
body_lines.append(before)
|
||||
blocks.append((start_line, " ".join(selector_lines).strip(), "\n".join(body_lines)))
|
||||
selector_lines = []
|
||||
body_lines = []
|
||||
in_block = False
|
||||
else:
|
||||
body_lines.append(line)
|
||||
return blocks
|
||||
|
||||
|
||||
def overflow_hidden_has_explicit_owner(selector: str, body: str) -> bool:
|
||||
owner_needles = (
|
||||
"min-height: 0",
|
||||
"height: 100%",
|
||||
"height: 100vh",
|
||||
"display: grid",
|
||||
"display: flex",
|
||||
"text-overflow: ellipsis",
|
||||
"border-radius:",
|
||||
"position: fixed",
|
||||
"position: absolute",
|
||||
"scrollbar",
|
||||
)
|
||||
selector_needles = (
|
||||
"html",
|
||||
"body",
|
||||
"#root",
|
||||
"tui-scrollbar",
|
||||
"table-scroll",
|
||||
"segmented-control__label",
|
||||
"docs-page",
|
||||
"docs-shell",
|
||||
"docs-content-layout",
|
||||
"docs-toc",
|
||||
"admin",
|
||||
"an-page",
|
||||
"an-resource",
|
||||
"an-data",
|
||||
"an-panel",
|
||||
"an-playground",
|
||||
"markdown-renderer",
|
||||
"meta",
|
||||
"select",
|
||||
)
|
||||
return any(needle in body for needle in owner_needles) or any(
|
||||
needle in selector for needle in selector_needles
|
||||
)
|
||||
|
||||
|
||||
def css_block_for_selector(text: str, wanted_selector: str) -> tuple[int, str] | None:
|
||||
for line_no, selector, body in iter_css_blocks(text):
|
||||
selectors = [item.strip() for item in selector.split(",")]
|
||||
if wanted_selector in selectors:
|
||||
return line_no, body
|
||||
return None
|
||||
|
||||
|
||||
def css_has_declaration(body: str, prop: str, value_pattern: str) -> bool:
|
||||
return re.search(rf"(^|[;\n])\s*{re.escape(prop)}\s*:\s*{value_pattern}\s*;", body) is not None
|
||||
|
||||
|
||||
def check_admin_shell_height_chain() -> None:
|
||||
text = read_text("frontend/src/admin/styles.css")
|
||||
required: dict[str, dict[str, str]] = {
|
||||
".admin-theme-root": {
|
||||
"min-height": r"0",
|
||||
"height": r"100%",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
".admin": {
|
||||
"min-height": r"0",
|
||||
"height": r"100%",
|
||||
"display": r"grid",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
".admin__sider": {
|
||||
"min-height": r"0",
|
||||
"height": r"100%",
|
||||
"display": r"flex",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
".admin__nav-scroll": {
|
||||
"flex": r"1\s+1\s+auto",
|
||||
"min-height": r"0",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
".admin__account": {
|
||||
"flex": r"0\s+0\s+auto",
|
||||
},
|
||||
".admin__content": {
|
||||
"min-height": r"0",
|
||||
"height": r"100%",
|
||||
"display": r"grid",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
".admin__content-inner": {
|
||||
"min-height": r"0",
|
||||
"height": r"100%",
|
||||
"overflow": r"hidden",
|
||||
},
|
||||
}
|
||||
|
||||
for selector, declarations in required.items():
|
||||
block = css_block_for_selector(text, selector)
|
||||
if block is None:
|
||||
fail(f"frontend/src/admin/styles.css: admin shell one-screen rule requires {selector}")
|
||||
continue
|
||||
line_no, body = block
|
||||
for prop, value_pattern in declarations.items():
|
||||
if css_has_declaration(body, prop, value_pattern):
|
||||
continue
|
||||
friendly_value = re.sub(r"\\s\+", " ", value_pattern)
|
||||
fail(
|
||||
"frontend/src/admin/styles.css:"
|
||||
f"{line_no}: admin shell one-screen rule requires {selector} "
|
||||
f"to declare {prop}: {friendly_value}"
|
||||
)
|
||||
|
||||
|
||||
def check_debug_output() -> None:
|
||||
for path in iter_frontend_src_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for line_no, line in enumerate(text.splitlines(), 1):
|
||||
if "console.log(" in line or re.search(r"\bdebugger\b", line):
|
||||
fail(f"{rel}:{line_no}: remove console.log/debugger from frontend source")
|
||||
if "console." in line and "token" in line.lower():
|
||||
fail(f"{rel}:{line_no}: console output must not include token material")
|
||||
|
||||
|
||||
def jsx_line_number(text: str, index: int) -> int:
|
||||
return text.count("\n", 0, index) + 1
|
||||
|
||||
|
||||
def iter_opening_tags(text: str, tag_name: str) -> list[tuple[int, str]]:
|
||||
tags: list[tuple[int, str]] = []
|
||||
needle = f"<{tag_name}"
|
||||
index = 0
|
||||
while True:
|
||||
start = text.find(needle, index)
|
||||
if start == -1:
|
||||
return tags
|
||||
next_char_index = start + len(needle)
|
||||
if next_char_index < len(text) and (text[next_char_index].isalnum() or text[next_char_index] in "_-"):
|
||||
index = next_char_index
|
||||
continue
|
||||
|
||||
quote: str | None = None
|
||||
brace_depth = 0
|
||||
cursor = next_char_index
|
||||
while cursor < len(text):
|
||||
char = text[cursor]
|
||||
if quote:
|
||||
if char == "\\":
|
||||
cursor += 2
|
||||
continue
|
||||
if char == quote:
|
||||
quote = None
|
||||
cursor += 1
|
||||
continue
|
||||
if char in ('"', "'", "`"):
|
||||
quote = char
|
||||
elif char == "{":
|
||||
brace_depth += 1
|
||||
elif char == "}":
|
||||
brace_depth = max(0, brace_depth - 1)
|
||||
elif char == ">" and brace_depth == 0:
|
||||
tags.append((start, text[start:cursor + 1]))
|
||||
index = cursor + 1
|
||||
break
|
||||
cursor += 1
|
||||
else:
|
||||
return tags
|
||||
|
||||
|
||||
def check_native_button_safety() -> None:
|
||||
for path in (root / "frontend/src").rglob("*.tsx"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for start, tag in iter_opening_tags(text, "button"):
|
||||
line_no = jsx_line_number(text, start)
|
||||
if not re.search(r"\btype\s*=", tag):
|
||||
fail(f"{rel}:{line_no}: native <button> must declare an explicit type")
|
||||
if re.search(r"\bdisabled\s*=", tag) and re.search(r"\{\s*\.\.\.", tag):
|
||||
spread_index = tag.find("{...")
|
||||
disabled_index = tag.find("disabled")
|
||||
if disabled_index < spread_index:
|
||||
fail(
|
||||
f"{rel}:{line_no}: button disabled state can be overridden by a later props spread"
|
||||
)
|
||||
|
||||
|
||||
def check_icon_button_accessibility() -> None:
|
||||
for path in (root / "frontend/src").rglob("*.tsx"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for start, tag in iter_opening_tags(text, "Button"):
|
||||
if not re.search(r"\bsize\s*=\s*['\"]icon['\"]", tag):
|
||||
continue
|
||||
if "aria-label" not in tag and "ariaLabel" not in tag:
|
||||
fail(f"{rel}:{jsx_line_number(text, start)}: icon Button must include aria-label")
|
||||
if not re.search(r"\btitle\s*=", tag):
|
||||
fail(f"{rel}:{jsx_line_number(text, start)}: icon Button must include title")
|
||||
|
||||
|
||||
def check_no_nested_cards() -> None:
|
||||
for path in (root / "frontend/src").rglob("*.tsx"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
depth = 0
|
||||
for match in re.finditer(r"</?Card\b", text):
|
||||
token = match.group(0)
|
||||
if token.startswith("</"):
|
||||
depth = max(0, depth - 1)
|
||||
continue
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
if depth > 0:
|
||||
fail(f"{rel}:{line_no}: do not nest Card components inside other Cards")
|
||||
tag_end = text.find(">", match.start())
|
||||
tag = text[match.start():tag_end + 1] if tag_end != -1 else ""
|
||||
if not tag.rstrip().endswith("/>"):
|
||||
depth += 1
|
||||
|
||||
|
||||
def check_no_antd_layout_primitives() -> None:
|
||||
for path in (root / "frontend/src").rglob("*.tsx"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
if re.search(r"\bfrom\s+['\"]antd['\"]", text):
|
||||
fail(f"{rel}: active frontend must not import AntD; use Tactile UI/Radix project components")
|
||||
for match in re.finditer(r"<Space\b", text):
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(f"{rel}:{line_no}: avoid implicit Space layout primitives in height-critical UI")
|
||||
|
||||
|
||||
def check_connection_test_input_pattern() -> None:
|
||||
text = read_text("frontend/src/admin/pages/PlainResourcePages.tsx")
|
||||
if "function ConnectionTestInput" not in text:
|
||||
fail("AI/WebSearch connection tests must use the shared ConnectionTestInput pattern")
|
||||
|
||||
for label in ("测试 AI Provider 连通性", "测试 Web Search 连通性"):
|
||||
if re.search(rf"<Button\b(?=[^>]*\btitle=['\"]{re.escape(label)}['\"])", text):
|
||||
fail(f"{label}: connection test must be attached to the Base URL input suffix, not a standalone Button")
|
||||
|
||||
provider_pattern = re.compile(
|
||||
r"key:\s*'base_url'[\s\S]{0,260}inputAction:[\s\S]{0,260}"
|
||||
r"title:\s*'测试 AI Provider 连通性'",
|
||||
)
|
||||
if not provider_pattern.search(text):
|
||||
fail("AI Provider Base URL field must expose its connection test through inputAction")
|
||||
|
||||
web_pattern = re.compile(
|
||||
r"key:\s*'base_url'[\s\S]{0,320}disabled:\s*webSearchDisabled[\s\S]{0,260}"
|
||||
r"title:\s*'测试 Web Search 连通性'",
|
||||
)
|
||||
if not web_pattern.search(text):
|
||||
fail("WebSearch Base URL field must expose a disabled-aware connection test through inputAction")
|
||||
|
||||
|
||||
def check_uiux_static_warnings() -> None:
|
||||
viewport_font_pattern = re.compile(r"\b(?:font-size|fontSize)\s*[:=]\s*[^;\n}]*(?:vw|vmin|vmax|cqw|cqi)")
|
||||
letter_spacing_pattern = re.compile(r"\b(?:letter-spacing|letterSpacing)\s*[:=]\s*([^;\n}]+)")
|
||||
shell_viewport_height_pattern = re.compile(r"\b(?:height|min-height)\s*:\s*100v[hw]\s*;")
|
||||
for path in iter_frontend_src_files():
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for match in viewport_font_pattern.finditer(text):
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(f"{rel}:{line_no}: font size must not scale with viewport width units under uiux rules")
|
||||
for match in letter_spacing_pattern.finditer(text):
|
||||
value = match.group(1).strip().strip("'\"")
|
||||
allowed = (
|
||||
value in {"0", "0em", "0rem", "normal", "inherit", "initial", "unset"}
|
||||
or re.fullmatch(r"var\([^,]+,\s*0(?:em|rem)?\s*\)", value)
|
||||
)
|
||||
if allowed:
|
||||
continue
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(f"{rel}:{line_no}: letter spacing must stay 0 under uiux rules, got {value!r}")
|
||||
if str(rel) in {"frontend/src/admin/styles.css", "frontend/src/pages/Docs/Docs.css"}:
|
||||
for match in shell_viewport_height_pattern.finditer(text):
|
||||
line_no = jsx_line_number(text, match.start())
|
||||
fail(
|
||||
f"{rel}:{line_no}: admin/docs shells must use the root 100% height chain, "
|
||||
"not exact 100vh/100vw sizing"
|
||||
)
|
||||
|
||||
if path.suffix == ".css":
|
||||
continue
|
||||
lines = text.splitlines()
|
||||
for line_no, line in enumerate(lines, 1):
|
||||
if "style={{" not in line:
|
||||
continue
|
||||
context = collect_inline_style_context(lines, line_no - 1)
|
||||
if not inline_style_is_dynamic(context):
|
||||
warn(f"{rel}:{line_no}: inline style found; rules prefer CSS classes unless values are truly dynamic")
|
||||
if re.search(r"\bas\s+any\b|<any>", text):
|
||||
warn(f"{rel}: broad TypeScript cast found; prefer specific types where practical")
|
||||
|
||||
for path in (root / "frontend/src").rglob("*.css"):
|
||||
text = path.read_text(encoding="utf-8")
|
||||
rel = path.relative_to(root)
|
||||
for line_no, selector, body in iter_css_blocks(text):
|
||||
if "overflow: hidden" not in body:
|
||||
continue
|
||||
if not overflow_hidden_has_explicit_owner(selector, body):
|
||||
warn(
|
||||
f"{rel}:{line_no}: overflow:hidden needs an explicit child scroll owner "
|
||||
f"or a documented clipping reason under uiux rules"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
check_package_manager()
|
||||
check_route_manifest_consistency()
|
||||
check_literal_internal_links()
|
||||
check_admin_search_route_targets()
|
||||
check_debug_output()
|
||||
check_native_button_safety()
|
||||
check_icon_button_accessibility()
|
||||
check_no_nested_cards()
|
||||
check_no_antd_layout_primitives()
|
||||
check_connection_test_input_pattern()
|
||||
check_admin_shell_height_chain()
|
||||
check_uiux_static_warnings()
|
||||
|
||||
for message in warnings:
|
||||
print(f"warn: {message}")
|
||||
if failures:
|
||||
for message in failures:
|
||||
print(f"fail: {message}")
|
||||
raise SystemExit(f"frontend rules check failed: {len(failures)} failure(s), {len(warnings)} warning(s)")
|
||||
print(f"frontend rules check passed: {len(warnings)} warning(s)")
|
||||
|
||||
|
||||
main()
|
||||
PY
|
||||
}
|
||||
|
||||
main "$@"
|
||||
1339
scripts/harness/frontend-smoke.mjs
Executable file
1339
scripts/harness/frontend-smoke.mjs
Executable file
File diff suppressed because it is too large
Load Diff
84
scripts/harness/lib.sh
Executable file
84
scripts/harness/lib.sh
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
harness_candidate_shells() {
|
||||
local shell
|
||||
local -a shells=()
|
||||
if [ -n "${SHELL:-}" ]; then
|
||||
shells+=("$SHELL")
|
||||
fi
|
||||
shells+=(zsh bash)
|
||||
|
||||
local seen=""
|
||||
for shell in "${shells[@]}"; do
|
||||
if [ -z "$shell" ]; then
|
||||
continue
|
||||
fi
|
||||
if ! command -v "$shell" >/dev/null 2>&1; then
|
||||
continue
|
||||
fi
|
||||
shell="$(command -v "$shell")"
|
||||
case ":$seen:" in
|
||||
*":$shell:"*) continue ;;
|
||||
esac
|
||||
seen="${seen:+$seen:}$shell"
|
||||
printf "%s\n" "$shell"
|
||||
done
|
||||
}
|
||||
|
||||
harness_find_cmd() {
|
||||
local cmd="$1"
|
||||
if [[ ! "$cmd" =~ ^[A-Za-z0-9_.+-]+$ ]]; then
|
||||
printf "invalid command name: %s\n" "$cmd" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local found=""
|
||||
found="$(command -v "$cmd" 2>/dev/null || true)"
|
||||
if [ -n "$found" ] && [ -x "$found" ]; then
|
||||
printf "%s\n" "$found"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local shell
|
||||
while IFS= read -r shell; do
|
||||
found="$("$shell" -lic "command -v $cmd" 2>/dev/null | sed -n '1p' || true)"
|
||||
if [ -n "$found" ] && [ -x "$found" ]; then
|
||||
printf "%s\n" "$found"
|
||||
return 0
|
||||
fi
|
||||
done < <(harness_candidate_shells)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
harness_require_tool() {
|
||||
local cmd="$1"
|
||||
local found
|
||||
if ! found="$(harness_find_cmd "$cmd")"; then
|
||||
printf "missing required command: %s\n" "$cmd" >&2
|
||||
printf "looked in the current non-interactive PATH and login interactive shells\n" >&2
|
||||
return 1
|
||||
fi
|
||||
harness_prepend_tool_dir "$found"
|
||||
printf "%s\n" "$found"
|
||||
}
|
||||
|
||||
harness_prepend_tool_dir() {
|
||||
local path="$1"
|
||||
local dir
|
||||
dir="$(dirname "$path")"
|
||||
case ":$PATH:" in
|
||||
*":$dir:"*) ;;
|
||||
*)
|
||||
PATH="$dir:$PATH"
|
||||
export PATH
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
harness_run() {
|
||||
printf "+ %s\n" "$*" >&2
|
||||
"$@"
|
||||
}
|
||||
@@ -3,26 +3,33 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
run() {
|
||||
printf "+ %s\n" "$*" >&2
|
||||
"$@"
|
||||
}
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
local uv_bin
|
||||
uv_bin="$(harness_require_tool uv)"
|
||||
|
||||
run scripts/harness/doctor.sh
|
||||
run git diff --check
|
||||
run zsh -n planet.sh
|
||||
run bash -n scripts/bootstrap-dev.sh
|
||||
run bash -n scripts/harness/doctor.sh
|
||||
run bash -n scripts/harness/quick-check.sh
|
||||
run bash -n scripts/harness/validate.sh
|
||||
harness_run scripts/harness/doctor.sh
|
||||
harness_run git diff --check
|
||||
harness_run zsh -n planet.sh
|
||||
harness_run bash -n scripts/bootstrap-dev.sh
|
||||
harness_run bash -n scripts/harness/lib.sh
|
||||
harness_run bash -n scripts/harness/doctor.sh
|
||||
harness_run bash -n scripts/harness/quick-check.sh
|
||||
harness_run bash -n scripts/harness/validate.sh
|
||||
harness_run bash -n scripts/harness/security-check.sh
|
||||
harness_run bash -n scripts/harness/backend-rules-check.sh
|
||||
harness_run bash -n scripts/harness/frontend-rules-check.sh
|
||||
harness_run bash -n scripts/harness/docs-consistency-check.sh
|
||||
harness_run scripts/harness/security-check.sh
|
||||
harness_run scripts/harness/backend-rules-check.sh
|
||||
harness_run scripts/harness/frontend-rules-check.sh
|
||||
harness_run scripts/harness/docs-consistency-check.sh
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR/backend"
|
||||
run uv run --frozen --group dev --project "$ROOT_DIR" python -m pytest -s \
|
||||
harness_run "$uv_bin" run --frozen --group dev --project "$ROOT_DIR" python -m pytest -s \
|
||||
tests/test_api.py \
|
||||
tests/test_realtime_sources.py \
|
||||
-q
|
||||
|
||||
74
scripts/harness/security-check.sh
Executable file
74
scripts/harness/security-check.sh
Executable file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
python - <<'PY'
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
root = Path.cwd()
|
||||
failures: list[str] = []
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
failures.append(message)
|
||||
|
||||
|
||||
tracked = subprocess.check_output(["git", "ls-files"], text=True).splitlines()
|
||||
for path in tracked:
|
||||
name = Path(path).name
|
||||
if name == ".env" or (name.startswith(".env.") and name not in {".env.example"}):
|
||||
fail(f"{path}: environment files must not be tracked")
|
||||
if re.search(r"\.(pem|key|p12|pfx)$", name):
|
||||
fail(f"{path}: private key/certificate material must not be tracked")
|
||||
|
||||
secret_patterns = [
|
||||
("private key block", re.compile(r"BEGIN [A-Z ]*PRIVATE KEY")),
|
||||
("AWS access key", re.compile(r"AKIA[0-9A-Z]{16}")),
|
||||
("Google API key", re.compile(r"AIza[0-9A-Za-z_-]{35}")),
|
||||
("OpenAI-style secret key", re.compile(r"sk-[A-Za-z0-9]{32,}")),
|
||||
("Slack token", re.compile(r"xox[baprs]-[A-Za-z0-9-]{10,}")),
|
||||
("GitHub token", re.compile(r"gh[pousr]_[A-Za-z0-9_]{30,}")),
|
||||
]
|
||||
skip_parts = {
|
||||
".git",
|
||||
".venv",
|
||||
"node_modules",
|
||||
"dist",
|
||||
"build",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
"__pycache__",
|
||||
}
|
||||
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if skip_parts.intersection(path.relative_to(root).parts):
|
||||
continue
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
rel = path.relative_to(root)
|
||||
for label, pattern in secret_patterns:
|
||||
for match in pattern.finditer(text):
|
||||
line_no = text.count("\n", 0, match.start()) + 1
|
||||
fail(f"{rel}:{line_no}: possible {label} committed to repository")
|
||||
|
||||
if failures:
|
||||
for message in failures:
|
||||
print(f"fail: {message}")
|
||||
raise SystemExit(f"security check failed: {len(failures)} failure(s)")
|
||||
|
||||
print("security check passed")
|
||||
PY
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -3,21 +3,21 @@
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
DOCKER_SMOKE="${PLANET_HARNESS_DOCKER_SMOKE:-0}"
|
||||
source "$ROOT_DIR/scripts/harness/lib.sh"
|
||||
|
||||
run() {
|
||||
printf "+ %s\n" "$*" >&2
|
||||
"$@"
|
||||
}
|
||||
DOCKER_SMOKE="${PLANET_HARNESS_DOCKER_SMOKE:-0}"
|
||||
FRONTEND_SMOKE="${PLANET_HARNESS_FRONTEND_SMOKE:-1}"
|
||||
FRONTEND_SMOKE_PORT="${PLANET_HARNESS_FRONTEND_SMOKE_PORT:-4173}"
|
||||
|
||||
run_helm_smoke_if_available() {
|
||||
if ! command -v helm >/dev/null 2>&1; then
|
||||
local helm_bin
|
||||
if ! helm_bin="$(harness_find_cmd helm)"; then
|
||||
printf "warn: helm not found; skipping Helm lint/template smoke\n"
|
||||
return 0
|
||||
fi
|
||||
|
||||
run helm lint deploy/helm/planet
|
||||
run helm template planet-staging deploy/helm/planet \
|
||||
harness_run "$helm_bin" lint deploy/helm/planet
|
||||
harness_run "$helm_bin" template planet-staging deploy/helm/planet \
|
||||
--namespace planet-staging \
|
||||
-f deploy/helm/planet/values.single-node.yaml \
|
||||
--set image.tag=harness-smoke >/tmp/planet-harness-rendered.yaml
|
||||
@@ -33,27 +33,83 @@ run_docker_smoke_if_requested() {
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
local docker_bin
|
||||
if ! docker_bin="$(harness_find_cmd docker)"; then
|
||||
printf "fail: docker not found; cannot run requested image smoke builds\n"
|
||||
return 1
|
||||
fi
|
||||
|
||||
run docker build -t planet-harness/frontend:smoke ./frontend
|
||||
run docker build -t planet-harness/backend:smoke -f backend/Dockerfile .
|
||||
run docker build -t planet-harness/aiprovider:smoke -f aiprovider/Dockerfile .
|
||||
harness_run "$docker_bin" build -t planet-harness/frontend:smoke ./frontend
|
||||
harness_run "$docker_bin" build -t planet-harness/backend:smoke -f backend/Dockerfile .
|
||||
harness_run "$docker_bin" build -t planet-harness/aiprovider:smoke -f aiprovider/Dockerfile .
|
||||
}
|
||||
|
||||
wait_for_frontend_preview() {
|
||||
local bun_bin="$1"
|
||||
local url="$2"
|
||||
local attempts=60
|
||||
local index=0
|
||||
while [ "$index" -lt "$attempts" ]; do
|
||||
if PLANET_FRONTEND_SMOKE_URL="$url" "$bun_bin" -e \
|
||||
'fetch(process.env.PLANET_FRONTEND_SMOKE_URL).then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))' \
|
||||
>/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
index=$((index + 1))
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
run_frontend_smoke_if_enabled() {
|
||||
local bun_bin="$1"
|
||||
case "$FRONTEND_SMOKE" in
|
||||
0|false|no|off)
|
||||
printf "info: frontend Playwright smoke skipped; set PLANET_HARNESS_FRONTEND_SMOKE=1 to enable\n"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
|
||||
local url="http://127.0.0.1:${FRONTEND_SMOKE_PORT}"
|
||||
local log_path="/tmp/planet-harness-frontend-preview.log"
|
||||
(
|
||||
cd "$ROOT_DIR/frontend"
|
||||
"$bun_bin" ./node_modules/vite/bin/vite.js preview \
|
||||
--host 127.0.0.1 \
|
||||
--port "$FRONTEND_SMOKE_PORT" \
|
||||
--strictPort
|
||||
) >"$log_path" 2>&1 &
|
||||
local preview_pid=$!
|
||||
|
||||
if ! wait_for_frontend_preview "$bun_bin" "$url"; then
|
||||
printf "fail: frontend preview did not become ready at %s\n" "$url"
|
||||
printf "preview log: %s\n" "$log_path"
|
||||
kill "$preview_pid" >/dev/null 2>&1 || true
|
||||
wait "$preview_pid" >/dev/null 2>&1 || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
local smoke_status=0
|
||||
PLANET_FRONTEND_SMOKE_URL="$url" harness_run "$bun_bin" "$ROOT_DIR/scripts/harness/frontend-smoke.mjs" || smoke_status=$?
|
||||
kill "$preview_pid" >/dev/null 2>&1 || true
|
||||
wait "$preview_pid" >/dev/null 2>&1 || true
|
||||
return "$smoke_status"
|
||||
}
|
||||
|
||||
main() {
|
||||
cd "$ROOT_DIR"
|
||||
local bun_bin
|
||||
bun_bin="$(harness_require_tool bun)"
|
||||
|
||||
run scripts/harness/quick-check.sh
|
||||
harness_run scripts/harness/quick-check.sh
|
||||
|
||||
(
|
||||
cd "$ROOT_DIR/frontend"
|
||||
run bun install --frozen-lockfile
|
||||
run bun run build
|
||||
harness_run "$bun_bin" install --frozen-lockfile
|
||||
harness_run "$bun_bin" run build
|
||||
)
|
||||
|
||||
run_frontend_smoke_if_enabled "$bun_bin"
|
||||
run_helm_smoke_if_available
|
||||
run_docker_smoke_if_requested
|
||||
|
||||
|
||||
Reference in New Issue
Block a user