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