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
+1 -2
View File
@@ -6,11 +6,10 @@
<meta name="description" content="WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="stylesheet" href="/assets/main.css" />
<title>WmailHub</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/assets/main.js"></script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="WmailHub icon">
<rect x="2" y="2" width="60" height="60" rx="15" fill="#2f54eb"/>
<path d="M16 39a9 9 0 0 1-1-18 17 17 0 0 1 32-1 10 10 0 0 1 1 19H16Z" fill="#ccdcff"/>
<rect x="12" y="28" width="40" height="27" rx="5" fill="#fff"/>
<path d="m14 32 16 12a3.5 3.5 0 0 0 4 0l16-12" fill="none" stroke="#2f54eb" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m14 52 12-10m24 10L38 42" fill="none" stroke="#9bb4ff" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 606 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="WmailHub logo">
<defs>
<linearGradient id="background" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#5578ff"/>
<stop offset="55%" style="stop-color:#2f54eb"/>
<stop offset="100%" style="stop-color:#1938bd"/>
</linearGradient>
</defs>
<rect x="24" y="24" width="464" height="464" rx="108" fill="#2f54eb"/>
<rect x="24" y="24" width="464" height="464" rx="108" fill="url(#background)" opacity=".2"/>
<path d="M132 310a68 68 0 0 1-4-136 132 132 0 0 1 248-5 71 71 0 0 1 8 141H132Z" fill="#dbe7ff"/>
<rect x="110" y="222" width="292" height="188" rx="30" fill="#fff"/>
<path d="m124 246 119 89a22 22 0 0 0 26 0l119-89" fill="none" stroke="#2f54eb" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="m122 391 88-74m180 74-88-74" fill="none" stroke="#a5bbff" stroke-width="12" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1007 B

+23 -16
View File
@@ -1,45 +1,41 @@
/** 应用框架:100vh 布局(侧栏 + 顶栏大标题 + 内容区滚动),会话守卫 */
/** 应用框架:100vh 布局(侧栏 + 顶栏页面标题 + 内容区滚动),会话守卫 + 设置入口 */
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { Avatar, Dropdown, Layout, Menu } from "antd";
import { Avatar, Button, Dropdown, Layout, Menu, Tooltip } from "antd";
import {
DashboardOutlined,
MailOutlined,
CloudServerOutlined,
GlobalOutlined,
BellOutlined,
KeyOutlined,
SettingOutlined,
UserOutlined,
LogoutOutlined,
} from "@ant-design/icons";
import { health, logout, me } from "./api.ts";
import { SettingsModal } from "./components/SettingsModal.tsx";
const { Sider, Header, Content } = Layout;
/** # 成员变量: 侧栏菜单项 **/
const MENU_ITEMS = [
{ key: "/", icon: <DashboardOutlined />, label: "概览" },
{ key: "/nodes", icon: <CloudServerOutlined />, label: "节点 Nodes" },
{ key: "/accounts", icon: <CloudServerOutlined />, label: "账号 Accounts" },
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
{ key: "/emails", icon: <MailOutlined />, label: "邮件 Emails" },
{ key: "/webhooks", icon: <BellOutlined />, label: "Webhooks" },
{ key: "/keys", icon: <KeyOutlined />, label: "API / MCP Keys" },
];
/** # 成员变量: 路由 -> 页面标题/副标题 **/
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/": { title: "概览", subtitle: "节点、邮件与投递总览" },
"/nodes": { title: "节点管理", subtitle: "通过 Cloudflare OAuth 自动安装的转发节点" },
"/domains": { title: "域名管理", subtitle: "接入 Email Routing 的收件域名" },
"/mailboxes": { title: "邮箱管理", subtitle: "收件地址与保留策略" },
"/": { title: "概览", subtitle: "账号、域名与邮件总览" },
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" },
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
"/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" },
"/webhooks": { title: "Webhooks", subtitle: "邮件事件推送与投递日志" },
"/keys": { title: "API / MCP Keys", subtitle: "wmh_ 前缀凭据,仅创建时可见" },
};
/** # 函数
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 版本号(package.json 来源)
* 应用框架组件:未登录跳 /login;顶栏展示页面标题 + 设置入口;侧栏品牌带版本号
*/
export function App() {
const navigate = useNavigate();
@@ -47,6 +43,7 @@ export function App() {
const [ready, setReady] = useState(false);
const [username, setUsername] = useState("");
const [version, setVersion] = useState("");
const [settingsOpen, setSettingsOpen] = useState(false);
useEffect(() => {
void (async () => {
@@ -70,6 +67,8 @@ export function App() {
<div className="brand">
<img src="/logo.svg" alt="WmailHub" />
<span className="name">WmailHub</span>
{/* 版本号小一号、居上对齐(来源 package.json) */}
{version && <span className="brand-version">v{version}</span>}
</div>
<Menu
mode="inline"
@@ -81,12 +80,19 @@ export function App() {
</Sider>
<Layout>
<Header className="app-header">
<div>
<div className="header-meta">
<div className="title">{meta.title}</div>
<div className="subtitle">{meta.subtitle}</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<span className="muted mono">v{version}</span>
<Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => setSettingsOpen(true)}
aria-label="设置"
/>
</Tooltip>
{/* Dropdown 子元素必须是 DOM 元素(antd v6 坑),所以外包 div */}
<div>
<Dropdown
@@ -116,6 +122,7 @@ export function App() {
<Outlet />
</Content>
</Layout>
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
</Layout>
);
}
+76 -73
View File
@@ -12,7 +12,7 @@ export function setUnauthorizedHandler(fn: () => void): void {
/** # 函数
* 通用请求封装
* 参数: method/url/body(JSON)/query
* 参数: method/url/body(JSON)
* 返回值: 解析后的 JSON;非 2xx 抛 ApiError
*/
export async function api<T = unknown>(
@@ -68,7 +68,7 @@ export function qs(params: Record<string, string | number | undefined>): string
return s ? `?${s}` : "";
}
/* ---- 具体接口封装 ---- */
/* ---- 会话 ---- */
/** 登录 */
export const login = (username: string, password: string) =>
@@ -83,16 +83,19 @@ export const me = () => api<{ user: { id: string; username: string } }>("/api/au
/** 健康检查(含版本号) */
export const health = () => api<{ ok: boolean; name: string; version: string }>("/api/health");
/* ---- 概览 ---- */
/** Dashboard 统计 */
export const stats = () =>
api<{
nodes_online: number;
nodes_total: number;
accounts_online: number;
accounts_total: number;
domains: number;
domains_active: number;
mailboxes: number;
emails: number;
emails_expired: number;
webhooks: number;
global_webhook: number;
deliveries_pending: number;
oauth_connections: number;
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
@@ -101,9 +104,9 @@ export const stats = () =>
/** 手动触发 Cron */
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
/* Nodes */
/* ---- 账号(原节点) ---- */
export interface NodeRow {
export interface AccountRow {
id: string;
name: string;
cloudflare_account_id: string | null;
@@ -114,62 +117,79 @@ export interface NodeRow {
created_at: string;
}
export const listNodes = () => api<{ items: NodeRow[]; total: number }>("/api/nodes");
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
export const createNode = (name: string, ingress_url: string) =>
api<{ ok: boolean; node_id: string; enrollment_token: string; ingress_secret: string; worker_name: string }>(
"/api/nodes",
"POST",
{ name, ingress_url },
);
export interface NodeDetail {
node: NodeRow & { enrollment_expires_at: string | null };
export interface AccountDetail {
account: AccountRow & { enrollment_expires_at: string | null };
steps: Record<string, { status: string; error?: string }>;
oauth: { id: string; account_id: string | null; created_at: string } | null;
domains: { id: string; name: string; status: string; worker_id: string | null }[];
}
export const getNode = (id: string) => api<NodeDetail>(`/api/nodes/${id}`);
export const getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
export const deleteNode = (id: string) => api(`/api/nodes/${id}`, "DELETE");
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
api(`/api/accounts/${id}`, "PATCH", body);
export const rotateNode = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/nodes/${id}/rotate`, "POST");
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
export const getNodeIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/nodes/${id}/ingress-secret`);
export const rotateAccount = (id: string) =>
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
/* OAuth */
export const getAccountIngressSecret = (id: string) =>
api<{ ingress_secret: string }>(`/api/accounts/${id}/ingress-secret`);
/* ---- OAuth ---- */
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
export const oauthStart = (nodeId: string) =>
api<{ url: string; state: string }>(`/api/oauth/cloudflare/start?node_id=${encodeURIComponent(nodeId)}`);
/** 开始授权:nodeId 缺省 = 自动创建账号 */
export const oauthStart = (nodeId?: string) =>
api<{ url: string; state: string }>(
`/api/oauth/cloudflare/start${nodeId ? `?node_id=${encodeURIComponent(nodeId)}` : ""}`,
);
export const oauthSimApprove = (state: string) =>
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
/* Domains */
/* ---- 域名 ---- */
export interface DomainRow {
id: string;
name: string;
status: string;
setup_error: string | null;
worker_id: string | null;
zone_id: string | null;
node_id: string | null;
node_name: string | null;
worker_id: string | null;
webhook_url: string | null;
webhook_headers: string | null;
webhook_has_key: number;
mailbox_count: number;
created_at: string;
}
export const listDomains = () => api<{ items: DomainRow[]; total: number }>("/api/domains");
export const createDomain = (name: string) => api<{ ok: boolean; id: string }>("/api/domains", "POST", { name });
/** 域名页账号筛选项 */
export const listDomainAccounts = () => api<{ items: { id: string; name: string }[] }>("/api/domains/filters/accounts");
/** 为域名配置 Email Workers(Worker + Email Routing + catch-all) */
export const setupDomain = (id: string) =>
api<{ ok: boolean; error?: string }>(`/api/domains/${id}/setup`, "POST");
/** 保存域名级 Webhook(url 空串清除;key undefined 保留原值) */
export const putDomainWebhook = (
id: string,
body: { url: string; headers?: Record<string, string>; key?: string },
) => api(`/api/domains/${id}/webhook`, "PUT", body);
export const deleteDomainWebhook = (id: string) => api(`/api/domains/${id}/webhook`, "DELETE");
export const deleteDomain = (id: string) => api(`/api/domains/${id}`, "DELETE");
/* Mailboxes */
/* ---- 邮箱 ---- */
export interface MailboxRow {
id: string;
@@ -179,6 +199,9 @@ export interface MailboxRow {
retention_seconds: number | null;
domain_id: string;
domain_name: string;
webhook_url: string | null;
webhook_headers: string | null;
webhook_has_key: number;
email_count: number;
created_at: string;
}
@@ -192,9 +215,17 @@ export const createMailbox = (body: { domain_id: string; address: string; retent
export const patchMailbox = (id: string, body: { status?: string; retention_seconds?: number | null }) =>
api(`/api/mailboxes/${id}`, "PATCH", body);
/** 保存邮箱级 Webhook(url 空串清除;key undefined 保留原值) */
export const putMailboxWebhook = (
id: string,
body: { url: string; headers?: Record<string, string>; key?: string },
) => api(`/api/mailboxes/${id}/webhook`, "PUT", body);
export const deleteMailboxWebhook = (id: string) => api(`/api/mailboxes/${id}/webhook`, "DELETE");
export const deleteMailbox = (id: string) => api(`/api/mailboxes/${id}`, "DELETE");
/* Emails */
/* ---- 邮件 ---- */
export interface EmailRow {
id: string;
@@ -222,54 +253,26 @@ export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
/* Webhooks */
/* ---- 设置 ---- */
export interface WebhookRow {
id: string;
name: string;
endpoint: string;
enabled: number;
delivery_count: number;
success_count: number;
created_at: string;
export interface SettingsInfo {
username: string;
default_ingress_url: string;
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
}
export const listWebhooks = () => api<{ items: WebhookRow[]; total: number }>("/api/webhooks");
export const getSettings = () => api<SettingsInfo>("/api/settings");
export const createWebhook = (name: string, endpoint: string) =>
api<{ ok: boolean; id: string; secret: string }>("/api/webhooks", "POST", { name, endpoint });
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
export const putSettings = (body: {
default_ingress_url?: string;
webhook?: { url: string; headers?: Record<string, string>; key?: string };
}) => api("/api/settings", "PUT", body);
export const patchWebhook = (id: string, body: { name?: string; endpoint?: string; enabled?: boolean }) =>
api(`/api/webhooks/${id}`, "PATCH", body);
export const changePassword = (old_password: string, new_password: string) =>
api("/api/settings/password", "PUT", { old_password, new_password });
export const rotateWebhookSecret = (id: string) =>
api<{ ok: boolean; secret: string }>(`/api/webhooks/${id}/rotate`, "POST");
export const deleteWebhook = (id: string) => api(`/api/webhooks/${id}`, "DELETE");
export const testWebhook = (id: string) =>
api<{ ok: boolean; status?: number; error?: string }>(`/api/webhooks/${id}/test`, "POST");
export interface DeliveryRow {
id: string;
webhook_id: string;
email_id: string;
status: string;
attempt_count: number;
next_retry_at: string | null;
last_error: string | null;
delivered_at: string | null;
created_at: string;
subject: string | null;
}
export const listDeliveries = (webhookId: string) =>
api<{ items: DeliveryRow[] }>(`/api/webhooks/${webhookId}/deliveries`);
export const redeliver = (deliveryId: string) =>
api(`/api/webhooks/deliveries/${deliveryId}/redeliver`, "POST");
/* API/MCP Keys */
/* ---- API/MCP Keys(设置模态窗口内使用) ---- */
export interface KeyRow {
id: string;
@@ -0,0 +1,345 @@
/**
* 设置模态窗口(v1.1.0):
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
*/
import { useCallback, useEffect, useState } from "react";
import {
Alert,
Button,
Form,
Input,
Modal,
Space,
Table,
Tabs,
Tag,
Typography,
App,
Popconfirm,
} from "antd";
import { PlusOutlined } from "@ant-design/icons";
import {
changePassword,
createKey,
deleteKey,
getSettings,
listKeys,
putSettings,
revokeKey,
type KeyRow,
type SettingsInfo,
} from "../api.ts";
/** # 函数
* 设置模态窗口组件
*/
export function SettingsModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { message } = App.useApp();
const [info, setInfo] = useState<SettingsInfo | null>(null);
const load = useCallback(async () => {
try {
setInfo(await getSettings());
} catch (err) {
message.error(err instanceof Error ? err.message : "读取设置失败");
}
}, [message]);
useEffect(() => {
if (open) void load();
}, [open, load]);
return (
<Modal title="设置" open={open} onCancel={onClose} footer={null} width={760} destroyOnHidden>
<Tabs
items={[
{ key: "general", label: "基本设置", children: info ? <GeneralTab info={info} onSaved={load} /> : null },
{ key: "api", label: "API Keys", children: <KeysTab kind="api" /> },
{ key: "mcp", label: "MCP Keys", children: <KeysTab kind="mcp" /> },
]}
/>
</Modal>
);
}
/** # 成员变量: Webhook 配置表单值 **/
interface WebhookFormValues {
url: string;
headers: string;
key?: string;
}
/** # 函数
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
*/
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
const { message } = App.useApp();
const [webhookForm] = Form.useForm<WebhookFormValues>();
const [ingressForm] = Form.useForm<{ ingress: string }>();
const [pwdForm] = Form.useForm<{
old_password: string;
new_password: string;
confirm: string;
}>();
const [saving, setSaving] = useState(false);
useEffect(() => {
webhookForm.setFieldsValue({
url: info.webhook.url ?? "",
headers: info.webhook.url ? JSON.stringify(info.webhook.headers ?? {}, null, 2) : "",
});
}, [info, webhookForm]);
/** 保存全局 Webhook 与默认 Ingress */
const saveWebhook = async () => {
const values = await webhookForm.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;
}
}
setSaving(true);
try {
await putSettings({
default_ingress_url: info.default_ingress_url,
webhook: { url: values.url.trim(), headers, key: values.key },
});
message.success("全局 Webhook 已保存");
webhookForm.setFieldValue("key", undefined);
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
/** 保存默认 Node Ingress 地址 */
const saveIngress = async () => {
const values = await ingressForm.validateFields();
try {
await putSettings({ default_ingress_url: values.ingress.trim() });
message.success("默认 Node Ingress 地址已保存");
onSaved();
} catch (err) {
message.error(err instanceof Error ? err.message : "保存失败");
}
};
/** 修改密码 */
const savePassword = async () => {
const values = await pwdForm.validateFields();
try {
await changePassword(values.old_password, values.new_password);
message.success("密码已更新");
pwdForm.resetFields();
} catch (err) {
message.error(err instanceof Error ? err.message : "修改失败");
}
};
return (
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<Form form={webhookForm} layout="vertical">
<Typography.Title level={5}>全局 Webhook</Typography.Title>
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message="收到邮件时按 邮箱 → 域名 → 全局 的顺序取第一个已配置的 Webhook 转发;此处为兜底的全局配置。留空 URL 表示停用全局 Webhook。"
/>
<Form.Item name="url" label="Webhook URL">
<Input placeholder="https://hooks.example.com/wmail" allowClear />
</Form.Item>
<Form.Item
name="headers"
label="自定义请求头(JSON 对象,可选)"
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>,头里的 Authorization 优先'
>
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
</Form.Item>
<Form.Item
name="key"
label="Key"
extra={
info.webhook.has_key
? `已设置 key(****${info.webhook.key_last4}),用于签名(X-WmailHub-Signature)与默认 Authorization;留空保留现有,填空格清除`
: "可选;默认以 Authorization: Bearer <key> 携带,并用于 Webhook 签名"
}
>
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
</Form.Item>
<Button type="primary" loading={saving} onClick={() => void saveWebhook()}>
保存 Webhook
</Button>
</Form>
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
</Typography.Text>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
</Form.Item>
<Button onClick={() => void saveIngress()}>保存</Button>
</Space.Compact>
</Form>
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
<Typography.Title level={5}>修改密码</Typography.Title>
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item name="new_password" label="新密码(至少 6 位)" rules={[{ required: true, min: 6, message: "至少 6 位" }]}>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Form.Item
name="confirm"
label="确认新密码"
dependencies={["new_password"]}
rules={[
{ required: true, message: "请再次输入" },
({ getFieldValue }) => ({
validator: (_, v) =>
!v || v === getFieldValue("new_password")
? Promise.resolve()
: Promise.reject(new Error("两次输入不一致")),
}),
]}
>
<Input.Password autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit">
更新密码
</Button>
</Form>
</Space>
);
}
/** # 函数
* API/MCP Key 管理表格
*/
function KeysTab({ kind }: { kind: "api" | "mcp" }) {
const { message, modal } = App.useApp();
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(false);
const [q, setQ] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listKeys()).items.filter((k) => k.kind === kind));
} finally {
setLoading(false);
}
}, [kind]);
useEffect(() => {
void load();
}, [load]);
const filtered = items.filter((k) =>
`${k.name} ${k.key_last4}`.toLowerCase().includes(q.trim().toLowerCase()),
);
/** 生成新 key:明文仅展示一次 */
const create = async () => {
try {
const res = await createKey(kind);
modal.info({
title: `${kind === "api" ? "API" : "MCP"} Key 已生成(仅显示一次)`,
content: (
<div>
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
{res.key}
</Typography.Paragraph>
<Typography.Text type="secondary">请立即保存;关闭后无法再次查看。</Typography.Text>
</div>
),
});
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "生成失败");
}
};
return (
<div>
<div className="page-toolbar">
<Input
allowClear
placeholder="搜索名称 / 尾号"
style={{ width: 220 }}
onChange={(e) => setQ(e.target.value)}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => void create()}>
生成 {kind === "api" ? "API" : "MCP"} Key
</Button>
</div>
<Table
rowKey="id"
size="small"
loading={loading}
dataSource={filtered}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name", ellipsis: true },
{ title: "尾号", dataIndex: "key_last4", width: 80, render: (v: string) => <span className="mono">****{v}</span> },
{
title: "状态",
dataIndex: "revoked_at",
width: 90,
render: (v: string | null) => (v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>),
},
{ title: "创建时间", dataIndex: "created_at", width: 165, render: (v: string) => new Date(v).toLocaleString() },
{
title: "最近使用",
dataIndex: "last_used_at",
width: 165,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 130,
render: (_: unknown, row: KeyRow) => (
<Space>
{!row.revoked_at && (
<Popconfirm
title="撤销后使用该 Key 的请求将被拒绝,确认?"
onConfirm={async () => {
await revokeKey(row.id);
message.success("已撤销");
void load();
}}
>
<Button size="small">撤销</Button>
</Popconfirm>
)}
<Button
size="small"
danger
onClick={async () => {
await deleteKey(row.id);
message.success("已删除");
void load();
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</div>
);
}
@@ -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>
);
}
+3 -7
View File
@@ -1,4 +1,4 @@
/** 前端入口:antd v6 + react-router,路由守卫由 App 内 authGate 状态控制 */
/** 前端入口:antd v6 + react-router(Vite 构建) */
import React from "react";
import { createRoot } from "react-dom/client";
import { ConfigProvider, App as AntdApp } from "antd";
@@ -10,12 +10,10 @@ import { App } from "./App.tsx";
import { LoginPage } from "./pages/Login.tsx";
import { OAuthSimPage } from "./pages/OAuthSim.tsx";
import { DashboardPage } from "./pages/Dashboard.tsx";
import { NodesPage } from "./pages/Nodes.tsx";
import { AccountsPage } from "./pages/Accounts.tsx";
import { DomainsPage } from "./pages/Domains.tsx";
import { MailboxesPage } from "./pages/Mailboxes.tsx";
import { EmailsPage } from "./pages/Emails.tsx";
import { WebhooksPage } from "./pages/Webhooks.tsx";
import { KeysPage } from "./pages/Keys.tsx";
import { setUnauthorizedHandler } from "./api.ts";
/** # 成员变量: 路由表 **/
@@ -27,12 +25,10 @@ const router = createBrowserRouter([
element: <App />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "nodes", element: <NodesPage /> },
{ path: "accounts", element: <AccountsPage /> },
{ path: "domains", element: <DomainsPage /> },
{ path: "mailboxes", element: <MailboxesPage /> },
{ path: "emails", element: <EmailsPage /> },
{ path: "webhooks", element: <WebhooksPage /> },
{ path: "keys", element: <KeysPage /> },
],
},
]);
@@ -1,36 +1,33 @@
/** 节点页:Add Node(OAuth 自动安装)、安装进度、凭据轮换 */
import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Table,
Tag,
Timeline,
Typography,
App,
Alert,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
createNode,
deleteNode,
getNode,
getNodeIngressSecret,
listNodes,
deleteAccount,
getAccount,
getAccountIngressSecret,
listAccounts,
oauthMode,
oauthStart,
rotateNode,
type NodeDetail,
type NodeRow,
rotateAccount,
updateAccount,
type AccountDetail,
type AccountRow,
} from "../api.ts";
/** # 成员变量: 节点状态 -> Tag 颜色 **/
/** # 成员变量: 账号状态 -> Tag 颜色 **/
const STATUS_COLOR: Record<string, string> = {
pending_oauth: "orange",
provisioning: "processing",
@@ -44,29 +41,26 @@ const STEP_LABEL: Record<string, string> = {
oauth: "Cloudflare OAuth 授权",
identity: "Node identity 与 credential",
discover: "Account / Zone 发现",
email_worker: "Email Worker 创建/更新",
email_routing: "Email Routing 配置",
domains: "Domain 创建",
mailboxes: "Mailbox 创建",
domains: "域名与 catch-all 邮箱同步",
};
/** # 函数
* 节点页组件
* 账号页组件
*/
export function NodesPage() {
export function AccountsPage() {
const { message, modal } = App.useApp();
const navigate = useNavigate();
const [params] = useSearchParams();
const [items, setItems] = useState<NodeRow[]>([]);
const [items, setItems] = useState<AccountRow[]>([]);
const [loading, setLoading] = useState(false);
const [addOpen, setAddOpen] = useState(false);
const [form] = Form.useForm();
const [detail, setDetail] = useState<NodeDetail | null>(null);
const [q, setQ] = useState("");
const [detail, setDetail] = useState<AccountDetail | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listNodes()).items);
setItems((await listAccounts()).items);
} finally {
setLoading(false);
}
@@ -79,7 +73,7 @@ export function NodesPage() {
if (provisionId) {
void (async () => {
try {
setDetail(await getNode(provisionId));
setDetail(await getAccount(provisionId));
setDetailOpen(true);
} catch {
// ignore
@@ -88,43 +82,53 @@ export function NodesPage() {
}
}, [load, params]);
/** Add Node 提交:创建节点后进入 OAuth */
const submitAdd = async () => {
const values = await form.validateFields();
const created = await createNode(values.name, values.ingress_url || undefined);
setAddOpen(false);
form.resetFields();
message.success("节点已创建,开始 Cloudflare 授权");
await startOAuth(created.node_id);
};
const filtered = useMemo(
() =>
items.filter((row) =>
`${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}`
.toLowerCase()
.includes(q.trim().toLowerCase()),
),
[items, q],
);
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */
const startOAuth = async (nodeId: string) => {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(nodeId);
if (mode === "cloudflare") {
window.location.href = url;
} else {
window.location.href = url; // /oauth/sim/authorize?state=...
/** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。
* accountId 缺省 = 服务端自动创建账号。 */
const startOAuth = async (accountId?: string) => {
try {
const mode = (await oauthMode()).mode;
const { url } = await oauthStart(accountId);
if (mode === "cloudflare") window.location.href = url;
else navigate(url); // /oauth/sim/authorize?state=...
} catch (err) {
message.error(err instanceof Error ? err.message : "发起授权失败");
}
};
const openDetail = async (id: string) => {
setDetail(await getNode(id));
setDetail(await getAccount(id));
setDetailOpen(true);
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
点击「添加节点」后完成 Cloudflare OAuth,系统自动安装 Email Worker / Email Routing / Domain / Mailbox,
全程无需在服务器执行任何安装命令。
</span>
<div className="filters">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索名称 / 状态 / Cloudflare 账户"
style={{ width: 280 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
</span>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setAddOpen(true)}>
添加节点
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
添加账号
</Button>
</Space>
</div>
@@ -133,7 +137,7 @@ export function NodesPage() {
<Table
rowKey="id"
loading={loading}
dataSource={items}
dataSource={filtered}
pagination={false}
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
columns={[
@@ -141,21 +145,22 @@ export function NodesPage() {
{
title: "状态",
dataIndex: "status",
width: 120,
width: 130,
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", render: (v: string | null) => v ?? "-" },
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
{
title: "最近心跳",
dataIndex: "last_seen_at",
width: 190,
width: 170,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "操作",
width: 150,
render: (_: unknown, row: NodeRow) => (
render: (_: unknown, row: AccountRow) => (
<Space>
<Button
size="small"
@@ -173,10 +178,10 @@ export function NodesPage() {
onClick={(e) => {
e.stopPropagation();
modal.confirm({
title: `删除节点 ${row.name}?`,
content: "将撤销节点凭据并标记为 revoked。",
title: `删除账号 ${row.name}?`,
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
onOk: async () => {
await deleteNode(row.id);
await deleteAccount(row.id);
message.success("已删除");
void load();
},
@@ -192,14 +197,12 @@ export function NodesPage() {
/>
</Card>
<AddNodeModal open={addOpen} form={form} onOk={() => void submitAdd()} onCancel={() => setAddOpen(false)} />
<NodeDetailDrawer
<AccountDetailDrawer
detail={detail}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onReload={async () => {
if (detail) setDetail(await getNode(detail.node.id));
if (detail) setDetail(await getAccount(detail.account.id));
void load();
}}
/>
@@ -208,74 +211,35 @@ export function NodesPage() {
}
/** # 函数
* 添加节点弹窗
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
*/
function AddNodeModal({
open,
form,
onOk,
onCancel,
}: {
open: boolean;
form: ReturnType<typeof Form.useForm>[0];
onOk: () => void;
onCancel: () => void;
}) {
return (
<Modal
title="添加节点"
open={open}
onOk={onOk}
onCancel={onCancel}
okText="创建并授权"
cancelText="取消"
destroyOnHidden
>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="创建后将跳转 Cloudflare OAuth;授权完成即自动完成全部安装。"
/>
<Form form={form} layout="vertical">
<Form.Item name="name" label="节点名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="例如:Local Node / Node-JP" />
</Form.Item>
<Form.Item
name="ingress_url"
label="Node Ingress 地址"
initialValue="http://127.0.0.1:18788/ingress/email"
extra="Email Worker 回源 Node 的地址;生产环境填 Node 的公网 HTTPS 地址"
>
<Input />
</Form.Item>
</Form>
</Modal>
);
}
/** # 函数
* 节点详情抽屉:安装步骤时间线 + 域名 + enrollment/凭据管理
*/
function NodeDetailDrawer({
function AccountDetailDrawer({
detail,
open,
onClose,
onReload,
}: {
detail: NodeDetail | null;
detail: AccountDetail | null;
open: boolean;
onClose: () => void;
onReload: () => void;
}) {
const { message, modal } = App.useApp();
const [showSecret, setShowSecret] = useState(false);
const [ingress, setIngress] = useState("");
const [name, setName] = useState("");
useEffect(() => {
if (detail) {
setIngress(detail.account.ingress_url);
setName(detail.account.name);
}
}, [detail]);
if (!detail) return null;
const steps = detail.steps;
return (
<Drawer title={`节点:${detail.node.name}`} size="large" open={open} onClose={onClose}>
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
<div>
<Typography.Title level={5}>安装进度</Typography.Title>
@@ -299,25 +263,49 @@ function NodeDetailDrawer({
/>
</div>
<Card size="small" title="已配置域名">
<Card size="small" title="已同步域名">
{detail.domains.length === 0 ? (
<span className="muted">暂无</span>
<span className="muted">暂无;OAuth 授权完成后自动同步</span>
) : (
detail.domains.map((d) => (
<div key={d.id}>
<Tag color="blue">{d.name}</Tag>
<span className="muted mono">{d.worker_id ?? d.status}</span>
<span className="muted mono">
{d.worker_id ?? "未配置 Email Workers"}
</span>
</div>
))
)}
</Card>
<Card size="small" title="凭据管理">
<Space orientation="vertical" style={{ width: "100%" }}>
<Card size="small" title="Ingress 与凭据">
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
<Space.Compact style={{ width: "100%" }}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
style={{ width: 180 }}
placeholder="账号名称"
/>
<Input
value={ingress}
onChange={(e) => setIngress(e.target.value)}
placeholder="Node Ingress 地址"
/>
<Button
onClick={async () => {
await updateAccount(detail.account.id, { name, ingress_url: ingress });
message.success("已保存(新邮件将从新 Ingress 转发)");
onReload();
}}
>
保存
</Button>
</Space.Compact>
<Space wrap>
<Button
onClick={async () => {
const res = await rotateNode(detail.node.id);
const res = await rotateAccount(detail.account.id);
modal.info({
title: "新的 Enrollment Token(仅显示一次)",
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
@@ -329,21 +317,22 @@ function NodeDetailDrawer({
</Button>
<Button
onClick={async () => {
const res = await getNodeIngressSecret(detail.node.id);
setShowSecret(true);
message.info(`Ingress Secret:${res.ingress_secret}`);
const res = await getAccountIngressSecret(detail.account.id);
modal.info({
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
});
}}
>
查看 Ingress Secret
</Button>
</Space>
<Typography.Text type="secondary" className="mono">
enrollment 有效期至 {detail.node.enrollment_expires_at ?? "-"}
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
</Typography.Text>
</Space>
</Card>
</Space>
{showSecret && null}
</Drawer>
);
}
+3 -3
View File
@@ -49,12 +49,12 @@ export function DashboardPage() {
<Row gutter={[16, 16]}>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="节点在线" value={data?.nodes_online ?? 0} suffix={`/ ${data?.nodes_total ?? 0}`} />
<Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="域名" value={data?.domains ?? 0} />
<Statistic title="域名" value={data?.domains ?? 0} suffix={`/ ${data?.domains_active ?? 0} 已配置`} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
@@ -69,7 +69,7 @@ export function DashboardPage() {
</Col>
<Col xs={12} md={8} lg={4}>
<Card loading={loading}>
<Statistic title="Webhook" value={data?.webhooks ?? 0} />
<Statistic title="全局 Webhook" value={data?.global_webhook ? "已配置" : "未配置"} />
</Card>
</Col>
<Col xs={12} md={8} lg={4}>
+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>
);
}
-158
View File
@@ -1,158 +0,0 @@
/** API / MCP Key 管理页(wmh_ 前缀,总长 32,仅创建时可见) */
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Dropdown, Space, Table, Tag, Typography, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createKey, deleteKey, listKeys, revokeKey, type KeyRow } from "../api.ts";
/** # 函数
* Keys 页组件
*/
export function KeysPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<KeyRow[]>([]);
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listKeys()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<span className="muted">
API Key 调用 /api/v1/*;MCP Key 调用 /api/v1/mcp(JSON-RPC)。均以 Authorization: Bearer wmh_...
传递;服务端只存 SHA-256,明文仅创建时展示一次。Key 总长 32 位(wmh_ + 28 位随机)。
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
{/* Dropdown 子元素必须是 DOM 元素(antd v6),外包 div */}
<div>
<Dropdown
menu={{
items: [
{ key: "api", label: "生成 API Key" },
{ key: "mcp", label: "生成 MCP Key" },
],
onClick: ({ key }) => void genKey(key as "api" | "mcp"),
}}
>
<Button type="primary" icon={<PlusOutlined />}>
生成 Key
</Button>
</Dropdown>
</div>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{
title: "类型",
dataIndex: "kind",
width: 90,
render: (v: string) => <Tag color={v === "api" ? "blue" : "purple"}>{v.toUpperCase()}</Tag>,
},
{ title: "名称", dataIndex: "name" },
{
title: "Key 尾缀",
dataIndex: "key_last4",
width: 110,
render: (v: string) => (
<span className="mono">
wmh_****{v}
</span>
),
},
{
title: "创建时间",
dataIndex: "created_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "最近使用",
dataIndex: "last_used_at",
width: 175,
render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"),
},
{
title: "状态",
dataIndex: "revoked_at",
width: 90,
render: (v: string | null) =>
v ? <Tag color="error">已撤销</Tag> : <Tag color="success">有效</Tag>,
},
{
title: "操作",
width: 160,
render: (_: unknown, row: KeyRow) => (
<Space>
{!row.revoked_at && (
<Button
size="small"
onClick={async () => {
await revokeKey(row.id);
message.success("已撤销");
void load();
}}
>
撤销
</Button>
)}
<Button
size="small"
danger
onClick={async () => {
await deleteKey(row.id);
message.success("已删除记录");
void load();
}}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
</div>
);
/** 生成 key 并展示一次 */
async function genKey(kind: "api" | "mcp") {
try {
const res = await createKey(kind);
modal.success({
title: `${kind.toUpperCase()} Key 已创建(仅显示一次)`,
width: 560,
content: (
<div style={{ marginTop: 12 }}>
<Typography.Paragraph type="warning">
请立即保存,关闭后无法再次查看。Key 格式:wmh_ 前缀 + 28 位随机串,总长 32。
</Typography.Paragraph>
<Typography.Paragraph copyable className="mono" style={{ fontSize: 15 }}>
{res.key}
</Typography.Paragraph>
</div>
),
});
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "生成失败");
}
}
}
+96 -28
View File
@@ -1,8 +1,17 @@
/** 邮箱页:创建/停用/删除/保留策略 */
import { useCallback, useEffect, useState } from "react";
/** 邮箱页:创建/停用/删除/保留策略/邮箱级 Webhook;支持搜索 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App } from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import { createMailbox, deleteMailbox, listDomains, listMailboxes, patchMailbox, type DomainRow, type MailboxRow } from "../api.ts";
import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import {
createMailbox,
deleteMailbox,
listDomains,
listMailboxes,
patchMailbox,
type DomainRow,
type MailboxRow,
} from "../api.ts";
import { WebhookForm } from "../components/WebhookForm.tsx";
/** # 函数
* 邮箱页组件
@@ -13,6 +22,8 @@ export function MailboxesPage() {
const [domains, setDomains] = useState<DomainRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
const [form] = Form.useForm();
const load = useCallback(async () => {
@@ -30,6 +41,14 @@ export function MailboxesPage() {
void load();
}, [load]);
const filtered = useMemo(
() =>
items.filter((row) =>
`${row.address} ${row.domain_name}`.toLowerCase().includes(q.trim().toLowerCase()),
),
[items, q],
);
const toggleStatus = async (row: MailboxRow) => {
await patchMailbox(row.id, { status: row.status === "active" ? "disabled" : "active" });
void load();
@@ -38,7 +57,16 @@ export function MailboxesPage() {
return (
<div>
<div className="page-toolbar">
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
<div className="filters">
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索地址 / 域名"
style={{ width: 260 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
</div>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
@@ -50,7 +78,7 @@ export function MailboxesPage() {
<Table
rowKey="id"
loading={loading}
dataSource={items}
dataSource={filtered}
pagination={false}
columns={[
{
@@ -62,47 +90,58 @@ export function MailboxesPage() {
</span>
),
},
{ title: "域名", dataIndex: "domain_name", width: 180 },
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
width: 95,
render: (v: string) => <Tag color={v === "active" ? "success" : "default"}>{v}</Tag>,
},
{
title: "Webhook",
width: 90,
render: (_: unknown, row: MailboxRow) =>
row.webhook_url ? <Tag color="blue">已配置</Tag> : <span className="muted">-</span>,
},
{
title: "保留(秒)",
dataIndex: "retention_seconds",
width: 110,
width: 105,
render: (v: number | null) => v ?? <span className="muted">默认 86400</span>,
},
{ title: "邮件数", dataIndex: "email_count", width: 90 },
{ title: "邮件数", dataIndex: "email_count", width: 85 },
{
title: "启用",
width: 80,
width: 75,
render: (_: unknown, row: MailboxRow) => (
<Switch checked={row.status === "active"} onChange={() => void toggleStatus(row)} />
),
},
{
title: "操作",
width: 90,
width: 150,
render: (_: unknown, row: MailboxRow) => (
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除邮箱 ${row.address}?`,
onOk: async () => {
await deleteMailbox(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
<Space>
<Button size="small" onClick={() => setWebhookTarget(row)}>
Webhook
</Button>
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除邮箱 ${row.address}?`,
onOk: async () => {
await deleteMailbox(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
</Space>
),
},
]}
@@ -152,6 +191,35 @@ export function MailboxesPage() {
</Form.Item>
</Form>
</Modal>
<Modal
title={`邮箱级 Webhook:${webhookTarget?.address ?? ""}`}
open={Boolean(webhookTarget)}
onCancel={() => setWebhookTarget(null)}
footer={null}
width={560}
destroyOnHidden
>
<p className="muted" style={{ marginTop: 0 }}>
该邮箱收到邮件时优先使用此 Webhook;未配置则回退域名级,再回退全局(设置中配置)。
</p>
{webhookTarget && (
<WebhookForm
scope="mailbox"
targetId={webhookTarget.id}
value={{
url: webhookTarget.webhook_url,
headers: webhookTarget.webhook_headers,
hasKey: Boolean(webhookTarget.webhook_has_key),
}}
onSaved={async () => {
const fresh = (await listMailboxes()).items.find((m) => m.id === webhookTarget.id) ?? null;
setWebhookTarget(fresh);
void load();
}}
/>
)}
</Modal>
</div>
);
}
+5 -5
View File
@@ -20,14 +20,14 @@ export function OAuthSimPage() {
const res = await oauthSimApprove(state);
if (res.ok) {
message.success("Cloudflare 授权与自动安装完成");
navigate(`/nodes?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true });
} else {
message.error(res.error ?? "安装失败");
navigate("/nodes", { replace: true });
navigate("/accounts", { replace: true });
}
} catch (err) {
message.error(err instanceof Error ? err.message : "授权失败");
navigate("/nodes", { replace: true });
navigate("/accounts", { replace: true });
} finally {
setBusy(false);
}
@@ -35,7 +35,7 @@ export function OAuthSimPage() {
// state 缺失直接回节点页
useEffect(() => {
if (!state) navigate("/nodes", { replace: true });
if (!state) navigate("/accounts", { replace: true });
}, [state, navigate]);
return (
@@ -44,7 +44,7 @@ export function OAuthSimPage() {
<p>sim 模式下,该页面模拟 Cloudflare OAuth 授权同意流程。</p>
<p className="muted">真实部署时,这里会跳转到 dash.cloudflare.com 的授权页面。</p>
<Space style={{ width: "100%", justifyContent: "center", marginTop: 16 }}>
<Button onClick={() => navigate("/nodes")}>拒绝</Button>
<Button onClick={() => navigate("/accounts")}>拒绝</Button>
<Button type="primary" loading={busy} onClick={() => void approve()}>
同意授权
</Button>
-266
View File
@@ -1,266 +0,0 @@
/** Webhooks 页:CRUD、secret 展示、测试投递、投递日志 */
import { useCallback, useEffect, useState } from "react";
import {
Button,
Card,
Drawer,
Form,
Input,
Modal,
Space,
Switch,
Table,
Tag,
Typography,
App,
} from "antd";
import { PlusOutlined, ReloadOutlined } from "@ant-design/icons";
import {
createWebhook,
deleteWebhook,
listDeliveries,
listWebhooks,
patchWebhook,
redeliver,
rotateWebhookSecret,
testWebhook,
type DeliveryRow,
type WebhookRow,
} from "../api.ts";
/** # 成员变量: 投递状态颜色 **/
const DELIVERY_COLOR: Record<string, string> = {
pending: "processing",
success: "success",
failed: "warning",
exhausted: "error",
};
/** # 函数
* Webhooks 页组件
*/
export function WebhooksPage() {
const { message, modal } = App.useApp();
const [items, setItems] = useState<WebhookRow[]>([]);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const [form] = Form.useForm();
const [deliveries, setDeliveries] = useState<{ hook: WebhookRow; rows: DeliveryRow[] } | null>(null);
const load = useCallback(async () => {
setLoading(true);
try {
setItems((await listWebhooks()).items);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
/** 展示一次性 secret */
const showSecretModal = (title: string, secret: string) => {
modal.info({
title,
width: 560,
content: (
<div>
<Typography.Paragraph type="warning">secret 仅此一次显示,请立即保存。</Typography.Paragraph>
<Typography.Paragraph copyable className="mono">
{secret}
</Typography.Paragraph>
</div>
),
});
};
return (
<div>
<div className="page-toolbar">
<span className="muted">
{"邮件入库后推送 email.received 事件;签名头 X-WmailHub-Signature = HMAC-SHA256(secret, `${timestamp}.${body}`)。"}
</span>
<Space>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)}>
添加 Webhook
</Button>
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={false}
columns={[
{ title: "名称", dataIndex: "name" },
{ title: "Endpoint", dataIndex: "endpoint", ellipsis: true, className: "mono" },
{
title: "投递",
width: 140,
render: (_: unknown, row: WebhookRow) => (
<span>
{row.success_count}/{row.delivery_count}
</span>
),
},
{
title: "启用",
width: 80,
render: (_: unknown, row: WebhookRow) => (
<Switch
checked={row.enabled === 1}
onChange={async (v) => {
await patchWebhook(row.id, { enabled: v });
void load();
}}
/>
),
},
{
title: "操作",
width: 280,
render: (_: unknown, row: WebhookRow) => (
<Space wrap>
<Button
size="small"
onClick={async () => {
const res = await testWebhook(row.id);
if (res.ok) message.success("测试投递成功");
else message.error(`测试投递失败:${res.error ?? res.status}`);
void load();
}}
>
测试
</Button>
<Button
size="small"
onClick={async () => {
setDeliveries({ hook: row, rows: (await listDeliveries(row.id)).items });
}}
>
日志
</Button>
<Button
size="small"
onClick={async () => {
const res = await rotateWebhookSecret(row.id);
showSecretModal("新 Secret", res.secret);
}}
>
轮换 Secret
</Button>
<Button
size="small"
danger
onClick={() =>
modal.confirm({
title: `删除 Webhook ${row.name}?`,
onOk: async () => {
await deleteWebhook(row.id);
message.success("已删除");
void load();
},
})
}
>
删除
</Button>
</Space>
),
},
]}
/>
</Card>
<Modal
title="添加 Webhook"
open={open}
onCancel={() => setOpen(false)}
okText="创建"
cancelText="取消"
destroyOnHidden
onOk={async () => {
const values = await form.validateFields();
try {
const res = await createWebhook(values.name, values.endpoint);
setOpen(false);
form.resetFields();
void load();
showSecretModal("Webhook Secret(仅显示一次)", res.secret);
} catch (err) {
message.error(err instanceof Error ? err.message : "创建失败");
}
}}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入名称" }]}>
<Input placeholder="CRM 通知" />
</Form.Item>
<Form.Item
name="endpoint"
label="Endpoint URL"
rules={[
{ required: true, message: "请输入 URL" },
{
validator: (_, v: string) =>
/^https?:\/\//.test(v ?? "") ? Promise.resolve() : Promise.reject(new Error("必须为 http(s) URL")),
},
]}
extra="禁止 localhost / 私网 / 内网地址(SSRF 防护)"
>
<Input placeholder="https://example.com/hooks/wmailhub" />
</Form.Item>
</Form>
</Modal>
<Drawer
title={`投递日志:${deliveries?.hook.name ?? ""}`}
size="large"
open={Boolean(deliveries)}
onClose={() => setDeliveries(null)}
>
<Table
rowKey="id"
size="small"
dataSource={deliveries?.rows ?? []}
pagination={false}
columns={[
{ title: "邮件", dataIndex: "subject", ellipsis: true },
{
title: "状态",
dataIndex: "status",
width: 100,
render: (v: string) => <Tag color={DELIVERY_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "尝试", dataIndex: "attempt_count", width: 60 },
{
title: "错误",
dataIndex: "last_error",
ellipsis: true,
render: (v: string | null) => v ?? "-",
},
{
title: "操作",
width: 90,
render: (_: unknown, row: DeliveryRow) => (
<Button
size="small"
onClick={async () => {
await redeliver(row.id);
message.success("已加入重试队列");
}}
>
重投
</Button>
),
},
]}
/>
</Drawer>
</div>
);
}
+29 -3
View File
@@ -39,6 +39,20 @@ body,
letter-spacing: 0.2px;
}
/* 版本号 tag:字体小一号、居上对齐 */
.brand-version {
align-self: flex-start;
margin-top: 1px;
padding: 0 6px;
font-size: 10.5px;
line-height: 16px;
color: #2f54eb;
background: rgba(47, 84, 235, 0.08);
border: 1px solid rgba(47, 84, 235, 0.25);
border-radius: 8px;
white-space: nowrap;
}
.app-header {
display: flex;
align-items: center;
@@ -47,15 +61,27 @@ body,
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
/* 标题/副标题紧凑排列:行高收紧、不换行、溢出省略 */
.header-meta {
min-width: 0;
line-height: 0;
}
.app-header .title {
font-size: 20px;
font-size: 17px;
font-weight: 650;
line-height: 22px;
white-space: nowrap;
}
.app-header .subtitle {
font-size: 12.5px;
font-size: 12px;
line-height: 16px;
color: rgba(0, 0, 0, 0.45);
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 56vw;
}
.app-content {