Files
Jyotisha/frontend/src/components/admin/codes-resource.tsx
T
Jesse_Chen 945d61fa1c
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped
fix(admin): remove redundant confirmations and repair code access
2026-08-16 17:55:15 +08:00

313 lines
8.6 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 {
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 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>
</>
);
}