feat: 后台抽奖活动管理(Stage 1)
- 服务层:12 个 /v1/admin/lottery 接口封装与 API 类型定义 - 活动列表:状态筛选、上架/暂停(带确认)、编辑、管理、发次数 - 奖品管理:3×3 九宫格可视化编辑、概率预览、保底奖警告、佣金元↔分转换、无限库存 - 规则设置:chance_sources 动态编辑,eligibility 简单/高级(JSON)双模式,本地 rulecaps 校验(深度≤8/节点≤64/≤8KB),Stage 1 未接线警示 - 手动发次数:自动生成幂等 source_ref - 路由 /dashboard/lottery、Commerce 菜单项、en-US/zh-CN 双语文案 - 对接文档见 vpn/aaaa.txt(Stage 1)
This commit is contained in:
@@ -0,0 +1,228 @@
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from "@workspace/ui/components/alert";
|
||||
import { Badge } from "@workspace/ui/components/badge";
|
||||
import { Skeleton } from "@workspace/ui/components/skeleton";
|
||||
import { cn } from "@workspace/ui/lib/utils";
|
||||
import { getLotteryPrizeList } from "@workspace/ui/services/admin/lottery";
|
||||
import { AlertTriangle, Plus } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import PrizeForm from "./prize-form";
|
||||
|
||||
const DEFAULT_GRID_SIZE = 9;
|
||||
const CENTER_SLOT = 4;
|
||||
const PERCENT_DECIMALS = 1;
|
||||
|
||||
interface PrizeGridProps {
|
||||
activity: API.LotteryActivity;
|
||||
}
|
||||
|
||||
type TranslateFn = (key: string, defaultValue: string) => string;
|
||||
|
||||
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)"),
|
||||
coupon: t("prize.typeCoupon", "Coupon"),
|
||||
points: t("prize.typePoints", "Points"),
|
||||
};
|
||||
return labels[type] ?? type;
|
||||
}
|
||||
|
||||
interface FormState {
|
||||
open: boolean;
|
||||
slot: number;
|
||||
prize?: API.LotteryPrize;
|
||||
}
|
||||
|
||||
export default function PrizeGrid({ activity }: PrizeGridProps) {
|
||||
const { t } = useTranslation("lottery");
|
||||
const [prizes, setPrizes] = useState<API.LotteryPrize[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formState, setFormState] = useState<FormState>({
|
||||
open: false,
|
||||
slot: 0,
|
||||
});
|
||||
|
||||
const fetchPrizes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await getLotteryPrizeList({ activity_id: activity.id });
|
||||
setPrizes(data?.data?.list ?? []);
|
||||
} catch {
|
||||
// Request layer already surfaces the error toast
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activity.id]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrizes();
|
||||
}, [fetchPrizes]);
|
||||
|
||||
const prizeBySlot = useMemo(() => {
|
||||
const map = new Map<number, API.LotteryPrize>();
|
||||
for (const prize of prizes) {
|
||||
map.set(prize.slot, prize);
|
||||
}
|
||||
return map;
|
||||
}, [prizes]);
|
||||
|
||||
const randomPrizes = useMemo(
|
||||
() => prizes.filter((prize) => prize.weight > 0 && !prize.is_fallback),
|
||||
[prizes]
|
||||
);
|
||||
const totalWeight = useMemo(
|
||||
() => randomPrizes.reduce((sum, prize) => sum + prize.weight, 0),
|
||||
[randomPrizes]
|
||||
);
|
||||
const hasFallback = prizes.some((prize) => prize.is_fallback);
|
||||
const gridSize = activity.grid_size || DEFAULT_GRID_SIZE;
|
||||
const slots = Array.from({ length: gridSize }, (_, index) => index);
|
||||
|
||||
const openForm = (slot: number, prize?: API.LotteryPrize) => {
|
||||
setFormState({ open: true, slot, prize });
|
||||
};
|
||||
|
||||
if (loading && prizes.length === 0) {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{slots.map((slot) => (
|
||||
<Skeleton className="min-h-[132px] rounded-lg" key={slot} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<p className="mb-2 font-medium text-sm">
|
||||
{t("prize.probabilityPreview", "Probability Preview")}
|
||||
</p>
|
||||
{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.weight / totalWeight) * 100).toFixed(PERCENT_DECIMALS)}
|
||||
%)
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t(
|
||||
"prize.noRandomPrizes",
|
||||
"No prizes participate in the random draw yet"
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!hasFallback && (
|
||||
<Alert>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("prize.noFallbackTitle", "No fallback prize")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"prize.noFallbackHint",
|
||||
"Consider configuring a fallback prize so draws never fail when limited prizes run out."
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{slots.map((slot) => {
|
||||
const prize = prizeBySlot.get(slot);
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"relative flex min-h-[132px] flex-col items-center justify-center gap-1 rounded-lg border p-3 text-center transition-colors hover:border-primary",
|
||||
prize ? "bg-card" : "border-dashed text-muted-foreground"
|
||||
)}
|
||||
key={slot}
|
||||
onClick={() => openForm(slot, prize)}
|
||||
type="button"
|
||||
>
|
||||
<span className="absolute top-1 left-1.5 text-[10px] text-muted-foreground">
|
||||
#{slot}
|
||||
</span>
|
||||
{slot === CENTER_SLOT && (
|
||||
<Badge
|
||||
className="absolute top-1 right-1 px-1.5 text-[10px]"
|
||||
variant="outline"
|
||||
>
|
||||
{t("prize.centerSlotHint", "Usually the draw button")}
|
||||
</Badge>
|
||||
)}
|
||||
{prize ? (
|
||||
<>
|
||||
{prize.icon_url ? (
|
||||
<img
|
||||
alt={prize.name}
|
||||
className="h-8 w-8 rounded object-cover"
|
||||
height={32}
|
||||
src={prize.icon_url}
|
||||
width={32}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="text-2xl">
|
||||
🎁
|
||||
</span>
|
||||
)}
|
||||
<span className="line-clamp-1 font-medium text-sm">
|
||||
{prize.name}
|
||||
</span>
|
||||
<Badge variant="secondary">
|
||||
{prizeTypeLabel(prize.type, t)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("prize.weightShort", "Weight")}: {prize.weight} ·{" "}
|
||||
{t("prize.stockShort", "Stock")}:{" "}
|
||||
{prize.total_stock === null ? "∞" : prize.total_stock}
|
||||
</span>
|
||||
<div className="flex flex-wrap justify-center gap-1">
|
||||
{prize.is_fallback && (
|
||||
<Badge variant="outline">
|
||||
{t("prize.fallbackBadge", "Fallback")}
|
||||
</Badge>
|
||||
)}
|
||||
{prize.sold_out && (
|
||||
<Badge variant="destructive">
|
||||
{t("prize.soldOutBadge", "Sold Out")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-5 w-5" />
|
||||
<span className="text-xs">
|
||||
{t("prize.addPrize", "Add Prize")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<PrizeForm
|
||||
activityId={activity.id}
|
||||
initialValues={formState.prize}
|
||||
onOpenChange={(open) => setFormState((prev) => ({ ...prev, open }))}
|
||||
onSuccess={fetchPrizes}
|
||||
open={formState.open}
|
||||
slot={formState.slot}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user