feat(抽奖): 开放人工奖(crypto/physical/manual_other) + 领奖工单管理 + REST 对齐
- prize-form: 解禁 crypto/physical/manual_other 并加各自 config;权重改为中奖概率(%), 合计校验;vpn_duration 加"无订阅时新建订阅套餐" - prize-grid: 概率合计=100% 校验告警;卡片渲染真实 config(时长/金额) - claims-panel(新): 领奖工单列表 + 审核通过/驳回/标记发放 - activity-detail: 新增"领奖工单"Tab - service: 奖品改/删 id 走 /prizes/:id(对齐后端 REST);新增 claims 5 个接口 + 类型 - i18n: 补齐 zh-CN/en-US lottery 文案 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,7 @@ import {
|
||||
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";
|
||||
@@ -49,7 +50,6 @@ const PRIZE_TYPE_OPTIONS: {
|
||||
value: API.LotteryPrizeType;
|
||||
labelKey: string;
|
||||
labelDefault: string;
|
||||
disabled?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
value: "vpn_duration",
|
||||
@@ -70,26 +70,30 @@ const PRIZE_TYPE_OPTIONS: {
|
||||
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"]),
|
||||
type: z.enum([
|
||||
"vpn_duration",
|
||||
"commission",
|
||||
"none",
|
||||
"crypto",
|
||||
"physical",
|
||||
"manual_other",
|
||||
]),
|
||||
name: z.string().min(1, t("prize.nameRequired", "Name is required")),
|
||||
icon_url: z
|
||||
.string()
|
||||
@@ -98,13 +102,21 @@ const getPrizeSchema = (t: TranslateFn) =>
|
||||
.optional(),
|
||||
weight: z
|
||||
.number()
|
||||
.int(t("prize.weightInteger", "Weight must be an integer"))
|
||||
.min(0, t("prize.weightMin", "Weight must be at least 0")),
|
||||
.int(t("prize.weightInteger", "Probability must be an integer"))
|
||||
.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) => {
|
||||
@@ -139,6 +151,45 @@ const getPrizeSchema = (t: TranslateFn) =>
|
||||
);
|
||||
}
|
||||
}
|
||||
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",
|
||||
@@ -162,6 +213,10 @@ interface PrizeFormProps {
|
||||
}
|
||||
|
||||
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 ?? "",
|
||||
@@ -170,23 +225,58 @@ function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
|
||||
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,
|
||||
duration_days: config.duration_days as number | undefined,
|
||||
subscribe_id: config.subscribe_id as number | undefined,
|
||||
amount_yuan:
|
||||
typeof prize?.config?.amount_cents === "number"
|
||||
? prize.config.amount_cents / CENTS_PER_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") {
|
||||
return { duration_days: values.duration_days };
|
||||
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 === "commission") {
|
||||
return {
|
||||
amount_cents: Math.round((values.amount_yuan ?? 0) * CENTS_PER_YUAN),
|
||||
};
|
||||
}
|
||||
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),
|
||||
});
|
||||
}
|
||||
if (values.type === "physical" || values.type === "manual_other") {
|
||||
const config: Record<string, any> = {};
|
||||
if (values.sku_name?.trim()) {
|
||||
config.sku_name = values.sku_name.trim();
|
||||
}
|
||||
return withTtl(config);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -200,6 +290,7 @@ export default function PrizeForm({
|
||||
}: 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),
|
||||
@@ -214,6 +305,16 @@ export default function PrizeForm({
|
||||
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 {
|
||||
@@ -224,7 +325,7 @@ export default function PrizeForm({
|
||||
name: values.name.trim(),
|
||||
icon_url: values.icon_url?.trim() ?? "",
|
||||
config: toConfig(values),
|
||||
weight: values.weight,
|
||||
weight: values.is_fallback ? 0 : values.weight,
|
||||
total_stock: values.unlimited_stock
|
||||
? null
|
||||
: (values.total_stock ?? null),
|
||||
@@ -268,7 +369,12 @@ export default function PrizeForm({
|
||||
| "weight"
|
||||
| "total_stock"
|
||||
| "duration_days"
|
||||
| "amount_yuan",
|
||||
| "amount_yuan"
|
||||
| "crypto_amount"
|
||||
| "crypto_currency"
|
||||
| "crypto_networks"
|
||||
| "sku_name"
|
||||
| "claim_ttl_hours",
|
||||
label: string,
|
||||
inputProps: Partial<EnhancedInputProps<any>>,
|
||||
description?: string
|
||||
@@ -322,8 +428,6 @@ export default function PrizeForm({
|
||||
/>
|
||||
);
|
||||
|
||||
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">
|
||||
@@ -365,13 +469,8 @@ export default function PrizeForm({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIZE_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem
|
||||
disabled={option.disabled}
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
>
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{t(option.labelKey, option.labelDefault)}
|
||||
{option.disabled ? ` · ${stage2Hint}` : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -399,6 +498,53 @@ export default function PrizeForm({
|
||||
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 === "commission" &&
|
||||
renderInput(
|
||||
"amount_yuan",
|
||||
@@ -424,18 +570,91 @@ export default function PrizeForm({
|
||||
)}
|
||||
</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 === "physical" || type === "manual_other") &&
|
||||
renderInput(
|
||||
"sku_name",
|
||||
t("prize.skuName", "Item name (optional)"),
|
||||
{
|
||||
placeholder: t(
|
||||
"prize.skuNamePlaceholder",
|
||||
"e.g. Limited T-shirt"
|
||||
),
|
||||
}
|
||||
)}
|
||||
{(type === "crypto" ||
|
||||
type === "physical" ||
|
||||
type === "manual_other") && (
|
||||
<>
|
||||
{renderInput(
|
||||
"claim_ttl_hours",
|
||||
t("prize.claimTtlHours", "Claim window (hours)"),
|
||||
{
|
||||
type: "number",
|
||||
min: 1,
|
||||
step: 1,
|
||||
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", "Weight"),
|
||||
{ type: "number", min: 0, step: 1, disabled: isFallback },
|
||||
t("prize.weight", "Win probability (%)"),
|
||||
{
|
||||
type: "number",
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 1,
|
||||
suffix: "%",
|
||||
disabled: isFallback,
|
||||
},
|
||||
isFallback
|
||||
? t(
|
||||
"prize.fallbackIgnoresWeight",
|
||||
"Fallback prize ignores weight"
|
||||
"Fallback prize ignores probability (never randomly drawn)"
|
||||
)
|
||||
: t(
|
||||
"prize.weightHint",
|
||||
"0 = displayed but excluded from random draw"
|
||||
"Percent chance in the grid. All drawable prizes must total 100%. 0 = shown but never drawn."
|
||||
)
|
||||
)}
|
||||
{renderSwitch(
|
||||
|
||||
Reference in New Issue
Block a user