- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
243 lines
8.8 KiB
TypeScript
243 lines
8.8 KiB
TypeScript
/**
|
|
* 域名页(v1.1.0):域名由 Cloudflare OAuth 自动同步(无手工添加);
|
|
* 点击域名配置 Email Workers(Worker + Email Routing + catch-all)与域名级 Webhook;
|
|
* 支持搜索与按账号筛选。
|
|
*/
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
|
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
|
import {
|
|
deleteDomain,
|
|
listDomainAccounts,
|
|
listDomains,
|
|
setupDomain,
|
|
type DomainRow,
|
|
} from "../api.ts";
|
|
import { WebhookForm } from "../components/WebhookForm.tsx";
|
|
|
|
/** # 成员变量: 域名状态 -> Tag **/
|
|
function StatusTag({ row }: { row: DomainRow }) {
|
|
if (row.status === "active") return <Tag color="success">已配置 Email Workers</Tag>;
|
|
if (row.status === "error")
|
|
return (
|
|
<Tooltip title={row.setup_error ?? "配置失败"}>
|
|
<Tag color="error">error</Tag>
|
|
</Tooltip>
|
|
);
|
|
return <Tag color="default">pending</Tag>;
|
|
}
|
|
|
|
/** # 函数
|
|
* 域名页组件
|
|
*/
|
|
export function DomainsPage() {
|
|
const { message, modal } = App.useApp();
|
|
const [items, setItems] = useState<DomainRow[]>([]);
|
|
const [accountOptions, setAccountOptions] = useState<{ id: string; name: string }[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [q, setQ] = useState("");
|
|
const [accountId, setAccountId] = useState<string | undefined>();
|
|
const [editing, setEditing] = useState<DomainRow | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [d, a] = await Promise.all([listDomains(), listDomainAccounts()]);
|
|
setItems(d.items);
|
|
setAccountOptions(a.items);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const filtered = useMemo(
|
|
() =>
|
|
items.filter(
|
|
(row) =>
|
|
(!accountId || row.node_id === accountId) &&
|
|
row.name.toLowerCase().includes(q.trim().toLowerCase()),
|
|
),
|
|
[items, q, accountId],
|
|
);
|
|
|
|
/** 配置 Email Workers */
|
|
const runSetup = async (row: DomainRow) => {
|
|
try {
|
|
const res = await setupDomain(row.id);
|
|
if (res.ok) message.success(`已为 ${row.name} 部署 Email Worker 并开启 Email Routing`);
|
|
else message.error(res.error ?? "配置失败");
|
|
void load();
|
|
setEditing((cur) => (cur?.id === row.id ? { ...cur, status: res.ok ? "active" : "error" } : cur));
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "配置失败");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div>
|
|
<div className="page-toolbar">
|
|
<div className="filters">
|
|
<Input
|
|
allowClear
|
|
prefix={<SearchOutlined />}
|
|
placeholder="搜索域名"
|
|
style={{ width: 240 }}
|
|
onChange={(e) => setQ(e.target.value)}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
placeholder="按账号筛选"
|
|
style={{ width: 200 }}
|
|
value={accountId}
|
|
onChange={setAccountId}
|
|
options={accountOptions.map((a) => ({ value: a.id, label: a.name }))}
|
|
/>
|
|
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
|
|
</div>
|
|
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
|
</div>
|
|
|
|
<Card styles={{ body: { padding: 0 } }}>
|
|
<Table
|
|
rowKey="id"
|
|
loading={loading}
|
|
dataSource={filtered}
|
|
pagination={false}
|
|
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
|
|
columns={[
|
|
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
|
{ title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
|
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
|
|
{
|
|
title: "Email Worker",
|
|
dataIndex: "worker_id",
|
|
width: 210,
|
|
ellipsis: true,
|
|
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未配置</span>),
|
|
},
|
|
{
|
|
title: "Webhook",
|
|
width: 95,
|
|
render: (_: unknown, row: DomainRow) =>
|
|
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
|
|
},
|
|
{ title: "邮箱数", dataIndex: "mailbox_count", width: 80 },
|
|
{
|
|
title: "操作",
|
|
width: 200,
|
|
render: (_: unknown, row: DomainRow) => (
|
|
<Space onClick={(e) => e.stopPropagation()}>
|
|
{row.status !== "active" ? (
|
|
<Button size="small" type="primary" onClick={() => void runSetup(row)}>
|
|
设置 Email Workers
|
|
</Button>
|
|
) : (
|
|
<Button size="small" onClick={() => void runSetup(row)}>
|
|
重新配置
|
|
</Button>
|
|
)}
|
|
<Button
|
|
size="small"
|
|
danger
|
|
onClick={() =>
|
|
modal.confirm({
|
|
title: `删除域名 ${row.name}?`,
|
|
content: "仅在域名无邮箱时可删;删除后重新 OAuth 授权会再次同步。",
|
|
onOk: async () => {
|
|
try {
|
|
await deleteDomain(row.id);
|
|
message.success("已删除");
|
|
void load();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "删除失败");
|
|
}
|
|
},
|
|
})
|
|
}
|
|
>
|
|
删除
|
|
</Button>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</Card>
|
|
|
|
<Drawer
|
|
title={
|
|
editing ? (
|
|
<span>
|
|
<span className="mono">{editing.name}</span>
|
|
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
|
|
{editing.node_name ?? "-"}
|
|
</Typography.Text>
|
|
</span>
|
|
) : (
|
|
"域名"
|
|
)
|
|
}
|
|
size="large"
|
|
open={Boolean(editing)}
|
|
onClose={() => setEditing(null)}
|
|
>
|
|
{editing && (
|
|
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
|
<Card
|
|
size="small"
|
|
title="Email Workers"
|
|
extra={
|
|
editing.status !== "active" && (
|
|
<Button size="small" type="primary" onClick={() => void runSetup(editing)}>
|
|
设置 Email Workers
|
|
</Button>
|
|
)
|
|
}
|
|
>
|
|
<Space orientation="vertical" style={{ width: "100%" }}>
|
|
<StatusTag row={editing} />
|
|
<Typography.Text type="secondary">
|
|
部署账号级 Email Worker(接收 Cloudflare 邮件事件),开启该域名的 Email Routing 并把 catch-all
|
|
规则指向 Worker;邮件经由 Node 转发回 Console。
|
|
</Typography.Text>
|
|
{editing.status === "active" && editing.worker_id && (
|
|
<Typography.Text className="mono">
|
|
Worker:{editing.worker_id} · Zone:{editing.zone_id}
|
|
</Typography.Text>
|
|
)}
|
|
{editing.status === "error" && editing.setup_error && (
|
|
<Typography.Text type="danger">{editing.setup_error}</Typography.Text>
|
|
)}
|
|
</Space>
|
|
</Card>
|
|
|
|
<Card size="small" title="域名级 Webhook">
|
|
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 12 }}>
|
|
该域名下邮件未命中邮箱级 Webhook 时使用;再未配置则回退全局 Webhook(设置中配置)。
|
|
</Typography.Text>
|
|
<WebhookForm
|
|
scope="domain"
|
|
targetId={editing.id}
|
|
value={{
|
|
url: editing.webhook_url,
|
|
headers: editing.webhook_headers,
|
|
hasKey: Boolean(editing.webhook_has_key),
|
|
}}
|
|
onSaved={async () => {
|
|
const fresh = (await listDomains()).items.find((d) => d.id === editing.id) ?? null;
|
|
setEditing(fresh);
|
|
void load();
|
|
}}
|
|
/>
|
|
</Card>
|
|
</Space>
|
|
)}
|
|
</Drawer>
|
|
</div>
|
|
);
|
|
}
|