Files
Jyotisha/frontend/src/components/admin/codes-resource.tsx
T
Jesse_Chen 7050f7ee17
Independent Staging Quality Gate / validate (push) Successful in 13m47s
Independent Staging Quality Gate / publish (push) Successful in 10m6s
fix(admin): restore product visibility and code copying
2026-08-17 10:25:16 +08:00

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>
</>
);
}