chore: sync local changes to Gitea
This commit is contained in:
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
.wrangler/
|
||||
dist/
|
||||
frontend/dist/
|
||||
*.log
|
||||
.dev.vars
|
||||
131
README.md
Normal file
131
README.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Domainflare
|
||||
|
||||
基于 **Cloudflare Workers** 的 **多账号 DNS 解析管理**:Web 面板 + **D1** 存储账号,HTTP **透传**官方 **API v4**(`/api/cf/*`),并提供与 `X-App-Token` / Bearer 一致的 **管理 API**,便于脚本与第三方工具集成。
|
||||
|
||||
## 功能概览
|
||||
|
||||
| 类型 | 说明 |
|
||||
|------|------|
|
||||
| Web 面板 | **React + Vite**;多 Zone、DNS 筛选与分页、增删改、DNSSEC、导出 JSON;响应式布局(由 Workers Static Assets 下发,不进入 Worker 脚本包体) |
|
||||
| 存储 | **D1** 表 `cf_accounts` 持久化账号名称与 Cloudflare API Token |
|
||||
| HTTP API | `GET /api` 目录、`GET /api/health` 探活、`/api/accounts` CRUD、`/api/cf/*` → `https://api.cloudflare.com/client/v4/*` |
|
||||
|
||||
完整接口说明、curl 与安全约定见:**[domainflare API调用文档.md](./domainflare%20API调用文档.md)**。
|
||||
|
||||
## 仓库结构(节选)
|
||||
|
||||
```
|
||||
domainflare/
|
||||
├── src/ # Worker:模块化 Hono API(仅 /api)
|
||||
│ ├── index.ts
|
||||
│ ├── app.ts
|
||||
│ ├── routes/ # meta、accounts、cf 代理
|
||||
│ ├── lib/ # 鉴权、HTTP 工具
|
||||
│ └── types/
|
||||
├── frontend/ # React + Vite 面板(构建产物 → Workers Static Assets)
|
||||
│ ├── src/
|
||||
│ └── dist/ # wrangler `[assets].directory`,由 `npm run build:frontend` 生成
|
||||
├── migrations/ # D1 SQL 迁移
|
||||
├── imports/ # Postman / OpenAPI / 环境模板
|
||||
├── wrangler.toml
|
||||
├── domainflare API调用文档.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **Node.js** 18+(建议 LTS)
|
||||
- **Wrangler** 4.x(见根目录 `package.json` devDependencies)
|
||||
- Cloudflare 账号(**Workers** + **D1**)
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
git clone <仓库 URL>
|
||||
cd domainflare
|
||||
npm install
|
||||
npm --prefix frontend install
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
### 首次:创建 D1 并迁移
|
||||
|
||||
```bash
|
||||
npx wrangler d1 create domainflare
|
||||
```
|
||||
|
||||
将命令输出中的 `database_id` 写入 `wrangler.toml` 的 `[[d1_databases]]` 对应项,然后:
|
||||
|
||||
```bash
|
||||
npm run db:migrate:remote # 远程 D1
|
||||
npm run db:migrate:local # 本地 wrangler dev 使用的 SQLite
|
||||
```
|
||||
|
||||
### 本地开发
|
||||
|
||||
需要先同时跑 **Worker** 与 **Vite 开发服务器**(根目录脚本已用 `concurrently` 封装):
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm --prefix frontend install # 首次克隆后安装前端依赖
|
||||
npm run dev
|
||||
```
|
||||
|
||||
- **Worker** 默认:`http://127.0.0.1:8787`(`wrangler dev`)
|
||||
- **面板**:`http://127.0.0.1:5173`(Vite),已通过 `vite.config.ts` 将 `/api` 代理到 Worker
|
||||
|
||||
在浏览器打开 **5173** 端口即可。默认 **`APP_ACCESS_TOKEN`** 可在 `wrangler.toml` 的 `[vars]` 中查看;**勿在生产依赖默认示例值**。
|
||||
|
||||
### 生产部署
|
||||
|
||||
一键先构建前端再上传 Worker + Static Assets:
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
等价于 `npm run build:frontend && wrangler deploy`。仅更新 Worker 代码而不改前端时,仍可直接 `npx wrangler deploy`(需已存在 `frontend/dist`)。
|
||||
|
||||
生产环境推荐通过 **Secret** 配置面板口令,避免把 `APP_ACCESS_TOKEN` 写在配置文件中:
|
||||
|
||||
```bash
|
||||
npx wrangler secret put APP_ACCESS_TOKEN
|
||||
```
|
||||
|
||||
部署完成后,将 Worker 的**根 URL**(无末尾 `/`)作为 API 文档中的 **`BASE`**。
|
||||
|
||||
## 配置说明
|
||||
|
||||
| 配置项 | 说明 |
|
||||
|--------|------|
|
||||
| `APP_ACCESS_TOKEN` | 保护管理接口与 `/api/cf/*` 中的面板身份;**不**应用于 `GET /api`、`GET /api/health` |
|
||||
| D1 `cf_accounts` | 由迁移创建;字段 `id`、`name`、`token`、`created_at` |
|
||||
|
||||
## API 与导入工具
|
||||
|
||||
- **环境变量约定**(与文档一致)
|
||||
- `BASE`:Worker 根 URL,无末尾 `/`
|
||||
- `PANEL_TOKEN`:即 `APP_ACCESS_TOKEN`
|
||||
|
||||
- **Postman**
|
||||
1. 导入 `imports/Domainflare.environment.postman.json` 并填写变量。
|
||||
2. 导入 `imports/Domainflare.postman_collection.json`,选中该环境。
|
||||
|
||||
- **Apifox**
|
||||
1. `导入` → `Postman` → `Domainflare.environment.postman.json`。
|
||||
2. `导入` → `OpenAPI` → `Domainflare.apifox.openapi.json`;认证中 `PanelHeader` / `PanelBearer` 使用 `{{PANEL_TOKEN}}`,`CfBearer` 使用 `{{cfToken}}`。
|
||||
|
||||
**鉴权速记**
|
||||
|
||||
- **管理类**(`/api/verify`、`/api/accounts`…):`X-App-Token: <口令>` **或** `Authorization: Bearer <口令>`。
|
||||
- **透传** `/api/cf/*`:**必须** `X-App-Token` + `Authorization: Bearer <Cloudflare API Token>`。
|
||||
|
||||
## 安全提示
|
||||
|
||||
- 使用强随机 `APP_ACCESS_TOKEN`;生产用 Secret 管理;限制 Worker 暴露面(例如配合 **Cloudflare Access**)。
|
||||
- `GET /api/accounts` 返回完整 CF Token,仅在内网或可信环境使用。
|
||||
- Cloudflare Token 遵循**最小权限**(如仅 Zone DNS 编辑)。
|
||||
|
||||
## 许可
|
||||
|
||||
仓库未包含 `LICENSE` 时,由维护者自行补充;使用本代码请遵守适用法律法规与 Cloudflare 服务条款。
|
||||
276
api-docs/domainflare-api.json
Normal file
276
api-docs/domainflare-api.json
Normal file
@@ -0,0 +1,276 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "7f8e9d0c-1b2a-4c5d-8e6f-7091a2b3c4d5",
|
||||
"name": "domainflare-api",
|
||||
"description": "Domainflare 面板 HTTP API:公开目录与健康检查、面板令牌保护下的账号管理(D1),以及 Cloudflare API v4 透传代理。集合变量 `baseUrl` 已指向生产环境;请在集合变量中填写 `panelToken`、`cfToken` 等敏感值。",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "公开",
|
||||
"description": "无需鉴权。",
|
||||
"item": [
|
||||
{
|
||||
"name": "API 目录",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": "{{baseUrl}}/api",
|
||||
"description": "返回服务名、版本、路由说明与 base 等机器可读目录。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "健康检查",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": "{{baseUrl}}/api/health",
|
||||
"description": "探活,返回 ok、service、version、time。"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "管理(面板令牌)",
|
||||
"description": "需 `X-App-Token: {{panelToken}}` 或 `Authorization: Bearer {{panelToken}}`(二选一;本集合示例统一用 X-App-Token,避免与 CF Bearer 混淆)。",
|
||||
"item": [
|
||||
{
|
||||
"name": "校验面板令牌",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/verify",
|
||||
"description": "校验面板访问令牌是否有效,成功返回 {\"ok\":true}。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "列出账号",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/accounts",
|
||||
"description": "列出 D1 中已保存的 Cloudflare 账号(响应含完整 api token,仅可信环境使用)。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "新增账号",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"我的账号\",\n \"token\": \"Cloudflare API Token\"\n}\n",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": "{{baseUrl}}/api/accounts",
|
||||
"description": "`token` 必填;`name` 可选。成功 201,响应 {\"account\":{...}}。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "更新账号",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"name\": \"新名称\",\n \"token\": \"新 Token(可选)\"\n}\n",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": "{{baseUrl}}/api/accounts/{{accountId}}",
|
||||
"description": "字段可选;未出现的字段保持不变。成功 200,{\"account\":{...}}。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "删除账号",
|
||||
"request": {
|
||||
"method": "DELETE",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/accounts/{{accountId}}",
|
||||
"description": "成功 200,{\"ok\":true}。"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cloudflare 透传 /api/cf",
|
||||
"description": "须同时携带:`X-App-Token: {{panelToken}}` 与 `Authorization: Bearer {{cfToken}}`。请求将转发至 `https://api.cloudflare.com/client/v4` 对应路径。",
|
||||
"item": [
|
||||
{
|
||||
"name": "验证 CF Token(示例)",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{cfToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/cf/user/tokens/verify",
|
||||
"description": "对应官方 GET /user/tokens/verify,用于快速验证 cfToken 是否有效。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "列出 Zones",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{cfToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/cf/zones?per_page=50&direction=desc",
|
||||
"description": "等价于官方 GET /zones,查询参数与 Cloudflare 文档一致。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "列出 DNS 记录",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{cfToken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/api/cf/zones/{{zoneId}}/dns_records?per_page=100&page=1",
|
||||
"description": "将 `zoneId` 设为实际 Zone ID。"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "创建 DNS 记录(示例)",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-App-Token",
|
||||
"value": "{{panelToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{cfToken}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"type\": \"A\",\n \"name\": \"www\",\n \"content\": \"192.0.2.1\",\n \"ttl\": 1,\n \"proxied\": true\n}\n",
|
||||
"options": {
|
||||
"raw": {
|
||||
"language": "json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"url": "{{baseUrl}}/api/cf/zones/{{zoneId}}/dns_records",
|
||||
"description": "正文与官方 Cloudflare API 一致,按实际 Zone 与记录类型修改。"
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"variable": [
|
||||
{
|
||||
"key": "baseUrl",
|
||||
"value": "https://domainflare.smyhub.com"
|
||||
},
|
||||
{
|
||||
"key": "panelToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "cfToken",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "accountId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "zoneId",
|
||||
"value": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
285
domainflare API调用文档.md
Normal file
285
domainflare API调用文档.md
Normal file
@@ -0,0 +1,285 @@
|
||||
# Domainflare HTTP API 调用文档
|
||||
|
||||
本文描述已部署的 **Domainflare**(Cloudflare Worker)对外 HTTP 接口。文中 **BASE** 表示 Worker 的**根 URL**(协议 + 主机,**无末尾斜杠**),例如 `https://domainflare.example.workers.dev`。
|
||||
|
||||
**相关文件**(仓库根目录):
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `imports/Domainflare.environment.postman.json` | 环境变量模板(`BASE`、`PANEL_TOKEN` 等),Postman / Apifox 可导入 |
|
||||
| `imports/Domainflare.postman_collection.json` | Postman 集合 |
|
||||
| `imports/Domainflare.apifox.openapi.json` | OpenAPI 3.0,供 Apifox 等导入 |
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. Postman / Apifox 与环境变量](#0-postman--apifox-与环境变量)
|
||||
- [1. 快速发现](#1-快速发现)
|
||||
- [2. 认证方式](#2-认证方式)
|
||||
- [3. 管理接口](#3-管理接口)
|
||||
- [4. Cloudflare API 透传](#4-cloudflare-api-透传)
|
||||
- [5. 错误响应](#5-错误响应)
|
||||
- [6. CORS](#6-cors)
|
||||
- [7. 集成建议](#7-集成建议)
|
||||
- [8. 安全提示](#8-安全提示)
|
||||
|
||||
---
|
||||
|
||||
## 0. Postman / Apifox 与环境变量
|
||||
|
||||
建议在 Shell、CI 或 API 工具中使用**同名**变量,便于对照:
|
||||
|
||||
```bash
|
||||
export BASE="https://你的 Worker 域名" # 无末尾 /
|
||||
export PANEL_TOKEN="你的 APP_ACCESS_TOKEN" # 与 Worker 的 APP_ACCESS_TOKEN 一致
|
||||
# 调用 /api/cf/* 时还需要 Cloudflare API Token,文档示例里用 CF_TOKEN;导入环境文件里对应键名为 cfToken,含义相同。
|
||||
```
|
||||
|
||||
**导入步骤概要**
|
||||
|
||||
1. **环境**:导入 `imports/Domainflare.environment.postman.json`(Postman:`导入` →选择环境文件;Apifox:`导入` → `Postman` → 选同一文件)。按实际修改 `BASE`、`PANEL_TOKEN`。
|
||||
2. **Postman**:再导入 `imports/Domainflare.postman_collection.json`;选中上述环境后请求中 `{{BASE}}`、`{{PANEL_TOKEN}}` 会生效。
|
||||
3. **Apifox**:在环境就绪后,`导入` → `OpenAPI` → 选择 `imports/Domainflare.apifox.openapi.json`(服务地址模板为 `{BASE}`,需在环境中定义 `BASE`)。在认证方案中:`PanelHeader` / `PanelBearer` 使用 `{{PANEL_TOKEN}}`,`CfBearer` 使用 `{{cfToken}}`。
|
||||
|
||||
---
|
||||
|
||||
## 1. 快速发现
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|------|------|:----:|------|
|
||||
| GET | `/api` | 否 | 返回 API 目录 JSON(`service`、`version`、路由说明等) |
|
||||
| GET | `/api/health` | 否 | 健康检查,可用于探活 |
|
||||
|
||||
示例:
|
||||
|
||||
```bash
|
||||
curl -sS "https://你的 Worker 域名/api/health"
|
||||
```
|
||||
|
||||
使用环境变量时:
|
||||
|
||||
```bash
|
||||
curl -sS "$BASE/api/health"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 认证方式
|
||||
|
||||
Worker 配置项 **`APP_ACCESS_TOKEN`**(面板口令)用于保护需鉴权的管理接口。
|
||||
|
||||
### 2.1 管理类接口(校验、账号 CRUD)
|
||||
|
||||
以下**二选一**即可(勿在同一条请求里混用两种含义的 Bearer,见下节):
|
||||
|
||||
| 方式 | 请求头 |
|
||||
|------|--------|
|
||||
| A | `X-App-Token: <APP_ACCESS_TOKEN>` |
|
||||
| B | `Authorization: Bearer <APP_ACCESS_TOKEN>` |
|
||||
|
||||
适用于:`GET /api/verify`,`GET/POST/PATCH/DELETE /api/accounts` 等。
|
||||
|
||||
### 2.2 Cloudflare API 透传(`/api/cf/*`)
|
||||
|
||||
须**同时**携带:
|
||||
|
||||
| 请求头 | 值 |
|
||||
|--------|-----|
|
||||
| `X-App-Token` | `<APP_ACCESS_TOKEN>`(**仅**用此头传面板口令,避免与 CF 的 Bearer 冲突) |
|
||||
| `Authorization` | `Bearer <Cloudflare API Token>` |
|
||||
|
||||
转发规则:请求 `BASE` + `/api/cf` + 子路径与查询串 → `https://api.cloudflare.com/client/v4` + 同一子路径与查询串。HTTP 方法及 Body(如 JSON)原样转发,响应状态码与正文与官方 API 一致。
|
||||
|
||||
---
|
||||
|
||||
## 3. 管理接口
|
||||
|
||||
以下示例假定已设置 `BASE`、`PANEL_TOKEN`(见第 0 节)。
|
||||
|
||||
### 3.1 校验面板口令
|
||||
|
||||
```http
|
||||
GET /api/verify
|
||||
```
|
||||
|
||||
| 结果 | HTTP | 响应体(示例) |
|
||||
|------|------|----------------|
|
||||
| 成功 | 200 | `{"ok":true}` |
|
||||
| 失败 | 401 | `{"success":false,"errors":[{"message":"Invalid app access token"}]}` |
|
||||
|
||||
```bash
|
||||
curl -sS -H "X-App-Token: $PANEL_TOKEN" "$BASE/api/verify"
|
||||
curl -sS -H "Authorization: Bearer $PANEL_TOKEN" "$BASE/api/verify"
|
||||
```
|
||||
|
||||
### 3.2 列出已保存的 Cloudflare 账号
|
||||
|
||||
```http
|
||||
GET /api/accounts
|
||||
```
|
||||
|
||||
**响应示例:**
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "显示名",
|
||||
"token": "Cloudflare API Token 全文",
|
||||
"created_at": 1712345678
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
> 响应含**完整** CF Token,仅允许在可信网络与可信客户端使用。
|
||||
|
||||
```bash
|
||||
curl -sS -H "Authorization: Bearer $PANEL_TOKEN" "$BASE/api/accounts"
|
||||
```
|
||||
|
||||
### 3.3 新增账号
|
||||
|
||||
```http
|
||||
POST /api/accounts
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "我的账号",
|
||||
"token": "Cloudflare API Token"
|
||||
}
|
||||
```
|
||||
|
||||
- `name`:可选;`token`:**必填**。
|
||||
- **成功**:`201`,响应体为 `{"account":{...}}`。
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/api/accounts" \
|
||||
-H "Authorization: Bearer $PANEL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"生产","token":"YOUR_CF_API_TOKEN"}'
|
||||
```
|
||||
|
||||
### 3.4 更新账号
|
||||
|
||||
```http
|
||||
PATCH /api/accounts/:id
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```json
|
||||
{ "name": "新名称", "token": "新 Token" }
|
||||
```
|
||||
|
||||
字段可选;未出现的字段保持不变。成功:`200`,`{"account":{...}}`。
|
||||
|
||||
```bash
|
||||
curl -sS -X PATCH "$BASE/api/accounts/ACCOUNT_ID" \
|
||||
-H "X-App-Token: $PANEL_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"新名称"}'
|
||||
```
|
||||
|
||||
### 3.5 删除账号
|
||||
|
||||
```http
|
||||
DELETE /api/accounts/:id
|
||||
```
|
||||
|
||||
成功:`200`,`{"ok":true}`。
|
||||
|
||||
```bash
|
||||
curl -sS -X DELETE "$BASE/api/accounts/ACCOUNT_ID" \
|
||||
-H "X-App-Token: $PANEL_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Cloudflare API 透传
|
||||
|
||||
本节对应 Worker 路径前缀 **`/api/cf/*`**,转发至 `https://api.cloudflare.com/client/v4/*`。
|
||||
|
||||
### 4.1 路径映射示例
|
||||
|
||||
| 客户端请求 | 等价 Cloudflare 端点 |
|
||||
|------------|----------------------|
|
||||
| `GET $BASE/api/cf/zones` | `GET https://api.cloudflare.com/client/v4/zones` |
|
||||
| `GET $BASE/api/cf/zones/{zone_id}/dns_records` | `GET .../client/v4/zones/{zone_id}/dns_records` |
|
||||
|
||||
### 4.2 列出 Zones
|
||||
|
||||
```bash
|
||||
export CF_TOKEN="你的 Cloudflare API Token"
|
||||
|
||||
curl -sS "$BASE/api/cf/zones?per_page=50&direction=desc" \
|
||||
-H "X-App-Token: $PANEL_TOKEN" \
|
||||
-H "Authorization: Bearer $CF_TOKEN"
|
||||
```
|
||||
|
||||
### 4.3 列出 DNS 记录
|
||||
|
||||
将 `ZONE_ID` 换为实际 Zone ID:
|
||||
|
||||
```bash
|
||||
curl -sS "$BASE/api/cf/zones/ZONE_ID/dns_records?per_page=100&page=1" \
|
||||
-H "X-App-Token: $PANEL_TOKEN" \
|
||||
-H "Authorization: Bearer $CF_TOKEN"
|
||||
```
|
||||
|
||||
### 4.4 创建 DNS 记录
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "$BASE/api/cf/zones/ZONE_ID/dns_records" \
|
||||
-H "X-App-Token: $PANEL_TOKEN" \
|
||||
-H "Authorization: Bearer $CF_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"A","name":"www","content":"192.0.2.1","ttl":1,"proxied":true}'
|
||||
```
|
||||
|
||||
### 4.5 官方参考
|
||||
|
||||
路径、字段与错误码以 Cloudflare 为准:
|
||||
[Cloudflare API v4 Documentation](https://developers.cloudflare.com/api/)
|
||||
|
||||
---
|
||||
|
||||
## 5. 错误响应
|
||||
|
||||
面板侧校验失败通常返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"errors": [{ "message": "说明文字" }]
|
||||
}
|
||||
```
|
||||
|
||||
常见 HTTP 状态:`401`(面板口令错误)、`400`(如 `/api/cf/*` 未带 CF Bearer)。
|
||||
经透传返回的响应可能与 Cloudflare 官方错误结构一致,不一定包含 `success` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 6. CORS
|
||||
|
||||
`/api/*` 已配置 `Access-Control-Allow-Origin: *`,浏览器跨域可调用;令牌仍须保密,勿写入前端公开代码或日志。
|
||||
|
||||
---
|
||||
|
||||
## 7. 集成建议
|
||||
|
||||
1. 在密钥管理或 CI 中保存 `BASE`、`PANEL_TOKEN`;调用 `/api/cf/*` 时再提供 `CF_TOKEN`(或先 `GET /api/accounts` 取用已存 Token,注意泄漏风险)。
|
||||
2. 启动或定时执行 `GET $BASE/api/health` 做探活。
|
||||
3. 可用 `GET $BASE/api` 获取机器可读目录与 `version`,便于对接升级。
|
||||
4. 所有 DNS 变更走 `/api/cf/...`,路径与官方 v4 一一对应。
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全提示
|
||||
|
||||
- 生产环境对 `APP_ACCESS_TOKEN` 使用强随机值,推荐使用 `wrangler secret put APP_ACCESS_TOKEN`,勿将明文口令提交到公开仓库。
|
||||
- `GET /api/accounts` 返回明文 CF Token,勿记录到公开日志或通过不可信代理。
|
||||
- CF API Token 遵守最小权限;面板口令与 CF Token 应保存在不同轮转策略的凭据系统中。
|
||||
118
frontend/index.html
Normal file
118
frontend/index.html
Normal file
@@ -0,0 +1,118 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Domainflare:Cloudflare 域名与 DNS 记录管理面板。"
|
||||
/>
|
||||
<meta name="theme-color" content="#2f6feb" />
|
||||
<meta name="application-name" content="Domainflare" />
|
||||
<meta name="msapplication-TileColor" content="#2f6feb" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-title"
|
||||
content="Domainflare"
|
||||
/>
|
||||
<title>Domainflare — Cloudflare DNS 控制台</title>
|
||||
<style>
|
||||
#df-splash-host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
}
|
||||
#df-splash-host .df-splash {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.25rem;
|
||||
font-family: "Literata", Georgia, "Times New Roman", serif;
|
||||
color: #1c1b19;
|
||||
background: linear-gradient(152deg, #faf7ef 0%, #f6f4ef 38%, #ebe4d8 72%, #dfeaf9 100%);
|
||||
}
|
||||
#df-splash-host .df-splash-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 1.35rem;
|
||||
}
|
||||
#df-splash-host .df-splash-logo {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
border-radius: 12px;
|
||||
object-fit: contain;
|
||||
}
|
||||
#df-splash-host .df-splash-title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.55rem, 4.5vw, 2rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
#df-splash-host .df-splash-sub {
|
||||
margin: -0.65rem 0 0;
|
||||
font-size: 0.98rem;
|
||||
color: #5c5954;
|
||||
}
|
||||
#df-splash-host .df-splash-dots {
|
||||
display: flex;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
#df-splash-host .df-splash-dot {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: #22c55e;
|
||||
}
|
||||
</style>
|
||||
<link rel="icon" href="/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" href="/logo192.png" sizes="192x192" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=JetBrains+Mono:wght@400;500&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="df-splash-host">
|
||||
<div class="df-splash" role="status" aria-busy="true" aria-label="正在加载 Domainflare">
|
||||
<div class="df-splash-halo" aria-hidden="true"></div>
|
||||
<div class="df-splash-inner">
|
||||
<div class="df-splash-logo-wrap">
|
||||
<div class="df-splash-rings" aria-hidden="true">
|
||||
<span class="df-splash-ring"></span>
|
||||
<span class="df-splash-ring"></span>
|
||||
<span class="df-splash-ring"></span>
|
||||
</div>
|
||||
<img
|
||||
class="df-splash-logo"
|
||||
src="/logo192.png"
|
||||
alt=""
|
||||
width="192"
|
||||
height="192"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
<h1 class="df-splash-title">Domainflare</h1>
|
||||
<p class="df-splash-sub">加载中</p>
|
||||
<div class="df-splash-dots" aria-hidden="true">
|
||||
<span class="df-splash-dot"></span>
|
||||
<span class="df-splash-dot"></span>
|
||||
<span class="df-splash-dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6268
frontend/package-lock.json
generated
Normal file
6268
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "domainflare-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/public/logo.png
Normal file
BIN
frontend/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 MiB |
BIN
frontend/public/logo192.png
Normal file
BIN
frontend/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/public/logo512.png
Normal file
BIN
frontend/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 349 KiB |
35
frontend/src/App.tsx
Normal file
35
frontend/src/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DomainflareProvider, useDomainflare } from "./context/DomainflareContext";
|
||||
import { AppSplash } from "./components/AppSplash";
|
||||
import { Gate } from "./components/Gate";
|
||||
import { MainLayout } from "./components/MainLayout";
|
||||
import { PwaUpdatePrompt } from "./components/PwaUpdatePrompt";
|
||||
import { ToastHost } from "./components/ToastHost";
|
||||
|
||||
function Shell() {
|
||||
const { unlocked } = useDomainflare();
|
||||
return (
|
||||
<>
|
||||
{!unlocked ? <Gate /> : null}
|
||||
{unlocked ? <MainLayout /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBody() {
|
||||
const { sessionBootstrapDone } = useDomainflare();
|
||||
return (
|
||||
<>
|
||||
{!sessionBootstrapDone ? <AppSplash /> : <Shell />}
|
||||
<ToastHost />
|
||||
<PwaUpdatePrompt />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<DomainflareProvider>
|
||||
<AppBody />
|
||||
</DomainflareProvider>
|
||||
);
|
||||
}
|
||||
94
frontend/src/api/client.ts
Normal file
94
frontend/src/api/client.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { CfAccount } from "../types";
|
||||
import { LEGACY_STORAGE_ACCOUNTS } from "../constants";
|
||||
|
||||
export async function apiVerify(token: string): Promise<boolean> {
|
||||
const r = await fetch("/api/verify", { headers: { "X-App-Token": token } });
|
||||
return r.ok;
|
||||
}
|
||||
|
||||
/** 调用 Cloudflare API(经 Worker /api/cf 代理) */
|
||||
export async function cfRequest(
|
||||
appToken: string,
|
||||
accountApiToken: string,
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<unknown> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("X-App-Token", appToken);
|
||||
headers.set("Authorization", `Bearer ${accountApiToken}`);
|
||||
const r = await fetch(`/api/cf${path}`, { ...init, headers });
|
||||
const text = await r.text();
|
||||
let json: Record<string, unknown> = {};
|
||||
try {
|
||||
json = text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
||||
} catch {
|
||||
throw new Error(`接口返回非 JSON:${r.status}`);
|
||||
}
|
||||
if (!r.ok || json.success === false) {
|
||||
const errs = json.errors as Array<{ message?: string; code?: string }> | undefined;
|
||||
const msgs = json.messages as string[] | undefined;
|
||||
const msg =
|
||||
errs?.map((e) => e.message || e.code).join(";") ||
|
||||
msgs?.join?.(";") ||
|
||||
`请求失败 (${r.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function fetchAccountsApi(appToken: string): Promise<CfAccount[]> {
|
||||
const r = await fetch("/api/accounts", { headers: { "X-App-Token": appToken } });
|
||||
const text = await r.text();
|
||||
let data: { accounts?: CfAccount[]; errors?: { message: string }[] } = {};
|
||||
try {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (!r.ok) {
|
||||
const msg =
|
||||
data.errors?.map((e) => e.message).join(";") || `加载账号失败 (${r.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return Array.isArray(data.accounts) ? data.accounts : [];
|
||||
}
|
||||
|
||||
/** 将旧版 localStorage 账号迁入 D1;若执行过迁入返回 true */
|
||||
export async function migrateLegacyAccountsIfNeeded(appToken: string): Promise<boolean> {
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_ACCOUNTS);
|
||||
if (!raw) return false;
|
||||
let list: unknown;
|
||||
try {
|
||||
list = JSON.parse(raw);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (!Array.isArray(list) || !list.length) return false;
|
||||
for (const a of list) {
|
||||
const row = a as { name?: string; token?: string };
|
||||
if (!row?.token) continue;
|
||||
const r = await fetch("/api/accounts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Token": appToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: row.name || "账号",
|
||||
token: String(row.token),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text();
|
||||
let err = t;
|
||||
try {
|
||||
err = (JSON.parse(t) as { errors?: { message: string }[] }).errors?.[0]?.message || t;
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
throw new Error(`迁移旧数据失败:${err}`);
|
||||
}
|
||||
}
|
||||
localStorage.removeItem(LEGACY_STORAGE_ACCOUNTS);
|
||||
return true;
|
||||
}
|
||||
102
frontend/src/components/AccountsSection.tsx
Normal file
102
frontend/src/components/AccountsSection.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useState } from "react";
|
||||
import type { CfAccount } from "../types";
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
|
||||
/** 侧边栏:账号列表与添加 */
|
||||
export function AccountsSection() {
|
||||
const {
|
||||
accounts,
|
||||
activeAccountId,
|
||||
selectAccount,
|
||||
deleteAccount,
|
||||
addAccount,
|
||||
toast,
|
||||
} = useDomainflare();
|
||||
const [name, setName] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
|
||||
async function onAdd() {
|
||||
const t = token.trim();
|
||||
if (!t) {
|
||||
toast("请填写 API Token", true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await addAccount(name.trim(), t);
|
||||
setName("");
|
||||
setToken("");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(a: CfAccount) {
|
||||
try {
|
||||
await deleteAccount(a);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<h3>Cloudflare 账号</h3>
|
||||
<div id="account-list" className="stack">
|
||||
{accounts.map((a) => (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`account-item${a.id === activeAccountId ? " active" : ""}`}
|
||||
>
|
||||
<span>{a.name || "未命名"}</span>
|
||||
<span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost tiny"
|
||||
disabled={a.id === activeAccountId}
|
||||
onClick={() => selectAccount(a.id)}
|
||||
>
|
||||
{a.id === activeAccountId ? "当前" : "切换"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost tiny danger"
|
||||
onClick={() => void onDelete(a)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!accounts.length ? <p className="muted small">尚未添加账号。</p> : null}
|
||||
|
||||
<details className="details">
|
||||
<summary>添加账号</summary>
|
||||
<label className="field">
|
||||
<span>显示名称</span>
|
||||
<input
|
||||
id="acct-name"
|
||||
type="text"
|
||||
placeholder="例如:个人 / 公司"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>API Token</span>
|
||||
<input
|
||||
id="acct-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="Zone DNS Edit 或更广权限"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" id="acct-add" className="btn secondary small" onClick={() => void onAdd()}>
|
||||
保存账号
|
||||
</button>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/AppSplash.tsx
Normal file
25
frontend/src/components/AppSplash.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
/** 与 index.html #df-splash-host 结构一致,共用 .df-splash 样式 */
|
||||
export function AppSplash() {
|
||||
return (
|
||||
<div className="df-splash" role="status" aria-live="polite" aria-busy="true" aria-label="正在加载 Domainflare">
|
||||
<div className="df-splash-halo" aria-hidden />
|
||||
<div className="df-splash-inner">
|
||||
<div className="df-splash-logo-wrap">
|
||||
<div className="df-splash-rings" aria-hidden>
|
||||
<span className="df-splash-ring" />
|
||||
<span className="df-splash-ring" />
|
||||
<span className="df-splash-ring" />
|
||||
</div>
|
||||
<img className="df-splash-logo" src="/logo192.png" alt="" width={192} height={192} decoding="async" />
|
||||
</div>
|
||||
<h1 className="df-splash-title">Domainflare</h1>
|
||||
<p className="df-splash-sub">加载中</p>
|
||||
<div className="df-splash-dots" aria-hidden>
|
||||
<span className="df-splash-dot" />
|
||||
<span className="df-splash-dot" />
|
||||
<span className="df-splash-dot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
413
frontend/src/components/DnsRecordDrawer.tsx
Normal file
413
frontend/src/components/DnsRecordDrawer.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import type { DnsRecord } from "../types";
|
||||
import { RECORD_TYPES } from "../constants";
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
mode: "create" | "edit";
|
||||
record: DnsRecord | null;
|
||||
zoneId: string | null;
|
||||
onClose: () => void;
|
||||
onSaved: () => Promise<void>;
|
||||
};
|
||||
|
||||
/** 右侧抽屉:新建 / 编辑 DNS 记录(沿用原版表单逻辑) */
|
||||
export function DnsRecordDrawer({
|
||||
open,
|
||||
mode,
|
||||
record,
|
||||
zoneId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: Props) {
|
||||
const { cf, toast } = useDomainflare();
|
||||
|
||||
const [recType, setRecType] = useState("A");
|
||||
const [name, setName] = useState("");
|
||||
const [ttl, setTtl] = useState("1");
|
||||
const [comment, setComment] = useState("");
|
||||
const [proxWrap, setProxWrap] = useState(false);
|
||||
const [proxied, setProxied] = useState(false);
|
||||
const [content, setContent] = useState("");
|
||||
const [mxTarget, setMxTarget] = useState("");
|
||||
const [mxPrio, setMxPrio] = useState("10");
|
||||
const [srvPrio, setSrvPrio] = useState("10");
|
||||
const [srvWeight, setSrvWeight] = useState("10");
|
||||
const [srvPort, setSrvPort] = useState("443");
|
||||
const [srvTarget, setSrvTarget] = useState("");
|
||||
const [caaFlags, setCaaFlags] = useState("0");
|
||||
const [caaTag, setCaaTag] = useState("issue");
|
||||
const [caaValue, setCaaValue] = useState("");
|
||||
const [advJson, setAdvJson] = useState("");
|
||||
const [advOpen, setAdvOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (mode === "edit" && record) {
|
||||
const rt = record.type;
|
||||
setRecType(rt);
|
||||
setName(record.name || "");
|
||||
setTtl(record.ttl === 1 ? "1" : String(record.ttl));
|
||||
setComment(record.comment || "");
|
||||
setContent("");
|
||||
setMxTarget("");
|
||||
setMxPrio("10");
|
||||
setSrvPrio("10");
|
||||
setSrvWeight("10");
|
||||
setSrvPort("443");
|
||||
setSrvTarget("");
|
||||
setCaaFlags("0");
|
||||
setCaaTag("issue");
|
||||
setCaaValue("");
|
||||
setAdvJson("");
|
||||
setAdvOpen(false);
|
||||
|
||||
if (["A", "AAAA", "CNAME", "TXT", "NS", "PTR"].includes(rt)) {
|
||||
setContent(record.content || "");
|
||||
}
|
||||
if (rt === "MX") {
|
||||
setMxTarget(record.content || "");
|
||||
setMxPrio(String(record.priority ?? 10));
|
||||
}
|
||||
if (rt === "SRV" && record.data) {
|
||||
const d = record.data as Record<string, unknown>;
|
||||
setSrvPrio(String(d.priority ?? 10));
|
||||
setSrvWeight(String(d.weight ?? 10));
|
||||
setSrvPort(String(d.port ?? 443));
|
||||
setSrvTarget(String(d.target ?? ""));
|
||||
}
|
||||
if (rt === "CAA" && record.data) {
|
||||
const d = record.data as Record<string, unknown>;
|
||||
setCaaFlags(String(d.flags ?? 0));
|
||||
setCaaTag(String(d.tag ?? "issue"));
|
||||
setCaaValue(String(d.value ?? ""));
|
||||
}
|
||||
if (record.proxiable) {
|
||||
setProxWrap(true);
|
||||
setProxied(!!record.proxied);
|
||||
} else {
|
||||
setProxWrap(false);
|
||||
setProxied(false);
|
||||
}
|
||||
if (record.data && !["SRV", "CAA"].includes(rt)) {
|
||||
setAdvOpen(true);
|
||||
setAdvJson(JSON.stringify({ data: record.data }, null, 2));
|
||||
}
|
||||
return;
|
||||
}
|
||||
setRecType("A");
|
||||
setName("");
|
||||
setTtl("1");
|
||||
setComment("");
|
||||
setProxWrap(false);
|
||||
setProxied(false);
|
||||
setContent("");
|
||||
setMxTarget("");
|
||||
setMxPrio("10");
|
||||
setSrvPrio("10");
|
||||
setSrvWeight("10");
|
||||
setSrvPort("443");
|
||||
setSrvTarget("");
|
||||
setCaaFlags("0");
|
||||
setCaaTag("issue");
|
||||
setCaaValue("");
|
||||
setAdvJson("");
|
||||
setAdvOpen(false);
|
||||
}, [open, mode, record]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!["A", "AAAA", "CNAME"].includes(recType)) {
|
||||
setProxied(false);
|
||||
}
|
||||
}, [recType]);
|
||||
|
||||
const showProxBox = proxWrap && ["A", "AAAA", "CNAME"].includes(recType);
|
||||
|
||||
function buildPayload(): Record<string, unknown> {
|
||||
const ttlRaw = ttl.trim();
|
||||
const ttlNum = ttlRaw === "auto" || ttlRaw === "" ? 1 : parseInt(ttlRaw, 10);
|
||||
const payload: Record<string, unknown> = {
|
||||
type: recType,
|
||||
name: name.trim(),
|
||||
ttl: Number.isFinite(ttlNum) ? ttlNum : 1,
|
||||
};
|
||||
if (comment.trim()) payload.comment = comment.trim();
|
||||
|
||||
if (["A", "AAAA", "CNAME", "TXT", "NS", "PTR"].includes(recType)) {
|
||||
payload.content = content.trim();
|
||||
} else if (recType === "MX") {
|
||||
payload.content = mxTarget.trim();
|
||||
payload.priority = parseInt(mxPrio || "10", 10) || 10;
|
||||
} else if (recType === "SRV") {
|
||||
payload.data = {
|
||||
priority: parseInt(srvPrio || "10", 10) || 10,
|
||||
weight: parseInt(srvWeight || "10", 10) || 10,
|
||||
port: parseInt(srvPort || "443", 10) || 443,
|
||||
target: srvTarget.trim(),
|
||||
};
|
||||
} else if (recType === "CAA") {
|
||||
payload.data = {
|
||||
flags: parseInt(caaFlags || "0", 10) || 0,
|
||||
tag: (caaTag || "issue").trim(),
|
||||
value: caaValue.trim().replace(/^"|"$/g, ""),
|
||||
};
|
||||
}
|
||||
|
||||
if (showProxBox) {
|
||||
payload.proxied = proxied;
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!zoneId) return;
|
||||
try {
|
||||
const payload = buildPayload();
|
||||
const advText = advJson.trim();
|
||||
if (advText) {
|
||||
const extra = JSON.parse(advText) as Record<string, unknown>;
|
||||
if (extra && typeof extra === "object") Object.assign(payload, extra);
|
||||
}
|
||||
const nameVal = String(payload.name ?? "").trim();
|
||||
if (!nameVal) {
|
||||
toast("名称不能为空", true);
|
||||
return;
|
||||
}
|
||||
payload.name = nameVal;
|
||||
const hasContent = payload.content !== undefined && String(payload.content).length > 0;
|
||||
const hasData = payload.data !== undefined && payload.data !== null;
|
||||
if (!hasContent && !hasData && !advText) {
|
||||
toast("请填写内容、结构化 data,或使用高级 JSON", true);
|
||||
return;
|
||||
}
|
||||
if (payload.type === "SRV" && payload.data && typeof payload.data === "object") {
|
||||
const d = payload.data as { target?: string };
|
||||
if (!String(d.target || "").trim()) {
|
||||
toast("SRV 记录请填写 target", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (payload.type === "CAA" && payload.data && typeof payload.data === "object") {
|
||||
const d = payload.data as { value?: string };
|
||||
if (!String(d.value || "").trim()) {
|
||||
toast("CAA 请填写 value", true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === "edit" && record) {
|
||||
const patch: Record<string, unknown> = {
|
||||
type: payload.type,
|
||||
name: payload.name,
|
||||
ttl: payload.ttl,
|
||||
comment: payload.comment,
|
||||
};
|
||||
if (payload.content != null) patch.content = payload.content;
|
||||
if (payload.priority != null) patch.priority = payload.priority;
|
||||
if (payload.data != null) patch.data = payload.data;
|
||||
if (["A", "AAAA", "CNAME"].includes(String(payload.type))) {
|
||||
patch.proxied = !!payload.proxied;
|
||||
}
|
||||
await cf(`/zones/${zoneId}/dns_records/${record.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
toast("已更新");
|
||||
} else {
|
||||
await cf(`/zones/${zoneId}/dns_records`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
toast("已创建");
|
||||
}
|
||||
onClose();
|
||||
await onSaved();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
let simpleBlock: ReactNode;
|
||||
if (["A", "AAAA", "CNAME", "TXT", "NS", "PTR"].includes(recType)) {
|
||||
simpleBlock = (
|
||||
<label className="field">
|
||||
<span>内容 content</span>
|
||||
<textarea id="f-content" rows={3} value={content} onChange={(e) => setContent(e.target.value)} />
|
||||
</label>
|
||||
);
|
||||
} else if (recType === "MX") {
|
||||
simpleBlock = (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>邮件服务器</span>
|
||||
<input id="f-mx-target" type="text" value={mxTarget} onChange={(e) => setMxTarget(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>优先级</span>
|
||||
<input
|
||||
id="f-mx-prio"
|
||||
type="number"
|
||||
value={mxPrio}
|
||||
onChange={(e) => setMxPrio(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
} else if (recType === "SRV") {
|
||||
simpleBlock = (
|
||||
<>
|
||||
<p className="muted small">
|
||||
名称需含服务名,如 <code>_sip._tcp</code> 加主机名。
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>优先级 priority</span>
|
||||
<input id="f-srv-prio" type="number" value={srvPrio} onChange={(e) => setSrvPrio(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>权重 weight</span>
|
||||
<input
|
||||
id="f-srv-weight"
|
||||
type="number"
|
||||
value={srvWeight}
|
||||
onChange={(e) => setSrvWeight(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>端口 port</span>
|
||||
<input id="f-srv-port" type="number" value={srvPort} onChange={(e) => setSrvPort(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>目标 target</span>
|
||||
<input id="f-srv-target" type="text" value={srvTarget} onChange={(e) => setSrvTarget(e.target.value)} />
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
} else if (recType === "CAA") {
|
||||
simpleBlock = (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>flags</span>
|
||||
<input id="f-caa-flags" type="number" value={caaFlags} onChange={(e) => setCaaFlags(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>tag(issue / issuewild / iodef)</span>
|
||||
<input id="f-caa-tag" type="text" value={caaTag} onChange={(e) => setCaaTag(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>value(不含引号也可)</span>
|
||||
<input
|
||||
id="f-caa-value"
|
||||
type="text"
|
||||
placeholder="letsencrypt.org"
|
||||
value={caaValue}
|
||||
onChange={(e) => setCaaValue(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
simpleBlock = (
|
||||
<p className="muted small">
|
||||
该类型请使用下方「高级 JSON」提供 <code>content</code> 或 <code>data</code> 字段。
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="drawer"
|
||||
className={`drawer${open ? "" : " hidden"}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="drawer-title"
|
||||
onClick={(ev) => {
|
||||
if ((ev.target as HTMLElement).id === "drawer") onClose();
|
||||
}}
|
||||
>
|
||||
<div className="drawer-panel">
|
||||
<div className="drawer-head">
|
||||
<h2 id="drawer-title">{mode === "edit" ? "编辑解析" : "新建解析"}</h2>
|
||||
<button type="button" id="drawer-close" className="btn ghost small" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="drawer-body" id="drawer-body">
|
||||
<label className="field">
|
||||
<span>记录类型</span>
|
||||
<select id="f-type" value={recType} onChange={(e) => setRecType(e.target.value)}>
|
||||
{RECORD_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>名称(可使用相对名称,如 www 或 @)</span>
|
||||
<input
|
||||
id="f-name"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
placeholder="例如 www 或 @ "
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>TTL(1 = 自动)</span>
|
||||
<input id="f-ttl" type="text" inputMode="numeric" value={ttl} onChange={(e) => setTtl(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>备注 comment(可选)</span>
|
||||
<input id="f-comment" type="text" value={comment} onChange={(e) => setComment(e.target.value)} />
|
||||
</label>
|
||||
<div id="f-simple" className="stack">
|
||||
{simpleBlock}
|
||||
</div>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="f-prox-wrap"
|
||||
checked={proxWrap}
|
||||
onChange={(e) => setProxWrap(e.target.checked)}
|
||||
/>
|
||||
<span className="muted small">对 A / AAAA / CNAME 显示「橙色云」代理选项</span>
|
||||
</label>
|
||||
<div id="f-prox-box" className={showProxBox ? "" : "hidden"}>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="f-proxied"
|
||||
checked={proxied}
|
||||
onChange={(e) => setProxied(e.target.checked)}
|
||||
/>
|
||||
<span>经过 Cloudflare 代理(orange cloud)</span>
|
||||
</label>
|
||||
</div>
|
||||
<details className="details" id="f-adv-details" open={advOpen} onToggle={(e) => setAdvOpen(e.currentTarget.open)}>
|
||||
<summary>高级:手动 JSON(覆盖简单字段,复杂类型)</summary>
|
||||
<p className="muted small">
|
||||
会合并到基础字段;可用于 HTTPS/SVCB 等需 <code>data</code> 的对象。
|
||||
</p>
|
||||
<textarea
|
||||
id="f-adv-json"
|
||||
className="cell-mono"
|
||||
placeholder='例如 { "data": { ... } }'
|
||||
value={advJson}
|
||||
onChange={(e) => setAdvJson(e.target.value)}
|
||||
/>
|
||||
</details>
|
||||
<div className="form-actions">
|
||||
<button type="button" id="f-save" className="btn primary" onClick={() => void save()}>
|
||||
{mode === "edit" ? "保存修改" : "创建记录"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
frontend/src/components/Gate.tsx
Normal file
55
frontend/src/components/Gate.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
|
||||
/** 登录门禁:校验面板 APP_ACCESS_TOKEN */
|
||||
export function Gate() {
|
||||
const { unlockWithToken } = useDomainflare();
|
||||
const [token, setToken] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
const t = token.trim();
|
||||
setError(null);
|
||||
if (!t) {
|
||||
setError("请输入令牌");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await unlockWithToken(t);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div id="overlay" className="overlay" role="dialog" aria-modal="true" aria-labelledby="gate-title">
|
||||
<div className="overlay-card">
|
||||
<h2 id="gate-title">访问验证</h2>
|
||||
<p className="muted small">
|
||||
请输入面板访问令牌。令牌由部署时在 Worker 环境变量 <code>APP_ACCESS_TOKEN</code>{" "}
|
||||
配置(默认与 wrangler.toml 中一致)。
|
||||
</p>
|
||||
<label className="field">
|
||||
<span>访问令牌</span>
|
||||
<input
|
||||
id="gate-token"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder="输入令牌"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && void submit()}
|
||||
/>
|
||||
</label>
|
||||
{error ? (
|
||||
<p id="gate-error" className="error small">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<button type="button" id="gate-submit" className="btn primary" onClick={() => void submit()}>
|
||||
进入
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
frontend/src/components/MainLayout.tsx
Normal file
109
frontend/src/components/MainLayout.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AccountsSection } from "./AccountsSection";
|
||||
import { RecordsSection } from "./RecordsSection";
|
||||
import { ZonesSection } from "./ZonesSection";
|
||||
|
||||
const MOBILE_SIDEBAR_MQ = "(max-width: 900px)";
|
||||
|
||||
/** 解锁后的主布局 */
|
||||
export function MainLayout() {
|
||||
const [isNarrow, setIsNarrow] = useState(() =>
|
||||
typeof window !== "undefined" ? window.matchMedia(MOBILE_SIDEBAR_MQ).matches : false,
|
||||
);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(MOBILE_SIDEBAR_MQ);
|
||||
const onChange = () => {
|
||||
const narrow = mq.matches;
|
||||
setIsNarrow(narrow);
|
||||
if (!narrow) setMobileSidebarOpen(false);
|
||||
};
|
||||
onChange();
|
||||
mq.addEventListener("change", onChange);
|
||||
return () => mq.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileSidebarOpen || !isNarrow) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setMobileSidebarOpen(false);
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [mobileSidebarOpen, isNarrow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isNarrow && mobileSidebarOpen) {
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}, [isNarrow, mobileSidebarOpen]);
|
||||
|
||||
return (
|
||||
<div id="app" className="layout" aria-hidden="false">
|
||||
<header className="topbar">
|
||||
<div className="topbar-start">
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost small sidebar-menu-btn"
|
||||
id="btn-sidebar-menu"
|
||||
aria-expanded={mobileSidebarOpen}
|
||||
aria-controls="sidebar"
|
||||
onClick={() => setMobileSidebarOpen((v) => !v)}
|
||||
>
|
||||
账号与域名
|
||||
</button>
|
||||
<div className="brand">
|
||||
<img className="brand-logo" src="/logo.png" alt="" width={40} height={40} />
|
||||
<div>
|
||||
<strong>Domainflare</strong>
|
||||
<div className="muted tiny">Cloudflare 域名解析管理面板</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isNarrow ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`sidebar-backdrop${mobileSidebarOpen ? " is-visible" : ""}`}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
<div className="shell">
|
||||
<aside
|
||||
className={`sidebar${mobileSidebarOpen && isNarrow ? " sidebar--open" : ""}`}
|
||||
id="sidebar"
|
||||
aria-hidden={isNarrow ? !mobileSidebarOpen : false}
|
||||
>
|
||||
<div className="sidebar-drawer-head">
|
||||
<span className="sidebar-drawer-title">账号与域名</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost tiny"
|
||||
id="btn-sidebar-close"
|
||||
onClick={() => setMobileSidebarOpen(false)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="sidebar-scroll">
|
||||
<AccountsSection />
|
||||
<ZonesSection />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main">
|
||||
<RecordsSection />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/PwaUpdatePrompt.tsx
Normal file
29
frontend/src/components/PwaUpdatePrompt.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useRegisterSW } from "virtual:pwa-register/react";
|
||||
|
||||
/** 检测到新版本 Service Worker 等待激活时提示刷新(需 vite-plugin-pwa registerType: prompt) */
|
||||
export function PwaUpdatePrompt() {
|
||||
const {
|
||||
needRefresh: [needRefresh, setNeedRefresh],
|
||||
updateServiceWorker,
|
||||
} = useRegisterSW({ immediate: true });
|
||||
|
||||
if (!needRefresh) return null;
|
||||
|
||||
return (
|
||||
<div className="pwa-update-bar" role="status" aria-live="polite">
|
||||
<span className="pwa-update-bar-text">发现新版本,刷新后即可使用最新功能</span>
|
||||
<div className="pwa-update-bar-actions">
|
||||
<button type="button" className="btn ghost tiny" onClick={() => setNeedRefresh(false)}>
|
||||
稍后
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary tiny"
|
||||
onClick={() => void updateServiceWorker(true)}
|
||||
>
|
||||
立即刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
296
frontend/src/components/RecordsSection.tsx
Normal file
296
frontend/src/components/RecordsSection.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
import type { DnsRecord } from "../types";
|
||||
import { RECORD_TYPES } from "../constants";
|
||||
import {
|
||||
dnsRecordTypeClass,
|
||||
proxiedBadgeClass,
|
||||
proxiedLabel,
|
||||
recordContentPreview,
|
||||
recordContentToneClass,
|
||||
ttlTagClass,
|
||||
} from "../utils/dns";
|
||||
import { DnsRecordDrawer } from "./DnsRecordDrawer";
|
||||
import { useState } from "react";
|
||||
|
||||
/** 主区:解析记录表与分页 */
|
||||
export function RecordsSection() {
|
||||
const {
|
||||
activeZone,
|
||||
activeZoneId,
|
||||
records,
|
||||
recordsStatus,
|
||||
fltType,
|
||||
setFltType,
|
||||
fltName,
|
||||
setFltName,
|
||||
fltContent,
|
||||
setFltContent,
|
||||
searchRecords,
|
||||
recordsPageInfo,
|
||||
goRecordsPrev,
|
||||
goRecordsNext,
|
||||
recordsPage,
|
||||
recordsTotalPages,
|
||||
toast,
|
||||
cf,
|
||||
} = useDomainflare();
|
||||
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("create");
|
||||
const [editRecord, setEditRecord] = useState<DnsRecord | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
if (!activeZoneId) {
|
||||
toast("请先选择域名", true);
|
||||
return;
|
||||
}
|
||||
setDrawerMode("create");
|
||||
setEditRecord(null);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(r: DnsRecord) {
|
||||
setDrawerMode("edit");
|
||||
setEditRecord(r);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function onDelete(r: DnsRecord) {
|
||||
if (!activeZoneId) return;
|
||||
if (!confirm(`删除 ${r.type} ${r.name}?`)) return;
|
||||
try {
|
||||
await cf(`/zones/${activeZoneId}/dns_records/${r.id}`, { method: "DELETE" });
|
||||
toast("已删除");
|
||||
await searchRecords();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="zone-banner" className={`banner${activeZone ? "" : " hidden"}`}>
|
||||
<div>
|
||||
<h2 id="zone-title">{activeZone?.name ?? "—"}</h2>
|
||||
<p className="muted small" id="zone-meta">
|
||||
{activeZone
|
||||
? `Zone ID: ${activeZone.id} · ${activeZone.status} · ${activeZone.plan?.name || "plan"}`
|
||||
: ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="banner-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="btn-zone-settings"
|
||||
className="btn ghost small"
|
||||
onClick={() => void dnssecAlert(cf, activeZoneId, toast)}
|
||||
>
|
||||
DNS 设置
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
id="btn-export"
|
||||
className="btn ghost small"
|
||||
onClick={() => void exportRecords(cf, activeZoneId, toast)}
|
||||
>
|
||||
导出记录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel flat records-section">
|
||||
<div className="records-toolbar">
|
||||
<div className="toolbar-filters">
|
||||
<label className="field tight inline">
|
||||
<span className="sr-only">类型</span>
|
||||
<select
|
||||
id="flt-type"
|
||||
className="input-compact flt-select"
|
||||
value={fltType}
|
||||
onChange={(e) => setFltType(e.target.value)}
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{RECORD_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field tight inline flex-grow">
|
||||
<span className="sr-only">名称</span>
|
||||
<input
|
||||
id="flt-name"
|
||||
type="text"
|
||||
className="input-compact"
|
||||
placeholder="名称包含…"
|
||||
value={fltName}
|
||||
onChange={(e) => setFltName(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field tight inline flex-grow">
|
||||
<span className="sr-only">内容</span>
|
||||
<input
|
||||
id="flt-content"
|
||||
type="text"
|
||||
className="input-compact"
|
||||
placeholder="内容…"
|
||||
value={fltContent}
|
||||
onChange={(e) => setFltContent(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="records-search"
|
||||
className="btn secondary small btn-compact"
|
||||
onClick={() => void searchRecords()}
|
||||
>
|
||||
查询
|
||||
</button>
|
||||
</div>
|
||||
<div className="toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
id="record-new"
|
||||
className="btn primary small btn-compact"
|
||||
onClick={openCreate}
|
||||
>
|
||||
新建解析
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="records-status" className="records-meta muted small">
|
||||
{recordsStatus}
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table className="table table-record" id="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="col-type">类型</th>
|
||||
<th className="col-name">名称</th>
|
||||
<th className="col-content">内容</th>
|
||||
<th className="col-narrow">TTL</th>
|
||||
<th className="col-narrow">代理</th>
|
||||
<th className="col-actions">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="records-body">
|
||||
{records.map((r) => (
|
||||
<tr key={r.id} className="record-row">
|
||||
<td data-label="类型" className="td-type">
|
||||
<span className={`pill ${dnsRecordTypeClass(r.type)}`}>{r.type}</span>
|
||||
</td>
|
||||
<td data-label="名称" className="cell-mono td-name td-name-rec">
|
||||
{r.name}
|
||||
</td>
|
||||
<td data-label="内容" className="cell-mono td-content">
|
||||
<span className={`record-content ${recordContentToneClass(r)}`}>
|
||||
{recordContentPreview(r)}
|
||||
</span>
|
||||
</td>
|
||||
<td data-label="TTL" className="td-ttl">
|
||||
<span className={ttlTagClass(r)}>{r.ttl === 1 ? "自动" : String(r.ttl)}</span>
|
||||
</td>
|
||||
<td data-label="代理" className="td-proxy">
|
||||
<span className={proxiedBadgeClass(r)}>{proxiedLabel(r)}</span>
|
||||
</td>
|
||||
<td data-label="操作" className="td-actions">
|
||||
<div className="record-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn secondary tiny btn-action btn-action-edit"
|
||||
onClick={() => openEdit(r)}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn ghost tiny danger btn-action"
|
||||
onClick={() => void onDelete(r)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="pager pager-tight">
|
||||
<button
|
||||
type="button"
|
||||
id="page-prev"
|
||||
className="btn ghost small"
|
||||
disabled={recordsPage <= 1}
|
||||
onClick={goRecordsPrev}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span id="page-info" className="muted small">
|
||||
{recordsPageInfo}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="page-next"
|
||||
className="btn ghost small"
|
||||
disabled={recordsPage >= recordsTotalPages}
|
||||
onClick={goRecordsNext}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<DnsRecordDrawer
|
||||
open={drawerOpen}
|
||||
mode={drawerMode}
|
||||
record={editRecord}
|
||||
zoneId={activeZoneId}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onSaved={async () => {
|
||||
await searchRecords();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
async function dnssecAlert(
|
||||
cf: (path: string, init?: RequestInit) => Promise<unknown>,
|
||||
zoneId: string | null,
|
||||
toast: (m: string, e?: boolean) => void,
|
||||
) {
|
||||
if (!zoneId) return;
|
||||
try {
|
||||
const json = (await cf(`/zones/${zoneId}/dnssec`)) as { result?: unknown };
|
||||
const pretty = JSON.stringify(json.result ?? json, null, 2);
|
||||
alert(`DNSSEC(Cloudflare API /zones/:id/dnssec):\n\n${pretty}`);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRecords(
|
||||
cf: (path: string, init?: RequestInit) => Promise<unknown>,
|
||||
zoneId: string | null,
|
||||
toast: (m: string, e?: boolean) => void,
|
||||
) {
|
||||
if (!zoneId) return;
|
||||
try {
|
||||
const json = (await cf(`/zones/${zoneId}/dns_records?per_page=5000`)) as {
|
||||
result?: unknown;
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(json.result, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `dns-export-${zoneId}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toast("已导出 JSON");
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
}
|
||||
17
frontend/src/components/ToastHost.tsx
Normal file
17
frontend/src/components/ToastHost.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
|
||||
/** 底部 Toast */
|
||||
export function ToastHost() {
|
||||
const { toastState } = useDomainflare();
|
||||
if (!toastState) return null;
|
||||
return (
|
||||
<div
|
||||
id="toast"
|
||||
className="toast"
|
||||
role="status"
|
||||
style={{ background: toastState.isError ? "var(--danger)" : "var(--ink)" }}
|
||||
>
|
||||
{toastState.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
79
frontend/src/components/ZonesSection.tsx
Normal file
79
frontend/src/components/ZonesSection.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import type { CfZone } from "../types";
|
||||
import { useDomainflare } from "../context/DomainflareContext";
|
||||
|
||||
/** 侧边栏:域名 Zone 列表 */
|
||||
export function ZonesSection() {
|
||||
const {
|
||||
zonesFilter,
|
||||
setZonesFilter,
|
||||
filteredZones,
|
||||
activeZoneId,
|
||||
selectZone,
|
||||
zonesPageInfo,
|
||||
goZonesPrev,
|
||||
goZonesNext,
|
||||
refreshZonesList,
|
||||
zonesPage,
|
||||
zonesTotalPages,
|
||||
} = useDomainflare();
|
||||
|
||||
return (
|
||||
<section className="panel">
|
||||
<div className="row-between">
|
||||
<h3>域名(Zones)</h3>
|
||||
<button type="button" id="zones-refresh" className="btn ghost tiny" onClick={refreshZonesList}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>筛选</span>
|
||||
<input
|
||||
id="zones-filter"
|
||||
type="search"
|
||||
placeholder="按名称过滤…"
|
||||
value={zonesFilter}
|
||||
onChange={(e) => setZonesFilter(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div id="zone-list" className="list">
|
||||
{filteredZones.map((z: CfZone) => (
|
||||
<button
|
||||
key={z.id}
|
||||
type="button"
|
||||
className={`zone-item${z.id === activeZoneId ? " active" : ""}`}
|
||||
onClick={() => selectZone(z)}
|
||||
>
|
||||
<strong>{z.name}</strong>
|
||||
<div className="muted tiny">
|
||||
{z.status} · {z.id}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{!filteredZones.length ? <p className="muted small">暂无域名或筛选无结果。</p> : null}
|
||||
<div className="zone-pager pager-tight">
|
||||
<button
|
||||
type="button"
|
||||
id="zones-page-prev"
|
||||
className="btn ghost tiny btn-compact"
|
||||
disabled={zonesPage <= 1}
|
||||
onClick={goZonesPrev}
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<span id="zones-page-info" className="muted tiny">
|
||||
{zonesPageInfo}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="zones-page-next"
|
||||
className="btn ghost tiny btn-compact"
|
||||
disabled={zonesPage >= zonesTotalPages}
|
||||
onClick={goZonesNext}
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
28
frontend/src/constants.ts
Normal file
28
frontend/src/constants.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** 浏览器旧版账号存储键(迁移至 D1 后可删除) */
|
||||
export const LEGACY_STORAGE_ACCOUNTS = "domainflare_accounts_v1";
|
||||
export const STORAGE_APP_TOKEN = "domainflare_app_token";
|
||||
export const STORAGE_ACTIVE_ACCOUNT = "domainflare_active_account";
|
||||
|
||||
/** Cloudflare DNS 常用类型 */
|
||||
export const RECORD_TYPES = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"TXT",
|
||||
"NS",
|
||||
"SRV",
|
||||
"CAA",
|
||||
"PTR",
|
||||
"SSHFP",
|
||||
"TLSA",
|
||||
"URI",
|
||||
"CERT",
|
||||
"DNSKEY",
|
||||
"DS",
|
||||
"HTTPS",
|
||||
"SVCB",
|
||||
"NAPTR",
|
||||
"SMIMEA",
|
||||
"LOC",
|
||||
] as const;
|
||||
468
frontend/src/context/DomainflareContext.tsx
Normal file
468
frontend/src/context/DomainflareContext.tsx
Normal file
@@ -0,0 +1,468 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { CfAccount, CfZone, DnsRecord } from "../types";
|
||||
import {
|
||||
apiVerify,
|
||||
cfRequest,
|
||||
fetchAccountsApi,
|
||||
migrateLegacyAccountsIfNeeded,
|
||||
} from "../api/client";
|
||||
import { STORAGE_ACTIVE_ACCOUNT, STORAGE_APP_TOKEN } from "../constants";
|
||||
|
||||
export type ToastState = { text: string; isError: boolean } | null;
|
||||
|
||||
type DomainflareContextValue = {
|
||||
appToken: string;
|
||||
unlocked: boolean;
|
||||
sessionBootstrapDone: boolean;
|
||||
unlockWithToken: (token: string) => Promise<void>;
|
||||
toast: (msg: string, isError?: boolean) => void;
|
||||
toastState: ToastState;
|
||||
accounts: CfAccount[];
|
||||
activeAccountId: string | null;
|
||||
selectAccount: (id: string) => void;
|
||||
refreshAccounts: () => Promise<void>;
|
||||
addAccount: (name: string, token: string) => Promise<void>;
|
||||
deleteAccount: (account: CfAccount) => Promise<void>;
|
||||
zones: CfZone[];
|
||||
zonesFilter: string;
|
||||
setZonesFilter: (v: string) => void;
|
||||
zonesPage: number;
|
||||
zonesTotalPages: number;
|
||||
zonesPageInfo: string;
|
||||
goZonesPrev: () => void;
|
||||
goZonesNext: () => void;
|
||||
refreshZonesList: () => void;
|
||||
activeZoneId: string | null;
|
||||
selectZone: (z: CfZone) => void;
|
||||
filteredZones: CfZone[];
|
||||
recordsPage: number;
|
||||
recordsTotalPages: number;
|
||||
recordsPageInfo: string;
|
||||
goRecordsPrev: () => void;
|
||||
goRecordsNext: () => void;
|
||||
fltType: string;
|
||||
setFltType: (v: string) => void;
|
||||
fltName: string;
|
||||
setFltName: (v: string) => void;
|
||||
fltContent: string;
|
||||
setFltContent: (v: string) => void;
|
||||
recordsStatus: string;
|
||||
records: DnsRecord[];
|
||||
searchRecords: () => Promise<void>;
|
||||
cf: (path: string, init?: RequestInit) => Promise<unknown>;
|
||||
activeZone: CfZone | null;
|
||||
};
|
||||
|
||||
const DomainflareContext = createContext<DomainflareContextValue | null>(null);
|
||||
|
||||
export function useDomainflare(): DomainflareContextValue {
|
||||
const v = useContext(DomainflareContext);
|
||||
if (!v) throw new Error("useDomainflare 须在 Provider 内使用");
|
||||
return v;
|
||||
}
|
||||
|
||||
export function DomainflareProvider({ children }: { children: ReactNode }) {
|
||||
const [appToken, setAppToken] = useState("");
|
||||
const [unlocked, setUnlocked] = useState(false);
|
||||
const [sessionBootstrapDone, setSessionBootstrapDone] = useState(() => {
|
||||
try {
|
||||
const t =
|
||||
sessionStorage.getItem(STORAGE_APP_TOKEN) || localStorage.getItem(STORAGE_APP_TOKEN);
|
||||
return !t;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
const [toastState, setToastState] = useState<ToastState>(null);
|
||||
const [accounts, setAccounts] = useState<CfAccount[]>([]);
|
||||
const [activeAccountId, setActiveAccountId] = useState<string | null>(null);
|
||||
const [zones, setZones] = useState<CfZone[]>([]);
|
||||
const [zonesPage, setZonesPage] = useState(1);
|
||||
const [zonesTotalPages, setZonesTotalPages] = useState(1);
|
||||
const [zonesPageInfo, setZonesPageInfo] = useState("");
|
||||
const [zonesFilter, setZonesFilter] = useState("");
|
||||
const [activeZoneId, setActiveZoneId] = useState<string | null>(null);
|
||||
const [recordsPage, setRecordsPage] = useState(1);
|
||||
const [recordsTotalPages, setRecordsTotalPages] = useState(1);
|
||||
const [recordsPageInfo, setRecordsPageInfo] = useState("");
|
||||
const [perPage] = useState(50);
|
||||
const [fltType, setFltType] = useState("");
|
||||
const [fltName, setFltName] = useState("");
|
||||
const [fltContent, setFltContent] = useState("");
|
||||
const [recordsStatus, setRecordsStatus] = useState("");
|
||||
const [records, setRecords] = useState<DnsRecord[]>([]);
|
||||
|
||||
/** 解析记录筛选:仅用 ref,避免输入时触发自动请求(与原版「查询」按钮一致) */
|
||||
const recordFiltersRef = useRef({ fltType: "", fltName: "", fltContent: "" });
|
||||
useEffect(() => {
|
||||
recordFiltersRef.current = { fltType, fltName, fltContent };
|
||||
}, [fltType, fltName, fltContent]);
|
||||
|
||||
const toast = useCallback((msg: string, isError?: boolean) => {
|
||||
setToastState({ text: msg, isError: !!isError });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!toastState) return;
|
||||
const t = window.setTimeout(() => setToastState(null), 3200);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [toastState]);
|
||||
|
||||
const activeAccount = useMemo(
|
||||
() => accounts.find((a) => a.id === activeAccountId) ?? null,
|
||||
[accounts, activeAccountId],
|
||||
);
|
||||
|
||||
const cf = useCallback(
|
||||
async (path: string, init?: RequestInit) => {
|
||||
if (!activeAccount) throw new Error("未选择账号");
|
||||
return cfRequest(appToken, activeAccount.token, path, init);
|
||||
},
|
||||
[appToken, activeAccount],
|
||||
);
|
||||
|
||||
/** 从服务端同步账号列表;若有迁移 legacy 返回 true */
|
||||
const syncAccountsFromServer = useCallback(
|
||||
async (panelToken: string): Promise<boolean> => {
|
||||
let list = await fetchAccountsApi(panelToken);
|
||||
let migrated = false;
|
||||
if (!list.length) {
|
||||
migrated = await migrateLegacyAccountsIfNeeded(panelToken);
|
||||
list = await fetchAccountsApi(panelToken);
|
||||
}
|
||||
setAccounts(list);
|
||||
const saved = sessionStorage.getItem(STORAGE_ACTIVE_ACCOUNT);
|
||||
let nextActive: string | null = null;
|
||||
if (saved && list.some((a) => a.id === saved)) nextActive = saved;
|
||||
else nextActive = list[0]?.id ?? null;
|
||||
setActiveAccountId(nextActive);
|
||||
if (nextActive) sessionStorage.setItem(STORAGE_ACTIVE_ACCOUNT, nextActive);
|
||||
return migrated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const bootstrapAfterAuth = useCallback(
|
||||
async (panelToken: string) => {
|
||||
const migrated = await syncAccountsFromServer(panelToken);
|
||||
if (migrated) toast("已将浏览器中的旧账号迁入 D1");
|
||||
setZonesPage(1);
|
||||
setActiveZoneId(null);
|
||||
setRecords([]);
|
||||
setRecordsPage(1);
|
||||
setRecordsPageInfo("");
|
||||
setZonesPageInfo("");
|
||||
},
|
||||
[syncAccountsFromServer, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const t =
|
||||
sessionStorage.getItem(STORAGE_APP_TOKEN) || localStorage.getItem(STORAGE_APP_TOKEN);
|
||||
if (!t) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
if (!(await apiVerify(t))) return;
|
||||
setAppToken(t);
|
||||
setUnlocked(true);
|
||||
try {
|
||||
await bootstrapAfterAuth(t);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : String(e), true);
|
||||
}
|
||||
} finally {
|
||||
setSessionBootstrapDone(true);
|
||||
}
|
||||
})();
|
||||
}, [bootstrapAfterAuth, toast]);
|
||||
|
||||
const unlockWithToken = useCallback(
|
||||
async (token: string) => {
|
||||
setSessionBootstrapDone(false);
|
||||
try {
|
||||
if (!(await apiVerify(token))) {
|
||||
throw new Error("令牌不正确");
|
||||
}
|
||||
setAppToken(token);
|
||||
sessionStorage.setItem(STORAGE_APP_TOKEN, token);
|
||||
localStorage.setItem(STORAGE_APP_TOKEN, token);
|
||||
setUnlocked(true);
|
||||
await bootstrapAfterAuth(token);
|
||||
} finally {
|
||||
setSessionBootstrapDone(true);
|
||||
}
|
||||
},
|
||||
[bootstrapAfterAuth],
|
||||
);
|
||||
|
||||
const refreshAccounts = useCallback(async () => {
|
||||
const migrated = await syncAccountsFromServer(appToken);
|
||||
if (migrated) toast("已将浏览器中的旧账号迁入 D1");
|
||||
}, [appToken, syncAccountsFromServer, toast]);
|
||||
|
||||
const selectAccount = useCallback((id: string) => {
|
||||
setActiveAccountId(id);
|
||||
sessionStorage.setItem(STORAGE_ACTIVE_ACCOUNT, id);
|
||||
setZones([]);
|
||||
setZonesPageInfo("");
|
||||
setActiveZoneId(null);
|
||||
setRecords([]);
|
||||
setRecordsStatus("");
|
||||
setRecordsPageInfo("");
|
||||
setZonesPage(1);
|
||||
}, []);
|
||||
|
||||
const fetchZonesForPage = useCallback(
|
||||
async (page: number) => {
|
||||
if (!activeAccountId || !activeAccount) {
|
||||
setZones([]);
|
||||
setZonesPageInfo("");
|
||||
return;
|
||||
}
|
||||
setRecordsStatus("加载域名列表…");
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("page", String(page));
|
||||
qp.set("per_page", "50");
|
||||
qp.set("direction", "desc");
|
||||
const json = (await cfRequest(
|
||||
appToken,
|
||||
activeAccount.token,
|
||||
`/zones?${qp}`,
|
||||
)) as {
|
||||
result?: CfZone[];
|
||||
result_info?: { page?: number; total_pages?: number };
|
||||
};
|
||||
setZones(json.result ?? []);
|
||||
const zi = json.result_info;
|
||||
const totalP = Math.max(1, zi?.total_pages ?? 1);
|
||||
setZonesTotalPages(totalP);
|
||||
setZonesPage(page);
|
||||
setZonesPageInfo(zi ? `第 ${zi.page} / ${zi.total_pages} 页` : "");
|
||||
setRecordsStatus(activeZoneId ? "" : "请选择域名");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setRecordsStatus(msg);
|
||||
toast(msg, true);
|
||||
setZonesPageInfo("");
|
||||
}
|
||||
},
|
||||
[activeAccount, activeAccountId, activeZoneId, appToken, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!unlocked || !activeAccountId || !activeAccount) {
|
||||
setZones([]);
|
||||
return;
|
||||
}
|
||||
void fetchZonesForPage(zonesPage);
|
||||
}, [activeAccount, activeAccountId, fetchZonesForPage, unlocked, zonesPage]);
|
||||
|
||||
const fetchRecordsPage = useCallback(
|
||||
async (page: number) => {
|
||||
if (!activeZoneId || !activeAccount) return;
|
||||
const zid = activeZoneId;
|
||||
const { fltType: ft, fltName: fn, fltContent: fc } = recordFiltersRef.current;
|
||||
const qs = new URLSearchParams();
|
||||
qs.set("page", String(page));
|
||||
qs.set("per_page", String(perPage));
|
||||
if (ft) qs.set("type", ft);
|
||||
if (fn.trim()) qs.set("name", fn.trim());
|
||||
if (fc.trim()) qs.set("content", fc.trim());
|
||||
setRecordsStatus("加载解析记录…");
|
||||
try {
|
||||
const json = (await cfRequest(
|
||||
appToken,
|
||||
activeAccount.token,
|
||||
`/zones/${zid}/dns_records?${qs}`,
|
||||
)) as {
|
||||
result?: DnsRecord[];
|
||||
result_info?: { total_count?: number; page?: number; total_pages?: number };
|
||||
};
|
||||
const rows = json.result ?? [];
|
||||
const info = json.result_info;
|
||||
setRecords(rows);
|
||||
const tp = Math.max(1, info?.total_pages ?? 1);
|
||||
setRecordsTotalPages(tp);
|
||||
setRecordsPage(page);
|
||||
setRecordsPageInfo(info ? `第 ${info.page} / ${info.total_pages} 页` : "");
|
||||
setRecordsStatus(`共 ${info?.total_count ?? rows.length} 条`);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setRecordsStatus(msg);
|
||||
toast(msg, true);
|
||||
setRecordsPageInfo("");
|
||||
}
|
||||
},
|
||||
[activeAccount, activeZoneId, appToken, perPage, toast],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeZoneId || !activeAccount) return;
|
||||
void fetchRecordsPage(recordsPage);
|
||||
}, [activeAccount, activeZoneId, fetchRecordsPage, recordsPage]);
|
||||
|
||||
const filteredZones = useMemo(() => {
|
||||
const q = zonesFilter.trim().toLowerCase();
|
||||
return zones.filter((z) => !q || (z.name || "").toLowerCase().includes(q));
|
||||
}, [zones, zonesFilter]);
|
||||
|
||||
const activeZone = useMemo(
|
||||
() => zones.find((z) => z.id === activeZoneId) ?? null,
|
||||
[zones, activeZoneId],
|
||||
);
|
||||
|
||||
const selectZone = useCallback((z: CfZone) => {
|
||||
setActiveZoneId(z.id);
|
||||
setRecordsPage(1);
|
||||
setRecordsPageInfo("");
|
||||
}, []);
|
||||
|
||||
const addAccount = useCallback(
|
||||
async (name: string, token: string) => {
|
||||
const r = await fetch("/api/accounts", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-App-Token": appToken,
|
||||
},
|
||||
body: JSON.stringify({ name: name || "账号", token }),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
let msg = text;
|
||||
try {
|
||||
msg = (JSON.parse(text) as { errors?: { message: string }[] }).errors?.[0]?.message || text;
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
await refreshAccounts();
|
||||
await fetchZonesForPage(1);
|
||||
toast("已写入 D1");
|
||||
},
|
||||
[appToken, fetchZonesForPage, refreshAccounts, toast],
|
||||
);
|
||||
|
||||
const deleteAccount = useCallback(
|
||||
async (account: CfAccount) => {
|
||||
if (!confirm(`删除账号「${account.name}」?(将自数据库移除)`)) return;
|
||||
const r = await fetch(`/api/accounts/${encodeURIComponent(account.id)}`, {
|
||||
method: "DELETE",
|
||||
headers: { "X-App-Token": appToken },
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) {
|
||||
let msg = text;
|
||||
try {
|
||||
msg = (JSON.parse(text) as { errors?: { message: string }[] }).errors?.[0]?.message || text;
|
||||
} catch {
|
||||
/* keep */
|
||||
}
|
||||
throw new Error(msg);
|
||||
}
|
||||
await refreshAccounts();
|
||||
await fetchZonesForPage(zonesPage);
|
||||
toast("已从数据库删除");
|
||||
},
|
||||
[appToken, fetchZonesForPage, refreshAccounts, toast, zonesPage],
|
||||
);
|
||||
|
||||
const searchRecords = useCallback(async () => {
|
||||
await fetchRecordsPage(1);
|
||||
}, [fetchRecordsPage]);
|
||||
|
||||
const value = useMemo<DomainflareContextValue>(
|
||||
() => ({
|
||||
appToken,
|
||||
unlocked,
|
||||
sessionBootstrapDone,
|
||||
unlockWithToken,
|
||||
toast,
|
||||
toastState,
|
||||
accounts,
|
||||
activeAccountId,
|
||||
selectAccount,
|
||||
refreshAccounts,
|
||||
addAccount,
|
||||
deleteAccount,
|
||||
zones,
|
||||
zonesFilter,
|
||||
setZonesFilter,
|
||||
zonesPage,
|
||||
zonesTotalPages,
|
||||
zonesPageInfo,
|
||||
goZonesPrev: () => setZonesPage((p) => Math.max(1, p - 1)),
|
||||
goZonesNext: () => setZonesPage((p) => Math.min(zonesTotalPages, p + 1)),
|
||||
refreshZonesList: () => void fetchZonesForPage(1),
|
||||
activeZoneId,
|
||||
selectZone,
|
||||
filteredZones,
|
||||
recordsPage,
|
||||
recordsTotalPages,
|
||||
recordsPageInfo,
|
||||
goRecordsPrev: () => setRecordsPage((p) => Math.max(1, p - 1)),
|
||||
goRecordsNext: () =>
|
||||
setRecordsPage((p) => Math.min(recordsTotalPages, p + 1)),
|
||||
fltType,
|
||||
setFltType,
|
||||
fltName,
|
||||
setFltName,
|
||||
fltContent,
|
||||
setFltContent,
|
||||
recordsStatus,
|
||||
records,
|
||||
searchRecords,
|
||||
cf,
|
||||
activeZone,
|
||||
}),
|
||||
[
|
||||
accounts,
|
||||
activeAccountId,
|
||||
activeZone,
|
||||
activeZoneId,
|
||||
addAccount,
|
||||
appToken,
|
||||
cf,
|
||||
deleteAccount,
|
||||
filteredZones,
|
||||
fltContent,
|
||||
fltName,
|
||||
fltType,
|
||||
records,
|
||||
recordsPage,
|
||||
recordsPageInfo,
|
||||
recordsStatus,
|
||||
recordsTotalPages,
|
||||
refreshAccounts,
|
||||
searchRecords,
|
||||
selectAccount,
|
||||
selectZone,
|
||||
sessionBootstrapDone,
|
||||
toast,
|
||||
toastState,
|
||||
unlockWithToken,
|
||||
unlocked,
|
||||
zones,
|
||||
zonesFilter,
|
||||
zonesPage,
|
||||
zonesPageInfo,
|
||||
zonesTotalPages,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<DomainflareContext.Provider value={value}>{children}</DomainflareContext.Provider>
|
||||
);
|
||||
}
|
||||
1383
frontend/src/index.css
Normal file
1383
frontend/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
12
frontend/src/main.tsx
Normal file
12
frontend/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
document.getElementById("df-splash-host")?.remove();
|
||||
29
frontend/src/types.ts
Normal file
29
frontend/src/types.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** 与后端 /api/accounts 一致的账号结构 */
|
||||
export type CfAccount = {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at?: number;
|
||||
};
|
||||
|
||||
/** Cloudflare Zone 列表项(精简字段) */
|
||||
export type CfZone = {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
plan?: { name?: string };
|
||||
};
|
||||
|
||||
/** DNS 记录(表单与表格用到的字段) */
|
||||
export type DnsRecord = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
content?: string;
|
||||
ttl: number;
|
||||
proxiable?: boolean;
|
||||
proxied?: boolean;
|
||||
priority?: number;
|
||||
comment?: string;
|
||||
data?: Record<string, unknown>;
|
||||
};
|
||||
57
frontend/src/utils/dns.ts
Normal file
57
frontend/src/utils/dns.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { DnsRecord } from "../types";
|
||||
|
||||
export function recordContentPreview(rec: DnsRecord): string {
|
||||
if (rec.data && typeof rec.data === "object") {
|
||||
return JSON.stringify(rec.data);
|
||||
}
|
||||
return rec.content ?? "—";
|
||||
}
|
||||
|
||||
export function proxiedLabel(rec: DnsRecord): string {
|
||||
if (rec.proxiable) {
|
||||
return rec.proxied ? "已代理" : "仅 DNS";
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** DNS 类型 pill 的配色类名(如 pill-dns-CNAME) */
|
||||
export function dnsRecordTypeClass(type: string): string {
|
||||
const key = type.toUpperCase().replace(/[^A-Z0-9]/g, "") || "UNKNOWN";
|
||||
return `pill-dns-${key}`;
|
||||
}
|
||||
|
||||
function isIpv4(s: string): boolean {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s.trim());
|
||||
if (!m) return false;
|
||||
return [m[1], m[2], m[3], m[4]].every((oct) => {
|
||||
const n = Number(oct);
|
||||
return n >= 0 && n <= 255 && oct === String(n);
|
||||
});
|
||||
}
|
||||
|
||||
function looksLikeIpv6(s: string): boolean {
|
||||
const t = s.trim();
|
||||
if (!t.includes(":") || /\s/.test(t)) return false;
|
||||
return /^[0-9a-fA-F:.]+$/.test(t) && t.split(":").length >= 2;
|
||||
}
|
||||
|
||||
/** 记录内容展示用的语义色(IP / 域名 / JSON 等) */
|
||||
export function recordContentToneClass(rec: DnsRecord): string {
|
||||
if (rec.data && typeof rec.data === "object") return "record-content--json";
|
||||
const c = (rec.content ?? "").trim();
|
||||
if (!c) return "record-content--empty";
|
||||
if (isIpv4(c)) return "record-content--ipv4";
|
||||
if (looksLikeIpv6(c)) return "record-content--ipv6";
|
||||
return "record-content--host";
|
||||
}
|
||||
|
||||
export function proxiedBadgeClass(rec: DnsRecord): string {
|
||||
if (rec.proxiable) {
|
||||
return rec.proxied ? "proxy-badge proxy-on" : "proxy-badge proxy-off";
|
||||
}
|
||||
return "proxy-badge proxy-na";
|
||||
}
|
||||
|
||||
export function ttlTagClass(rec: DnsRecord): string {
|
||||
return rec.ttl === 1 ? "ttl-tag ttl-auto" : "ttl-tag ttl-num";
|
||||
}
|
||||
8
frontend/src/utils/html.ts
Normal file
8
frontend/src/utils/html.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** HTML 转义(表格展示) */
|
||||
export function escapeHtml(s: string): string {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
3
frontend/src/vite-env.d.ts
vendored
Normal file
3
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
/// <reference types="vite-plugin-pwa/react" />
|
||||
21
frontend/tsconfig.app.json
Normal file
21
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
4
frontend/tsconfig.json
Normal file
4
frontend/tsconfig.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
19
frontend/tsconfig.node.json
Normal file
19
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
96
frontend/vite.config.ts
Normal file
96
frontend/vite.config.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { VitePWA } from "vite-plugin-pwa";
|
||||
|
||||
// 本地开发:API 由 wrangler dev(默认 8787)提供
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: "prompt",
|
||||
includeAssets: ["favicon.ico", "logo.png", "logo192.png", "logo512.png"],
|
||||
manifest: {
|
||||
id: "/",
|
||||
name: "Domainflare",
|
||||
short_name: "Domainflare",
|
||||
description: "Cloudflare 域名与 DNS 记录管理面板",
|
||||
lang: "zh-CN",
|
||||
dir: "ltr",
|
||||
start_url: "/",
|
||||
scope: "/",
|
||||
display: "standalone",
|
||||
orientation: "any",
|
||||
theme_color: "#2f6feb",
|
||||
background_color: "#fffcf5",
|
||||
categories: ["utilities", "productivity"],
|
||||
prefer_related_applications: false,
|
||||
icons: [
|
||||
{
|
||||
src: "/logo192.png",
|
||||
sizes: "192x192",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "any",
|
||||
},
|
||||
{
|
||||
src: "/logo512.png",
|
||||
sizes: "512x512",
|
||||
type: "image/png",
|
||||
purpose: "maskable",
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
skipWaiting: false,
|
||||
clientsClaim: true,
|
||||
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
|
||||
globPatterns: ["**/*.{js,css,html,ico,png,svg,woff2}"],
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api\b/],
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\//,
|
||||
handler: "NetworkOnly",
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/,
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "google-fonts-stylesheets",
|
||||
expiration: { maxEntries: 10, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||
},
|
||||
},
|
||||
{
|
||||
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/,
|
||||
handler: "StaleWhileRevalidate",
|
||||
options: {
|
||||
cacheName: "google-fonts-webfonts",
|
||||
expiration: { maxEntries: 30, maxAgeSeconds: 60 * 60 * 24 * 365 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
9
migrations/0001_init.sql
Normal file
9
migrations/0001_init.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Cloudflare 账号(API Token)持久化
|
||||
CREATE TABLE IF NOT EXISTS cf_accounts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
token TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cf_accounts_created ON cf_accounts (created_at);
|
||||
1894
package-lock.json
generated
Normal file
1894
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "domainflare",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently -k \"npm run dev:worker\" \"npm run dev:frontend\"",
|
||||
"dev:worker": "wrangler dev",
|
||||
"dev:frontend": "npm --prefix frontend run dev",
|
||||
"build:frontend": "npm --prefix frontend run build",
|
||||
"deploy": "npm run build:frontend && wrangler deploy",
|
||||
"types": "wrangler types",
|
||||
"db:migrate:local": "wrangler d1 migrations apply domainflare --local",
|
||||
"db:migrate:remote": "wrangler d1 migrations apply domainflare --remote"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20241127.0",
|
||||
"concurrently": "^9.1.2",
|
||||
"typescript": "^5.7.2",
|
||||
"wrangler": "^4.87.0"
|
||||
}
|
||||
}
|
||||
33
src/app.ts
Normal file
33
src/app.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import type { Env } from "./types/env";
|
||||
import { registerMetaRoutes } from "./routes/meta";
|
||||
import { registerAccountRoutes } from "./routes/accounts";
|
||||
import { registerCfProxyRoutes } from "./routes/cf-proxy";
|
||||
|
||||
/** 组装 Hono:仅注册 /api;静态资源由 Workers Assets + SPA 配置处理 */
|
||||
export function createApp(): Hono<{ Bindings: Env }> {
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
app.use(
|
||||
"/api/*",
|
||||
cors({
|
||||
origin: "*",
|
||||
allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
allowHeaders: [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
"X-App-Token",
|
||||
"X-Requested-With",
|
||||
],
|
||||
exposeHeaders: ["Content-Type"],
|
||||
maxAge: 86400,
|
||||
}),
|
||||
);
|
||||
|
||||
registerMetaRoutes(app);
|
||||
registerAccountRoutes(app);
|
||||
registerCfProxyRoutes(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
4
src/index.ts
Normal file
4
src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { createApp } from "./app";
|
||||
|
||||
/** Worker 入口:仅导出 API 应用(不含前端打包内容) */
|
||||
export default createApp();
|
||||
34
src/lib/auth.ts
Normal file
34
src/lib/auth.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { Context } from "hono";
|
||||
import type { Env } from "../types/env";
|
||||
import { jsonError } from "./http";
|
||||
|
||||
type PanelCtx = Context<{ Bindings: Env }>;
|
||||
|
||||
/** 管理类接口:X-App-Token 或 Authorization: Bearer <面板令牌> */
|
||||
export function resolvePanelToken(c: PanelCtx): string {
|
||||
const x = (c.req.header("X-App-Token") ?? "").trim();
|
||||
if (x) return x;
|
||||
const auth = c.req.header("Authorization") ?? "";
|
||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
||||
return m ? m[1].trim() : "";
|
||||
}
|
||||
|
||||
export function requirePanelForManagement(c: PanelCtx): Response | null {
|
||||
const t = resolvePanelToken(c);
|
||||
if (t !== c.env.APP_ACCESS_TOKEN) {
|
||||
return jsonError(401, "Invalid app access token");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Cloudflare 代理:面板令牌只能用 X-App-Token(避免与 CF 的 Bearer 冲突) */
|
||||
export function requirePanelForCfProxy(c: PanelCtx): Response | null {
|
||||
const t = (c.req.header("X-App-Token") ?? "").trim();
|
||||
if (t !== c.env.APP_ACCESS_TOKEN) {
|
||||
return jsonError(
|
||||
401,
|
||||
"Invalid app access token (use header X-App-Token for /api/cf)",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
2
src/lib/constants.ts
Normal file
2
src/lib/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const SERVICE = "domainflare";
|
||||
export const API_VERSION = "1.0.0";
|
||||
7
src/lib/http.ts
Normal file
7
src/lib/http.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** JSON 错误响应(与既有前端契约一致) */
|
||||
export function jsonError(status: number, message: string): Response {
|
||||
return new Response(JSON.stringify({ success: false, errors: [{ message }] }), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
88
src/routes/accounts.ts
Normal file
88
src/routes/accounts.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { Env } from "../types/env";
|
||||
import type { CfAccountRow } from "../types/account";
|
||||
import { jsonError } from "../lib/http";
|
||||
import { requirePanelForManagement } from "../lib/auth";
|
||||
|
||||
/** Cloudflare 账号 CRUD(D1) */
|
||||
export function registerAccountRoutes(app: Hono<{ Bindings: Env }>): void {
|
||||
app.get("/api/accounts", async (c) => {
|
||||
const denied = requirePanelForManagement(c);
|
||||
if (denied) return denied;
|
||||
const res = await c.env.DB.prepare(
|
||||
"SELECT id, name, token, created_at FROM cf_accounts ORDER BY created_at ASC",
|
||||
).all<CfAccountRow>();
|
||||
return c.json({ accounts: res.results ?? [] });
|
||||
});
|
||||
|
||||
app.post("/api/accounts", async (c) => {
|
||||
const denied = requirePanelForManagement(c);
|
||||
if (denied) return denied;
|
||||
let body: { name?: string; token?: string };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return jsonError(400, "Invalid JSON body");
|
||||
}
|
||||
const token = String(body.token ?? "").trim();
|
||||
if (!token) return jsonError(400, "token is required");
|
||||
const name = String(body.name ?? "").trim() || "账号";
|
||||
const id = crypto.randomUUID();
|
||||
await c.env.DB.prepare(
|
||||
"INSERT INTO cf_accounts (id, name, token) VALUES (?1, ?2, ?3)",
|
||||
)
|
||||
.bind(id, name, token)
|
||||
.run();
|
||||
const row = await c.env.DB.prepare(
|
||||
"SELECT id, name, token, created_at FROM cf_accounts WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.first<CfAccountRow>();
|
||||
return c.json({ account: row }, 201);
|
||||
});
|
||||
|
||||
app.patch("/api/accounts/:id", async (c) => {
|
||||
const denied = requirePanelForManagement(c);
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
const existing = await c.env.DB.prepare(
|
||||
"SELECT id, name, token FROM cf_accounts WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.first<{ id: string; name: string; token: string }>();
|
||||
if (!existing) return jsonError(404, "Account not found");
|
||||
let body: { name?: string; token?: string };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return jsonError(400, "Invalid JSON body");
|
||||
}
|
||||
const name =
|
||||
body.name !== undefined ? String(body.name).trim() || "账号" : existing.name;
|
||||
const token =
|
||||
body.token !== undefined ? String(body.token).trim() : existing.token;
|
||||
if (!token) return jsonError(400, "token cannot be empty");
|
||||
await c.env.DB.prepare(
|
||||
"UPDATE cf_accounts SET name = ?1, token = ?2 WHERE id = ?3",
|
||||
)
|
||||
.bind(name, token, id)
|
||||
.run();
|
||||
const row = await c.env.DB.prepare(
|
||||
"SELECT id, name, token, created_at FROM cf_accounts WHERE id = ?1",
|
||||
)
|
||||
.bind(id)
|
||||
.first<CfAccountRow>();
|
||||
return c.json({ account: row });
|
||||
});
|
||||
|
||||
app.delete("/api/accounts/:id", async (c) => {
|
||||
const denied = requirePanelForManagement(c);
|
||||
if (denied) return denied;
|
||||
const id = c.req.param("id");
|
||||
const r = await c.env.DB.prepare("DELETE FROM cf_accounts WHERE id = ?1")
|
||||
.bind(id)
|
||||
.run();
|
||||
if (r.meta.changes === 0) return jsonError(404, "Account not found");
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
40
src/routes/cf-proxy.ts
Normal file
40
src/routes/cf-proxy.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { Env } from "../types/env";
|
||||
import { jsonError } from "../lib/http";
|
||||
import { requirePanelForCfProxy } from "../lib/auth";
|
||||
|
||||
/** 透传 Cloudflare API v4 */
|
||||
export function registerCfProxyRoutes(app: Hono<{ Bindings: Env }>): void {
|
||||
app.all("/api/cf/*", async (c) => {
|
||||
const denied = requirePanelForCfProxy(c);
|
||||
if (denied) return denied;
|
||||
const auth = c.req.header("Authorization") ?? "";
|
||||
if (!auth.startsWith("Bearer ") || auth.length < 12) {
|
||||
return jsonError(
|
||||
400,
|
||||
"Missing or invalid Cloudflare API token (Authorization: Bearer ...)",
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL(c.req.url);
|
||||
const rest = url.pathname.replace(/^\/api\/cf/, "") || "/";
|
||||
const cfUrl = `https://api.cloudflare.com/client/v4${rest}${url.search}`;
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Authorization", auth);
|
||||
const ct = c.req.header("Content-Type");
|
||||
if (ct) headers.set("Content-Type", ct);
|
||||
|
||||
const method = c.req.method;
|
||||
let body: BodyInit | undefined;
|
||||
if (!["GET", "HEAD"].includes(method)) {
|
||||
body = await c.req.arrayBuffer();
|
||||
}
|
||||
|
||||
const res = await fetch(cfUrl, { method, headers, body });
|
||||
const out = new Response(res.body, { status: res.status });
|
||||
const outCt = res.headers.get("Content-Type");
|
||||
if (outCt) out.headers.set("Content-Type", outCt);
|
||||
return out;
|
||||
});
|
||||
}
|
||||
96
src/routes/meta.ts
Normal file
96
src/routes/meta.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Hono } from "hono";
|
||||
import type { Env } from "../types/env";
|
||||
import { SERVICE, API_VERSION } from "../lib/constants";
|
||||
import { requirePanelForManagement } from "../lib/auth";
|
||||
|
||||
/** API 目录、健康检查、令牌校验 */
|
||||
export function registerMetaRoutes(app: Hono<{ Bindings: Env }>): void {
|
||||
app.get("/api", (c) => {
|
||||
const url = new URL(c.req.url);
|
||||
const origin = url.origin;
|
||||
return c.json({
|
||||
service: SERVICE,
|
||||
version: API_VERSION,
|
||||
docs: {
|
||||
repository_file: "domainflare API调用文档.md",
|
||||
note: "面板 HTTP API 说明见仓库内该文档;部署根路径即下方 base。",
|
||||
},
|
||||
base: `${origin}/api`,
|
||||
health: `${origin}/api/health`,
|
||||
auth: {
|
||||
management:
|
||||
"管理接口(账号、校验、及非 cf 代理)可使用 X-App-Token 或 Authorization: Bearer <APP_ACCESS_TOKEN>",
|
||||
cloudflare_proxy:
|
||||
"/api/cf/* 须同时带 X-App-Token: <APP_ACCESS_TOKEN> 与 Authorization: Bearer <Cloudflare API Token>",
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/health",
|
||||
auth: false,
|
||||
description: "健康检查,用于探活",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api",
|
||||
auth: false,
|
||||
description: "返回本 API 目录(当前 JSON)",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/verify",
|
||||
auth: true,
|
||||
description: "校验面板访问令牌是否有效",
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/accounts",
|
||||
auth: true,
|
||||
description: "列出 D1 中保存的 Cloudflare 账号(响应含 api token)",
|
||||
},
|
||||
{
|
||||
method: "POST",
|
||||
path: "/api/accounts",
|
||||
auth: true,
|
||||
body: { name: "string 可选", token: "string 必填 Cloudflare API Token" },
|
||||
description: "新增账号",
|
||||
},
|
||||
{
|
||||
method: "PATCH",
|
||||
path: "/api/accounts/:id",
|
||||
auth: true,
|
||||
body: { name: "string 可选", token: "string 可选" },
|
||||
description: "更新账号名称或 Token",
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
path: "/api/accounts/:id",
|
||||
auth: true,
|
||||
description: "删除账号",
|
||||
},
|
||||
{
|
||||
method: "*",
|
||||
path: "/api/cf/…",
|
||||
auth: "X-App-Token + Authorization: Bearer <CF_TOKEN>",
|
||||
description:
|
||||
"透传至 https://api.cloudflare.com/client/v4/…,方法与正文原样转发",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/health", (c) =>
|
||||
c.json({
|
||||
ok: true,
|
||||
service: SERVICE,
|
||||
version: API_VERSION,
|
||||
time: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/api/verify", (c) => {
|
||||
const denied = requirePanelForManagement(c);
|
||||
if (denied) return denied;
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
}
|
||||
7
src/types/account.ts
Normal file
7
src/types/account.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** D1 cf_accounts 表行 */
|
||||
export type CfAccountRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at: number;
|
||||
};
|
||||
5
src/types/env.ts
Normal file
5
src/types/env.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/** Worker 绑定(脚本仅处理 API,静态资源由平台根据 wrangler.toml 下发) */
|
||||
export type Env = {
|
||||
APP_ACCESS_TOKEN: string;
|
||||
DB: D1Database;
|
||||
};
|
||||
11
tsconfig.json
Normal file
11
tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["@cloudflare/workers-types"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
19
wrangler.toml
Normal file
19
wrangler.toml
Normal file
@@ -0,0 +1,19 @@
|
||||
name = "domainflare"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2026-04-05"
|
||||
|
||||
# 应用入口令牌;生产环境建议改为 wrangler secret put APP_ACCESS_TOKEN
|
||||
[vars]
|
||||
APP_ACCESS_TOKEN = "shumengya"
|
||||
|
||||
[assets]
|
||||
directory = "./frontend/dist"
|
||||
binding = "ASSETS"
|
||||
not_found_handling = "single-page-application"
|
||||
run_worker_first = [ "/api", "/api/*" ]
|
||||
|
||||
[[d1_databases]]
|
||||
binding = "DB"
|
||||
database_name = "domainflare"
|
||||
database_id = "3b639703-aba7-4959-9aa9-6bb931a1ba83"
|
||||
migrations_dir = "migrations"
|
||||
Reference in New Issue
Block a user