chore: sync local changes to Gitea
This commit is contained in:
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" />
|
||||
Reference in New Issue
Block a user