316 lines
8.7 KiB
TypeScript
316 lines
8.7 KiB
TypeScript
"use client";
|
|
|
|
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 { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
|
|
|
|
type CodeRecord = {
|
|
id: string;
|
|
code?: string;
|
|
mask: string;
|
|
credits: number;
|
|
expiresAt: string | null;
|
|
note: string | null;
|
|
createdAt: string;
|
|
redeemedEmail: string | null;
|
|
redeemedAt: string | null;
|
|
revokedAt: string | null;
|
|
status: "available" | "expired" | "redeemed" | "revoked";
|
|
};
|
|
|
|
type CreateValues = {
|
|
credits: number;
|
|
count: number;
|
|
expiresAt?: ReturnType<typeof dayjs>;
|
|
note?: string;
|
|
};
|
|
type EditValues = {
|
|
note?: string;
|
|
expiresAt?: ReturnType<typeof dayjs> | null;
|
|
};
|
|
|
|
const statusColors: Record<CodeRecord["status"], string> = {
|
|
available: "green",
|
|
expired: "orange",
|
|
redeemed: "blue",
|
|
revoked: "red",
|
|
};
|
|
|
|
export default function CodesPage() {
|
|
const { message } = App.useApp();
|
|
const invalidate = useInvalidate();
|
|
const { data: identity } = useGetIdentity<AdminIdentity>();
|
|
const { mutateAsync: createCodes, mutation: createMutation } = useCreate<{
|
|
id: string;
|
|
generated: CodeRecord[];
|
|
}>();
|
|
const { mutateAsync: updateCode, mutation: updateMutation } =
|
|
useUpdate<CodeRecord>();
|
|
const [createOpen, setCreateOpen] = useState(false);
|
|
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
|
|
const [generated, setGenerated] = useState<CodeRecord[]>([]);
|
|
const [revokingId, setRevokingId] = useState<string | null>(null);
|
|
const [createForm] = Form.useForm<CreateValues>();
|
|
const [editForm] = Form.useForm<EditValues>();
|
|
const writable = Boolean(identity?.permissions.includes("admin.access"));
|
|
|
|
async function submitCreate(values: CreateValues) {
|
|
try {
|
|
const result = await createCodes({
|
|
resource: "codes",
|
|
values: {
|
|
credits: values.credits,
|
|
count: values.count,
|
|
expiresAt: values.expiresAt?.toISOString() ?? null,
|
|
note: values.note?.trim() || null,
|
|
},
|
|
successNotification: false,
|
|
});
|
|
setGenerated(result.data.generated);
|
|
setCreateOpen(false);
|
|
createForm.resetFields();
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "生成兑换码失败");
|
|
}
|
|
}
|
|
|
|
async function submitEdit(values: EditValues) {
|
|
if (!editRecord) return;
|
|
try {
|
|
await updateCode({
|
|
resource: "codes",
|
|
id: editRecord.id,
|
|
values: {
|
|
note: values.note?.trim() || null,
|
|
expiresAt: values.expiresAt?.toISOString() ?? null,
|
|
},
|
|
successNotification: false,
|
|
});
|
|
setEditRecord(null);
|
|
message.success("兑换码已保存");
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "保存兑换码失败");
|
|
}
|
|
}
|
|
|
|
async function revoke(record: CodeRecord) {
|
|
setRevokingId(record.id);
|
|
try {
|
|
await adminRequestJson(`/api/admin/codes/${record.id}`, {
|
|
method: "DELETE",
|
|
headers: { "x-request-id": crypto.randomUUID() },
|
|
});
|
|
await invalidate({ resource: "codes", invalidates: ["list"] });
|
|
message.success("兑换码已撤销");
|
|
} catch (error) {
|
|
message.error(error instanceof Error ? error.message : "撤销失败");
|
|
} finally {
|
|
setRevokingId(null);
|
|
}
|
|
}
|
|
|
|
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: "note", 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: "操作",
|
|
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={revokingId === record.id}
|
|
onClick={() => void revoke(record)}
|
|
>
|
|
撤销
|
|
</Button>
|
|
</Space>
|
|
) : (
|
|
"—"
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<>
|
|
<ResourceTable<CodeRecord>
|
|
resource="codes"
|
|
title={`兑换码${identity ? ` · ${identity.email} (${identity.roles.join("、")})` : ""}`}
|
|
columns={columns}
|
|
statusOptions={[
|
|
{ label: "可用", value: "available" },
|
|
{ label: "已过期", value: "expired" },
|
|
{ label: "已兑换", value: "redeemed" },
|
|
{ label: "已撤销", value: "revoked" },
|
|
]}
|
|
extra={
|
|
writable ? (
|
|
<Button
|
|
type="primary"
|
|
onClick={() => 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={(values) => void submitCreate(values)}
|
|
>
|
|
<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={{ text: record.code ?? "" }}
|
|
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={(values) => void submitEdit(values)}
|
|
>
|
|
<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>
|
|
</>
|
|
);
|
|
}
|