chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:24 +08:00
commit d7ef0c3549
49 changed files with 12762 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
.wrangler/
dist/
frontend/dist/
*.log
.dev.vars

131
README.md Normal file
View 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 服务条款。

View 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": ""
}
]
}

View 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
View 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="DomainflareCloudflare 域名与 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

File diff suppressed because it is too large Load Diff

23
frontend/package.json Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

35
frontend/src/App.tsx Normal file
View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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>tagissue / 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>TTL1 = </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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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(`DNSSECCloudflare 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);
}
}

View 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>
);
}

View 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
View 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;

View 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

File diff suppressed because it is too large Load Diff

12
frontend/src/main.tsx Normal file
View 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
View 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
View 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";
}

View File

@@ -0,0 +1,8 @@
/** HTML 转义(表格展示) */
export function escapeHtml(s: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

3
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="vite-plugin-pwa/react" />

View 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
View File

@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
package.json Normal file
View 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
View 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
View File

@@ -0,0 +1,4 @@
import { createApp } from "./app";
/** Worker 入口:仅导出 API 应用(不含前端打包内容) */
export default createApp();

34
src/lib/auth.ts Normal file
View 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
View File

@@ -0,0 +1,2 @@
export const SERVICE = "domainflare";
export const API_VERSION = "1.0.0";

7
src/lib/http.ts Normal file
View 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
View 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 账号 CRUDD1 */
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
View 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
View 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
View 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
View File

@@ -0,0 +1,5 @@
/** Worker 绑定(脚本仅处理 API静态资源由平台根据 wrangler.toml 下发) */
export type Env = {
APP_ACCESS_TOKEN: string;
DB: D1Database;
};

11
tsconfig.json Normal file
View 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
View 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"