6e1e424eb2
- 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>
295 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|