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