feat: v2.3.0 设置内配置 Cloudflare OAuth 客户端 + 全列表批量删除
- 设置新增「Cloudflare 接入」tab:Client ID / Client Secret(Secret AES-GCM 加密入库,
只回显末 4 位),并展示可复制的授权回调地址;settings 优先、env 变量兜底,
保存后立即生效无需重新部署;DELETE /api/settings/cf-oauth 清除
- 四个资源列表(账号/域名/邮箱/收件箱)加复选框批量选择与批量删除
- 新增 POST /api/{accounts,domains,mailboxes,emails}/batch-delete:
账号软删+撤销凭据;域名/邮箱级联删除其下邮件;邮件连 R2 原文与附件一并清理
- 抽出 deleteEmailById / deleteEmailsByMailbox / revokeAccount 供单条与批量共用
- 点击复选框不再触发行点击(避免误开详情抽屉)
- 新增 tests/batch.test.ts(6 例)与 settings OAuth 凭据用例(2 例);ui-smoke 扩到 17 步
This commit is contained in:
@@ -266,21 +266,63 @@ export const getEmail = (id: string) => api<EmailDetail>(`/api/emails/${id}`);
|
||||
|
||||
export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||
|
||||
/* ---- 批量删除(列表复选框) ---- */
|
||||
|
||||
/** # 成员变量: 批量删除结果 **/
|
||||
export interface BatchDeleteResult {
|
||||
ok: boolean;
|
||||
/** 成功删除的主记录数 **/
|
||||
deleted: number;
|
||||
/** 级联删除的邮箱数(域名批量删除时有值) **/
|
||||
mailboxes?: number;
|
||||
/** 级联删除的邮件数(域名/邮箱批量删除时有值) **/
|
||||
emails?: number;
|
||||
/** 删除失败的 id 及原因(不存在/已删除) **/
|
||||
failed: { id: string; reason: string }[];
|
||||
}
|
||||
|
||||
export const batchDeleteAccounts = (ids: string[]) =>
|
||||
api<BatchDeleteResult>("/api/accounts/batch-delete", "POST", { ids });
|
||||
|
||||
export const batchDeleteDomains = (ids: string[]) =>
|
||||
api<BatchDeleteResult>("/api/domains/batch-delete", "POST", { ids });
|
||||
|
||||
export const batchDeleteMailboxes = (ids: string[]) =>
|
||||
api<BatchDeleteResult>("/api/mailboxes/batch-delete", "POST", { ids });
|
||||
|
||||
export const batchDeleteEmails = (ids: string[]) =>
|
||||
api<BatchDeleteResult>("/api/emails/batch-delete", "POST", { ids });
|
||||
|
||||
/* ---- 设置 ---- */
|
||||
|
||||
export interface SettingsInfo {
|
||||
username: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
api_key: { has_key: boolean; key_last4: string; created_at: string | null };
|
||||
/** Cloudflare OAuth 客户端凭据元信息(不含明文 secret) */
|
||||
cf_oauth: {
|
||||
client_id: string;
|
||||
has_secret: boolean;
|
||||
secret_last4: string;
|
||||
/** settings = 后台设置;env = 部署变量兜底;none = 未配置 */
|
||||
source: "settings" | "env" | "none";
|
||||
};
|
||||
/** 需要在 Cloudflare Dashboard 登记的授权回调地址 */
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
|
||||
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
|
||||
/** 保存设置
|
||||
* webhook.key / cf_oauth.secret 语义一致:undefined 保留原值、空串清除、非空设置新值 */
|
||||
export const putSettings = (body: {
|
||||
webhook?: { url: string; headers?: Record<string, string>; key?: string };
|
||||
webhook?: { url: string; headers: Record<string, string>; key?: string };
|
||||
cf_oauth?: { client_id: string; secret?: string };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
/** 清除后台保存的 Cloudflare OAuth 客户端凭据(env 兜底不受影响) */
|
||||
export const deleteCfOAuth = () => api("/api/settings/cf-oauth", "DELETE");
|
||||
|
||||
export const changePassword = (old_password: string, new_password: string) =>
|
||||
api("/api/settings/password", "PUT", { old_password, new_password });
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 列表批量操作(v2.3.0):
|
||||
* 各资源列表共用的复选框选中状态 hook + 工具条(已选计数 / 清除选择 / 批量删除)。
|
||||
*/
|
||||
import { useState, type Key } from "react";
|
||||
import { Button, Popconfirm, Space, Typography } from "antd";
|
||||
import { DeleteOutlined } from "@ant-design/icons";
|
||||
|
||||
/** # 成员变量: 行选择器配置(直接展开到 antd Table 的 rowSelection) **/
|
||||
export type RowSelection = {
|
||||
type: "checkbox";
|
||||
selectedRowKeys: Key[];
|
||||
onChange: (keys: Key[]) => void;
|
||||
};
|
||||
|
||||
/** # 函数
|
||||
* 复选框选中状态 hook:返回 Table 的 rowSelection 配置、选中的字符串 id 列表与清空方法
|
||||
*/
|
||||
export function useRowSelection(): {
|
||||
rowSelection: RowSelection;
|
||||
/** 选中的行 id(字符串形式,可直接提交给批量接口) **/
|
||||
ids: string[];
|
||||
count: number;
|
||||
clear: () => void;
|
||||
} {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<Key[]>([]);
|
||||
return {
|
||||
rowSelection: { type: "checkbox", selectedRowKeys, onChange: setSelectedRowKeys },
|
||||
ids: selectedRowKeys.map((k) => String(k)),
|
||||
count: selectedRowKeys.length,
|
||||
clear: () => setSelectedRowKeys([]),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 成员变量: 批量操作工具条属性 **/
|
||||
interface BatchActionsProps {
|
||||
/** 已选数量;0 时不渲染 **/
|
||||
count: number;
|
||||
/** 清除选择 */
|
||||
onClear: () => void;
|
||||
/** 确认删除后执行 */
|
||||
onDelete: () => void | Promise<void>;
|
||||
/** 删除确认标题(含数量) */
|
||||
title: string;
|
||||
/** 删除影响范围说明(域名/邮箱级联删除时必填) */
|
||||
description?: string;
|
||||
/** 删除进行中 */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 批量操作工具条:未选中时渲染 null,选中后出现在列表工具栏右侧
|
||||
*/
|
||||
export function BatchActions({ count, onClear, onDelete, title, description, loading }: BatchActionsProps) {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<Space>
|
||||
<Typography.Text type="secondary">已选 {count} 项</Typography.Text>
|
||||
<Button onClick={onClear} disabled={loading}>
|
||||
清除选择
|
||||
</Button>
|
||||
<Popconfirm title={title} description={description} onConfirm={() => void onDelete()}>
|
||||
<Button danger icon={<DeleteOutlined />} loading={loading}>
|
||||
批量删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 设置模态窗口(v2.1.0):
|
||||
* - 两个 tab:全局 Webhook(含 API/MCP Key 卡片)、账号密码
|
||||
* 设置模态窗口(v2.3.0):
|
||||
* - 三个 tab:全局 Webhook(含 API/MCP Key 卡片)、Cloudflare 接入、账号密码
|
||||
* - 单一 API/MCP Key(API 与 MCP 共用),凭据存 settings 表,明文仅生成时返回一次
|
||||
* - Cloudflare OAuth 客户端(Client ID / Secret)也在设置里维护,Secret 加密存储只回 last4,
|
||||
* 保存后立即生效,无需重新部署;未配置时账号页「添加账号」不可用
|
||||
* - 内容高度不超过屏幕(超出滚动)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -20,12 +22,21 @@ import {
|
||||
Typography,
|
||||
} from "antd";
|
||||
import {
|
||||
CloudServerOutlined,
|
||||
CopyOutlined,
|
||||
DeleteOutlined,
|
||||
KeyOutlined,
|
||||
PlusOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { changePassword, createApiKey, deleteApiKey, getSettings, putSettings, type SettingsInfo } from "../api.ts";
|
||||
import {
|
||||
changePassword,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
deleteCfOAuth,
|
||||
getSettings,
|
||||
putSettings,
|
||||
type SettingsInfo,
|
||||
} from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 设置模态窗口组件
|
||||
@@ -61,6 +72,7 @@ export function SettingsModal({ open, onClose }: { open: boolean; onClose: () =>
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "webhook", label: "全局 Webhook", children: info ? <WebhookTab info={info} onSaved={load} /> : null },
|
||||
{ key: "cloudflare", label: <span><CloudServerOutlined style={{ marginRight: 6 }} />Cloudflare 接入</span>, children: info ? <CloudflareTab info={info} onSaved={load} /> : null },
|
||||
{ key: "password", label: "账号密码", children: <PasswordTab /> },
|
||||
]}
|
||||
/>
|
||||
@@ -251,6 +263,125 @@ function ApiKeyCard({
|
||||
);
|
||||
}
|
||||
|
||||
/** # 成员变量: Cloudflare OAuth 客户端表单值 **/
|
||||
interface CfOAuthFormValues {
|
||||
client_id: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Cloudflare 接入 tab:维护 OAuth 客户端凭据 + 展示授权回调地址与申请权限
|
||||
*/
|
||||
function CloudflareTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [form] = Form.useForm<CfOAuthFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const meta = info.cf_oauth;
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldsValue({ client_id: meta.client_id });
|
||||
}, [meta.client_id, form]);
|
||||
|
||||
/** 保存 OAuth 客户端凭据 */
|
||||
const save = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({ cf_oauth: { client_id: values.client_id.trim(), secret: values.secret } });
|
||||
message.success("Cloudflare OAuth 客户端已保存");
|
||||
form.setFieldValue("secret", undefined);
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** 清除后台保存的凭据 */
|
||||
const clear = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await deleteCfOAuth();
|
||||
message.success("已清除后台保存的凭据");
|
||||
form.resetFields();
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "清除失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<Alert
|
||||
type={meta.source === "none" ? "warning" : "success"}
|
||||
showIcon
|
||||
message={
|
||||
meta.source === "none"
|
||||
? "尚未配置 Cloudflare OAuth 客户端:账号页「添加账号」会返回 503,无法接入 Cloudflare 账号。"
|
||||
: `已配置(来源:${meta.source === "settings" ? "后台设置" : "部署环境变量"}),账号页「添加账号」可正常发起授权。`
|
||||
}
|
||||
description={
|
||||
<>
|
||||
在 Cloudflare Dashboard 的 <a href="https://dash.cloudflare.com/profile/oauth-apps" target="_blank" rel="noreferrer">OAuth Applications</a>{" "}
|
||||
新建应用,把下面的回调地址填入 Redirect URL,并勾选 Account:Read / Zone:Read /
|
||||
Email Routing:Edit / Workers Scripts:Edit 四项权限。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="授权回调地址(Redirect URL)">
|
||||
<Typography.Paragraph
|
||||
copyable={{ text: info.redirect_uri }}
|
||||
className="mono"
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
{info.redirect_uri}
|
||||
</Typography.Paragraph>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="client_id"
|
||||
label="Client ID"
|
||||
rules={[{ required: true, message: "请填写 Client ID" }]}
|
||||
extra="Cloudflare OAuth 应用的 Client ID"
|
||||
>
|
||||
<Input placeholder="Cloudflare OAuth Client ID" autoComplete="off" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="secret"
|
||||
label="Client Secret"
|
||||
extra={
|
||||
meta.source === "settings" && meta.has_secret
|
||||
? `已保存(****${meta.secret_last4}),留空保留现有,填空格清除`
|
||||
: "仅此一次可见;AES-GCM 加密入库,保存后只回显末 4 位"
|
||||
}
|
||||
>
|
||||
<Input.Password placeholder="留空保留现有 Secret" autoComplete="new-password" />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
{meta.source === "settings" && (
|
||||
<Popconfirm
|
||||
title="清除后台保存的 OAuth 凭据?"
|
||||
description="若未配置部署环境变量兜底,清除后账号页将无法添加账号。"
|
||||
onConfirm={() => void clear()}
|
||||
>
|
||||
<Button danger icon={<DeleteOutlined />} disabled={saving}>
|
||||
清除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
</Form>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号密码 tab:修改登录密码
|
||||
*/
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "antd";
|
||||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
batchDeleteAccounts,
|
||||
deleteAccount,
|
||||
getAccount,
|
||||
listAccounts,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type AccountDetail,
|
||||
type AccountRow,
|
||||
} from "../api.ts";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
|
||||
/** # 成员变量: 账号状态 -> Tag 颜色 **/
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -52,6 +54,8 @@ export function AccountsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [detail, setDetail] = useState<AccountDetail | null>(null);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -120,6 +124,26 @@ export function AccountsPage() {
|
||||
</span>
|
||||
</div>
|
||||
<Space>
|
||||
<BatchActions
|
||||
count={count}
|
||||
onClear={clear}
|
||||
loading={deleting}
|
||||
title={`删除选中的 ${count} 个账号?`}
|
||||
description="账号的 Worker 凭据会被撤销(已部署的 Email Worker 随后无法再向 Console 投递);域名与邮箱数据保留。"
|
||||
onDelete={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await batchDeleteAccounts(selectedIds);
|
||||
message.success(`已删除 ${res.deleted} 个账号`);
|
||||
clear();
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => void startOAuth()}>
|
||||
添加账号
|
||||
@@ -132,8 +156,16 @@ export function AccountsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
rowSelection={rowSelection}
|
||||
pagination={false}
|
||||
onRow={(row) => ({ onClick: () => void openDetail(row.id), style: { cursor: "pointer" } })}
|
||||
onRow={(row) => ({
|
||||
onClick: (e) => {
|
||||
// 点击复选框不打开详情抽屉
|
||||
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||
void openDetail(row.id);
|
||||
},
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
columns={[
|
||||
{ title: "名称", dataIndex: "name" },
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Card, Drawer, Input, Select, Space, Table, Tag, Tooltip, Typography, App } from "antd";
|
||||
import { CloudUploadOutlined, DeleteOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import {
|
||||
batchDeleteDomains,
|
||||
deleteDomain,
|
||||
listDomainAccounts,
|
||||
listDomains,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type DomainRow,
|
||||
} from "../api.ts";
|
||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
|
||||
/** # 成员变量: 域名状态 -> Tag **/
|
||||
function StatusTag({ row }: { row: DomainRow }) {
|
||||
@@ -38,6 +40,8 @@ export function DomainsPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [accountId, setAccountId] = useState<string | undefined>();
|
||||
const [editing, setEditing] = useState<DomainRow | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -98,7 +102,29 @@ export function DomainsPage() {
|
||||
/>
|
||||
<span className="muted">域名在 Cloudflare OAuth 授权后自动同步;点击域名行设置 Email Workers 与 Webhook。</span>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Space>
|
||||
<BatchActions
|
||||
count={count}
|
||||
onClear={clear}
|
||||
loading={deleting}
|
||||
title={`删除选中的 ${count} 个域名?`}
|
||||
description="这些域名下的所有邮箱(含 catch-all)及其邮件会一并删除,不可恢复。"
|
||||
onDelete={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await batchDeleteDomains(selectedIds);
|
||||
message.success(`已删除 ${res.deleted} 个域名、${res.mailboxes ?? 0} 个邮箱、${res.emails ?? 0} 封邮件`);
|
||||
clear();
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
@@ -106,8 +132,16 @@ export function DomainsPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
rowSelection={rowSelection}
|
||||
pagination={false}
|
||||
onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })}
|
||||
onRow={(row) => ({
|
||||
onClick: (e) => {
|
||||
// 点击复选框不打开详情抽屉
|
||||
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||
setEditing(row);
|
||||
},
|
||||
style: { cursor: "pointer" },
|
||||
})}
|
||||
columns={[
|
||||
{ title: "域名", dataIndex: "name", render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
|
||||
@@ -22,7 +22,16 @@ import {
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { deleteEmail, getEmail, listDomains, listEmails, type EmailDetail, type EmailRow } from "../api.ts";
|
||||
import {
|
||||
batchDeleteEmails,
|
||||
deleteEmail,
|
||||
getEmail,
|
||||
listDomains,
|
||||
listEmails,
|
||||
type EmailDetail,
|
||||
type EmailRow,
|
||||
} from "../api.ts";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
|
||||
/** # 函数
|
||||
* 字节数格式化
|
||||
@@ -79,6 +88,8 @@ export function InboxPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<EmailDetail | null>(null);
|
||||
const [htmlPreview, setHtmlPreview] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -133,16 +144,42 @@ export function InboxPage() {
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Space>
|
||||
<BatchActions
|
||||
count={count}
|
||||
onClear={clear}
|
||||
loading={deleting}
|
||||
title={`删除选中的 ${count} 封邮件?`}
|
||||
description="邮件原文与附件会从 R2 一并删除,不可恢复。"
|
||||
onDelete={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await batchDeleteEmails(selectedIds);
|
||||
message.success(`已删除 ${res.deleted} 封邮件`);
|
||||
clear();
|
||||
setDetail(null);
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
</Space>
|
||||
</div>
|
||||
<Card styles={{ body: { padding: 0 } }}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={items}
|
||||
rowSelection={rowSelection}
|
||||
rowClassName={(row) => (row.expires_at <= new Date().toISOString() ? "muted" : "")}
|
||||
onRow={(row) => ({
|
||||
onClick: async () => {
|
||||
onClick: async (e) => {
|
||||
// 点击复选框不打开邮件详情
|
||||
if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return;
|
||||
setDetail(await getEmail(row.id));
|
||||
setHtmlPreview(false);
|
||||
},
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type MailboxRow,
|
||||
} from "../api.ts";
|
||||
import { WebhookForm } from "../components/WebhookForm.tsx";
|
||||
import { BatchActions, useRowSelection } from "../components/BatchActions.tsx";
|
||||
import { batchDeleteMailboxes } from "../api.ts";
|
||||
|
||||
/** # 函数
|
||||
* 邮箱页组件
|
||||
@@ -25,6 +27,8 @@ export function MailboxesPage() {
|
||||
const [q, setQ] = useState("");
|
||||
const [webhookTarget, setWebhookTarget] = useState<MailboxRow | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const { rowSelection, ids: selectedIds, count, clear } = useRowSelection();
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -68,6 +72,26 @@ export function MailboxesPage() {
|
||||
<span className="muted">catch-all 邮箱(*@domain)会接收该域名下所有未精确匹配的邮件。</span>
|
||||
</div>
|
||||
<Space>
|
||||
<BatchActions
|
||||
count={count}
|
||||
onClear={clear}
|
||||
loading={deleting}
|
||||
title={`删除选中的 ${count} 个邮箱?`}
|
||||
description="邮箱下的邮件(含 R2 原文与附件)会一并删除,且不可恢复。"
|
||||
onDelete={async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await batchDeleteMailboxes(selectedIds);
|
||||
message.success(`已删除 ${res.deleted} 个邮箱、${res.emails ?? 0} 封邮件`);
|
||||
clear();
|
||||
void load();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "批量删除失败");
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void load()} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setOpen(true)} disabled={domains.length === 0}>
|
||||
添加邮箱
|
||||
@@ -79,6 +103,7 @@ export function MailboxesPage() {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
rowSelection={rowSelection}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user