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:
2026-07-16 20:29:47 -07:00
parent ea4f6799e9
commit dd837cac7c
8 changed files with 1224 additions and 73 deletions
+244 -25
View File
@@ -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(