feat(抽奖): 开放人工奖(crypto/physical/manual_other) + 领奖工单管理 + REST 对齐
- prize-form: 解禁 crypto/physical/manual_other 并加各自 config;权重改为中奖概率(%), 合计校验;vpn_duration 加"无订阅时新建订阅套餐" - prize-grid: 概率合计=100% 校验告警;卡片渲染真实 config(时长/金额) - claims-panel(新): 领奖工单列表 + 审核通过/驳回/标记发放 - activity-detail: 新增"领奖工单"Tab - service: 奖品改/删 id 走 /prizes/:id(对齐后端 REST);新增 claims 5 个接口 + 类型 - i18n: 补齐 zh-CN/en-US lottery 文案 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,555 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import { Input } from "@workspace/ui/components/input";
|
||||
import { Textarea } from "@workspace/ui/components/textarea";
|
||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||
import {
|
||||
ProTable,
|
||||
type ProTableActions,
|
||||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||
import {
|
||||
approveLotteryClaim,
|
||||
getLotteryClaimList,
|
||||
markPaidLotteryClaim,
|
||||
rejectLotteryClaim,
|
||||
} from "@workspace/ui/services/admin/lottery";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { z } from "zod";
|
||||
import { formatDate } from "@/utils/common";
|
||||
|
||||
interface ClaimsPanelProps {
|
||||
activity: API.LotteryActivity;
|
||||
}
|
||||
|
||||
type TranslateFn = (key: string, defaultValue: string) => string;
|
||||
|
||||
const STATUS_META: Record<
|
||||
API.LotteryClaimStatus,
|
||||
{ labelKey: string; labelDefault: string; className: string }
|
||||
> = {
|
||||
pending_claim: {
|
||||
labelKey: "claim.statusPending",
|
||||
labelDefault: "Awaiting user",
|
||||
className: "bg-gray-100 text-gray-600 border-gray-200",
|
||||
},
|
||||
reviewing: {
|
||||
labelKey: "claim.statusReviewing",
|
||||
labelDefault: "Reviewing",
|
||||
className: "bg-amber-100 text-amber-700 border-amber-200",
|
||||
},
|
||||
paying: {
|
||||
labelKey: "claim.statusPaying",
|
||||
labelDefault: "Paying / Shipping",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
paid: {
|
||||
labelKey: "claim.statusPaid",
|
||||
labelDefault: "Fulfilled",
|
||||
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||||
},
|
||||
rejected: {
|
||||
labelKey: "claim.statusRejected",
|
||||
labelDefault: "Rejected",
|
||||
className: "bg-rose-100 text-rose-700 border-rose-200",
|
||||
},
|
||||
expired: {
|
||||
labelKey: "claim.statusExpired",
|
||||
labelDefault: "Expired",
|
||||
className: "bg-gray-100 text-gray-500 border-gray-200",
|
||||
},
|
||||
};
|
||||
|
||||
function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
|
||||
const labels: Record<string, string> = {
|
||||
crypto: t("prize.typeCrypto", "Crypto"),
|
||||
physical: t("prize.typePhysical", "Physical"),
|
||||
manual_other: t("prize.typeManualOther", "Manual (Other)"),
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
function ClaimDataCell({ data }: { data?: Record<string, any> }) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return <span className="text-muted-foreground">--</span>;
|
||||
}
|
||||
return (
|
||||
<div className="max-w-[240px] space-y-0.5 text-xs">
|
||||
{Object.entries(data).map(([key, value]) => (
|
||||
<div className="break-all" key={key}>
|
||||
<span className="text-muted-foreground">{key}:</span>{" "}
|
||||
<span className="font-medium">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rejectSchema = (t: TranslateFn) =>
|
||||
z.object({
|
||||
reason: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2, t("claim.reasonRequired", "Please enter a reason"))
|
||||
.max(512, t("claim.reasonTooLong", "Reason cannot exceed 512 chars")),
|
||||
});
|
||||
|
||||
const markPaidSchema = z.object({
|
||||
tx_hash: z.string().trim().max(128).optional(),
|
||||
delivery_ref: z.string().trim().max(128).optional(),
|
||||
});
|
||||
|
||||
export default function ClaimsPanel({ activity }: ClaimsPanelProps) {
|
||||
const { t } = useTranslation("lottery");
|
||||
const ref = useRef<ProTableActions>(null);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [paidOpen, setPaidOpen] = useState(false);
|
||||
const [currentRow, setCurrentRow] = useState<API.AdminLotteryClaim | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const rejectForm = useForm<{ reason: string }>({
|
||||
resolver: zodResolver(rejectSchema(t)),
|
||||
defaultValues: { reason: "" },
|
||||
});
|
||||
const paidForm = useForm<z.infer<typeof markPaidSchema>>({
|
||||
resolver: zodResolver(markPaidSchema),
|
||||
defaultValues: { tx_hash: "", delivery_ref: "" },
|
||||
});
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: (id: number) => approveLotteryClaim({ id }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("claim.approveSuccess", "Claim approved"));
|
||||
ref.current?.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (body: API.RejectLotteryClaimRequest) =>
|
||||
rejectLotteryClaim(body),
|
||||
onSuccess: () => {
|
||||
toast.success(t("claim.rejectSuccess", "Claim rejected"));
|
||||
setRejectOpen(false);
|
||||
setCurrentRow(null);
|
||||
rejectForm.reset({ reason: "" });
|
||||
ref.current?.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const paidMutation = useMutation({
|
||||
mutationFn: (body: API.MarkPaidLotteryClaimRequest) =>
|
||||
markPaidLotteryClaim(body),
|
||||
onSuccess: () => {
|
||||
toast.success(t("claim.markPaidSuccess", "Marked as fulfilled"));
|
||||
setPaidOpen(false);
|
||||
setCurrentRow(null);
|
||||
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
||||
ref.current?.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
const statusOptions = (
|
||||
Object.keys(STATUS_META) as API.LotteryClaimStatus[]
|
||||
).map((value) => ({
|
||||
value,
|
||||
label: t(STATUS_META[value].labelKey, STATUS_META[value].labelDefault),
|
||||
}));
|
||||
|
||||
const typeOptions = [
|
||||
{ value: "crypto", label: t("prize.typeCrypto", "Crypto") },
|
||||
{ value: "physical", label: t("prize.typePhysical", "Physical") },
|
||||
{
|
||||
value: "manual_other",
|
||||
label: t("prize.typeManualOther", "Manual (Other)"),
|
||||
},
|
||||
];
|
||||
|
||||
const openReject = (row: API.AdminLotteryClaim) => {
|
||||
setCurrentRow(row);
|
||||
rejectForm.reset({ reason: "" });
|
||||
setRejectOpen(true);
|
||||
};
|
||||
const openPaid = (row: API.AdminLotteryClaim) => {
|
||||
setCurrentRow(row);
|
||||
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
||||
setPaidOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProTable<
|
||||
API.AdminLotteryClaim,
|
||||
{ type?: string; status?: string; user_id?: string }
|
||||
>
|
||||
action={ref}
|
||||
actions={{
|
||||
render: (row) => {
|
||||
const actions: ReactNode[] = [];
|
||||
if (row.status === "reviewing") {
|
||||
actions.push(
|
||||
<ConfirmButton
|
||||
cancelText={t("claim.cancel", "Cancel")}
|
||||
confirmText={t("claim.confirm", "Confirm")}
|
||||
description={t(
|
||||
"claim.approveDescription",
|
||||
"Approving moves this claim to the paying/shipping stage."
|
||||
)}
|
||||
key="approve"
|
||||
onConfirm={async () => {
|
||||
await approveMutation.mutateAsync(row.id);
|
||||
}}
|
||||
title={t("claim.approveTitle", "Approve this claim?")}
|
||||
trigger={
|
||||
<Button size="sm" variant="default">
|
||||
{t("claim.approve", "Approve")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (row.status === "paying") {
|
||||
actions.push(
|
||||
<Button
|
||||
key="mark-paid"
|
||||
onClick={() => openPaid(row)}
|
||||
size="sm"
|
||||
variant="default"
|
||||
>
|
||||
{t("claim.markPaid", "Mark fulfilled")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (row.status === "reviewing" || row.status === "paying") {
|
||||
actions.push(
|
||||
<Button
|
||||
key="reject"
|
||||
onClick={() => openReject(row)}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
{t("claim.reject", "Reject")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return actions;
|
||||
},
|
||||
}}
|
||||
columns={[
|
||||
{ accessorKey: "id", header: "ID" },
|
||||
{
|
||||
accessorKey: "user",
|
||||
header: t("claim.user", "User"),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
<div>#{row.original.user.id}</div>
|
||||
{row.original.user.email && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{row.original.user.email}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "prize",
|
||||
header: t("claim.prize", "Prize"),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{row.original.prize.name}</div>
|
||||
<Badge className="mt-0.5" variant="secondary">
|
||||
{prizeTypeLabel(row.original.prize.type, t)}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "claim_data",
|
||||
header: t("claim.claimData", "Claim info"),
|
||||
cell: ({ row }) => <ClaimDataCell data={row.original.claim_data} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: t("claim.status", "Status"),
|
||||
cell: ({ row }) => {
|
||||
const meta = STATUS_META[row.original.status];
|
||||
return (
|
||||
<Badge className={meta?.className} variant="outline">
|
||||
{meta
|
||||
? t(meta.labelKey, meta.labelDefault)
|
||||
: row.original.status}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "fulfillment",
|
||||
header: t("claim.fulfillment", "Tx / Delivery"),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[180px] break-all text-xs">
|
||||
{row.original.tx_hash || row.original.delivery_ref || "--"}
|
||||
{row.original.reject_reason && (
|
||||
<div className="text-rose-600">
|
||||
{row.original.reject_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "expires_at",
|
||||
header: t("claim.expiresAt", "Expires"),
|
||||
cell: ({ row }) =>
|
||||
row.original.expires_at
|
||||
? formatDate(row.original.expires_at)
|
||||
: "--",
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: t("claim.createdAt", "Won at"),
|
||||
cell: ({ row }) =>
|
||||
row.original.created_at
|
||||
? formatDate(row.original.created_at)
|
||||
: "--",
|
||||
},
|
||||
]}
|
||||
header={{ title: t("claim.tableTitle", "Claims") }}
|
||||
params={[
|
||||
{
|
||||
key: "type",
|
||||
options: typeOptions,
|
||||
placeholder: t("claim.filterType", "Type"),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
options: statusOptions,
|
||||
placeholder: t("claim.filterStatus", "Status"),
|
||||
},
|
||||
{
|
||||
key: "user_id",
|
||||
placeholder: t("claim.filterUserId", "User ID"),
|
||||
},
|
||||
]}
|
||||
request={async (pagination, filter) => {
|
||||
const { data } = await getLotteryClaimList({
|
||||
page: pagination.page,
|
||||
size: pagination.size,
|
||||
activity_id: activity.id,
|
||||
type: (filter.type as API.LotteryPrizeType) || undefined,
|
||||
status: (filter.status as API.LotteryClaimStatus) || undefined,
|
||||
user_id: filter.user_id ? Number(filter.user_id) : undefined,
|
||||
});
|
||||
return {
|
||||
list: data.data?.claims || [],
|
||||
total: data.data?.total || 0,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Reject dialog */}
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
setRejectOpen(open);
|
||||
if (!open) {
|
||||
setCurrentRow(null);
|
||||
rejectForm.reset({ reason: "" });
|
||||
}
|
||||
}}
|
||||
open={rejectOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("claim.rejectTitle", "Reject claim")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"claim.rejectDescription",
|
||||
"The reason is shown to the user, who may then resubmit."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...rejectForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={rejectForm.handleSubmit(async (values) => {
|
||||
if (!currentRow) return;
|
||||
await rejectMutation.mutateAsync({
|
||||
id: currentRow.id,
|
||||
reason: values.reason.trim(),
|
||||
});
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={rejectForm.control}
|
||||
name="reason"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("claim.reason", "Reason")}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t(
|
||||
"claim.reasonPlaceholder",
|
||||
"Reason shown to the user"
|
||||
)}
|
||||
rows={4}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setRejectOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("claim.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={rejectMutation.isPending} type="submit">
|
||||
{t("claim.confirmReject", "Confirm reject")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Mark-paid dialog */}
|
||||
<Dialog
|
||||
onOpenChange={(open) => {
|
||||
setPaidOpen(open);
|
||||
if (!open) {
|
||||
setCurrentRow(null);
|
||||
paidForm.reset({ tx_hash: "", delivery_ref: "" });
|
||||
}
|
||||
}}
|
||||
open={paidOpen}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("claim.markPaidTitle", "Mark as fulfilled")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"claim.markPaidDescription",
|
||||
"Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one."
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...paidForm}>
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={paidForm.handleSubmit(async (values) => {
|
||||
if (!currentRow) return;
|
||||
const txHash = values.tx_hash?.trim();
|
||||
const deliveryRef = values.delivery_ref?.trim();
|
||||
const prizeType = currentRow.prize.type;
|
||||
if (prizeType === "crypto" && !txHash) {
|
||||
paidForm.setError("tx_hash", {
|
||||
message: t(
|
||||
"claim.txHashRequired",
|
||||
"Tx hash is required for crypto"
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (prizeType === "physical" && !deliveryRef) {
|
||||
paidForm.setError("delivery_ref", {
|
||||
message: t(
|
||||
"claim.deliveryRefRequired",
|
||||
"Delivery ref is required for physical"
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!(txHash || deliveryRef)) {
|
||||
paidForm.setError("tx_hash", {
|
||||
message: t(
|
||||
"claim.oneRefRequired",
|
||||
"Fill at least one field"
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await paidMutation.mutateAsync({
|
||||
id: currentRow.id,
|
||||
tx_hash: txHash || undefined,
|
||||
delivery_ref: deliveryRef || undefined,
|
||||
});
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
control={paidForm.control}
|
||||
name="tx_hash"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("claim.txHash", "Tx hash")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="0x..." {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={paidForm.control}
|
||||
name="delivery_ref"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("claim.deliveryRef", "Delivery ref")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t(
|
||||
"claim.deliveryRefPlaceholder",
|
||||
"Tracking number"
|
||||
)}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => setPaidOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{t("claim.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button disabled={paidMutation.isPending} type="submit">
|
||||
{t("claim.confirmMarkPaid", "Confirm")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user