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
@@ -0,0 +1,134 @@
/**
* Webhook 配置表单(域名级/邮箱级共用;全局版本在设置模态窗口内)。
* key 语义:留空保留现有;填空格清除;非空设置新 key。
*/
import { useEffect, useState } from "react";
import { Button, Form, Input, Space, App } from "antd";
import { putDomainWebhook, putMailboxWebhook } from "../api.ts";
/** # 成员变量: 表单当前值(由父组件传入) **/
export interface WebhookValue {
url: string | null;
headers: string | null;
hasKey: boolean;
}
/** # 函数
* 解析 headers JSON 串为对象(失败返回 {})
*/
export function parseHeaders(s: string | null): Record<string, string> {
if (!s) return {};
try {
const v = JSON.parse(s);
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, string>) : {};
} catch {
return {};
}
}
/** # 函数
* Webhook 配置表单组件
* 参数: scope - "domain" | "mailbox";targetId - 域名/邮箱 ID;value - 当前配置;onSaved - 保存后回调
*/
export function WebhookForm({
scope,
targetId,
value,
onSaved,
}: {
scope: "domain" | "mailbox";
targetId: string;
value: WebhookValue;
onSaved: () => void;
}) {
const { message } = App.useApp();
const [form] = Form.useForm<{ url: string; headers: string; key?: string }>();
const [saving, setSaving] = useState(false);
useEffect(() => {
form.setFieldsValue({
url: value.url ?? "",
headers: value.url ? JSON.stringify(parseHeaders(value.headers), null, 2) : "",
});
}, [value, form, targetId]);
const save = async () => {
const values = await form.validateFields();
let headers: Record<string, string> = {};
if (values.headers.trim()) {
try {
headers = JSON.parse(values.headers) as Record<string, string>;
} catch {
message.error("自定义请求头必须是合法 JSON 对象");
return;
}
}
const key = values.key ?? undefined;
// "填空格清除 key":把单个空格翻译成空串
const normalizedKey = key !== undefined && key.trim() === "" && key.length > 0 ? "" : key;
setSaving(true);
try {
const body = { url: values.url.trim(), headers, key: normalizedKey };
if (scope === "domain") await putDomainWebhook(targetId, body);
else await putMailboxWebhook(targetId, body);
message.success("Webhook 已保存");
form.setFieldValue("key", undefined);
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
const clear = async () => {
setSaving(true);
try {
if (scope === "domain") await putDomainWebhook(targetId, { url: "" });
else await putMailboxWebhook(targetId, { url: "" });
message.success("已清除该级 Webhook(将回退到上一级配置)");
form.resetFields();
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "清除失败");
} finally {
setSaving(false);
}
};
return (
<Form form={form} layout="vertical">
<Form.Item name="url" label="Webhook URL" extra="留空并保存 = 清除该级配置">
<Input placeholder="https://hooks.example.com/wmail" allowClear />
</Form.Item>
<Form.Item
name="headers"
label="自定义请求头(JSON 对象,可选)"
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>'
>
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
</Form.Item>
<Form.Item
name="key"
label="Key"
extra={
value.hasKey
? "已设置 key(****尾号见保存后提示);留空保留,填空格清除"
: "可选;默认以 Authorization: Bearer <key> 携带,并用于投递签名"
}
>
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
</Form.Item>
<Space>
<Button type="primary" loading={saving} onClick={() => void save()}>
保存
</Button>
{value.url && (
<Button danger onClick={() => void clear()}>
清除该级配置
</Button>
)}
</Space>
</Form>
);
}