Files
hi-frontend/apps/admin/src/sections/lottery/prize-form.tsx
T
shanshanzhong147 a98c7ecb52
PR Check / Lint, Check, Test and Build (push) Has been cancelled
fix(抽奖): 数字输入改 text 类型(时长/概率/库存/领奖窗口/发放次数)+ 重建 dist.zip
避开 EnhancedInput 在 type=number 下 min/max 实时钳制导致的"输入0/改数字跳变",
用 formatOutput 转 number,后端仍收 int。

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

658 lines
22 KiB
TypeScript

import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@workspace/ui/components/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { ScrollArea } from "@workspace/ui/components/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import {
Sheet,
SheetContent,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@workspace/ui/components/sheet";
import { Switch } from "@workspace/ui/components/switch";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
EnhancedInput,
type EnhancedInputProps,
} from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
import {
createLotteryPrize,
deleteLotteryPrize,
updateLotteryPrize,
} from "@workspace/ui/services/admin/lottery";
import { getSubscribeList } from "@workspace/ui/services/admin/subscribe";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
const CENTS_PER_YUAN = 100;
// 概率单位约定:weight 1000 = 1%(支持 3 位小数概率,总权重 100000 = 100%)。
const WEIGHT_PER_PERCENT = 1000;
// numberOutput 把 text 输入转成数字(空/非法→undefined)。数字输入统一用 type="text"
// + 该 formatOutput,避开 EnhancedInput 在 type="number" 下 min/max 实时钳制导致的
// “输入 0 / 改中间位跳变”问题;提交仍是数字,后端收 int。
const numberOutput = (v: string | number): number | undefined => {
const s = String(v ?? "").trim();
if (s === "") {
return;
}
const n = Number(s);
return Number.isNaN(n) ? undefined : n;
};
type TranslateFn = (key: string, defaultValue: string) => string;
const PRIZE_TYPE_OPTIONS: {
value: API.LotteryPrizeType;
labelKey: string;
labelDefault: string;
}[] = [
{
value: "vpn_duration",
labelKey: "prize.typeVpnDuration",
labelDefault: "VPN Duration",
},
{
value: "crypto",
labelKey: "prize.typeCrypto",
labelDefault: "Crypto",
},
{
value: "none",
labelKey: "prize.typeNone",
labelDefault: "No Prize (Thanks)",
},
];
const getPrizeSchema = (t: TranslateFn) =>
z
.object({
type: z.enum(["vpn_duration", "crypto", "none"]),
name: z.string().min(1, t("prize.nameRequired", "Name is required")),
icon_url: z
.string()
.url(t("prize.iconUrlInvalid", "Must be a valid URL"))
.or(z.literal(""))
.optional(),
weight: z
.number()
.min(0, t("prize.weightMin", "Probability must be at least 0"))
.max(100, t("prize.weightMax", "Probability cannot exceed 100")),
unlimited_stock: z.boolean(),
total_stock: z.number().optional(),
is_fallback: z.boolean(),
duration_days: z.number().optional(),
subscribe_id: z.number().optional(),
amount_yuan: z.number().optional(),
// 人工奖(crypto / physical / manual_other)通用/专属字段
crypto_amount: z.string().optional(),
crypto_currency: z.string().optional(),
crypto_networks: z.string().optional(), // 逗号分隔,提交时 split 成 string[]
sku_name: z.string().optional(),
claim_ttl_hours: z.number().optional(),
})
.superRefine((data, ctx) => {
const issue = (path: string, message: string) => {
ctx.addIssue({ code: z.ZodIssueCode.custom, message, path: [path] });
};
const isPositiveInt = (value?: number) =>
!!value && value > 0 && Number.isInteger(value);
if (data.type === "vpn_duration" && !isPositiveInt(data.duration_days)) {
issue(
"duration_days",
t(
"prize.durationDaysRequired",
"Duration days must be a positive integer"
)
);
}
if (data.type === "crypto") {
if (!data.crypto_amount?.trim()) {
issue(
"crypto_amount",
t("prize.cryptoAmountRequired", "Amount is required")
);
}
if (!data.crypto_currency?.trim()) {
issue(
"crypto_currency",
t("prize.cryptoCurrencyRequired", "Currency is required")
);
}
const networks = (data.crypto_networks ?? "")
.split(",")
.map((n) => n.trim())
.filter(Boolean);
if (networks.length === 0) {
issue(
"crypto_networks",
t(
"prize.cryptoNetworksRequired",
"At least one network is required (comma-separated)"
)
);
}
}
if (
data.claim_ttl_hours !== undefined &&
(!Number.isInteger(data.claim_ttl_hours) || data.claim_ttl_hours <= 0)
) {
issue(
"claim_ttl_hours",
t(
"prize.claimTtlInvalid",
"Claim window (hours) must be a positive integer"
)
);
}
if (!(data.unlimited_stock || isPositiveInt(data.total_stock))) {
issue(
"total_stock",
t(
"prize.totalStockRequired",
"Total stock must be a positive integer"
)
);
}
});
type PrizeFormValues = z.infer<ReturnType<typeof getPrizeSchema>>;
interface PrizeFormProps {
activityId: number;
slot: number;
initialValues?: API.LotteryPrize;
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
const config = prize?.config ?? {};
const networks = Array.isArray(config.networks)
? (config.networks as string[]).join(", ")
: undefined;
return {
type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration",
name: prize?.name ?? "",
icon_url: prize?.icon_url ?? "",
weight: (prize?.weight ?? 0) / WEIGHT_PER_PERCENT,
unlimited_stock: prize ? prize.total_stock === null : true,
total_stock: prize?.total_stock ?? undefined,
is_fallback: prize?.is_fallback ?? false,
duration_days: config.duration_days as number | undefined,
subscribe_id: config.subscribe_id as number | undefined,
amount_yuan:
typeof config.amount_cents === "number"
? config.amount_cents / CENTS_PER_YUAN
: undefined,
crypto_amount: config.amount as string | undefined,
crypto_currency: config.currency as string | undefined,
crypto_networks: networks,
sku_name: config.sku_name as string | undefined,
claim_ttl_hours: config.claim_ttl_hours as number | undefined,
};
}
function toConfig(values: PrizeFormValues): Record<string, any> {
// claim_ttl_hours 对所有人工奖通用:控制领奖窗口(缺省后端默认 7 天)。
const withTtl = (base: Record<string, any>): Record<string, any> => {
if (values.claim_ttl_hours && values.claim_ttl_hours > 0) {
base.claim_ttl_hours = values.claim_ttl_hours;
}
return base;
};
if (values.type === "vpn_duration") {
const config: Record<string, any> = { duration_days: values.duration_days };
// 可选:无活跃订阅时按该套餐新建订阅发放时长。0/未选则沿用旧行为(跳过发放)。
if (values.subscribe_id && values.subscribe_id > 0) {
config.subscribe_id = values.subscribe_id;
}
return config;
}
if (values.type === "crypto") {
return withTtl({
amount: values.crypto_amount?.trim() ?? "",
currency: values.crypto_currency?.trim() ?? "",
networks: (values.crypto_networks ?? "")
.split(",")
.map((n) => n.trim())
.filter(Boolean),
});
}
return {};
}
export default function PrizeForm({
activityId,
slot,
initialValues,
open,
onOpenChange,
onSuccess,
}: PrizeFormProps) {
const { t } = useTranslation("lottery");
const [loading, setLoading] = useState(false);
const [plans, setPlans] = useState<API.SubscribeItem[]>([]);
const form = useForm<PrizeFormValues>({
resolver: zodResolver(getPrizeSchema(t)),
defaultValues: toFormValues(initialValues),
});
const type = form.watch("type");
const unlimitedStock = form.watch("unlimited_stock");
const isFallback = form.watch("is_fallback");
const isEdit = !!initialValues?.id;
const targetSlot = initialValues?.slot ?? slot;
useEffect(() => {
if (open) form.reset(toFormValues(initialValues));
}, [form, initialValues, open]);
// vpn_duration 需要一个可选的“新建订阅套餐”下拉,仅在面板打开且类型匹配时拉取。
useEffect(() => {
if (!(open && type === "vpn_duration") || plans.length > 0) return;
getSubscribeList({ page: 1, size: 100 })
.then(({ data }) => setPlans(data?.data?.list ?? []))
.catch(() => {
// Request layer already surfaces the error toast
});
}, [open, type, plans.length]);
async function handleSubmit(values: PrizeFormValues) {
setLoading(true);
try {
const body: API.CreateLotteryPrizeRequest = {
activity_id: activityId,
slot: targetSlot,
type: values.type,
name: values.name.trim(),
icon_url: values.icon_url?.trim() ?? "",
config: toConfig(values),
weight: values.is_fallback
? 0
: Math.round(values.weight * WEIGHT_PER_PERCENT),
total_stock: values.unlimited_stock
? null
: (values.total_stock ?? null),
is_fallback: values.is_fallback,
};
if (isEdit) {
await updateLotteryPrize({ ...body, id: initialValues!.id });
toast.success(t("prize.updateSuccess", "Prize updated"));
} else {
await createLotteryPrize(body);
toast.success(t("prize.createSuccess", "Prize created"));
}
onSuccess();
onOpenChange(false);
} catch {
// Request layer already surfaces the error toast
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (!initialValues?.id) return;
setLoading(true);
try {
await deleteLotteryPrize({ id: initialValues.id });
toast.success(t("prize.deleteSuccess", "Prize deleted"));
onSuccess();
onOpenChange(false);
} catch {
// Request layer already surfaces the error toast
} finally {
setLoading(false);
}
}
const renderInput = (
name:
| "name"
| "icon_url"
| "weight"
| "total_stock"
| "duration_days"
| "amount_yuan"
| "crypto_amount"
| "crypto_currency"
| "crypto_networks"
| "sku_name"
| "claim_ttl_hours",
label: string,
inputProps: Partial<EnhancedInputProps<any>>,
description?: string
) => (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem>
<FormLabel>{label}</FormLabel>
<FormControl>
<EnhancedInput
onValueChange={(value) => form.setValue(name, value)}
value={field.value}
{...inputProps}
/>
</FormControl>
{description && (
<p className="text-muted-foreground text-xs">{description}</p>
)}
<FormMessage />
</FormItem>
)}
/>
);
const renderSwitch = (
name: "unlimited_stock" | "is_fallback",
label: string,
description?: string
) => (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-md border p-3">
<div className="space-y-1">
<FormLabel>{label}</FormLabel>
{description && (
<p className="text-muted-foreground text-xs">{description}</p>
)}
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={(checked) => form.setValue(name, checked)}
/>
</FormControl>
</FormItem>
)}
/>
);
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent className="w-[520px] max-w-full md:max-w-screen-md">
<SheetHeader>
<SheetTitle>
{isEdit
? t("prize.editTitle", "Edit Prize")
: t("prize.createTitle", "Add Prize")}
{" · "}
{t("prize.slotLabel", "Slot")} {targetSlot}
</SheetTitle>
</SheetHeader>
<ScrollArea className="h-[calc(100vh-48px-36px-36px-env(safe-area-inset-top))]">
<Form {...form}>
<form
className="space-y-4 px-6 pt-4"
onSubmit={form.handleSubmit(handleSubmit)}
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("prize.type", "Prize Type")}</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
form.setValue(
field.name,
value as PrizeFormValues["type"]
)
}
value={field.value}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t("prize.selectType", "Select type")}
/>
</SelectTrigger>
<SelectContent>
{PRIZE_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey, option.labelDefault)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{renderInput("name", t("prize.name", "Prize Name"), {
placeholder: t("prize.namePlaceholder", "e.g. 1-Day Pass"),
})}
{renderInput("icon_url", t("prize.iconUrl", "Icon URL"), {
placeholder: "https://",
})}
{type === "vpn_duration" &&
renderInput(
"duration_days",
t("prize.durationDays", "Duration (days)"),
{
type: "text",
inputMode: "numeric",
formatOutput: numberOutput,
placeholder: t("prize.durationDaysPlaceholder", "e.g. 1"),
suffix: t("prize.daysSuffix", "days"),
}
)}
{type === "vpn_duration" && (
<FormField
control={form.control}
name="subscribe_id"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("prize.subscribePlan", "New-subscription plan")}
</FormLabel>
<FormControl>
<Select
onValueChange={(value) =>
form.setValue(
"subscribe_id",
value ? Number(value) : undefined
)
}
value={field.value ? String(field.value) : undefined}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={t(
"prize.subscribePlanPlaceholder",
"None — skip if user has no active subscription"
)}
/>
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name ?? `#${plan.id}`}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
<p className="text-muted-foreground text-xs">
{t(
"prize.subscribePlanHint",
"When the user has no active subscription, create one on this plan and grant the duration. Leave empty to skip granting."
)}
</p>
<FormMessage />
</FormItem>
)}
/>
)}
{type === "none" && (
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
"prize.noneHint",
"No config needed. Consider enabling fallback so users always land on this prize when limited prizes run out."
)}
</p>
)}
{type === "crypto" && (
<>
{renderInput(
"crypto_amount",
t("prize.cryptoAmount", "Amount"),
{
placeholder: t(
"prize.cryptoAmountPlaceholder",
"e.g. 0.01"
),
}
)}
{renderInput(
"crypto_currency",
t("prize.cryptoCurrency", "Currency"),
{ placeholder: "USDT / BTC / ETH" }
)}
{renderInput(
"crypto_networks",
t("prize.cryptoNetworks", "Networks"),
{ placeholder: "TRC20, ERC20, BTC" },
t(
"prize.cryptoNetworksHint",
"Comma-separated. The user picks one of these when claiming."
)
)}
</>
)}
{type === "crypto" && (
<>
{renderInput(
"claim_ttl_hours",
t("prize.claimTtlHours", "Claim window (hours)"),
{
type: "text",
inputMode: "numeric",
formatOutput: numberOutput,
placeholder: "168",
suffix: t("prize.hoursSuffix", "hours"),
},
t(
"prize.claimTtlHint",
"How long the user has to submit claim info. Default 168h (7 days)."
)
)}
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
{t(
"prize.manualPrizeHint",
"Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab."
)}
</p>
</>
)}
{renderInput(
"weight",
t("prize.weight", "Win probability (%)"),
{
type: "text",
inputMode: "decimal",
formatOutput: numberOutput,
suffix: "%",
disabled: isFallback,
},
isFallback
? t(
"prize.fallbackIgnoresWeight",
"Fallback prize ignores probability (never randomly drawn)"
)
: t(
"prize.weightHint",
"Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn."
)
)}
{renderSwitch(
"unlimited_stock",
t("prize.unlimitedStock", "Unlimited Stock")
)}
{!unlimitedStock &&
renderInput(
"total_stock",
t("prize.totalStock", "Total Stock"),
{
type: "text",
inputMode: "numeric",
formatOutput: numberOutput,
placeholder: t("prize.totalStockPlaceholder", "e.g. 100"),
}
)}
{renderSwitch(
"is_fallback",
t("prize.isFallback", "Fallback Prize"),
t(
"prize.isFallbackHint",
"Awarded when limited prizes are sold out"
)
)}
</form>
</Form>
</ScrollArea>
<SheetFooter className="flex-row justify-end gap-2 pt-3">
{isEdit && (
<ConfirmButton
cancelText={t("prize.cancel", "Cancel")}
confirmText={t("prize.confirmDelete", "Delete")}
description={t(
"prize.deleteDescription",
"This prize will be removed from the grid. This action cannot be undone."
)}
onConfirm={handleDelete}
title={t("prize.deleteTitle", "Delete this prize?")}
trigger={
<Button
className="mr-auto"
disabled={loading}
variant="destructive"
>
{t("prize.delete", "Delete")}
</Button>
}
/>
)}
<Button
disabled={loading}
onClick={() => onOpenChange(false)}
variant="outline"
>
{t("prize.cancel", "Cancel")}
</Button>
<Button disabled={loading} onClick={form.handleSubmit(handleSubmit)}>
{loading && (
<Icon className="mr-2 animate-spin" icon="mdi:loading" />
)}
{t("prize.confirm", "Confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}