feat: v1.1.0 账号化重构 + 三级 Webhook + 设置中心 + Vite 工具链

- 账号化:节点更名账号,/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
This commit is contained in:
bigcode
2026-09-30 11:37:38 +08:00
parent d4c1334143
commit 421cfb2f04
47 changed files with 2316 additions and 1431 deletions
+196 -70
View File
@@ -1,8 +1,31 @@
/** 域名页 */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Form, Input, Modal, Space, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.ts";
/**
* 域名页(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>;
}
/** # 函数
* 域名页组件
@@ -10,14 +33,18 @@ import { createDomain, deleteDomain, listDomains, type DomainRow } from "../api.
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 [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [q, setQ] = useState("");
const [accountId, setAccountId] = useState<string | undefined>();
const [editing, setEditing] = useState<DomainRow | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listDomains()).items);
const [d, a] = await Promise.all([listDomains(), listDomainAccounts()]);
setItems(d.items);
setAccountOptions(a.items);
} finally {
setLoading(false);
}
@@ -27,90 +54,189 @@ export function DomainsPage() {
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">
<span className="muted">OAuth 安装流程会自动创建域名;此处也可手工登记。</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加域名
</Button>
</Space>
<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={items}
dataSource={filtered}
pagination={false}
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
columns={[
{ title: "域名", dataIndex: "name" },
{ 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: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
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: "节点", dataIndex: "node_name", width: 160, render: (v: string | null) => v ?? "-" },
{ title: "Worker", dataIndex: "worker_id", width: 220, render: (v: string | null) => v ?? "-" },
{ title: "邮箱数", dataIndex: "mailbox_count", width: 90 },
{
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: 90,
width: 200,
render: (_: unknown, row: DomainRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除域名 ${row.name}?`,
onOk: async () => {
try {
await deleteDomain(row.id);
message.success("已删除");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
}
},
})
}
>
删除
</Button>
<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>
<Modal
title="添加域名"
open={open}
onCancel={() => setOpen(false)}
onOk={async () => {
const values = await form.validateFields();
try {
await createDomain(values.name);
message.success("已添加");
setOpen(false);
form.resetFields();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "添加失败");
}
}}
okText="添加"
cancelText="取消"
destroyOnHidden
<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)}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="域名" rules={[{ required: true, message: "请输入域名" }]}>
<Input placeholder="example.com" />
</Form.Item>
</Form>
</Modal>
{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>
);
}