feat(抽奖): 后台"抽奖记录"页 — 中奖/发放流水列表

- draws-panel(新): ProTable 列出中奖人/奖品/发放状态/发放结果,按 是否中奖/发放状态/用户ID 过滤
- activity-detail: 新增"抽奖记录"Tab
- service+types: getLotteryDrawList + AdminLotteryDraw
- i18n: draw.* zh/en

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-16 23:41:47 -07:00
parent 143773c928
commit 508cfa91ba
6 changed files with 306 additions and 2 deletions
@@ -10,7 +10,8 @@
"detail": {
"prizesTab": "Prizes",
"rulesTab": "Rules",
"claimsTab": "Claims"
"claimsTab": "Claims",
"drawsTab": "Draw records"
},
"edit": "Edit",
"editActivity": "Edit Activity",
@@ -245,5 +246,24 @@
"txHash": "Tx hash",
"txHashRequired": "Tx hash is required for crypto",
"user": "User"
},
"draw": {
"dispatchAutoClaimed": "Auto-granted",
"dispatchExpired": "Expired",
"dispatchFailed": "Failed",
"dispatchNone": "Pending",
"dispatchPaid": "Fulfilled",
"dispatchPendingClaim": "Awaiting claim",
"dispatchState": "Dispatch",
"drawnAt": "Drawn at",
"filterDispatch": "Dispatch",
"filterUserId": "User ID",
"filterWin": "Won?",
"grant": "Grant result",
"loseOnly": "Not won",
"prize": "Prize",
"tableTitle": "Draw records",
"user": "User",
"winOnly": "Won"
}
}
@@ -10,7 +10,8 @@
"detail": {
"prizesTab": "奖品",
"rulesTab": "规则",
"claimsTab": "领奖工单"
"claimsTab": "领奖工单",
"drawsTab": "抽奖记录"
},
"edit": "编辑",
"editActivity": "编辑活动",
@@ -245,5 +246,24 @@
"txHash": "交易哈希",
"txHashRequired": "加密货币奖品必须填写交易哈希",
"user": "用户"
},
"draw": {
"dispatchAutoClaimed": "已自动发放",
"dispatchExpired": "已过期",
"dispatchFailed": "发放失败",
"dispatchNone": "待发放",
"dispatchPaid": "已发放",
"dispatchPendingClaim": "待领取",
"dispatchState": "发放状态",
"drawnAt": "抽奖时间",
"filterDispatch": "发放状态",
"filterUserId": "用户 ID",
"filterWin": "是否中奖",
"grant": "发放结果",
"loseOnly": "未中奖",
"prize": "奖品",
"tableTitle": "抽奖记录",
"user": "用户",
"winOnly": "已中奖"
}
}
@@ -15,6 +15,7 @@ import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ClaimsPanel from "./claims-panel";
import DrawsPanel from "./draws-panel";
import PrizeGrid from "./prize-grid";
import RuleEditor from "./rule-editor";
@@ -106,6 +107,9 @@ export default function ActivityDetail({
<TabsTrigger value="claims">
{t("detail.claimsTab", "Claims")}
</TabsTrigger>
<TabsTrigger value="draws">
{t("detail.drawsTab", "Draw records")}
</TabsTrigger>
</TabsList>
<TabsContent value="prizes">
<PrizeGrid activity={current} />
@@ -116,6 +120,9 @@ export default function ActivityDetail({
<TabsContent value="claims">
<ClaimsPanel activity={current} />
</TabsContent>
<TabsContent value="draws">
<DrawsPanel activity={current} />
</TabsContent>
</Tabs>
</div>
</>
@@ -0,0 +1,193 @@
import { Badge } from "@workspace/ui/components/badge";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import { getLotteryDrawList } from "@workspace/ui/services/admin/lottery";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { formatDate } from "@/utils/common";
interface DrawsPanelProps {
activity: API.LotteryActivity;
}
type TranslateFn = (key: string, defaultValue: string) => string;
const DISPATCH_META: Record<
string,
{ labelKey: string; labelDefault: string; className: string }
> = {
none: {
labelKey: "draw.dispatchNone",
labelDefault: "Pending",
className: "bg-gray-100 text-gray-500 border-gray-200",
},
auto_claimed: {
labelKey: "draw.dispatchAutoClaimed",
labelDefault: "Auto-granted",
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
},
pending_claim: {
labelKey: "draw.dispatchPendingClaim",
labelDefault: "Awaiting claim",
className: "bg-amber-100 text-amber-700 border-amber-200",
},
paid: {
labelKey: "draw.dispatchPaid",
labelDefault: "Fulfilled",
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
},
expired: {
labelKey: "draw.dispatchExpired",
labelDefault: "Expired",
className: "bg-gray-100 text-gray-500 border-gray-200",
},
failed: {
labelKey: "draw.dispatchFailed",
labelDefault: "Failed",
className: "bg-rose-100 text-rose-700 border-rose-200",
},
};
function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
const labels: Record<string, string> = {
vpn_duration: t("prize.typeVpnDuration", "VPN Duration"),
commission: t("prize.typeCommission", "Commission"),
none: t("prize.typeNone", "No Prize (Thanks)"),
crypto: t("prize.typeCrypto", "Crypto"),
physical: t("prize.typePhysical", "Physical"),
manual_other: t("prize.typeManualOther", "Manual (Other)"),
};
return labels[type] ?? type;
}
export default function DrawsPanel({ activity }: DrawsPanelProps) {
const { t } = useTranslation("lottery");
const ref = useRef<ProTableActions>(null);
const dispatchOptions = Object.entries(DISPATCH_META).map(
([value, meta]) => ({
value,
label: t(meta.labelKey, meta.labelDefault),
})
);
const winOptions = [
{ value: "1", label: t("draw.winOnly", "Won") },
{ value: "0", label: t("draw.loseOnly", "Not won") },
];
return (
<ProTable<
API.AdminLotteryDraw,
{ win?: string; dispatch_state?: string; user_id?: string }
>
action={ref}
columns={[
{ accessorKey: "draw_id", header: "ID" },
{
accessorKey: "user",
header: t("draw.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("draw.prize", "Prize"),
cell: ({ row }) => {
const prize = row.original.prize;
if (!(row.original.is_win && prize)) {
return (
<span className="text-muted-foreground">
{t("prize.typeNone", "No Prize (Thanks)")}
</span>
);
}
return (
<div className="text-sm">
<div className="font-medium">{prize.name}</div>
<Badge className="mt-0.5" variant="secondary">
{prizeTypeLabel(prize.type, t)}
</Badge>
</div>
);
},
},
{
accessorKey: "dispatch_state",
header: t("draw.dispatchState", "Dispatch"),
cell: ({ row }) => {
const meta = DISPATCH_META[row.original.dispatch_state];
return (
<Badge className={meta?.className} variant="outline">
{meta
? t(meta.labelKey, meta.labelDefault)
: row.original.dispatch_state}
</Badge>
);
},
},
{
accessorKey: "grant_message",
header: t("draw.grant", "Grant result"),
cell: ({ row }) => (
<div className="max-w-[220px] break-all text-xs">
{row.original.grant_message || "--"}
{row.original.dispatch_error && (
<div className="text-rose-600">
{row.original.dispatch_error}
</div>
)}
</div>
),
},
{
accessorKey: "drawn_at",
header: t("draw.drawnAt", "Drawn at"),
cell: ({ row }) =>
row.original.drawn_at ? formatDate(row.original.drawn_at) : "--",
},
]}
header={{ title: t("draw.tableTitle", "Draw records") }}
params={[
{
key: "win",
options: winOptions,
placeholder: t("draw.filterWin", "Won?"),
},
{
key: "dispatch_state",
options: dispatchOptions,
placeholder: t("draw.filterDispatch", "Dispatch"),
},
{
key: "user_id",
placeholder: t("draw.filterUserId", "User ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await getLotteryDrawList({
page: pagination.page,
size: pagination.size,
activity_id: activity.id,
win: (filter.win as "1" | "0") || undefined,
dispatch_state: filter.dispatch_state || undefined,
user_id: filter.user_id ? Number(filter.user_id) : undefined,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
);
}
+18
View File
@@ -373,3 +373,21 @@ export async function markPaidLotteryClaim(
}
);
}
/**
* 抽奖记录(发放流水)
* GET /v1/admin/lottery/draws
*/
export async function getLotteryDrawList(
params: API.GetLotteryDrawListParams,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.ListLotteryDrawsResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/lottery/draws`,
{
method: "GET",
params: { ...params },
...(options || {}),
}
);
}
+46
View File
@@ -3503,4 +3503,50 @@ declare namespace API {
delivery_ref?: string;
paid_at?: number;
};
// ---- Stage 3 抽奖记录(发放流水)-----------------------------------------
type GetLotteryDrawListParams = {
activity_id?: number;
user_id?: number;
prize_type?: LotteryPrizeType;
dispatch_state?: string;
win?: "1" | "0";
from?: number;
to?: number;
page: number;
size: number;
};
type AdminLotteryDrawUser = {
id: number;
email?: string;
};
type AdminLotteryDrawPrize = {
slot: number;
type: LotteryPrizeType;
name: string;
config: Record<string, any>;
};
type AdminLotteryDraw = {
draw_id: number;
activity_id: number;
user: AdminLotteryDrawUser;
is_win: boolean;
prize?: AdminLotteryDrawPrize;
dispatch_state: string;
dispatch_error?: string;
grant_amount?: number;
grant_message?: string;
drawn_at: number;
dispatched_at?: number;
created_at: number;
};
type ListLotteryDrawsResponse = {
total: number;
list: AdminLotteryDraw[];
};
}