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:
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user