feat: 后台抽奖活动管理(Stage 1)
- 服务层:12 个 /v1/admin/lottery 接口封装与 API 类型定义 - 活动列表:状态筛选、上架/暂停(带确认)、编辑、管理、发次数 - 奖品管理:3×3 九宫格可视化编辑、概率预览、保底奖警告、佣金元↔分转换、无限库存 - 规则设置:chance_sources 动态编辑,eligibility 简单/高级(JSON)双模式,本地 rulecaps 校验(深度≤8/节点≤64/≤8KB),Stage 1 未接线警示 - 手动发次数:自动生成幂等 source_ref - 路由 /dashboard/lottery、Commerce 菜单项、en-US/zh-CN 双语文案 - 对接文档见 vpn/aaaa.txt(Stage 1)
This commit is contained in:
@@ -0,0 +1,506 @@
|
||||
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 { 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;
|
||||
|
||||
type TranslateFn = (key: string, defaultValue: string) => string;
|
||||
|
||||
const PRIZE_TYPE_OPTIONS: {
|
||||
value: API.LotteryPrizeType;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "vpn_duration",
|
||||
labelKey: "prize.typeVpnDuration",
|
||||
labelDefault: "VPN Duration",
|
||||
},
|
||||
{
|
||||
value: "commission",
|
||||
labelKey: "prize.typeCommission",
|
||||
labelDefault: "Commission",
|
||||
},
|
||||
{
|
||||
value: "none",
|
||||
labelKey: "prize.typeNone",
|
||||
labelDefault: "No Prize (Thanks)",
|
||||
},
|
||||
{
|
||||
value: "crypto",
|
||||
labelKey: "prize.typeCrypto",
|
||||
labelDefault: "Crypto",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "physical",
|
||||
labelKey: "prize.typePhysical",
|
||||
labelDefault: "Physical",
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
value: "manual_other",
|
||||
labelKey: "prize.typeManualOther",
|
||||
labelDefault: "Manual (Other)",
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
const getPrizeSchema = (t: TranslateFn) =>
|
||||
z
|
||||
.object({
|
||||
type: z.enum(["vpn_duration", "commission", "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()
|
||||
.int(t("prize.weightInteger", "Weight must be an integer"))
|
||||
.min(0, t("prize.weightMin", "Weight must be at least 0")),
|
||||
unlimited_stock: z.boolean(),
|
||||
total_stock: z.number().optional(),
|
||||
is_fallback: z.boolean(),
|
||||
duration_days: z.number().optional(),
|
||||
amount_yuan: 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 === "commission") {
|
||||
const yuan = data.amount_yuan ?? 0;
|
||||
const cents = yuan * CENTS_PER_YUAN;
|
||||
if (yuan <= 0) {
|
||||
issue(
|
||||
"amount_yuan",
|
||||
t("prize.amountRequired", "Amount must be greater than 0")
|
||||
);
|
||||
} else if (Math.abs(Math.round(cents) - cents) > 1e-6) {
|
||||
issue(
|
||||
"amount_yuan",
|
||||
t(
|
||||
"prize.amountPrecision",
|
||||
"Amount supports at most 2 decimal places"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return {
|
||||
type: (prize?.type as PrizeFormValues["type"]) ?? "vpn_duration",
|
||||
name: prize?.name ?? "",
|
||||
icon_url: prize?.icon_url ?? "",
|
||||
weight: prize?.weight ?? 1,
|
||||
unlimited_stock: prize ? prize.total_stock === null : true,
|
||||
total_stock: prize?.total_stock ?? undefined,
|
||||
is_fallback: prize?.is_fallback ?? false,
|
||||
duration_days: prize?.config?.duration_days as number | undefined,
|
||||
amount_yuan:
|
||||
typeof prize?.config?.amount_cents === "number"
|
||||
? prize.config.amount_cents / CENTS_PER_YUAN
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toConfig(values: PrizeFormValues): Record<string, any> {
|
||||
if (values.type === "vpn_duration") {
|
||||
return { duration_days: values.duration_days };
|
||||
}
|
||||
if (values.type === "commission") {
|
||||
return {
|
||||
amount_cents: Math.round((values.amount_yuan ?? 0) * CENTS_PER_YUAN),
|
||||
};
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export default function PrizeForm({
|
||||
activityId,
|
||||
slot,
|
||||
initialValues,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: PrizeFormProps) {
|
||||
const { t } = useTranslation("lottery");
|
||||
const [loading, setLoading] = useState(false);
|
||||
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]);
|
||||
|
||||
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.weight,
|
||||
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",
|
||||
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>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
const stage2Hint = t("prize.stage2Hint", "Available in Stage 2");
|
||||
|
||||
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
|
||||
disabled={option.disabled}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
{t(option.labelKey, option.labelDefault)}
|
||||
{option.disabled ? ` · ${stage2Hint}` : ""}
|
||||
</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: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
placeholder: t("prize.durationDaysPlaceholder", "e.g. 1"),
|
||||
suffix: t("prize.daysSuffix", "days"),
|
||||
}
|
||||
)}
|
||||
{type === "commission" &&
|
||||
renderInput(
|
||||
"amount_yuan",
|
||||
t("prize.amountYuan", "Commission Amount (CNY)"),
|
||||
{
|
||||
type: "number",
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
placeholder: "0.00",
|
||||
prefix: "¥",
|
||||
suffix: t("prize.yuanSuffix", "CNY"),
|
||||
},
|
||||
t(
|
||||
"prize.amountUnitHint",
|
||||
"Entered in CNY (yuan), stored as cents; up to 2 decimal places"
|
||||
)
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{renderInput(
|
||||
"weight",
|
||||
t("prize.weight", "Weight"),
|
||||
{ type: "number", min: 0, step: 1, disabled: isFallback },
|
||||
isFallback
|
||||
? t(
|
||||
"prize.fallbackIgnoresWeight",
|
||||
"Fallback prize ignores weight"
|
||||
)
|
||||
: t(
|
||||
"prize.weightHint",
|
||||
"0 = displayed but excluded from random draw"
|
||||
)
|
||||
)}
|
||||
{renderSwitch(
|
||||
"unlimited_stock",
|
||||
t("prize.unlimitedStock", "Unlimited Stock")
|
||||
)}
|
||||
{!unlimitedStock &&
|
||||
renderInput(
|
||||
"total_stock",
|
||||
t("prize.totalStock", "Total Stock"),
|
||||
{
|
||||
type: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user