a98c7ecb52
PR Check / Lint, Check, Test and Build (push) Has been cancelled
避开 EnhancedInput 在 type=number 下 min/max 实时钳制导致的"输入0/改数字跳变", 用 formatOutput 转 number,后端仍收 int。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
658 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|