feat(admin): add operations resources and audit UI

This commit is contained in:
Jesse_Chen
2026-08-06 20:15:08 +08:00
parent 28e04857fa
commit 5622ab448a
39 changed files with 3770 additions and 293 deletions
+23 -9
View File
@@ -1,14 +1,20 @@
"use client";
import {
ApiOutlined,
ArrowLeftOutlined,
AuditOutlined,
ControlOutlined,
CreditCardOutlined,
DatabaseOutlined,
ExperimentOutlined,
GiftOutlined,
ShoppingOutlined,
MessageOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
TeamOutlined,
TransactionOutlined,
UserOutlined,
} from "@ant-design/icons";
import { Authenticated, Refine } from "@refinedev/core";
import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd";
@@ -51,14 +57,22 @@ export function AdminApp({ children }: { children: ReactNode }) {
accessControlProvider={adminAccessControlProvider}
notificationProvider={notificationProvider}
resources={[
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: <CreditCardOutlined /> } },
{ name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: <ShoppingOutlined /> } },
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
]}
{ name: "administrators", list: "/admin/administrators", meta: { label: "管理员", icon: <SafetyCertificateOutlined /> } },
{ name: "roles", list: "/admin/roles", meta: { label: "角色权限", icon: <TeamOutlined /> } },
{ name: "customers", list: "/admin/customers", meta: { label: "用户资料", icon: <UserOutlined /> } },
{ name: "products", list: "/admin/products", meta: { label: "商品权益", icon: <ShoppingOutlined /> } },
{ name: "subscriptions", list: "/admin/subscriptions", meta: { label: "订阅", icon: <CreditCardOutlined /> } },
{ name: "orders", list: "/admin/orders", meta: { label: "订单", icon: <DatabaseOutlined /> } },
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
{ name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
{ name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: <ExperimentOutlined /> } },
{ name: "models", list: "/admin/models", meta: { label: "模型配置", icon: <ApiOutlined /> } },
{ name: "model-releases", list: "/admin/model-releases", meta: { label: "模型发布", icon: <ControlOutlined /> } },
{ name: "feature-flags", list: "/admin/feature-flags", meta: { label: "功能开关", icon: <ControlOutlined /> } },
{ name: "security", list: "/admin/security", meta: { label: "安全验证", icon: <SafetyCertificateOutlined /> } },
{ name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
@@ -0,0 +1,166 @@
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { useTable } from "@refinedev/antd";
import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typography } from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const roleOptions = [
{ value: "owner", label: "Owner" },
{ value: "model_admin", label: "Model Admin" },
{ value: "billing_admin", label: "Billing Admin" },
{ value: "operations", label: "Operations" },
{ value: "support", label: "Support" },
{ value: "auditor", label: "Auditor" },
] as const;
type RoleCode = (typeof roleOptions)[number]["value"];
interface Administrator {
id: string;
email: string;
name: string;
roles: RoleCode[];
createdAt: string;
}
type AssignmentForm = { email: string; roleCode: RoleCode };
type PendingRoleAction = {
action: "assign" | "revoke";
roleCode: RoleCode;
userId?: string;
email?: string;
label: string;
};
export default function AdministratorsResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const { tableProps, searchFormProps, tableQuery } = useTable<Administrator, { message: string; statusCode: number }, { q?: string }>({
resource: "administrators",
syncWithLocation: true,
pagination: { pageSize: 20 },
onSearch: ({ q }) => [{ field: "q", operator: "contains", value: q }],
});
const [assignmentForm] = Form.useForm<AssignmentForm>();
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [assignmentUser, setAssignmentUser] = useState<Administrator | null>(null);
const [pendingAction, setPendingAction] = useState<PendingRoleAction | null>(null);
const [saving, setSaving] = useState(false);
const canManage = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
function openAssignment(item?: Administrator) {
setAssignmentUser(item ?? null);
assignmentForm.setFieldsValue({ email: item?.email ?? "", roleCode: undefined });
setAssignmentOpen(true);
}
function prepareAssignment(values: AssignmentForm) {
const role = roleOptions.find((item) => item.value === values.roleCode)!;
setPendingAction({
action: "assign",
roleCode: values.roleCode,
...(assignmentUser ? { userId: assignmentUser.id } : { email: values.email.trim() }),
label: `${assignmentUser?.email ?? values.email.trim()} · ${role.label}`,
});
}
async function submitRoleAction(reason: string) {
if (!pendingAction) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/administrators", {
method: pendingAction.action === "assign" ? "POST" : "DELETE",
body: JSON.stringify({
userId: pendingAction.userId,
email: pendingAction.email,
roleCode: pendingAction.roleCode,
reason,
}),
});
message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
setPendingAction(null);
setAssignmentOpen(false);
setAssignmentUser(null);
assignmentForm.resetFields();
await tableQuery.refetch();
} finally {
setSaving(false);
}
}
return (
<Card
title="管理员"
extra={canManage ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openAssignment()}></Button> : <Tag color="gold">RBAC</Tag>}
>
<Typography.Paragraph type="secondary">
Owner
</Typography.Paragraph>
<Form {...searchFormProps} layout="inline" style={{ marginBottom: 16 }}>
<Form.Item name="q" label="搜索">
<Input.Search allowClear placeholder="管理员邮箱或姓名" />
</Form.Item>
</Form>
<Table
{...tableProps}
rowKey="id"
scroll={{ x: "max-content" }}
columns={[
{ title: "邮箱", dataIndex: "email" },
{ title: "姓名", dataIndex: "name", render: (value: string) => value || "—" },
{
title: "角色",
dataIndex: "roles",
render: (roles: RoleCode[], item: Administrator) => <Space wrap>{roles.map((role) => <Tag
key={role}
closable={canManage}
onClose={(event) => {
event.preventDefault();
setPendingAction({ action: "revoke", roleCode: role, userId: item.id, label: `${item.email} · ${role}` });
}}
>{role}</Tag>)}</Space>,
},
{ title: "加入时间", dataIndex: "createdAt", render: formatAdminDate },
...(canManage ? [{ title: "操作", fixed: "right" as const, render: (_: unknown, item: Administrator) => <Button type="link" onClick={() => openAssignment(item)}></Button> }] : []),
]}
/>
<Modal
title={assignmentUser ? `${assignmentUser.email} 分配角色` : "按邮箱分配管理员角色"}
open={assignmentOpen}
okText="继续验证"
cancelText="取消"
onOk={() => assignmentForm.submit()}
onCancel={() => setAssignmentOpen(false)}
destroyOnHidden
>
<Form<AssignmentForm> form={assignmentForm} layout="vertical" onFinish={prepareAssignment}>
<Form.Item name="email" label="用户邮箱" rules={[{ required: true }, { type: "email" }]}>
<Input disabled={Boolean(assignmentUser)} autoComplete="email" />
</Form.Item>
<Form.Item name="roleCode" label="角色" rules={[{ required: true }]}>
<Select
placeholder="选择要分配的角色"
options={roleOptions.filter((role) => !assignmentUser?.roles.includes(role.value))}
/>
</Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingAction)}
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
okText={pendingAction?.action === "revoke" ? "验证并撤销" : "验证并分配"}
danger={pendingAction?.action === "revoke"}
confirmLoading={saving}
reauthPermission="admin.users.manage_roles"
onCancel={() => setPendingAction(null)}
onSubmit={submitRoleAction}
/>
</Card>
);
}
@@ -0,0 +1,572 @@
"use client";
import { useTable } from "@refinedev/antd";
import { useGetIdentity, useInvalidate } from "@refinedev/core";
import { List } from "@refinedev/antd";
import {
App,
Button,
Form,
InputNumber,
Modal,
Space,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate, ResourceTable } from "./resource-table";
const { Text } = Typography;
type Subscription = {
id: string;
userId: string;
email: string | null;
productCode: string;
productVersion: number;
status: string;
startsAt: string;
endsAt: string;
createdAt: string;
};
type AdjustmentForm = { days: number };
export function SubscriptionsResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const table = useTable<Subscription>({
resource: "subscriptions",
syncWithLocation: true,
});
const [form] = Form.useForm<AdjustmentForm>();
const [selected, setSelected] = useState<Subscription | null>(null);
const [saving, setSaving] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<Subscription | null>(null);
const [extendDays, setExtendDays] = useState<number | null>(null);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function adjust(
item: Subscription,
action: "extend" | "revoke",
days: number | undefined,
reason: string,
) {
await adminRequestJson("/api/admin/subscriptions", {
method: "POST",
body: JSON.stringify({
id: item.id,
action,
days,
expectedEndsAt: item.endsAt,
reason,
}),
});
await table.tableQuery.refetch();
}
function prepareExtend(values: AdjustmentForm) {
setExtendDays(values.days);
}
async function extend(reason: string) {
if (!selected || extendDays === null) return;
setSaving(true);
try {
await adjust(selected, "extend", extendDays, reason);
message.success("订阅已延长");
setExtendDays(null);
setSelected(null);
form.resetFields();
} finally {
setSaving(false);
}
}
async function revoke(item: Subscription, reason: string) {
setRevokingId(item.id);
try {
await adjust(item, "revoke", undefined, reason);
message.success("订阅已撤销");
setRevokeTarget(null);
} finally {
setRevokingId(null);
}
}
const columns: TableColumnsType<Subscription> = [
{
title: "用户",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text>{item.email ?? "未记录邮箱"}</Text>
<Text type="secondary" copyable>
{item.userId}
</Text>
</Space>
),
},
{
title: "商品",
render: (_, item) => `${item.productCode} · v${item.productVersion}`,
},
{
title: "状态",
dataIndex: "status",
render: (value) => (
<Tag
color={
value === "active"
? "green"
: value === "revoked"
? "red"
: "default"
}
>
{value}
</Tag>
),
},
{ title: "开始", dataIndex: "startsAt", render: formatAdminDate },
{ title: "到期", dataIndex: "endsAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => (
<Space>
{canAdjust && (
<Button
type="link"
disabled={item.status !== "active"}
onClick={() => {
setSelected(item);
setExtendDays(null);
form.setFieldsValue({ days: 30 });
}}
>
</Button>
)}
{canAdjust && (
<Button
type="link"
danger
disabled={item.status !== "active"}
loading={revokingId === item.id}
onClick={() => setRevokeTarget(item)}
>
</Button>
)}
</Space>
),
},
];
return (
<List title="用户订阅">
<Table
{...table.tableProps}
columns={columns}
rowKey="id"
scroll={{ x: "max-content" }}
/>
<Modal
title="人工延长订阅"
open={Boolean(selected && extendDays === null)}
okText="继续验证"
cancelText="取消"
onOk={() => form.submit()}
onCancel={() => setSelected(null)}
destroyOnHidden
>
<Form<AdjustmentForm>
form={form}
layout="vertical"
onFinish={prepareExtend}
>
<Form.Item name="days" label="延长天数" rules={[{ required: true }]}>
<InputNumber
min={1}
max={3660}
precision={0}
style={{ width: "100%" }}
/>
</Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(selected && extendDays !== null)}
title="延长订阅权益"
okText="验证并延长"
confirmLoading={saving}
reauthPermission="billing.adjustments.write"
onCancel={() => setExtendDays(null)}
onSubmit={extend}
/>
<ReasonActionModal
open={Boolean(revokeTarget)}
title="撤销订阅权益"
okText="确认撤销"
danger
confirmLoading={Boolean(revokingId)}
reauthPermission="billing.adjustments.write"
onCancel={() => setRevokeTarget(null)}
onSubmit={(reason) => revoke(revokeTarget!, reason)}
/>
</List>
);
}
type Order = {
id: string;
orderNo: string;
userId: string;
email: string | null;
productCode: string | null;
productVersion: number | null;
moneyCents: number;
currency: string;
status: string;
grantType: string | null;
grantStatus: string;
grantError: string | null;
adjustmentVersion: number;
refundStatus: string;
refundAmountCents: number | null;
refundedAt: string | null;
paidAt: string | null;
createdAt: string;
};
export function OrdersResource() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const invalidate = useInvalidate();
const [target, setTarget] = useState<{
order: Order;
action: "retry_grant" | "compensate" | "record_refund";
} | null>(null);
const [saving, setSaving] = useState(false);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function adjust(reason: string) {
if (!target) return;
setSaving(true);
try {
const result = await adminRequestJson<{
data: { actionSuccess: boolean };
}>("/api/admin/orders", {
method: "POST",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({
id: target.order.id,
action: target.action,
expectedVersion: target.order.adjustmentVersion,
reason,
}),
});
await invalidate({ resource: "orders", invalidates: ["list"] });
if (target.action === "retry_grant" && !result.data.actionSuccess) {
message.warning("已执行重试,但权益发放仍失败,请查看最新错误");
} else {
message.success(
target.action === "record_refund"
? "已记录账务全额退款状态"
: "订单权益操作已完成",
);
}
setTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "订单操作失败");
} finally {
setSaving(false);
}
}
const columns: TableColumnsType<Order> = [
{
title: "订单",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text copyable>{item.orderNo}</Text>
<Text type="secondary">{formatAdminDate(item.createdAt)}</Text>
</Space>
),
},
{ title: "用户", render: (_, item) => item.email ?? item.userId },
{
title: "商品",
render: (_, item) =>
item.productCode
? `${item.productCode} · v${item.productVersion}`
: "旧积分订单",
},
{
title: "金额",
render: (_, item) =>
`${item.currency} ${(item.moneyCents / 100).toFixed(2)}`,
},
{
title: "支付",
dataIndex: "status",
render: (value) => (
<Tag
color={
value === "paid"
? "green"
: value === "refunded"
? "orange"
: "default"
}
>
{value}
</Tag>
),
},
{
title: "权益发放",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Tag
color={
item.grantStatus === "granted"
? "green"
: item.grantStatus === "failed"
? "red"
: "gold"
}
>
{item.grantStatus}
</Tag>
{item.grantError && <Text type="danger">{item.grantError}</Text>}
</Space>
),
},
{
title: "退款账务",
render: (_, item) =>
item.refundStatus === "recorded" ? (
<Space direction="vertical" size={0}>
<Tag color="orange"></Tag>
<Text type="secondary">{formatAdminDate(item.refundedAt)}</Text>
</Space>
) : (
"—"
),
},
{ title: "支付时间", dataIndex: "paidAt", render: formatAdminDate },
{
title: "领域动作",
fixed: "right",
render: (_, item) => canAdjust ? (
<Space wrap>
<Button
size="small"
disabled={item.grantStatus !== "failed"}
onClick={() => setTarget({ order: item, action: "retry_grant" })}
>
</Button>
<Button
size="small"
disabled={
item.grantStatus !== "failed" || item.grantType !== "credits"
}
onClick={() => setTarget({ order: item, action: "compensate" })}
>
</Button>
<Button
size="small"
danger
disabled={
item.refundStatus !== "none" ||
item.paidAt == null ||
item.status === "refunded"
}
onClick={() => setTarget({ order: item, action: "record_refund" })}
>
退
</Button>
</Space>
) : null,
},
];
const modalTitle =
target?.action === "retry_grant"
? "重试失败的权益发放"
: target?.action === "compensate"
? "人工补偿失败的积分权益"
: "仅记录账务全额退款(不会调用支付网关)";
return (
<>
<ResourceTable<Order>
resource="orders"
title="支付订单与权益发放"
columns={columns}
statusOptions={[
"pending",
"paid",
"failed",
"granted",
"refunded",
"recorded",
].map((value) => ({ value, label: value }))}
/>
<ReasonActionModal
open={Boolean(target)}
title={modalTitle}
okText="确认执行"
danger={target?.action === "record_refund"}
confirmLoading={saving}
reauthPermission="billing.adjustments.write"
onCancel={() => setTarget(null)}
onSubmit={adjust}
/>
</>
);
}
type Usage = {
id: string;
userId: string;
email: string | null;
requestId: string;
featureKey: string;
source: string;
requestedModelId: string | null;
actualModelId: string | null;
modelConfigVersion: number | null;
inputTokens: number;
outputTokens: number;
costMicrousd: number;
durationMs: number | null;
createdAt: string;
};
export function UsageResource() {
const columns: TableColumnsType<Usage> = [
{
title: "请求",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text copyable>{item.requestId}</Text>
<Text type="secondary">{item.email ?? item.userId}</Text>
</Space>
),
},
{ title: "功能", dataIndex: "featureKey" },
{
title: "资金来源",
dataIndex: "source",
render: (value) => (
<Tag color={value === "subscription" ? "green" : "blue"}>{value}</Tag>
),
},
{
title: "模型",
render: (_, item) => (
<Space direction="vertical" size={0}>
<Text>{item.actualModelId ?? "—"}</Text>
<Text type="secondary">
{item.requestedModelId ?? "—"} · v
{item.modelConfigVersion ?? "—"}
</Text>
</Space>
),
},
{
title: "Token",
render: (_, item) =>
`${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`,
},
{
title: "成本",
dataIndex: "costMicrousd",
render: (value: number) => `$${(value / 1_000_000).toFixed(6)}`,
},
{
title: "耗时",
dataIndex: "durationMs",
render: (value) => (value == null ? "—" : `${value} ms`),
},
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
];
return (
<ResourceTable<Usage>
resource="usage"
title="用量、Token 与成本"
columns={columns}
statusOptions={[
"subscription",
"credits",
"complimentary",
"chat.standard",
"rectification",
].map((value) => ({ value, label: value }))}
/>
);
}
type ModelRelease = {
id: string;
modelId: string;
fromVersion: number | null;
toVersion: number;
action: string;
actorEmail: string | null;
reason: string;
requestId: string;
createdAt: string;
};
export function ModelReleasesResource() {
const columns: TableColumnsType<ModelRelease> = [
{ title: "模型", dataIndex: "modelId" },
{
title: "版本",
render: (_, item) => `${item.fromVersion ?? "—"}${item.toVersion}`,
},
{
title: "动作",
dataIndex: "action",
render: (value) => (
<Tag color={value === "publish" ? "green" : "gold"}>{value}</Tag>
),
},
{
title: "管理员",
dataIndex: "actorEmail",
render: (value) => value ?? "—",
},
{ title: "原因", dataIndex: "reason" },
{
title: "请求 ID",
dataIndex: "requestId",
render: (value) => <Text copyable>{value}</Text>,
},
{ title: "时间", dataIndex: "createdAt", render: formatAdminDate },
];
return (
<ResourceTable<ModelRelease>
resource="model-releases"
title="模型发布与回滚历史"
columns={columns}
statusOptions={["publish", "rollback"].map((value) => ({
value,
label: value,
}))}
/>
);
}
+257 -75
View File
@@ -1,12 +1,33 @@
"use client";
import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core";
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd";
import {
useCreate,
useGetIdentity,
useInvalidate,
useUpdate,
} from "@refinedev/core";
import {
App,
Button,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Space,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import dayjs from "dayjs";
import { useState } from "react";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import type { AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
import {
formatAdminDate,
ResourceTable,
} from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
type CodeRecord = {
id: string;
@@ -28,7 +49,10 @@ type CreateValues = {
expiresAt?: ReturnType<typeof dayjs>;
note?: string;
};
type EditValues = { note?: string; expiresAt?: ReturnType<typeof dayjs> | null };
type EditValues = {
note?: string;
expiresAt?: ReturnType<typeof dayjs> | null;
};
const statusColors: Record<CodeRecord["status"], string> = {
available: "green",
@@ -38,90 +62,144 @@ const statusColors: Record<CodeRecord["status"], string> = {
};
export default function CodesPage() {
const { data: role } = usePermissions<"admin">({});
const { message } = App.useApp();
const invalidate = useInvalidate();
const { data: identity } = useGetIdentity<AdminIdentity>();
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
const { mutate: revokeCode, mutation: revokeMutation } = useDelete<CodeRecord>();
const { mutateAsync: createCodes, mutation: createMutation } = useCreate<{
id: string;
generated: CodeRecord[];
}>();
const { mutateAsync: updateCode, mutation: updateMutation } =
useUpdate<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [pendingCreate, setPendingCreate] = useState<CreateValues | null>(null);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [pendingEdit, setPendingEdit] = useState<EditValues | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [revokeRecord, setRevokeRecord] = useState<CodeRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = role === "admin";
const writable = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
function submitCreate(values: CreateValues) {
createCodes({
async function submitCreate(reason: string) {
if (!pendingCreate) return;
const result = await createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
credits: pendingCreate.credits,
count: pendingCreate.count,
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
note: pendingCreate.note?.trim() || null,
reason,
},
successNotification: false,
}, {
onSuccess(result) {
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
},
});
setGenerated(result.data.generated);
setPendingCreate(null);
setCreateOpen(false);
createForm.resetFields();
}
function submitEdit(values: EditValues) {
if (!editRecord) return;
updateCode({
async function submitEdit(reason: string) {
if (!editRecord || !pendingEdit) return;
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: pendingEdit.note?.trim() || null,
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
reason,
},
}, { onSuccess: () => setEditRecord(null) });
});
setPendingEdit(null);
setEditRecord(null);
}
function confirmRevoke(record: CodeRecord) {
Modal.confirm({
title: "撤销此兑换码?",
content: `${record.mask} 撤销后不可兑换,且不能恢复。`,
okText: "确认撤销",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => new Promise<void>((resolve, reject) => {
revokeCode({ resource: "codes", id: record.id }, {
onSuccess: () => resolve(),
onError: () => reject(new Error("撤销失败")),
});
}),
});
async function revoke(record: CodeRecord, reason: string) {
setRevoking(true);
try {
await adminRequestJson(`/api/admin/codes/${record.id}`, {
method: "DELETE",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({ reason }),
});
await invalidate({ resource: "codes", invalidates: ["list"] });
message.success("兑换码已撤销");
setRevokeRecord(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销失败");
} finally {
setRevoking(false);
}
}
const columns: TableColumnsType<CodeRecord> = [
{ title: "兑换码", dataIndex: "mask" },
{ title: "点数", dataIndex: "credits", sorter: true },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag> },
{ title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate },
{
title: "状态",
dataIndex: "status",
sorter: true,
render: (value) => (
<Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag>
),
},
{
title: "到期时间",
dataIndex: "expiresAt",
sorter: true,
render: formatAdminDate,
},
{ title: "备注", dataIndex: "note", render: (value) => value || "—" },
{ title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" },
{
title: "兑换账户",
dataIndex: "redeemedEmail",
render: (value) => value || "—",
},
{ title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate },
{ title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{
title: "创建时间",
dataIndex: "createdAt",
sorter: true,
render: formatAdminDate,
},
{
title: "操作",
fixed: "right",
render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? (
<Space>
<Button size="small" onClick={() => {
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}></Button>
<Button danger size="small" loading={revokeMutation.isPending} onClick={() => confirmRevoke(record)}></Button>
</Space>
) : "—",
render: (_, record) =>
writable &&
record.status !== "redeemed" &&
record.status !== "revoked" ? (
<Space>
<Button
size="small"
onClick={() => {
setPendingEdit(null);
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}
>
</Button>
<Button
danger
size="small"
loading={revoking && revokeRecord?.id === record.id}
onClick={() => setRevokeRecord(record)}
>
</Button>
</Space>
) : (
"—"
),
},
];
@@ -129,7 +207,7 @@ export default function CodesPage() {
<>
<ResourceTable<CodeRecord>
resource="codes"
title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`}
title={`兑换码${identity ? ` · ${identity.email} (${identity.roles.join("、")})` : ""}`}
columns={columns}
statusOptions={[
{ label: "可用", value: "available" },
@@ -137,31 +215,135 @@ export default function CodesPage() {
{ label: "已兑换", value: "redeemed" },
{ label: "已撤销", value: "revoked" },
]}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : null}
extra={
writable ? (
<Button
type="primary"
onClick={() => {
setPendingCreate(null);
setCreateOpen(true);
}}
>
</Button>
) : null
}
/>
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
<Form form={createForm} layout="vertical" initialValues={{ credits: 10, count: 1 }} onFinish={submitCreate}>
<Form.Item name="credits" label="每个点数" rules={[{ required: true }]}><InputNumber min={1} max={1_000_000} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={100} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={createMutation.isPending}></Button>
<Modal
title="批量生成兑换码"
open={createOpen}
onCancel={() => {
setPendingCreate(null);
setCreateOpen(false);
}}
footer={null}
destroyOnHidden
>
<Form
form={createForm}
layout="vertical"
initialValues={{ credits: 10, count: 1 }}
onFinish={setPendingCreate}
>
<Form.Item
name="credits"
label="每个点数"
rules={[{ required: true }]}
>
<InputNumber min={1} max={1_000_000} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}>
<InputNumber min={1} max={100} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input maxLength={500} />
</Form.Item>
<Button
block
type="primary"
htmlType="submit"
loading={createMutation.isPending}
>
</Button>
</Form>
</Modal>
<Modal title="完整兑换码(仅显示本次)" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button onClick={() => setGenerated([])}></Button>}>
<Typography.Paragraph type="warning"></Typography.Paragraph>
{generated.map((record) => <Typography.Paragraph copyable key={record.code}><Typography.Text code>{record.code}</Typography.Text></Typography.Paragraph>)}
<Modal
title="完整兑换码(仅显示本次)"
open={generated.length > 0}
onCancel={() => setGenerated([])}
footer={<Button onClick={() => setGenerated([])}></Button>}
>
<Typography.Paragraph type="warning">
</Typography.Paragraph>
{generated.map((record) => (
<Typography.Paragraph copyable key={record.code}>
<Typography.Text code>{record.code}</Typography.Text>
</Typography.Paragraph>
))}
</Modal>
<Modal title="编辑未兑换码" open={Boolean(editRecord)} onCancel={() => setEditRecord(null)} footer={null} destroyOnHidden>
<Form form={editForm} layout="vertical" onFinish={submitEdit}>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={updateMutation.isPending}></Button>
<Modal
title="编辑未兑换码"
open={Boolean(editRecord)}
onCancel={() => {
setPendingEdit(null);
setEditRecord(null);
}}
footer={null}
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={setPendingEdit}>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input maxLength={500} />
</Form.Item>
<Button
block
type="primary"
htmlType="submit"
loading={updateMutation.isPending}
>
</Button>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="验证并生成"
confirmLoading={createMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingCreate(null)}
onSubmit={submitCreate}
/>
<ReasonActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="验证并保存"
confirmLoading={updateMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingEdit(null)}
onSubmit={submitEdit}
/>
<ReasonActionModal
open={Boolean(revokeRecord)}
title="撤销兑换码"
okText="确认撤销"
danger
confirmLoading={revoking}
reauthPermission="billing.adjustments.write"
onCancel={() => setRevokeRecord(null)}
onSubmit={(reason) => revoke(revokeRecord!, reason)}
/>
</>
);
}
@@ -0,0 +1,170 @@
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { useTable } from "@refinedev/antd";
import { List } from "@refinedev/antd";
import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type FeatureFlag = {
id: string;
flagKey: string;
version: number;
enabled: boolean;
rolloutPercentage: number;
config: Record<string, unknown>;
status: string;
createdAt: string;
publishedAt: string | null;
};
type FlagFilters = { q?: string; status?: string };
type FlagForm = {
flagKey: string;
enabled: boolean;
rolloutPercentage: number;
configJson: string;
reason: string;
};
export default function FeatureFlagsManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const table = useTable<FeatureFlag, { message: string; statusCode: number }, FlagFilters>({
resource: "feature-flags",
syncWithLocation: true,
pagination: { pageSize: 20 },
onSearch: ({ q, status }) => [
{ field: "q", operator: "contains", value: q },
{ field: "status", operator: "eq", value: status },
],
});
const [form] = Form.useForm<FlagForm>();
const [editing, setEditing] = useState<FeatureFlag | null>(null);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<FeatureFlag | null>(null);
const canWrite = Boolean(identity?.permissions.includes("ops.flags.write"));
function edit(item?: FeatureFlag) {
setEditing(item ?? null);
form.setFieldsValue(item ? {
flagKey: item.flagKey,
enabled: item.enabled,
rolloutPercentage: item.rolloutPercentage,
configJson: JSON.stringify(item.config, null, 2),
reason: "",
} : {
flagKey: "",
enabled: false,
rolloutPercentage: 0,
configJson: "{}",
reason: "",
});
setOpen(true);
}
async function save(values: FlagForm) {
setSaving(true);
try {
let config: unknown;
try {
config = JSON.parse(values.configJson);
} catch {
throw new Error("配置 JSON 格式不正确");
}
await adminRequestJson("/api/admin/feature-flags", {
method: "POST",
body: JSON.stringify({
action: "save",
id: editing?.id ?? null,
flagKey: values.flagKey.trim(),
enabled: values.enabled,
rolloutPercentage: values.rolloutPercentage,
config,
expectedVersion: editing?.version ?? null,
reason: values.reason.trim(),
}),
});
message.success("功能开关草稿已保存");
setOpen(false);
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function publish(item: FeatureFlag, reason: string) {
setPublishingId(item.id);
try {
await adminRequestJson("/api/admin/feature-flags", {
method: "POST",
body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version, reason }),
});
message.success("功能开关已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} finally {
setPublishingId(null);
}
}
const columns: TableColumnsType<FeatureFlag> = [
{ title: "开关", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.flagKey}</Text><Text type="secondary">v{item.version}</Text></Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "灰度", dataIndex: "rolloutPercentage", render: (value) => `${value}%` },
{ title: "配置", dataIndex: "config", render: (value) => <Text code>{JSON.stringify(value)}</Text> },
{ title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}</Space>,
},
];
return <List title="功能开关" headerButtons={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => edit()}></Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form {...table.searchFormProps} layout="inline" style={{ rowGap: 8 }}>
<Form.Item name="q" label="搜索"><Input.Search allowClear placeholder="开关键" /></Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
/>
</Form.Item>
</Form>
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.flagKey}` : "新增功能开关"} open={open} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
<Form<FlagForm> form={form} layout="vertical" onFinish={save}>
<Form.Item name="flagKey" label="开关键" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9._-]{1,99}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item>
<Form.Item name="rolloutPercentage" label="灰度百分比" rules={[{ required: true }]}><InputNumber min={0} max={100} precision={0} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
<Form.Item name="configJson" label="配置 JSON" rules={[{ required: true }]}><Input.TextArea rows={6} spellCheck={false} /></Form.Item>
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(publishTarget)}
title={`发布功能开关${publishTarget ? `${publishTarget.flagKey}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
reauthPermission="ops.flags.write"
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
</List>;
}
@@ -0,0 +1,279 @@
"use client";
import {
Alert,
Button,
Card,
Descriptions,
Divider,
Form,
Input,
List,
Radio,
Space,
Spin,
Tag,
Typography,
} from "antd";
import { useEffect, useState } from "react";
const { Paragraph, Text, Title } = Typography;
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
expiresIn?: number;
};
type Enrollment = {
totpUri: string;
backupCodes: string[];
verificationRequired: true;
};
type Factor = "totp" | "backup";
async function mfaRequest<T>(body?: object): Promise<T> {
const response = await fetch("/api/admin/mfa", {
method: body ? "POST" : "GET",
cache: "no-store",
credentials: "same-origin",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const payload = await response.json().catch(() => null) as { data?: T; error?: unknown } | null;
if (!response.ok || !payload?.data) {
throw new Error(typeof payload?.error === "string" ? payload.error : "MFA 请求失败,请稍后再试");
}
return payload.data;
}
export default function MfaSecurity() {
const [status, setStatus] = useState<MfaStatus>();
const [enrollment, setEnrollment] = useState<Enrollment>();
const [backupCodes, setBackupCodes] = useState<string[]>();
const [factor, setFactor] = useState<Factor>("totp");
const [loading, setLoading] = useState(true);
const [action, setAction] = useState<string>();
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
useEffect(() => {
let cancelled = false;
void mfaRequest<MfaStatus>()
.then((value) => {
if (!cancelled) setStatus(value);
})
.catch((cause: unknown) => {
if (!cancelled) {
setError(cause instanceof Error ? cause.message : "无法读取 MFA 状态");
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, []);
async function run<T>(name: string, operation: () => Promise<T>): Promise<T | undefined> {
setAction(name);
setError(undefined);
setNotice(undefined);
try {
return await operation();
} catch (cause) {
setError(cause instanceof Error ? cause.message : "操作失败,请稍后再试");
} finally {
setAction(undefined);
}
}
async function enroll({ password }: { password: string }) {
const data = await run("enroll", () => mfaRequest<Enrollment>({ action: "enroll", password }));
if (!data) return;
setEnrollment(data);
setBackupCodes(data.backupCodes);
setNotice("请先保存恢复码,再使用认证器生成的 6 位验证码完成 enrollment。未验证前 MFA 不会启用。");
}
async function verify({ code }: { code: string }) {
const data = await run("verify", () => mfaRequest<MfaStatus>({ action: "verify", code }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setNotice("当前管理员 session 已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。");
}
async function recover({ code }: { code: string }) {
const data = await run("recover", () => mfaRequest<MfaStatus>({ action: "recover", code }));
if (!data) return;
setStatus(data);
setNotice("恢复码已消费,当前 session 已完成 MFA 验证。请在恢复访问后重新生成恢复码。");
}
async function regenerate({ password }: { password: string }) {
const data = await run("regenerate", () => mfaRequest<{ backupCodes: string[] }>({
action: "regenerate",
password,
}));
if (!data) return;
setBackupCodes(data.backupCodes);
setNotice("新的恢复码已生成,旧恢复码已全部失效。请立即离线保存。");
}
async function disable({ password }: { password: string }) {
const data = await run("disable", () => mfaRequest<MfaStatus>({ action: "disable", password }));
if (!data) return;
setStatus(data);
setEnrollment(undefined);
setBackupCodes(undefined);
setNotice(data.required
? "MFA 已禁用。该角色要求 MFA,因此高风险写入现已关闭,重新 enrollment 后才能恢复。"
: "MFA 已禁用,当前 MFA 与邮箱重认证证明均已撤销。");
}
if (loading && !status) {
return <Card><Space><Spin /><Text> MFA </Text></Space></Card>;
}
return <Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={2}></Title>
<Paragraph type="secondary">
MFA 使 Better Auth TOTP MFA server session session
</Paragraph>
</div>
{error ? <Alert type="error" showIcon message={error} /> : null}
{notice ? <Alert type="success" showIcon message={notice} /> : null}
{status?.required && !status.enrolled ? <Alert
type="warning"
showIcon
message="当前角色强制要求 MFA"
description="在 enrollment 完成前,所有高风险写入都会 fail closed。"
/> : null}
<Card title="当前状态">
<Descriptions column={{ xs: 1, sm: 2 }}>
<Descriptions.Item label="角色要求">
<Tag color={status?.required ? "red" : "default"}>{status?.required ? "必须 MFA" : "可选 MFA"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Enrollment">
<Tag color={status?.enrolled ? "green" : "orange"}>{status?.enrolled ? "已启用" : "未启用"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="当前 session">
<Tag color={status?.verified ? "green" : "default"}>{status?.verified ? "已验证" : "未验证"}</Tag>
</Descriptions.Item>
<Descriptions.Item label="高风险写入前置能力">
<Tag color={status?.highRiskWritesEnabled ? "green" : "red"}>
{status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"}
</Tag>
</Descriptions.Item>
</Descriptions>
</Card>
{!status?.enrolled && !enrollment ? <Card title="启用 TOTP MFA">
<Paragraph> URI seed </Paragraph>
<Form layout="vertical" onFinish={enroll} style={{ maxWidth: 520 }}>
<Form.Item name="password" label="当前密码" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "enroll"}> enrollment</Button>
</Form>
</Card> : null}
{enrollment ? <Card title="完成 enrollment">
<Alert
type="info"
showIcon
message="先保存恢复码,再验证 TOTP"
description="刷新页面不会再次显示这些明文恢复码。不要把 URI、seed 或恢复码粘贴到日志、工单或聊天中。"
/>
<Divider orientation="left"> URI</Divider>
<Paragraph copyable={{ text: enrollment.totpUri }} code style={{ overflowWrap: "anywhere" }}>
{enrollment.totpUri}
</Paragraph>
<Divider orientation="left"></Divider>
<BackupCodeList codes={backupCodes ?? enrollment.backupCodes} />
<Divider />
<Form layout="vertical" onFinish={verify} style={{ maxWidth: 520 }}>
<Form.Item name="code" label="认证器验证码" rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify"}> MFA</Button>
</Form>
</Card> : null}
{status?.enrolled && !status.verified ? <Card title="验证当前 session">
<Paragraph></Paragraph>
<Radio.Group value={factor} onChange={(event) => setFactor(event.target.value as Factor)}>
<Radio.Button value="totp"></Radio.Button>
<Radio.Button value="backup"></Radio.Button>
</Radio.Group>
<Form
key={factor}
layout="vertical"
onFinish={factor === "totp" ? verify : recover}
style={{ maxWidth: 520, marginTop: 16 }}
>
<Form.Item
name="code"
label={factor === "totp" ? "6 位验证码" : "一次性恢复码"}
rules={factor === "totp"
? [{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]
: [{ required: true, whitespace: true }]}
>
<Input
inputMode={factor === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
maxLength={factor === "totp" ? 6 : 128}
/>
</Form.Item>
<Button type="primary" htmlType="submit" loading={action === "verify" || action === "recover"}>
session
</Button>
</Form>
</Card> : null}
{status?.enrolled && status.verified ? <Card title="恢复与禁用">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<div>
<Title level={4}></Title>
<Paragraph type="secondary"></Paragraph>
<Form layout="inline" onFinish={regenerate}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button htmlType="submit" loading={action === "regenerate"}></Button>
</Form>
</div>
{backupCodes?.length ? <BackupCodeList codes={backupCodes} /> : null}
<Divider />
<div>
<Title level={4}> MFA</Title>
<Paragraph type="secondary"> Better Auth session MFA </Paragraph>
<Form layout="inline" onFinish={disable}>
<Form.Item name="password" rules={[{ required: true, message: "请输入当前密码" }]}>
<Input.Password placeholder="当前密码" autoComplete="current-password" maxLength={128} />
</Form.Item>
<Button danger htmlType="submit" loading={action === "disable"}> MFA</Button>
</Form>
</div>
</Space>
</Card> : null}
</Space>;
}
function BackupCodeList({ codes }: { codes: string[] }) {
return <List
bordered
size="small"
dataSource={codes}
grid={{ gutter: 8, xs: 1, sm: 2, md: 3 }}
renderItem={(code) => <List.Item><Text code>{code}</Text></List.Item>}
/>;
}
@@ -0,0 +1,408 @@
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { List } from "@refinedev/antd";
import {
App,
Button,
Card,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useCallback, useEffect, useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type Provider = {
id: string;
code: string;
name: string;
providerType: "openai" | "openai-compatible";
baseUrl: string | null;
secretConfigured: boolean;
enabled: boolean;
updatedAt: string;
};
type ModelVersion = {
id: string;
configId: string;
modelId: string;
version: number;
providerId: string;
providerCode: string;
label: string;
description: string;
providerModel: string;
modelTier: "standard" | "premium" | "internal";
creditCost: number;
contextWindow: number | null;
inputCostMicrousdPerMillion: number;
outputCostMicrousdPerMillion: number;
enabled: boolean;
isDefault: boolean;
fallbackModelId: string | null;
status: string;
settings: Record<string, unknown>;
createdAt: string;
publishedAt: string | null;
};
type ProviderForm = {
code: string;
name: string;
providerType: "openai" | "openai-compatible";
baseUrl: string | null;
enabled: boolean;
};
type ModelForm = Omit<ModelVersion, "id" | "configId" | "version" | "providerCode" | "status" | "createdAt" | "publishedAt" | "settings"> & {
versionId?: string;
settingsJson: string;
reason: string;
};
type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] };
type ModelFilters = { q?: string; status?: string };
type VersionAction = { action: "publish" | "rollback"; model: ModelVersion };
export default function ModelManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [providerForm] = Form.useForm<ProviderForm>();
const [modelForm] = Form.useForm<ModelForm>();
const [filterForm] = Form.useForm<ModelFilters>();
const [models, setModels] = useState<ModelVersion[]>([]);
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
const [providerOpen, setProviderOpen] = useState(false);
const [modelOpen, setModelOpen] = useState(false);
const [editingProvider, setEditingProvider] = useState<Provider | null>(null);
const [editingModel, setEditingModel] = useState<ModelVersion | null>(null);
const [saving, setSaving] = useState(false);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [pendingProvider, setPendingProvider] = useState<Record<string, unknown> | null>(null);
const [filters, setFilters] = useState<ModelFilters>({});
const canWrite = Boolean(identity?.permissions.includes("models.write"));
const canTest = Boolean(identity?.permissions.includes("models.test"));
const canPublish = Boolean(identity?.permissions.includes("models.publish"));
const canRollback = Boolean(identity?.permissions.includes("models.rollback"));
const load = useCallback(async () => {
setLoading(true);
try {
const search = new URLSearchParams({ page: "1", pageSize: "100" });
if (filters.q) search.set("q", filters.q);
if (filters.status) search.set("status", filters.status);
const payload = await adminRequestJson<ModelsPayload>(`/api/admin/models?${search}`);
setModels(payload.data);
setProviders(payload.providers);
} catch (error) {
message.error(error instanceof Error ? error.message : "读取模型配置失败");
} finally {
setLoading(false);
}
}, [filters.q, filters.status, message]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
function openProvider(provider?: Provider) {
setEditingProvider(provider ?? null);
providerForm.setFieldsValue(provider ? {
...provider,
baseUrl: provider.baseUrl,
} : {
code: "",
name: "",
providerType: "openai-compatible",
baseUrl: "https://",
enabled: false,
});
setProviderOpen(true);
}
function openModel(model?: ModelVersion) {
setEditingModel(model ?? null);
modelForm.setFieldsValue(model ? {
modelId: model.modelId,
versionId: model.id,
providerId: model.providerId,
label: model.label,
description: model.description,
providerModel: model.providerModel,
modelTier: model.modelTier,
creditCost: model.creditCost,
contextWindow: model.contextWindow,
inputCostMicrousdPerMillion: model.inputCostMicrousdPerMillion,
outputCostMicrousdPerMillion: model.outputCostMicrousdPerMillion,
enabled: model.enabled,
isDefault: model.isDefault,
fallbackModelId: model.fallbackModelId,
settingsJson: JSON.stringify(model.settings, null, 2),
reason: "",
} : {
modelId: "",
providerId: providers[0]?.id,
label: "",
description: "",
providerModel: "",
modelTier: "standard",
creditCost: 1,
contextWindow: null,
inputCostMicrousdPerMillion: 0,
outputCostMicrousdPerMillion: 0,
enabled: false,
isDefault: false,
fallbackModelId: null,
settingsJson: "{}",
reason: "",
});
setModelOpen(true);
}
function prepareProviderSave(values: ProviderForm) {
setPendingProvider({
action: "saveProvider",
id: editingProvider?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
providerType: values.providerType,
baseUrl: values.providerType === "openai" ? null : values.baseUrl?.trim(),
enabled: values.enabled,
});
}
async function saveProvider(reason: string) {
if (!pendingProvider) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({ ...pendingProvider, reason }),
});
message.success("供应商配置已保存");
setPendingProvider(null);
setProviderOpen(false);
await load();
} finally {
setSaving(false);
}
}
async function saveModel(values: ModelForm) {
setSaving(true);
try {
let settings: unknown;
try {
settings = JSON.parse(values.settingsJson);
} catch {
throw new Error("设置 JSON 格式不正确");
}
await adminRequestJson("/api/admin/models", {
method: "POST",
body: JSON.stringify({
action: "saveDraft",
modelId: values.modelId.trim(),
versionId: editingModel?.id ?? null,
providerId: values.providerId,
label: values.label.trim(),
description: values.description?.trim() ?? "",
providerModel: values.providerModel.trim(),
modelTier: values.modelTier,
creditCost: values.creditCost,
contextWindow: values.contextWindow ?? null,
inputCostMicrousdPerMillion: values.inputCostMicrousdPerMillion,
outputCostMicrousdPerMillion: values.outputCostMicrousdPerMillion,
enabled: values.enabled,
isDefault: values.isDefault,
fallbackModelId: values.fallbackModelId?.trim() || null,
settings,
reason: values.reason.trim(),
}),
});
message.success("模型草稿已保存");
setModelOpen(false);
await load();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存失败");
} finally {
setSaving(false);
}
}
async function act(body: Record<string, unknown>, success: string, id: string) {
setActingId(id);
try {
await adminRequestJson("/api/admin/models", { method: "POST", body: JSON.stringify(body) });
message.success(success);
await load();
} finally {
setActingId(null);
}
}
async function submitVersionAction(reason: string) {
if (!versionAction) return;
const { action, model } = versionAction;
await act(action === "publish"
? { action, versionId: model.id, reason }
: { action, configId: model.configId, targetVersion: model.version, reason },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
}
async function testVersion(item: ModelVersion) {
try {
await act({ action: "test", versionId: item.id }, "连接测试通过,可在短有效期内发布", item.id);
} catch (error) {
message.error(error instanceof Error ? error.message : "连接测试失败");
}
}
const providerColumns: TableColumnsType<Provider> = [
{ title: "供应商", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code}</Text></Space> },
{ title: "类型", dataIndex: "providerType" },
{ title: "地址", dataIndex: "baseUrl", render: (value) => value ?? "OpenAI 官方" },
{ title: "密钥状态", render: (_, item) => <Tag color={item.secretConfigured ? "green" : "red"}>{item.secretConfigured ? "已配置" : "未配置"}</Tag> },
{ title: "状态", dataIndex: "enabled", render: (value) => value ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{ title: "操作", render: (_, item) => canWrite ? <Button type="link" onClick={() => openProvider(item)}></Button> : null },
];
const modelColumns: TableColumnsType<ModelVersion> = [
{ title: "模型", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.label}</Text><Text type="secondary">{item.modelId} · v{item.version}</Text></Space> },
{ title: "供应商模型", render: (_, item) => `${item.providerCode} / ${item.providerModel}` },
{ title: "档位", dataIndex: "modelTier", render: (value) => <Tag>{value}</Tag> },
{ title: "点数", dataIndex: "creditCost" },
{ title: "成本/百万 Token", render: (_, item) => `$${(item.inputCostMicrousdPerMillion / 1_000_000).toFixed(4)} / $${(item.outputCostMicrousdPerMillion / 1_000_000).toFixed(4)}` },
{ title: "路由", render: (_, item) => <Space direction="vertical" size={0}>{item.isDefault && <Tag color="blue"></Tag>}<Text type="secondary">fallback: {item.fallbackModelId ?? "—"}</Text></Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openModel(item)}>稿</Button>}
{canTest && <Button type="link" loading={actingId === item.id} onClick={() => void testVersion(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => setVersionAction({ action: "publish", model: item })}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => setVersionAction({ action: "rollback", model: item })}></Button>}
</Space>,
},
];
return <List title="模型配置中心">
<Space direction="vertical" size="large" style={{ width: "100%" }}>
<Card title="供应商" extra={canWrite ? <Button icon={<PlusOutlined />} onClick={() => openProvider()}></Button> : null}>
<Table rowKey="id" columns={providerColumns} dataSource={providers} loading={loading} pagination={false} scroll={{ x: "max-content" }} />
</Card>
<Card title="模型版本" extra={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openModel()} disabled={!providers.length}>稿</Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form<ModelFilters>
form={filterForm}
layout="inline"
style={{ rowGap: 8 }}
onFinish={(values) => setFilters({
q: values.q?.trim() || undefined,
status: values.status || undefined,
})}
>
<Form.Item name="q" label="搜索">
<Input allowClear placeholder="模型 ID、名称或供应商" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={() => {
filterForm.resetFields();
setFilters({});
}}></Button>
</Space>
</Form.Item>
</Form>
<Table rowKey="id" columns={modelColumns} dataSource={models} loading={loading} pagination={{ pageSize: 20 }} scroll={{ x: "max-content" }} />
</Space>
</Card>
</Space>
<Modal title={editingProvider ? "编辑供应商" : "新增供应商"} open={providerOpen} okText="继续验证" cancelText="取消" confirmLoading={saving} onOk={() => providerForm.submit()} onCancel={() => setProviderOpen(false)} destroyOnHidden>
<Form<ProviderForm> form={providerForm} layout="vertical" onFinish={prepareProviderSave}>
<Row gutter={16}><Col xs={24} md={12}><Form.Item name="code" label="代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_-]{1,63}$/ }]}><Input disabled={Boolean(editingProvider)} /></Form.Item></Col><Col xs={24} md={12}><Form.Item name="name" label="名称" rules={[{ required: true }]}><Input /></Form.Item></Col></Row>
<Form.Item name="providerType" label="类型" rules={[{ required: true }]}><Select options={[{ value: "openai", label: "OpenAI 官方" }, { value: "openai-compatible", label: "OpenAI Compatible" }]} /></Form.Item>
<Form.Item noStyle shouldUpdate={(before, after) => before.providerType !== after.providerType}>{({ getFieldValue }) => getFieldValue("providerType") === "openai-compatible" ? <Form.Item name="baseUrl" label="Base URL" rules={[{ required: true }, { type: "url" }]}><Input /></Form.Item> : null}</Form.Item>
<Form.Item label="部署密钥"><Text type="secondary"></Text></Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
</Form>
</Modal>
<Modal title={editingModel ? `编辑 ${editingModel.modelId}` : "新增模型草稿"} open={modelOpen} width={860} okText="保存草稿" cancelText="取消" confirmLoading={saving} onOk={() => modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden>
<Form<ModelForm> form={modelForm} layout="vertical" onFinish={saveModel}>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="modelId" label="模型 ID" rules={[{ required: true }, { pattern: /^[a-z0-9][a-z0-9._-]{0,63}$/ }]}><Input disabled={Boolean(editingModel)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="label" label="显示名称" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="providerId" label="供应商" rules={[{ required: true }]}><Select options={providers.map((item) => ({ value: item.id, label: item.name }))} /></Form.Item></Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12}><Form.Item name="providerModel" label="供应商模型名" rules={[{ required: true }]}><Input /></Form.Item></Col>
<Col xs={24} md={6}><Form.Item name="modelTier" label="模型档位" rules={[{ required: true }]}><Select options={["standard", "premium", "internal"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={6}><Form.Item name="creditCost" label="单次点数" rules={[{ required: true }]}><InputNumber min={1} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="description" label="说明"><Input.TextArea rows={2} /></Form.Item>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="contextWindow" label="上下文窗口"><InputNumber min={1} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="inputCostMicrousdPerMillion" label="输入成本(微美元/百万 Token" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="outputCostMicrousdPerMillion" label="输出成本(微美元/百万 Token" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="fallbackModelId" label="Fallback 模型 ID"><Input allowClear /></Form.Item>
<Form.Item name="settingsJson" label="设置 JSON" rules={[{ required: true }]}><Input.TextArea rows={5} spellCheck={false} /></Form.Item>
<Space size="large"><Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item><Form.Item name="isDefault" label="默认模型" valuePropName="checked" dependencies={["enabled"]} rules={[({ getFieldValue }) => ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}><Switch /></Form.Item></Space>
<Form.Item name="reason" label="修改原因" rules={[{ required: true }, { max: 500 }]}><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingProvider)}
title="保存模型供应商"
okText="验证并保存"
confirmLoading={saving}
reauthPermission="models.write"
onCancel={() => setPendingProvider(null)}
onSubmit={saveProvider}
/>
<ReasonActionModal
open={Boolean(versionAction)}
title={versionAction?.action === "rollback" ? `回滚到 v${versionAction.model.version}` : "发布模型版本"}
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
danger={versionAction?.action === "rollback"}
confirmLoading={Boolean(actingId)}
reauthPermission={versionAction?.action === "rollback" ? "models.rollback" : "models.publish"}
onCancel={() => setVersionAction(null)}
onSubmit={submitVersionAction}
/>
</List>;
}
@@ -1,6 +1,7 @@
"use client";
import { List } from "@refinedev/antd";
import { useGetIdentity } from "@refinedev/core";
import {
Alert,
App,
@@ -24,6 +25,9 @@ import {
import type { Dayjs } from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
import type { AdminIdentity } from "@/lib/admin/providers";
const { Text } = Typography;
type Order = {
@@ -90,6 +94,7 @@ async function responsePayload(response: Response) {
export default function PaymentManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [filterForm] = Form.useForm<PaymentFilters>();
const [epayForm] = Form.useForm<EpaySettingsForm>();
const [orders, setOrders] = useState<Order[]>([]);
@@ -104,6 +109,8 @@ export default function PaymentManagement() {
const [epaySaving, setEpaySaving] = useState(false);
const [epayTesting, setEpayTesting] = useState(false);
const [epayError, setEpayError] = useState("");
const [pendingEpaySettings, setPendingEpaySettings] = useState<EpaySettingsForm | null>(null);
const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write"));
const loadPayments = useCallback(async () => {
setPaymentLoading(true);
@@ -168,19 +175,23 @@ export default function PaymentManagement() {
}
}
async function saveEpaySettings(values: EpaySettingsForm) {
function prepareEpaySettings(values: EpaySettingsForm) {
setPendingEpaySettings(values);
}
async function saveEpaySettings() {
if (!pendingEpaySettings) return;
setEpaySaving(true);
try {
await responsePayload(await fetch("/api/admin/epay-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
body: JSON.stringify({ ...pendingEpaySettings, newKey: pendingEpaySettings.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
message.success("Z-Pay(易支付)配置已保存");
await loadEpaySettings();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
setPendingEpaySettings(null);
} finally {
setEpaySaving(false);
}
@@ -219,14 +230,14 @@ export default function PaymentManagement() {
children: (
<Card
loading={epayLoading}
extra={<Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}></Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}></Button></Space>}
extra={canAdjustBilling ? <Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}></Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}></Button></Space> : null}
>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Text type="secondary"> Z-Pay / </Text>
{epaySettings && <Space wrap><Tag color={epaySettings.source === "database" ? "blue" : "default"}>{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}</Tag><Tag color={epaySettings.complete ? "green" : "orange"}>{epaySettings.complete ? "配置完整" : "配置不完整"}</Tag><Tag color={epaySettings.keyConfigured ? "green" : "orange"}>{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}</Tag><Tag color={epaySettings.chatEnabled ? "green" : "default"}>{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Tag></Space>}
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}></Button>} />}
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={prepareEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
@@ -266,6 +277,15 @@ export default function PaymentManagement() {
</Space>
</Card>
</Space>
<ReasonActionModal
open={Boolean(pendingEpaySettings)}
title="保存易支付设置"
okText="验证并保存"
confirmLoading={epaySaving}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingEpaySettings(null)}
onSubmit={saveEpaySettings}
/>
</List>
);
}
@@ -0,0 +1,273 @@
"use client";
import { PlusOutlined } from "@ant-design/icons";
import { useGetIdentity } from "@refinedev/core";
import { useTable } from "@refinedev/antd";
import { List } from "@refinedev/antd";
import {
App,
Button,
Col,
Form,
Input,
InputNumber,
Modal,
Row,
Select,
Space,
Switch,
Table,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "./reason-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
type Entitlement = {
featureKey: string;
allowanceType: string;
allowanceCount: number | null;
resetPeriod: string;
modelTier?: string | null;
fairUsePolicyId?: string | null;
metadata: Record<string, unknown>;
};
type Product = {
id: string;
code: string;
version: number;
name: string;
description: string;
productType: "credit_pack" | "trial" | "subscription";
billingPeriod: "none" | "day" | "month" | "year";
intervalCount: number;
priceCents: number;
currency: string;
enabled: boolean;
status: string;
sortOrder: number;
oneTimePerUser: boolean;
effectiveFrom: string | null;
updatedAt: string;
entitlements: Entitlement[];
};
type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "effectiveFrom" | "updatedAt" | "entitlements"> & {
id?: string;
priceYuan: number;
entitlementsJson: string;
};
type PendingProductSave = Record<string, unknown>;
type ProductFilters = { q?: string; status?: string };
const defaultEntitlements: Entitlement[] = [{
featureKey: "chat.standard",
allowanceType: "unlimited",
allowanceCount: null,
resetPeriod: "billing_period",
modelTier: "standard",
fairUsePolicyId: null,
metadata: { minuteLimit: 6, dayLimit: 100 },
}];
export default function ProductManagement() {
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const table = useTable<Product, { message: string; statusCode: number }, ProductFilters>({
resource: "products",
syncWithLocation: true,
pagination: { pageSize: 20 },
onSearch: ({ q, status }) => [
{ field: "q", operator: "contains", value: q },
{ field: "status", operator: "eq", value: status },
],
});
const [form] = Form.useForm<ProductForm>();
const [editing, setEditing] = useState<Product | null>(null);
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<Product | null>(null);
const [pendingSave, setPendingSave] = useState<PendingProductSave | null>(null);
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
function openProduct(product?: Product) {
setEditing(product ?? null);
form.setFieldsValue(product ? {
id: product.id,
code: product.code,
name: product.name,
description: product.description,
productType: product.productType,
billingPeriod: product.billingPeriod,
intervalCount: product.intervalCount,
priceYuan: product.priceCents / 100,
currency: product.currency,
enabled: product.enabled,
sortOrder: product.sortOrder,
oneTimePerUser: product.oneTimePerUser,
entitlementsJson: JSON.stringify(product.entitlements, null, 2),
} : {
code: "",
name: "",
description: "",
productType: "subscription",
billingPeriod: "month",
intervalCount: 1,
priceYuan: 99,
currency: "CNY",
enabled: false,
sortOrder: 0,
oneTimePerUser: false,
entitlementsJson: JSON.stringify(defaultEntitlements, null, 2),
});
setOpen(true);
}
function prepareSave(values: ProductForm) {
let entitlements: unknown;
try {
entitlements = JSON.parse(values.entitlementsJson);
} catch {
message.error("权益 JSON 格式不正确");
return;
}
setPendingSave({
action: "save",
id: editing?.id ?? null,
code: values.code.trim(),
name: values.name.trim(),
description: values.description?.trim() ?? "",
productType: values.productType,
billingPeriod: values.billingPeriod,
intervalCount: values.intervalCount,
priceCents: Math.round(values.priceYuan * 100),
currency: values.currency.toUpperCase(),
enabled: values.enabled,
sortOrder: values.sortOrder,
oneTimePerUser: values.oneTimePerUser,
entitlements,
});
}
async function save(reason: string) {
if (!pendingSave) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ ...pendingSave, reason }),
});
message.success("商品草稿已保存");
setPendingSave(null);
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
} finally {
setSaving(false);
}
}
async function publish(product: Product, reason: string) {
setPublishingId(product.id);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify({ action: "publish", id: product.id, reason }),
});
message.success("商品已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} finally {
setPublishingId(null);
}
}
const columns: TableColumnsType<Product> = [
{
title: "商品",
dataIndex: "name",
render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.code} · v{item.version}</Text></Space>,
},
{ title: "类型", dataIndex: "productType", render: (value) => <Tag>{value}</Tag> },
{ title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` },
{ title: "价格", render: (_, item) => `¥${(item.priceCents / 100).toFixed(2)}` },
{ title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => <Space wrap>{items.map((item) => <Tag key={`${item.featureKey}-${item.allowanceType}`}>{item.featureKey}: {item.allowanceType}</Tag>)}</Space> },
{ title: "状态", render: (_, item) => <Space><Tag color={item.status === "published" ? "green" : "gold"}>{item.status}</Tag>{item.enabled ? <Tag color="blue"></Tag> : <Tag></Tag>}</Space> },
{ title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate },
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>稿</Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}
</Space>,
},
];
return <List title="商品与权益" headerButtons={canWrite ? <Button type="primary" icon={<PlusOutlined />} onClick={() => openProduct()}></Button> : null}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form {...table.searchFormProps} layout="inline" style={{ rowGap: 8 }}>
<Form.Item name="q" label="搜索"><Input.Search allowClear placeholder="商品名称或代码" /></Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
placeholder="全部状态"
style={{ minWidth: 140 }}
options={["draft", "published", "retired"].map((value) => ({ value, label: value }))}
/>
</Form.Item>
</Form>
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
<Form<ProductForm> form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="name" label="名称" rules={[{ required: true }, { max: 80 }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="priceYuan" label="价格(元)" rules={[{ required: true }]}><InputNumber min={0.01} precision={2} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Row gutter={16}>
<Col xs={24} md={12} lg={6}><Form.Item name="productType" label="类型" rules={[{ required: true }]}><Select options={["credit_pack", "trial", "subscription"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="billingPeriod" label="计费周期" rules={[{ required: true }]}><Select options={["none", "day", "month", "year"].map((value) => ({ value, label: value }))} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="intervalCount" label="周期数量" rules={[{ required: true }]}><InputNumber min={0} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
<Col xs={24} md={12} lg={6}><Form.Item name="sortOrder" label="排序" rules={[{ required: true }]}><InputNumber precision={0} style={{ width: "100%" }} /></Form.Item></Col>
</Row>
<Form.Item name="description" label="购买页说明" rules={[{ max: 1000 }]}><Input.TextArea rows={2} showCount maxLength={1000} /></Form.Item>
<Form.Item name="entitlementsJson" label="权益 JSON" extra="每项必须包含 featureKey、allowanceType、allowanceCount、resetPeriod、metadata。" rules={[{ required: true }]}><Input.TextArea rows={10} spellCheck={false} /></Form.Item>
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="currency" label="币种" rules={[{ required: true }, { len: 3 }]}><Input /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="enabled" label="可售" valuePropName="checked"><Switch /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="oneTimePerUser" label="每人限购一次" valuePropName="checked"><Switch /></Form.Item></Col>
</Row>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingSave)}
title="保存商品草稿"
okText="验证并保存"
confirmLoading={saving}
reauthPermission="billing.products.write"
onCancel={() => setPendingSave(null)}
onSubmit={save}
/>
<ReasonActionModal
open={Boolean(publishTarget)}
title={`发布商品${publishTarget ? `${publishTarget.name}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
reauthPermission="billing.products.publish"
onCancel={() => setPublishTarget(null)}
onSubmit={(reason) => publish(publishTarget!, reason)}
/>
</List>;
}
@@ -0,0 +1,253 @@
"use client";
import { Alert, Button, Form, Input, Modal, Radio, Space, Spin, Typography } from "antd";
import Link from "next/link";
import { useState } from "react";
import type { AdminPermission } from "@/lib/admin/auth-policy";
const { Text } = Typography;
type ReasonActionModalProps = {
open: boolean;
title: string;
okText: string;
confirmLoading?: boolean;
danger?: boolean;
reauthPermission?: AdminPermission;
onCancel: () => void;
onSubmit: (reason: string) => void | Promise<void>;
};
type FormValues = { reason: string; otp?: string; mfaCode?: string };
type MfaFactor = "totp" | "backup";
type MfaStatus = {
required: boolean;
enrolled: boolean;
verified: boolean;
highRiskWritesEnabled: boolean;
};
async function adminSecurityRequest<T>(url: string, body?: object): Promise<T> {
const response = await fetch(url, {
method: body ? "POST" : "GET",
cache: "no-store",
credentials: "same-origin",
headers: body ? { "content-type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
});
const value = await response.json().catch(() => null) as {
data?: T;
error?: unknown;
} | null;
if (response.ok && value?.data) return value.data;
throw new Error(
typeof value?.error === "string" ? value.error : "安全验证失败,请稍后再试",
);
}
export function ReasonActionModal({
open,
title,
okText,
confirmLoading = false,
danger = false,
reauthPermission,
onCancel,
onSubmit,
}: ReasonActionModalProps) {
const [form] = Form.useForm<FormValues>();
const [mfaStatus, setMfaStatus] = useState<MfaStatus>();
const [mfaFactor, setMfaFactor] = useState<MfaFactor>("totp");
const [mfaLoading, setMfaLoading] = useState(false);
const [otpSent, setOtpSent] = useState(false);
const [reauthLoading, setReauthLoading] = useState(false);
const [reauthError, setReauthError] = useState<string>();
const mfaReady = !reauthPermission
|| (mfaStatus !== undefined && (!mfaStatus.required || mfaStatus.verified));
async function loadMfaStatus() {
if (!reauthPermission) return;
setMfaLoading(true);
setReauthError(undefined);
try {
setMfaStatus(await adminSecurityRequest<MfaStatus>("/api/admin/mfa"));
} catch (error) {
setMfaStatus(undefined);
setReauthError(error instanceof Error ? error.message : "无法读取 MFA 状态");
} finally {
setMfaLoading(false);
}
}
async function verifyMfa() {
const code = form.getFieldValue("mfaCode")?.trim();
if (!code) {
setReauthError(mfaFactor === "totp" ? "请输入 6 位认证器验证码" : "请输入恢复码");
return;
}
if (mfaFactor === "totp" && !/^\d{6}$/.test(code)) {
setReauthError("请输入 6 位认证器验证码");
return;
}
setMfaLoading(true);
setReauthError(undefined);
try {
const status = await adminSecurityRequest<MfaStatus>("/api/admin/mfa", {
action: mfaFactor === "totp" ? "verify" : "recover",
code,
});
setMfaStatus(status);
form.setFieldValue("mfaCode", undefined);
} catch (error) {
setReauthError(error instanceof Error ? error.message : "MFA 验证失败");
} finally {
setMfaLoading(false);
}
}
async function requestOtp() {
if (!reauthPermission || !mfaReady) return;
setReauthLoading(true);
setReauthError(undefined);
try {
await adminSecurityRequest("/api/admin/reauth", {
action: "request",
permission: reauthPermission,
});
setOtpSent(true);
} catch (error) {
setReauthError(error instanceof Error ? error.message : "验证码发送失败");
} finally {
setReauthLoading(false);
}
}
async function submit({ reason, otp }: FormValues) {
setReauthError(undefined);
setReauthLoading(true);
try {
if (reauthPermission) {
await adminSecurityRequest("/api/admin/reauth", {
action: "verify",
permission: reauthPermission,
otp,
});
}
await onSubmit(reason.trim());
} catch (error) {
setReauthError(error instanceof Error ? error.message : "操作失败,请稍后再试");
} finally {
setReauthLoading(false);
}
}
return <Modal
title={title}
open={open}
okText={okText}
cancelText="取消"
confirmLoading={confirmLoading || reauthLoading}
okButtonProps={{
danger,
disabled: Boolean(reauthPermission && (!mfaReady || !otpSent)),
}}
onOk={() => form.submit()}
onCancel={onCancel}
afterOpenChange={(visible) => {
if (visible) {
form.resetFields();
void loadMfaStatus();
}
setMfaStatus(undefined);
setMfaFactor("totp");
setOtpSent(false);
setReauthError(undefined);
}}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={submit}>
<Form.Item name="reason" label="操作原因" rules={[{ required: true, whitespace: true }, { max: 500 }]}>
<Input.TextArea rows={3} showCount maxLength={500} autoFocus />
</Form.Item>
{reauthPermission && mfaLoading && !mfaStatus ? <Space>
<Spin size="small" />
<Text type="secondary"> session MFA </Text>
</Space> : null}
{reauthPermission && mfaStatus?.required && !mfaStatus.enrolled ? <Alert
type="error"
showIcon
message="当前角色必须启用 MFA"
description={<span>
fail closed <Link href="/admin/security"></Link> enrollment
</span>}
style={{ marginBottom: 16 }}
/> : null}
{reauthPermission && mfaStatus?.required && mfaStatus.enrolled && !mfaStatus.verified ? <Space
direction="vertical"
size="middle"
style={{ width: "100%", marginBottom: 16 }}
>
<Alert
type="warning"
showIcon
message="先验证真实第二因素"
description="当前 session 完成 TOTP 或一次性恢复码验证后,才能发送权限范围内的邮箱验证码。"
/>
<Radio.Group
value={mfaFactor}
onChange={(event) => {
setMfaFactor(event.target.value as MfaFactor);
form.setFieldValue("mfaCode", undefined);
}}
>
<Radio.Button value="totp"></Radio.Button>
<Radio.Button value="backup"></Radio.Button>
</Radio.Group>
<Space.Compact style={{ width: "100%" }}>
<Form.Item name="mfaCode" noStyle>
<Input
inputMode={mfaFactor === "totp" ? "numeric" : "text"}
autoComplete="one-time-code"
maxLength={mfaFactor === "totp" ? 6 : 128}
placeholder={mfaFactor === "totp" ? "6 位认证器验证码" : "一次性恢复码"}
onPressEnter={(event) => {
event.preventDefault();
void verifyMfa();
}}
/>
</Form.Item>
<Button type="default" loading={mfaLoading} onClick={() => void verifyMfa()}>
MFA
</Button>
</Space.Compact>
</Space> : null}
{reauthPermission && mfaReady ? <Form.Item
label="邮箱验证码"
extra="真实第二因素完成后,验证码发送到当前登录管理员邮箱,5 分钟内有效且只授权本次 permission。"
required
>
<Space.Compact style={{ width: "100%" }}>
<Form.Item
name="otp"
noStyle
rules={[{ required: true }, { pattern: /^\d{6}$/, message: "请输入 6 位验证码" }]}
>
<Input inputMode="numeric" autoComplete="one-time-code" maxLength={6} placeholder="6 位验证码" />
</Form.Item>
<Button type="default" loading={reauthLoading} onClick={() => void requestOtp()}>
{otpSent ? "重新发送" : "发送验证码"}
</Button>
</Space.Compact>
</Form.Item> : null}
{reauthError ? <Alert type="error" showIcon message={reauthError} /> : null}
</Form>
</Modal>;
}
@@ -37,10 +37,10 @@ export function ResourceTable<T extends BaseRecord>({
<List title={title} headerButtons={extra}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form {...searchFormProps} layout="inline">
<Form.Item name="q"><Input.Search allowClear placeholder="搜索" /></Form.Item>
<Form.Item name="q" label="搜索"><Input.Search allowClear placeholder="名称、邮箱或编号" /></Form.Item>
{statusOptions && (
<Form.Item name="status">
<Select allowClear placeholder="状态" options={statusOptions} style={{ minWidth: 160 }} />
<Form.Item name="status" label="状态">
<Select allowClear placeholder="全部状态" options={statusOptions} style={{ minWidth: 160 }} />
</Form.Item>
)}
</Form>
@@ -0,0 +1,39 @@
"use client";
import { useTable } from "@refinedev/antd";
import { Alert, Card, Space, Table, Tag } from "antd";
interface RoleRecord {
id: string;
code: string;
name: string;
description: string;
requiresMfa: boolean;
permissions: string[];
}
export default function RolesResource() {
const table = useTable<RoleRecord>({ resource: "roles" });
return (
<Card title="角色与权限矩阵">
<Alert
type="info"
showIcon
message="系统预置只读矩阵"
description="当前版本不提供角色权限矩阵写 API。六类系统角色的成员分配与撤销请在“管理员”页面完成。"
style={{ marginBottom: 16 }}
/>
<Table
{...table.tableProps}
rowKey="id"
pagination={false}
columns={[
{ title: "角色", dataIndex: "name" },
{ title: "说明", dataIndex: "description" },
{ title: "MFA", dataIndex: "requiresMfa", render: (value: boolean) => value ? <Tag color="red"></Tag> : <Tag></Tag> },
{ title: "权限", dataIndex: "permissions", render: (permissions: string[]) => <Space wrap>{permissions.map((permission) => <Tag key={permission}>{permission}</Tag>)}</Space> },
]}
/>
</Card>
);
}
@@ -1,8 +1,13 @@
"use client";
import { Tag, type TableColumnsType } from "antd";
import { useGetIdentity } from "@refinedev/core";
import { App, Button, Space, Tag, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
const { Text } = Typography;
type UserRecord = {
id: string;
@@ -13,24 +18,71 @@ type UserRecord = {
banned: boolean;
createdAt: string;
credits: number;
birthDate: null;
birthTimeStatus: null;
birthPlace: null;
birthDataMasked: true;
};
type RevealedBirthData = {
userId: string;
birthDate: string | null;
birthTimeStatus: string | null;
birthPlace: string | null;
};
const columns: TableColumnsType<UserRecord> = [
{ title: "邮箱", dataIndex: "email", sorter: true },
{ title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" },
{ title: "角色", dataIndex: "role", render: (value) => <Tag>{value}</Tag> },
{ title: "积分", dataIndex: "credits", sorter: true },
{ title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" },
{ title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" },
{ title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" },
{ title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" },
{ title: "状态", dataIndex: "banned", render: (value) => value ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
export default function UsersPage() {
return <ResourceTable<UserRecord> resource="users" title="用户资料(只读)" columns={columns} />;
const { message } = App.useApp();
const { data: identity } = useGetIdentity<AdminIdentity>();
const [revealed, setRevealed] = useState<Record<string, RevealedBirthData>>({});
const [revealingId, setRevealingId] = useState<string | null>(null);
const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read"));
async function revealBirthData(userId: string) {
setRevealingId(userId);
try {
const payload = await adminRequestJson<{ data: RevealedBirthData }>(
`/api/admin/customers?revealUserId=${encodeURIComponent(userId)}`,
);
setRevealed((current) => ({ ...current, [userId]: payload.data }));
message.success("出生资料已读取,本次查看已写入审计日志");
} catch (error) {
message.error(error instanceof Error ? error.message : "读取出生资料失败");
} finally {
setRevealingId(null);
}
}
const maskedValue = (value: string | null | undefined) => value || "未填写";
const columns: TableColumnsType<UserRecord> = [
{ title: "邮箱", dataIndex: "email", sorter: true },
{ title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" },
{ title: "角色", dataIndex: "role", render: (value) => <Tag>{value}</Tag> },
{ title: "积分", dataIndex: "credits", sorter: true },
{
title: "出生资料",
render: (_, item) => {
const birth = revealed[item.id];
return <Space direction="vertical" size={0}>
{birth ? <>
<Text>{maskedValue(birth.birthDate)}</Text>
<Text>{maskedValue(birth.birthTimeStatus)}</Text>
<Text>{maskedValue(birth.birthPlace)}</Text>
</> : <Text type="secondary"></Text>}
{canReveal && <Button
type="link"
size="small"
loading={revealingId === item.id}
onClick={() => void revealBirthData(item.id)}
style={{ paddingInline: 0 }}
>{birth ? "再次读取并审计" : "查看出生资料"}</Button>}
</Space>;
},
},
{ title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" },
{ title: "状态", dataIndex: "banned", render: (value) => value ? <Tag color="red"></Tag> : <Tag color="green"></Tag> },
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
];
return <ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />;
}