feat(admin): add operations resources and audit UI

This commit is contained in:
Jesse_Chen
2026-08-06 20:15:08 +08:00
parent 28e04857fa
commit 5622ab448a
39 changed files with 3770 additions and 293 deletions
+257 -75
View File
@@ -1,12 +1,33 @@
"use client";
import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core";
import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd";
import {
useCreate,
useGetIdentity,
useInvalidate,
useUpdate,
} from "@refinedev/core";
import {
App,
Button,
DatePicker,
Form,
Input,
InputNumber,
Modal,
Space,
Tag,
Typography,
type TableColumnsType,
} from "antd";
import dayjs from "dayjs";
import { useState } from "react";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import type { AdminIdentity } from "@/lib/admin/providers";
import { ReasonActionModal } from "@/components/admin/reason-action-modal";
import {
formatAdminDate,
ResourceTable,
} from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
type CodeRecord = {
id: string;
@@ -28,7 +49,10 @@ type CreateValues = {
expiresAt?: ReturnType<typeof dayjs>;
note?: string;
};
type EditValues = { note?: string; expiresAt?: ReturnType<typeof dayjs> | null };
type EditValues = {
note?: string;
expiresAt?: ReturnType<typeof dayjs> | null;
};
const statusColors: Record<CodeRecord["status"], string> = {
available: "green",
@@ -38,90 +62,144 @@ const statusColors: Record<CodeRecord["status"], string> = {
};
export default function CodesPage() {
const { data: role } = usePermissions<"admin">({});
const { message } = App.useApp();
const invalidate = useInvalidate();
const { data: identity } = useGetIdentity<AdminIdentity>();
const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>();
const { mutate: updateCode, mutation: updateMutation } = useUpdate<CodeRecord>();
const { mutate: revokeCode, mutation: revokeMutation } = useDelete<CodeRecord>();
const { mutateAsync: createCodes, mutation: createMutation } = useCreate<{
id: string;
generated: CodeRecord[];
}>();
const { mutateAsync: updateCode, mutation: updateMutation } =
useUpdate<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [pendingCreate, setPendingCreate] = useState<CreateValues | null>(null);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [pendingEdit, setPendingEdit] = useState<EditValues | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [revokeRecord, setRevokeRecord] = useState<CodeRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = role === "admin";
const writable = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
function submitCreate(values: CreateValues) {
createCodes({
async function submitCreate(reason: string) {
if (!pendingCreate) return;
const result = await createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
credits: pendingCreate.credits,
count: pendingCreate.count,
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
note: pendingCreate.note?.trim() || null,
reason,
},
successNotification: false,
}, {
onSuccess(result) {
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
},
});
setGenerated(result.data.generated);
setPendingCreate(null);
setCreateOpen(false);
createForm.resetFields();
}
function submitEdit(values: EditValues) {
if (!editRecord) return;
updateCode({
async function submitEdit(reason: string) {
if (!editRecord || !pendingEdit) return;
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: pendingEdit.note?.trim() || null,
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
reason,
},
}, { onSuccess: () => setEditRecord(null) });
});
setPendingEdit(null);
setEditRecord(null);
}
function confirmRevoke(record: CodeRecord) {
Modal.confirm({
title: "撤销此兑换码?",
content: `${record.mask} 撤销后不可兑换,且不能恢复。`,
okText: "确认撤销",
okButtonProps: { danger: true },
cancelText: "取消",
onOk: () => new Promise<void>((resolve, reject) => {
revokeCode({ resource: "codes", id: record.id }, {
onSuccess: () => resolve(),
onError: () => reject(new Error("撤销失败")),
});
}),
});
async function revoke(record: CodeRecord, reason: string) {
setRevoking(true);
try {
await adminRequestJson(`/api/admin/codes/${record.id}`, {
method: "DELETE",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({ reason }),
});
await invalidate({ resource: "codes", invalidates: ["list"] });
message.success("兑换码已撤销");
setRevokeRecord(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销失败");
} finally {
setRevoking(false);
}
}
const columns: TableColumnsType<CodeRecord> = [
{ title: "兑换码", dataIndex: "mask" },
{ title: "点数", dataIndex: "credits", sorter: true },
{ title: "状态", dataIndex: "status", sorter: true, render: (value) => <Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag> },
{ title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate },
{
title: "状态",
dataIndex: "status",
sorter: true,
render: (value) => (
<Tag color={statusColors[value as CodeRecord["status"]]}>{value}</Tag>
),
},
{
title: "到期时间",
dataIndex: "expiresAt",
sorter: true,
render: formatAdminDate,
},
{ title: "备注", dataIndex: "note", render: (value) => value || "—" },
{ title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" },
{
title: "兑换账户",
dataIndex: "redeemedEmail",
render: (value) => value || "—",
},
{ title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate },
{ title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate },
{ title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
{
title: "创建时间",
dataIndex: "createdAt",
sorter: true,
render: formatAdminDate,
},
{
title: "操作",
fixed: "right",
render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? (
<Space>
<Button size="small" onClick={() => {
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}></Button>
<Button danger size="small" loading={revokeMutation.isPending} onClick={() => confirmRevoke(record)}></Button>
</Space>
) : "—",
render: (_, record) =>
writable &&
record.status !== "redeemed" &&
record.status !== "revoked" ? (
<Space>
<Button
size="small"
onClick={() => {
setPendingEdit(null);
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
expiresAt: record.expiresAt ? dayjs(record.expiresAt) : null,
});
}}
>
</Button>
<Button
danger
size="small"
loading={revoking && revokeRecord?.id === record.id}
onClick={() => setRevokeRecord(record)}
>
</Button>
</Space>
) : (
"—"
),
},
];
@@ -129,7 +207,7 @@ export default function CodesPage() {
<>
<ResourceTable<CodeRecord>
resource="codes"
title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`}
title={`兑换码${identity ? ` · ${identity.email} (${identity.roles.join("、")})` : ""}`}
columns={columns}
statusOptions={[
{ label: "可用", value: "available" },
@@ -137,31 +215,135 @@ export default function CodesPage() {
{ label: "已兑换", value: "redeemed" },
{ label: "已撤销", value: "revoked" },
]}
extra={writable ? <Button type="primary" onClick={() => setCreateOpen(true)}></Button> : null}
extra={
writable ? (
<Button
type="primary"
onClick={() => {
setPendingCreate(null);
setCreateOpen(true);
}}
>
</Button>
) : null
}
/>
<Modal title="批量生成兑换码" open={createOpen} onCancel={() => setCreateOpen(false)} footer={null} destroyOnHidden>
<Form form={createForm} layout="vertical" initialValues={{ credits: 10, count: 1 }} onFinish={submitCreate}>
<Form.Item name="credits" label="每个点数" rules={[{ required: true }]}><InputNumber min={1} max={1_000_000} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}><InputNumber min={1} max={100} style={{ width: "100%" }} /></Form.Item>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={createMutation.isPending}></Button>
<Modal
title="批量生成兑换码"
open={createOpen}
onCancel={() => {
setPendingCreate(null);
setCreateOpen(false);
}}
footer={null}
destroyOnHidden
>
<Form
form={createForm}
layout="vertical"
initialValues={{ credits: 10, count: 1 }}
onFinish={setPendingCreate}
>
<Form.Item
name="credits"
label="每个点数"
rules={[{ required: true }]}
>
<InputNumber min={1} max={1_000_000} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="count" label="数量" rules={[{ required: true }]}>
<InputNumber min={1} max={100} style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input maxLength={500} />
</Form.Item>
<Button
block
type="primary"
htmlType="submit"
loading={createMutation.isPending}
>
</Button>
</Form>
</Modal>
<Modal title="完整兑换码(仅显示本次)" open={generated.length > 0} onCancel={() => setGenerated([])} footer={<Button onClick={() => setGenerated([])}></Button>}>
<Typography.Paragraph type="warning"></Typography.Paragraph>
{generated.map((record) => <Typography.Paragraph copyable key={record.code}><Typography.Text code>{record.code}</Typography.Text></Typography.Paragraph>)}
<Modal
title="完整兑换码(仅显示本次)"
open={generated.length > 0}
onCancel={() => setGenerated([])}
footer={<Button onClick={() => setGenerated([])}></Button>}
>
<Typography.Paragraph type="warning">
</Typography.Paragraph>
{generated.map((record) => (
<Typography.Paragraph copyable key={record.code}>
<Typography.Text code>{record.code}</Typography.Text>
</Typography.Paragraph>
))}
</Modal>
<Modal title="编辑未兑换码" open={Boolean(editRecord)} onCancel={() => setEditRecord(null)} footer={null} destroyOnHidden>
<Form form={editForm} layout="vertical" onFinish={submitEdit}>
<Form.Item name="expiresAt" label="到期时间"><DatePicker showTime style={{ width: "100%" }} /></Form.Item>
<Form.Item name="note" label="备注"><Input maxLength={500} /></Form.Item>
<Button block type="primary" htmlType="submit" loading={updateMutation.isPending}></Button>
<Modal
title="编辑未兑换码"
open={Boolean(editRecord)}
onCancel={() => {
setPendingEdit(null);
setEditRecord(null);
}}
footer={null}
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={setPendingEdit}>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
<Form.Item name="note" label="备注">
<Input maxLength={500} />
</Form.Item>
<Button
block
type="primary"
htmlType="submit"
loading={updateMutation.isPending}
>
</Button>
</Form>
</Modal>
<ReasonActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="验证并生成"
confirmLoading={createMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingCreate(null)}
onSubmit={submitCreate}
/>
<ReasonActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="验证并保存"
confirmLoading={updateMutation.isPending}
reauthPermission="billing.adjustments.write"
onCancel={() => setPendingEdit(null)}
onSubmit={submitEdit}
/>
<ReasonActionModal
open={Boolean(revokeRecord)}
title="撤销兑换码"
okText="确认撤销"
danger
confirmLoading={revoking}
reauthPermission="billing.adjustments.write"
onCancel={() => setRevokeRecord(null)}
onSubmit={(reason) => revoke(revokeRecord!, reason)}
/>
</>
);
}