feat(admin): add operations resources and audit UI
This commit is contained in:
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user