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:
@@ -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,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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[];
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user