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>
This commit is contained in:
2026-07-16 21:03:18 -07:00
parent a30d83505f
commit 6e1e424eb2
2 changed files with 25 additions and 14 deletions
@@ -43,6 +43,8 @@ import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
const CENTS_PER_YUAN = 100; const CENTS_PER_YUAN = 100;
// 概率单位约定:weight 1000 = 1%(支持 3 位小数概率,总权重 100000 = 100%)。
const WEIGHT_PER_PERCENT = 1000;
type TranslateFn = (key: string, defaultValue: string) => string; type TranslateFn = (key: string, defaultValue: string) => string;
@@ -102,7 +104,6 @@ const getPrizeSchema = (t: TranslateFn) =>
.optional(), .optional(),
weight: z weight: z
.number() .number()
.int(t("prize.weightInteger", "Probability must be an integer"))
.min(0, t("prize.weightMin", "Probability must be at least 0")) .min(0, t("prize.weightMin", "Probability must be at least 0"))
.max(100, t("prize.weightMax", "Probability cannot exceed 100")), .max(100, t("prize.weightMax", "Probability cannot exceed 100")),
unlimited_stock: z.boolean(), unlimited_stock: z.boolean(),
@@ -221,7 +222,7 @@ function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration", type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration",
name: prize?.name ?? "", name: prize?.name ?? "",
icon_url: prize?.icon_url ?? "", icon_url: prize?.icon_url ?? "",
weight: prize?.weight ?? 1, weight: (prize?.weight ?? 0) / WEIGHT_PER_PERCENT,
unlimited_stock: prize ? prize.total_stock === null : true, unlimited_stock: prize ? prize.total_stock === null : true,
total_stock: prize?.total_stock ?? undefined, total_stock: prize?.total_stock ?? undefined,
is_fallback: prize?.is_fallback ?? false, is_fallback: prize?.is_fallback ?? false,
@@ -325,7 +326,9 @@ export default function PrizeForm({
name: values.name.trim(), name: values.name.trim(),
icon_url: values.icon_url?.trim() ?? "", icon_url: values.icon_url?.trim() ?? "",
config: toConfig(values), config: toConfig(values),
weight: values.is_fallback ? 0 : values.weight, weight: values.is_fallback
? 0
: Math.round(values.weight * WEIGHT_PER_PERCENT),
total_stock: values.unlimited_stock total_stock: values.unlimited_stock
? null ? null
: (values.total_stock ?? null), : (values.total_stock ?? null),
@@ -643,7 +646,7 @@ export default function PrizeForm({
type: "number", type: "number",
min: 0, min: 0,
max: 100, max: 100,
step: 1, step: 0.001,
suffix: "%", suffix: "%",
disabled: isFallback, disabled: isFallback,
}, },
+18 -10
View File
@@ -14,9 +14,16 @@ import PrizeForm from "./prize-form";
const DEFAULT_GRID_SIZE = 9; const DEFAULT_GRID_SIZE = 9;
const CENTER_SLOT = 4; const CENTER_SLOT = 4;
const PERCENT_DECIMALS = 1;
const REQUIRED_TOTAL_PERCENT = 100;
const CENTS_PER_YUAN = 100; 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 { interface PrizeGridProps {
activity: API.LotteryActivity; activity: API.LotteryActivity;
@@ -110,7 +117,7 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
[randomPrizes] [randomPrizes]
); );
const hasFallback = prizes.some((prize) => prize.is_fallback); const hasFallback = prizes.some((prize) => prize.is_fallback);
const totalIsValid = totalWeight === REQUIRED_TOTAL_PERCENT; const totalIsValid = totalWeight === FULL_TOTAL_WEIGHT;
const gridSize = activity.grid_size || DEFAULT_GRID_SIZE; const gridSize = activity.grid_size || DEFAULT_GRID_SIZE;
const slots = Array.from({ length: gridSize }, (_, index) => index); const slots = Array.from({ length: gridSize }, (_, index) => index);
@@ -137,7 +144,8 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
</p> </p>
{randomPrizes.length > 0 && ( {randomPrizes.length > 0 && (
<Badge variant={totalIsValid ? "secondary" : "destructive"}> <Badge variant={totalIsValid ? "secondary" : "destructive"}>
{t("prize.probabilityTotal", "Total")}: {totalWeight}% {t("prize.probabilityTotal", "Total")}:{" "}
{weightToPercent(totalWeight)}%
</Badge> </Badge>
)} )}
</div> </div>
@@ -145,9 +153,7 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{randomPrizes.map((prize) => ( {randomPrizes.map((prize) => (
<Badge key={prize.id} variant="secondary"> <Badge key={prize.id} variant="secondary">
{prize.name}:{" "} {prize.name}: {weightToPercent(prize.weight)}%
{((prize.weight / totalWeight) * 100).toFixed(PERCENT_DECIMALS)}
%
</Badge> </Badge>
))} ))}
</div> </div>
@@ -170,7 +176,7 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
{t( {t(
"prize.totalNot100Hint", "prize.totalNot100Hint",
"The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.", "The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
{ total: totalWeight } { total: weightToPercent(totalWeight) }
)} )}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
@@ -244,8 +250,10 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
})()} })()}
<span className="text-muted-foreground text-xs"> <span className="text-muted-foreground text-xs">
{t("prize.weightShort", "Prob")}:{" "} {t("prize.weightShort", "Prob")}:{" "}
{prize.is_fallback ? "—" : `${prize.weight}%`} ·{" "} {prize.is_fallback
{t("prize.stockShort", "Stock")}:{" "} ? ""
: `${weightToPercent(prize.weight)}%`}{" "}
· {t("prize.stockShort", "Stock")}:{" "}
{prize.total_stock === null ? "∞" : prize.total_stock} {prize.total_stock === null ? "∞" : prize.total_stock}
</span> </span>
<div className="flex flex-wrap justify-center gap-1"> <div className="flex flex-wrap justify-center gap-1">