import { useEffect, useState } from "react"; import { App, Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag } from "antd"; import type { ColumnsType } from "antd/es/table"; import { Ban, Copy, Eye, KeyRound, RefreshCw, Search, TicketCheck } from "lucide-react"; import { ListToolbar, TableSurface } from "@/components/layout/workspace-page"; import { formatCredits } from "@/constant/credits"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { createAdminRedeemBatch, disableAdminRedeemBatch, disableAdminRedeemCode, listAdminRedeemBatchCodes, listAdminRedeemBatches, type AdminRedeemCode, type RedeemBatch } from "@/services/api/wallet"; import { AdminExportButton } from "./admin-ui"; type RedeemFormValues = { amount: number; count: number; note?: string; expiresAt?: string }; export default function RedemptionCodesPanel() { const { message } = App.useApp(); const [batches, setBatches] = useState([]); const [generatedCodes, setGeneratedCodes] = useState([]); const [selectedBatch, setSelectedBatch] = useState(null); const [loading, setLoading] = useState(true); const [creating, setCreating] = useState(false); const [keyword, setKeyword] = useState(""); const debouncedKeyword = useDebouncedValue(keyword); const [validity, setValidity] = useState<"all" | "active" | "expired">("all"); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); const [total, setTotal] = useState(0); const [form] = Form.useForm(); const reload = async (targetPage = page, targetPageSize = pageSize) => { setLoading(true); try { const result = await listAdminRedeemBatches({ keyword: debouncedKeyword || undefined, validity: validity === "all" ? undefined : validity, page: targetPage, limit: targetPageSize }); setBatches(result.batches); setTotal(result.total); } catch (error) { message.error(error instanceof Error ? error.message : "读取兑换码批次失败"); } finally { setLoading(false); } }; useEffect(() => { form.setFieldsValue({ amount: 10, count: 10 }); }, [form]); useEffect(() => { void reload(page, pageSize); }, [debouncedKeyword, validity, page, pageSize]); const createBatch = async () => { const values = await form.validateFields(); setCreating(true); try { const result = await createAdminRedeemBatch({ amountMicrocredits: Math.round(values.amount * 1_000_000), count: values.count, note: values.note?.trim(), expiresAt: values.expiresAt ? new Date(values.expiresAt).toISOString() : undefined, }); setGeneratedCodes(result.codes); const createdBatch: RedeemBatch = { ...result.batch, availableCount: result.batch.count, redeemedCount: 0, disabledCount: 0, expiredCount: 0 }; setBatches((current) => [createdBatch, ...current.filter((item) => item.id !== createdBatch.id)].slice(0, pageSize)); setTotal((current) => current + 1); setPage(1); message.success(`已生成 ${result.codes.length} 个兑换码`); } catch (error) { const detail = error instanceof Error ? error.message : "生成兑换码失败"; message.error(detail.toLowerCase().includes("timeout") ? "生成超过 30 秒,后台可能仍已完成;请刷新批次列表确认,兑换码可从批次明细重新查看。" : detail); } finally { setCreating(false); } }; const columns: ColumnsType = [ { title: "创建时间", dataIndex: "createdAt", width: 180, render: formatTime }, { title: "单码积分", dataIndex: "amountMicrocredits", width: 130, align: "right", render: (value) => {formatCredits(value)} }, { title: "数量", dataIndex: "count", width: 100, align: "right", render: (value) => {value} }, { title: "核销状态", width: 180, render: (_, batch) => (
{batch.redeemedCount ?? 0}/{batch.count} 已核销 {(batch.expiredCount ?? 0) > 0 ? ( {batch.expiredCount} 已过期 ) : null}
), }, { title: "有效期", dataIndex: "expiresAt", width: 180, render: (value) => (value ? formatTime(value) : 永久有效) }, { title: "批次备注", dataIndex: "note", render: (value) => value || 未填写 }, { title: "操作", width: 210, fixed: "right", render: (_, batch) => ( { try { const result = await disableAdminRedeemBatch(batch.id); message.success(`已禁用 ${result.disabledCount} 个兑换码`); await reload(); } catch (error) { message.error(error instanceof Error ? error.message : "禁用批次失败"); } }} > ), }, ]; return (

生成兑换码批次

兑换码为 32 位随机字符串,生成后加密保存,可在批次明细中再次查看。

单批最多生成 5,000 个。生成成功后会立即显示结果,请及时下载留存。

批次记录

查看每个兑换码的当前状态、核销用户、时间和来源 IP。

{ setKeyword(""); setValidity("all"); setPage(1); }} > } value={keyword} placeholder="搜索批次备注、积分或数量" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} /> { setStatus(value); setPage(1); }} options={[ { label: "全部状态", value: "all" }, { label: "可用", value: "available" }, { label: "已核销", value: "redeemed" }, { label: "已过期", value: "expired" }, { label: "已禁用", value: "disabled" }, ]} />
`共 ${value} 个兑换码`, onChange: (nextPage, nextSize) => { setPage(nextSize !== pageSize ? 1 : nextPage); setPageSize(nextSize); }, }} scroll={{ x: 960, y: 460 }} /> ); } function renderCodeStatus(status: AdminRedeemCode["status"]) { const config = { unused: { label: "可用", color: "green" }, redeemed: { label: "已核销", color: "blue" }, disabled: { label: "已禁用", color: "default" }, expired: { label: "已过期", color: "default" }, }[status]; return ( {config.label} ); } function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--"; }