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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user