v2.5.0:操作日志 + 邮件保留 + 账号弹窗精简 + 复制按钮推广

- 操作日志:operation_logs 表(0004 迁移)记录 账号/域名/邮箱/Webhook 全部写操作,
  含操作账号(Console 登录名,系统动作为 system);/logs 日志页(类型筛选+搜索+分页);
  上限 10000 条,Cron housekeeping 超限删最旧
- 邮件保留:emails.retained;POST /api/emails/retain(单条/批量);
  TTL 清理与 Webhook 投递过期判定均跳过已保留;/inboxs 操作列图钉按钮 + 批量保留
- 添加账号弹窗:OAuth 与 API Token 同窗按钮组切换,去掉说明文字与账户 ID 字段
  (账户 ID 以 Token 实际所属账户为准);账号列表去掉 Email Worker 列
- 复制按钮推广:CopyText 组件;/domains 域名列、/inboxs 发件人与收件人(原「邮箱」改名)列
- 测试:104 bun tests(+10 日志/保留)、ui-smoke 35 项(+logs 页/新弹窗/复制/保留)
This commit is contained in:
bigcode
2026-10-01 00:25:38 +08:00
parent 8c5ed7fc8b
commit 8b255ef2e0
22 changed files with 1240 additions and 228 deletions
+4 -1
View File
@@ -10,6 +10,7 @@ import {
UserOutlined,
LogoutOutlined,
SettingOutlined,
HistoryOutlined,
} from "@ant-design/icons";
import { health, logout, me } from "./api.ts";
import { SettingsModal } from "./components/SettingsModal.tsx";
@@ -24,6 +25,7 @@ const MENU_ITEMS = [
{ key: "/domains", icon: <GlobalOutlined />, label: "域名 Domains" },
{ key: "/mailboxes", icon: <MailOutlined />, label: "邮箱 Mailboxes" },
{ key: "/inboxs", icon: <MailOutlined />, label: "收件箱 Inboxs" },
{ key: "/logs", icon: <HistoryOutlined />, label: "日志 Logs" },
];
/** # 成员变量: 路由 -> 页面标题/副标题 **/
@@ -32,7 +34,8 @@ const PAGE_META: Record<string, { title: string; subtitle: string }> = {
"/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" },
"/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" },
"/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" },
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天),支持按域名筛选" },
"/inboxs": { title: "收件箱", subtitle: "已接收邮件(默认保留 1 天,可点保留永不过期),支持按域名筛选" },
"/logs": { title: "日志", subtitle: "账号 / 域名 / 邮箱 / Webhook 的操作记录(最多保留 10000 条)" },
};
/** # 成员变量: 主题切换按钮的太阳/月亮 SVG 图标 **/
+27 -7
View File
@@ -140,13 +140,10 @@ export const updateAccount = (id: string, body: { name?: string }) =>
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
/** 用 Cloudflare API Token 添加账号(与 OAuth 并列的第二种接入方式)。
* 后端会先用 token 探一次 Cloudflare 确认可用、且与填写的账户 ID 一致,再建账号并自动接入。 */
export const createAccountByToken = (body: {
name: string;
cloudflare_account_id?: string;
token: string;
}) =>
/** 用 Cloudflare API Token 添加账号(OAuth 之外的另一种接入方式)。
* 后端会先用 token 探一次 Cloudflare 确认可用,再建账号并自动接入;
* 账户 ID 以 Token 实际所属账户为准,无需(也无法)手工指定。 */
export const createAccountByToken = (body: { name: string; token: string }) =>
api<{ ok: boolean; account_id: string; error?: string }>("/api/accounts/token", "POST", body);
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
@@ -255,6 +252,8 @@ export interface EmailRow {
size: number;
received_at: string;
expires_at: string;
/** 1 = 已保留,永不过期(Cron TTL 清理跳过) **/
retained: number;
attachment_count: number;
}
@@ -302,6 +301,27 @@ export const batchDeleteMailboxes = (ids: string[]) =>
export const batchDeleteEmails = (ids: string[]) =>
api<BatchDeleteResult>("/api/emails/batch-delete", "POST", { ids });
/** 保留邮件(单条/批量):之后永不过期 */
export const retainEmails = (ids: string[]) =>
api<{ ok: boolean; retained: number }>("/api/emails/retain", "POST", { ids });
/* ---- 操作日志 ---- */
export interface OperationLogRow {
id: string;
action: string;
target_type: "account" | "domain" | "mailbox" | "email" | "webhook";
target_id: string | null;
target_name: string | null;
detail: string | null;
operator: string;
created_at: string;
}
/** 操作日志查询(target_type / action / q 均可省略) */
export const listLogs = (params: { target_type?: string; action?: string; q?: string; limit?: number; offset?: number }) =>
api<{ items: OperationLogRow[]; total: number }>(`/api/logs${qs(params)}`);
/* ---- 设置 ---- */
export interface SettingsInfo {
@@ -0,0 +1,70 @@
/**
* 可复制文本(v2.5.0):文本右侧带复制按钮,点击写剪贴板并播放复制成功动画。
* /mailboxes 的地址列首发,后推广到 /domains 域名列与 /inboxs 发件人/收件人列。
*/
import { useEffect, useRef, useState, type ReactNode } from "react";
import { App, Button, Tooltip } from "antd";
import { CheckOutlined, CopyOutlined } from "@ant-design/icons";
/** # 成员变量: 组件属性 **/
interface CopyTextProps {
/** 要展示与复制的文本 **/
value: string;
/** 展示文本(缺省用 value;例如截断的长地址) **/
display?: string;
/** 追加在文本后面的节点(如 catch-all 标签) **/
extra?: ReactNode;
}
/** # 函数
* 可复制文本:成功时按钮弹一下并短暂变成对勾;剪贴板被拒时提示手动复制
*/
export function CopyText({ value, display, extra }: CopyTextProps) {
const { message } = App.useApp();
/** 刚复制成功的标志(播放动画 + 换对勾,1 秒后还原) **/
const [copied, setCopied] = useState(false);
/** 动画还原定时器(卸载/重复点击时清掉) **/
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
/** 复制文本到剪贴板 */
const copy = async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1000);
} catch {
// 剪贴板不可用(非 https / 权限被拒)时提示,让用户手动复制
message.warning("浏览器拒绝了剪贴板访问,请手动复制");
}
};
return (
<span style={{ display: "inline-flex", alignItems: "center", gap: 4, minWidth: 0, maxWidth: "100%" }}>
<span className="mono" style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{display ?? value}
</span>
{extra}
<Tooltip title="复制">
<Button
type="text"
size="small"
className={`copy-btn${copied ? " copied" : ""}`}
icon={copied ? <CheckOutlined /> : <CopyOutlined />}
onClick={(e) => {
// 行点击会打开详情抽屉,复制按钮不要触发
e.stopPropagation();
void copy();
}}
/>
</Tooltip>
</span>
);
}
+2
View File
@@ -14,6 +14,7 @@ import { AccountsPage } from "./pages/Accounts.tsx";
import { DomainsPage } from "./pages/Domains.tsx";
import { MailboxesPage } from "./pages/Mailboxes.tsx";
import { InboxPage } from "./pages/Inbox.tsx";
import { LogsPage } from "./pages/Logs.tsx";
import { setUnauthorizedHandler } from "./api.ts";
/** # 成员变量: 路由表(邮件页路由 /inboxs,v2.1.0) **/
@@ -28,6 +29,7 @@ const router = createBrowserRouter([
{ path: "domains", element: <DomainsPage /> },
{ path: "mailboxes", element: <MailboxesPage /> },
{ path: "inboxs", element: <InboxPage /> },
{ path: "logs", element: <LogsPage /> },
],
},
]);
+39 -107
View File
@@ -1,10 +1,10 @@
/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */
/** 账号页:添加账号(Cloudflare OAuth / API Token 同窗切换),授权后自动接入并同步域名;搜索、详情、凭据管理 */
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "react-router-dom";
import {
Alert,
Button,
Card,
Divider,
Drawer,
Form,
Input,
@@ -12,7 +12,6 @@ import {
Radio,
Space,
Table,
Tabs,
Tag,
Timeline,
Typography,
@@ -137,7 +136,6 @@ export function AccountsPage() {
try {
const res = await createAccountByToken({
name: values.name,
cloudflare_account_id: values.cloudflare_account_id?.trim() || undefined,
token: values.token.trim(),
});
if (!res.ok) {
@@ -179,10 +177,7 @@ export function AccountsPage() {
style={{ width: 280 }}
onChange={(e) => setQ(e.target.value)}
/>
<span className="muted">
「添加账号」支持 Cloudflare OAuth 授权与 API Token 两种方式;接入后自动同步域名、
部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
</span>
<span className="muted">接入后自动同步域名、部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。</span>
</div>
<Space>
<BatchActions
@@ -236,12 +231,6 @@ export function AccountsPage() {
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
},
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
{
title: "Email Worker",
dataIndex: "worker_id",
ellipsis: true,
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未部署</span>),
},
{
title: "接入时间",
dataIndex: "created_at",
@@ -320,9 +309,9 @@ interface AddAccountModalProps {
}
/** # 函数
* 「添加账号」模态窗口:Cloudflare OAuth 与 API Token 两种授权方式,各自带说明。
* 两种方式最终都走同一套自动接入流程(发现域名 -> 部署 Email Worker -> 签发 Worker 凭据),
* 区别只在于 token 从哪来:OAuth 走用户点授权,API Token 由用户自己粘贴。
* 「添加 Cloudflare 账号」模态窗口:Cloudflare OAuth 授权与 API Token 授权在同一个窗口,
* 顶部按钮组切换方式,字段随方式变化 —— OAuth 只要一个「前往授权」按钮,
* API Token 填账户名称 + Token 即可(账户 ID 由 Token 在 Cloudflare 侧实际所属账户自动确定)。
*/
function AddAccountModal(props: AddAccountModalProps) {
const { open, mode, loading, form, onModeChange, onCancel, onOAuth, onTokenSubmit } = props;
@@ -333,96 +322,41 @@ function AddAccountModal(props: AddAccountModalProps) {
onCancel={onCancel}
maskClosable={!loading}
closable={!loading}
width={620}
width={520}
footer={null}
destroyOnHidden
>
<Tabs
activeKey={mode}
onChange={(k) => onModeChange(k as "oauth" | "token")}
items={[
{
key: "oauth",
label: (
<span>
<SafetyOutlined /> Cloudflare OAuth 授权
</span>
),
children: (
<Space orientation="vertical" size="middle" style={{ width: "100%" }}>
<Alert
type="info"
showIcon
message="推荐:不需要在本机保存任何密钥"
description={
<span>
点击下方按钮跳转到 Cloudflare 官方授权页,用你的 Cloudflare 账号登录并同意授权,
完成后自动跳回本控制台。Console 会用拿到的授权去发现该账号下的域名、
部署 Email Worker 并签发 Worker 凭据,全程无需在本系统里保存 Cloudflare 密钥。
<br />
前提:「设置 → Cloudflare 接入」里已填好 Client ID / Secret,且 OAuth App
已勾选本应用需要的权限(scope 用点号分隔)。
</span>
}
/>
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
前往 Cloudflare 授权
</Button>
</Space>
),
},
{
key: "token",
label: (
<span>
<ApiOutlined /> API Token 授权
</span>
),
children: (
<Form form={form} layout="vertical" requiredMark="optional">
<Alert
type="warning"
showIcon
style={{ marginBottom: 16 }}
message="适合不方便走 OAuth 跳转的场景"
description={
<span>
直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍
(并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。
Token 加密入库、不回显、不写日志。
</span>
}
/>
<Form.Item
name="name"
label="账户名称"
rules={[{ required: true, message: "请输入账户名称" }]}
>
<Input placeholder="我的主账号" />
</Form.Item>
<Form.Item
name="cloudflare_account_id"
label="账户 ID"
extra="Cloudflare 账户 ID(账户主页右侧的 Account ID)。可留空,留空则以 Token 实际所属账户为准;填错会被校验拦下。"
>
<Input placeholder="bf8cd983e6dfd171c1881b00b512ed5a" className="mono" />
</Form.Item>
<Form.Item
name="token"
label="API Token"
rules={[{ required: true, message: "请输入 API Token" }]}
extra="到 Cloudflare → My Profile → API Tokens 生成。所需权限与 OAuth 方式一致:Zone Read、Workers Scripts Edit、Email Routing Rules Edit、Zone Settings Write。"
>
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
</Form.Item>
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
{loading ? "正在校验并接入…" : "用 API Token 接入"}
</Button>
</Form>
),
},
]}
/>
<Radio.Group
block
value={mode}
onChange={(e) => onModeChange(e.target.value as "oauth" | "token")}
style={{ marginBottom: 4 }}
>
<Radio.Button value="oauth" style={{ width: "50%" }}>
<SafetyOutlined /> Cloudflare OAuth 授权
</Radio.Button>
<Radio.Button value="token" style={{ width: "50%" }}>
<ApiOutlined /> API Token 授权
</Radio.Button>
</Radio.Group>
<Divider style={{ margin: "16px 0" }} />
{mode === "oauth" ? (
<Button type="primary" icon={<SafetyOutlined />} onClick={onOAuth} block>
前往 Cloudflare 授权
</Button>
) : (
<Form form={form} layout="vertical" requiredMark="optional">
<Form.Item name="name" label="账户名称" rules={[{ required: true, message: "请输入账户名称" }]}>
<Input placeholder="我的主账号" />
</Form.Item>
<Form.Item name="token" label="API Token" rules={[{ required: true, message: "请输入 API Token" }]}>
<Input.Password placeholder="粘贴 Cloudflare API Token" autoComplete="new-password" />
</Form.Item>
<Button type="primary" icon={<ApiOutlined />} onClick={onTokenSubmit} loading={loading} block>
{loading ? "正在校验并接入…" : "用 API Token 接入"}
</Button>
</Form>
)}
</Modal>
);
}
@@ -483,9 +417,7 @@ function AccountDetailDrawer({
detail.domains.map((d) => (
<div key={d.id}>
<Tag color="blue">{d.name}</Tag>
<span className="muted mono">
{d.worker_id ?? "未配置 Email Workers"}
</span>
<span className="muted">{d.worker_id ? "已配置 Email Workers" : "未配置 Email Workers"}</span>
</div>
))
)}
+2 -1
View File
@@ -15,6 +15,7 @@ import {
type DomainRow,
} from "../api.ts";
import { WebhookForm } from "../components/WebhookForm.tsx";
import { CopyText } from "../components/CopyText.tsx";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
/** # 成员变量: 域名状态 -> Tag **/
@@ -175,7 +176,7 @@ export function DomainsPage() {
style: { cursor: "pointer" },
})}
columns={[
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
{ title: "域名", dataIndex: "name", render: (v: string) => <CopyText value={v} /> },
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
{ title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => <StatusTag row={row} /> },
{
+137 -34
View File
@@ -1,4 +1,4 @@
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载) */
/** 收件箱页(/inboxs):域名筛选、搜索、分页、详情(正文/HTML 预览/附件/raw 下载)、保留(不再过期) */
import { useCallback, useEffect, useState } from "react";
import {
Button,
@@ -10,6 +10,7 @@ import {
Space,
Table,
Tag,
Tooltip,
Typography,
App,
Popconfirm,
@@ -19,6 +20,8 @@ import {
DeleteOutlined,
DownloadOutlined,
EyeOutlined,
PushpinFilled,
PushpinOutlined,
ReloadOutlined,
SearchOutlined,
} from "@ant-design/icons";
@@ -28,10 +31,12 @@ import {
getEmail,
listDomains,
listEmails,
retainEmails,
type EmailDetail,
type EmailRow,
} from "../api.ts";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
import { CopyText } from "../components/CopyText.tsx";
/** # 函数
* 字节数格式化
@@ -42,37 +47,6 @@ function fmtSize(n: number): string {
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** # 成员变量: 表格列(分页在组件内配置) **/
const columns = [
{
title: "主题",
dataIndex: "subject",
ellipsis: true,
render: (v: string | null) => v ?? <span className="muted">(无主题)</span>,
},
{ title: "发件人", dataIndex: "from_address", width: 200, ellipsis: true },
{ title: "邮箱", dataIndex: "mailbox_address", width: 190, ellipsis: true, render: (v: string | null) => v ?? "-" },
{
title: "附件",
dataIndex: "attachment_count",
width: 70,
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
},
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
{
title: "接收时间",
dataIndex: "received_at",
width: 175,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "过期时间",
dataIndex: "expires_at",
width: 175,
render: (v: string) => <span className="muted">{new Date(v).toLocaleString()}</span>,
},
];
/** # 函数
* 收件箱页组件
*/
@@ -89,6 +63,8 @@ export function InboxPage() {
const [detail, setDetail] = useState<EmailDetail | null>(null);
const [htmlPreview, setHtmlPreview] = useState(false);
const [deleting, setDeleting] = useState(false);
/** 批量保留进行中 **/
const [retaining, setRetaining] = useState(false);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -119,6 +95,110 @@ export function InboxPage() {
})();
}, []);
/** 保留邮件(单条):retained=1 后 Cron TTL 清理跳过,永不过期 */
const retainOne = async (row: EmailRow) => {
try {
await retainEmails([row.id]);
message.success("已保留,该邮件不再过期");
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "保留失败");
}
};
/** 批量保留选中的邮件 */
const retainSelected = async () => {
setRetaining(true);
try {
const res = await retainEmails(selectedIds);
message.success(`已保留 ${res.retained} 封邮件`);
clear();
void load();
} catch (err) {
message.error(err instanceof Error ? err.message : "批量保留失败");
} finally {
setRetaining(false);
}
};
/** # 成员变量: 表格列(依赖保留动作,定义在组件内) **/
const columns = [
{
title: "主题",
dataIndex: "subject",
ellipsis: true,
render: (v: string | null, row: EmailRow) =>
row.retained ? (
<span>
<Tooltip title="已保留,不再过期">
<PushpinFilled style={{ color: "#52c41a", marginRight: 6 }} />
</Tooltip>
{v ?? <span className="muted">(无主题)</span>}
</span>
) : (
(v ?? <span className="muted">(无主题)</span>)
),
},
{
title: "发件人",
dataIndex: "from_address",
width: 230,
ellipsis: true,
render: (v: string | null) => (v ? <CopyText value={v} /> : "-"),
},
{
title: "收件人",
dataIndex: "mailbox_address",
width: 230,
ellipsis: true,
render: (v: string | null) => (v ? <CopyText value={v} /> : <span className="muted">(未匹配)</span>),
},
{
title: "附件",
dataIndex: "attachment_count",
width: 70,
render: (v: number) => (v > 0 ? <Tag color="blue">{v}</Tag> : "-"),
},
{ title: "大小", dataIndex: "size", width: 90, render: (v: number) => fmtSize(v) },
{
title: "接收时间",
dataIndex: "received_at",
width: 165,
render: (v: string) => new Date(v).toLocaleString(),
},
{
title: "过期时间",
dataIndex: "expires_at",
width: 165,
render: (v: string, row: EmailRow) =>
row.retained ? <Tag color="green">永久保留</Tag> : <span className="muted">{new Date(v).toLocaleString()}</span>,
},
{
title: "操作",
width: 80,
render: (_: unknown, row: EmailRow) =>
row.retained ? (
<Tooltip title="已保留,不再过期">
<Button type="text" size="small" className="pin-btn" icon={<PushpinFilled style={{ color: "#52c41a" }} />} />
</Tooltip>
) : (
<Tooltip title="保留:不再过期">
<Button
type="text"
size="small"
className="pin-btn"
icon={<PushpinOutlined />}
onClick={(e) => {
// 行点击会打开邮件详情,保留按钮不要触发
e.stopPropagation();
void retainOne(row);
}}
/>
</Tooltip>
),
},
];
return (
<div>
<div className="page-toolbar">
@@ -148,6 +228,11 @@ export function InboxPage() {
/>
</Space>
<Space>
{count > 0 && (
<Button icon={<PushpinOutlined />} loading={retaining} onClick={() => void retainSelected()}>
保留选中({count})
</Button>
)}
<BatchActions
count={count}
onClear={clear}
@@ -178,7 +263,7 @@ export function InboxPage() {
loading={loading}
dataSource={items}
rowSelection={rowSelection}
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
rowClassName={(row) => (row.expires_at <= new Date().toISOString() && !row.retained ? "muted" : "")}
onRow={(row) => ({
onClick: async (e) => {
// 点击复选框不打开邮件详情
@@ -211,6 +296,20 @@ export function InboxPage() {
extra={
detail && (
<Space>
{!detail.email.retained && (
<Button
size="small"
icon={<PushpinOutlined />}
onClick={async () => {
await retainEmails([detail.email.id]);
message.success("已保留,该邮件不再过期");
setDetail(await getEmail(detail.email.id));
void load();
}}
>
保留
</Button>
)}
<Button size="small" icon={<EyeOutlined />} onClick={() => setHtmlPreview((v) => !v)} disabled={!detail.email.html_body}>
{htmlPreview ? "显示文本" : "HTML 预览"}
</Button>
@@ -247,7 +346,11 @@ export function InboxPage() {
</Descriptions.Item>
<Descriptions.Item label="接收 / 过期">
{new Date(detail.email.received_at).toLocaleString()} →{" "}
<span className="muted">{new Date(detail.email.expires_at).toLocaleString()}</span>
{detail.email.retained ? (
<Tag color="green" style={{ marginRight: 0 }}>永久保留</Tag>
) : (
<span className="muted">{new Date(detail.email.expires_at).toLocaleString()}</span>
)}
</Descriptions.Item>
</Descriptions>
+182
View File
@@ -0,0 +1,182 @@
/**
* 操作日志页(/logs,v2.5.0):Console 内所有操作的审计流水。
* 覆盖:操作账号(添加/接入/删除/轮换凭据)、操作域名(配置 Email Workers/删除)、
* 操作邮箱(创建/更新/删除)、Webhook 记录(配置/清除/投递成功与失败)。
* 服务端分页,可按对象类型筛选;库里最多保留 10000 条(Cron 裁剪最旧的)。
*/
import { useCallback, useEffect, useState } from "react";
import { Button, Card, Input, Select, Space, Table, Tag, Tooltip } from "antd";
import { ReloadOutlined, SearchOutlined } from "@ant-design/icons";
import { listLogs, type OperationLogRow } from "../api.ts";
/** # 成员变量: 操作对象类型 -> 中文与颜色 **/
const TYPE_META: Record<string, { label: string; color: string }> = {
account: { label: "账号", color: "geekblue" },
domain: { label: "域名", color: "cyan" },
mailbox: { label: "邮箱", color: "purple" },
email: { label: "邮件", color: "orange" },
webhook: { label: "Webhook", color: "blue" },
};
/** # 成员变量: 动作名 -> 中文标签(未收录的动作原样展示) **/
const ACTION_LABEL: Record<string, string> = {
"account.create": "添加账号(API Token)",
"account.connect": "接入账号(OAuth)",
"account.update": "更新账号",
"account.delete": "删除账号",
"account.batch_delete": "批量删除账号",
"account.rotate": "轮换 Worker 凭据",
"domain.setup": "配置 Email Workers",
"domain.delete": "删除域名",
"domain.batch_delete": "批量删除域名",
"mailbox.create": "添加邮箱",
"mailbox.update": "更新邮箱",
"mailbox.delete": "删除邮箱",
"mailbox.batch_delete": "批量删除邮箱",
"email.delete": "删除邮件",
"email.batch_delete": "批量删除邮件",
"email.retain": "保留邮件",
"webhook.save": "配置 Webhook",
"webhook.clear": "清除 Webhook",
"webhook.deliver": "Webhook 投递成功",
"webhook.deliver_failed": "Webhook 投递失败",
};
/** # 函数
* detail JSON 压缩成一行文本(键值对用 · 连接;解析失败原样返回)
*/
function detailText(raw: string | null): string {
if (!raw) return "";
try {
const obj = JSON.parse(raw) as Record<string, unknown>;
return Object.entries(obj)
.map(([k, v]) => `${k}: ${typeof v === "object" && v !== null ? JSON.stringify(v) : String(v)}`)
.join(" · ");
} catch {
return raw;
}
}
/** # 函数
* 操作日志页组件
*/
export function LogsPage() {
const [items, setItems] = useState<OperationLogRow[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [targetType, setTargetType] = useState<string | undefined>();
const [q, setQ] = useState("");
const [loading, setLoading] = useState(false);
const load = useCallback(async () => {
setLoading(true);
try {
const res = await listLogs({ target_type: targetType, q, limit: pageSize, offset: (page - 1) * pageSize });
setItems(res.items);
setTotal(res.total);
} finally {
setLoading(false);
}
}, [targetType, q, page, pageSize]);
useEffect(() => {
void load();
}, [load]);
return (
<div>
<div className="page-toolbar">
<Space>
<Select
allowClear
placeholder="按类型筛选"
style={{ width: 160 }}
value={targetType}
onChange={(v) => {
setTargetType(v);
setPage(1);
}}
options={Object.entries(TYPE_META).map(([value, m]) => ({ value, label: m.label }))}
/>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="搜索对象 / 操作账号"
style={{ width: 240 }}
onChange={(e) => {
setQ(e.target.value);
setPage(1);
}}
/>
</Space>
<Space>
<span className="muted">最多保留 10000 条,超出自动删最旧</span>
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
</Space>
</div>
<Card styles={{ body: { padding: 0 } }}>
<Table
rowKey="id"
loading={loading}
dataSource={items}
pagination={{
current: page,
pageSize,
total,
showSizeChanger: true,
showTotal: (t) => `共 ${t} 条`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
columns={[
{ title: "时间", dataIndex: "created_at", width: 170, render: (v: string) => new Date(v).toLocaleString() },
{
title: "操作",
dataIndex: "action",
width: 190,
render: (v: string) => ACTION_LABEL[v] ?? v,
},
{
title: "类型",
dataIndex: "target_type",
width: 100,
render: (v: string) => {
const m = TYPE_META[v];
return m ? <Tag color={m.color}>{m.label}</Tag> : <Tag>{v}</Tag>;
},
},
{
title: "对象",
dataIndex: "target_name",
ellipsis: true,
render: (v: string | null, row: OperationLogRow) =>
v ? (
<Tooltip title={row.target_id ?? undefined}>
<span className="mono">{v}</span>
</Tooltip>
) : (
<span className="muted">-</span>
),
},
{
title: "详情",
dataIndex: "detail",
ellipsis: true,
render: (v: string | null) =>
v ? <span className="muted">{detailText(v)}</span> : <span className="muted">-</span>,
},
{
title: "操作账号",
dataIndex: "operator",
width: 120,
render: (v: string) => (v === "system" ? <Tag>system</Tag> : v || "-"),
},
]}
/>
</Card>
</div>
);
}
+2 -29
View File
@@ -3,8 +3,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Button, Card, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, App, Tooltip } from "antd";
import {
ApiOutlined,
CheckOutlined,
CopyOutlined,
DeleteOutlined,
PlusOutlined,
ReloadOutlined,
@@ -21,6 +19,7 @@ import {
type DomainRow,
type MailboxRow,
} from "../api.ts";
import { CopyText } from "../components/CopyText.tsx";
import { WebhookForm } from "../components/WebhookForm.tsx";
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
import { batchDeleteMailboxes } from "../api.ts";
@@ -49,8 +48,6 @@ export function MailboxesPage() {
const [deleting, setDeleting] = useState(false);
/** 正在拉取随机用户名的 loading **/
const [fetchingName, setFetchingName] = useState(false);
/** 刚复制完的邮箱 id(用于播放复制成功的动画) **/
const [copiedId, setCopiedId] = useState<string | null>(null);
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
const load = useCallback(async () => {
@@ -97,18 +94,6 @@ export function MailboxesPage() {
}
};
/** 复制邮箱地址:成功时按钮播放一下动画并短暂变成对勾 */
const copyAddress = async (row: MailboxRow) => {
try {
await navigator.clipboard.writeText(row.address);
setCopiedId(row.id);
setTimeout(() => setCopiedId((cur) => (cur === row.id ? null : cur)), 1000);
} catch {
// 剪贴板不可用(非 https / 权限被拒)时退回选中,让用户自己复制
message.warning("浏览器拒绝了剪贴板访问,请手动复制");
}
};
const filtered = useMemo(
() =>
items.filter((row) =>
@@ -175,19 +160,7 @@ export function MailboxesPage() {
title: "地址",
dataIndex: "address",
render: (v: string, row: MailboxRow) => (
<span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
<span className="mono">{v}</span>
{row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null}
<Tooltip title="复制邮箱地址">
<Button
type="text"
size="small"
className={`copy-btn${copiedId === row.id ? " copied" : ""}`}
icon={copiedId === row.id ? <CheckOutlined /> : <CopyOutlined />}
onClick={() => void copyAddress(row)}
/>
</Tooltip>
</span>
<CopyText value={v} extra={row.is_catch_all ? <Tag color="purple">catch-all</Tag> : null} />
),
},
{ title: "域名", dataIndex: "domain_name", width: 170, ellipsis: true },
+10
View File
@@ -217,3 +217,13 @@ body,
70% { transform: scale(0.92); }
100% { transform: scale(1); }
}
/* 保留按钮(收件箱):无边框图钉,悬停变绿 */
.pin-btn {
color: var(--wmh-muted, #8c8c8c);
}
.pin-btn:hover {
color: #52c41a !important;
background: rgba(82, 196, 26, 0.10);
}