diff --git a/apps/admin/public/assets/locales/en-US/lottery.json b/apps/admin/public/assets/locales/en-US/lottery.json
index 5cdb158..f6a6a33 100644
--- a/apps/admin/public/assets/locales/en-US/lottery.json
+++ b/apps/admin/public/assets/locales/en-US/lottery.json
@@ -9,7 +9,8 @@
"createSuccess": "Create Success",
"detail": {
"prizesTab": "Prizes",
- "rulesTab": "Rules"
+ "rulesTab": "Rules",
+ "claimsTab": "Claims"
},
"edit": "Edit",
"editActivity": "Edit Activity",
@@ -58,10 +59,21 @@
"amountYuan": "Commission Amount (CNY)",
"cancel": "Cancel",
"centerSlotHint": "Usually the draw button",
+ "claimTtlHint": "How long the user has to submit claim info. Default 168h (7 days).",
+ "claimTtlHours": "Claim window (hours)",
+ "claimTtlInvalid": "Claim window (hours) must be a positive integer",
"confirm": "Confirm",
"confirmDelete": "Delete",
"createSuccess": "Prize created",
"createTitle": "Add Prize",
+ "cryptoAmount": "Amount",
+ "cryptoAmountPlaceholder": "e.g. 0.01",
+ "cryptoAmountRequired": "Amount is required",
+ "cryptoCurrency": "Currency",
+ "cryptoCurrencyRequired": "Currency is required",
+ "cryptoNetworks": "Networks",
+ "cryptoNetworksHint": "Comma-separated. The user picks one of these when claiming.",
+ "cryptoNetworksRequired": "At least one network is required (comma-separated)",
"daysSuffix": "days",
"delete": "Delete",
"deleteDescription": "This prize will be removed from the grid. This action cannot be undone.",
@@ -70,13 +82,16 @@
"durationDays": "Duration (days)",
"durationDaysPlaceholder": "e.g. 1",
"durationDaysRequired": "Duration days must be a positive integer",
+ "durationSummary": "{{days}} days",
"editTitle": "Edit Prize",
"fallbackBadge": "Fallback",
- "fallbackIgnoresWeight": "Fallback prize ignores weight",
+ "fallbackIgnoresWeight": "Fallback prize ignores probability (never randomly drawn)",
+ "hoursSuffix": "hours",
"iconUrl": "Icon URL",
"iconUrlInvalid": "Must be a valid URL",
"isFallback": "Fallback Prize",
"isFallbackHint": "Awarded when limited prizes are sold out",
+ "manualPrizeHint": "Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab.",
"name": "Prize Name",
"namePlaceholder": "e.g. 1-Day Pass",
"nameRequired": "Name is required",
@@ -85,11 +100,18 @@
"noRandomPrizes": "No prizes participate in the random draw yet",
"noneHint": "No config needed. Consider enabling fallback so users always land on this prize when limited prizes run out.",
"probabilityPreview": "Probability Preview",
+ "probabilityTotal": "Total",
"selectType": "Select type",
+ "skuName": "Item name (optional)",
+ "skuNamePlaceholder": "e.g. Limited T-shirt",
"slotLabel": "Slot",
"soldOutBadge": "Sold Out",
- "stage2Hint": "Available in Stage 2",
"stockShort": "Stock",
+ "subscribePlan": "New-subscription plan",
+ "subscribePlanHint": "When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting.",
+ "subscribePlanPlaceholder": "None — skip if user has no active subscription",
+ "totalNot100Hint": "The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
+ "totalNot100Title": "Probabilities must total 100%",
"totalStock": "Total Stock",
"totalStockPlaceholder": "e.g. 100",
"totalStockRequired": "Total stock must be a positive integer",
@@ -104,11 +126,12 @@
"typeVpnDuration": "VPN Duration",
"unlimitedStock": "Unlimited Stock",
"updateSuccess": "Prize updated",
- "weight": "Weight",
- "weightHint": "0 = displayed but excluded from random draw",
- "weightInteger": "Weight must be an integer",
- "weightMin": "Weight must be at least 0",
- "weightShort": "Weight",
+ "weight": "Win probability (%)",
+ "weightHint": "Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn.",
+ "weightInteger": "Probability must be an integer",
+ "weightMax": "Probability cannot exceed 100",
+ "weightMin": "Probability must be at least 0",
+ "weightShort": "Prob",
"yuanSuffix": "CNY"
},
"publish": "Publish",
@@ -177,5 +200,50 @@
"statusAll": "All",
"statusLabel": "Status",
"title": "Title",
- "updateSuccess": "Update Success"
+ "updateSuccess": "Update Success",
+ "claim": {
+ "approve": "Approve",
+ "approveDescription": "Approving moves this claim to the paying/shipping stage.",
+ "approveSuccess": "Claim approved",
+ "approveTitle": "Approve this claim?",
+ "cancel": "Cancel",
+ "claimData": "Claim info",
+ "confirm": "Confirm",
+ "confirmMarkPaid": "Confirm",
+ "confirmReject": "Confirm reject",
+ "createdAt": "Won at",
+ "deliveryRef": "Delivery ref",
+ "deliveryRefPlaceholder": "Tracking number",
+ "deliveryRefRequired": "Delivery ref is required for physical",
+ "expiresAt": "Expires",
+ "filterStatus": "Status",
+ "filterType": "Type",
+ "filterUserId": "User ID",
+ "fulfillment": "Tx / Delivery",
+ "markPaid": "Mark fulfilled",
+ "markPaidDescription": "Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one.",
+ "markPaidSuccess": "Marked as fulfilled",
+ "markPaidTitle": "Mark as fulfilled",
+ "oneRefRequired": "Fill at least one field",
+ "prize": "Prize",
+ "reason": "Reason",
+ "reasonPlaceholder": "Reason shown to the user",
+ "reasonRequired": "Please enter a reason",
+ "reasonTooLong": "Reason cannot exceed 512 chars",
+ "reject": "Reject",
+ "rejectDescription": "The reason is shown to the user, who may then resubmit.",
+ "rejectSuccess": "Claim rejected",
+ "rejectTitle": "Reject claim",
+ "status": "Status",
+ "statusExpired": "Expired",
+ "statusPaid": "Fulfilled",
+ "statusPaying": "Paying / Shipping",
+ "statusPending": "Awaiting user",
+ "statusRejected": "Rejected",
+ "statusReviewing": "Reviewing",
+ "tableTitle": "Claims",
+ "txHash": "Tx hash",
+ "txHashRequired": "Tx hash is required for crypto",
+ "user": "User"
+ }
}
diff --git a/apps/admin/public/assets/locales/zh-CN/lottery.json b/apps/admin/public/assets/locales/zh-CN/lottery.json
index a44b324..4eff1c0 100644
--- a/apps/admin/public/assets/locales/zh-CN/lottery.json
+++ b/apps/admin/public/assets/locales/zh-CN/lottery.json
@@ -9,7 +9,8 @@
"createSuccess": "创建成功",
"detail": {
"prizesTab": "奖品",
- "rulesTab": "规则"
+ "rulesTab": "规则",
+ "claimsTab": "领奖工单"
},
"edit": "编辑",
"editActivity": "编辑活动",
@@ -58,10 +59,21 @@
"amountYuan": "佣金金额(元)",
"cancel": "取消",
"centerSlotHint": "通常为抽奖按钮",
+ "claimTtlHint": "用户提交领奖信息的时限,缺省 168 小时(7 天)。",
+ "claimTtlHours": "领奖窗口(小时)",
+ "claimTtlInvalid": "领奖窗口(小时)必须为正整数",
"confirm": "确认",
"confirmDelete": "删除",
"createSuccess": "奖品创建成功",
"createTitle": "添加奖品",
+ "cryptoAmount": "金额",
+ "cryptoAmountPlaceholder": "例如:0.01",
+ "cryptoAmountRequired": "请输入金额",
+ "cryptoCurrency": "币种",
+ "cryptoCurrencyRequired": "请输入币种",
+ "cryptoNetworks": "网络",
+ "cryptoNetworksHint": "逗号分隔,用户领奖时从中选择一个网络。",
+ "cryptoNetworksRequired": "至少填写一个网络(逗号分隔)",
"daysSuffix": "天",
"delete": "删除",
"deleteDescription": "该奖品将从九宫格中移除,此操作不可撤销。",
@@ -70,13 +82,16 @@
"durationDays": "时长(天)",
"durationDaysPlaceholder": "例如:1",
"durationDaysRequired": "时长天数必须为正整数",
+ "durationSummary": "{{days}} 天",
"editTitle": "编辑奖品",
"fallbackBadge": "保底",
- "fallbackIgnoresWeight": "保底奖品不参与权重计算",
+ "fallbackIgnoresWeight": "保底奖品不参与概率(永不被随机抽中)",
+ "hoursSuffix": "小时",
"iconUrl": "图标 URL",
"iconUrlInvalid": "请输入有效的 URL",
"isFallback": "保底奖品",
"isFallbackHint": "限量奖品抽完后发放该奖品",
+ "manualPrizeHint": "人工奖:用户先提交领奖信息,运营在「领奖工单」页审核并标记发放/发货。",
"name": "奖品名称",
"namePlaceholder": "例如:1 天时长卡",
"nameRequired": "请输入奖品名称",
@@ -85,11 +100,18 @@
"noRandomPrizes": "暂无参与随机抽取的奖品",
"noneHint": "无需额外配置。建议开启保底,限量奖品抽完后用户将始终抽中该奖品。",
"probabilityPreview": "概率预览",
+ "probabilityTotal": "合计",
"selectType": "选择类型",
+ "skuName": "物品名称(可选)",
+ "skuNamePlaceholder": "例如:限量 T 恤",
"slotLabel": "格子",
"soldOutBadge": "已抽完",
- "stage2Hint": "Stage 2 开放",
"stockShort": "库存",
+ "subscribePlan": "无订阅时新建套餐",
+ "subscribePlanHint": "当用户无活跃订阅时,按该套餐新建订阅并发放时长;留空则跳过发放。",
+ "subscribePlanPlaceholder": "不选:用户无活跃订阅时跳过发放",
+ "totalNot100Hint": "当前可抽奖品概率合计 {{total}}%。请调整各奖品的中奖概率,使其精确等于 100%。",
+ "totalNot100Title": "概率合计必须为 100%",
"totalStock": "总库存",
"totalStockPlaceholder": "例如:100",
"totalStockRequired": "总库存必须为正整数",
@@ -104,11 +126,12 @@
"typeVpnDuration": "免费时长",
"unlimitedStock": "无限库存",
"updateSuccess": "奖品更新成功",
- "weight": "权重",
- "weightHint": "权重为 0 时仅展示,不参与随机抽取",
- "weightInteger": "权重必须为整数",
- "weightMin": "权重不能小于 0",
- "weightShort": "权重",
+ "weight": "中奖概率(%)",
+ "weightHint": "九宫格内的中奖概率。所有参与随机的奖品合计须为 100%。0 = 展示但不参与随机。",
+ "weightInteger": "概率必须为整数",
+ "weightMax": "概率不能超过 100",
+ "weightMin": "概率不能小于 0",
+ "weightShort": "概率",
"yuanSuffix": "元"
},
"publish": "上架",
@@ -177,5 +200,50 @@
"statusAll": "全部",
"statusLabel": "状态",
"title": "标题",
- "updateSuccess": "更新成功"
+ "updateSuccess": "更新成功",
+ "claim": {
+ "approve": "通过",
+ "approveDescription": "通过后工单进入待发放/发货阶段。",
+ "approveSuccess": "工单已通过",
+ "approveTitle": "确认通过该工单?",
+ "cancel": "取消",
+ "claimData": "领奖信息",
+ "confirm": "确认",
+ "confirmMarkPaid": "确认",
+ "confirmReject": "确认驳回",
+ "createdAt": "中奖时间",
+ "deliveryRef": "物流单号",
+ "deliveryRefPlaceholder": "快递单号",
+ "deliveryRefRequired": "实物奖品必须填写物流单号",
+ "expiresAt": "过期时间",
+ "filterStatus": "状态",
+ "filterType": "类型",
+ "filterUserId": "用户 ID",
+ "fulfillment": "交易/物流单号",
+ "markPaid": "标记发放",
+ "markPaidDescription": "加密货币需填交易哈希;实物需填物流单号;人工其他至少填一项。",
+ "markPaidSuccess": "已标记发放",
+ "markPaidTitle": "标记为已发放",
+ "oneRefRequired": "至少填写一项",
+ "prize": "奖品",
+ "reason": "驳回原因",
+ "reasonPlaceholder": "填写驳回原因,用户端会看到",
+ "reasonRequired": "请填写驳回原因",
+ "reasonTooLong": "驳回原因不能超过 512 字",
+ "reject": "驳回",
+ "rejectDescription": "驳回原因会展示给用户,用户可重新提交。",
+ "rejectSuccess": "工单已驳回",
+ "rejectTitle": "驳回工单",
+ "status": "状态",
+ "statusExpired": "已过期",
+ "statusPaid": "已发放",
+ "statusPaying": "待发放/发货",
+ "statusPending": "待用户填写",
+ "statusRejected": "已驳回",
+ "statusReviewing": "待审核",
+ "tableTitle": "领奖工单",
+ "txHash": "交易哈希",
+ "txHashRequired": "加密货币奖品必须填写交易哈希",
+ "user": "用户"
+ }
}
diff --git a/apps/admin/src/sections/lottery/activity-detail.tsx b/apps/admin/src/sections/lottery/activity-detail.tsx
index e2630fd..86514f0 100644
--- a/apps/admin/src/sections/lottery/activity-detail.tsx
+++ b/apps/admin/src/sections/lottery/activity-detail.tsx
@@ -14,6 +14,7 @@ import {
import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
+import ClaimsPanel from "./claims-panel";
import PrizeGrid from "./prize-grid";
import RuleEditor from "./rule-editor";
@@ -102,6 +103,9 @@ export default function ActivityDetail({
{t("detail.rulesTab", "Rules")}
+
+ {t("detail.claimsTab", "Claims")}
+
@@ -109,6 +113,9 @@ export default function ActivityDetail({
+
+
+
>
diff --git a/apps/admin/src/sections/lottery/claims-panel.tsx b/apps/admin/src/sections/lottery/claims-panel.tsx
new file mode 100644
index 0000000..6a9a2f7
--- /dev/null
+++ b/apps/admin/src/sections/lottery/claims-panel.tsx
@@ -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 = {
+ 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 }) {
+ if (!data || Object.keys(data).length === 0) {
+ return -- ;
+ }
+ return (
+
+ {Object.entries(data).map(([key, value]) => (
+
+ {key}: {" "}
+ {String(value)}
+
+ ))}
+
+ );
+}
+
+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(null);
+ const [rejectOpen, setRejectOpen] = useState(false);
+ const [paidOpen, setPaidOpen] = useState(false);
+ const [currentRow, setCurrentRow] = useState(
+ null
+ );
+
+ const rejectForm = useForm<{ reason: string }>({
+ resolver: zodResolver(rejectSchema(t)),
+ defaultValues: { reason: "" },
+ });
+ const paidForm = useForm>({
+ 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 (
+ <>
+
+ action={ref}
+ actions={{
+ render: (row) => {
+ const actions: ReactNode[] = [];
+ if (row.status === "reviewing") {
+ actions.push(
+ {
+ await approveMutation.mutateAsync(row.id);
+ }}
+ title={t("claim.approveTitle", "Approve this claim?")}
+ trigger={
+
+ {t("claim.approve", "Approve")}
+
+ }
+ />
+ );
+ }
+ if (row.status === "paying") {
+ actions.push(
+ openPaid(row)}
+ size="sm"
+ variant="default"
+ >
+ {t("claim.markPaid", "Mark fulfilled")}
+
+ );
+ }
+ if (row.status === "reviewing" || row.status === "paying") {
+ actions.push(
+ openReject(row)}
+ size="sm"
+ variant="destructive"
+ >
+ {t("claim.reject", "Reject")}
+
+ );
+ }
+ return actions;
+ },
+ }}
+ columns={[
+ { accessorKey: "id", header: "ID" },
+ {
+ accessorKey: "user",
+ header: t("claim.user", "User"),
+ cell: ({ row }) => (
+
+
#{row.original.user.id}
+ {row.original.user.email && (
+
+ {row.original.user.email}
+
+ )}
+
+ ),
+ },
+ {
+ accessorKey: "prize",
+ header: t("claim.prize", "Prize"),
+ cell: ({ row }) => (
+
+
{row.original.prize.name}
+
+ {prizeTypeLabel(row.original.prize.type, t)}
+
+
+ ),
+ },
+ {
+ accessorKey: "claim_data",
+ header: t("claim.claimData", "Claim info"),
+ cell: ({ row }) => ,
+ },
+ {
+ accessorKey: "status",
+ header: t("claim.status", "Status"),
+ cell: ({ row }) => {
+ const meta = STATUS_META[row.original.status];
+ return (
+
+ {meta
+ ? t(meta.labelKey, meta.labelDefault)
+ : row.original.status}
+
+ );
+ },
+ },
+ {
+ accessorKey: "fulfillment",
+ header: t("claim.fulfillment", "Tx / Delivery"),
+ cell: ({ row }) => (
+
+ {row.original.tx_hash || row.original.delivery_ref || "--"}
+ {row.original.reject_reason && (
+
+ {row.original.reject_reason}
+
+ )}
+
+ ),
+ },
+ {
+ 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 */}
+ {
+ setRejectOpen(open);
+ if (!open) {
+ setCurrentRow(null);
+ rejectForm.reset({ reason: "" });
+ }
+ }}
+ open={rejectOpen}
+ >
+
+
+ {t("claim.rejectTitle", "Reject claim")}
+
+ {t(
+ "claim.rejectDescription",
+ "The reason is shown to the user, who may then resubmit."
+ )}
+
+
+
+
+
+
+
+ {/* Mark-paid dialog */}
+ {
+ setPaidOpen(open);
+ if (!open) {
+ setCurrentRow(null);
+ paidForm.reset({ tx_hash: "", delivery_ref: "" });
+ }
+ }}
+ open={paidOpen}
+ >
+
+
+
+ {t("claim.markPaidTitle", "Mark as fulfilled")}
+
+
+ {t(
+ "claim.markPaidDescription",
+ "Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one."
+ )}
+
+
+
+ {
+ 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,
+ });
+ })}
+ >
+ (
+
+ {t("claim.txHash", "Tx hash")}
+
+
+
+
+
+ )}
+ />
+ (
+
+
+ {t("claim.deliveryRef", "Delivery ref")}
+
+
+
+
+
+
+ )}
+ />
+
+ setPaidOpen(false)}
+ type="button"
+ variant="outline"
+ >
+ {t("claim.cancel", "Cancel")}
+
+
+ {t("claim.confirmMarkPaid", "Confirm")}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/sections/lottery/prize-form.tsx b/apps/admin/src/sections/lottery/prize-form.tsx
index bdf2ee2..1a1a1e9 100644
--- a/apps/admin/src/sections/lottery/prize-form.tsx
+++ b/apps/admin/src/sections/lottery/prize-form.tsx
@@ -35,6 +35,7 @@ import {
deleteLotteryPrize,
updateLotteryPrize,
} from "@workspace/ui/services/admin/lottery";
+import { getSubscribeList } from "@workspace/ui/services/admin/subscribe";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
@@ -49,7 +50,6 @@ const PRIZE_TYPE_OPTIONS: {
value: API.LotteryPrizeType;
labelKey: string;
labelDefault: string;
- disabled?: boolean;
}[] = [
{
value: "vpn_duration",
@@ -70,26 +70,30 @@ const PRIZE_TYPE_OPTIONS: {
value: "crypto",
labelKey: "prize.typeCrypto",
labelDefault: "Crypto",
- disabled: true,
},
{
value: "physical",
labelKey: "prize.typePhysical",
labelDefault: "Physical",
- disabled: true,
},
{
value: "manual_other",
labelKey: "prize.typeManualOther",
labelDefault: "Manual (Other)",
- disabled: true,
},
];
const getPrizeSchema = (t: TranslateFn) =>
z
.object({
- type: z.enum(["vpn_duration", "commission", "none"]),
+ type: z.enum([
+ "vpn_duration",
+ "commission",
+ "none",
+ "crypto",
+ "physical",
+ "manual_other",
+ ]),
name: z.string().min(1, t("prize.nameRequired", "Name is required")),
icon_url: z
.string()
@@ -98,13 +102,21 @@ const getPrizeSchema = (t: TranslateFn) =>
.optional(),
weight: z
.number()
- .int(t("prize.weightInteger", "Weight must be an integer"))
- .min(0, t("prize.weightMin", "Weight must be at least 0")),
+ .int(t("prize.weightInteger", "Probability must be an integer"))
+ .min(0, t("prize.weightMin", "Probability must be at least 0"))
+ .max(100, t("prize.weightMax", "Probability cannot exceed 100")),
unlimited_stock: z.boolean(),
total_stock: z.number().optional(),
is_fallback: z.boolean(),
duration_days: z.number().optional(),
+ subscribe_id: z.number().optional(),
amount_yuan: z.number().optional(),
+ // 人工奖(crypto / physical / manual_other)通用/专属字段
+ crypto_amount: z.string().optional(),
+ crypto_currency: z.string().optional(),
+ crypto_networks: z.string().optional(), // 逗号分隔,提交时 split 成 string[]
+ sku_name: z.string().optional(),
+ claim_ttl_hours: z.number().optional(),
})
.superRefine((data, ctx) => {
const issue = (path: string, message: string) => {
@@ -139,6 +151,45 @@ const getPrizeSchema = (t: TranslateFn) =>
);
}
}
+ if (data.type === "crypto") {
+ if (!data.crypto_amount?.trim()) {
+ issue(
+ "crypto_amount",
+ t("prize.cryptoAmountRequired", "Amount is required")
+ );
+ }
+ if (!data.crypto_currency?.trim()) {
+ issue(
+ "crypto_currency",
+ t("prize.cryptoCurrencyRequired", "Currency is required")
+ );
+ }
+ const networks = (data.crypto_networks ?? "")
+ .split(",")
+ .map((n) => n.trim())
+ .filter(Boolean);
+ if (networks.length === 0) {
+ issue(
+ "crypto_networks",
+ t(
+ "prize.cryptoNetworksRequired",
+ "At least one network is required (comma-separated)"
+ )
+ );
+ }
+ }
+ if (
+ data.claim_ttl_hours !== undefined &&
+ (!Number.isInteger(data.claim_ttl_hours) || data.claim_ttl_hours <= 0)
+ ) {
+ issue(
+ "claim_ttl_hours",
+ t(
+ "prize.claimTtlInvalid",
+ "Claim window (hours) must be a positive integer"
+ )
+ );
+ }
if (!(data.unlimited_stock || isPositiveInt(data.total_stock))) {
issue(
"total_stock",
@@ -162,6 +213,10 @@ interface PrizeFormProps {
}
function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
+ const config = prize?.config ?? {};
+ const networks = Array.isArray(config.networks)
+ ? (config.networks as string[]).join(", ")
+ : undefined;
return {
type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration",
name: prize?.name ?? "",
@@ -170,23 +225,58 @@ function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
unlimited_stock: prize ? prize.total_stock === null : true,
total_stock: prize?.total_stock ?? undefined,
is_fallback: prize?.is_fallback ?? false,
- duration_days: prize?.config?.duration_days as number | undefined,
+ duration_days: config.duration_days as number | undefined,
+ subscribe_id: config.subscribe_id as number | undefined,
amount_yuan:
- typeof prize?.config?.amount_cents === "number"
- ? prize.config.amount_cents / CENTS_PER_YUAN
+ typeof config.amount_cents === "number"
+ ? config.amount_cents / CENTS_PER_YUAN
: undefined,
+ crypto_amount: config.amount as string | undefined,
+ crypto_currency: config.currency as string | undefined,
+ crypto_networks: networks,
+ sku_name: config.sku_name as string | undefined,
+ claim_ttl_hours: config.claim_ttl_hours as number | undefined,
};
}
function toConfig(values: PrizeFormValues): Record {
+ // claim_ttl_hours 对所有人工奖通用:控制领奖窗口(缺省后端默认 7 天)。
+ const withTtl = (base: Record): Record => {
+ if (values.claim_ttl_hours && values.claim_ttl_hours > 0) {
+ base.claim_ttl_hours = values.claim_ttl_hours;
+ }
+ return base;
+ };
if (values.type === "vpn_duration") {
- return { duration_days: values.duration_days };
+ const config: Record = { duration_days: values.duration_days };
+ // 可选:无活跃订阅时按该套餐新建订阅发放时长。0/未选则沿用旧行为(跳过发放)。
+ if (values.subscribe_id && values.subscribe_id > 0) {
+ config.subscribe_id = values.subscribe_id;
+ }
+ return config;
}
if (values.type === "commission") {
return {
amount_cents: Math.round((values.amount_yuan ?? 0) * CENTS_PER_YUAN),
};
}
+ if (values.type === "crypto") {
+ return withTtl({
+ amount: values.crypto_amount?.trim() ?? "",
+ currency: values.crypto_currency?.trim() ?? "",
+ networks: (values.crypto_networks ?? "")
+ .split(",")
+ .map((n) => n.trim())
+ .filter(Boolean),
+ });
+ }
+ if (values.type === "physical" || values.type === "manual_other") {
+ const config: Record = {};
+ if (values.sku_name?.trim()) {
+ config.sku_name = values.sku_name.trim();
+ }
+ return withTtl(config);
+ }
return {};
}
@@ -200,6 +290,7 @@ export default function PrizeForm({
}: PrizeFormProps) {
const { t } = useTranslation("lottery");
const [loading, setLoading] = useState(false);
+ const [plans, setPlans] = useState([]);
const form = useForm({
resolver: zodResolver(getPrizeSchema(t)),
defaultValues: toFormValues(initialValues),
@@ -214,6 +305,16 @@ export default function PrizeForm({
if (open) form.reset(toFormValues(initialValues));
}, [form, initialValues, open]);
+ // vpn_duration 需要一个可选的“新建订阅套餐”下拉,仅在面板打开且类型匹配时拉取。
+ useEffect(() => {
+ if (!(open && type === "vpn_duration") || plans.length > 0) return;
+ getSubscribeList({ page: 1, size: 100 })
+ .then(({ data }) => setPlans(data?.data?.list ?? []))
+ .catch(() => {
+ // Request layer already surfaces the error toast
+ });
+ }, [open, type, plans.length]);
+
async function handleSubmit(values: PrizeFormValues) {
setLoading(true);
try {
@@ -224,7 +325,7 @@ export default function PrizeForm({
name: values.name.trim(),
icon_url: values.icon_url?.trim() ?? "",
config: toConfig(values),
- weight: values.weight,
+ weight: values.is_fallback ? 0 : values.weight,
total_stock: values.unlimited_stock
? null
: (values.total_stock ?? null),
@@ -268,7 +369,12 @@ export default function PrizeForm({
| "weight"
| "total_stock"
| "duration_days"
- | "amount_yuan",
+ | "amount_yuan"
+ | "crypto_amount"
+ | "crypto_currency"
+ | "crypto_networks"
+ | "sku_name"
+ | "claim_ttl_hours",
label: string,
inputProps: Partial>,
description?: string
@@ -322,8 +428,6 @@ export default function PrizeForm({
/>
);
- const stage2Hint = t("prize.stage2Hint", "Available in Stage 2");
-
return (
@@ -365,13 +469,8 @@ export default function PrizeForm({
{PRIZE_TYPE_OPTIONS.map((option) => (
-
+
{t(option.labelKey, option.labelDefault)}
- {option.disabled ? ` · ${stage2Hint}` : ""}
))}
@@ -399,6 +498,53 @@ export default function PrizeForm({
suffix: t("prize.daysSuffix", "days"),
}
)}
+ {type === "vpn_duration" && (
+ (
+
+
+ {t("prize.subscribePlan", "New-subscription plan")}
+
+
+
+ form.setValue(
+ "subscribe_id",
+ value ? Number(value) : undefined
+ )
+ }
+ value={field.value ? String(field.value) : undefined}
+ >
+
+
+
+
+ {plans.map((plan) => (
+
+ {plan.name ?? `#${plan.id}`}
+
+ ))}
+
+
+
+
+ {t(
+ "prize.subscribePlanHint",
+ "When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting."
+ )}
+
+
+
+ )}
+ />
+ )}
{type === "commission" &&
renderInput(
"amount_yuan",
@@ -424,18 +570,91 @@ export default function PrizeForm({
)}
)}
+ {type === "crypto" && (
+ <>
+ {renderInput(
+ "crypto_amount",
+ t("prize.cryptoAmount", "Amount"),
+ {
+ placeholder: t(
+ "prize.cryptoAmountPlaceholder",
+ "e.g. 0.01"
+ ),
+ }
+ )}
+ {renderInput(
+ "crypto_currency",
+ t("prize.cryptoCurrency", "Currency"),
+ { placeholder: "USDT / BTC / ETH" }
+ )}
+ {renderInput(
+ "crypto_networks",
+ t("prize.cryptoNetworks", "Networks"),
+ { placeholder: "TRC20, ERC20, BTC" },
+ t(
+ "prize.cryptoNetworksHint",
+ "Comma-separated. The user picks one of these when claiming."
+ )
+ )}
+ >
+ )}
+ {(type === "physical" || type === "manual_other") &&
+ renderInput(
+ "sku_name",
+ t("prize.skuName", "Item name (optional)"),
+ {
+ placeholder: t(
+ "prize.skuNamePlaceholder",
+ "e.g. Limited T-shirt"
+ ),
+ }
+ )}
+ {(type === "crypto" ||
+ type === "physical" ||
+ type === "manual_other") && (
+ <>
+ {renderInput(
+ "claim_ttl_hours",
+ t("prize.claimTtlHours", "Claim window (hours)"),
+ {
+ type: "number",
+ min: 1,
+ step: 1,
+ placeholder: "168",
+ suffix: t("prize.hoursSuffix", "hours"),
+ },
+ t(
+ "prize.claimTtlHint",
+ "How long the user has to submit claim info. Default 168h (7 days)."
+ )
+ )}
+
+ {t(
+ "prize.manualPrizeHint",
+ "Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab."
+ )}
+
+ >
+ )}
{renderInput(
"weight",
- t("prize.weight", "Weight"),
- { type: "number", min: 0, step: 1, disabled: isFallback },
+ t("prize.weight", "Win probability (%)"),
+ {
+ type: "number",
+ min: 0,
+ max: 100,
+ step: 1,
+ suffix: "%",
+ disabled: isFallback,
+ },
isFallback
? t(
"prize.fallbackIgnoresWeight",
- "Fallback prize ignores weight"
+ "Fallback prize ignores probability (never randomly drawn)"
)
: t(
"prize.weightHint",
- "0 = displayed but excluded from random draw"
+ "Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn."
)
)}
{renderSwitch(
diff --git a/apps/admin/src/sections/lottery/prize-grid.tsx b/apps/admin/src/sections/lottery/prize-grid.tsx
index 0c16a9e..a5565a7 100644
--- a/apps/admin/src/sections/lottery/prize-grid.tsx
+++ b/apps/admin/src/sections/lottery/prize-grid.tsx
@@ -15,6 +15,8 @@ import PrizeForm from "./prize-form";
const DEFAULT_GRID_SIZE = 9;
const CENTER_SLOT = 4;
const PERCENT_DECIMALS = 1;
+const REQUIRED_TOTAL_PERCENT = 100;
+const CENTS_PER_YUAN = 100;
interface PrizeGridProps {
activity: API.LotteryActivity;
@@ -36,6 +38,30 @@ function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
return labels[type] ?? type;
}
+// prizeConfigSummary 把奖品的类型专属 config 渲染成一行可读文本,
+// 让九宫格卡片显示真实配置值(免费时长天数 / 佣金金额),而不是只有类型徽章。
+function prizeConfigSummary(
+ prize: API.LotteryPrize,
+ t: TranslateFn
+): string | null {
+ const config = prize.config ?? {};
+ if (prize.type === "vpn_duration") {
+ const days = config.duration_days as number | undefined;
+ if (typeof days === "number" && days > 0) {
+ return `${days} ${t("prize.daysSuffix", "days")}`;
+ }
+ return null;
+ }
+ if (prize.type === "commission") {
+ const cents = config.amount_cents as number | undefined;
+ if (typeof cents === "number" && cents > 0) {
+ return `¥${(cents / CENTS_PER_YUAN).toFixed(2)}`;
+ }
+ return null;
+ }
+ return null;
+}
+
interface FormState {
open: boolean;
slot: number;
@@ -84,6 +110,7 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
[randomPrizes]
);
const hasFallback = prizes.some((prize) => prize.is_fallback);
+ const totalIsValid = totalWeight === REQUIRED_TOTAL_PERCENT;
const gridSize = activity.grid_size || DEFAULT_GRID_SIZE;
const slots = Array.from({ length: gridSize }, (_, index) => index);
@@ -104,16 +131,23 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
return (
-
- {t("prize.probabilityPreview", "Probability Preview")}
-
+
+
+ {t("prize.probabilityPreview", "Probability Preview")}
+
+ {randomPrizes.length > 0 && (
+
+ {t("prize.probabilityTotal", "Total")}: {totalWeight}%
+
+ )}
+
{randomPrizes.length > 0 ? (
{randomPrizes.map((prize) => (
- {prize.name}: {prize.weight}/{totalWeight} (
+ {prize.name}:{" "}
{((prize.weight / totalWeight) * 100).toFixed(PERCENT_DECIMALS)}
- %)
+ %
))}
@@ -126,6 +160,21 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
)}
+ {randomPrizes.length > 0 && !totalIsValid && (
+
+
+
+ {t("prize.totalNot100Title", "Probabilities must total 100%")}
+
+
+ {t(
+ "prize.totalNot100Hint",
+ "The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
+ { total: totalWeight }
+ )}
+
+
+ )}
{!hasFallback && (
@@ -185,8 +234,17 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
{prizeTypeLabel(prize.type, t)}
+ {(() => {
+ const summary = prizeConfigSummary(prize, t);
+ return summary ? (
+
+ {summary}
+
+ ) : null;
+ })()}
- {t("prize.weightShort", "Weight")}: {prize.weight} ·{" "}
+ {t("prize.weightShort", "Prob")}:{" "}
+ {prize.is_fallback ? "—" : `${prize.weight}%`} ·{" "}
{t("prize.stockShort", "Stock")}:{" "}
{prize.total_stock === null ? "∞" : prize.total_stock}
diff --git a/packages/ui/src/services/admin/lottery.ts b/packages/ui/src/services/admin/lottery.ts
index fd6d4fa..436f301 100644
--- a/packages/ui/src/services/admin/lottery.ts
+++ b/packages/ui/src/services/admin/lottery.ts
@@ -202,14 +202,15 @@ export async function updateLotteryPrize(
body: API.UpdateLotteryPrizeRequest,
options?: { [key: string]: any }
) {
+ const { id, ...rest } = body;
return request(
- `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/prizes`,
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/prizes/${id}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
- data: body,
+ data: rest,
...(options || {}),
}
);
@@ -227,13 +228,9 @@ export async function deleteLotteryPrize(
options?: { [key: string]: any }
) {
return request(
- `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/prizes`,
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/prizes/${body.id}`,
{
method: "DELETE",
- headers: {
- "Content-Type": "application/json",
- },
- data: body,
...(options || {}),
}
);
@@ -285,3 +282,94 @@ export async function grantLotteryChances(
}
);
}
+
+/**
+ * 领奖工单列表(Stage 2 人工奖)
+ * GET /v1/admin/lottery/claims
+ */
+export async function getLotteryClaimList(
+ params: API.GetLotteryClaimListParams,
+ options?: { [key: string]: any }
+) {
+ return request(
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims`,
+ {
+ method: "GET",
+ params: { ...params },
+ ...(options || {}),
+ }
+ );
+}
+
+/**
+ * 领奖工单统计(各类型 reviewing/paying + 逾期)
+ * GET /v1/admin/lottery/claims/summary
+ */
+export async function getLotteryClaimsSummary(options?: {
+ [key: string]: any;
+}) {
+ return request(
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/summary`,
+ {
+ method: "GET",
+ ...(options || {}),
+ }
+ );
+}
+
+/**
+ * 审核通过领奖工单(reviewing → paying)
+ * POST /v1/admin/lottery/claims/approve
+ */
+export async function approveLotteryClaim(
+ body: API.ApproveLotteryClaimRequest,
+ options?: { [key: string]: any }
+) {
+ return request(
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/approve`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ data: body,
+ ...(options || {}),
+ }
+ );
+}
+
+/**
+ * 驳回领奖工单(reviewing/paying → rejected,reason 必填)
+ * POST /v1/admin/lottery/claims/reject
+ */
+export async function rejectLotteryClaim(
+ body: API.RejectLotteryClaimRequest,
+ options?: { [key: string]: any }
+) {
+ return request(
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/reject`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ data: body,
+ ...(options || {}),
+ }
+ );
+}
+
+/**
+ * 标记已发放(paying → paid),填 tx_hash / delivery_ref
+ * POST /v1/admin/lottery/claims/mark-paid
+ */
+export async function markPaidLotteryClaim(
+ body: API.MarkPaidLotteryClaimRequest,
+ options?: { [key: string]: any }
+) {
+ return request(
+ `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/mark-paid`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ data: body,
+ ...(options || {}),
+ }
+ );
+}
diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts
index 2cb4fe2..c0f02e0 100644
--- a/packages/ui/src/services/admin/typings.d.ts
+++ b/packages/ui/src/services/admin/typings.d.ts
@@ -3295,25 +3295,25 @@ declare namespace API {
type AdminWithdrawalLog = WithdrawalLog;
type LotteryPrizeType =
- | 'vpn_duration'
- | 'commission'
- | 'none'
- | 'crypto'
- | 'physical'
- | 'manual_other'
- | 'coupon'
- | 'points';
+ | "vpn_duration"
+ | "commission"
+ | "none"
+ | "crypto"
+ | "physical"
+ | "manual_other"
+ | "coupon"
+ | "points";
- type LotteryActivityStatus = 'draft' | 'running' | 'paused' | 'ended';
+ type LotteryActivityStatus = "draft" | "running" | "paused" | "ended";
type LotteryChanceSourceType =
- | 'daily_signin'
- | 'new_subscription'
- | 'invite_success'
- | 'manual_grant';
+ | "daily_signin"
+ | "new_subscription"
+ | "invite_success"
+ | "manual_grant";
type LotteryEligibilityNode = {
- op?: 'AND' | 'OR';
+ op?: "AND" | "OR";
children?: LotteryEligibilityNode[];
type?: string;
params?: Record;
@@ -3364,11 +3364,20 @@ declare namespace API {
unmet_action: string;
};
- type UpdateLotteryActivityRequest = CreateLotteryActivityRequest & { id: number };
+ type UpdateLotteryActivityRequest = CreateLotteryActivityRequest & {
+ id: number;
+ };
- type GetLotteryActivityListParams = { page: number; size: number; status?: string };
+ type GetLotteryActivityListParams = {
+ page: number;
+ size: number;
+ status?: string;
+ };
- type GetLotteryActivityListResponse = { total: number; list: LotteryActivity[] };
+ type GetLotteryActivityListResponse = {
+ total: number;
+ list: LotteryActivity[];
+ };
type GetLotteryActivityDetailParams = { id: number };
@@ -3408,4 +3417,83 @@ declare namespace API {
amount: number;
source_ref: string;
};
+
+ // ---- Stage 2 领奖工单(人工奖)--------------------------------------------
+
+ type LotteryClaimStatus =
+ | "pending_claim"
+ | "reviewing"
+ | "paying"
+ | "paid"
+ | "rejected"
+ | "expired";
+
+ type GetLotteryClaimListParams = {
+ type?: LotteryPrizeType;
+ status?: LotteryClaimStatus;
+ activity_id?: number;
+ user_id?: number;
+ from?: number;
+ to?: number;
+ page: number;
+ size: number;
+ };
+
+ type AdminLotteryClaimUser = {
+ id: number;
+ email?: string;
+ };
+
+ type AdminLotteryClaimPrize = {
+ type: LotteryPrizeType;
+ name: string;
+ config: Record;
+ };
+
+ type AdminLotteryClaim = {
+ id: number;
+ draw_id: number;
+ activity_id: number;
+ user: AdminLotteryClaimUser;
+ prize: AdminLotteryClaimPrize;
+ status: LotteryClaimStatus;
+ claim_data?: Record;
+ submitted_at?: number;
+ expires_at: number;
+ reviewed_by?: number;
+ reviewed_at?: number;
+ reject_reason?: string;
+ tx_hash?: string;
+ delivery_ref?: string;
+ paid_at?: number;
+ created_at: number;
+ };
+
+ type ListLotteryClaimsResponse = {
+ total: number;
+ claims: AdminLotteryClaim[];
+ };
+
+ type LotteryClaimsStatusCount = {
+ reviewing: number;
+ paying: number;
+ };
+
+ type LotteryClaimsSummary = {
+ crypto: LotteryClaimsStatusCount;
+ physical: LotteryClaimsStatusCount;
+ manual_other: LotteryClaimsStatusCount;
+ overdue: number;
+ };
+
+ type ApproveLotteryClaimRequest = { id: number };
+
+ type RejectLotteryClaimRequest = { id: number; reason: string };
+
+ type MarkPaidLotteryClaimRequest = {
+ id: number;
+ tx_hash?: string;
+ delivery_ref?: string;
+ paid_at?: number;
+ };
}