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:
2026-07-16 20:29:47 -07:00
parent ea4f6799e9
commit dd837cac7c
8 changed files with 1224 additions and 73 deletions
@@ -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"
}
}
@@ -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": "用户"
}
}
@@ -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({
<TabsTrigger value="rules">
{t("detail.rulesTab", "Rules")}
</TabsTrigger>
<TabsTrigger value="claims">
{t("detail.claimsTab", "Claims")}
</TabsTrigger>
</TabsList>
<TabsContent value="prizes">
<PrizeGrid activity={current} />
@@ -109,6 +113,9 @@ export default function ActivityDetail({
<TabsContent value="rules">
<RuleEditor activity={current} onSaved={onChanged} />
</TabsContent>
<TabsContent value="claims">
<ClaimsPanel activity={current} />
</TabsContent>
</Tabs>
</div>
</>
@@ -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>
</>
);
}
+244 -25
View File
@@ -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<string, any> {
// claim_ttl_hours 对所有人工奖通用:控制领奖窗口(缺省后端默认 7 天)。
const withTtl = (base: Record<string, any>): Record<string, any> => {
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<string, any> = { 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<string, any> = {};
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<API.SubscribeItem[]>([]);
const form = useForm<PrizeFormValues>({
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<EnhancedInputProps<any>>,
description?: string
@@ -322,8 +428,6 @@ export default function PrizeForm({
/>
);
const stage2Hint = t("prize.stage2Hint", "Available in Stage 2");
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent className="w-[520px] max-w-full md:max-w-screen-md">
@@ -365,13 +469,8 @@ export default function PrizeForm({
</SelectTrigger>
<SelectContent>
{PRIZE_TYPE_OPTIONS.map((option) => (
<SelectItem
disabled={option.disabled}
key={option.value}
value={option.value}
>
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey, option.labelDefault)}
{option.disabled ? ` · ${stage2Hint}` : ""}
</SelectItem>
))}
</SelectContent>
@@ -399,6 +498,53 @@ export default function PrizeForm({
suffix: t("prize.daysSuffix", "days"),
}
)}
{type === "vpn_duration" && (
<FormField
control={form.control}
name="subscribe_id"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("prize.subscribePlan", "New-subscription plan")}
</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
form.setValue(
"subscribe_id",
value ? Number(value) : undefined
)
}
value={field.value ? String(field.value) : undefined}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"prize.subscribePlanPlaceholder",
"None — skip if user has no active subscription"
)}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name ?? `#${plan.id}`}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<p className="text-muted-foreground text-xs">
{t(
"prize.subscribePlanHint",
"When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting."
)}
</p>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "commission" &&
renderInput(
"amount_yuan",
@@ -424,18 +570,91 @@ export default function PrizeForm({
)}
</p>
)}
{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)."
)
)}
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
"prize.manualPrizeHint",
"Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab."
)}
</p>
</>
)}
{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(
+62 -4
View File
@@ -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 (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="mb-2 font-medium text-sm">
<div className="mb-2 flex items-center justify-between">
<p className="font-medium text-sm">
{t("prize.probabilityPreview", "Probability Preview")}
</p>
{randomPrizes.length > 0 && (
<Badge variant={totalIsValid ? "secondary" : "destructive"}>
{t("prize.probabilityTotal", "Total")}: {totalWeight}%
</Badge>
)}
</div>
{randomPrizes.length > 0 ? (
<div className="flex flex-wrap gap-2">
{randomPrizes.map((prize) => (
<Badge key={prize.id} variant="secondary">
{prize.name}: {prize.weight}/{totalWeight} (
{prize.name}:{" "}
{((prize.weight / totalWeight) * 100).toFixed(PERCENT_DECIMALS)}
%)
%
</Badge>
))}
</div>
@@ -126,6 +160,21 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
</p>
)}
</div>
{randomPrizes.length > 0 && !totalIsValid && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("prize.totalNot100Title", "Probabilities must total 100%")}
</AlertTitle>
<AlertDescription>
{t(
"prize.totalNot100Hint",
"The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
{ total: totalWeight }
)}
</AlertDescription>
</Alert>
)}
{!hasFallback && (
<Alert>
<AlertTriangle className="h-4 w-4" />
@@ -185,8 +234,17 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
<Badge variant="secondary">
{prizeTypeLabel(prize.type, t)}
</Badge>
{(() => {
const summary = prizeConfigSummary(prize, t);
return summary ? (
<span className="line-clamp-1 font-medium text-primary text-xs">
{summary}
</span>
) : null;
})()}
<span className="text-muted-foreground text-xs">
{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}
</span>
+95 -7
View File
@@ -202,14 +202,15 @@ export async function updateLotteryPrize(
body: API.UpdateLotteryPrizeRequest,
options?: { [key: string]: any }
) {
const { id, ...rest } = body;
return request<API.Response & { data?: API.LotteryPrize }>(
`${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<API.Response & { data?: any }>(
`${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<API.Response & { data?: API.ListLotteryClaimsResponse }>(
`${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<API.Response & { data?: API.LotteryClaimsSummary }>(
`${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<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/approve`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
data: body,
...(options || {}),
}
);
}
/**
* 驳回领奖工单(reviewing/paying → rejectedreason 必填)
* POST /v1/admin/lottery/claims/reject
*/
export async function rejectLotteryClaim(
body: API.RejectLotteryClaimRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: any }>(
`${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<API.Response & { data?: any }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/claims/mark-paid`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
data: body,
...(options || {}),
}
);
}
+105 -17
View File
@@ -3302,25 +3302,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<string, any>;
@@ -3371,11 +3371,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 };
@@ -3415,4 +3424,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<string, any>;
};
type AdminLotteryClaim = {
id: number;
draw_id: number;
activity_id: number;
user: AdminLotteryClaimUser;
prize: AdminLotteryClaimPrize;
status: LotteryClaimStatus;
claim_data?: Record<string, any>;
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;
};
}