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:
@@ -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 图标 **/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -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} /> },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user