feat(抽奖): 人工发放(crypto)奖品去掉金额/币种/网络/领奖窗口配置 + 重建 dist.zip
人工奖只留 名称+图标+概率+库存;中奖凭截图联系客服线下发放,无需 in-app 领奖表单。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
@@ -92,7 +92,7 @@
|
||||
"iconUrlInvalid": "Must be a valid URL",
|
||||
"isFallback": "Fallback Prize",
|
||||
"isFallbackHint": "Awarded when limited prizes are sold out",
|
||||
"manualPrizeHint": "Manual prize: the winner submits claim info, then an operator approves and marks it paid/shipped under the Claims tab.",
|
||||
"manualPrizeHint": "Manual prize: no extra config. When won, the user contacts customer service with a screenshot to redeem — no in-app claim form.",
|
||||
"name": "Prize Name",
|
||||
"namePlaceholder": "e.g. 1-Day Pass",
|
||||
"nameRequired": "Name is required",
|
||||
|
||||
@@ -92,7 +92,7 @@
|
||||
"iconUrlInvalid": "请输入有效的 URL",
|
||||
"isFallback": "保底奖品",
|
||||
"isFallbackHint": "限量奖品抽完后发放该奖品",
|
||||
"manualPrizeHint": "人工奖:用户先提交领奖信息,运营在「领奖工单」页审核并标记发放/发货。",
|
||||
"manualPrizeHint": "人工发放奖品:无需额外配置。中奖后用户凭截图联系人工客服兑换,无需在 app 内填领奖表单。",
|
||||
"name": "奖品名称",
|
||||
"namePlaceholder": "例如:1 天时长卡",
|
||||
"nameRequired": "请输入奖品名称",
|
||||
|
||||
@@ -42,7 +42,6 @@ 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;
|
||||
|
||||
@@ -101,13 +100,6 @@ const getPrizeSchema = (t: TranslateFn) =>
|
||||
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) => {
|
||||
@@ -124,45 +116,6 @@ 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",
|
||||
@@ -187,9 +140,6 @@ 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 ?? "",
|
||||
@@ -200,26 +150,10 @@ function toFormValues(prize?: API.LotteryPrize): PrizeFormValues {
|
||||
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/未选则沿用旧行为(跳过发放)。
|
||||
@@ -228,16 +162,7 @@ function toConfig(values: PrizeFormValues): Record<string, any> {
|
||||
}
|
||||
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),
|
||||
});
|
||||
}
|
||||
// crypto(人工发放) / none 无需额外配置:中奖后凭截图联系客服线下发放。
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -326,18 +251,7 @@ export default function PrizeForm({
|
||||
}
|
||||
|
||||
const renderInput = (
|
||||
name:
|
||||
| "name"
|
||||
| "icon_url"
|
||||
| "weight"
|
||||
| "total_stock"
|
||||
| "duration_days"
|
||||
| "amount_yuan"
|
||||
| "crypto_amount"
|
||||
| "crypto_currency"
|
||||
| "crypto_networks"
|
||||
| "sku_name"
|
||||
| "claim_ttl_hours",
|
||||
name: "name" | "icon_url" | "weight" | "total_stock" | "duration_days",
|
||||
label: string,
|
||||
inputProps: Partial<EnhancedInputProps<any>>,
|
||||
description?: string
|
||||
@@ -517,57 +431,12 @@ export default function PrizeForm({
|
||||
</p>
|
||||
)}
|
||||
{type === "crypto" && (
|
||||
<>
|
||||
{renderInput(
|
||||
"crypto_amount",
|
||||
t("prize.cryptoAmount", "Amount"),
|
||||
{
|
||||
placeholder: t(
|
||||
"prize.cryptoAmountPlaceholder",
|
||||
"e.g. 0.01"
|
||||
),
|
||||
}
|
||||
<p className="rounded-md bg-muted/50 p-3 text-muted-foreground text-xs">
|
||||
{t(
|
||||
"prize.manualPrizeHint",
|
||||
"Manual prize: no extra config. When won, the user contacts customer service with a screenshot to redeem — no in-app claim form."
|
||||
)}
|
||||
{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>
|
||||
</>
|
||||
</p>
|
||||
)}
|
||||
{renderInput(
|
||||
"weight",
|
||||
|
||||
Reference in New Issue
Block a user