Compare commits

..

22 Commits

Author SHA1 Message Date
ryan 18dee29b73 chore(release): v3.5.9
### ✨ 新功能
- 新增完整俄语界面,支持俄语浏览器语言识别、原生语言名称、日期与数字格式;内置错误页及离线页跟随界面语言。

### 🛠 修复
- 修复 Agent 自动注册时节点不断重复创建的问题;响应丢失或重试后仍复用原节点,不会覆盖管理员修改。
- 修复 IP 组按规则自动抓取时会收录内网、回环及链路本地等地址的问题;历史抓取结果会在下次同步时清理。
- 修复通知收件人误判,以及推送模板变量与富文本标签丢失的问题。
- 选择非默认语言时,页面首次绘制前即应用所选语言,避免刷新或跳转时短暂显示英文。
- 界面脚本加载失败时提供同语言的重新加载入口,避免出现空白页。

### ⚡️ 优化与改进
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,大规模节点场景下列表加载和轮询更轻量;详情页改为单节点查询。
- Agent、Relay 与 Flared 的自动更新检查改为每 6 小时一次,检查或下载更新失败时自动延长间隔重试(最长 3 小时),避免频繁请求触发 GitHub 限流;手动触发的立即更新不受限制。
- SQLite 数据库读写并发性能提升,数据库导出可完整包含最新已提交的数据。

### 💄 其他/体验
- 界面默认语言调整为英文,已有语言偏好保持不变;语言选择器固定显示各语言的原生名称。
- 统一并校对 Server、Agent、任务详情、通知与内置模板的英文文案,窄屏下 WAF 标题和通知模板说明的换行更合理。
2026-11-07 21:50:40 +08:00
ryan b60546a94d feat(updater): throttle auto-update checks with exponential backoff
Check GitHub releases at most every 6 hours and retry transient failures
(release query, checksum or binary download) with exponential backoff
from 1 minute up to 3 hours. Forced updates bypass the throttle.
2026-11-07 21:49:46 +08:00
dependabot[bot] 8867c822bb chore(deps): bump source-map-js from 1.2.1 to 1.2.2 in /docs (#61)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:37:09 +08:00
ryan 5f7e6d8907 feat(i18n): default to English, localize runtime text and add Russian (#52) 2026-11-07 21:35:55 +08:00
ArthurKoba 3c7b347d7b perf(nodes): paginate node lists and fetch details directly (#54)
* perf(nodes): paginate node lists and fetch details directly

* fix(nodes): preserve Unicode search and page transition state

* fix(nodes): validate search using the UTF-8 byte limit
2026-11-07 21:29:52 +08:00
ArthurKoba 72d67c80f2 fix(agent): make discovery enrollment compatible and idempotent (#53)
* fix(agent): make discovery enrollment compatible and idempotent

* fix(agent): confirm enrollment persistence and document token aliases
2026-11-07 21:29:47 +08:00
dependabot[bot] 1ca03df0ef chore(deps): bump next from 16.3.6 to 16.4.1 in /frontend (#57)
Bumps [next](https://github.com/vercel/next.js) from 16.3.6 to 16.4.1.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.3.6...v16.4.1)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.4.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:28:38 +08:00
dependabot[bot] d7c7ccb29b chore(deps): bump follow-redirects from 1.16.0 to 1.16.2 in /frontend (#60)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.2.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.2)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:52 +08:00
dependabot[bot] babead59e4 chore(deps): bump zod from 4.4.3 to 4.6.6 in /frontend (#59)
Bumps [zod](https://github.com/colinhacks/zod) from 4.4.3 to 4.6.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.4.3...v4.6.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.6.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:46 +08:00
dependabot[bot] 5db05bd18d chore(deps): bump undici from 8.11.2 to 8.12.3 in /frontend (#56)
Bumps [undici](https://github.com/nodejs/undici) from 8.11.2 to 8.12.3.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v8.11.2...v8.12.3)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 8.12.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:35 +08:00
dependabot[bot] bcdb2775d7 chore(deps): bump dompurify from 3.4.16 to 3.4.17 in /frontend (#55)
Bumps [dompurify](https://github.com/cure53/DOMPurify) from 3.4.16 to 3.4.17.
- [Release notes](https://github.com/cure53/DOMPurify/releases)
- [Commits](https://github.com/cure53/DOMPurify/compare/3.4.16...3.4.17)

---
updated-dependencies:
- dependency-name: dompurify
  dependency-version: 3.4.17
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:29 +08:00
dependabot[bot] 47c47fea2c chore(deps): bump vite from 8.1.4 to 8.3.5 in /frontend (#50)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.1.4 to 8.3.5.
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:26:23 +08:00
dependabot[bot] 32cec2ac6e chore(deps): bump vite from 7.3.3 to 7.3.7 in /docs (#51)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.3 to 7.3.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.7/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:59 +08:00
dependabot[bot] 6a2503cf7a chore(deps): bump markdown-it from 14.3.2 to 14.3.3 in /docs (#49)
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.2 to 14.3.3.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/14.3.3/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.2...14.3.3)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:54 +08:00
dependabot[bot] e2acbecb42 chore(deps): bump github.com/go-jose/go-jose/v4 from 4.1.4 to 4.1.6 (#58)
Bumps [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose) from 4.1.4 to 4.1.6.
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.4...v4.1.6)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.6
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-07 21:25:48 +08:00
Arthur Koba b4e94076c9 feat(i18n): add Russian UI and harden localization 2026-10-25 22:35:32 +03:00
Arthur Koba 65a96670d5 fix(i18n): use English runtime text and localize system UI 2026-10-25 19:44:15 +03:00
Noru Wyrms ada0550571 fix(sqlite): enable WAL and export consistent snapshots 2026-10-25 02:27:05 +00:00
ryan 02a2b79c08 fix(waf): exclude internal IPs from automatic IP groups 2026-10-16 00:02:09 +08:00
ryan 0e1ce40cbb docs(readme): update project description and deployment instructions 2026-10-11 21:38:30 +08:00
ryan cf7613dade docs(readme): update project description and deployment instructions 2026-10-11 21:32:08 +08:00
ryan 53f2e58ced docs(readme): update project description and deployment instructions 2026-10-11 21:28:14 +08:00
424 changed files with 20626 additions and 4865 deletions
+4
View File
@@ -33,3 +33,7 @@ jobs:
- name: Run ESLint
working-directory: frontend
run: npx eslint . --max-warnings 0
- name: Check translation catalogs and runtime language
working-directory: frontend
run: node scripts/merge-i18n-fragments.mjs && pnpm check:i18n
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
// if any unchecked, fail the workflow
if (unchecked.length > 0) {
core.setFailed(`PR checklist 未全部勾选,请确保所有 checklist 项都已勾选。未勾选项如下:\n${unchecked.join('\n')}`);
core.setFailed(`Complete all PR checklist items. Unchecked items:\n${unchecked.join('\n')}`);
} else {
console.log('all checklist items are checked.');
}
+1
View File
@@ -87,3 +87,4 @@ profile.cov
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
frontend/messages/zh-CN.json
frontend/messages/en.json
frontend/messages/ru.json
+1 -1
View File
@@ -45,7 +45,7 @@ code-check:
exit 1; \
fi
golangci-lint run
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm check:i18n && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
build-backend:
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
+145 -69
View File
@@ -2,12 +2,10 @@
# OpenFlare
**Next-Gen Distributed Reverse Proxy & Edge Security Gateway for OpenResty / Nginx**
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
</div>
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
@@ -20,65 +18,120 @@ OpenFlare is an open-source CDN orchestration and edge security platform. It sup
</a>
</p>
> [!WARNING]
> After the first login with the `admin` user, you must change the default password `12345678`.
>
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
</div>
## Documentation
---
**https://openflare.fyrn.link**
## 📖 What is OpenFlare?
Common entry points:
**OpenFlare** is a modern, open-source **distributed reverse proxy and edge security gateway platform** designed for multi-server infrastructures and edge clusters.
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
* [System Design](https://openflare.fyrn.link/design/)
Rather than a simple single-host admin panel, OpenFlare empowers traditional OpenResty / Nginx nodes with **centralized cluster orchestration, dynamic zero-reload sync, line-rate edge WAF defense, and secure in-network tunneling**:
## Core Capabilities
* ⚡ **Zero-Reload Dynamic Orchestration**: Centralized control plane manages any number of edge nodes. Synchronize rule diffs in milliseconds with zero Nginx reload disruptions.
* 🛡️ **Built-in Edge Security & Anti-CC (Turnstile Alternative)**: Comprehensive WAF rule engine, MaxMind GeoIP regional ACLs, and **client-side cryptographic Proof of Work (PoW) challenges** to eliminate botnets and CC attacks at line rate.
* 🚇 **Zero-Trust Tunnels (Cloudflare Tunnel Alternative)**: Securely publish private homelab and internal web services to edge proxies via lightweight `openflared` clients without public IPs or open inbound ports.
* 📦 **Pages Edge Hosting**: Host static sites and SPAs pulled directly from GitHub Releases or uploaded bundles, distributed across edge nodes with instant rollbacks and fallback routing.
* 🔒 **Automated Certificates & SSO**: Automatic multi-domain Let's Encrypt certificates via ACME and enterprise single sign-on with GitHub OAuth and standard OIDC.
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
---
## Interface Preview
## 🌐 Flexible Deployment: Standalone or Behind Cloudflare
OpenFlare is designed to operate seamlessly either as an independent edge gateway or in harmony with Cloudflare and public CDNs:
### 1. Standalone Edge Gateway (Private CDN)
Ideal for developers managing multi-region VPS clusters or homelabs:
* Deploy your own high-availability CDN and edge proxy cluster with 100% data and privacy ownership.
* Protect origin sites with built-in Turnstile-like PoW human challenges, dynamic WAF, and automated SSL certs without expensive enterprise CDN security plans.
### 2. Behind Cloudflare (Hardened Origin Shield)
Functions as an intelligent **origin shield and routing gateway** behind Cloudflare:
* **Leaked Real IP Protection**: If attackers bypass Cloudflare and directly hit your origin's public IP address, OpenFlare's edge WAF and PoW challenges will immediately intercept and block the attack.
* **Full Private Log & Metric Retention**: Overcomes Cloudflare Free tier limitations by retaining comprehensive, searchable request logs and operational metrics locally.
* **Unified In-Network Routing**: Ingest incoming Cloudflare traffic and dynamically route it across internal backends or private homelab services through secure tunnels.
---
## 📊 Comparison: Traditional Nginx vs NPM vs OpenFlare
| Feature | Traditional Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **Cluster Architecture** | Manual per-host editing via SSH | Single node only; no cluster sync | **Native Distributed Control Plane + Edge Agents** |
| **Configuration Sync** | Requires `nginx -s reload` (connection churn) | Reloads on every change | **Shared memory dynamic sync (Zero-Reload)** |
| **Anti-CC / Bot Protection** | Basic `limit_req` rate limiting only | Not supported | **Built-in Turnstile-like PoW Client Challenge** |
| **In-Network Tunnels** | Requires separate third-party tools (frp) | Not supported | **Built-in OpenFlared Tunnels (Zero inbound ports)** |
| **WAF & Threat Defense** | Requires manual ModSecurity compilation | Basic exploit rules only | **Dynamic WAF, GeoIP, IP list checksum sync** |
| **Static Pages Hosting** | Manual directory copying and setup | Static file proxying only | **Automated GitHub Release deployment & rollbacks** |
| **Works with Cloudflare** | Manual `set_real_ip_from` list maintenance | Complex proxy header tuning | **Native real IP restoration, ideal origin shield** |
---
## 🏗️ Architecture
```mermaid
flowchart TD
subgraph Visitors ["Public Visitors & Clients"]
User["Public Users / Browsers"]
CF["Cloudflare CDN (Optional Edge Layer)"]
end
subgraph EdgePlane ["Edge Data Plane (OpenResty Agent Nodes)"]
Edge1["Edge Node A (OpenResty)"]
Edge2["Edge Node B (OpenResty)"]
end
subgraph ControlPlane ["Control Plane"]
Server["OpenFlare Server (Web Dashboard)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["Upstream & Internal Services"]
Origin["Public Origin Web Server"]
subgraph PrivateNet ["Private Network / Homelab"]
NAS["Internal Apps / NAS"]
Client["OpenFlared Tunnel Client"]
end
end
User -->|Direct HTTPS Request| Edge1
User -->|Proxied via Cloudflare| CF
CF -->|Secure Upstream Traffic| Edge2
Server <-->|Dynamic Sync / Metrics| Edge1
Server <-->|Dynamic Sync / Metrics| Edge2
Server --- DB
Edge1 -->|Reverse Proxy| Origin
Edge2 -->|Reverse Proxy| Origin
Client <-->|Encrypted Tunnel| Edge1
NAS --- Client
```
---
## 🖥️ UI Preview
### Dashboard Overview
![OpenFlare Dashboard Overview](./docs/assets/readme/dashboard-overview.png)
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### Edge WAF & Access Rules
![OpenFlare WAF Protection](./docs/assets/readme/waf.png)
### Access Logs
### Domain & Traffic Analytics
![OpenFlare Domain Overview](./docs/assets/readme/domain_overview.png)
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
---
### WAF Protection
## ⚡ Quick Start
![OpenFlare version release](./docs/assets/readme/waf.png)
### 1. Launch OpenFlare Server
## Quick Start
### Hardware Configuration Recommendations
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|------------------------|-----------------------------------|-----------------------------------|-------|
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
### 1. Start the Server
Use `docker-compose`:
Deploy the control plane using `docker-compose`:
```bash
# Download environment variable template and create .env file
# Download environment template
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -130,46 +183,69 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
* **Dashboard URL**: `http://localhost:3000`
* **Default Credentials**: `admin` / `12345678`
Access address: `http://localhost:3000`
> [!WARNING]
> Change the default password `12345678` immediately upon first login.
> The BETA version is in active development and testing; avoid using it in mission-critical production environments without regular backups.
Default account:
### 2. Connect Edge Agent Node
* Username: `admin`
* Password: `12345678`
### 2. Install Agent
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
#### Docker Deployment
Docker deployment can directly run the Agent image:
Run the Agent container on any edge server (ensure ports 80/443 are open):
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>:3000 \
-e OPENFLARE_AGENT_TOKEN=<YOUR_AGENT_TOKEN> \
ghcr.io/rain-kl/openflare-agent:latest
```
*(Copy the node token directly from Node Management in the dashboard)*
## Open Source License
---
## 📚 Documentation
Official Documentation: **[https://openflare.fyrn.link](https://openflare.fyrn.link)**
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
* [System Design](https://openflare.fyrn.link/design/)
<details>
<summary><b>🔍 Minimum & Recommended Hardware Specifications</b></summary>
| Component | Minimum Requirements | Recommended Requirements | Note |
| :--- | :--- | :--- | :--- |
| **Server Control Plane** | 1 CPU / 2 GB RAM / 20 GB Disk | 2 CPU / 4 GB RAM / 50 GB+ Disk | Scale disk based on access log retention |
| **Agent Data Plane** | 1 CPU / 512 MB RAM / 2 GB Disk | 2 CPU / 2 GB RAM / 10 GB+ Disk | Scale based on concurrent connections & WAF load |
| **Relay Node** | 1 CPU / 1 GB RAM / 5 GB Disk | 2 CPU / 2 GB RAM / 20 GB Disk | Mainly bound by bandwidth and CPU network throughput |
| **OpenFlared Client** | 1 CPU / 256 MB RAM / 1 GB Disk | 1 CPU / 512 MB RAM / 5 GB Disk | Extremely lightweight client |
</details>
---
## ⚖️ Trademark Disclaimer
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
---
## 📄 License
This project is licensed under the [Apache License 2.0](./LICENSE).
## Star History
## ⭐ Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
+146 -67
View File
@@ -2,11 +2,10 @@
# OpenFlare
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
**新一代分布式反向代理与边缘安全网关**
*Next-Gen Distributed Reverse Proxy & Edge Gateway for OpenResty / Nginx*
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
</div>
**[简体中文](./README.zh-CN.md) | [English](./README.md)**
<p align="center">
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
@@ -20,65 +19,120 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
</a>
</p>
> [!WARNING]
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
>
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
</div>
## 文档
---
**https://openflare.fyrn.link**
## 📖 什么是 OpenFlare?
常用入口:
**OpenFlare** 是一套面向多服务器与分布式集群的**新一代开源反向代理与边缘安全网关平台**。
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
* [系统设计](https://openflare.fyrn.link/design/)
它并非简单的单机管理面板,而是将传统的 OpenResty / Nginx 升级为具备**多节点集群化编排、动态热更新、边缘 WAF 防护以及内网穿透能力**的现代化边缘网络基础设施:
## 核心能力
* ⚡ **告别手写配置与 Reload 抖动**:单一控制面板管理任意数量的边缘节点,配置差分毫秒级热同步(Zero-Reload),业务流量平滑无感。
* 🛡️ **开箱即用的边缘安全防御**:内置 WAF 规则引擎、国家级 GeoIP 访问控制,以及**类似 Cloudflare Turnstile 的客户端密码学 PoW 防 CC 人机验证(5 秒盾)**,在边缘秒级阻断僵尸网络。
* 🚇 **原生安全内网穿透(Tunnels)**:无需公网 IP 和复杂路由器端口映射,通过轻量级 `openflared` 客户端打通加密隧道,安全将内网 Web 服务接入边缘网关并享受全套安全防护。
* 📦 **静态 Pages 边缘托管**:支持从 GitHub Release 产物或直传静态包全自动部署至各边缘节点本地分发,支持多版本回滚与 SPA 路由。
* 🔒 **自动化证书与统一认证**:ACME 协议全自动申请与续期 Let's Encrypt 多域名证书,支持 GitHub OAuth 和标准 OIDC 企业单点登录。
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
---
## 界面预览
## 🌐 灵活的部署模式:独立自建 vs 协同 Cloudflare
### 仪表盘总览
OpenFlare 既可以作为独立的边缘网关使用,也可以与 Cloudflare 等公网 CDN 完美协作互补:
![OpenFlare dashboard overview](./docs/assets/readme/dashboard-overview.png)
### 1. 独立自建模式 (Standalone Edge Gateway)
适用于多台 VPS、跨地域云服务器或 Homelab:
* 自主搭建私有 CDN 与反向代理集群,数据和流量 100% 本地掌控。
* 拥有完整的边缘防 CC 人机验证、自建 SSL 证书管理与内网穿透能力,无需采购昂贵的商业安全套餐。
### 访问日志
### 2. Cloudflare 源站防护盾模式 (Behind Cloudflare / Origin Shield)
作为 Cloudflare 背后坚固的**源站集群网关**:
* **防范源站真实 IP 泄露**:即使攻击者通过历史解析或漏洞绕过 Cloudflare 直连你的源站真实 IP,OpenFlare 边缘的 WAF 与 PoW 人机挑战仍会在第一时间将其阻断。
* **全量私有访问日志沉淀**:弥补商业 CDN 免费版日志保留极短、明细不足的痛点,全量审计请求日志与监控指标。
* **统一内网与跨服路由**:由 OpenFlare 统一承接来自 Cloudflare 的流量,智能分发至源站多服务或内网穿透应用。
![OpenFlare version release](./docs/assets/readme/domain_overview.png)
---
### WAF 防护
## 📊 横向对比:传统 Nginx vs Nginx Proxy Manager vs OpenFlare
![OpenFlare version release](./docs/assets/readme/waf.png)
| 维度 | 传统 Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **集群架构** | 每台机器独立维护,频繁 SSH | 仅支持单机,无法多节点集群协同 | **原生分布式控制面 + 边缘 Agent 集群** |
| **配置生效方式** | 每次需 `nginx -s reload`,连接可能抖动 | 频繁 Reload | **共享内存/差分热同步,Zero-Reload** |
| **防 CC / 爬虫人机验证** | 仅支持简单的 `limit_req` 频率限制 | 不支持 | **内置类似 Turnstile 的 PoW 客户端挑战** |
| **内网穿透 (Tunnels)** | 需繁琐集成第三方程式 (如 frp) | 不支持 | **内置 OpenFlared 穿透隧道,开箱即用** |
| **WAF 与安全防护** | 需复杂编译配置 ModSecurity | 仅基础 Exploits 规则 | **动态 WAF、国家级 GeoIP、IP 组差分准入** |
| **静态 Pages 托管** | 手动上传并配置静态目录 | 仅静态文件代理 | **自动化拉取 GitHub Release,版本回滚** |
| **配合 Cloudflare** | 手动配置 `set_real_ip_from` 极其繁琐 | 需繁琐设置代理头部 | **原生还原真实 IP,完美充当源站护盾** |
## 快速开始
---
### 硬件配置推荐
## 🏗️ 架构拓扑
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
| --- |-------------------------------| --- | --- |
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
```mermaid
flowchart TD
subgraph Visitors ["公网访客与客户端"]
User["公网访客 / 移动端"]
CF["Cloudflare CDN (可选代理层)"]
end
### 1. 启动 Server
subgraph EdgePlane ["边缘数据面 (OpenResty Agent 节点)"]
Edge1["边缘节点 A (OpenResty)"]
Edge2["边缘节点 B (OpenResty)"]
end
使用 docker-compose
subgraph ControlPlane ["控制面 (Control Plane)"]
Server["OpenFlare Server (Web 控制台)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["源站与内网服务"]
Origin["公网源站 Web 服务"]
subgraph PrivateNet ["私有网络 / Homelab"]
NAS["私有应用 / NAS"]
Client["OpenFlared 穿透客户端"]
end
end
User -->|直连 HTTPS 访问| Edge1
User -->|经 Cloudflare 代理| CF
CF -->|安全回源| Edge2
Server <-->|差分热同步 / 指标上报| Edge1
Server <-->|差分热同步 / 指标上报| Edge2
Server --- DB
Edge1 -->|反向代理| Origin
Edge2 -->|反向代理| Origin
Client <-->|加密穿透隧道| Edge1
NAS --- Client
```
---
## 🖥️ 界面预览
### 仪表盘与实时观测
![OpenFlare 仪表盘总览](./docs/assets/readme/dashboard-overview.png)
### 边缘 WAF 与访问控制
![OpenFlare WAF 防护](./docs/assets/readme/waf.png)
### 域名与流量分析
![OpenFlare 域名访问概览](./docs/assets/readme/domain_overview.png)
---
## ⚡ 快速开始
### 1. 启动 Server 控制端
使用 `docker-compose` 快速启动控制面:
```bash
# 下载环境变量模板并创建 .env 文件
# 获取环境变量模板
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
cp .env.example .env
```
@@ -130,46 +184,71 @@ services:
start_period: 5s
volumes:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
openflare_uploads:
openflare_postgres_data:
openflare_redis_data:
```
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
* **访问控制台**:`http://localhost:3000`
* **默认账号密码**:`admin` / `12345678`
访问地址:`http://localhost:3000`
> [!WARNING]
> 初次登录后,请务必在后台第一时间修改默认密码 `12345678`。
> BETA 版本处于开发与测试阶段,生产环境请提前做好数据备份。
默认账号:
### 2. 部署边缘 Agent 节点
* 用户名:`admin`
* 密码:`12345678`
### 2. 安装 Agent
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
#### Docker 部署
Docker 部署可直接运行 Agent 镜像:
在任意作为边缘代理的服务器上执行(需开放 80/443 端口):
```bash
docker pull ghcr.io/rain-kl/openflare-agent:latest
docker rm -f openflare-agent 2>/dev/null || true
docker run -d --name openflare-agent --restart unless-stopped \
-p 80:80 -p 443:443/tcp -p 443:443/udp \
-v openflare-agent-pages:/data/var/lib/openflare/pages \
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
-e OPENFLARE_SERVER_URL=http://<YOUR_SERVER_IP>:3000 \
-e OPENFLARE_AGENT_TOKEN=<YOUR_AGENT_TOKEN> \
ghcr.io/rain-kl/openflare-agent:latest
```
*(Token 可在管理后台的「节点管理 -> 详情 -> 节点信息」中直接一键生成与复制)*
## 开源协议
---
本项目采用 [Apache License 2.0](./LICENSE) 开源。
## 📚 官方文档
## Star History
完整配置与进阶部署详见官方文档:**[https://openflare.fyrn.link](https://openflare.fyrn.link)**
* [快速入门指南](https://openflare.fyrn.link/guide/quick-start)
* [生产部署与架构说明](https://openflare.fyrn.link/deployment/deployment)
* [配置项详细参考](https://openflare.fyrn.link/reference/configuration)
* [系统底层设计](https://openflare.fyrn.link/design/)
<details>
<summary><b>🔍 查看各组件最低与推荐硬件配置要求</b></summary>
| 组件 | 最低硬件配置 | 推荐硬件配置 | 说明 |
| :--- | :--- | :--- | :--- |
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 根据访问日志存储时长和吞吐量灵活规划 |
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 取决于并发代理量与 WAF 规则量 |
| **Relay 中继节点** | 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 主要受网络带宽与并发吞吐限制 |
| **OpenFlared 客户端** | 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 极低内存占用,保证网络连接畅通即可 |
</details>
---
## ⚖️ 商标免责声明 (Trademark Disclaimer)
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
OpenFlare 是一个由社区驱动的独立开源项目,与 Cloudflare, Inc. 无任何附属、认可或赞助关系。Cloudflare 及其相关商标均为其各自持有者的财产。
---
## 📄 开源协议
本项目基于 [Apache License 2.0](./LICENSE) 协议开源。
## ⭐ Star History
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
<picture>
+34 -8
View File
@@ -8,6 +8,40 @@ sidebar: false
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
## 重大变更
> [!IMPORTANT]
>
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
>
## [Unreleased]
## [v3.5.9] - 2026-11-07
### ✨ 新功能
- 新增完整俄语界面,支持俄语浏览器语言识别、原生语言名称、日期与数字格式;内置错误页及离线页跟随界面语言。
### 🛠 修复
- 修复 Agent 自动注册时节点不断重复创建的问题;响应丢失或重试后仍复用原节点,不会覆盖管理员修改。
- 修复 IP 组按规则自动抓取时会收录内网、回环及链路本地等地址的问题;历史抓取结果会在下次同步时清理。
- 修复通知收件人误判,以及推送模板变量与富文本标签丢失的问题。
- 选择非默认语言时,页面首次绘制前即应用所选语言,避免刷新或跳转时短暂显示英文。
- 界面脚本加载失败时提供同语言的重新加载入口,避免出现空白页。
### ⚡️ 优化与改进
- 节点管理页新增服务端分页及名称、节点标识、IP、状态和类型筛选,大规模节点场景下列表加载和轮询更轻量;详情页改为单节点查询。
- Agent、Relay 与 Flared 的自动更新检查改为每 6 小时一次,检查或下载更新失败时自动延长间隔重试(最长 3 小时),避免频繁请求触发 GitHub 限流;手动触发的立即更新不受限制。
- SQLite 数据库读写并发性能提升,数据库导出可完整包含最新已提交的数据。
### 💄 其他/体验
- 界面默认语言调整为英文,已有语言偏好保持不变;语言选择器固定显示各语言的原生名称。
- 统一并校对 Server、Agent、任务详情、通知与内置模板的英文文案,窄屏下 WAF 标题和通知模板说明的换行更合理。
## [v3.5.8] - 2026-10-08
### ✨ 新功能
@@ -75,14 +109,6 @@ sidebar: false
### 💄 其他/体验
- 前端使用 `next/font` 自托管 Inter 字体,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。
## 重大变更
> [!IMPORTANT]
>
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
>
## [v3.5.3] - 2026-08-13
### 新增
+1 -1
View File
@@ -2,7 +2,7 @@ import {type DefaultTheme, defineAdditionalConfig} from 'vitepress'
export default defineAdditionalConfig({
description:
'OpenFlare 是轻量、自托管的 OpenResty 控制面,用于管理反向代理、配置发布、节点同步、TLS 证书与基础观测。',
'OpenFlare 是新一代分布式反向代理与边缘安全网关,为多节点 OpenResty / Nginx 提供集群编排、Zero-Reload 动态配置同步、边缘 WAF、PoW 防 CC 盾与内网穿透。',
themeConfig: {
nav: nav(),
+17 -4
View File
@@ -32,15 +32,28 @@ Agent 主要由以下核心子模块组成,共同配合完成其完整的生
---
## 节点列表与详情查询
节点管理页使用管理员接口 `GET /api/v1/d/nodes/page`,返回 `data: {total, results, page, page_size}`。默认每页 20 条,最大 100 条;按数据库 ID 降序排列,可通过 `search`(名称、节点标识或 IP 的字面子串,最多 256 字节)、`status` 和 `node_type` 进行服务端筛选。`pending` 表示尚未上报心跳,最近 60 秒内上报的节点为 `online`,其余为 `offline`;筛选和返回状态使用同一时间基准。未设置类型的旧节点按 `edge_node` 处理。搜索保留原始 Unicode 字符的字面匹配,同时兼容 ASCII 大小写折叠;SQLite 不保证完整的 Unicode 大小写不敏感匹配。前端使用相同的 256 字节 UTF-8 上限校验去除首尾空白后的搜索词,超限时保留上次有效查询并显示提示,缩短输入后恢复搜索。
配置下发记录仅为当前页节点批量加载;详情页通过 `GET /api/v1/d/nodes/{id}` 获取单个节点及相同的下发元数据。列表与详情每 15 秒刷新,在后台、离线或请求出错时暂停定时刷新,可通过窗口聚焦、网络恢复或手动刷新重试。筛选或每页数量变化后回到第一页,删除末页最后一条记录后自动回到有效页。同一筛选条件下切页时保留已加载的表格与总数,页码仍显示已加载页,等待响应期间禁用节点编辑、删除和详情入口;改变筛选条件时不会显示上一筛选的节点,请求失败时可手动重试。
为兼容现有集成和节点选择器,原 `GET /api/v1/d/nodes` 仍返回完整数组,未改变含义;选择器尚未迁移为分页搜索。仪表盘仍为全局统计和地图加载全部节点,并使用现有缓存;本次没有截断全局总数或地图数据。分页计数和当前页是独立查询,节点并发增删时可能短暂不一致,下一次刷新会更新。普通列表和类型筛选利用主键及类型索引,状态筛选可使用心跳时间索引;字面子串搜索仍可能扫描匹配候选,未宣称 B-tree 索引能加速任意子串。
---
## 与 Server 的交互链路
Agent 在生命周期中主要通过 **基于 Token 的自动注册** 和 **心跳/WebSocket 双通道** 与控制面通信。
### 1. 自动注册流程
若 Agent 启动时本地 `agent.json` 的 `access_token` 为空,但配置了 `discovery_token`,将触发自动注册流程:
1. Agent 向控制面 `/api/v1/agent/nodes/register` 发送注册请求,携带本地硬件摘要、IP 及主机名。
2. Server 校验 `discovery_token` 有效后,在数据库生成唯一的 `NodeID` 与专属 `AccessToken`(即 `agent_token`)并返回。
3. Agent 将获取的专用 Token 写入本地配置文件,擦除一次性 `discovery_token`,后续所有的通信均基于专属 `AccessToken` 进行鉴权认证。
若 Agent 启动时本地 `agent.json` 的 `agent_token` 为空,但配置了 `discovery_token`,将触发自动注册流程:
1. Agent 在本地状态文件原子保存随机 256 位 `registration_key`,再向控制面 `/api/v1/agent/nodes/register` 发送注册请求,携带该重试凭证、本地硬件摘要、IP 及主机名。支持目录刷盘的平台会在文件刷盘及重命名后刷新所在目录;目录刷盘失败会返回错误,已有凭证也必须在重试时确认目录刷盘成功后才能发送。Windows 保留文件刷盘与替换,但目录持久化仅为尽力而为;上述行为针对所在目录已存在的文件更新,不保证所有文件系统或突然断电场景下的完整持久性。该凭证仅用于注册,不进入心跳、WebSocket 或观测数据,必须与节点 Token 一样保密。
2. Server 校验当前 `discovery_token` 有效后,从重试凭证使用不同的哈希域分别生成 `NodeID` 与专属 Token。相同凭证的重复或并发请求只保留一个节点,不按 IP、主机名或公开 `node_id` 合并,也不会覆盖管理员修改的名称、升级标志等数据。响应以 `agent_token` 为准,同时保留旧的 `access_token` 字段;新版 Agent 能识别两种字段,值冲突时拒绝响应。
3. Agent 原子保存服务端分配的节点 ID 和配置 Token,全部写入成功后才切换运行时鉴权并清空配置中的 `discovery_token`,后续通信均使用节点专属 Token。响应丢失或配置写入失败时,重启仍沿用原重试凭证;状态文件中的重试凭证与节点 ID 不会被旧的后台任务快照覆盖。
4. 注册失败按 3、6、12、24、48、60 秒退避,之后每次至少间隔 60 秒重试(实际请求在下一个心跳定时器周期触发),不再随每次心跳立即注册。成功后重置退避。
若节点 Token 已被清空或更换,旧重试凭证不能取回新 Token 或恢复旧 Token,必须使用管理员分配的节点凭证。物理删除节点不构成永久封禁:持有当前有效 discovery Token 的客户端仍能重新注册。旧版 Agent 不携带 `registration_key`,修复后的响应可使其正常完成注册,但其响应丢失后的重试仍不具备幂等保证。请保留每个实例独立的持久化状态与配置,不要把已初始化的状态目录复制给其他实例。
### 2. 双通道心跳与同步机制
* **HTTP 轮询通道(兜底与探测)**:Agent 默认按设定的 `heartbeat_interval` 间隔发送 POST 心跳包。上报指标的同时获取当前激活版本的摘要信息(Version & Checksum)。
+6 -5
View File
@@ -2,18 +2,19 @@
你会学到:OpenFlare 是什么、当前稳定能力,以及开发时应遵守的核心产品边界与仓库结构目录分工。
OpenFlare 是一套自托管的 OpenResty 控制面,面向单团队或单组织内部运维场景。
OpenFlare 是一套新一代分布式反向代理与边缘安全网关平台,面向多服务器集群与单团队运维场景。
---
## 项目定位
OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具备以下定位:
OpenFlare 适合需要统一管理多台 OpenResty 代理节点、构建自建高可用边缘网络,或为源站搭建坚固防护网关的团队,具备以下定位:
* **控制与落地分离**:Server 控制面不直接 SSH 到代理节点,而是通过 Agent 主动拉取版本并应用。
* **不可变配置发布**:采用完整的配置版本进行预览、发布、激活和一键回滚。
* **一体化网关托管**:在同一个控制面内集成网站反代、TLS 证书自动续期申请、WAF 防护拦截、内网穿透(Tunnel)以及 Pages 静态网站托管。
* **Zero-Reload 动态配置同步**:基于 Lua 共享内存实现差分热更新,避免传统 Nginx 频繁 reload 导致的连接抖动。
* **一体化边缘安全网关**:在同一个控制面内集成反向代理、TLS 证书自动续期、WAF 防护拦截、Turnstile-like 客户端 PoW 防 CC 盾、内网穿透(Tunnel)以及 Pages 静态网站托管。
* **双重部署生态适应性**:既支持作为独立私有边缘 CDN 运行,亦可作为 Cloudflare 背后坚固的源站防护网关(Origin Shield),防止源站真实 IP 泄露并沉淀全量私有审计日志。
**非本产品定位**:多租户云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
**非本产品定位**:多租户公有云平台、Kubernetes Ingress Controller、服务网格或通用日志平台。
---
+45
View File
@@ -0,0 +1,45 @@
# 运行时语言与国际化
## 运维输出与界面文案
Server、Agent、后台任务、安装/卸载脚本以及浏览器 PoW 调试日志的固定文案使用英文。语言切换不会改变控制台日志或 OTel 异常信息,便于跨语言检索和排障。任务参数、文件名、用户配置和第三方错误保留原文,可能包含中文。
前端使用 `next-intl`,支持 `en`、`zh-CN` 和 `ru`,默认语言仍为 `en`。语言偏好按 `NEXT_LOCALE` Cookie、浏览器语言、默认语言的顺序解析,俄语区域变体(如 `ru-RU`)归一为 `ru`。文案维护在 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 生成主消息包,禁止手动修改生成的 `en.json`、`zh-CN.json` 和 `ru.json`。技术示例与 HTML 使用原始消息读取,避免把代码中的花括号当作 ICU 参数。
语言选择器始终从各语言自己的资源读取名称:`English`、`中文`、`Русский`,不随当前界面语言翻译语言名称。日期、数字和日历跟随界面语言;语言切换不改变既有时区。俄语数量文案使用 ICU 的 `one`、`few`、`many` 和 `other` 分支。
静态导出仍预渲染英文。`<head>` 中的独立启动脚本在正文绘制前读取语言偏好;若与预渲染语言不同,则暂时隐藏正文,Provider 提交对应语言资源后再显示,避免刷新时闪现英文。脚本不依赖应用 bundle,损坏的 Cookie 会回退到浏览器语言。脚本加载出错或等待超过 10 秒时,显示同语言的独立恢复提示和重新加载按钮;应用恢复后隐藏提示并清理等待监听,避免无限空白页。格式化使用的语言在 Provider 更新上下文前同步,已有数据的首次本地化渲染也使用正确的日期和数字格式。
不在 React 渲染上下文中的浏览器代码(如网络异常和纯表单工具)通过 `frontend/i18n/runtime.ts` 调用同一套 `next-intl` 文案。消息在使用时解析语言,不在模块导入时固定语言。API 客户端发送当前界面语言的 `Accept-Language`。
## 服务端展示边界
`pkg/i18n/locales` 保留内置服务端文案的英文、中文和俄语版本,英文同时是运行时源码中的基准文案。展示接口依次采用 `NEXT_LOCALE` Cookie、`Accept-Language`,没有支持的语言时回退英文。
- 错误中间件先记录原始英文异常及 OTel 状态,再翻译 HTTP 响应中的 `error_msg`。登录流程使用的 `smtp_invalid:` 和 `need_email_code:` 控制前缀保持不变。
- 任务类型、参数说明、执行记录和内置通知元数据使用请求内的副本进行翻译,避免修改共享注册表或持久化记录。
- 默认定时任务名称可翻译;管理员自定义名称、通知接收目标和已保存的消息模板保留原文。
- 通知接收目标只识别既有技术标识 `system`、`0` 和历史别名 `系统`;显示译文 `System`、`Система` 不会扩大保留目标集合,也不会把同名普通用户改投给管理员。
- 新任务执行详情保存英文系统文案。历史中文文本通过已知的完整文案或格式模板在读取时展示为英文,不执行数据库回写。
格式模板只识别已登记的系统消息,并逐字保留捕获到的参数;时间戳也保留。未知消息原样返回。此兼容机制只用于系统拥有的展示字段,不能用来翻译任意用户数据,也不能通过比较翻译后的文本决定业务状态。任务重试和存储配置校验使用错误类型进行分类。
## 持久化兼容性
上传统计原有的六个中文分类名同时是数据库计数器键和 API 分类标识,不能当作标签直接改名。保留这些既有键,前端图表、图例和最大分类说明单独使用语言包标签;无需清空或重建现有统计。
历史 SQL 迁移、测试样例、中文项目文档以及用户已经保存的数据不属于待替换的运行时固定文案。离线页面和通知模板一旦由管理员选择、编辑并保存,就作为用户数据处理,不随界面语言切换而回写。
系统配置描述和内置邮件模板的名称、说明可能来自历史 seed。管理界面仅在配置键或内置模板键已登记、且文本与已知语言默认值完全一致时,使用 `builtin-metadata` 语言分片展示;未知键、自定义描述与非内置模板保持原文。编辑表单保留原始状态,单纯保存其他字段不会把展示翻译写回数据库;邮件主题和正文始终按已保存内容显示。内置源站错误页和离线页在选择模板或空值预览时使用当前语言,已保存的自定义 HTML 保持不变。
系统状态 API 的诊断时长和容量字符串保留英文及技术单位,界面在展示边界转换为当前语言;原始字符串继续用于既有内存计算。数据库计数采用当前 `next-intl` 上下文格式化。日历使用 DayPicker 扩展语言包,同时翻译日期与导航、下拉框、选中日期等无障碍名称。
## 校验与扩展
在仓库根目录运行 `node scripts/audit-non-english.mjs` 可生成完整的非 ASCII 文本清单,默认输出到被 Git 忽略的 `.codex-artifacts/non-english-audit/`。`occurrences.csv` 包含文件、行号、模块、分类和原文;JSON 另含字符位置、Unicode 码点、字符集及跳过的文件;`review-candidates.csv` 单列待审查源码。命令读取所有已跟踪和未被忽略的新文件,不按业务目录名称排除内容,也识别 Unicode 转义。
清单包含标点、表情、注释、文档、语言资源与历史迁移,不表示每个命中都需要替换。依赖、构建输出和私有环境文件不进入清单;二进制文件单独列出,不执行截图 OCR 或解包。生成的 Swagger 和中文文档站配置保留中文,既有上传分类键保留兼容性。字符集也不等同于语言,例如 Han 字符可能属于中文或日文。
`pnpm check:i18n` 校验三个语言包的键、标签和 `$` 变量一致性,并检查 Go、TypeScript/TSX 和安装脚本中的汉字、日文假名、韩文及西里尔字母运行时文本;注释、语言资源、测试样例、六个上传持久化键及通知历史目标别名有明确边界。该检查同时接入 `make code-check` 和现有前端 CI。俄语资源测试还验证命名参数、真实 rich-text 渲染、ICU 格式、复数边界、可复制的限速语法及 HTML 模板占位符,服务端测试验证格式动词、模板变量和枚举值。技术标识(如 `10r/s`、`30m`、`true`、`and`)不随语言翻译。
新增语言时,补齐前端分片、语言配置与 Provider 消息映射,以及服务端同名语言目录项,并验证格式占位符一致。新增系统消息仍以英文作为源文案,在相应语言资源中补齐译文。新增数据协议应使用稳定标识与参数,不再引入以中文显示文案充当持久化键的设计。
+261 -4
View File
@@ -4443,12 +4443,12 @@ const docTemplate = `{
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据",
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agent.NodePayload"
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
}
}
],
@@ -4464,7 +4464,7 @@ const docTemplate = `{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponse"
"$ref": "#/definitions/agent.RegistrationResponseDocs"
}
}
}
@@ -7953,6 +7953,169 @@ const docTemplate = `{
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -14989,12 +15152,15 @@ const docTemplate = `{
}
}
},
"agent.RegistrationResponse": {
"agent.RegistrationResponseDocs": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -16293,6 +16459,77 @@ const docTemplate = `{
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest": {
"type": "object",
"properties": {
"access_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog"
}
},
"buffered": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord"
}
},
"current_version": {
"type": "string"
},
"edge_health": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth"
},
"ext_version": {
"type": "string"
},
"health_events": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent"
}
},
"host_metrics": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot"
},
"ip": {
"type": "string"
},
"last_error": {
"type": "string"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile"
},
"registration_key": {
"type": "string"
},
"schema_version": {
"type": "integer"
},
"version": {
"type": "string"
},
"waf_ip_group_checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": {
"type": "object",
"properties": {
@@ -17681,6 +17918,26 @@ const docTemplate = `{
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
+1 -1
View File
@@ -2,7 +2,7 @@
你会学到:OpenFlare 文档如何组织、首次运行应该读哪些页面,以及部署、使用、排查分别从哪里开始。
OpenFlare 是一套自托管的 OpenResty 控制面。它把反向代理网站配置、配置版本发布、Agent 节点同步、TLS 证书和基础观测放到一个管理端中,适合单团队或单组织管理多台代理节点。
OpenFlare 是新一代分布式反向代理与边缘安全网关。它将传统 OpenResty / Nginx 升级为具备多节点集群编排、Zero-Reload 动态配置同步、边缘 WAF、客户端 PoW 防 CC 人机验证、内网穿透(Tunnels)与 Pages 静态托管的现代化边缘网络平台。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
## 推荐阅读路径
+3 -1
View File
@@ -32,11 +32,13 @@
自动 IP 组先按单个客户端 IP 聚合指标,再对每个 IP 执行规则表达式:
1. Server 读取最近 `lookback` 时长内的请求日志。
2. 按 `remote_addr` 归一化后的 IP 分组。
2. 按 `remote_addr` 归一化后的 IP 分组,跳过 IPv4 私网、IPv6 唯一本地地址(ULA)、回环、链路本地、未指定、组播及广播地址;IPv4 映射 IPv6 地址按对应的 IPv4 地址判断。
3. 为每个 IP 计算请求数、404 数、直连 IP Host 次数等指标。
4. 逐个 IP 执行 `rules[].expr`。
5. 只要某个 IP 命中任意规则,就写入该自动 IP 组的 `IP / IP 段` 列表。
**测试规则**与实际自动抓取使用相同的地址过滤逻辑。自动 IP 组下次同步时,也会清理历史抓取结果中的上述地址,不受存活时间(TTL)限制。手动 IP 组和订阅 IP 组不受此过滤影响。
Host 是否为“通过 IP 访问”按请求日志中的 `Host` 字段判断:如果 Host 是 IPv4 或 IPv6 字面量,例如 `203.0.113.10`、`[2001:db8::10]`、`203.0.113.10:443`,就计入 `ip_host_count`。
## 可用关键字
+114 -20
View File
@@ -3,36 +3,130 @@ layout: home
hero:
name: OpenFlare
text: 开源 CDN 编排与边缘安全平台
tagline: 支持反向代理、集中式配置同步、Pages 静态托管、内网穿透(Tunnels)、动态 WAF 防护与人机防 CC 挑战。
text: 新一代分布式反向代理与边缘安全网关
tagline: 让 Nginx 拥有现代云端边缘能力。集中编排多节点 OpenResty、Zero-Reload 动态热生效、内置 WAF 与客户端 PoW 防 CC 盾。可独立自建运行,亦可作为源站护盾与 Cloudflare 协同防御。
actions:
- theme: brand
text: 快速开始
link: /guide/quick-start
- theme: alt
text: 设计边界
link: /design/
text: 系统架构
link: /design/architecture
- theme: alt
text: GitHub
link: https://github.com/Rain-kl/OpenFlare
features:
- icon: 🛰️
title: 集中式配置同步
details: 通过 WebSocket 与心跳实现全网节点配置秒级同步下发与热生效,状态即时回收。
- icon: 🌐
title: 分布式 CDN 编排
details: 将独立的 OpenResty 编排为高度协同的分布式 CDN 舰队,支持源站多负载均衡。
- icon: 📄
title: Pages 静态托管
details: 直接上传前端打包 zip 资产,由边缘节点拉取解压并提供高性能本地服务与 API 代理。
- icon: ⚡
title: 集群化集中编排 (Zero-Reload)
details: 单一控制台统一管理任意数量边缘节点,配置差分毫秒级下发,无需重载 Nginx,业务平滑无抖动。
- icon: 🛡️
title: 边缘 WAF 与防 CC 盾 (PoW)
details: 内置 WAF 规则组、国家级 GeoIP 访问控制,以及类似 Cloudflare Turnstile 的客户端密码学 PoW 挑战。
- icon: 🚇
title: 安全内网穿透 (Tunnels)
details: 对标 Cloudflare Tunnels,无须公网 IP 或暴露入向端口,安全穿透本地服务至公网。
- icon: 🛡️
title: 边缘 WAF 安全防护
details: IP 组成员差分同步写入 Lua 共享内存,实现免 Nginx 重载的 WAF 热更新与 GeoIP 过滤。
- icon: 🧩
title: 防 CC 与人机挑战 (PoW)
details: 内置高性能客户端 Proof of Work 密码学挑战,网关边缘秒级拦截阻断僵尸网络与爬虫。
details: 无须公网 IP 或暴露入向端口,通过轻量级 openflared 客户端建立加密隧道,安全穿透内网服务。
- icon: 📦
title: Pages 静态托管
details: 支持直传压缩包或自动从 GitHub Release 拉取预构建产物,边缘多版本回滚与 SPA 路由支持。
- icon: 🌐
title: 独立自建或协同 Cloudflare
details: 既可作为独立自建边缘网关,亦可作为 Cloudflare 背后坚固的源站护盾(防真实 IP 泄露,沉淀全量私有日志)。
- icon: 🔒
title: 自动化证书与企业 SSO
details: 基于 ACME 协议向 Let's Encrypt 自动申请与续期多域名证书,支持 GitHub OAuth 与标准 OIDC 单点登录。
---
<div class="vp-doc" style="max-width: 960px; margin: 40px auto 0; padding: 0 24px;">
## 📖 核心理念:超越传统 Nginx 单机运维
在传统的多服务器架构中,管理 Nginx 往往面临如下痛点:
1. **多节点难以协同**:每台机器需要手动编写 `nginx.conf`,证书分散,容易产生配置漂移;
2. **Reload 容易产生连接抖动**:每次更新域名或上游必须执行 `nginx -s reload`;
3. **缺乏开箱即用的高阶防御**:单机 Nginx 仅有基础的 `limit_req` 频率限制,面对分布式 CC 攻击和爬虫往往无能为力。
**OpenFlare 将 OpenResty 升级为具备云端调度能力的高可用边缘网络**。控制面统一下发配置,数据面通过 Lua 共享内存实现差分热更新,并内置类似 Turnstile 的客户端 PoW 人机验证,在网络边缘秒级阻断恶意流量。
---
## 🌐 双重部署模式
::: tip 模式一:独立自建模式 (Standalone Edge Gateway)
适用于拥有多台独立 VPS、跨地域云服务器或 Homelab 的开发者。完全自主掌控流量与数据,自建属于自己的高可用分发与安全防御网络。
:::
::: info 模式二:Cloudflare 源站护盾模式 (Behind Cloudflare / Origin Shield)
作为 Cloudflare 背后的源站集群网关:
- **防范源站真实 IP 泄露**:攻击者即使绕过 Cloudflare 直连源站真实 IP,OpenFlare 边缘的 WAF 与 PoW 人机验证依然能够就地拦截;
- **全量私有访问日志留存**:弥补商业 CDN 免费版日志保留极短、明细不足的痛点;
- **多后端统一动态路由**:承接 Cloudflare 流量并动态分发至内网服务或不同源站。
:::
---
## 📊 横向对比
| 维度 | 传统 Nginx (`nginx.conf`) | Nginx Proxy Manager (NPM) | **OpenFlare** |
| :--- | :--- | :--- | :--- |
| **集群架构** | 每台机器独立维护,频繁 SSH | 仅支持单机,无法多节点集群协同 | **原生分布式控制面 + 边缘 Agent 集群** |
| **配置生效方式** | 每次需 `nginx -s reload`,连接可能抖动 | 频繁 Reload | **共享内存/差分热同步,Zero-Reload** |
| **防 CC / 爬虫人机验证** | 仅支持简单的 `limit_req` 频率限制 | 不支持 | **内置类似 Turnstile 的 PoW 客户端挑战** |
| **内网穿透 (Tunnels)** | 需繁琐集成第三方程式 (如 frp) | 不支持 | **内置 OpenFlared 穿透隧道,开箱即用** |
| **WAF 与安全防护** | 需复杂编译配置 ModSecurity | 仅基础 Exploits 规则 | **动态 WAF、国家级 GeoIP、IP 组差分准入** |
| **静态 Pages 托管** | 手动上传并配置静态目录 | 仅静态文件代理 | **自动化拉取 GitHub Release,版本回滚** |
| **配合 Cloudflare** | 手动配置 `set_real_ip_from` 极其繁琐 | 需繁琐设置代理头部 | **原生还原真实 IP,完美充当源站护盾** |
---
## 🏗️ 架构拓扑
```mermaid
flowchart TD
subgraph Visitors ["公网访客与客户端"]
User["公网访客 / 移动端"]
CF["Cloudflare CDN (可选代理层)"]
end
subgraph EdgePlane ["边缘数据面 (OpenResty Agent 节点)"]
Edge1["边缘节点 A (OpenResty)"]
Edge2["边缘节点 B (OpenResty)"]
end
subgraph ControlPlane ["控制面 (Control Plane)"]
Server["OpenFlare Server (Web 控制台)"]
DB[("PostgreSQL + Redis")]
end
subgraph Backend ["源站与内网服务"]
Origin["公网源站 Web 服务"]
subgraph PrivateNet ["私有网络 / Homelab"]
NAS["私有应用 / NAS"]
Client["OpenFlared 穿透客户端"]
end
end
User -->|直连 HTTPS 访问| Edge1
User -->|经 Cloudflare 代理| CF
CF -->|安全回源| Edge2
Server <-->|差分热同步 / 指标上报| Edge1
Server <-->|差分热同步 / 指标上报| Edge2
Server --- DB
Edge1 -->|反向代理| Origin
Edge2 -->|反向代理| Origin
Client <-->|加密穿透隧道| Edge1
NAS --- Client
```
---
## ⚖️ 商标免责声明 (Trademark Disclaimer)
OpenFlare is an independent, community-driven open-source project. It is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. Cloudflare and related trademarks are the property of their respective owners.
OpenFlare 是一个由社区驱动的独立开源项目,与 Cloudflare, Inc. 无任何附属、认可或赞助关系。Cloudflare 及其相关商标均为其各自持有者的财产。
</div>
+17 -17
View File
@@ -926,8 +926,8 @@ packages:
markdown-it-mathjax3@4.3.2:
resolution: {integrity: sha512-TX3GW5NjmupgFtMJGRauioMbbkGsOXAAt1DZ/rzzYmTHqzkO1rNAdiMD4NiruurToPApn2kYy76x02QN26qr2w==}
markdown-it@14.3.2:
resolution: {integrity: sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==}
markdown-it@14.3.3:
resolution: {integrity: sha512-L//tDAkjTjJCU6pBUSBTgs3Kg4InVaZuUZog/b6V12UKhcDQ7S78877ibbCanRExwPuYP1CZD7Gd2dxSy1GXRg==}
hasBin: true
markdown-title@1.0.2:
@@ -1225,8 +1225,8 @@ packages:
slick@1.12.2:
resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
source-map-js@1.2.2:
resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
engines: {node: '>=0.10.0'}
space-separated-tokens@2.0.2:
@@ -1352,8 +1352,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@7.3.3:
resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==}
vite@7.3.7:
resolution: {integrity: sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1759,10 +1759,10 @@ snapshots:
'@ungap/structured-clone@1.3.1': {}
'@vitejs/plugin-vue@6.0.6(vite@7.3.3(jiti@1.21.7))(vue@3.5.34)':
'@vitejs/plugin-vue@6.0.6(vite@7.3.7(jiti@1.21.7))(vue@3.5.34)':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.13
vite: 7.3.3(jiti@1.21.7)
vite: 7.3.7(jiti@1.21.7)
vue: 3.5.34
'@vue/compiler-core@3.5.34':
@@ -1771,7 +1771,7 @@ snapshots:
'@vue/shared': 3.5.34
entities: 7.0.1
estree-walker: 2.0.2
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-dom@3.5.34':
dependencies:
@@ -1788,7 +1788,7 @@ snapshots:
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.14
source-map-js: 1.2.1
source-map-js: 1.2.2
'@vue/compiler-ssr@3.5.34':
dependencies:
@@ -2222,7 +2222,7 @@ snapshots:
transitivePeerDependencies:
- encoding
markdown-it@14.3.2:
markdown-it@14.3.3:
dependencies:
argparse: 2.0.1
entities: 4.5.0
@@ -2532,7 +2532,7 @@ snapshots:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
source-map-js: 1.2.2
pretty-bytes@7.1.0: {}
@@ -2679,7 +2679,7 @@ snapshots:
slick@1.12.2: {}
source-map-js@1.2.1: {}
source-map-js@1.2.2: {}
space-separated-tokens@2.0.2: {}
@@ -2817,7 +2817,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@7.3.3(jiti@1.21.7):
vite@7.3.7(jiti@1.21.7):
dependencies:
esbuild: 0.27.7
fdir: 6.5.0(picomatch@4.0.4)
@@ -2832,7 +2832,7 @@ snapshots:
vitepress-plugin-llms@1.12.2:
dependencies:
gray-matter: 4.0.3
markdown-it: 14.3.2
markdown-it: 14.3.3
markdown-title: 1.0.2
mdast-util-from-markdown: 2.0.3
millify: 6.1.0
@@ -2858,7 +2858,7 @@ snapshots:
'@shikijs/transformers': 3.23.0
'@shikijs/types': 3.23.0
'@types/markdown-it': 14.1.2
'@vitejs/plugin-vue': 6.0.6(vite@7.3.3(jiti@1.21.7))(vue@3.5.34)
'@vitejs/plugin-vue': 6.0.6(vite@7.3.7(jiti@1.21.7))(vue@3.5.34)
'@vue/devtools-api': 8.1.2
'@vue/shared': 3.5.34
'@vueuse/core': 14.3.0(vue@3.5.34)
@@ -2867,7 +2867,7 @@ snapshots:
mark.js: 8.11.1
minisearch: 7.2.0
shiki: 3.23.0
vite: 7.3.3(jiti@1.21.7)
vite: 7.3.7(jiti@1.21.7)
vue: 3.5.34
optionalDependencies:
markdown-it-mathjax3: 4.3.2
+4
View File
@@ -87,6 +87,8 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `database.max_idle_conn` | `DB_MAX_IDLE_CONN` | 数据库连接池最大空闲连接数 | `16` |
| `database.max_open_conn` | `DB_MAX_OPEN_CONN` | 数据库连接池最大打开连接数 | `128` |
SQLite 文件数据库在启动时自动启用 WAL 模式;内存数据库保留 `memory` 模式。请确保数据库所在目录可写,以创建 `-wal` 和 `-shm` 文件。在线备份请使用管理端数据库导出(生成一致性快照),不要仅复制运行中的 `.db` 文件。
### 3. Redis 配置 (`redis:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -126,6 +128,8 @@ Server 的所有核心基础配置定义在 `config.yaml` 中,且均支持环
| `log.max_size` | - | 单个日志文件的最大空间(MB),超出自动切割轮转 | `100` |
| `log.max_age` | - | 历史切割日志文件最大保留天数 | `30` |
Server、Agent、调度器、任务框架及存储维护等运行时日志的固定描述使用英文,便于跨语言排障和检索。界面语言切换不会改变终端日志和 OTel 异常;接口错误、任务执行详情与内置元数据在展示时按语言翻译,历史记录不回写。用户配置名称及第三方错误保留原文,可能包含中文。现有日志格式、级别、参数及输出渠道保持不变,详见 [运行时语言与国际化](../design/log-language.md)。
### 6. 异步任务 Worker 队列配置 (`worker:`)
| 配置文件 YAML 路径 | 对应覆盖环境变量 | 作用说明 | 默认值 |
| --- | --- | --- | --- |
@@ -0,0 +1,161 @@
# AutoUpdate 6-Hour Interval and Exponential Backoff Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Throttle agent/edge auto-update checks to once every 6 hours, and apply exponential backoff on failure (doubling up to a maximum of 3 hours) to prevent exhausting GitHub API rate limits.
**Architecture:** Add check interval and exponential backoff state tracking to `edgeupdater.Service` protected by a mutex, with clock injection (`NowFunc`) for deterministic testing. Expose `CanCheck(options UpdateOptions) bool` and integrate it into both `CheckAndUpdate` and `edgeheartbeat.TryAutoUpdate` so unthrottled checks and noisy logs are eliminated during periodic heartbeat loops.
**Tech Stack:** Go (1.25), standard library (`sync`, `time`, `net/http`, `log/slog`).
## Global Constraints
- Normal update check interval: 6 hours (`6 * time.Hour`).
- Initial backoff on failure: 1 minute (`1 * time.Minute`), doubling on each consecutive failure.
- Maximum retry backoff: 3 hours (`3 * time.Hour`).
- Success resets failure count and backoff, and sets next check to `now + 6h`.
- Force check (`UpdateOptions.Force = true`) must bypass backoff and interval immediately.
- TDD required: write failing tests first, watch them fail, implement minimal code, verify pass.
---
### Task 1: Add Interval and Exponential Backoff to `edgeupdater.Service`
**Files:**
- Modify: `internal/apps/edge/updater/service.go`
- Test: `internal/apps/edge/updater/service_test.go`
**Interfaces:**
- Consumes: `UpdateOptions{Channel, TagName, Force}`
- Produces: `(s *Service) CanCheck(options UpdateOptions) bool`, `(s *Service) CheckAndUpdate(...) error` with throttling and exponential backoff
- [x] **Step 1: Write the failing tests in `service_test.go`**
Add tests covering:
- `TestCheckAndUpdateIntervalAndBackoff`:
1. Initial state allows check.
2. Successful check sets next check time 6 hours later. Calls before 6 hours return false on `CanCheck` and are skipped in `CheckAndUpdate`.
3. After 6 hours, check is allowed again.
4. Failed check (e.g. mock server returns 403) schedules next check 1 minute later.
5. Second failed check schedules next check 2 minutes later.
6. Subsequent failures double up to 3 hours (capped at 3 hours).
7. Force check (`Force: true`) always succeeds in running regardless of cooldown/backoff.
8. Success after failure resets backoff to initial and schedules next check in 6 hours.
- [x] **Step 2: Run test to verify it fails**
Run: `go test -v ./internal/apps/edge/updater -run TestCheckAndUpdateIntervalAndBackoff`
Expected: FAIL (fields or methods missing or behavior not implemented).
- [x] **Step 3: Write minimal implementation in `service.go`**
1. Define constants:
```go
const (
DefaultCheckInterval = 6 * time.Hour
DefaultInitialBackoff = 1 * time.Minute
DefaultMaxBackoff = 3 * time.Hour
)
```
2. Extend `Config`:
```go
type Config struct {
LocalVersion string
AssetPrefix string
LogLabel string
CheckInterval time.Duration
InitialBackoff time.Duration
MaxBackoff time.Duration
NowFunc func() time.Time
}
```
3. Extend `Service` struct with:
```go
type Service struct {
httpClient *http.Client
lastCheckKey string
localVersion string
assetPrefix string
logLabel string
checkInterval time.Duration
initialBackoff time.Duration
maxBackoff time.Duration
nowFunc func() time.Time
mu sync.Mutex
nextCheckTime time.Time
currentBackoff time.Duration
consecutiveFailures int
}
```
4. Implement `CanCheck(options UpdateOptions) bool`:
```go
func (s *Service) CanCheck(options UpdateOptions) bool {
if options.Force {
return true
}
s.mu.Lock()
defer s.mu.Unlock()
if s.nextCheckTime.IsZero() {
return true
}
now := s.now()
return !now.Before(s.nextCheckTime)
}
```
5. In `CheckAndUpdate`:
- Check `CanCheck(options)`. If false, return `nil`.
- On `getRelease` error: calculate next backoff (initial backoff on 1st failure, double previous backoff capped at maxBackoff on subsequent), increment `consecutiveFailures`, set `nextCheckTime = now.Add(currentBackoff)`, return error.
- On `getRelease` success: reset `consecutiveFailures = 0`, reset `currentBackoff = initialBackoff`, set `nextCheckTime = now.Add(checkInterval)`.
- [x] **Step 4: Run test to verify it passes**
Run: `go test -v ./internal/apps/edge/updater/...`
Expected: PASS.
---
### Task 2: Integrate `CanCheck` in `edgeheartbeat.TryAutoUpdate`
**Files:**
- Modify: `internal/apps/edge/heartbeat/autoupdate.go`
- Test: `internal/apps/edge/heartbeat/autoupdate_test.go`
**Interfaces:**
- Consumes: `(s *Service) CanCheck(options UpdateOptions) bool`
- Produces: `TryAutoUpdate(ctx context.Context, updater *edgeupdater.Service, settings *AutoUpdateSettings, logLabel string)`
- [x] **Step 1: Write the failing test in `autoupdate_test.go`**
Test that `TryAutoUpdate` checks `updater.CanCheck(opts)` and skips both log output and `CheckAndUpdate` call when throttled.
- [x] **Step 2: Run test to verify it fails**
Run: `go test -v ./internal/apps/edge/heartbeat/...`
Expected: FAIL.
- [x] **Step 3: Update `autoupdate.go`**
Update `TryAutoUpdate` to evaluate `updater.CanCheck(opts)` before logging `checking for updates` and executing `CheckAndUpdate`.
- [x] **Step 4: Run test to verify it passes**
Run: `go test -v ./internal/apps/edge/heartbeat/...`
Expected: PASS.
---
### Task 3: Full Verification of Edge and Agent Packages
**Files:**
- Test: All tests in `internal/apps/edge/...` and `internal/apps/agent/...`
- [x] **Step 1: Run comprehensive tests**
Run:
```bash
go test -v ./internal/apps/edge/... ./internal/apps/agent/...
```
Expected: PASS with zero failures or race conditions.
+261 -4
View File
@@ -4436,12 +4436,12 @@
"summary": "注册或发现 Agent 节点",
"parameters": [
{
"description": "节点上报数据",
"description": "节点上报数据;registration_key 为首次注册重试凭证",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/agent.NodePayload"
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest"
}
}
],
@@ -4457,7 +4457,7 @@
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/agent.RegistrationResponse"
"$ref": "#/definitions/agent.RegistrationResponseDocs"
}
}
}
@@ -7946,6 +7946,169 @@
}
}
},
"/api/v1/d/nodes/page": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取分页节点列表",
"parameters": [
{
"type": "integer",
"description": "页码(默认 1)",
"name": "page",
"in": "query"
},
{
"type": "integer",
"description": "每页数量(默认 20,最大 100)",
"name": "page_size",
"in": "query"
},
{
"type": "string",
"description": "名称、节点标识或 IP 的字面子串(最多 256 字节)",
"name": "search",
"in": "query"
},
{
"type": "string",
"description": "pending、online 或 offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "edge_node、tunnel_relay 或 tunnel_client",
"name": "node_type",
"in": "query"
}
],
"responses": {
"200": {
"description": "节点分页",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.ListResult"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}": {
"get": {
"security": [
{
"SessionCookie": []
}
],
"description": "返回指定节点及最新配置下发记录,需要管理员权限",
"produces": [
"application/json"
],
"tags": [
"openflare-node"
],
"summary": "获取节点详情",
"parameters": [
{
"type": "integer",
"description": "节点 ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "节点详情",
"schema": {
"allOf": [
{
"$ref": "#/definitions/response.Any"
},
{
"type": "object",
"properties": {
"data": {
"$ref": "#/definitions/node.View"
}
}
}
]
}
},
"400": {
"description": "参数错误",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"401": {
"description": "未登录",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"404": {
"description": "无权限或不存在",
"schema": {
"$ref": "#/definitions/response.Any"
}
},
"500": {
"description": "查询失败",
"schema": {
"$ref": "#/definitions/response.Any"
}
}
}
}
},
"/api/v1/d/nodes/{id}/agent-release": {
"get": {
"security": [
@@ -14982,12 +15145,15 @@
}
}
},
"agent.RegistrationResponse": {
"agent.RegistrationResponseDocs": {
"type": "object",
"properties": {
"access_token": {
"type": "string"
},
"agent_token": {
"type": "string"
},
"name": {
"type": "string"
},
@@ -16286,6 +16452,77 @@
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest": {
"type": "object",
"properties": {
"access_logs": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog"
}
},
"buffered": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord"
}
},
"current_version": {
"type": "string"
},
"edge_health": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth"
},
"ext_version": {
"type": "string"
},
"health_events": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent"
}
},
"host_metrics": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot"
},
"ip": {
"type": "string"
},
"last_error": {
"type": "string"
},
"name": {
"type": "string"
},
"node_id": {
"type": "string"
},
"openresty_message": {
"type": "string"
},
"openresty_status": {
"type": "string"
},
"profile": {
"$ref": "#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile"
},
"registration_key": {
"type": "string"
},
"schema_version": {
"type": "integer"
},
"version": {
"type": "string"
},
"waf_ip_group_checksums": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
}
},
"github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup": {
"type": "object",
"properties": {
@@ -17674,6 +17911,26 @@
}
}
},
"node.ListResult": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"results": {
"type": "array",
"items": {
"$ref": "#/definitions/node.View"
}
},
"total": {
"type": "integer"
}
}
},
"node.ObservabilityView": {
"type": "object",
"properties": {
+165 -4
View File
@@ -99,10 +99,12 @@ definitions:
type: string
type: object
type: object
agent.RegistrationResponse:
agent.RegistrationResponseDocs:
properties:
access_token:
type: string
agent_token:
type: string
name:
type: string
node_id:
@@ -955,6 +957,53 @@ definitions:
total_size:
type: integer
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest:
properties:
access_logs:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeAccessLog'
type: array
buffered:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.BufferedObservabilityRecord'
type: array
current_version:
type: string
edge_health:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeEdgeHealth'
ext_version:
type: string
health_events:
items:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeHealthEvent'
type: array
host_metrics:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeMetricSnapshot'
ip:
type: string
last_error:
type: string
name:
type: string
node_id:
type: string
openresty_message:
type: string
openresty_status:
type: string
profile:
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.NodeSystemProfile'
registration_key:
type: string
schema_version:
type: integer
version:
type: string
waf_ip_group_checksums:
additionalProperties:
type: string
type: object
type: object
github_com_Rain-kl_Wavelet_pkg_protocol.WAFIPGroup:
properties:
checksum:
@@ -1882,6 +1931,19 @@ definitions:
relay_web_server_enabled:
type: boolean
type: object
node.ListResult:
properties:
page:
type: integer
page_size:
type: integer
results:
items:
$ref: '#/definitions/node.View'
type: array
total:
type: integer
type: object
node.ObservabilityView:
properties:
analytics:
@@ -7020,12 +7082,12 @@ paths:
- application/json
description: 使用节点 access token 重新注册,或使用全局 discovery token 发现新节点;请求头需携带 X-Agent-Token
parameters:
- description: 节点上报数据
- description: 节点上报数据;registration_key 为首次注册重试凭证
in: body
name: body
required: true
schema:
$ref: '#/definitions/agent.NodePayload'
$ref: '#/definitions/github_com_Rain-kl_Wavelet_pkg_protocol.RegisterNodeRequest'
produces:
- application/json
responses:
@@ -7036,7 +7098,7 @@ paths:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/agent.RegistrationResponse'
$ref: '#/definitions/agent.RegistrationResponseDocs'
type: object
"400":
description: 参数错误
@@ -9070,6 +9132,48 @@ paths:
summary: 创建节点
tags:
- openflare-node
/api/v1/d/nodes/{id}:
get:
description: 返回指定节点及最新配置下发记录,需要管理员权限
parameters:
- description: 节点 ID
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 节点详情
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.View'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限或不存在
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取节点详情
tags:
- openflare-node
/api/v1/d/nodes/{id}/agent-release:
get:
description: 返回指定节点可用的最新 Agent 版本信息,需要管理员权限
@@ -9457,6 +9561,63 @@ paths:
summary: 轮换引导令牌
tags:
- openflare-node
/api/v1/d/nodes/page:
get:
description: 返回分页节点和筛选后的总数;旧版 /nodes 列表接口保持兼容,需要管理员权限
parameters:
- description: 页码(默认 1)
in: query
name: page
type: integer
- description: 每页数量(默认 20,最大 100)
in: query
name: page_size
type: integer
- description: 名称、节点标识或 IP 的字面子串(最多 256 字节)
in: query
name: search
type: string
- description: pending、online 或 offline
in: query
name: status
type: string
- description: edge_node、tunnel_relay 或 tunnel_client
in: query
name: node_type
type: string
produces:
- application/json
responses:
"200":
description: 节点分页
schema:
allOf:
- $ref: '#/definitions/response.Any'
- properties:
data:
$ref: '#/definitions/node.ListResult'
type: object
"400":
description: 参数错误
schema:
$ref: '#/definitions/response.Any'
"401":
description: 未登录
schema:
$ref: '#/definitions/response.Any'
"404":
description: 无权限
schema:
$ref: '#/definitions/response.Any'
"500":
description: 查询失败
schema:
$ref: '#/definitions/response.Any'
security:
- SessionCookie: []
summary: 获取分页节点列表
tags:
- openflare-node
/api/v1/d/option:
get:
description: 返回全部非敏感 OpenFlare 配置项,需要管理员权限
+12 -6
View File
@@ -1,17 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { apiSections, DOCS_LAST_UPDATED } from '@/components/common/docs/api';
import {
createApiSections,
DOCS_LAST_UPDATED,
} from '@/components/common/docs/api';
export default function ApiDocPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='API 接口文档'
title={rt('api_documentation_cb3d2229')}
lastUpdated={DOCS_LAST_UPDATED}
sections={apiSections}
sections={createApiSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
OpenFlare 提供简单、强大的 API
接口,支持多种编程语言和开发环境。通过标准化的 RESTful
接口,您可以轻松地与后端服务进行集成与二次开发。
{rt('openflare_provides_a_simple_powerful_api_for_ba400468')}
</p>
}
/>
+9 -5
View File
@@ -1,17 +1,21 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { howToUseSections } from '@/components/common/docs/how-to-use';
import { createHowToUseSections } from '@/components/common/docs/how-to-use';
import { DOCS_LAST_UPDATED } from '@/components/common/docs/api';
export default function HowToUsePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='平台使用指南'
title={rt('platform_user_guide_d0ead991')}
lastUpdated={DOCS_LAST_UPDATED}
sections={howToUseSections}
sections={createHowToUseSections(rt)}
description={
<p className='text-muted-foreground text-sm leading-relaxed'>
欢迎使用 OpenFlare
管理平台。本文档将引导您快速了解平台的核心组件、角色权限设计及二次开发流程,帮助您快速上手开发。
{rt('welcome_to_the_openflare_management_platform_this_e8ecbe4d')}
</p>
}
/>
@@ -1,18 +1,23 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import { privacySections } from '@/components/common/docs/privacy';
import { createPrivacySections } from '@/components/common/docs/privacy';
import { TERMS_LAST_UPDATED } from '@/components/common/docs/terms';
export default function PrivacyPolicyPage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='隐私政策 (Privacy)'
title={rt('privacy_policy_b6da1ca6')}
lastUpdated={TERMS_LAST_UPDATED}
sections={privacySections}
sections={createPrivacySections(rt)}
description={
<span>
我们非常重视您的隐私保护。本政策将向您透明地展示我们如何收集、使用、存储您的个人信息,
{rt('we_value_your_privacy_this_policy_explains_2c9e3c0b')}
<br className='hidden md:block' />
以及您享有的相关权利。
{rt('and_the_rights_you_have_over_it_4560abf2')}
</span>
}
/>
@@ -1,16 +1,25 @@
'use client';
import { useTranslations } from 'next-intl';
import { LegalPageLayout } from '@/components/common/docs/legal-page-layout';
import {
TERMS_LAST_UPDATED,
termsSections,
createTermsSections,
} from '@/components/common/docs/terms';
export default function TermsOfServicePage() {
const rt = useTranslations('runtime');
return (
<LegalPageLayout
title='服务协议 (Terms)'
title={rt('terms_of_service_4e80a140')}
lastUpdated={TERMS_LAST_UPDATED}
sections={termsSections}
description={<span>为了保障您的合法权益,请您仔细阅读以下条款。</span>}
sections={createTermsSections(rt)}
description={
<span>
{rt('please_read_the_following_terms_carefully_to_c46a6a61')}
</span>
}
/>
);
}
@@ -1,3 +1,4 @@
import { getFormattingLocale } from '@/i18n/format';
export type AccessLogTab = 'overview' | 'ips' | 'list';
export type SearchDraft = {
@@ -66,7 +67,7 @@ export function parseSortValue(value: string) {
}
export function formatCompactNumber(value: number) {
return new Intl.NumberFormat('zh-CN', {
return new Intl.NumberFormat(getFormattingLocale(), {
notation: value >= 10000 ? 'compact' : 'standard',
maximumFractionDigits: 1,
}).format(value);
@@ -368,6 +368,8 @@ export function IpAnalysisPanel({
*/
initialHours?: number;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('accessLogs');
const trendChartConfig = useTrendChartConfig();
const [ipGroupOpen, setIpGroupOpen] = useState(false);
@@ -576,32 +578,32 @@ export function IpAnalysisPanel({
{analysis ? (
<div className='grid gap-3 md:grid-cols-2'>
<MiniRankCard
title='Top Paths'
title={uiText('ui_topPaths')}
color='#a78bfa'
items={toRankItems(analysis.top_paths)}
/>
<MiniRankCard
title='Top Hosts'
title={uiText('ui_topHosts')}
color='#34d399'
items={toRankItems(analysis.top_hosts)}
/>
<MiniRankCard
title='Status Codes'
title={uiText('ui_statusCodes')}
color='#f59e0b'
items={toRankItems(analysis.status_codes)}
/>
<MiniRankCard
title='Top User-Agents'
title={uiText('ui_topUserAgents')}
color='#818cf8'
items={toRankItems(analysis.top_user_agents)}
/>
<MiniRankCard
title='Device Types'
title={uiText('ui_deviceTypes')}
color='#38bdf8'
items={toRankItems(analysis.device_types)}
/>
<MiniRankCard
title='Top Browsers'
title={uiText('ui_topBrowsers')}
color='#22c55e'
items={toRankItems(analysis.top_browsers)}
/>
@@ -318,6 +318,8 @@ function OverviewContent({
data: AccessLogOverview;
hours: number;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('accessLogs');
const requestLabels = data.trends.requests.map((point) =>
formatOverviewTrendLabel(point.bucket_started_at, hours),
@@ -331,7 +333,7 @@ function OverviewContent({
<>
<div className='grid gap-4 lg:grid-cols-3'>
<SparklineMetricCard
title='Total Requests'
title={uiText('ui_totalRequests')}
value={formatCompactNumber(data.summary.total_requests)}
hint={hint}
color='#f59e0b'
@@ -340,7 +342,7 @@ function OverviewContent({
values={requestValues}
/>
<SparklineMetricCard
title='Total Visits'
title={uiText('ui_totalVisits')}
value={formatCompactNumber(data.summary.total_visits)}
hint={t('overview.uniqueVisitors', { hint })}
color='#38bdf8'
@@ -349,7 +351,7 @@ function OverviewContent({
values={visitValues}
/>
<SparklineMetricCard
title='Bandwidth Served'
title={uiText('ui_bandwidthServed')}
value={formatBytes(data.summary.bandwidth_served)}
hint={t('overview.served', { hint })}
color='#34d399'
@@ -363,7 +365,7 @@ function OverviewContent({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-sm font-semibold'>
Requests over time
{uiText('ui_requestsOverTime')}
</CardTitle>
<CardDescription className='text-xs'>
{t('overview.requestsHint')}
@@ -389,13 +391,13 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-2'>
<PieDistributionCard
title='Requests by device type'
title={uiText('ui_requestsByDevice')}
description={t('overview.deviceDesc')}
items={data.device_types ?? []}
emptyMessage={t('overview.deviceEmpty')}
/>
<PieDistributionCard
title='Status code'
title={uiText('ui_statusCodes')}
description={t('overview.statusDesc')}
items={data.status_codes ?? []}
emptyMessage={t('overview.statusEmpty')}
@@ -404,17 +406,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top Paths'
title={uiText('ui_topPaths')}
description={t('overview.topPathsDesc')}
items={toRankItems(data.top_paths)}
/>
<RankCard
title='Top Hosts'
title={uiText('ui_topHosts')}
description={t('overview.topHostsDesc')}
items={toRankItems(data.top_hosts)}
/>
<RankCard
title='Top IPs'
title={uiText('ui_topIps')}
description={t('overview.topIpsDesc')}
items={toRankItems(data.top_ips)}
/>
@@ -422,17 +424,17 @@ function OverviewContent({
<div className='grid gap-6 xl:grid-cols-3'>
<RankCard
title='Top browsers'
title={uiText('ui_topBrowsers')}
description={t('overview.browsersDesc')}
items={toRankItems(data.top_browsers)}
/>
<RankCard
title='Top Operating System'
title={uiText('ui_topOperatingSystems')}
description={t('overview.osDesc')}
items={toRankItems(data.top_operating_systems)}
/>
<RankCard
title='Top User-Agent'
title={uiText('ui_topUserAgents')}
description={t('overview.uaDesc')}
items={toRankItems(data.top_user_agents)}
/>
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { HardDrive, RefreshCw, Trash2 } from 'lucide-react';
@@ -28,25 +28,7 @@ import {
} from '@/components/ui/dialog';
import type { CacheStatus } from '@/lib/services/admin';
import services from '@/lib/services';
/**
* 格式化字节大小
*/
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
import { formatBytes } from '@/lib/utils/metrics';
interface CacheManagerProps {
refreshTrigger: number;
@@ -54,6 +36,7 @@ interface CacheManagerProps {
export function CacheManager({ refreshTrigger }: CacheManagerProps) {
const t = useTranslations('admin.database.cache');
const format = useFormatter();
const [cacheStatus, setCacheStatus] = useState<CacheStatus | null>(null);
const [loadingCache, setLoadingCache] = useState<boolean>(true);
const [savingConfig, setSavingConfig] = useState<boolean>(false);
@@ -208,7 +191,7 @@ export function CacheManager({ refreshTrigger }: CacheManagerProps) {
{t('cacheKeysCount')}
</p>
<p className='text-xl font-bold tracking-tight text-foreground'>
{formatNumber(cacheStatus.keys_count)}{' '}
{format.number(cacheStatus.keys_count)}{' '}
<span className='text-xs text-muted-foreground font-normal'>
{t('files')}
</span>
@@ -142,7 +142,9 @@ export function SQLConsole({ dbType, onClose }: SQLConsoleProps) {
</div>
</div>
<div className='text-xs text-muted-foreground font-mono'>
{dbType === 'postgres' ? 'PostgreSQL Connected' : 'SQLite Connected'}
{t('connected', {
database: dbType === 'postgres' ? 'PostgreSQL' : 'SQLite',
})}
</div>
</div>
@@ -2,7 +2,7 @@
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Layers, RefreshCw } from 'lucide-react';
@@ -33,14 +33,6 @@ import { Button } from '@/components/ui/button';
import type { TableDataResponse } from '@/lib/services/db-manage';
import { DbManageService } from '@/lib/services/db-manage';
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
/**
* 格式化表格单元格内容
*/
@@ -69,6 +61,7 @@ export function TableBrowser({
refreshTrigger,
}: TableBrowserProps) {
const t = useTranslations('admin.database.tableBrowser');
const format = useFormatter();
const [selectedTable, setSelectedTable] = useState<string>('');
const [tableData, setTableData] = useState<TableDataResponse | null>(null);
const [page, setPage] = useState<number>(1);
@@ -222,7 +215,7 @@ export function TableBrowser({
<div>
{t('totalRows')}:{' '}
<span className='font-mono text-foreground font-semibold'>
{formatNumber(tableData.total)}
{format.number(tableData.total)}
</span>{' '}
{t('records')}
</div>
@@ -3,7 +3,7 @@
import dynamic from 'next/dynamic';
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { useFormatter, useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
Activity,
@@ -49,16 +49,9 @@ const SQLConsole = dynamic(
{ ssr: false },
);
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
};
export function DatabasePageClient() {
const t = useTranslations('admin.database');
const format = useFormatter();
// 核心状态
const [overview, setOverview] = useState<DBOverview | null>(null);
const [tables, setTables] = useState<string[]>([]);
@@ -275,7 +268,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{formatNumber(overview.table_count)}
{format.number(overview.table_count)}
</div>
</CardContent>
</Card>
@@ -290,7 +283,7 @@ export function DatabasePageClient() {
</CardHeader>
<CardContent>
<div className='text-sm font-bold'>
{formatNumber(overview.connections)}
{format.number(overview.connections)}
</div>
</CardContent>
</Card>
+19 -17
View File
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -22,6 +22,8 @@ import { TableTab } from './tabs/table-tab';
import { ControlsTab } from './tabs/controls-tab';
export default function HeaderDemoPage() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo');
const [activeTab, setActiveTab] = React.useState('dashboard');
@@ -59,7 +61,8 @@ export default function HeaderDemoPage() {
{t('rules.structureFocus.description')}
</li>
<li>
<strong>{t('rules.containerAlignment.title')}</strong>:使用{' '}
<strong>{t('rules.containerAlignment.title')}</strong>
{rt('use_1b3d699e')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-center gap-2
</code>{' '}
@@ -67,22 +70,23 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6 px-1
</code>{' '}
(或{' '}
{rt('or_07d4e3c0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
py-6
</code>
) {t('rules.containerAlignment.description2')}
</li>
<li>
<strong>{t('rules.iconPresentation.title')}</strong>:直接将
Lucide 图标组件嵌套于标题容器中,应用{' '}
<strong>{t('rules.iconPresentation.title')}</strong>
{rt('place_the_lucide_icon_directly_inside_the_104f794c')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
size-5 text-primary
</code>{' '}
{t('rules.iconPresentation.description')}
</li>
<li>
<strong>{t('rules.textStyle.title')}</strong>:文本使用且仅使用{' '}
<strong>{t('rules.textStyle.title')}</strong>
{rt('use_only_5e12eecb')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'h1 className="text-2xl font-semibold tracking-tight"'}
</code>
@@ -93,10 +97,8 @@ export default function HeaderDemoPage() {
{t('rules.textStyle.description2')}
</li>
<li>
<strong>{t('rules.tabsModular.title')}</strong>:凡是带有多个
Tab 页切换的页面,**禁止**将所有 Tab
的渲染代码堆积在同一个物理页面主文件内。每个 Tab 页对应的
Content 必须单独拆分为独立组件文件(如{' '}
<strong>{t('rules.tabsModular.title')}</strong>
{rt('for_pages_with_multiple_tabs_keep_each_5f721959')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
tabs/events-tab.tsx
</code>{' '}
@@ -107,8 +109,8 @@ export default function HeaderDemoPage() {
{t('rules.tabsModular.description2')}
</li>
<li>
<strong>{t('rules.complexityDriven.title')}</strong>:不仅是
Tabs 切换,当一个页面代码行数超过 600
<strong>{t('rules.complexityDriven.title')}</strong>
{rt('beyond_tabs_when_a_page_exceeds_600_d152cfdc')}
{t('rules.complexityDriven.description')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
app/(main)/admin/database/components/
@@ -121,19 +123,19 @@ export default function HeaderDemoPage() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
/admin/database
</code>
) 的{' '}
{rt('message_18ab4763')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
table-browser.tsx
</code>
、
{rt('list_separator')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
cache-manager.tsx
</code>{' '}
与{' '}
{rt('and_de1d5dd0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
sql-console.tsx
</code>
。
{rt('sentence_end')}
</li>
</ul>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code } from 'lucide-react';
@@ -26,6 +26,8 @@ import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
export function ControlsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.controls');
const [formData, setFormData] = React.useState({
name: '',
@@ -166,78 +168,71 @@ export function ControlsTab() {
{t('spec.unifiedHeight')}
</p>
<p>
输入框 (
{rt('inputs_fabee312')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
Input
</code>
) 与下拉选择器 (
{rt('and_selectors_e4875cdf')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
SelectTrigger
</code>
) 的高度应统一限制为小号高度{' '}
{rt('should_share_the_compact_height_f1fe2218')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8
</code>
,字体大小使用{' '}
{rt('with_font_size_e7c29991')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs
</code>
,并使用{' '}
{rt('and_use_ca398a12')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
shadow-none
</code>{' '}
去除原生的重叠阴影。
{rt('to_remove_overlapping_native_shadows_dd65b475')}
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.checkboxAlignment')}
</p>
<p>
Checkbox 应在左侧垂直偏上对齐,使用容器{' '}
{rt('align_checkboxes_toward_the_top_left_using_092dcef0')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
flex items-start gap-2.5
</code>{' '}
排列。Checkbox 组件本身添加{' '}
{rt('add_bf15da3a')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
mt-0.5
</code>
,从而在多行描述文本的情况下依然保持良好的顶对齐,避免出现居中对齐导致的凌乱感。
{rt('to_the_checkbox_so_multiline_descriptions_stay_1bb85c4b')}
</p>
<p className='font-semibold text-foreground text-[11px] pt-1'>
{t('spec.buttonStrategy')}
</p>
<p>
普通主表单及新增/保存按钮,一律使用默认 Primary 按钮或次要{' '}
{rt('use_the_default_primary_or_secondary_db7513e5')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
{'variant="secondary"'}
</code>{' '}
按钮,小号高度统一采用{' '}
{rt('button_for_forms_and_create_save_actions_a657dba8')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
h-8 text-xs
</code>
。线框按钮必须带有{' '}
{rt('outline_buttons_must_include_the_66ace005')}{' '}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
border-dashed shadow-none
</code>{' '}
类。
{rt('class_43eff5ff')}
</p>
</div>
<div className='bg-background/80 p-3 rounded border border-border/40 font-mono text-[10px] space-y-2 overflow-x-auto'>
<div>{`// 1. Label 与 Input 对齐`}</div>
<div>{`<div className="space-y-1.5">
<Label htmlFor="id" className="text-xs font-medium text-foreground">标题</Label>
<Input id="id" className="h-8 text-xs shadow-none" />
</div>`}</div>
<div className='pt-2'>{`// 2. Checkbox 顶对齐`}</div>
<div>{`<div className="flex items-start gap-2.5">
<Checkbox id="c" className="mt-0.5" />
<div className="grid gap-1">
<label htmlFor="c" className="text-xs font-medium leading-none">标题</label>
<p className="text-[10px] text-muted-foreground">多行说明内容</p>
</div>
</div>`}</div>
<div>{rt('1_align_label_and_input_32d4558d')}</div>
<div>{rt.raw('div_classname_space_y_1_5_label_32230ad7')}</div>
<div className='pt-2'>
{rt('2_align_checkbox_to_the_top_bdf68ad8')}
</div>
<div>{rt.raw('div_classname_flex_items_start_gap_2_ccf0d526')}</div>
</div>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Cpu, Database, HardDrive } from 'lucide-react';
@@ -10,6 +10,8 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
export function DashboardTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.dashboard');
return (
<div className='space-y-6'>
@@ -129,17 +131,9 @@ export function DashboardTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<Card className="border-dashed shadow-none">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<span className="text-xs font-medium text-muted-foreground">指标标题</span>
<Cpu className="size-4 text-primary" />
</CardHeader>
<CardContent className="space-y-2">
<div className="text-2xl font-semibold tracking-tight">42.8 %</div>
<Progress value={42.8} className="h-1.5" />
<p className="text-[10px] text-muted-foreground">状态说明小字</p>
</CardContent>
</Card>`}
{rt.raw(
'card_classname_border_dashed_shadow_none_cardheader_35727acf',
)}
</pre>
</CardContent>
</Card>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { Code, Edit, ListFilter, Plus, Search, Trash2 } from 'lucide-react';
@@ -20,6 +20,8 @@ import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export function TableTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.demo.table');
const mockTableData = [
{
@@ -193,7 +195,7 @@ export function TableTab() {
border border-dashed shadow-none rounded-lg overflow-hidden
bg-background
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.headerBg')}
@@ -204,7 +206,7 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
text-xs font-semibold
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.cellDivider')}
@@ -215,14 +217,14 @@ export function TableTab() {
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
hover:bg-muted/10 transition-colors
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.monoFont')}
<code className='bg-muted px-1 py-0.5 rounded font-mono'>
font-mono text-xs
</code>
。
{rt('sentence_end')}
</li>
<li>
{t('spec.badgeStyle')}
@@ -233,27 +235,7 @@ export function TableTab() {
</li>
</ul>
<pre className='text-[11px] font-mono text-muted-foreground overflow-x-auto p-3 bg-background rounded border border-border/40 leading-relaxed'>
{`<div className="border border-dashed shadow-none rounded-lg overflow-hidden bg-background">
<Table>
<TableHeader className="bg-muted/40">
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="text-xs font-semibold">编号</TableHead>
<TableHead className="text-xs font-semibold">状态</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="border-dashed hover:bg-muted/10 transition-colors">
<TableCell className="font-mono text-xs">ID-1001</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 border-emerald-500/20 text-emerald-600 rounded-full py-0 px-2">
<span className="size-1 bg-emerald-500 rounded-full mr-1.5 shrink-0" />
成功
</Badge>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>`}
{rt.raw('div_classname_border_border_dashed_shadow_none_b430b520')}
</pre>
</CardContent>
</Card>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -72,7 +73,7 @@ function getFileIcon(mimeType: string, className = 'size-8') {
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleString('zh-CN', {
return new Date(dateStr).toLocaleString(getFormattingLocale(), {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -82,6 +83,8 @@ function formatDate(dateStr: string) {
}
export function FileList() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.files');
const queryClient = useQueryClient();
const [keyword, setKeyword] = React.useState('');
@@ -564,7 +567,7 @@ export function FileList() {
<div className='grid grid-cols-3 gap-2 border-b border-dashed pb-2'>
<span className='text-muted-foreground font-medium'>
Hash (SHA-256)
{uiText('ui_fileHash')}
</span>
<span className='col-span-2 font-mono break-all select-all text-foreground/90'>
{detailTarget.hash}
@@ -50,6 +50,12 @@ const categoryMap: Record<string, string> = {
其他: 'others',
};
// The API retains persisted category keys; labels always follow the UI locale.
function categoryTranslationKey(name: string) {
const key = categoryMap[name] || 'others';
return `stats.cat${key.charAt(0).toUpperCase()}${key.slice(1)}`;
}
function useStatsChartConfig() {
const t = useTranslations('admin.files');
return {
@@ -158,11 +164,11 @@ export function FileStats() {
stats.categories.forEach((cat) => {
if (cat.count > maxCount) {
maxCount = cat.count;
maxCountName = cat.name;
maxCountName = t(categoryTranslationKey(cat.name));
}
if (cat.size > maxSize) {
maxSize = cat.size;
maxSizeName = cat.name;
maxSizeName = t(categoryTranslationKey(cat.name));
}
});
@@ -217,7 +223,7 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-sky-500'>
{stats.total_count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
{t('stats.countUnit')}
{t.raw('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -254,7 +260,7 @@ export function FileStats() {
<div className='text-2xl font-bold font-mono text-indigo-500'>
+{trendSummary.count.toLocaleString()}{' '}
<span className='text-xs font-normal text-muted-foreground'>
{t('stats.countUnit')}
{t.raw('stats.countUnit')}
</span>
</div>
<p className='text-[10px] text-muted-foreground mt-1'>
@@ -378,7 +384,7 @@ export function FileStats() {
const formattedValue =
trendMetric === 'size'
? formatFileSize(Number(value))
: `${value} ${t('stats.countUnit')}`;
: `${value} ${t.raw('stats.countUnit')}`;
return (
<>
<div className='flex items-center gap-1.5'>
@@ -488,7 +494,7 @@ export function FileStats() {
</span>
</div>
<span className='text-foreground font-mono font-medium tabular-nums ml-auto'>
{value} {t('stats.countUnit')}
{value} {t.raw('stats.countUnit')}
</span>
</>
);
@@ -501,13 +501,15 @@ function ObjectFields({
value: ObjectStorageConfig;
onChange: (patch: Partial<ObjectStorageConfig>) => void;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.files');
return (
<>
{driver === 'r2' && (
<div className='md:col-span-2'>
<TextField
label='Account ID'
label={uiText('ui_accountId')}
value={value.account_id || ''}
onChange={(account_id) => onChange({ account_id })}
/>
@@ -516,7 +518,7 @@ function ObjectFields({
{driver !== 's3' && (
<div className='md:col-span-2'>
<TextField
label='Endpoint'
label={uiText('ui_endpointLabel')}
value={value.endpoint}
placeholder='https://...'
onChange={(endpoint) => onChange({ endpoint })}
@@ -524,22 +526,22 @@ function ObjectFields({
</div>
)}
<TextField
label='Region'
label={uiText('ui_regionLabel')}
value={value.region}
onChange={(region) => onChange({ region })}
/>
<TextField
label='Bucket'
label={uiText('ui_bucketLabel')}
value={value.bucket}
onChange={(bucket) => onChange({ bucket })}
/>
<TextField
label='Access Key ID'
label={uiText('ui_accessKeyId')}
value={value.access_key_id}
onChange={(access_key_id) => onChange({ access_key_id })}
/>
<TextField
label='Secret Access Key'
label={uiText('ui_secretAccessKey')}
type='password'
value={value.secret_access_key}
onChange={(secret_access_key) => onChange({ secret_access_key })}
@@ -564,7 +566,7 @@ function ObjectFields({
>
<div className='flex flex-col gap-0.5'>
<FieldLabel className='text-xs font-semibold cursor-pointer'>
Path Style
{uiText('ui_pathStyle')}
</FieldLabel>
<FieldDescription className='text-[11px] leading-relaxed text-muted-foreground'>
{t('storage.pathStyleDesc')}
@@ -17,6 +17,7 @@ limitations under the License.
'use client';
import { useCallback, useEffect, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import {
BarChart3,
Globe,
@@ -117,7 +118,7 @@ export function AccessAnalytics() {
const errorInstance =
err instanceof Error ? err : new Error(t('fetchFailed'));
const errMsg = errorInstance.message || '';
if (errMsg.includes('ClickHouse') || errMsg.includes('未启用')) {
if (isLogStorageDisabledMessage(errMsg)) {
setClickhouseDisabled(true);
} else {
setError(errorInstance);
@@ -1,3 +1,5 @@
'use client';
/*
Copyright 2026 Arctel.net
@@ -14,9 +16,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
'use client';
import { useMemo, useState } from 'react';
import { isLogStorageDisabledMessage } from './log-storage-error';
import { useQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { format } from 'date-fns';
@@ -94,10 +95,12 @@ function statusVariant(status: number) {
function isClickhouseDisabledError(error: Error) {
const errMsg = error.message || '';
return errMsg.includes('ClickHouse') || errMsg.includes('未启用');
return isLogStorageDisabledMessage(errMsg);
}
export function AccessLogs() {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.logs.accessLogs');
const [page, setPage] = useState(1);
@@ -519,7 +522,7 @@ export function AccessLogs() {
<div className='grid gap-2 flex-1'>
<div className='flex items-center justify-between'>
<Label className='text-xs font-bold text-muted-foreground'>
Request Headers
{uiText('ui_requestHeaders')}
</Label>
<Button
variant='ghost'
@@ -0,0 +1,14 @@
import { locales } from '@/i18n/config';
import { getRuntimeTranslator } from '@/i18n/runtime';
export function isLogStorageDisabledMessage(message: string): boolean {
const normalized = message.toLowerCase();
return (
message.includes('ClickHouse') ||
locales.some((locale) =>
normalized.includes(
getRuntimeTranslator(locale)('disabled_f95ea7f4').toLowerCase(),
),
)
);
}
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -81,6 +81,8 @@ import type {
import { PushService } from '@/lib/services/push';
export function EventsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.events');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushEvent | null>(
@@ -706,11 +708,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{newEventType === 'task'
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -721,7 +723,11 @@ export function EventsTab() {
onChange={(e) => setNewEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
newEventType === 'task'
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</div>
@@ -886,11 +892,11 @@ export function EventsTab() {
</div>
<div className='space-y-1.5'>
<div className='flex justify-between items-center'>
<div className='flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between'>
<Label className='text-xs font-semibold'>
{t('contentTemplate')}
</Label>
<span className='text-[10px] text-muted-foreground font-mono flex items-center'>
<span className='text-[10px] text-muted-foreground font-mono'>
{selectedEvent.task_type
? t('taskTemplateVars')
: t('eventTemplateVars')}
@@ -901,7 +907,11 @@ export function EventsTab() {
onChange={(e) => setEventTemplate(e.target.value)}
rows={6}
className='text-xs font-mono'
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
placeholder={rt.raw(
selectedEvent.task_type
? 'task_notification_template_example'
: 'title_administrator_login_alert_content_administrator_user_6f32cf7c',
)}
/>
</div>
</div>
@@ -1,8 +1,8 @@
'use client';
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -62,6 +62,8 @@ import type {
import { PushService } from '@/lib/services/push';
export function SettingsTab() {
const rt = useTranslations('runtime');
const t = useTranslations('admin.push.settings');
const queryClient = useQueryClient();
const [deleteTarget, setDeleteTarget] = React.useState<PushChannel | null>(
@@ -606,7 +608,7 @@ export function SettingsTab() {
toast.success(t('loadedFeishuTemplate'));
}}
>
飞书 Webhook
{rt('lark_webhook_c2e0b312')}
</Button>
<Button
variant='outline'
@@ -623,7 +625,9 @@ export function SettingsTab() {
msgtype: 'markdown',
markdown: {
title: '$title',
text: '### $title\n$content\n\n[查看详情]($url)',
text: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -633,7 +637,7 @@ export function SettingsTab() {
toast.success(t('loadedDingTalkTemplate'));
}}
>
钉钉群机器人
{rt('dingtalk_group_bot_5d8efec9')}
</Button>
<Button
variant='outline'
@@ -649,8 +653,9 @@ export function SettingsTab() {
{
msgtype: 'markdown',
markdown: {
content:
'### $title\n$content\n\n[查看详情]($url)',
content: rt(
'title_content_view_details_url_7af4b7d5',
),
},
},
null,
@@ -660,7 +665,7 @@ export function SettingsTab() {
toast.success(t('loadedWeChatTemplate'));
}}
>
企业微信群机器人
{rt('wecom_group_bot_138ea7a8')}
</Button>
<Button
variant='outline'
@@ -684,7 +689,7 @@ export function SettingsTab() {
toast.success(t('loadedBarkTemplate'));
}}
>
Bark App
{rt('ui_barkApp')}
</Button>
</div>
</div>
@@ -64,6 +64,8 @@ async function copyText(value: string) {
}
export function OpenFlareOpsSettings() {
const uiText = useTranslations('runtime');
const t = useTranslations('openflareOps');
const queryClient = useQueryClient();
const [fields, setFields] = useState<OpenFlareOpsFields>(
@@ -346,7 +348,7 @@ export function OpenFlareOpsSettings() {
value={geoIPMutation.data.name || '—'}
/>
<InfoCell
label='ISO Code'
label={uiText('ui_isoCode')}
value={geoIPMutation.data.iso_code || '—'}
/>
</div>
@@ -427,7 +429,7 @@ export function OpenFlareOpsSettings() {
</CardHeader>
<CardContent className='space-y-4'>
<FieldInput
label='Server URL'
label={uiText('ui_serverUrl')}
value={fields.server_address}
onChange={(value) => updateField('server_address', value)}
placeholder='https://yourdomain.com'
@@ -1,7 +1,9 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { normalizeLocale } from '@/i18n/config';
import { formatSystemMetric } from '@/lib/utils/system-metrics';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
import {
@@ -34,19 +36,12 @@ import { Skeleton } from '@/components/ui/skeleton';
import type { SystemStatus } from '@/lib/services/admin';
import services from '@/lib/services';
/**
* 格式化数字,每3位加逗号
*/
const formatNumber = (num: number | string) => {
if (num === undefined || num === null) return '0';
return num.toString().replace(/\B(?=(\d{3})+(?!\B))/g, ',');
};
/**
* 运行时系统状态展示与管理组件
*/
export function SystemStatusManager() {
const t = useTranslations('openflareOps.status');
const locale = normalizeLocale(useLocale());
const [status, setStatus] = useState<SystemStatus | null>(null);
const [loading, setLoading] = useState(true);
const [wavelet, setWavelet] = useState(false);
@@ -164,7 +159,7 @@ export function SystemStatusManager() {
<span
className={`text-xs font-mono font-medium ${field ? getPulseClass(field) : ''}`}
>
{typeof value === 'number' ? formatNumber(value) : value}
{formatSystemMetric(value, locale)}
</span>
</div>
);
+20 -5
View File
@@ -1,7 +1,8 @@
'use client';
import * as React from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { localizeConfigDescription } from '@/i18n/builtin-metadata';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import {
@@ -52,6 +53,7 @@ function SystemConfigDetailPanel({
saving: boolean;
}) {
const t = useTranslations('admin.system');
const locale = useLocale();
return (
<ManageDetailPanel isEmpty={!config} onSave={onSave} saving={saving}>
<div className='grid grid-cols-1 gap-0'>
@@ -138,8 +140,16 @@ function SystemConfigDetailPanel({
type='text'
value={
editData.description !== undefined
? editData.description
: config?.description || ''
? localizeConfigDescription(
config?.key ?? '',
editData.description,
locale,
)
: localizeConfigDescription(
config?.key ?? '',
config?.description || '',
locale,
)
}
placeholder={t('optionalDescription')}
onChange={(e) => {
@@ -205,6 +215,7 @@ function SystemConfigDetailPanel({
*/
export function SystemConfigs() {
const t = useTranslations('admin.system');
const locale = useLocale();
const {
systemConfigs: configs,
systemConfigsLoading: loading,
@@ -309,9 +320,13 @@ export function SystemConfigs() {
cell: (item) => (
<span
className='truncate max-w-[200px] inline-block text-muted-foreground'
title={item.description}
title={localizeConfigDescription(
item.key,
item.description,
locale,
)}
>
{item.description}
{localizeConfigDescription(item.key, item.description, locale)}
</span>
),
width: '200px',
@@ -47,7 +47,6 @@ import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import { format } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { Calendar } from '@/components/ui/calendar';
import {
Popover,
@@ -177,7 +176,6 @@ function DatePickerWithTime({
mode='single'
selected={date}
onSelect={handleDateSelect}
locale={zhCN}
/>
</PopoverContent>
</Popover>
@@ -34,6 +34,8 @@ export function UserDetailSheet({
detailLoading,
onStatusToggle,
}: UserDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('admin.users');
const displayValue = (value?: string) =>
value && value.trim() ? value : '-';
@@ -72,7 +74,7 @@ export function UserDetailSheet({
</Badge>
{selectedUser.is_admin && (
<Badge className='h-4.5 px-1.5 text-[9px] uppercase font-medium'>
Admin
{uiText('ui_administrator')}
</Badge>
)}
</div>
@@ -61,6 +61,8 @@ export function LogDetailSheet({
open,
onOpenChange,
}: LogDetailSheetProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('applyLogs');
return (
<Sheet open={open} onOpenChange={onOpenChange}>
@@ -75,7 +77,7 @@ export function LogDetailSheet({
<div className='flex flex-wrap gap-2'>
<ResultBadge result={log.result} />
<Badge variant='outline' className='text-[10px] rounded-full'>
Node: {log.node_id}
{uiText('ui_nodeLabel')} {log.node_id}
</Badge>
<Badge variant='outline' className='text-[10px] rounded-full'>
{t('versionBadge', { version: log.version })}
@@ -130,6 +130,8 @@ function statusVariant(status: TaskExecutionStatus) {
}
export function SyncTasksPanel() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare.tasks');
const queryClient = useQueryClient();
const [page, setPage] = useState(1);
@@ -469,7 +471,9 @@ export function SyncTasksPanel() {
) : null}
<div className='space-y-1'>
<div className='text-xs text-muted-foreground'>Payload</div>
<div className='text-xs text-muted-foreground'>
{uiText('ui_payload')}
</div>
<pre className='max-h-36 overflow-auto rounded-md border bg-muted/40 p-3 text-xs leading-relaxed'>
{selected.payload || '{}'}
</pre>
@@ -67,6 +67,8 @@ function getGroupIdFromPathname(pathname: string | null): number {
}
export function CloudflareGroupDetailPageClient() {
const uiText = useTranslations('runtime');
const t = useTranslations('cloudflare');
const tCommon = useTranslations('common');
const [mounted, setMounted] = useState(false);
@@ -449,7 +451,7 @@ export function CloudflareGroupDetailPageClient() {
setSelectedMemberIDs(new Set());
}
}}
aria-label='Select all'
aria-label={uiText('ui_selectAll')}
/>
</TableHead>
<TableHead>{t('columns.domain')}</TableHead>
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useTranslations } from 'next-intl';
import { RankChart } from '@/components/data/rank-chart';
@@ -16,7 +17,7 @@ function buildNodeRankItems(
const leftValue = selector(left);
const rightValue = selector(right);
if (leftValue === rightValue) {
return left.name.localeCompare(right.name, 'zh-CN');
return left.name.localeCompare(right.name, getFormattingLocale());
}
return rightValue - leftValue;
})
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import ReactEChartsCore from 'echarts-for-react/lib/core';
import type { EChartsCoreOption } from 'echarts/core';
import * as echarts from 'echarts/core';
@@ -506,7 +507,7 @@ export function WorldStageMap({
) {
return [
`<div style="font-weight:600;margin-bottom:6px;">${payload.name ?? data.name}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString('zh-CN') })}</div>`,
`<div>${t('sourceRequests', { count: data.value.toLocaleString(getFormattingLocale()) })}</div>`,
].join('');
}
@@ -522,8 +523,8 @@ export function WorldStageMap({
`<div style="font-weight:600;margin-bottom:6px;">${data.name}</div>`,
`<div>${locationLine}</div>`,
`<div>${t('requestsErrors', {
requests: data.requestCount.toLocaleString('zh-CN'),
errors: data.errorCount.toLocaleString('zh-CN'),
requests: data.requestCount.toLocaleString(getFormattingLocale()),
errors: data.errorCount.toLocaleString(getFormattingLocale()),
})}</div>`,
`<div>${t('eventsStatus', {
events: data.activeEventCount,
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import type { LucideIcon } from 'lucide-react';
import {
@@ -13,6 +16,8 @@ interface PlaceholderPageProps {
}
export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
const rt = useTranslations('runtime');
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center gap-2'>
@@ -22,8 +27,12 @@ export function PlaceholderPage({ title, icon: Icon }: PlaceholderPageProps) {
<Card>
<CardHeader>
<CardTitle className='text-base'>开发中</CardTitle>
<CardDescription>该功能正在迁移开发中,敬请期待。</CardDescription>
<CardTitle className='text-base'>
{rt('under_development_78c33fbf')}
</CardTitle>
<CardDescription>
{rt('this_feature_is_being_migrated_and_developed_61ebaf78')}
</CardDescription>
</CardHeader>
</Card>
</div>
@@ -64,6 +64,7 @@ const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
@@ -320,7 +321,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Edge'
typeLabel={rt('node_type_edge')}
typeTone='info'
statusBadges={[
{
@@ -0,0 +1,148 @@
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
Field,
FieldError,
FieldGroup,
FieldLabel,
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { NodeStatus } from '@/lib/services/openflare';
export function NodeListControls({
search,
searchTooLong,
onSearchChange,
status,
onStatusChange,
page,
pageSize,
onPageSizeChange,
total,
totalPages,
loading,
hasData,
onPageChange,
}: {
search: string;
searchTooLong: boolean;
onSearchChange: (value: string) => void;
status: NodeStatus | '';
onStatusChange: (value: NodeStatus | '') => void;
page: number;
pageSize: number;
onPageSizeChange: (value: number) => void;
total: number;
totalPages: number;
loading: boolean;
hasData: boolean;
onPageChange: (value: number) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='flex flex-col gap-4'>
<FieldGroup className='flex flex-wrap gap-3 sm:flex-row'>
<Field className='flex-1' data-invalid={searchTooLong || undefined}>
<FieldLabel htmlFor='node-search'>
{t('pagination.search')}
</FieldLabel>
<Input
id='node-search'
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder={t('pagination.searchPlaceholder')}
maxLength={256}
aria-invalid={searchTooLong || undefined}
aria-describedby={searchTooLong ? 'node-search-error' : undefined}
/>
{searchTooLong && (
<FieldError id='node-search-error'>
{t('pagination.searchTooLong')}
</FieldError>
)}
</Field>
<Field className='sm:w-44'>
<FieldLabel htmlFor='node-status'>{t('table.status')}</FieldLabel>
<Select
value={status || 'all'}
onValueChange={(value) =>
onStatusChange(value === 'all' ? '' : (value as NodeStatus))
}
>
<SelectTrigger id='node-status'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem value='all'>
{t('pagination.allStatuses')}
</SelectItem>
{(['pending', 'online', 'offline'] as const).map((value) => (
<SelectItem key={value} value={value}>
{t(`status.${value}`)}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field className='sm:w-32'>
<FieldLabel htmlFor='node-page-size'>
{t('pagination.pageSize')}
</FieldLabel>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger id='node-page-size'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{[20, 50, 100].map((value) => (
<SelectItem key={value} value={String(value)}>
{value}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
</FieldGroup>
<div className='flex flex-wrap items-center justify-between gap-3'>
{hasData && (
<p className='text-sm text-muted-foreground' aria-live='polite'>
{t('pagination.summary', { page, totalPages, total })}
</p>
)}
<nav aria-label={t('pagination.navigation')} className='flex gap-2'>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page <= 1}
onClick={() => onPageChange(page - 1)}
>
{t('pagination.previous')}
</Button>
<Button
variant='outline'
size='sm'
disabled={!hasData || loading || page >= totalPages}
onClick={() => onPageChange(page + 1)}
>
{t('pagination.next')}
</Button>
</nav>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
@@ -652,7 +653,9 @@ export function NodeObservability({
hint={
topSourceCountry
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
count: topSourceCountry.value.toLocaleString(
getFormattingLocale(),
),
})
: t('obs.noSourceDist')
}
@@ -64,13 +64,14 @@ export function getFilterDescription(
export function NodeTypeFilter() {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
edge: rt('node_type_edge'),
relay: rt('node_type_relay'),
tunnel: rt('node_type_tunnel'),
};
return (
@@ -1,3 +1,5 @@
import { getFormattingLocale } from '@/i18n/format';
import { runtimeText } from '@/i18n/runtime';
import type {
ApplyResult,
NodeItem,
@@ -111,9 +113,9 @@ export function getRelayStatusLabel(
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
if (nodeType === 'tunnel_relay') return 'Relay';
if (nodeType === 'tunnel_client') return 'Tunnel';
return 'Edge';
if (nodeType === 'tunnel_relay') return runtimeText('node_type_relay');
if (nodeType === 'tunnel_client') return runtimeText('node_type_tunnel');
return runtimeText('node_type_edge');
}
export function getErrorMessage(error: unknown, fallback: string) {
@@ -255,7 +257,7 @@ export function formatMetricCount(value?: number | null) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
return value.toLocaleString('zh-CN');
return value.toLocaleString(getFormattingLocale());
}
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
@@ -37,15 +37,20 @@ export function NodesTable({
deletingId,
onEdit,
onDelete,
actionsDisabled = false,
}: {
nodes: NodeItem[];
deletingId: number | null;
actionsDisabled?: boolean;
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='border border-dashed rounded-lg overflow-hidden'>
<div
className='border border-dashed rounded-lg overflow-hidden'
aria-busy={actionsDisabled}
>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
@@ -144,16 +149,22 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs'
asChild
asChild={!actionsDisabled}
disabled={actionsDisabled}
>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
{actionsDisabled ? (
t('detail')
) : (
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
)}
</Button>
<Button
variant='outline'
size='sm'
className='h-7 text-xs'
disabled={actionsDisabled}
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
@@ -163,7 +174,7 @@ export function NodesTable({
variant='outline'
size='sm'
className='h-7 text-xs text-destructive hover:text-destructive'
disabled={deletingId === node.id}
disabled={actionsDisabled || deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
@@ -79,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const rt = useTranslations('runtime');
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
@@ -116,7 +118,9 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
'relay_frps_web_ui_port',
{
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
description:
portCfg?.description ||
rt('port_for_the_built_in_frps_web_cfab540f'),
},
);
},
@@ -485,7 +489,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Relay'
typeLabel={rt('node_type_relay')}
typeTone='warning'
statusBadges={[
{
@@ -65,6 +65,7 @@ const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const rt = useTranslations('runtime');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
@@ -313,7 +314,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel='Tunnel'
typeLabel={rt('node_type_tunnel')}
typeTone='info'
statusBadges={[
{
@@ -1,7 +1,6 @@
'use client';
import Link from 'next/link';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
@@ -25,20 +24,20 @@ export function NodeDetailPageClient() {
const searchParams = useSearchParams();
const nodeId = searchParams.get('id')?.trim() ?? '';
const validNodeId =
Number.isSafeInteger(Number(nodeId)) && Number(nodeId) > 0;
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
queryKey: [...nodesQueryKey, 'detail', nodeId],
queryFn: () => NodeService.getNode(Number(nodeId)),
enabled: validNodeId,
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
});
const node = useMemo(() => {
if (!nodeId) return null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
const node = nodesQuery.data;
if (!nodeId) {
if (!validNodeId) {
return (
<div className='py-6 px-1'>
<EmptyStateWithBorder icon={Server} description={t('missingId')} />
+136 -26
View File
@@ -2,7 +2,7 @@
import Link from 'next/link';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -29,46 +29,112 @@ import {
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import type {
NodeItem,
NodeListQuery,
NodeMutationPayload,
NodeStatus,
NodeType,
} from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import { NodeEditorDialog } from './components/node-editor-dialog';
import {
filterNodesByType,
getFilterDescription,
getNodeFilter,
NodeTypeFilter,
type NodeFilter,
} from './components/node-type-filter';
import { NodesTable } from './components/nodes-table';
import { NodeListControls } from './components/node-list-controls';
import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function NodesPageClient() {
const searchParams = useSearchParams();
const nodeFilter = getNodeFilter(
new URLSearchParams(searchParams.toString()),
);
return <NodesPage key={nodeFilter} nodeFilter={nodeFilter} />;
}
function NodesPage({ nodeFilter }: { nodeFilter: NodeFilter }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<NodeItem | null>(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [status, setStatus] = useState<NodeStatus | ''>('');
const searchTooLong = new TextEncoder().encode(search.trim()).length > 256;
const nodeFilter = useMemo(
() => getNodeFilter(new URLSearchParams(searchParams.toString())),
[searchParams],
);
const resetNodeActions = useCallback(() => {
setEditingNode(null);
setEditorOpen(false);
setDeleteTarget(null);
}, []);
useEffect(() => {
if (searchTooLong || search.trim() === debouncedSearch) return;
const timeout = setTimeout(() => {
setDebouncedSearch(search.trim());
setPage(1);
resetNodeActions();
}, 300);
return () => clearTimeout(timeout);
}, [search, searchTooLong, debouncedSearch, resetNodeActions]);
const nodeTypes: Record<NodeFilter, NodeType | ''> = {
all: '',
edge: 'edge_node',
relay: 'tunnel_relay',
tunnel: 'tunnel_client',
};
const query = {
page,
page_size: pageSize,
search: debouncedSearch,
status,
node_type: nodeTypes[nodeFilter],
};
const nodesQuery = useQuery({
queryKey: nodesQueryKey,
queryFn: () => NodeService.listNodes(),
refetchInterval: 5000,
queryKey: [...nodesQueryKey, 'page', query],
queryFn: () => NodeService.listNodePage(query),
placeholderData: (previousData, previousQuery) => {
const previousParams = previousQuery?.queryKey[3] as
| NodeListQuery
| undefined;
return previousParams?.search === query.search &&
previousParams?.status === query.status &&
previousParams?.node_type === query.node_type &&
previousParams?.page_size === query.page_size
? previousData
: undefined;
},
refetchInterval: (query) =>
query.state.status === 'error' ? false : 15000,
refetchIntervalInBackground: false,
});
const nodes = useMemo(() => nodesQuery.data ?? [], [nodesQuery.data]);
const filteredNodes = useMemo(
() => filterNodesByType(nodes, nodeFilter),
[nodeFilter, nodes],
const nodes = useMemo(
() => nodesQuery.data?.results ?? [],
[nodesQuery.data],
);
const total = nodesQuery.data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (
!nodesQuery.isPlaceholderData &&
nodesQuery.data?.page === page &&
page > totalPages
) {
setPage(totalPages);
resetNodeActions();
}
const saveMutation = useMutation({
mutationFn: async (payload: NodeMutationPayload) => {
@@ -106,6 +172,7 @@ export function NodesPageClient() {
};
const handleEdit = (node: NodeItem) => {
if (nodesQuery.isPlaceholderData) return;
setEditingNode(node);
setEditorOpen(true);
};
@@ -115,7 +182,7 @@ export function NodesPageClient() {
};
return (
<div className='py-6 px-1 space-y-6'>
<div className='flex w-full flex-col gap-6 py-6 px-1'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
@@ -166,8 +233,34 @@ export function NodesPageClient() {
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<CardContent className='flex flex-col gap-4'>
<NodeTypeFilter />
<NodeListControls
search={search}
searchTooLong={searchTooLong}
onSearchChange={setSearch}
status={status}
onStatusChange={(value) => {
setStatus(value);
setPage(1);
resetNodeActions();
}}
page={nodesQuery.data?.page ?? page}
pageSize={pageSize}
onPageSizeChange={(value) => {
setPageSize(value);
setPage(1);
resetNodeActions();
}}
total={total}
totalPages={totalPages}
loading={nodesQuery.isFetching || nodesQuery.isPlaceholderData}
hasData={Boolean(nodesQuery.data)}
onPageChange={(value) => {
setPage(value);
resetNodeActions();
}}
/>
{nodesQuery.isLoading ? (
<LoadingStateWithBorder
@@ -182,21 +275,26 @@ export function NodesPageClient() {
className='justify-center'
/>
</div>
) : filteredNodes.length === 0 ? (
) : nodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
debouncedSearch || status || nodeFilter !== 'all'
? t('emptyFilter')
: t('emptyAll')
}
/>
) : (
<NodesTable
nodes={filteredNodes}
nodes={nodes}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={setDeleteTarget}
onDelete={(node) => {
if (!nodesQuery.isPlaceholderData) setDeleteTarget(node);
}}
actionsDisabled={nodesQuery.isPlaceholderData}
/>
)}
</CardContent>
@@ -205,13 +303,17 @@ export function NodesPageClient() {
<NodeEditorDialog
open={editorOpen}
node={editingNode}
submitting={saveMutation.isPending}
submitting={
saveMutation.isPending ||
(Boolean(editingNode) && nodesQuery.isPlaceholderData)
}
onClose={() => {
setEditorOpen(false);
setEditingNode(null);
}}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
if (!editingNode || !nodesQuery.isPlaceholderData)
await saveMutation.mutateAsync(payload);
}}
/>
@@ -227,14 +329,22 @@ export function NodesPageClient() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
<AlertDialogCancel
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
>
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending || nodesQuery.isPlaceholderData
}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
deleteTarget &&
!nodesQuery.isPlaceholderData &&
deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
@@ -92,7 +92,7 @@ function sourceLabel(
) {
const key = SOURCE_LABEL_KEYS[type];
if (key) return t(key);
return type === 'remote_url' ? 'Remote URL' : 'GitHub';
return type === 'remote_url' ? t('history.source.remote_url') : 'GitHub';
}
function deploymentSnapshot(
@@ -230,6 +230,8 @@ export function DeploymentHistory({
rootDir = '',
entryFile = 'index.html',
}: DeploymentHistoryProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -401,7 +403,7 @@ export function DeploymentHistory({
<div className='flex flex-col gap-6'>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>Production</CardTitle>
<CardTitle className='text-base'>{uiText('ui_production')}</CardTitle>
<CardDescription>{t('history.productionHint')}</CardDescription>
<CardAction>
<Button
@@ -440,7 +442,9 @@ export function DeploymentHistory({
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>All deployments</CardTitle>
<CardTitle className='text-base'>
{uiText('ui_allDeployments')}
</CardTitle>
<CardDescription>{t('history.historyHint')}</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
@@ -94,6 +94,8 @@ export function PagesSourceDialog({
initialMode,
onActionDispatched,
}: PagesSourceDialogProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
const tCommon = useTranslations('common');
const queryClient = useQueryClient();
@@ -361,10 +363,10 @@ export function PagesSourceDialog({
{t('manual')}
</ToggleGroupItem>
<ToggleGroupItem value='remote_url' className='w-full'>
Remote URL
{uiText('ui_remoteUrl')}
</ToggleGroupItem>
<ToggleGroupItem value='github_release' className='w-full'>
GitHub Release
{uiText('ui_githubRelease')}
</ToggleGroupItem>
</ToggleGroup>
</Field>
@@ -379,7 +381,9 @@ export function PagesSourceDialog({
) : mode === 'remote_url' ? (
<>
<Field data-invalid={Boolean(urlError)}>
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
<FieldLabel htmlFor='pages-remote-url'>
{uiText('ui_remoteUrl')}
</FieldLabel>
<Input
id='pages-remote-url'
type='url'
@@ -1,3 +1,5 @@
'use client';
import { useTranslations } from 'next-intl';
import {
@@ -44,6 +46,8 @@ export function PagesSourceGitHubFields({
onChange,
onErrorsChange,
}: PagesSourceGitHubFieldsProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('pages.source');
return (
<>
@@ -107,7 +111,9 @@ export function PagesSourceGitHubFields({
{value.releaseSelector === 'tag' ? (
<Field data-invalid={Boolean(errors.releaseTag)}>
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
<FieldLabel htmlFor='pages-github-tag'>
{uiText('ui_releaseTag')}
</FieldLabel>
<Input
id='pages-github-tag'
placeholder='v1.2.3'
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
ProxyRouteConfigSection,
ProxyRouteCustomHeader,
@@ -154,7 +155,8 @@ const limitRatePattern = /^\d+(?:[kKmM])?$/;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function linesFromTextarea(value: string) {
@@ -65,6 +65,8 @@ export function ProxySection({
onRouteUpdate,
onSavingChange,
}: ProxySectionProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('proxyRoutes');
const reverseProxySchema = z
.object({
@@ -479,7 +481,7 @@ export function ProxySection({
name='origin_host'
render={({ field }) => (
<FormItem>
<FormLabel>Origin Host Header</FormLabel>
<FormLabel>{uiText('ui_originHostHeader')}</FormLabel>
<FormControl>
<Input placeholder='origin.example.internal' {...field} />
</FormControl>
@@ -116,6 +116,8 @@ function SortableRule({ id, name, index, total, onMove }: SortableRuleProps) {
}
export function WafSection({ route, onSavingChange }: WafSectionProps) {
const uiText = useTranslations('runtime');
const t = useTranslations('proxyRoutes');
const queryClient = useQueryClient();
const [selectedIDs, setSelectedIDs] = useState<number[]>([]);
@@ -220,7 +222,7 @@ export function WafSection({ route, onSavingChange }: WafSectionProps) {
<div className='flex items-center justify-between gap-3'>
<div>
<p className='text-[10px] font-semibold uppercase tracking-wider text-muted-foreground'>
Global Rule Group
{uiText('ui_globalRuleGroup')}
</p>
<p className='mt-1 text-sm font-semibold'>
{wafQuery.data.global_rule_group.name}
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { Gauge } from 'lucide-react';
import { useTranslations } from 'next-intl';
@@ -31,9 +32,11 @@ function toAvgRpsItems(items: DistributionItem[] | undefined, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) {
return value.toLocaleString('zh-CN', { maximumFractionDigits: 1 });
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 1,
});
}
return value.toLocaleString('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 3,
minimumFractionDigits: 0,
});
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useCallback, useMemo, useRef } from 'react';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
@@ -48,7 +49,7 @@ function formatAxisTime(value: string, hours: number) {
function formatRps(value: number) {
if (!Number.isFinite(value)) return '—';
if (value >= 100) return formatCompactNumber(value);
return value.toLocaleString('zh-CN', {
return value.toLocaleString(getFormattingLocale(), {
maximumFractionDigits: 2,
minimumFractionDigits: 0,
});
+19 -8
View File
@@ -1,7 +1,10 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFIPGroup, WAFIPGroupPayload } from '@/lib/services/openflare';
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error ? error.message : (fallback ?? '操作失败');
return error instanceof Error
? error.message
: (fallback ?? runtimeText('operation_failed_0c3b4cf7'));
}
export function listToText(items: string[] | undefined) {
@@ -17,7 +20,9 @@ export function parseTextareaList(text: string) {
export function parseAutomaticConfig(
text: string,
invalidMessage = '自动配置必须是 JSON 对象。',
invalidMessage = runtimeText(
'automatic_configuration_must_be_a_json_object_90ba17a4',
),
): Record<string, unknown> {
const parsed = JSON.parse(text || '{}') as unknown;
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
@@ -28,12 +33,16 @@ export function parseAutomaticConfig(
export const automaticPresetRules = [
{
name: '单 IP 404 高频扫描',
get name() {
return runtimeText('high_frequency_404_scan_from_one_ip_f7d27ffb');
},
labelKey: 'presets.scan404' as const,
expr: 'request_count > 100 && StatusRatio(404) >= 0.8',
},
{
name: '单 IP 直连访问异常',
get name() {
return runtimeText('abnormal_direct_access_from_one_ip_732dbeee');
},
labelKey: 'presets.directAccess' as const,
expr: 'ip_host_count > 50 && ip_host_ratio > 0.5',
},
@@ -75,7 +84,7 @@ export function formatIPGroupBanRemaining(
): string {
const reference = now ?? new Date();
if (ttlSeconds <= 0) {
return t ? t('ban.permanent') : '永久';
return t ? t('ban.permanent') : runtimeText('permanent_3e71ccc8');
}
const capturedDate = new Date(capturedAt);
if (Number.isNaN(capturedDate.getTime())) {
@@ -83,7 +92,7 @@ export function formatIPGroupBanRemaining(
}
const expireDate = new Date(capturedDate.getTime() + ttlSeconds * 1000);
if (expireDate.getTime() <= reference.getTime()) {
return t ? t('ban.expired') : '已过期';
return t ? t('ban.expired') : runtimeText('expired_2fe0e333');
}
const diffMins = Math.round(
(expireDate.getTime() - reference.getTime()) / (60 * 1000),
@@ -91,10 +100,12 @@ export function formatIPGroupBanRemaining(
if (diffMins < 60) {
return t
? t('ban.minutesLater', { count: diffMins })
: `${diffMins} 分钟后`;
: runtimeText('in_value0_minutes_fd2a5b53', { value0: diffMins });
}
const hours = Math.round(diffMins / 60);
return t ? t('ban.hoursLater', { count: hours }) : `${hours} 小时后`;
return t
? t('ban.hoursLater', { count: hours })
: runtimeText('in_value0_hours_5ca37144', { value0: hours });
}
export function getIPGroupViewEntries(
@@ -1,7 +1,9 @@
import { locales } from '@/i18n/config';
import type { EdgeChange, Node, NodeChange } from '@xyflow/react';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { wouldCreateCycle } from './graph-validation';
import { getRuntimeTranslator } from '@/i18n/runtime';
export type GraphErrorTarget = { kind: 'node' | 'edge'; id: string };
@@ -109,9 +111,9 @@ export function findGraphErrorTarget(
}
const messages = collectMessages(payload);
for (const message of messages) {
const nodeId = findMessageId(message, '节点', nodeIds);
const nodeId = findMessageId(message, 'node', nodeIds);
if (nodeId) return { kind: 'node', id: nodeId };
const edgeId = findMessageId(message, '边', edgeIds);
const edgeId = findMessageId(message, 'edge', edgeIds);
if (edgeId) return { kind: 'edge', id: edgeId };
}
return undefined;
@@ -132,16 +134,22 @@ function collectMessages(value: unknown): string[] {
function findMessageId(
message: string,
prefix: '节点' | '边',
prefix: 'node' | 'edge',
ids: string[],
): string | undefined {
const token = prefix === '边' ? '边(?:\\s+ID)?' : '节点';
const tokenKey = prefix === 'edge' ? 'edge_099db313' : 'node_2410d860';
return [...ids]
.sort((a, b) => b.length - a.length)
.find((id) =>
new RegExp(`${token}\\s+${escapeRegExp(id)}(?=$|[\\s,。,::的])`).test(
message,
),
locales.some((locale) => {
const t = getRuntimeTranslator(locale);
const token = `${escapeRegExp(t(tokenKey))}(?:\\s+ID)?`;
const pattern = t
.raw('value0_s_value1_s_40960c98')
.replace('{value0}', token)
.replace('{value1}', escapeRegExp(id));
return new RegExp(pattern, 'i').test(message);
}),
);
}
@@ -1,8 +1,22 @@
import { describe, expect, it } from 'vitest';
import { countryOptions, regionOptions } from './geo-options';
import { createGeoOptions } from './geo-options';
const { countryOptions, regionOptions } = createGeoOptions('zh-CN');
describe('geo options', () => {
it('uses English country names when requested without changing country codes', () => {
const english = createGeoOptions('en');
expect(english.countryOptions).toContainEqual(
expect.objectContaining({ value: 'CN', label: 'China' }),
);
expect(english.regionOptions).toContainEqual(
expect.objectContaining({
value: 'CN-BJ',
searchText: expect.stringContaining('China'),
}),
);
});
it('contains the complete country list with localized names', () => {
expect(countryOptions).toHaveLength(249);
expect(countryOptions).toContainEqual(
@@ -8,46 +8,50 @@ export interface GeoOption {
const countryCodePattern = /^[A-Z]{2}$/;
const regionCodePattern = /^[A-Z0-9]{1,3}$/;
const countryDisplayNames = new Intl.DisplayNames(['zh-CN'], {
type: 'region',
});
export function createGeoOptions(locale: string) {
const countryDisplayNames = new Intl.DisplayNames([locale], {
type: 'region',
});
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
function localizedCountryName(code: string, fallback: string) {
const localized = countryDisplayNames.of(code);
return !localized || localized === code ? fallback : localized;
}
const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, locale));
const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
return { countryOptions, regionOptions };
}
export const countryOptions: GeoOption[] = allCountries
.filter(([, code]) => countryCodePattern.test(code))
.map(([englishName, code]) => {
const label = localizedCountryName(code, englishName);
return {
value: code,
label,
searchText: `${label} ${englishName} ${code}`,
};
})
.sort((left, right) => left.label.localeCompare(right.label, 'zh-CN'));
export const regionOptions: GeoOption[] = allCountries.flatMap(
([englishCountryName, countryCode, regions]) => {
const countryName = localizedCountryName(countryCode, englishCountryName);
return regions.flatMap(([regionName, shortCode]) => {
const normalizedShortCode = shortCode?.toUpperCase();
if (
!normalizedShortCode ||
!regionCodePattern.test(normalizedShortCode)
) {
return [];
}
const value = `${countryCode}-${normalizedShortCode}`;
return [
{
value,
label: `${regionName} · ${countryName}`,
searchText: `${regionName} ${countryName} ${englishCountryName} ${value}`,
},
];
});
},
);
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type { WAFRuleGraph, WAFRuleNode } from '@/lib/services/openflare';
import { UA_BROWSER_LABELS, UA_OS_LABELS } from './ua-options';
@@ -39,25 +40,33 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
const issues: GraphIssue[] = [];
const nodeMap = new Map(graph.nodes.map((node) => [node.id, node]));
if (graph.schema_version !== 1)
issues.push({ code: 'schema', message: '规则图 schema_version 必须为 1' });
issues.push({
code: 'schema',
message: runtimeText('rule_graph_schema_version_must_be_1_ea9a1161'),
});
if (
graph.nodes.length > 128 ||
graph.edges.length > 256 ||
new TextEncoder().encode(JSON.stringify(graph)).length > 256 * 1024
)
issues.push({ code: 'size_limit', message: '规则图超过大小限制' });
issues.push({
code: 'size_limit',
message: runtimeText('rule_graph_exceeds_the_size_limit_337651b9'),
});
const nodeIds = new Set<string>();
for (const node of graph.nodes) {
if (!node.id.trim())
issues.push({
code: 'empty_id',
message: '节点 ID 不能为空',
message: runtimeText('node_id_must_not_be_empty_51a5de58'),
nodeId: node.id,
});
if (nodeIds.has(node.id))
issues.push({
code: 'duplicate_node_id',
message: `节点 ID ${node.id} 重复`,
message: runtimeText('duplicate_node_id_value0_621230af', {
value0: node.id,
}),
nodeId: node.id,
});
nodeIds.add(node.id);
@@ -74,20 +83,22 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!edge.id.trim())
issues.push({
code: 'empty_id',
message: '连线 ID 不能为空',
message: runtimeText('edge_id_must_not_be_empty_bc8f69cb'),
edgeId: edge.id,
});
if (edgeIds.has(edge.id))
issues.push({
code: 'duplicate_edge_id',
message: `连线 ID ${edge.id} 重复`,
message: runtimeText('duplicate_edge_id_value0_0a9d1c7c', {
value0: edge.id,
}),
edgeId: edge.id,
});
edgeIds.add(edge.id);
if (nodeMap.get(edge.target)?.type === 'start')
issues.push({
code: 'start_incoming',
message: '开始节点不能有入边',
message: runtimeText('the_start_node_must_not_have_incoming_50a07b17'),
edgeId: edge.id,
nodeId: edge.target,
});
@@ -95,12 +106,12 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (graph.nodes.filter((node) => node.type === 'start').length !== 1)
issues.push({
code: 'start_count',
message: '规则图必须恰好有一个开始节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_7dfbc027'),
});
if (graph.nodes.filter((node) => node.type === 'allow').length !== 1)
issues.push({
code: 'allow_count',
message: '规则图必须恰好有一个通过节点',
message: runtimeText('the_rule_graph_must_contain_exactly_one_0036eb82'),
});
for (const edge of graph.edges) {
@@ -112,7 +123,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
) {
issues.push({
code: 'invalid_edge',
message: `连线 ${edge.id} 的端点或出口无效`,
message: runtimeText(
'edge_value0_has_an_invalid_endpoint_or_9d84eb7e',
{ value0: edge.id },
),
edgeId: edge.id,
});
}
@@ -125,13 +139,19 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (outgoing.length === 0)
issues.push({
code: 'missing_handle',
message: `节点 ${node.id} 的 ${handle} 出口未连接`,
message: runtimeText(
'node_value0_output_value1_is_not_connected_c3038e81',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
if (outgoing.length > 1)
issues.push({
code: 'duplicate_handle',
message: `节点 ${node.id} 的 ${handle} 出口只能连接一次`,
message: runtimeText(
'node_value0_output_value1_can_only_be_903e54ce',
{ value0: node.id, value1: handle },
),
nodeId: node.id,
});
}
@@ -151,7 +171,10 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!reachable.has(node.id))
issues.push({
code: 'unreachable',
message: `节点 ${node.id} 无法从开始节点到达`,
message: runtimeText(
'node_value0_is_unreachable_from_the_start_086531dc',
{ value0: node.id },
),
nodeId: node.id,
});
const terminals = graph.nodes
@@ -162,18 +185,27 @@ export function validateGraph(graph: WAFRuleGraph): GraphIssue[] {
if (!canTerminate.has(node.id))
issues.push({
code: 'non_terminating',
message: `节点 ${node.id} 无法抵达终止节点`,
message: runtimeText(
'node_value0_cannot_reach_a_terminal_node_8de2ff6e',
{ value0: node.id },
),
nodeId: node.id,
});
if (hasCycle(graph))
issues.push({ code: 'cycle', message: '规则图不能包含循环' });
issues.push({
code: 'cycle',
message: runtimeText('the_rule_graph_must_not_contain_cycles_fd6fe797'),
});
return issues;
}
function validateNodeConfig(node: WAFRuleNode): string | undefined {
if (node.type === 'ip_match') {
if (node.config.ips.some((value) => !isIP(value)))
return `节点 ${node.id} 包含无效 IP`;
return runtimeText(
'node_value0_contains_an_invalid_ip_address_1873ab94',
{ value0: node.id },
);
if (
node.config.cidrs.some((value) => {
const parts = value.split('/');
@@ -186,9 +218,13 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
);
})
)
return `节点 ${node.id} 包含无效 CIDR`;
return runtimeText('node_value0_contains_an_invalid_cidr_00c880d9', {
value0: node.id,
});
if (node.config.ip_group_ids.some((id) => !Number.isInteger(id) || id <= 0))
return `节点 ${node.id} 包含无效 IP 组`;
return runtimeText('node_value0_contains_an_invalid_ip_group_589219a5', {
value0: node.id,
});
}
if (
node.type === 'geo_match' &&
@@ -197,7 +233,10 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
(code) => !/^[A-Z]{2}-[A-Z0-9]{1,3}$/.test(code),
))
)
return `节点 ${node.id} 包含无效地域代码`;
return runtimeText(
'node_value0_contains_an_invalid_geography_code_1a9f469b',
{ value0: node.id },
);
if (
node.type === 'pow' &&
(!['fast', 'slow'].includes(node.config.algorithm) ||
@@ -205,37 +244,61 @@ function validateNodeConfig(node: WAFRuleNode): string | undefined {
!isIntegerInRange(node.config.session_ttl, 60) ||
!isIntegerInRange(node.config.challenge_ttl, 30))
)
return `节点 ${node.id} 的 PoW 配置超出范围`;
return runtimeText('node_value0_has_pow_settings_outside_the_9fd2a4cb', {
value0: node.id,
});
if (
node.type === 'block' &&
(!isIntegerInRange(node.config.status_code, 400, 599) ||
new TextEncoder().encode(node.config.response_body).length > 16 * 1024)
)
return `节点 ${node.id} 的阻止响应配置无效`;
return runtimeText(
'node_value0_has_invalid_block_response_settings_ae4c35ca',
{ value0: node.id },
);
if (node.type === 'ua_check') {
if (!['and', 'or'].includes(node.config.match_mode))
return `节点 ${node.id} 的匹配模式必须为 and 或 or`;
return runtimeText('node_value0_match_mode_must_be_and_242c1ae7', {
value0: node.id,
});
if (node.config.browsers.some((label) => !UA_BROWSER_LABELS.has(label)))
return `节点 ${node.id} 包含无效浏览器标签`;
return runtimeText(
'node_value0_contains_an_invalid_browser_tag_4d0e6740',
{ value0: node.id },
);
if (node.config.operating_systems.some((label) => !UA_OS_LABELS.has(label)))
return `节点 ${node.id} 包含无效操作系统标签`;
return runtimeText(
'node_value0_contains_an_invalid_operating_system_45bea1fb',
{ value0: node.id },
);
if (node.config.custom_ua_patterns.length > 32)
return `节点 ${node.id} 的自定义 UA 正则不能超过 32 条`;
return runtimeText('node_value0_allows_at_most_32_custom_497938c6', {
value0: node.id,
});
for (const pattern of node.config.custom_ua_patterns) {
if (!pattern.trim()) return `节点 ${node.id} 的自定义 UA 正则不能为空`;
if (!pattern.trim())
return runtimeText('node_value0_custom_ua_pattern_must_not_a72718be', {
value0: node.id,
});
if (new TextEncoder().encode(pattern).length > 256)
return `节点 ${node.id} 的自定义 UA 正则过长`;
return runtimeText('node_value0_custom_ua_pattern_is_too_c2c83c0e', {
value0: node.id,
});
try {
void new RegExp(pattern);
} catch {
return `节点 ${node.id} 的自定义 UA 正则无效`;
return runtimeText('node_value0_has_an_invalid_custom_ua_c47ffd0d', {
value0: node.id,
});
}
}
if (
node.config.block_custom_ua &&
node.config.custom_ua_patterns.length === 0
)
return `节点 ${node.id} 开启屏蔽自定义 UA 时至少需要一条正则`;
return runtimeText('node_value0_requires_at_least_one_pattern_6114bb93', {
value0: node.id,
});
}
return undefined;
}
@@ -1,29 +1,38 @@
import { describe, expect, it } from 'vitest';
import { getRuntimeTranslator } from '@/i18n/runtime';
import {
createRuleNode,
displayNodeTitle,
NODE_TYPE_LABELS,
parseAddableNodeType,
} from './node-factory';
describe('displayNodeTitle', () => {
it('uses trimmed label when present', () => {
expect(
displayNodeTitle({
type: 'ip_match',
label: ' 办公室 ',
}),
displayNodeTitle(
{
type: 'ip_match',
label: ' 办公室 ',
},
getRuntimeTranslator('en'),
),
).toBe('办公室');
});
it('falls back to type default when label empty', () => {
expect(
displayNodeTitle({
type: 'block',
label: ' ',
}),
).toBe(NODE_TYPE_LABELS.block);
displayNodeTitle(
{
type: 'block',
label: ' ',
},
getRuntimeTranslator('en'),
),
).toBe('Block');
expect(
displayNodeTitle({ type: 'block' }, getRuntimeTranslator('zh-CN')),
).toBe('阻止');
});
});
@@ -7,22 +7,25 @@ export type AddableNodeType = Extract<
'ip_match' | 'geo_match' | 'ua_check' | 'security_check' | 'pow' | 'block'
>;
export const NODE_TYPE_LABELS: Record<WAFRuleNode['type'], string> = {
start: '开始',
ip_match: 'IP 匹配',
geo_match: '地域匹配',
ua_check: 'UA 检查',
security_check: '安全防护',
pow: 'PoW 挑战',
allow: '通过',
block: '阻止',
};
export const NODE_TYPE_LABEL_KEYS = {
start: 'start_d2bb025a',
ip_match: 'ip_match_43f4b6d4',
geo_match: 'geography_match_9acb08c4',
ua_check: 'ua_check_b56dac23',
security_check: 'security_checks_e09822e6',
pow: 'pow_challenge_3c043088',
allow: 'allow_1e9f2561',
block: 'block_2a366d09',
} as const satisfies Record<WAFRuleNode['type'], string>;
export function displayNodeTitle(
node: Pick<WAFRuleNode, 'type' | 'label'>,
translate: (
key: (typeof NODE_TYPE_LABEL_KEYS)[WAFRuleNode['type']],
) => string,
): string {
const custom = node.label?.trim();
return custom || NODE_TYPE_LABELS[node.type];
return custom || translate(NODE_TYPE_LABEL_KEYS[node.type]);
}
export function createRuleNode(
@@ -1,3 +1,4 @@
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -10,7 +11,7 @@ import {
import { Button } from '@/components/ui/button';
import {
NODE_TYPE_LABELS,
NODE_TYPE_LABEL_KEYS,
WAF_NODE_DRAG_MIME,
type AddableNodeType,
} from './node-factory';
@@ -25,6 +26,7 @@ const items = [
] satisfies { type: AddableNodeType; icon: typeof Fingerprint }[];
export function NodeLibrary() {
const rt = useTranslations('runtime');
return (
<div className='flex items-center gap-2'>
{items.map(({ type, icon: Icon }) => (
@@ -42,7 +44,7 @@ export function NodeLibrary() {
}}
>
<Icon data-icon='inline-start' />
{NODE_TYPE_LABELS[type]}
{rt(NODE_TYPE_LABEL_KEYS[type])}
</Button>
))}
</div>
@@ -1,6 +1,6 @@
import { CircleHelp, Settings2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useLocale, useTranslations } from 'next-intl';
import { useMemo, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
@@ -35,8 +35,8 @@ import {
} from '@/components/ui/tooltip';
import type { WAFIPGroup, WAFRuleNode } from '@/lib/services/openflare';
import { countryOptions, regionOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABELS } from './node-factory';
import { createGeoOptions, type GeoOption } from './geo-options';
import { NODE_TYPE_LABEL_KEYS } from './node-factory';
import { UA_BROWSER_OPTIONS, UA_OS_OPTIONS } from './ua-options';
export function NodeProperties({
@@ -78,6 +78,14 @@ function PropertyFields({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const locale = useLocale();
const { countryOptions, regionOptions } = useMemo(
() =>
node.type === 'geo_match'
? createGeoOptions(locale)
: { countryOptions: [], regionOptions: [] },
[locale, node.type],
);
if (node.type === 'start' || node.type === 'allow')
return (
<p className='text-sm text-muted-foreground'>{t('systemNodeNoConfig')}</p>
@@ -518,13 +526,14 @@ function DisplayNameField({
onChange: (node: WAFRuleNode) => void;
}) {
const t = useTranslations('waf.editor');
const rt = useTranslations('runtime');
return (
<Field>
<FieldLabel htmlFor={`${node.id}-label`}>{t('displayName')}</FieldLabel>
<Input
id={`${node.id}-label`}
value={node.label ?? ''}
placeholder={NODE_TYPE_LABELS[node.type]}
placeholder={rt(NODE_TYPE_LABEL_KEYS[node.type])}
onChange={(event) => onChange({ ...node, label: event.target.value })}
/>
</Field>
@@ -1,4 +1,5 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { useTranslations } from 'next-intl';
import {
Ban,
Fingerprint,
@@ -42,10 +43,11 @@ const outputHandles: Partial<Record<WAFRuleNode['type'], string[]>> = {
};
export function RuleNode({ data, selected }: NodeProps) {
const rt = useTranslations('runtime');
const value = data as RuleFlowNodeData;
const { rule, issues } = value;
const { icon: Icon } = meta[rule.type];
const title = displayNodeTitle(rule);
const title = displayNodeTitle(rule, rt);
return (
<div
className={cn(
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
export const UA_BROWSER_OPTIONS = [
{ value: 'Chrome', label: 'Chrome' },
{ value: 'Safari', label: 'Safari' },
@@ -5,12 +6,32 @@ export const UA_BROWSER_OPTIONS = [
{ value: 'Edge', label: 'Edge' },
{ value: 'Opera', label: 'Opera' },
{ value: 'Chromium', label: 'Chromium' },
{ value: 'WeChat', label: '微信' },
{
value: 'WeChat',
get label() {
return runtimeText('wechat_81b430b6');
},
},
{ value: 'Postman', label: 'Postman' },
{ value: 'CLI', label: 'CLI' },
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
] as const;
export const UA_OS_OPTIONS = [
@@ -20,9 +41,24 @@ export const UA_OS_OPTIONS = [
{ value: 'macOS', label: 'macOS' },
{ value: 'Chrome OS', label: 'Chrome OS' },
{ value: 'Linux', label: 'Linux' },
{ value: 'Bot', label: 'Bot' },
{ value: 'Unknown', label: 'Unknown' },
{ value: 'Other', label: 'Other' },
{
value: 'Bot',
get label() {
return runtimeText('ui_botLabel');
},
},
{
value: 'Unknown',
get label() {
return runtimeText('ui_unknownLabel');
},
},
{
value: 'Other',
get label() {
return runtimeText('ui_otherLabel');
},
},
] as const;
export const UA_BROWSER_LABELS = new Set<string>(
@@ -219,8 +219,8 @@ function EditorContent() {
<ArrowLeft className='size-3.5' />
{t('back')}
</Button>
<div className='flex items-center justify-between gap-4'>
<div className='flex min-w-0 items-center gap-2'>
<div className='flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between'>
<div className='flex min-w-0 flex-wrap items-center gap-2'>
<GitBranch className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>
{ruleQuery.data.name}
@@ -235,7 +235,7 @@ function EditorContent() {
</Badge>
{dirty && <Badge variant='secondary'>{t('unsaved')}</Badge>}
</div>
<div className='flex shrink-0 items-center gap-2'>
<div className='flex flex-wrap items-center gap-2 xl:shrink-0'>
<div className='flex items-center gap-2'>
<Switch
id='rule-enabled'
@@ -1,5 +1,6 @@
'use client';
import { getFormattingLocale } from '@/i18n/format';
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Activity, HardDrive, Users } from 'lucide-react';
@@ -31,13 +32,13 @@ function formatAxisLabel(iso: string, range: ZoneStatsRange) {
return '';
}
if (range === '24h') {
return date.toLocaleTimeString('en-US', {
return date.toLocaleTimeString(getFormattingLocale(), {
hour: 'numeric',
minute: undefined,
hour12: true,
});
}
return date.toLocaleDateString('en-GB', {
return date.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'short',
});
@@ -54,7 +55,10 @@ function formatWindowLabel(startedAt?: string, endedAt?: string) {
}
const fmt = (value: Date) =>
value
.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
.toLocaleDateString(getFormattingLocale(), {
day: 'numeric',
month: 'long',
})
.toUpperCase();
return `${fmt(start)} — ${fmt(end)}`;
}
@@ -65,6 +65,8 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise<void>;
}) {
const uiText = useTranslations('runtime');
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
@@ -214,7 +216,7 @@ export function QuickCreateZoneDomainDialog({
>
{!fixedZoneId ? (
<div className='space-y-1.5'>
<Label>Zone</Label>
<Label>{uiText('ui_zone')}</Label>
<Select
value={form.watch('zone_id') || undefined}
onValueChange={(value) => form.setValue('zone_id', value)}
@@ -1,3 +1,4 @@
import { runtimeText } from '@/i18n/runtime';
import type {
TlsCertificateFileImportPayload,
TlsCertificateItem,
@@ -17,7 +18,8 @@ export type TranslateFn = (
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
: (fallback ??
runtimeText('request_failed_please_try_again_later_8977b79a'));
}
export function getMatchTypeMeta(domain: string): {
@@ -25,8 +27,8 @@ export function getMatchTypeMeta(domain: string): {
tone: StatusTone;
} {
return domain.startsWith('*.')
? { label: '通配符', tone: 'warning' }
: { label: '精确匹配', tone: 'info' };
? { label: runtimeText('wildcard_c6b0739e'), tone: 'warning' }
: { label: runtimeText('exact_match_391fe725'), tone: 'info' };
}
export function getCertificateStatus(
@@ -57,7 +59,10 @@ export function getCertificateStatus(
export function buildCertificateLabel(certificate: TlsCertificateItem) {
return certificate.not_after
? `${certificate.name}(到期:${formatDateTime(certificate.not_after)})`
? runtimeText('value0_expires_value1_7d8600f8', {
value0: certificate.name,
value1: formatDateTime(certificate.not_after),
})
: certificate.name;
}
+12
View File
@@ -4,6 +4,18 @@
@custom-variant dark (&:is(.dark *));
html[data-locale-pending] {
overflow: hidden;
}
html[data-locale-pending] body {
visibility: hidden;
}
#locale-load-fallback[hidden] {
display: none;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+24 -1
View File
@@ -12,10 +12,12 @@ import { AppIntlProvider } from '@/components/providers/intl-provider';
import { SiteTitleUpdater } from '@/components/providers/title-updater';
import { RobotsMeta } from '@/components/layout/robots-meta';
import type { AppLocale } from '@/i18n/config';
import { createLocaleBootstrapScript } from '@/i18n/bootstrap';
import { Button } from '@/components/ui/button';
import './globals.css';
const inter = Inter({
subsets: ['latin'],
subsets: ['latin', 'cyrillic'],
variable: '--font-inter',
display: 'swap',
});
@@ -35,6 +37,7 @@ export default async function RootLayout({
}>) {
const locale = (await getLocale()) as AppLocale;
const messages = await getMessages();
const loading = await getTranslations('languageLoading');
return (
<html
@@ -42,10 +45,30 @@ export default async function RootLayout({
className={`hide-scrollbar font-sans ${inter.variable}`}
suppressHydrationWarning
>
<head>
<script
dangerouslySetInnerHTML={{
__html: createLocaleBootstrapScript(locale),
}}
/>
</head>
<body
className='hide-scrollbar font-sans antialiased'
suppressHydrationWarning
>
<div
id='locale-load-fallback'
role='alert'
hidden
className='visible flex min-h-svh flex-col items-center justify-center gap-4 p-6 text-center'
>
<p data-locale-error-message suppressHydrationWarning>
{loading('failed')}
</p>
<Button type='button' suppressHydrationWarning>
{loading('reload')}
</Button>
</div>
<ThemeProvider
attribute='class'
defaultTheme='system'
+8 -5
View File
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { motion } from 'motion/react';
@@ -11,6 +12,8 @@ import { Button } from '@/components/ui/button';
* 当用户访问不存在的页面时显示
*/
export default function NotFound() {
const rt = useTranslations('runtime');
const router = useRouter();
return (
@@ -31,7 +34,7 @@ export default function NotFound() {
</h1>
<div className='absolute inset-0 flex items-center justify-center'>
<span className='text-2xl font-medium tracking-[0.2em] text-foreground/80 uppercase'>
Page Not Found
{rt('ui_pageNotFound')}
</span>
</div>
</motion.div>
@@ -43,9 +46,9 @@ export default function NotFound() {
className='space-y-8'
>
<p className='text-muted-foreground max-w-[400px] mx-auto text-sm leading-relaxed'>
抱歉,您访问的页面似已迷失在数字星云中。
{rt('sorry_the_page_you_requested_seems_to_ba66cd83')}
<br />
请检查路径或返回安全地带。
{rt('check_the_path_or_return_to_safety_b1f31888')}
</p>
<div className='flex justify-center gap-4'>
@@ -56,7 +59,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs border-foreground/10 hover:bg-foreground/10 transition-all duration-300'
>
<ArrowLeft className='size-3 opacity-70' />
上一页
{rt('previous_c9b9ae7a')}
</Button>
<Link href='/'>
@@ -66,7 +69,7 @@ export default function NotFound() {
className='rounded-full w-24 text-xs hover:bg-primary/80 transition-all duration-300'
>
<Home className='size-3' />
首页
{rt('home_203c08e0')}
</Button>
</Link>
</div>
+459 -404
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
import {
@@ -16,57 +17,65 @@ export const DOCS_LAST_UPDATED = '2026-06-07';
* API 文档
* ------------------------------------------------------------------
*/
export const apiSections: PolicySection[] = [
{
value: 'api-specs',
title: '1. 接口规范与鉴权说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明
</p>
</div>
export function createApiSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'api-specs',
title: rt('1_api_conventions_and_authentication_e6d4df61'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt(
'standard_api_request_conventions_and_developer_access_39eb956e',
)}
</p>
</div>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.1 统一响应格式
</h3>
<p>系统所有 API 接口均遵循标准 JSON 响应结构:</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>字段</DocsTableHead>
<DocsTableHead className='w-[100px]'>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
错误信息。请求成功时为空字符串
`&quot;&quot;`,失败时包含错误详情描述。
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>data</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
接口返回的具体数据内容。请求失败或无数据返回时为 `null`。
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='1-1-response-format'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_1_standard_response_format_5cddae2f')}
</h3>
<p>{rt('all_api_endpoints_use_a_standard_json_87c92fb0')}</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('field_49ecd0e3')}
</DocsTableHead>
<DocsTableHead className='w-[100px]'>
{rt('type_ba40014f')}
</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
error_msg
</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('error_message_empty_on_success_contains_error_d89d457f')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
data
</DocsTableCell>
<DocsTableCell>any</DocsTableCell>
<DocsTableCell>
{rt('the_response_payload_null_when_the_request_97b8b29c')}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<p className='mt-2'>成功响应示例:</p>
<CodeBlock
code={`{
<p className='mt-2'>{rt('successful_response_example_8917e853')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"id": 1,
@@ -74,263 +83,290 @@ export const apiSections: PolicySection[] = [
"nickname": "Ryan"
}
}`}
language='json'
/>
language='json'
/>
<p className='mt-2'>失败响应示例:</p>
<CodeBlock
code={`{
"error_msg": "用户密码错误",
"data": null
}`}
language='json'
/>
<p className='mt-2'>{rt('failed_response_example_eae899e1')}</p>
<CodeBlock
code={rt.raw('error_msg_incorrect_password_data_null_b2eccc86')}
language='json'
/>
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
1.2 鉴权方式
</h3>
<p>
除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>Session 凭证:</strong>浏览器环境下支持利用常规 Session
Cookie 会话保持登录。
</li>
<li>
<strong>AccessToken 令牌:</strong>
供后台调用或第三方应用集成使用。客户端生成 API
访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
支持携带令牌的请求头格式(二选一):
<h3
id='1-2-authentication'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-3'
>
{rt('1_2_authentication_methods_3c5db38a')}
</h3>
<p>
{rt(
'protected_endpoints_require_credentials_except_public_endpoints_1fa0a24c',
)}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<ul className='list-disc pl-5 space-y-2'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
<strong>{rt('session_credentials_de72024d')}</strong>{' '}
{rt('browser_clients_can_stay_signed_in_using_f316279b')}
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
<strong>{rt('access_tokens_8de23550')}</strong>{' '}
{rt('for_backend_calls_and_third_party_integrations_dfd8bf0f')}
</li>
</ul>
<div className='bg-muted border rounded-xl p-4 mt-2 space-y-2'>
<p className='font-bold text-xs'>
{rt('use_either_of_the_following_request_headers_1e03b04b')}
</p>
<ul className='list-disc pl-5 text-xs text-muted-foreground space-y-1'>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
Authorization: Bearer at_xxx
</code>
</li>
<li>
<code className='bg-muted-foreground/10 px-1 rounded text-[11px] font-mono'>
X-Access-Token: at_xxx
</code>
</li>
</ul>
</div>
</div>
</div>
),
children: [
{ value: '1-1-response-format', title: '1.1 统一响应格式' },
{ value: '1-2-authentication', title: '1.2 鉴权方式' },
],
},
{
value: 'auth-apis',
title: '2. 用户与认证接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 用户注册
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>说明:</strong>注册本地账户(在后台注册开关开启状态下)。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名,必须唯一且无空格。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码,长度必须大于等于 8 位。</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>否</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>昵称。未传时默认与用户名一致。</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
),
children: [
{
value: '1-1-response-format',
title: rt('1_1_standard_response_format_5cddae2f'),
},
{
value: '1-2-authentication',
title: rt('1_2_authentication_methods_3c5db38a'),
},
],
},
{
value: 'auth-apis',
title: rt('2_user_and_authentication_endpoints_4a89d429'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='2-1-register'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_user_registration_4f332f85')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/register
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('register_a_local_account_when_registration_is_938891dc')}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('username_must_be_unique_and_contain_no_e81c1e5c')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt('password_at_least_8_characters_37cdc807')}
</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
nickname
</DocsTableCell>
<DocsTableCell>{rt('no_0c70665b')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>
{rt(
'nickname_defaults_to_the_username_when_omitted_676aae7e',
)}
</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.2 密码登录
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>说明:</strong>通过常规用户名密码进行登录校验,成功后建立
Session Cookie 会话。
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>参数</DocsTableHead>
<DocsTableHead className='w-[80px]'>必填</DocsTableHead>
<DocsTableHead>类型</DocsTableHead>
<DocsTableHead>说明</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>用户名</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>是</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>密码</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-2-login'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_2_password_login_271483ba')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/login
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'authenticate_with_a_username_and_password_establishing_a38ff252',
)}
</p>
<DocsTable>
<DocsTableHeader>
<DocsTableRow>
<DocsTableHead className='w-[120px]'>
{rt('parameter_9634fb08')}
</DocsTableHead>
<DocsTableHead className='w-[80px]'>
{rt('required_11da9dc4')}
</DocsTableHead>
<DocsTableHead>{rt('type_ba40014f')}</DocsTableHead>
<DocsTableHead>{rt('description_4262c45d')}</DocsTableHead>
</DocsTableRow>
</DocsTableHeader>
<DocsTableBody>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
username
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('username_1a3f0617')}</DocsTableCell>
</DocsTableRow>
<DocsTableRow>
<DocsTableCell className='font-mono text-xs'>
password
</DocsTableCell>
<DocsTableCell>{rt('yes_b5141d3d')}</DocsTableCell>
<DocsTableCell>string</DocsTableCell>
<DocsTableCell>{rt('password_a621ab60')}</DocsTableCell>
</DocsTableRow>
</DocsTableBody>
</DocsTable>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.3 退出登录
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>说明:</strong>销毁当前会话 Cookie 并退出登录状态。
</p>
<h3
id='2-3-logout'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_3_sign_out_3252611d')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/logout
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('delete_the_current_session_cookie_and_sign_c213ffd9')}
</p>
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
2.4 获取个人资料
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>说明:</strong>获取当前登录账户的基本数据模型(包含
ID、角色、昵称等)。
</p>
</div>
),
children: [
{ value: '2-1-register', title: '2.1 用户注册' },
{ value: '2-2-login', title: '2.2 密码登录' },
{ value: '2-3-logout', title: '2.3 退出登录' },
{ value: '2-4-profile', title: '2.4 获取个人资料' },
],
},
{
value: 'token-apis',
title: '3. 个人访问令牌 (AccessToken) 接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
AccessToken 管理相关接口均要求通过 Session
登录后调用,支持普通用户权限。
<h3
id='2-4-profile'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('2_4_get_profile_7b96110a')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/self
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_the_signed_in_account_s_basic_b0eb8cfa')}
</p>
</div>
),
children: [
{ value: '2-1-register', title: rt('2_1_user_registration_4f332f85') },
{ value: '2-2-login', title: rt('2_2_password_login_271483ba') },
{ value: '2-3-logout', title: rt('2_3_sign_out_3252611d') },
{ value: '2-4-profile', title: rt('2_4_get_profile_7b96110a') },
],
},
{
value: 'token-apis',
title: rt('3_personal_access_token_endpoints_989f5d7c'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-4'>
<p className='text-muted-foreground m-0'>
{rt('access_token_management_requires_a_session_login_42d91543')}
</p>
</div>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 获取令牌列表
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>说明:</strong>
查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。
</p>
<h3
id='3-1-list-token'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_list_tokens_9840c227')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('list_the_current_user_s_tokens_with_c34d2210')}
</p>
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.2 新建访问令牌
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>参数:</strong>JSON Body{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>{`{"name": "token名称", "is_admin": false}`}</code>
</p>
<p>
<strong>说明:</strong>生成一个全新访问令牌。返回体中包含一次性明文
Token,切勿遗失。
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>(可选,默认{' '}
<code className='bg-muted px-1 rounded'>false</code>
):是否赋予令牌管理员权限,仅管理员用户可设置。非管理员令牌无法访问{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code> 端点。
</p>
<p className='mt-2'>成功返回样例:</p>
<CodeBlock
code={`{
<h3
id='3-2-create-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_2_create_an_access_token_e53bd784')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens
</code>
</p>
<p>
<strong>{rt('parameters_ba89685c')}</strong> {rt('ui_jsonBody')}{' '}
<code className='bg-muted px-1.5 rounded text-xs font-mono'>
{rt.raw('name_token_name_is_admin_false_4f3e4868')}
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('generate_an_access_token_the_response_includes_70552390')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
<code className='bg-muted px-1 rounded'>is_admin</code>
{rt('optional_default_210b9d8f')}{' '}
<code className='bg-muted px-1 rounded'>false</code>
{rt('grant_administrator_privileges_to_the_token_only_2292cad8')}{' '}
<code className='bg-muted px-1 rounded'>/admin/**</code>{' '}
{rt('endpoints_815c88e5')}
</p>
<p className='mt-2'>{rt('successful_response_example_7783a39c')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"token": "at_628d022b7a95e26bcd8b29c9...",
@@ -344,76 +380,84 @@ export const apiSections: PolicySection[] = [
}
}
}`}
language='json'
/>
language='json'
/>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.3 撤销/删除令牌
</h3>
<p>
<strong>接口:</strong>DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>说明:</strong>通过 ID
物理删除对应访问令牌,该令牌将立即失效。
</p>
<h3
id='3-3-delete-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_3_revoke_delete_a_token_ae510f58')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> DELETE{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('permanently_delete_a_token_by_id_immediately_145287f6')}
</p>
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
3.4 轮换令牌密钥
</h3>
<p>
<strong>接口:</strong>POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>说明:</strong>
轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文
Token,令牌名称与 ID 保持一致。
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: '3.1 获取令牌列表' },
{ value: '3-2-create-token', title: '3.2 新建访问令牌' },
{ value: '3-3-delete-token', title: '3.3 撤销/删除令牌' },
{ value: '3-4-rotate-token', title: '3.4 轮换令牌密钥' },
],
},
{
value: 'config-apis',
title: '4. 公共配置与管理接口',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
4.1 公共系统配置
</h3>
<p>
<strong>接口:</strong>GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>说明:</strong>
无感获取当前系统配置表中公共可见的键值集合。供前端页面动态渲染使用。
</p>
<p className='mt-2'>返回数据结构样例:</p>
<CodeBlock
code={`{
<h3
id='3-4-rotate-token'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('3_4_rotate_a_token_secret_79209983')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> POST{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/user/access-tokens/:id/rotate
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('replace_a_token_s_secret_and_return_d2caca89')}
</p>
</div>
),
children: [
{ value: '3-1-list-token', title: rt('3_1_list_tokens_9840c227') },
{
value: '3-2-create-token',
title: rt('3_2_create_an_access_token_e53bd784'),
},
{
value: '3-3-delete-token',
title: rt('3_3_revoke_delete_a_token_ae510f58'),
},
{
value: '3-4-rotate-token',
title: rt('3_4_rotate_a_token_secret_79209983'),
},
],
},
{
value: 'config-apis',
title: rt('4_public_configuration_and_administration_17ebdbd5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<h3
id='4-1-public-config'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('4_1_public_system_configuration_2be912e4')}
</h3>
<p>
<strong>{rt('endpoint_79a2369a')}</strong> GET{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
/api/v1/config/public
</code>
</p>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt('get_publicly_visible_configuration_keys_and_values_bc7bff66')}
</p>
<p className='mt-2'>{rt('response_example_d3087445')}</p>
<CodeBlock
code={`{
"error_msg": "",
"data": {
"site_name": "OpenFlare",
@@ -424,50 +468,61 @@ export const apiSections: PolicySection[] = [
"oidc_login_enabled": "true"
}
}`}
language='json'
/>
language='json'
/>
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
4.2 系统配置项 CRUD (管理员)
</h3>
<p>
<strong>说明:</strong>用于在后台对 `system_configs`
配置进行动态变更,要求管理员权限会话调用。
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>获取配置列表:</strong>GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>新建配置项:</strong>POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>修改指定配置值:</strong>PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>删除配置项:</strong>DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{ value: '4-1-public-config', title: '4.1 公共系统配置' },
{ value: '4-2-admin-configs', title: '4.2 系统配置项 CRUD (管理员)' },
],
},
];
<h3
id='4-2-admin-configs'
className='text-base md:text-lg font-semibold text-foreground mt-6 mb-2'
>
{rt('4_2_system_configuration_crud_administrator_2ce66c8b')}
</h3>
<p>
<strong>{rt('description_3e633a20')}</strong>{' '}
{rt(
'manage_system_configs_dynamically_through_an_administrator_acdcdb99',
)}
</p>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('list_configuration_3d58680b')}</strong> GET{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs?type=system
</code>
</li>
<li>
<strong>{rt('create_a_configuration_entry_1d995264')}</strong>{' '}
POST{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs
</code>
</li>
<li>
<strong>{rt('update_a_configuration_value_2a0b0b76')}</strong> PUT{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
<li>
<strong>{rt('delete_a_configuration_entry_1b77a6ea')}</strong>{' '}
DELETE{' '}
<code className='bg-muted px-1 rounded text-xs font-mono'>
/api/v1/admin/system-configs/:key
</code>
</li>
</ul>
</div>
),
children: [
{
value: '4-1-public-config',
title: rt('4_1_public_system_configuration_2be912e4'),
},
{
value: '4-2-admin-configs',
title: rt('4_2_system_configuration_crud_administrator_2ce66c8b'),
},
],
},
];
}
+264 -239
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
import { CodeBlock } from '@/components/ui/code-block';
@@ -6,249 +7,273 @@ import { CodeBlock } from '@/components/ui/code-block';
* 使用指南 (How To Use)
* ------------------------------------------------------------------
*/
export const howToUseSections: PolicySection[] = [
{
value: 'quick-start',
title: '1. 快速开始',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明
</p>
export function createHowToUseSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'quick-start',
title: rt('1_quick_start_ef8fbb58'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<div className='bg-muted/50 border border-border/50 rounded-lg px-3 py-2 mb-6'>
<p className='text-muted-foreground m-0'>
{rt('a_guide_to_the_general_purpose_full_9cd8c987')}
</p>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>{rt('architecture_e992b65c')}</strong>{' '}
{rt('go_gin_gorm_redis_asynq_backend_react_d5fe02d3')}
</li>
<li>
<strong>{rt('authentication_d36b345b')}</strong>{' '}
{rt(
'local_username_password_registration_and_login_plus_583ab638',
)}
</li>
<li>
<strong>{rt('access_tokens_b7b856d3')}</strong>{' '}
{rt('personal_developer_access_tokens_api_keys_for_4b407149')}
</li>
<li>
<strong>{rt('observability_ea0cc1bf')}</strong>{' '}
{rt(
'zap_structured_logging_and_opentelemetry_distributed_tracing_9fd6da9f',
)}
</li>
</ul>
</div>
<ul className='list-disc pl-5 space-y-2'>
<li>
<strong>架构底座:</strong>Go (Gin + GORM + Redis + Asynq) 后端 +
React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
</li>
<li>
<strong>认证体系:</strong>支持本地常规账号密码注册登录 +
第三方自定义 OIDC (OAuth2) 认证源绑定
</li>
<li>
<strong>访问令牌:</strong>提供开发者个人 AccessToken (API
Key),用于通过 Http Header 鉴权直接调用系统 API
</li>
<li>
<strong>可观测性:</strong>集成 Zap 结构化日志与 OpenTelemetry
全链路 Tracing 追踪
</li>
</ul>
</div>
),
},
{
value: 'auth-security',
title: '2. 身份认证与安全设置',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>平台采用混合式身份认证,满足不同的部署和业务场景需求:</p>
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.1 常规账号密码认证
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>自主注册与密码登录:</strong>
支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
高强度加盐哈希存储。
</li>
<li>
<strong>系统开关控制:</strong>
管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
2.2 第三方 OIDC 认证源
</h3>
<p>用户可以在个人资料页面关联绑定外部授权账户:</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
进入 <strong>设置 / 个人资料</strong> 页面。
</li>
<li>
在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC
认证源直接触发 OAuth2 绑定流。
</li>
<li>
绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
</li>
</ol>
</div>
),
children: [
{ value: '2-1-login', title: '2.1 常规账号密码认证' },
{ value: '2-2-oidc', title: '2.2 第三方 OIDC 认证源' },
],
},
{
value: 'access-token',
title: '3. 个人访问令牌 (AccessToken) 接口对接',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
为便于开发者或第三方工具直接调用系统
API,平台提供个人访问令牌管理功能。
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.1 令牌生成与存储规范
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>一次性明文展示:</strong>创建令牌时生成的随机明文 Token 值
(形如{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
)
仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
</li>
<li>
<strong>安全哈希存储:</strong>数据库仅存储 Token 的 SHA-256
哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
3.2 携带 Header 进行 API 调用
</h3>
<p>
您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种
Http 请求头携带方式之一:
</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式一:Authorization Bearer 头
),
},
{
value: 'auth-security',
title: rt('2_authentication_and_security_settings_76967059'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_combines_authentication_methods_to_support_bc9f1772',
)}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
<h3
id='2-1-login'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_1_username_and_password_authentication_97184f8f')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('registration_and_password_login_c5bd6cb4')}</strong>{' '}
{rt('users_can_register_and_create_sessions_with_21883e04')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
bcrypt
</code>{' '}
{rt('salted_hashing_dc2a9b63')}
</li>
<li>
<strong>{rt('system_switches_53741557')}</strong>{' '}
{rt(
'administrators_can_disable_password_registration_or_login_a2648e62',
)}
</li>
</ul>
<h3
id='2-2-oidc'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('2_2_third_party_oidc_providers_f14083c8')}
</h3>
<p>{rt('users_can_link_external_accounts_from_their_c08d3f5a')}</p>
<ol className='list-decimal pl-4 md:pl-5 space-y-1'>
<li>
{rt('open_4540d74d')}
<strong>{rt('settings_profile_32835b0d')}</strong>{' '}
{rt('message_a173cce8')}
</li>
<li>
{rt(
'under_linked_accounts_view_existing_connections_or_5151eb9b',
)}
</li>
<li>{rt('after_linking_use_the_oidc_login_button_6095b15d')}</li>
</ol>
</div>
),
children: [
{
value: '2-1-login',
title: rt('2_1_username_and_password_authentication_97184f8f'),
},
{
value: '2-2-oidc',
title: rt('2_2_third_party_oidc_providers_f14083c8'),
},
],
},
{
value: 'access-token',
title: rt('3_integrating_personal_access_tokens_af2647b5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('personal_access_tokens_allow_developers_and_third_9a242a0e')}
</p>
<h3
id='3-1-generation'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_1_token_generation_and_storage_2b78c681')}
</h3>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('one_time_plaintext_display_fd13912a')}</strong>{' '}
{rt('a_newly_generated_plaintext_token_such_as_57eb11a1')}{' '}
<code className='bg-muted px-1.5 py-0.5 rounded text-xs font-mono'>
at_xxx
</code>
{rt('is_displayed_only_once_copy_and_save_8d378c8a')}
</li>
<li>
<strong>{rt('secure_hash_storage_1ce392ff')}</strong>{' '}
{rt('only_a_sha_256_hash_of_the_dbf434b1')}
</li>
</ul>
<h3
id='3-2-usage'
className='text-base md:text-lg font-semibold text-foreground mt-4 mb-2'
>
{rt('3_2_calling_apis_with_headers_fccd7b0d')}
</h3>
<p>{rt('use_your_saved_plaintext_token_with_either_3af53a05')}</p>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_1_authorization_bearer_header_2a9c2a6c')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
Authorization: Bearer at_628d022b7a95e26b...`}
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
方式二:X-Access-Token 自定义头
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
language='http'
/>
</div>
<div className='space-y-2'>
<p className='font-semibold text-xs text-muted-foreground'>
{rt('method_2_custom_x_access_token_header_4548169f')}
</p>
<CodeBlock
code={`GET /api/v1/user/self HTTP/1.1
Host: localhost:3000
X-Access-Token: at_628d022b7a95e26b...`}
language='http'
/>
language='http'
/>
</div>
</div>
</div>
),
children: [
{ value: '3-1-generation', title: '3.1 令牌生成与存储规范' },
{ value: '3-2-usage', title: '3.2 携带 Header 进行 API 调用' },
],
},
{
value: 'config-system',
title: '4. 动态系统配置项',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>缓存读取加速:</strong>配置加载基于 GORM 读取数据库,并辅以
Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
</li>
<li>
<strong>核心系统配置项说明:</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
:平台展示名称
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
:密码登录开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
:用户自主注册开关
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
:普通用户创建令牌的最大数限制 (默认5)
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: '5. 异步任务与定时调度',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker
服务解耦复杂计算或高延迟IO:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>定时任务派发 (CMD scheduler):</strong>负责按 Cron
表达式配置,定时将待执行任务推送到 Redis 队列中。
</li>
<li>
<strong>多优先级 Worker (CMD worker):</strong>基于 Asynq
驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: '6. 链路追踪与结构化日志',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>OpenTelemetry Tracing:</strong>自动传递 Tracing
上下文,所有经由 Gin 中间件、外部请求或 GORM
数据库的事务操作都将带有全局唯一的
Span,用于排查链路耗时或调用异常。
</li>
<li>
<strong>Zap 结构化日志:</strong>
将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki
等日志收集分析工具无缝对接。
</li>
</ul>
</div>
),
},
];
),
children: [
{
value: '3-1-generation',
title: rt('3_1_token_generation_and_storage_2b78c681'),
},
{
value: '3-2-usage',
title: rt('3_2_calling_apis_with_headers_fccd7b0d'),
},
],
},
{
value: 'config-system',
title: rt('4_dynamic_system_configuration_b3bf8c49'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('the_built_in_key_value_configuration_module_713f6d05')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('cached_reads_35828322')}</strong>{' '}
{rt('configuration_is_read_from_the_database_through_3e9f6a34')}
</li>
<li>
<strong>{rt('core_system_configuration_keys_6dc02be8')}</strong>
<ul className='list-[circle] pl-5 mt-1 space-y-1 text-muted-foreground'>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
site_name
</code>
{rt('platform_display_name_116219b1')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
password_login_enabled
</code>
{rt('password_login_switch_153eaa45')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
registration_enabled
</code>
{rt('user_registration_switch_827b5b21')}
</li>
<li>
<code className='bg-muted px-1 rounded text-xs font-mono'>
max_api_keys_per_user
</code>
{rt('maximum_tokens_per_regular_user_default_5_5dd6d9e7')}
</li>
</ul>
</li>
</ul>
</div>
),
},
{
value: 'worker-scheduler',
title: rt('5_background_tasks_and_scheduling_7142ce96'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('cobra_provides_separate_commands_so_worker_services_1bbae2df')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>
{rt('scheduled_dispatch_scheduler_command_e79062ad')}
</strong>{' '}
{rt('enqueues_tasks_in_redis_according_to_their_694e5985')}
</li>
<li>
<strong>
{rt('workers_with_queue_priorities_worker_command_faa970b1')}
</strong>{' '}
{rt('asynq_consumes_tasks_by_priority_and_executes_69d82251')}
</li>
</ul>
</div>
),
},
{
value: 'tracing-metrics',
title: rt('6_distributed_tracing_and_structured_logs_e8971cad'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt(
'the_platform_integrates_monitoring_components_for_observability_2b1da50f',
)}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('ui_openTelemetryTracing')}</strong>{' '}
{rt(
'tracing_context_is_propagated_automatically_gin_requests_0f549cce',
)}
</li>
<li>
<strong>{rt('zap_structured_logs_1c9725e8')}</strong>{' '}
{rt('standardizes_backend_console_and_log_output_as_16970757')}
</li>
</ul>
</div>
),
},
];
}
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import Link from 'next/link';
import { ArrowLeft, Calendar, Check, Copy, FileText } from 'lucide-react';
@@ -21,6 +22,8 @@ export function LegalPageLayout({
sections,
description,
}: LegalPageLayoutProps) {
const rt = useTranslations('runtime');
const [activeSection, setActiveSection] = React.useState<string>(
sections[0]?.value || '',
);
@@ -192,7 +195,7 @@ export function LegalPageLayout({
const lines: string[] = [];
lines.push(`# ${title}`);
lines.push('');
lines.push(`最后更新:${lastUpdated}`);
lines.push(rt('last_updated_value0_87a896df', { value0: lastUpdated }));
if (description) {
const descriptionElement = contentRoot.querySelector(
'[data-docs-description]',
@@ -303,7 +306,7 @@ export function LegalPageLayout({
className='gap-2 pl-0 text-muted-foreground hover:text-foreground hover:bg-transparent -ml-2'
>
<ArrowLeft className='w-4 h-4' />
返回首页
{rt('back_to_home_d66f4359')}
</Button>
</Link>
@@ -318,11 +321,12 @@ export function LegalPageLayout({
<div className='flex flex-wrap items-center gap-4 text-xs text-muted-foreground mb-8'>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<FileText className='w-3.5 h-3.5' />
{sections.length} 个内容
{sections.length} {rt('sections_fcb2696e')}
</span>
<span className='flex items-center gap-1.5 bg-muted/50 px-3 py-1 rounded-full border border-border/50'>
<Calendar className='w-3.5 h-3.5' />
最后更新:{lastUpdated}
{rt('last_updated_a83cd480')}
{lastUpdated}
</span>
<Button
type='button'
@@ -336,7 +340,9 @@ export function LegalPageLayout({
) : (
<Copy className='w-3.5 h-3.5' />
)}
{hasCopied ? '已复制' : '复制 Markdown'}
{hasCopied
? rt('copied_8f6f8d97')
: rt('copy_markdown_f9c71ee4')}
</Button>
</div>
{description && (
@@ -357,7 +363,7 @@ export function LegalPageLayout({
<div className='sticky top-24'>
<div className='space-y-2 max-h-[calc(100vh-8rem)] overflow-y-auto pr-4 scrollbar-thin'>
<div className='text-sm font-semibold text-foreground mb-4 pl-3'>
目录
{rt('table_of_contents_52daa71e')}
</div>
<nav className='space-y-1'>
{sections.map((section) => (
@@ -445,7 +451,7 @@ export function LegalPageLayout({
<div className='pt-16 border-t border-border mt-20'>
<div className='bg-muted/30 rounded-2xl p-8 text-center'>
<p className='text-muted-foreground text-sm mb-4'>
对这些内容有疑问?
{rt('questions_about_this_content_feb7d44a')}
</p>
<div className='flex justify-center gap-4'>
<Link
@@ -453,7 +459,7 @@ export function LegalPageLayout({
target='_blank'
>
<Button variant='outline' className='h-9 text-xs'>
联系支持
{rt('contact_support_4cac90c8')}
</Button>
</Link>
</div>
+156 -149
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
/**
@@ -5,156 +6,162 @@ import { type PolicySection } from './types';
* 隐私政策 (PRIVACY POLICY)
* ------------------------------------------------------------------
*/
export const privacySections: PolicySection[] = [
{
value: 'collection-details',
title: '1. 详细信息收集说明',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
我们仅遵循合法、正当、必要的原则,收集为您提供服务所必需的信息。我们的数据收集范围严格限制如下:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
1.1 身份鉴权信息:
</span>
<p className='mt-1 text-muted-foreground'>
当您通过本地账号注册或绑定第三方 OIDC
认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方
OpenID 标识符。
<strong>
我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
1.2 服务与接口日志信息:
</span>
<p className='mt-1 text-muted-foreground'>
为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括
IP 地址、访问日期和时间、个人访问令牌 API
调用历史记录、User-Agent(浏览器/设备/请求工具类型)。
</p>
export function createPrivacySections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'collection-details',
title: rt('1_information_we_collect_af0db091'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('we_collect_only_the_information_necessary_to_72f6e528')}</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{rt('1_1_authentication_information_60199ee9')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('when_you_register_locally_or_sign_in_acdd6bd1')}{' '}
<strong>
{rt('we_do_not_require_a_phone_number_3540dfca')}
</strong>
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{rt('1_2_service_and_api_logs_46c9a04d')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt(
'for_system_security_and_auditing_we_automatically_89737fdd',
)}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'storage-security',
title: '2. 数据存储与安全保护',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>存储安全:</strong>
用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
</li>
<li>
<strong>加密技术:</strong>
敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API
数据传输链路强制使用 SSL/TLS 进行安全加密。
</li>
<li>
<strong>访问控制:</strong>我们实行严格的最小权限原则(Least
Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: '3. 信息使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>我们收集的信息将仅用于以下目的:</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>身份识别:</strong>
用于确认您的注册身份,展示您的个人中心及设置页面数据。
</li>
<li>
<strong>业务功能:</strong>用于识别并处理您的个人访问令牌 API
鉴权指令。
</li>
<li>
<strong>安全风控:</strong>利用 IP
及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
</li>
</ul>
<p>
<strong>禁止用途:</strong>我们承诺<strong>绝不</strong>
将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: '4. 信息共享与对外披露',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>4.1 共享原则:</strong>
除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>事先获得您的明确授权或同意;</li>
<li>
根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
</li>
</ul>
<p>
<strong>4.2 转让与公开披露:</strong>
我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。
</p>
</div>
),
},
{
value: 'user-rights',
title: '5. 您的权利与数据管理',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
5.1 查阅与管理权:
</span>
<p className='mt-1 text-muted-foreground'>
您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的
AccessToken (API 密钥)。
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
5.2 删除与注销权:
</span>
<p className='mt-1 text-muted-foreground'>
若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。
</p>
),
},
{
value: 'storage-security',
title: rt('2_data_storage_and_security_7cc5cdef'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('we_recognize_the_importance_of_data_security_cd3bd4d9')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('storage_security_a33cfbef')}</strong>{' '}
{rt('user_data_is_stored_in_a_dedicated_132af7bb')}
</li>
<li>
<strong>{rt('cryptography_9a15a88b')}</strong>{' '}
{rt(
'sensitive_password_and_personal_access_token_hashes_9f724cd8',
)}
</li>
<li>
<strong>{rt('access_control_3471eafa')}</strong>{' '}
{rt('we_apply_the_principle_of_least_privilege_79328c28')}
</li>
</ul>
</div>
),
},
{
value: 'usage-rules',
title: rt('3_how_we_use_information_41b4d6ad'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('collected_information_is_used_only_for_the_4868b538')}</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1'>
<li>
<strong>{rt('identification_481b07f4')}</strong>{' '}
{rt('to_identify_your_registered_account_and_display_8c559094')}
</li>
<li>
<strong>{rt('service_functions_c5834f20')}</strong>{' '}
{rt(
'to_identify_and_process_api_authentication_requests_d48a36a2',
)}
</li>
<li>
<strong>{rt('security_e7132437')}</strong>{' '}
{rt('to_analyze_ip_addresses_and_activity_logs_5210836a')}
</li>
</ul>
<p>
<strong>{rt('prohibited_uses_b13fde94')}</strong>{' '}
{rt('we_will_8856fb95')}
<strong>{rt('never_c1d7d603')}</strong>{' '}
{rt('sell_your_data_to_third_parties_or_7bb4d77a')}
</p>
</div>
),
},
{
value: 'sharing-disclosure',
title: rt('4_information_sharing_and_disclosure_c034d295'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('4_1_sharing_principles_b89eb890')}</strong>{' '}
{rt('we_do_not_share_personal_information_with_ffcf1e8f')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>
{rt('with_your_prior_explicit_authorization_or_consent_0fa05878')}
</li>
<li>
{rt(
'when_required_by_applicable_laws_legal_proceedings_cb547dd6',
)}
</li>
</ul>
<p>
<strong>{rt('4_2_transfer_and_public_disclosure_383d35be')}</strong>{' '}
{rt('we_do_not_transfer_your_personal_information_e3339921')}
</p>
</div>
),
},
{
value: 'user-rights',
title: rt('5_your_rights_and_data_management_08999326'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('under_the_personal_information_protection_law_of_94ea405e')}
</p>
<div className='space-y-3'>
<div>
<span className='font-semibold text-foreground'>
{rt('5_1_access_and_management_854d0793')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('you_may_sign_in_at_any_time_8727c5be')}
</p>
</div>
<div>
<span className='font-semibold text-foreground'>
{rt('5_2_deletion_and_account_closure_9d1e7aba')}
</span>
<p className='mt-1 text-muted-foreground'>
{rt('if_you_stop_using_the_service_you_24c642c6')}
</p>
</div>
</div>
</div>
</div>
),
},
{
value: 'policy-update',
title: '6. 政策更新与通知',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。</p>
<p>
当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。
</p>
</div>
),
},
];
),
},
{
value: 'policy-update',
title: rt('6_policy_updates_and_notices_444076f5'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>{rt('this_privacy_policy_may_change_as_the_2a270464')}</p>
<p>
{rt('we_will_prominently_announce_material_changes_for_210fbd3b')}
</p>
</div>
),
},
];
}
+170 -146
View File
@@ -1,3 +1,4 @@
import type { RuntimeTranslator } from '@/i18n/runtime';
import { type PolicySection } from './types';
export const TERMS_LAST_UPDATED = '2026-06-07';
@@ -7,153 +8,176 @@ export const TERMS_LAST_UPDATED = '2026-06-07';
* 服务条款 (TERMS OF SERVICE)
* ------------------------------------------------------------------
*/
export const termsSections: PolicySection[] = [
{
value: 'contract-establishment',
title: '1. 缔约申明与服务综述',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>1.1 缔约主体:</strong>
本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。
</p>
<p>
<strong>1.2 审慎阅读:</strong>
本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC
接入、API Key
令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。
</p>
<p>
<strong>1.3 协议构成:</strong>
本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。
</p>
</div>
),
},
{
value: 'service-definition',
title: '2. 服务定义与性质界定',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>责任自担:</strong>
关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
</li>
</ul>
</div>
),
},
{
value: 'account-specifications',
title: '3. 账号注册与使用规范',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>3.1 账号体系:</strong>
用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方
OIDC 认证源进行登录关联。
</p>
<p>
<strong>3.2 密码及令牌安全责任:</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>密码安全:</strong>您应妥善保管您账户的登录密码。
</li>
<li>
<strong>API Token 安全:</strong>个人生成的 AccessToken (API Key)
代表您账户的完整调用权限。
<strong>
因您保管不善导致 Token
泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: '4. 用户行为准则(负面清单)',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。
<strong>严禁利用本平台从事以下活动(“红线条款”):</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
export function createTermsSections(rt: RuntimeTranslator): PolicySection[] {
return [
{
value: 'contract-establishment',
title: rt('1_agreement_and_service_overview_511c6782'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('1_1_parties_d2bf79cd')}</strong>{' '}
{rt('these_terms_of_service_the_agreement_govern_1e6754fc')}
</p>
<p>
<strong>{rt('1_2_read_carefully_dfde346e')}</strong>{' '}
{rt('this_system_is_a_general_purpose_full_f1d395fe')}
</p>
<p>
<strong>{rt('1_3_agreement_contents_f05cebc0')}</strong>{' '}
{rt('the_agreement_includes_these_terms_and_any_43e244b6')}
</p>
</div>
),
},
{
value: 'service-definition',
title: rt('2_service_definition_and_nature_61db87f8'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>危害国家安全:</strong>
反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
</li>
<li>
<strong>非法信息服务:</strong>黑客攻击工具、DDoS
攻击服务、服务器爆破等其他非法信息服务平台;
</li>
<li>
<strong>黄赌毒关联:</strong>
制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
</li>
<li>
<strong>侵犯知识产权:</strong>
销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
</li>
<li>
<strong>其他违法信息:</strong>
涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
<strong>{rt('your_responsibility_527cbdbf')}</strong>{' '}
{rt('the_entity_deploying_and_operating_a_customized_047c6a8f')}
</li>
</ul>
</div>
<p>
<strong>处理规则:</strong>
一旦发现您违反上述规定,平台运维主体有权不经通知
<strong>
立即永久封禁您的账号、拦截所有 AccessToken
请求,并依法向公安机关、网安部门移交相关线索。
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: '5. 免责声明与不可抗力',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>5.1 基础免责:</strong>
本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。
</p>
<p>
<strong>5.2 技术服务中断:</strong>
对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>自然灾害(台风、地震、海啸、洪水等);</li>
<li>政府行为、网络安全法律法规调整或行政命令;</li>
<li>电信部门线路技术故障、机房海底光缆损毁;</li>
<li>黑客入侵、勒索病毒感染导致的数据损毁或宕机。</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: '6. 法律适用与争议解决',
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>6.1 法律适用:</strong>
本协议的订立、执行、解释及争议的解决均适用
<strong>中华人民共和国法律</strong>(不包括港澳台地区冲突法)。
</p>
<p>
<strong>6.2 争议解决:</strong>
若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。
</p>
</div>
),
},
];
),
},
{
value: 'account-specifications',
title: rt('3_registration_and_account_use_cfab1555'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('3_1_accounts_2631c114')}</strong>{' '}
{rt('users_may_register_through_the_frontend_or_e527ef52')}
</p>
<p>
<strong>{rt('3_2_password_and_token_security_80dd80d9')}</strong>
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-2'>
<li>
<strong>{rt('password_security_7a29e44e')}</strong>{' '}
{rt('keep_your_account_password_secure_eb9adf10')}
</li>
<li>
<strong>{rt('api_token_security_34af066f')}</strong>{' '}
{rt('a_personal_access_token_api_key_represents_6215b3b0')}{' '}
<strong>
{rt('you_are_responsible_for_data_loss_or_0c9acc88')}
</strong>
</li>
</ul>
</div>
),
},
{
value: 'user-conduct',
title: rt('4_prohibited_user_conduct_bd5628b6'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
{rt('when_using_this_system_you_must_comply_1e54356c')}{' '}
<strong>
{rt('the_following_activities_are_strictly_prohibited_5b57de7f')}
</strong>
</p>
<div className='bg-red-500/5 border border-red-500/20 rounded-lg p-3 md:p-4 space-y-3'>
<ul className='list-disc pl-4 md:pl-5 space-y-2 text-red-600 dark:text-red-400 font-medium'>
<li>
<strong>{rt('endangering_national_security_4a160c4e')}</strong>{' '}
{rt(
'opposing_constitutional_principles_endangering_national_security_disclosing_bdfb8814',
)}
</li>
<li>
<strong>{rt('illegal_information_services_c97748bb')}</strong>{' '}
{rt('hacking_tools_ddos_attack_services_server_brute_686746c4')}
</li>
<li>
<strong>{rt('pornography_gambling_and_drugs_fdb44a54')}</strong>{' '}
{rt(
'creating_copying_publishing_or_distributing_obscene_pornographic_ec8c0fdb',
)}
</li>
<li>
<strong>
{rt('intellectual_property_infringement_cc10b950')}
</strong>{' '}
{rt('selling_pirated_software_or_media_illegal_game_3f0f4159')}
</li>
<li>
<strong>{rt('other_unlawful_information_4085c17c')}</strong>{' '}
{rt(
'spreading_rumors_promoting_cults_or_superstition_insulting_a062981a',
)}
</li>
</ul>
</div>
<p>
<strong>{rt('enforcement_9c7cfd68')}</strong>{' '}
{rt('if_these_rules_are_violated_the_platform_86df3b18')}
<strong>
{rt('permanently_suspend_your_account_block_all_access_b9f04a42')}
</strong>
</p>
</div>
),
},
{
value: 'liability-limitation',
title: rt('5_disclaimer_and_force_majeure_be649f43'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('5_1_general_disclaimer_30d01f0d')}</strong>{' '}
{rt('this_development_platform_is_provided_as_is_23bc2b55')}
</p>
<p>
<strong>{rt('5_2_service_interruptions_16558fa8')}</strong>{' '}
{rt('the_platform_is_not_liable_for_interruptions_405b1c95')}
</p>
<ul className='list-disc pl-4 md:pl-5 space-y-1 text-muted-foreground'>
<li>
{rt(
'natural_disasters_including_typhoons_earthquakes_tsunamis_and_1bbbfb21',
)}
</li>
<li>
{rt(
'government_actions_cybersecurity_law_changes_or_administrative_2b7322f5',
)}
</li>
<li>
{rt('telecommunication_faults_or_damage_to_data_center_259a542f')}
</li>
<li>
{rt('data_corruption_or_downtime_caused_by_hacking_68f2e04f')}
</li>
</ul>
</div>
),
},
{
value: 'governing-law',
title: rt('6_governing_law_and_disputes_b9663c86'),
content: (
<div className='space-y-4 text-sm leading-relaxed'>
<p>
<strong>{rt('6_1_governing_law_ff2afc5e')}</strong>{' '}
{rt(
'the_formation_performance_interpretation_and_resolution_of_c1510775',
)}
<strong>{rt('the_laws_of_the_people_s_republic_5d5c80d6')}</strong>{' '}
{rt('excluding_the_conflict_of_laws_rules_of_b235c4e3')}
</p>
<p>
<strong>{rt('6_2_dispute_resolution_44e871de')}</strong>{' '}
{rt('disputes_should_first_be_resolved_through_friendly_1ccf4610')}
</p>
</div>
),
},
];
}
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { ImageOff } from 'lucide-react';
@@ -23,6 +24,8 @@ export function FileImagePreview({
fallbackClassName,
variant = 'default',
}: FileImagePreviewProps) {
const rt = useTranslations('runtime');
const [failed, setFailed] = React.useState(false);
const src = getFileUrl(fileId, quality);
@@ -34,7 +37,7 @@ export function FileImagePreview({
return (
<div
role='img'
aria-label={`${alt} 加载失败`}
aria-label={rt('failed_to_load_value0_7f5a06a3', { value0: alt })}
className={cn(
'flex size-full flex-col items-center justify-center gap-1 bg-muted/50 text-muted-foreground',
fallbackClassName,
@@ -47,7 +50,9 @@ export function FileImagePreview({
)}
/>
{variant === 'default' && (
<span className='text-[10px] leading-none'>加载失败</span>
<span className='text-[10px] leading-none'>
{rt('load_failed_d1d04482')}
</span>
)}
</div>
);
@@ -1,3 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -78,11 +81,15 @@ export function ManagePage<T>({
columns,
renderDetail,
emptyIcon = Layers,
emptyDescription = '暂无数据',
loadingDescription = '加载中',
emptyDescription = undefined,
loadingDescription = undefined,
getId,
headerExtra,
}: ManagePageProps<T>) {
const rt = useTranslations('runtime');
emptyDescription ??= rt('no_data_497c8569');
loadingDescription ??= rt('loading_d04fcbda');
/** 悬停状态 */
const [hoveredItem, setHoveredItem] = useState<T | null>(null);
const [selectedItem, setSelectedItem] = useState<T | null>(null);
@@ -125,10 +132,11 @@ export function ManagePage<T>({
try {
setSaving(true);
await onSave(selectedItem, editData);
toast.success('保存成功');
toast.success(rt('saved_e05ddf99'));
} catch (error) {
toast.error('保存失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(rt('save_failed_6309a3bb'), {
description:
error instanceof Error ? error.message : rt('unknown_error_13a46616'),
});
} finally {
setSaving(false);
@@ -144,7 +152,7 @@ export function ManagePage<T>({
if (!deletingItem || !onDelete) return;
try {
await onDelete(deletingItem);
toast.success('删除成功');
toast.success(rt('deleted_5223f91b'));
setDeletingItem(null);
// 如果删除的是当前选中的项,清除选中状态
if (selectedItem && getId(selectedItem) === getId(deletingItem)) {
@@ -152,8 +160,9 @@ export function ManagePage<T>({
setEditData({});
}
} catch (error) {
toast.error('删除失败', {
description: error instanceof Error ? error.message : '未知错误',
toast.error(rt('delete_failed_c228558c'), {
description:
error instanceof Error ? error.message : rt('unknown_error_13a46616'),
});
}
};
@@ -216,7 +225,9 @@ export function ManagePage<T>({
<div className='space-y-6'>
<div>
<div className='mb-4'>
<div className='font-semibold'>配置列表</div>
<div className='font-semibold'>
{rt('configuration_list_53052147')}
</div>
</div>
{renderContent()}
</div>
@@ -239,18 +250,18 @@ export function ManagePage<T>({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除?</AlertDialogTitle>
<AlertDialogTitle>{rt('confirm_delete_2d61cf17')}</AlertDialogTitle>
<AlertDialogDescription>
此操作无法撤销。这将永久删除该配置。
{rt('this_cannot_be_undone_this_will_permanently_8c896756')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogCancel>{rt('cancel_2cd0f3be')}</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
className='bg-red-600 hover:bg-red-700'
>
确认删除
{rt('confirm_delete_a3ea3c17')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -271,13 +282,17 @@ interface ManageDetailPanelProps {
/** 详情面板 */
export function ManageDetailPanel({
title = '配置信息',
title = undefined,
isEmpty,
emptyDescription = '请选择配置查看详情',
emptyDescription = undefined,
onSave,
saving = false,
children,
}: ManageDetailPanelProps) {
const rt = useTranslations('runtime');
title ??= rt('configuration_info_7f69a860');
emptyDescription ??= rt('select_a_configuration_to_view_details_ec4949ab');
if (isEmpty) {
return (
<div className='space-y-4'>
@@ -301,10 +316,10 @@ export function ManageDetailPanel({
>
{saving ? (
<>
<Spinner /> 更新中
<Spinner /> {rt('updating_e1d9dd03')}
</>
) : (
'更新'
rt('update_3055a035')
)}
</Button>
)}
@@ -341,6 +356,8 @@ export function ManageTable<T>({
onDelete?: (item: T) => void;
getId: (item: T) => string | number;
}) {
const rt = useTranslations('runtime');
return (
<div className='border border-dashed shadow-none rounded-lg overflow-hidden'>
<ScrollArea className='w-full'>
@@ -358,7 +375,7 @@ export function ManageTable<T>({
))}
{onDelete && (
<TableHead className='whitespace-nowrap text-center w-[120px]'>
操作
{rt('actions_ed31fbb4')}
</TableHead>
)}
</TableRow>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { useState } from 'react';
import {
@@ -36,9 +37,13 @@ export function PasswordDialog({
onOpenChange,
onConfirm,
loading = false,
title = '请输入密码',
description = '为了您的账户安全,请输入6位认证密码进行验证',
title = undefined,
description = undefined,
}: PasswordDialogProps) {
const rt = useTranslations('runtime');
title ??= rt('enter_password_728a7b60');
description ??= rt('for_your_account_security_please_enter_the_28fc0bbd');
const [password, setPassword] = useState('');
const handleConfirm = () => {
@@ -92,7 +97,7 @@ export function PasswordDialog({
disabled={loading}
className='h-8 text-xs'
>
取消
{rt('cancel_2cd0f3be')}
</Button>
<Button
type='button'
@@ -103,10 +108,10 @@ export function PasswordDialog({
{loading ? (
<>
<Spinner className='mr-2 h-4 w-4' />
验证中...
{rt('verifying_6c128e0a')}
</>
) : (
'确认'
rt('confirm_36f33ada')
)}
</Button>
</div>
+38 -23
View File
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { AnimatePresence, motion } from 'motion/react';
import { useUser } from '@/contexts/user-context';
@@ -18,13 +19,17 @@ import {
import Link from 'next/link';
export function HomeMain() {
const rt = useTranslations('runtime');
const { user } = useUser();
const [isBannerVisible, setIsBannerVisible] = React.useState(true);
const quickLinks = [
{
title: '个人资料',
description: '管理您的个人账户信息及个性化配置',
title: rt('profile_b94d13a4'),
description: rt(
'manage_your_account_information_and_preferences_cced83b4',
),
icon: User,
url: '/settings/profile',
color: 'text-blue-500',
@@ -32,8 +37,8 @@ export function HomeMain() {
borderColor: 'hover:border-blue-500/30',
},
{
title: '使用文档',
description: '学习如何集成 API 及日常操作帮助指南',
title: rt('user_guide_c83b6b0a'),
description: rt('api_integration_and_everyday_operation_guides_281269a1'),
icon: HelpCircle,
url: 'https://openflare.fyrn.link/',
color: 'text-purple-500',
@@ -45,8 +50,10 @@ export function HomeMain() {
const adminLinks = [
{
title: '系统设置',
description: '管理登录注册开关与认证源安全策略',
title: rt('system_settings_68ea5dd4'),
description: rt(
'manage_registration_login_and_authentication_provider_security_f378ed37',
),
icon: Shield,
url: '/admin/settings',
color: 'text-amber-500',
@@ -54,8 +61,8 @@ export function HomeMain() {
borderColor: 'hover:border-amber-500/30',
},
{
title: '全局系统配置',
description: '动态管理平台运行时核心配置参数',
title: rt('global_system_configuration_1eb8931e'),
description: rt('manage_core_platform_runtime_settings_341e4182'),
icon: ShieldCheck,
url: '/admin/system',
color: 'text-indigo-500',
@@ -63,8 +70,8 @@ export function HomeMain() {
borderColor: 'hover:border-indigo-500/30',
},
{
title: '用户权限管理',
description: '集中查询并管理系统注册用户的启用状态',
title: rt('user_permissions_8e8b1d33'),
description: rt('view_and_manage_registered_users_and_their_db533b7c'),
icon: User,
url: '/admin/users',
color: 'text-rose-500',
@@ -72,8 +79,10 @@ export function HomeMain() {
borderColor: 'hover:border-rose-500/30',
},
{
title: '后台任务调度',
description: '分发与观测系统异步定时任务的执行情况',
title: rt('background_task_scheduling_408172d1'),
description: rt(
'dispatch_and_monitor_background_and_scheduled_tasks_83967fc9',
),
icon: Layers,
url: '/admin/tasks',
color: 'text-teal-500',
@@ -81,8 +90,8 @@ export function HomeMain() {
borderColor: 'hover:border-teal-500/30',
},
{
title: '系统日志',
description: '查看异步任务执行日志与系统运行状态详情',
title: rt('system_logs_366ada1d'),
description: rt('view_background_task_logs_and_system_status_24fd6a7d'),
icon: Terminal,
url: '/admin/logs',
color: 'text-cyan-500',
@@ -109,8 +118,9 @@ export function HomeMain() {
Modern Platform
</h3>
<p className='text-xs text-muted-foreground leading-relaxed'>
通用的、现代化的后台管理系统脚手架,
开箱即用、完整基建、极易扩展,助您快速构建企业级应用。
{rt(
'a_modern_extensible_administration_platform_with_ready_0d95c0c1',
)}
</p>
<div className='flex gap-2.5'>
<Button
@@ -118,7 +128,7 @@ export function HomeMain() {
className='bg-indigo-600 hover:bg-indigo-700 text-xs font-semibold px-4 shadow-sm h-7'
asChild
>
<Link href='/'>开始使用</Link>
<Link href='/'>{rt('get_started_715ee9dc')}</Link>
</Button>
<Button
size='sm'
@@ -126,7 +136,7 @@ export function HomeMain() {
className='text-xs font-medium hover:bg-black/5 dark:hover:bg-white/5 h-7'
onClick={() => setIsBannerVisible(false)}
>
隐藏提示
{rt('hide_tip_738f4b27')}
</Button>
</div>
</div>
@@ -137,7 +147,9 @@ export function HomeMain() {
{/* 快捷导航 */}
<div className='space-y-4'>
<h2 className='text-lg font-semibold tracking-tight'>常用功能导航</h2>
<h2 className='text-lg font-semibold tracking-tight'>
{rt('quick_navigation_05deefcb')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{quickLinks.map((link, idx) => (
<motion.div
@@ -179,7 +191,8 @@ export function HomeMain() {
target='_blank'
rel='noopener noreferrer'
>
立即跳转 <ArrowRight className='size-3 ml-1' />
{rt('open_fee3b5a5')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
) : (
@@ -189,7 +202,8 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{rt('open_df42ebc3')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
)}
@@ -205,7 +219,7 @@ export function HomeMain() {
<div className='space-y-4 pt-2'>
<h2 className='text-lg font-semibold tracking-tight text-rose-500 flex items-center gap-1.5'>
<Shield className='size-5' />
后台管理控制台
{rt('administration_console_ceb0c289')}
</h2>
<div className='grid grid-cols-1 md:grid-cols-3 gap-4'>
{adminLinks.map((link, idx) => (
@@ -240,7 +254,8 @@ export function HomeMain() {
asChild
>
<Link href={link.url}>
立即进入 <ArrowRight className='size-3 ml-1' />
{rt('open_df42ebc3')}
<ArrowRight className='size-3 ml-1' />
</Link>
</Button>
</div>
@@ -1,5 +1,6 @@
'use client';
import { useTranslations } from 'next-intl';
import { useRef, useState } from 'react';
import { useTheme } from 'next-themes';
import CodeMirror from '@uiw/react-codemirror';
@@ -39,8 +40,11 @@ export function HtmlEditorWorkspace({
</>
),
showPreviewLink = true,
previewTitle = '源站错误页实时预览',
previewTitle = undefined,
}: HtmlEditorWorkspaceProps) {
const rt = useTranslations('runtime');
previewTitle ??= rt('live_origin_error_page_preview_a2a12823');
const containerRef = useRef<HTMLDivElement>(null);
const [editorWidthPercent, setEditorWidthPercent] = useState(48);
const { resolvedTheme } = useTheme();
@@ -79,10 +83,14 @@ export function HtmlEditorWorkspace({
<div className='flex items-center justify-between px-4 py-2 border-b bg-muted/40 shrink-0 gap-4 flex-wrap'>
<div className='flex items-center gap-2 min-w-0'>
<Terminal className='size-4 text-primary shrink-0' />
<span className='text-xs font-semibold'>HTML 编辑器</span>
<span className='text-xs font-semibold'>
{rt('html_editor_19f0d7b7')}
</span>
<span className='text-[11px] text-muted-foreground font-mono truncate'>
{htmlBytes} / {maxBytes} 字节
{value.trim() === '' ? ' · 空则使用内置默认' : ''}
{htmlBytes} / {maxBytes} {rt('bytes_f5d87a8b')}
{value.trim() === ''
? rt('leave_empty_for_the_built_in_default_29ffce9f')
: ''}
</span>
</div>
<div className='flex items-center gap-1.5 flex-wrap'>
@@ -109,21 +117,21 @@ export function HtmlEditorWorkspace({
allowMultipleSelections: false,
indentOnInput: true,
}}
placeholder='留空使用内置默认模板…'
placeholder={rt('leave_empty_to_use_the_built_in_e57252bd')}
/>
</div>
<div
onMouseDown={handleMouseDown}
className='w-1.5 bg-border/60 hover:bg-primary/50 cursor-col-resize transition-colors flex items-center justify-center shrink-0 select-none z-10'
title='拖动调整宽度'
title={rt('drag_to_resize_b5154511')}
>
<div className='h-8 w-1 rounded bg-muted-foreground/30' />
</div>
<div className='flex-1 min-w-[200px] flex flex-col overflow-hidden bg-muted/10'>
<div className='flex items-center justify-between px-4 py-1.5 border-b bg-muted/20 shrink-0 text-[11px] text-muted-foreground font-mono gap-2'>
<span className='font-semibold'>实时预览</span>
<span className='font-semibold'>{rt('live_preview_a76fdaad')}</span>
<div className='flex items-center gap-2'>
<span
className={footerHint === null ? 'hidden' : 'hidden sm:inline'}
@@ -139,7 +147,7 @@ export function HtmlEditorWorkspace({
>
<Link href='/error-pages/preview'>
<Expand className='size-3' />
预览
{rt('preview_13d61fea')}
</Link>
</Button>
) : null}

Some files were not shown because too many files have changed in this diff Show More