/** 账号页:添加账号(Cloudflare OAuth / API Token 同窗平铺),授权后自动接入并同步域名;搜索、详情、凭据管理 */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { Button, Card, Divider, Drawer, Form, Input, Modal, Space, Table, Tag, Timeline, Typography, App, } from "antd"; import { ApiOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SafetyOutlined, SearchOutlined } from "@ant-design/icons"; import { batchDeleteAccounts, createAccountByToken, deleteAccount, getAccount, listAccounts, oauthStart, rotateAccount, updateAccount, type AccountDetail, type AccountRow, } from "../api.ts"; import { BatchActions, useRowSelection } from "../components/BatchActions.tsx"; /** # 成员变量: 账号状态 -> Tag 颜色 **/ const STATUS_COLOR: Record = { pending_oauth: "orange", provisioning: "processing", active: "success", error: "error", }; /** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/ const STEP_LABEL: Record = { oauth: "Cloudflare OAuth 授权", discover: "Account / Zone 发现", domains: "域名与 catch-all 邮箱同步", worker: "Email Worker 部署与 Worker 凭据", }; /** # 函数 * 账号页组件 */ export function AccountsPage() { const { message, modal } = App.useApp(); const [params] = useSearchParams(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); const [q, setQ] = useState(""); const [detail, setDetail] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const [deleting, setDeleting] = useState(false); const { rowSelection, ids: selectedIds, count, clear } = useRowSelection(); const load = useCallback(async () => { setLoading(true); try { setItems((await listAccounts()).items); } finally { setLoading(false); } }, []); useEffect(() => { void load(); // 授权被 Cloudflare 拒绝时回调会带 ?oauth_error=...,把原因直接显示出来 const oauthError = params.get("oauth_error"); if (oauthError) { const detailText = params.get("oauth_error_description") ?? ""; modal.info({ title: `Cloudflare 授权未完成(${oauthError})`, content: (

{detailText || "Cloudflare 拒绝了本次授权请求。"}

常见原因:OAuth App 未 promote to public、Client URI 域名未校验、 或 App 中登记的 scope 与本应用请求的不一致(需点号分隔)。 到「设置 → Cloudflare 接入」点「检测授权配置」可看到具体原因。

), }); return; } // OAuth 回调携带 ?provision= 时自动打开详情 const provisionId = params.get("provision"); if (provisionId) { void (async () => { try { setDetail(await getAccount(provisionId)); setDetailOpen(true); } catch { // ignore } })(); } }, [load, params]); const filtered = useMemo( () => items.filter((row) => `${row.name} ${row.status} ${row.cloudflare_account_id ?? ""}` .toLowerCase() .includes(q.trim().toLowerCase()), ), [items, q], ); const openDetail = async (id: string) => { setDetail(await getAccount(id)); setDetailOpen(true); }; /** 添加账号:OAuth 与 API Token 两种方式都收在同一个模态窗口里 */ const [addOpen, setAddOpen] = useState(false); /** API Token 提交的 loading(要防重复点击,一次接入要走好几次 Cloudflare 调用) **/ const [adding, setAdding] = useState(false); const [tokenForm] = Form.useForm(); /** API Token 授权:校验 + 建账号 + 自动接入,一条龙 */ const submitToken = async () => { const values = await tokenForm.validateFields(); setAdding(true); try { const res = await createAccountByToken({ name: values.name, token: values.token.trim(), }); if (!res.ok) { message.error(res.error ?? "接入失败"); return; } message.success(`已接入账号 ${values.name},正在同步域名`); setAddOpen(false); tokenForm.resetFields(); void load(); // 直接打开详情看接入步骤,和 OAuth 回调回来的体验一致 setDetail(await getAccount(res.account_id)); setDetailOpen(true); } catch (err) { message.error(err instanceof Error ? err.message : "接入失败"); } finally { setAdding(false); } }; /** OAuth 授权:整页跳转到 Cloudflare 授权页(授权完成后浏览器重定向回 Console 回调端点) */ const startOAuth = async () => { try { const { url } = await oauthStart(); window.location.href = url; } catch (err) { message.error(err instanceof Error ? err.message : "发起授权失败"); } }; return (
} placeholder="搜索名称 / 状态 / Cloudflare 账户" style={{ width: 280 }} onChange={(e) => setQ(e.target.value)} /> 接入后自动同步域名、部署 Email Worker 并签发 Worker 凭据(Email Worker 直连 Console)。
{ 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); } }} />
({ onClick: (e) => { // 点击复选框不打开详情抽屉 if ((e.target as HTMLElement).closest(".ant-table-selection-cell")) return; void openDetail(row.id); }, style: { cursor: "pointer" }, })} columns={[ { title: "名称", dataIndex: "name" }, { title: "状态", dataIndex: "status", width: 130, render: (v: string) => {v}, }, { title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" }, { title: "接入时间", dataIndex: "created_at", width: 170, render: (v: string) => new Date(v).toLocaleString(), }, { title: "操作", width: 110, render: (_: unknown, row: AccountRow) => ( ), }, ]} /> { if (adding) return; setAddOpen(false); tokenForm.resetFields(); }} onOAuth={() => void startOAuth()} onTokenSubmit={() => void submitToken()} /> setDetailOpen(false)} onReload={async () => { if (detail) setDetail(await getAccount(detail.account.id)); void load(); }} /> ); } /** # 成员变量: 添加账号模态窗口的属性 **/ interface AddAccountModalProps { open: boolean; loading: boolean; form: ReturnType[0]; onCancel: () => void; onOAuth: () => void; onTokenSubmit: () => void; } /** # 函数 * 「添加 Cloudflare 账号」模态窗口:两种授权方式平铺展示、无需切换 —— * 顶部「Cloudflare OAuth 授权」一键跳转授权,分隔线下方是 API Token 表单 * (账户名称 + Token,账户 ID 由 Token 在 Cloudflare 侧实际所属账户自动确定)。 */ function AddAccountModal(props: AddAccountModalProps) { const { open, loading, form, onCancel, onOAuth, onTokenSubmit } = props; return (
); } /** # 函数 * 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理 */ function AccountDetailDrawer({ detail, open, onClose, onReload, }: { detail: AccountDetail | null; open: boolean; onClose: () => void; onReload: () => void; }) { const { message, modal } = App.useApp(); const [name, setName] = useState(""); useEffect(() => { if (detail) setName(detail.account.name); }, [detail]); if (!detail) return null; const steps = detail.steps; return (
接入进度 { const st = steps[key]?.status ?? "pending"; return { color: st === "done" ? "green" : st === "error" ? "red" : st === "running" ? "blue" : "gray", children: ( {label}{" "} {st === "error" && steps[key]?.error ? ( {steps[key].error} ) : ( {st} )} ), }; })} />
{detail.domains.length === 0 ? ( 暂无;OAuth 授权完成后自动同步 ) : ( detail.domains.map((d) => (
{d.name} {d.worker_id ? "已配置 Email Workers" : "未配置 Email Workers"}
)) )}
setName(e.target.value)} placeholder="账号名称" /> Worker ID:{detail.account.worker_id ?? "-"} {detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""} Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
); }