Files
Jyotisha/frontend/src/components/admin/codes-resource.tsx
T
Jesse_Chen c712787ec2
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled
fix(admin): remove manual reasons and repair code creation
2026-08-16 16:49:11 +08:00

344 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 { ConfirmActionModal } from "@/components/admin/confirm-action-modal";
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 [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 = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
async function submitCreate() {
if (!pendingCreate) return;
const result = await createCodes({
resource: "codes",
values: {
credits: pendingCreate.credits,
count: pendingCreate.count,
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
note: pendingCreate.note?.trim() || null,
},
successNotification: false,
});
setGenerated(result.data.generated);
setPendingCreate(null);
setCreateOpen(false);
createForm.resetFields();
}
async function submitEdit() {
if (!editRecord || !pendingEdit) return;
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: pendingEdit.note?.trim() || null,
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
},
});
setPendingEdit(null);
setEditRecord(null);
}
async function revoke(record: CodeRecord) {
setRevoking(true);
try {
await adminRequestJson(`/api/admin/codes/${record.id}`, {
method: "DELETE",
headers: { "x-request-id": crypto.randomUUID() },
});
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: "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={() => {
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>
) : (
"—"
),
},
];
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={() => {
setPendingCreate(null);
setCreateOpen(true);
}}
>
</Button>
) : null
}
/>
<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>
<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>
<ConfirmActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="确认生成"
confirmLoading={createMutation.isPending}
onCancel={() => setPendingCreate(null)}
onConfirm={submitCreate}
/>
<ConfirmActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="确认保存"
confirmLoading={updateMutation.isPending}
onCancel={() => setPendingEdit(null)}
onConfirm={submitEdit}
/>
<ConfirmActionModal
open={Boolean(revokeRecord)}
title="撤销兑换码"
okText="确认撤销"
danger
confirmLoading={revoking}
onCancel={() => setRevokeRecord(null)}
onConfirm={() => revoke(revokeRecord!)}
/>
</>
);
}