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:
2026-07-09 13:35:35 -07:00
parent 39bc0c5c7b
commit 01591adb86
18 changed files with 3315 additions and 0 deletions
@@ -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>
);
}