diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx index 0d90390..3e5da0e 100644 --- a/console/frontend/src/App.tsx +++ b/console/frontend/src/App.tsx @@ -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: , label: "域名 Domains" }, { key: "/mailboxes", icon: , label: "邮箱 Mailboxes" }, { key: "/inboxs", icon: , label: "收件箱 Inboxs" }, + { key: "/logs", icon: , label: "日志 Logs" }, ]; /** # 成员变量: 路由 -> 页面标题/副标题 **/ @@ -32,7 +34,8 @@ const PAGE_META: Record = { "/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 图标 **/ diff --git a/console/frontend/src/api.ts b/console/frontend/src/api.ts index 2baedc8..aff6650 100644 --- a/console/frontend/src/api.ts +++ b/console/frontend/src/api.ts @@ -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("/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 { diff --git a/console/frontend/src/components/CopyText.tsx b/console/frontend/src/components/CopyText.tsx new file mode 100644 index 0000000..53e12a2 --- /dev/null +++ b/console/frontend/src/components/CopyText.tsx @@ -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 | 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 ( + + + {display ?? value} + + {extra} + + - - ), - }, - { - key: "token", - label: ( - - API Token 授权 - - ), - children: ( -
- - 直接粘贴一个 Cloudflare API Token。Token 会在提交时先被 Cloudflare 校验一遍 - (并与填写的账户 ID 比对),确认可用后才建账号并自动接入,填错会当场报错。 - Token 加密入库、不回显、不写日志。 - - } - /> - - - - - - - - - - - - ), - }, - ]} - /> + onModeChange(e.target.value as "oauth" | "token")} + style={{ marginBottom: 4 }} + > + + Cloudflare OAuth 授权 + + + API Token 授权 + + + + {mode === "oauth" ? ( + + ) : ( +
+ + + + + + + +
+ )} ); } @@ -483,9 +417,7 @@ function AccountDetailDrawer({ detail.domains.map((d) => (
{d.name} - - {d.worker_id ?? "未配置 Email Workers"} - + {d.worker_id ? "已配置 Email Workers" : "未配置 Email Workers"}
)) )} diff --git a/console/frontend/src/pages/Domains.tsx b/console/frontend/src/pages/Domains.tsx index 2cbd169..47b6d88 100644 --- a/console/frontend/src/pages/Domains.tsx +++ b/console/frontend/src/pages/Domains.tsx @@ -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) => {v} }, + { title: "域名", dataIndex: "name", render: (v: string) => }, { title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" }, { title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => }, { diff --git a/console/frontend/src/pages/Inbox.tsx b/console/frontend/src/pages/Inbox.tsx index 919342a..6270fa4 100644 --- a/console/frontend/src/pages/Inbox.tsx +++ b/console/frontend/src/pages/Inbox.tsx @@ -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 ?? (无主题), - }, - { 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 ? {v} : "-"), - }, - { 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) => {new Date(v).toLocaleString()}, - }, -]; - /** # 函数 * 收件箱页组件 */ @@ -89,6 +63,8 @@ export function InboxPage() { const [detail, setDetail] = useState(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 ? ( + + + + + {v ?? (无主题)} + + ) : ( + (v ?? (无主题)) + ), + }, + { + title: "发件人", + dataIndex: "from_address", + width: 230, + ellipsis: true, + render: (v: string | null) => (v ? : "-"), + }, + { + title: "收件人", + dataIndex: "mailbox_address", + width: 230, + ellipsis: true, + render: (v: string | null) => (v ? : (未匹配)), + }, + { + title: "附件", + dataIndex: "attachment_count", + width: 70, + render: (v: number) => (v > 0 ? {v} : "-"), + }, + { 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 ? 永久保留 : {new Date(v).toLocaleString()}, + }, + { + title: "操作", + width: 80, + render: (_: unknown, row: EmailRow) => + row.retained ? ( + + + )} (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 && ( + {!detail.email.retained && ( + + )} @@ -247,7 +346,11 @@ export function InboxPage() { {new Date(detail.email.received_at).toLocaleString()} →{" "} - {new Date(detail.email.expires_at).toLocaleString()} + {detail.email.retained ? ( + 永久保留 + ) : ( + {new Date(detail.email.expires_at).toLocaleString()} + )} diff --git a/console/frontend/src/pages/Logs.tsx b/console/frontend/src/pages/Logs.tsx new file mode 100644 index 0000000..320ed9e --- /dev/null +++ b/console/frontend/src/pages/Logs.tsx @@ -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 = { + 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 = { + "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; + 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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(20); + const [targetType, setTargetType] = useState(); + 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 ( +
+
+ + } + placeholder="搜索对象 / 操作账号" + style={{ width: 240 }} + onChange={(e) => { + setQ(e.target.value); + setPage(1); + }} + /> + + + 最多保留 10000 条,超出自动删最旧 +
+ + `共 ${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 ? {m.label} : {v}; + }, + }, + { + title: "对象", + dataIndex: "target_name", + ellipsis: true, + render: (v: string | null, row: OperationLogRow) => + v ? ( + + {v} + + ) : ( + - + ), + }, + { + title: "详情", + dataIndex: "detail", + ellipsis: true, + render: (v: string | null) => + v ? {detailText(v)} : -, + }, + { + title: "操作账号", + dataIndex: "operator", + width: 120, + render: (v: string) => (v === "system" ? system : v || "-"), + }, + ]} + /> + + + ); +} diff --git a/console/frontend/src/pages/Mailboxes.tsx b/console/frontend/src/pages/Mailboxes.tsx index c3eba1b..b1ab40d 100644 --- a/console/frontend/src/pages/Mailboxes.tsx +++ b/console/frontend/src/pages/Mailboxes.tsx @@ -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(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) => ( - - {v} - {row.is_catch_all ? catch-all : null} - -