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
+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>
))
)}