diff --git a/.claude/commands/docs.md b/.claude/commands/docs.md index e67c2b97..0be584f4 100644 --- a/.claude/commands/docs.md +++ b/.claude/commands/docs.md @@ -46,6 +46,16 @@ rg -n "class |def |function |export |router|@router|interface |type " - Keep filenames lowercase and hyphenated. - Apply the repository-specific rules file before writing. +#### Document Audience Routing (Planet) + +In this repository, classify the action's performer before picking a target file: + +- Browser/UI end user → `docs/technical/{zh,en}/manual.md` or `quickstart.md`. +- Shell / Docker / log paths / `planet.sh` / SMTP fallbacks / port forwarding → `docs/technical/{zh,en}/ops-runbook.md` (or an existing `ops-*.md`). +- Second-party developers → existing `*-context.md` / `backend-*.md` / `earth-*.md` files. + +Never put shell commands, log paths, or Docker operations into `manual.md` / `quickstart.md`. Never put UI button labels or screenshots into `ops-*.md`. When the same action has both a UI and a CLI path, write each in its own home and cross-link them with one sentence. + For ambiguous or large documentation changes, briefly state the intended doc plan before editing. For clear small changes, proceed directly. ### Step 3 — Write @@ -63,6 +73,7 @@ Style: - Use fenced code blocks with language tags. - Prefer tables for comparisons or parameter lists. - Keep snippets concise and relevant. +- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change. ### Step 4 — Verify diff --git a/.codex/skills/docs/SKILL.md b/.codex/skills/docs/SKILL.md index 9c399c85..ec7d6c56 100644 --- a/.codex/skills/docs/SKILL.md +++ b/.codex/skills/docs/SKILL.md @@ -52,6 +52,7 @@ rg -n "class |def |function |export |router|@router|interface |type " - Keep code snippets short and directly relevant. - List related files only when they help future maintainers navigate. - Use the repository’s existing language, heading style, and naming conventions. +- For UI labels, chart labels, feature names, datasource names, and other terms that may become mixed Chinese/English copy, check `docs/technical/{zh,en}/naming-glossary.md` and use the documented display name. If a confusing term is missing, update the glossary in both languages as part of the docs change. 4. Verify: diff --git a/.dockerignore b/.dockerignore index d683496a..b3daf053 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,9 +2,14 @@ !pyproject.toml !uv.lock +!VERSION +!backend/ +!backend/** !aiprovider/ !aiprovider/** +backend/.env +backend/.env.* aiprovider/.env aiprovider/.env.* !aiprovider/.env.example diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 00000000..4e5c0705 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,64 @@ +name: ci + +on: + push: + branches: + - dev + - main + pull_request: + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + +jobs: + backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + - name: Sync Python dependencies + run: ~/.local/bin/uv sync --group dev + - name: Run backend smoke tests + working-directory: backend + run: PYTHONPATH=. "$GITHUB_WORKSPACE/.venv/bin/python" -m pytest -s tests/test_api.py tests/test_realtime_sources.py -q + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Bun + run: curl -fsSL https://bun.sh/install | bash + - name: Build frontend + working-directory: frontend + run: | + ~/.bun/bin/bun install --frozen-lockfile + ~/.bun/bin/bun run build + + delivery: + runs-on: ubuntu-latest + needs: + - backend + - frontend + steps: + - uses: actions/checkout@v4 + - name: Install Helm + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz + tar -xzf /tmp/helm.tar.gz -C /tmp + mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Docker build smoke + run: | + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/frontend:${GITHUB_SHA}" ./frontend + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/backend:${GITHUB_SHA}" -f backend/Dockerfile . + docker build -t "$REGISTRY/$IMAGE_NAMESPACE/aiprovider:${GITHUB_SHA}" -f aiprovider/Dockerfile . + - name: Helm template smoke + run: | + helm lint deploy/helm/planet + helm template planet-staging deploy/helm/planet \ + --namespace planet-staging \ + -f deploy/helm/planet/values.single-node.yaml \ + --set image.tag="${GITHUB_SHA}" >/tmp/planet-rendered.yaml diff --git a/.gitea/workflows/deploy-staging.yaml b/.gitea/workflows/deploy-staging.yaml new file mode 100644 index 00000000..43fc8fe8 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yaml @@ -0,0 +1,67 @@ +name: deploy-staging + +on: + workflow_dispatch: + push: + branches: + - main + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + RELEASE_NAME: planet-staging + NAMESPACE: planet-staging + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install deploy tools + run: | + mkdir -p "$HOME/.local/bin" + curl -fsSL https://get.helm.sh/helm-v3.15.4-linux-amd64.tar.gz -o /tmp/helm.tar.gz + tar -xzf /tmp/helm.tar.gz -C /tmp + mv /tmp/linux-amd64/helm "$HOME/.local/bin/helm" + curl -fsSL https://dl.k8s.io/release/v1.30.5/bin/linux/amd64/kubectl -o /tmp/kubectl + install -m 0755 /tmp/kubectl "$HOME/.local/bin/kubectl" + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Configure kubeconfig + run: | + mkdir -p "$HOME/.kube" + printf "%s" "${{ secrets.KUBE_CONFIG_STAGING }}" | base64 -d > "$HOME/.kube/config" + chmod 600 "$HOME/.kube/config" + - name: Deploy Helm release + run: | + kubectl create namespace "$NAMESPACE" --dry-run=client -o yaml | kubectl apply -f - + helm upgrade --install "$RELEASE_NAME" deploy/helm/planet \ + --namespace "$NAMESPACE" \ + -f deploy/helm/planet/values.single-node.yaml \ + --set global.imageRegistry="$REGISTRY" \ + --set global.imageNamespace="$IMAGE_NAMESPACE" \ + --set image.tag="${GITHUB_SHA}" + - name: Wait for rollout + run: | + kubectl rollout status deployment/planet-frontend -n "$NAMESPACE" --timeout=180s + kubectl rollout status deployment/planet-backend -n "$NAMESPACE" --timeout=180s + kubectl rollout status deployment/planet-aiprovider -n "$NAMESPACE" --timeout=180s + - name: Smoke test services + run: | + kubectl run planet-smoke-${GITHUB_RUN_NUMBER} \ + --rm -i --restart=Never \ + --namespace "$NAMESPACE" \ + --image=curlimages/curl:8.11.1 \ + --command -- sh -c ' + set -eu + curl -fsS http://planet-frontend:3000/ >/dev/null + curl -fsS http://planet-frontend:3000/health >/dev/null + curl -fsS http://planet-frontend:3000/api/health >/dev/null + curl -fsS http://planet-backend:8000/health >/dev/null + curl -fsS http://planet-aiprovider:8010/health >/dev/null + ' + - name: Collect diagnostics on failure + if: failure() + run: | + kubectl get all -n "$NAMESPACE" -o wide || true + kubectl describe pods -n "$NAMESPACE" || true + kubectl logs -n "$NAMESPACE" -l app.kubernetes.io/instance="$RELEASE_NAME" --all-containers --tail=200 || true diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 00000000..cf7fcbea --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,58 @@ +name: release + +on: + push: + branches: + - main + tags: + - "v*" + workflow_dispatch: + +env: + REGISTRY: gitea.rclaw.top + IMAGE_NAMESPACE: linkong/planet + +jobs: + images: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Resolve image tags + id: meta + run: | + echo "sha_tag=${GITHUB_SHA}" >> "$GITHUB_OUTPUT" + if printf "%s" "${GITHUB_REF}" | grep -q '^refs/tags/v'; then + echo "release_tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT" + else + echo "release_tag=" >> "$GITHUB_OUTPUT" + fi + - name: Login to registry + run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin + - name: Build and push images + run: | + for service in frontend backend aiprovider; do + case "$service" in + frontend) + dockerfile="./frontend/Dockerfile" + context="./frontend" + ;; + backend) + dockerfile="backend/Dockerfile" + context="." + ;; + aiprovider) + dockerfile="aiprovider/Dockerfile" + context="." + ;; + esac + + image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.sha_tag }}" + docker build -t "$image" -f "$dockerfile" "$context" + docker push "$image" + + if [ -n "${{ steps.meta.outputs.release_tag }}" ]; then + release_image="$REGISTRY/$IMAGE_NAMESPACE/$service:${{ steps.meta.outputs.release_tag }}" + docker tag "$image" "$release_image" + docker push "$release_image" + fi + done diff --git a/.gitignore b/.gitignore index dff37800..7f7520e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .env .env.local .env.*.local +config/earth-boundary-sources.local.json *.pem *.key *.crt @@ -150,3 +151,9 @@ temp/ # Runtime Data # ---------------------- data/ai/bgp-briefs/ +data/earth-boundary-sources/ + +# Generated Earth boundary tile artifacts. Keep source configs and builders in +# Git; publish PMTiles/MVT artifacts through release/deploy storage instead of +# committing thousands of generated tile files. +frontend/public/earth/data/boundaries/ diff --git a/README.md b/README.md index 16fbb393..de98845a 100644 --- a/README.md +++ b/README.md @@ -8,68 +8,54 @@ ## 系统架构 +当前仓库的核心形态是“Web Earth 可视化 + React 运维台 + FastAPI 数据与 AI 编排后端 + 独立模型适配层”。物理大屏与 UE 客户端仍是长期方向,但不再作为本地开发和当前发布的必需运行单元。 + ``` -┌─────────────────────────────────────────────────────────────────────────┐ -│ 物理大屏展示层 │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 偏振片3D大屏 (2m×3m, 4K, 120Hz, 眼镜式) │ │ -│ │ ┌─────────────────────────────────────────────────────────┐ │ │ -│ │ │ 虚幻引擎 UE5 客户端 │ │ │ -│ │ │ ├── 3D地球渲染 (Cesium for UE) │ │ │ -│ │ │ ├── 算力点可视化 (GPU集群、智算中心) │ │ │ -│ │ │ ├── 连接弧线 (光缆、路由、数据流向) │ │ │ -│ │ │ ├── 粒子效果 (数据流动、告警提示) │ │ │ -│ │ │ └── 自动巡航相机 + 交互控制 │ │ │ -│ │ └─────────────────────────────────────────────────────────┘ │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ WebSocket (实时推送) - │ 120Hz 心跳 / 数据帧同步 - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 数据中台服务层 (FastAPI) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ API Gateway (Redis 限流) │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -│ │ │ -│ ┌───────────────────┬──────────────────────────┬──────────────────┐ │ -│ │ 数据采集服务 │ 核心业务服务 │ 运维管理服务 │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 调度中心 │ │ │ WebSocket 服务 │ │ │ 用户管理 │ │ │ -│ │ │ (Celery) │ │ │ (FastAPI) │ │ │ (JWT Auth) │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 采集器池 │ │ │ 数据查询 API │ │ │ 数据源配置 │ │ │ -│ │ │ (10+源) │ │ │ (REST) │ │ │ 监控告警 │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ │ ┌─────────────┐ │ ┌─────────────────┐ │ ┌─────────────┐ │ │ -│ │ │ 消息队列 │ │ │ 态势分析引擎 │ │ │ 系统配置 │ │ │ -│ │ │ (Kafka) │ │ │ (计算/聚合) │ │ │ 日志审计 │ │ │ -│ │ └─────────────┘ │ └─────────────────┘ │ └─────────────┘ │ │ -│ └───────────────────┴──────────────────────────┴──────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ 内部 API 调用 - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ Web管理端 (React Admin) │ -│ ┌─────────────────────────────────────────────────────────────────┐ │ -│ │ 登录页 │ 仪表盘 │ 用户管理 │ 数据源配置 │ 任务监控 │ 系统配置 │ │ -│ └─────────────────────────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ - ▲ - │ PostgreSQL / Redis - ▼ -┌─────────────────────────────────────────────────────────────────────────┐ -│ 数据存储层 │ -│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ -│ │ PostgreSQL │ │ TimescaleDB │ │ Redis │ │ MinIO │ │ -│ │ (用户/配置) │ │ (时序数据) │ │ (缓存/会话) │ │ (文件存储) │ │ -│ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ -└─────────────────────────────────────────────────────────────────────────┘ +┌─────────────────────────────────────────────────────────────────────┐ +│ 浏览器展示与运维层 │ +│ ┌──────────────────────────────┐ ┌──────────────────────────────┐ │ +│ │ Web Earth │ │ React 运维台 │ │ +│ │ frontend/public/earth │ │ frontend/src │ │ +│ │ Three.js 地球 / HUD / 新闻 │ │ 数据源 / 告警 / AI 设置 │ │ +│ │ 国界精度 / 品牌内容配置 │ │ 提示词配置 / 用户与系统配置 │ │ +│ └──────────────────────────────┘ └──────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ REST / WebSocket + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ FastAPI 业务与编排后端 │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ +│ │ 数据 API 与认证 │ │ Earth 新闻增强 │ │ 告警与态势简报 │ │ +│ │ JWT / 权限 / 审计 │ │ 位置推断 / 本地化 │ │ BGP / 告警研判 │ │ +│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │ +│ ┌────────────────────┐ ┌────────────────────┐ ┌─────────────────┐ │ +│ │ 系统运行配置 │ │ 默认提示词注册表 │ │ 未来 Agent Runtime│ │ +│ │ system_settings │ │ 代码发布 + DB 覆盖 │ │ 工具/证据/工作流 │ │ +│ └────────────────────┘ └────────────────────┘ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────────┘ + │ SQLAlchemy / Redis Stream │ 纯净 LLM 调用 + ▼ ▼ +┌──────────────────────────────┐ ┌──────────────────────────────┐ +│ PostgreSQL / Redis │ │ aiprovider │ +│ 用户、配置、采集结果、新闻 │ │ provider + protocol adapter │ +│ Stream、缓存、运行状态 │ │ OpenAI / MiniMax / Ollama 等 │ +└──────────────────────────────┘ └──────────────────────────────┘ + ▲ + │ 采集器 / 外部数据源 + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ RSS 新闻、BGP 观测、公开数据源、后续 WebSearch/OCR/语音识别等工具 │ +└─────────────────────────────────────────────────────────────────────┘ ``` +架构边界: + +- `backend` 负责业务语义、证据收集、提示词选择、AI 任务编排、权限和数据落库。 +- `aiprovider` 只负责把纯净模型请求适配到不同供应商或协议,不内置具体业务提示词。 +- 默认提示词随代码发布并保存在 `backend/app/ai_tasks/default_prompts.json`,运维台可在数据库中保存覆盖值,重置时回到当前代码版本的默认提示词。 +- Earth 新闻保留英文原文,中文展示结果存入 `localizations`,前端默认展示 `zh-CN` 的 `display_title`、`display_summary` 和中文地域/状态文案。 +- Earth LLM 指令、语音识别、多角色态势研判属于后续 Agent Runtime 方向,计划见 [docs/plans/agents-earth-command-runtime-plan.md](/home/ray/dev/linkong/planet/docs/plans/agents-earth-command-runtime-plan.md)。 + ## 四大核心要素 | 层级 | 要素 | 描述 | @@ -87,11 +73,10 @@ |------|------|------| | FastAPI | 0.109+ | Web 框架 | | SQLAlchemy | 2.0+ | ORM | -| Alembic | - | 数据库迁移 | -| Celery | 5.3+ | 任务队列 | -| Redis | 7.0+ | 缓存/消息 | -| Kafka | 3.0+ | 事件流 | +| uv | - | Python 依赖与命令运行 | +| Redis | 7.0+ | 缓存、Stream 与运行协调 | | PyJWT | - | 认证 | +| APScheduler / 后台任务 | - | 采集、增强与运行时任务 | ### 前端 (React Admin) @@ -102,6 +87,7 @@ | Axios | HTTP 客户端 | | Socket.io-client | WebSocket 客户端 | | ECharts | 统计图表 | +| Three.js | Earth 3D 地球渲染 | | Bun | 前端包管理与脚本运行 | 前端工程统一使用 Bun: @@ -110,22 +96,16 @@ - 运行脚本使用 `bun run ` + return html.includes('') ? html.replace('', `${client}`) : `${html}${client}` +} + +function serveFile(request, response) { + if (request.url === '/__preview_reload') { + response.writeHead(200, { + 'content-type': 'text/event-stream', + 'cache-control': 'no-cache', + connection: 'keep-alive', + }) + response.write('\n') + clients.add(response) + request.on('close', () => clients.delete(response)) + return + } + + let file = safePath(request.url || '/') + if (!existsSync(file) || statSync(file).isDirectory()) file = join(file, 'index.html') + if (!existsSync(file)) file = join(dist, 'index.html') + if (!existsSync(file)) { + response.writeHead(404) + response.end('Run bun run build first.') + return + } + + const ext = extname(file) + response.setHeader('content-type', mime[ext] || 'application/octet-stream') + response.setHeader('cache-control', 'no-store') + if (ext === '.html') { + let html = '' + createReadStream(file, 'utf8') + .on('data', (chunk) => { html += chunk }) + .on('end', () => response.end(injectReloadClient(html))) + .on('error', () => { + response.writeHead(500) + response.end('Failed to read preview file.') + }) + return + } + createReadStream(file).pipe(response) +} + +await mkdir(dist, { recursive: true }) +await runBuild() + +watchPath(join(root, 'src')) +watchPath(join(root, 'public')) +for (const file of ['index.html', 'vite.config.mts', 'tailwind.config.ts', 'postcss.config.cjs', 'package.json']) { + watchPath(join(root, file)) +} + +if (watchOnly) { + console.log('[preview:auto] watching source changes; press Ctrl+C to stop') +} else { + createServer(serveFile).listen(port, () => { + console.log(`[preview:auto] serving http://localhost:${port}`) + console.log('[preview:auto] build success will reload connected pages') + }) +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18c129c7..7ac080ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,11 +1,14 @@ import { Suspense, lazy } from 'react' import { Spin } from 'antd' -import { Routes, Route, Navigate } from 'react-router-dom' +import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { useAuthStore } from './stores/auth' import Login from './pages/Login/Login' +const Register = lazy(() => import('./pages/Register/Register')) +const VerifyEmail = lazy(() => import('./pages/VerifyEmail/VerifyEmail')) +const ForgotPassword = lazy(() => import('./pages/ForgotPassword/ForgotPassword')) const SystemAlerts = lazy(() => import('./pages/Alerts/SystemAlerts')) const BGPAlerts = lazy(() => import('./pages/Alerts/BGPAlerts')) const SituationalAlerts = lazy(() => import('./pages/Alerts/SituationalAlerts')) @@ -19,21 +22,30 @@ const AISettings = lazy(() => import('./pages/AISettings/AISettings')) const BGP = lazy(() => import('./pages/BGP/BGP')) const Logs = lazy(() => import('./pages/Logs/Logs')) const Docs = lazy(() => import('./pages/Docs/Docs')) +const AdminNextRoutes = lazy(() => import('./admin-next/AdminNextRoutes')) const ROOT_ROUTE = '/' const EARTH_ROUTE = '/earth' const DOCS_ROUTE = '/docs' const DOCS_ROUTE_PATTERN = '/docs/:slug' const DOCS_ROUTE_PREFIX = `${DOCS_ROUTE}/` -const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE]) +const AUTH_ROUTES = new Set(['/login', '/register', '/verify-email', '/forgot-password']) +const PUBLIC_EXACT_ROUTES = new Set([ROOT_ROUTE, EARTH_ROUTE, DOCS_ROUTE, ...AUTH_ROUTES]) function isPublicPath(pathname: string) { return PUBLIC_EXACT_ROUTES.has(pathname) || pathname.startsWith(DOCS_ROUTE_PREFIX) } +function AdminNextCompatRedirect() { + const { pathname, search, hash } = useLocation() + const nextPath = pathname.replace(/^\/admin-next/, '') || '' + return +} + function App() { const { token } = useAuthStore() - const isPublicRoute = isPublicPath(window.location.pathname) + const { pathname } = useLocation() + const isPublicRoute = isPublicPath(pathname) if (!token && !isPublicRoute) { return @@ -48,27 +60,36 @@ function App() { )} > - } /> + } /> + } /> + } /> + } /> } /> } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> ) } -export default App +export default App diff --git a/frontend/src/admin-next/AdminNextRoutes.tsx b/frontend/src/admin-next/AdminNextRoutes.tsx new file mode 100644 index 00000000..6132d7d7 --- /dev/null +++ b/frontend/src/admin-next/AdminNextRoutes.tsx @@ -0,0 +1,48 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import { AdminThemeProvider } from './design/theme' +import DashboardNext from './pages/DashboardNext' +import DataListNext from './pages/DataListNext' +import { + AINext, + BGPAlertsNext, + BGPNext, + CollectionManagementNext, + DataSourcesNext, + EarthContentNext, + SettingsNext, + SituationalAlertsNext, + SystemAlertsNext, +} from './pages/PlainResourcePages' +import UsersNext from './pages/UsersNext' +import LogsNext from './pages/LogsNext' +import { ToastProvider } from './components/ui/toast' +import { AdminSearchProvider } from './search/AdminSearchContext' +import './styles.css' + +export default function AdminNextRoutes() { + return ( + + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ) +} diff --git a/frontend/src/admin-next/components/data-table/DataTable.tsx b/frontend/src/admin-next/components/data-table/DataTable.tsx new file mode 100644 index 00000000..bafbb036 --- /dev/null +++ b/frontend/src/admin-next/components/data-table/DataTable.tsx @@ -0,0 +1,191 @@ +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type SortingState, +} from '@tanstack/react-table' +import { ArrowDown, ArrowUp, ChevronsUpDown } from 'lucide-react' +import { useMemo, useState } from 'react' +import TableScrollRegion from '../../../components/Scrollbar/TableScrollRegion' +import { Button } from '../ui/button' + +interface DataTableProps { + columns: Array> + data: TData[] + getRowId?: (row: TData, index: number) => string + getRowClassName?: (row: TData) => string | undefined + selection?: { + selectedRowIds: Set + onToggleAllVisible: (rowIds: string[]) => void + onToggleRow: (rowId: string, row: TData) => void + getCheckboxLabel?: (row: TData) => string + isRowSelectable?: (row: TData) => boolean + } + loading?: boolean + emptyText?: string + className?: string + footer?: React.ReactNode + onRowClick?: (row: TData) => void +} + +export function DataTable({ + columns, + data, + getRowId, + getRowClassName, + selection, + loading = false, + emptyText = '暂无数据', + className = '', + footer, + onRowClick, +}: DataTableProps) { + const [sorting, setSorting] = useState([]) + const memoizedColumns = useMemo(() => columns, [columns]) + + const table = useReactTable({ + data, + columns: memoizedColumns, + state: { sorting }, + onSortingChange: setSorting, + getRowId, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + }) + const visibleSelectableRows = selection + ? table.getRowModel().rows.filter((row) => selection.isRowSelectable?.(row.original) ?? true) + : [] + const visibleSelectableIds = visibleSelectableRows.map((row) => row.id) + const allVisibleSelected = visibleSelectableIds.length > 0 && visibleSelectableIds.every((rowId) => selection?.selectedRowIds.has(rowId)) + const someVisibleSelected = visibleSelectableIds.some((rowId) => selection?.selectedRowIds.has(rowId)) + const columnCount = columns.length + (selection ? 1 : 0) + + return ( +
+ +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {selection ? ( + + ) : null} + {headerGroup.headers.map((header) => { + const sorted = header.column.getIsSorted() + const stickyEnd = header.column.id === 'actions' || header.column.id === 'action' + return ( + + ) + })} + + ))} + + + {loading ? ( + + + + ) : table.getRowModel().rows.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(row.original) : undefined} + data-clickable={onRowClick ? 'true' : undefined} + > + {selection ? ( + + ) : null} + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + ) : ( + + + + )} + +
+ { + if (element) element.indeterminate = someVisibleSelected && !allVisibleSelected + }} + onChange={() => selection.onToggleAllVisible(visibleSelectableIds)} + /> + + {header.isPlaceholder ? null : ( + + )} +
+
+ + 加载中 +
+
+ event.stopPropagation()} + onChange={() => selection.onToggleRow(row.id, row.original)} + /> + + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
{emptyText}
+
+
+
+ {footer ?
{footer}
: null} +
+ ) +} + +export function DataTablePager({ + page, + pageSize, + total, + onPageChange, +}: { + page: number + pageSize: number + total: number + onPageChange: (page: number) => void +}) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)) + return ( +
+ + 第 {page} / {totalPages} 页,共 {total.toLocaleString()} 条 + +
+ + +
+
+ ) +} diff --git a/frontend/src/admin-next/components/layout/AdminNextLayout.tsx b/frontend/src/admin-next/components/layout/AdminNextLayout.tsx new file mode 100644 index 00000000..610aa698 --- /dev/null +++ b/frontend/src/admin-next/components/layout/AdminNextLayout.tsx @@ -0,0 +1,309 @@ +import { + ChevronDown, + LogOut, + Menu, + Moon, + Monitor, + Search, + Sun, + X, +} from 'lucide-react' +import { type FocusEvent, type KeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +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 { useAuthStore } from '../../../stores/auth' +import { useAdminTheme, type AdminThemeMode } from '../../design/theme' +import { cn } from '../../lib/utils' +import { adminRouteGroups, getVisibleAdminRoutes } from '../../routes/manifest' +import { useAdminSearch } from '../../search/AdminSearchContext' +import { Button } from '../ui/button' + +const DEFAULT_OPEN_MENU_KEY = 'collection' +let cachedOpenKeys: string[] = [DEFAULT_OPEN_MENU_KEY] +let cachedMenuScrollTop = 0 + +export function AdminNextLayout({ children }: { children: ReactNode }) { + const location = useLocation() + const navigate = useNavigate() + const adminSearch = useAdminSearch() + const { user, logout } = useAuthStore() + const { mode, setMode } = useAdminTheme() + const [collapsed, setCollapsed] = useState(false) + const [mobileNavOpen, setMobileNavOpen] = useState(false) + const [openKeys, setOpenKeys] = useState(cachedOpenKeys) + const [searchQuery, setSearchQuery] = useState('') + const [searchOpen, setSearchOpen] = useState(false) + const [highlightedSearchIndex, setHighlightedSearchIndex] = useState(0) + const menuViewportRef = useRef(null) + const searchInputRef = useRef(null) + const isSuperAdmin = user?.role === 'super_admin' + const visibleRoutes = useMemo(() => getVisibleAdminRoutes(isSuperAdmin), [isSuperAdmin]) + const navGroups = useMemo(() => { + return adminRouteGroups.map((group) => ({ + ...group, + children: visibleRoutes.filter((route) => route.group === group.key), + })).filter((group) => group.children.length > 0) + }, [visibleRoutes]) + const selectedKey = location.pathname === '/admin/' ? '/admin' : location.pathname.replace(/\/$/, '') + const activeRoute = visibleRoutes.find((route) => route.path === selectedKey) + const searchResults = useMemo(() => adminSearch.search(searchQuery), [adminSearch, searchQuery]) + const themeOptions = useMemo(() => [ + { value: 'light' as const, label: '浅色', title: '浅色', icon: }, + { value: 'system' as const, label: '系统', title: '跟随系统', icon: }, + { value: 'dark' as const, label: '深色', title: '深色', icon: }, + ], []) + + const updateOpenKeys = (nextKeys: string[]) => { + cachedOpenKeys = nextKeys + setOpenKeys(nextKeys) + } + + const selectSearchResult = (index = highlightedSearchIndex) => { + const target = searchResults[index] || searchResults[0] + if (!target) return + setSearchOpen(false) + setSearchQuery('') + setHighlightedSearchIndex(0) + searchInputRef.current?.blur() + adminSearch.openTarget(target) + } + + const handleSearchKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setSearchOpen(false) + setHighlightedSearchIndex(0) + searchInputRef.current?.blur() + return + } + if (event.key === 'ArrowDown') { + event.preventDefault() + setSearchOpen(true) + setHighlightedSearchIndex((index) => Math.min(index + 1, Math.max(searchResults.length - 1, 0))) + return + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setSearchOpen(true) + setHighlightedSearchIndex((index) => Math.max(index - 1, 0)) + return + } + if (event.key === 'Enter') { + event.preventDefault() + selectSearchResult() + } + } + + const handleSearchBlur = (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget as Node | null)) return + setSearchOpen(false) + setHighlightedSearchIndex(0) + } + + useEffect(() => { + void adminSearch.ensureDynamicIndex(searchQuery) + }, [adminSearch, searchQuery]) + + const nav = ( + <> +
setCollapsed(false) : undefined}> + + {!collapsed ? ( +
+ Planet + Admin Next +
+ ) : null} +
+ + + + + + {!collapsed ? ( +
+
+
+ Hi, {user?.username || '-'} +
+ +
+
+ 版本号 + v{packageJson.version} +
+ + ariaLabel="Admin Next 主题" + className="admin-next__theme-control admin-next__theme-control--sider" + options={themeOptions} + scale={0.72} + value={mode} + onChange={setMode} + /> +
+ ) : null} + + ) + + useLayoutEffect(() => { + const viewport = menuViewportRef.current + if (!viewport) return + const restoreScroll = () => { + viewport.scrollTop = cachedMenuScrollTop + } + restoreScroll() + const frameId = window.requestAnimationFrame(restoreScroll) + return () => window.cancelAnimationFrame(frameId) + }, [collapsed, location.pathname, openKeys]) + + useLayoutEffect(() => { + const viewport = menuViewportRef.current + if (!viewport) return + const cacheScroll = () => { + cachedMenuScrollTop = viewport.scrollTop + } + viewport.addEventListener('scroll', cacheScroll, { passive: true }) + return () => viewport.removeEventListener('scroll', cacheScroll) + }, []) + + return ( +
+ + {mobileNavOpen ? ( +
+
{nav}
+
+ ) : null} +
+
+ +
+ + { + setSearchQuery(event.target.value) + setSearchOpen(true) + setHighlightedSearchIndex(0) + }} + onFocus={() => setSearchOpen(true)} + onKeyDown={handleSearchKeyDown} + /> + {searchOpen ? ( +
+ {searchResults.length > 0 ? searchResults.map((target, index) => { + const ResultIcon = target.icon || Search + return ( + + ) + }) : ( +
{adminSearch.loading ? '正在加载搜索索引…' : '没有找到匹配内容'}
+ )} +
+ ) : null} +
+
+
{children}
+
+
+ ) +} diff --git a/frontend/src/admin-next/components/ui/badge.tsx b/frontend/src/admin-next/components/ui/badge.tsx new file mode 100644 index 00000000..4c1b93be --- /dev/null +++ b/frontend/src/admin-next/components/ui/badge.tsx @@ -0,0 +1,12 @@ +import { type HTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +type BadgeTone = 'default' | 'blue' | 'green' | 'amber' | 'red' | 'purple' | 'cyan' | 'slate' + +interface BadgeProps extends HTMLAttributes { + tone?: BadgeTone +} + +export function Badge({ className, tone = 'default', ...props }: BadgeProps) { + return +} diff --git a/frontend/src/admin-next/components/ui/button.tsx b/frontend/src/admin-next/components/ui/button.tsx new file mode 100644 index 00000000..4d975c71 --- /dev/null +++ b/frontend/src/admin-next/components/ui/button.tsx @@ -0,0 +1,61 @@ +import { forwardRef, type ButtonHTMLAttributes } from 'react' + +import { + TactileButton, + type TactileButtonProps, + type TactileButtonSize, + type TactileButtonVariant, + type TactileControlOptions, + type TactileIconValue, +} from '../../../components/tactile-ui' + +type AdminButtonVariant = 'default' | 'primary' | 'ghost' | 'subtle' | 'danger' | 'link' +type AdminButtonSize = 'sm' | 'md' | 'icon' + +function mapVariant(variant?: AdminButtonVariant): TactileButtonVariant { + if (variant === 'primary') return 'primary' + if (variant === 'danger') return 'danger' + if (variant === 'ghost' || variant === 'link') return 'ghost' + if (variant === 'subtle') return 'subtle' + return 'neutral' +} + +function mapSize(size?: AdminButtonSize): TactileButtonSize { + if (size === 'sm') return 'sm' + if (size === 'icon') return 'icon' + return 'md' +} + +export interface ButtonProps + extends Omit, 'size'> { + asChild?: boolean + loading?: boolean + icon?: TactileIconValue + iconSize?: number + size?: AdminButtonSize + tactile?: boolean | TactileControlOptions + variant?: AdminButtonVariant +} + +export const Button = forwardRef(function Button( + { asChild = false, loading = false, size, title, variant, ...props }, + ref, +) { + const tooltip = typeof title === 'string' ? title : undefined + const searchText = [tooltip, typeof props['aria-label'] === 'string' ? props['aria-label'] : undefined] + .filter(Boolean) + .join(' ') + const buttonProps = { + ...props, + asChild, + disabled: props.disabled || loading, + loading, + size: mapSize(size), + title: tooltip, + tooltip, + variant: mapVariant(variant), + 'data-admin-search-text': searchText || undefined, + } satisfies TactileButtonProps & { 'data-admin-search-text'?: string } + if (variant === 'link') buttonProps.tactile = false + return +}) diff --git a/frontend/src/admin-next/components/ui/card.tsx b/frontend/src/admin-next/components/ui/card.tsx new file mode 100644 index 00000000..4e1a6712 --- /dev/null +++ b/frontend/src/admin-next/components/ui/card.tsx @@ -0,0 +1,22 @@ +import { type HTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +export function Card({ className, ...props }: HTMLAttributes) { + return
+} + +export function CardHeader({ className, ...props }: HTMLAttributes) { + return
+} + +export function CardTitle({ className, ...props }: HTMLAttributes) { + return

+} + +export function CardDescription({ className, ...props }: HTMLAttributes) { + return

+} + +export function CardContent({ className, ...props }: HTMLAttributes) { + return

+} diff --git a/frontend/src/admin-next/components/ui/dialog.tsx b/frontend/src/admin-next/components/ui/dialog.tsx new file mode 100644 index 00000000..7780a5ad --- /dev/null +++ b/frontend/src/admin-next/components/ui/dialog.tsx @@ -0,0 +1,90 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { type ReactNode } from 'react' +import Scrollbar from '../../../components/Scrollbar/Scrollbar' +import { Button } from './button' + +interface DialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + children: ReactNode + footer?: ReactNode + width?: number +} + +export function Dialog({ open, onOpenChange, title, description, children, footer, width }: DialogProps) { + return ( + + + + +
+
+ {title} + {description ? ( + + {description} + + ) : null} +
+ + + +
+ {children} + {footer ?
{footer}
: null} +
+
+
+ ) +} + +interface ConfirmDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + title: string + description?: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean + loading?: boolean + onConfirm: () => void +} + +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = '确认', + cancelLabel = '取消', + danger = false, + loading = false, + onConfirm, +}: ConfirmDialogProps) { + return ( + + + + + )} + > + {description || '请确认本次操作。'} + + ) +} diff --git a/frontend/src/admin-next/components/ui/input.tsx b/frontend/src/admin-next/components/ui/input.tsx new file mode 100644 index 00000000..6b10bc70 --- /dev/null +++ b/frontend/src/admin-next/components/ui/input.tsx @@ -0,0 +1,14 @@ +import { forwardRef, type InputHTMLAttributes, type TextareaHTMLAttributes } from 'react' +import { cn } from '../../lib/utils' + +export const Input = forwardRef>( + ({ className, ...props }, ref) => , +) + +Input.displayName = 'Input' + +export const Textarea = forwardRef>( + ({ className, ...props }, ref) =>