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 CENTS_PER_YUAN = 100; // 概率单位约定:weight 1000 = 1%,总权重 100000 = 100.000%(支持 3 位小数概率)。 const WEIGHT_PER_PERCENT = 1000; const FULL_TOTAL_WEIGHT = 100 * WEIGHT_PER_PERCENT; // weightToPercent 把原始 weight 转成百分比字符串(最多 3 位小数,去尾零)。 function weightToPercent(weight: number): string { const pct = weight / WEIGHT_PER_PERCENT; return `${Number.parseFloat(pct.toFixed(3))}`; } interface PrizeGridProps { activity: API.LotteryActivity; } type TranslateFn = (key: string, defaultValue: string) => string; function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string { const labels: Record = { 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; } // prizeConfigSummary 把奖品的类型专属 config 渲染成一行可读文本, // 让九宫格卡片显示真实配置值(免费时长天数 / 佣金金额),而不是只有类型徽章。 function prizeConfigSummary( prize: API.LotteryPrize, t: TranslateFn ): string | null { const config = prize.config ?? {}; if (prize.type === "vpn_duration") { const days = config.duration_days as number | undefined; if (typeof days === "number" && days > 0) { return `${days} ${t("prize.daysSuffix", "days")}`; } return null; } if (prize.type === "commission") { const cents = config.amount_cents as number | undefined; if (typeof cents === "number" && cents > 0) { return `¥${(cents / CENTS_PER_YUAN).toFixed(2)}`; } return null; } return null; } interface FormState { open: boolean; slot: number; prize?: API.LotteryPrize; } export default function PrizeGrid({ activity }: PrizeGridProps) { const { t } = useTranslation("lottery"); const [prizes, setPrizes] = useState([]); const [loading, setLoading] = useState(false); const [formState, setFormState] = useState({ 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(); 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 totalIsValid = totalWeight === FULL_TOTAL_WEIGHT; 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 (
{slots.map((slot) => ( ))}
); } return (

{t("prize.probabilityPreview", "Probability Preview")}

{randomPrizes.length > 0 && ( {t("prize.probabilityTotal", "Total")}:{" "} {weightToPercent(totalWeight)}% )}
{randomPrizes.length > 0 ? (
{randomPrizes.map((prize) => ( {prize.name}: {weightToPercent(prize.weight)}% ))}
) : (

{t( "prize.noRandomPrizes", "No prizes participate in the random draw yet" )}

)}
{randomPrizes.length > 0 && !totalIsValid && ( {t("prize.totalNot100Title", "Probabilities must total 100%")} {t( "prize.totalNot100Hint", "The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.", { total: weightToPercent(totalWeight) } )} )} {!hasFallback && ( {t("prize.noFallbackTitle", "No fallback prize")} {t( "prize.noFallbackHint", "Consider configuring a fallback prize so draws never fail when limited prizes run out." )} )}
{slots.map((slot) => { const prize = prizeBySlot.get(slot); return ( ); })}
setFormState((prev) => ({ ...prev, open }))} onSuccess={fetchPrizes} open={formState.open} slot={formState.slot} />
); }