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

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" />