Files
hi-frontend/apps/admin/src/sections/lottery/prize-grid.tsx
T
shanshanzhong147 6e1e424eb2 fix(抽奖): 概率按 weight/1000 显示与录入(支持小数%,总权重100000=100%)
- prize-grid: 概率预览/合计/卡片改用 weightToPercent(weight/1000),
  合计校验对齐 FULL_TOTAL_WEIGHT=100000;修正原来把原始 weight 当 % 显示(如 49901%)
- prize-form: 概率输入按百分比(0-100,step 0.001),提交 ×1000 存 weight,回填 ÷1000

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 00:19:03 -07:00

295 lines
10 KiB
TypeScript

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<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;
}
// 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<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 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 (
<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">
<div className="mb-2 flex items-center justify-between">
<p className="font-medium text-sm">
{t("prize.probabilityPreview", "Probability Preview")}
</p>
{randomPrizes.length > 0 && (
<Badge variant={totalIsValid ? "secondary" : "destructive"}>
{t("prize.probabilityTotal", "Total")}:{" "}
{weightToPercent(totalWeight)}%
</Badge>
)}
</div>
{randomPrizes.length > 0 ? (
<div className="flex flex-wrap gap-2">
{randomPrizes.map((prize) => (
<Badge key={prize.id} variant="secondary">
{prize.name}: {weightToPercent(prize.weight)}%
</Badge>
))}
</div>
) : (
<p className="text-muted-foreground text-xs">
{t(
"prize.noRandomPrizes",
"No prizes participate in the random draw yet"
)}
</p>
)}
</div>
{randomPrizes.length > 0 && !totalIsValid && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("prize.totalNot100Title", "Probabilities must total 100%")}
</AlertTitle>
<AlertDescription>
{t(
"prize.totalNot100Hint",
"The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
{ total: weightToPercent(totalWeight) }
)}
</AlertDescription>
</Alert>
)}
{!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>
{(() => {
const summary = prizeConfigSummary(prize, t);
return summary ? (
<span className="line-clamp-1 font-medium text-primary text-xs">
{summary}
</span>
) : null;
})()}
<span className="text-muted-foreground text-xs">
{t("prize.weightShort", "Prob")}:{" "}
{prize.is_fallback
? "—"
: `${weightToPercent(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>
);
}