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
@@ -14,6 +14,7 @@ import {
import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ClaimsPanel from "./claims-panel";
import PrizeGrid from "./prize-grid";
import RuleEditor from "./rule-editor";
@@ -102,6 +103,9 @@ export default function ActivityDetail({
<TabsTrigger value="rules">
{t("detail.rulesTab", "Rules")}
</TabsTrigger>
<TabsTrigger value="claims">
{t("detail.claimsTab", "Claims")}
</TabsTrigger>
</TabsList>
<TabsContent value="prizes">
<PrizeGrid activity={current} />
@@ -109,6 +113,9 @@ export default function ActivityDetail({
<TabsContent value="rules">
<RuleEditor activity={current} onSaved={onChanged} />
</TabsContent>
<TabsContent value="claims">
<ClaimsPanel activity={current} />
</TabsContent>
</Tabs>
</div>
</>
@@ -0,0 +1,555 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import { Textarea } from "@workspace/ui/components/textarea";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
approveLotteryClaim,
getLotteryClaimList,
markPaidLotteryClaim,
rejectLotteryClaim,
} from "@workspace/ui/services/admin/lottery";
import { type ReactNode, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { formatDate } from "@/utils/common";
interface ClaimsPanelProps {
activity: API.LotteryActivity;
}
type TranslateFn = (key: string, defaultValue: string) => string;
const STATUS_META: Record<
API.LotteryClaimStatus,
{ labelKey: string; labelDefault: string; className: string }
> = {
pending_claim: {
labelKey: "claim.statusPending",
labelDefault: "Awaiting user",
className: "bg-gray-100 text-gray-600 border-gray-200",
},
reviewing: {
labelKey: "claim.statusReviewing",
labelDefault: "Reviewing",
className: "bg-amber-100 text-amber-700 border-amber-200",
},
paying: {
labelKey: "claim.statusPaying",
labelDefault: "Paying / Shipping",
className: "bg-blue-100 text-blue-700 border-blue-200",
},
paid: {
labelKey: "claim.statusPaid",
labelDefault: "Fulfilled",
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
},
rejected: {
labelKey: "claim.statusRejected",
labelDefault: "Rejected",
className: "bg-rose-100 text-rose-700 border-rose-200",
},
expired: {
labelKey: "claim.statusExpired",
labelDefault: "Expired",
className: "bg-gray-100 text-gray-500 border-gray-200",
},
};
function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
const labels: Record<string, string> = {
crypto: t("prize.typeCrypto", "Crypto"),
physical: t("prize.typePhysical", "Physical"),
manual_other: t("prize.typeManualOther", "Manual (Other)"),
};
return labels[type] ?? type;
}
function ClaimDataCell({ data }: { data?: Record<string, any> }) {
if (!data || Object.keys(data).length === 0) {
return <span className="text-muted-foreground">--</span>;
}
return (
<div className="max-w-[240px] space-y-0.5 text-xs">
{Object.entries(data).map(([key, value]) => (
<div className="break-all" key={key}>
<span className="text-muted-foreground">{key}:</span>{" "}
<span className="font-medium">{String(value)}</span>
</div>
))}
</div>
);
}
const rejectSchema = (t: TranslateFn) =>
z.object({
reason: z
.string()
.trim()
.min(2, t("claim.reasonRequired", "Please enter a reason"))
.max(512, t("claim.reasonTooLong", "Reason cannot exceed 512 chars")),
});
const markPaidSchema = z.object({
tx_hash: z.string().trim().max(128).optional(),
delivery_ref: z.string().trim().max(128).optional(),
});
export default function ClaimsPanel({ activity }: ClaimsPanelProps) {
const { t } = useTranslation("lottery");
const ref = useRef<ProTableActions>(null);
const [rejectOpen, setRejectOpen] = useState(false);
const [paidOpen, setPaidOpen] = useState(false);
const [currentRow, setCurrentRow] = useState<API.AdminLotteryClaim | null>(
null
);
const rejectForm = useForm<{ reason: string }>({
resolver: zodResolver(rejectSchema(t)),
defaultValues: { reason: "" },
});
const paidForm = useForm<z.infer<typeof markPaidSchema>>({
resolver: zodResolver(markPaidSchema),
defaultValues: { tx_hash: "", delivery_ref: "" },
});
const approveMutation = useMutation({
mutationFn: (id: number) => approveLotteryClaim({ id }),
onSuccess: () => {
toast.success(t("claim.approveSuccess", "Claim approved"));
ref.current?.refresh();
},
});
const rejectMutation = useMutation({
mutationFn: (body: API.RejectLotteryClaimRequest) =>
rejectLotteryClaim(body),
onSuccess: () => {
toast.success(t("claim.rejectSuccess", "Claim rejected"));
setRejectOpen(false);
setCurrentRow(null);
rejectForm.reset({ reason: "" });
ref.current?.refresh();
},
});
const paidMutation = useMutation({
mutationFn: (body: API.MarkPaidLotteryClaimRequest) =>
markPaidLotteryClaim(body),
onSuccess: () => {
toast.success(t("claim.markPaidSuccess", "Marked as fulfilled"));
setPaidOpen(false);
setCurrentRow(null);
paidForm.reset({ tx_hash: "", delivery_ref: "" });
ref.current?.refresh();
},
});
const statusOptions = (
Object.keys(STATUS_META) as API.LotteryClaimStatus[]
).map((value) => ({
value,
label: t(STATUS_META[value].labelKey, STATUS_META[value].labelDefault),
}));
const typeOptions = [
{ value: "crypto", label: t("prize.typeCrypto", "Crypto") },
{ value: "physical", label: t("prize.typePhysical", "Physical") },
{
value: "manual_other",
label: t("prize.typeManualOther", "Manual (Other)"),
},
];
const openReject = (row: API.AdminLotteryClaim) => {
setCurrentRow(row);
rejectForm.reset({ reason: "" });
setRejectOpen(true);
};
const openPaid = (row: API.AdminLotteryClaim) => {
setCurrentRow(row);
paidForm.reset({ tx_hash: "", delivery_ref: "" });
setPaidOpen(true);
};
return (
<>
<ProTable<
API.AdminLotteryClaim,
{ type?: string; status?: string; user_id?: string }
>
action={ref}
actions={{
render: (row) => {
const actions: ReactNode[] = [];
if (row.status === "reviewing") {
actions.push(
<ConfirmButton
cancelText={t("claim.cancel", "Cancel")}
confirmText={t("claim.confirm", "Confirm")}
description={t(
"claim.approveDescription",
"Approving moves this claim to the paying/shipping stage."
)}
key="approve"
onConfirm={async () => {
await approveMutation.mutateAsync(row.id);
}}
title={t("claim.approveTitle", "Approve this claim?")}
trigger={
<Button size="sm" variant="default">
{t("claim.approve", "Approve")}
</Button>
}
/>
);
}
if (row.status === "paying") {
actions.push(
<Button
key="mark-paid"
onClick={() => openPaid(row)}
size="sm"
variant="default"
>
{t("claim.markPaid", "Mark fulfilled")}
</Button>
);
}
if (row.status === "reviewing" || row.status === "paying") {
actions.push(
<Button
key="reject"
onClick={() => openReject(row)}
size="sm"
variant="destructive"
>
{t("claim.reject", "Reject")}
</Button>
);
}
return actions;
},
}}
columns={[
{ accessorKey: "id", header: "ID" },
{
accessorKey: "user",
header: t("claim.user", "User"),
cell: ({ row }) => (
<div className="text-sm">
<div>#{row.original.user.id}</div>
{row.original.user.email && (
<div className="text-muted-foreground text-xs">
{row.original.user.email}
</div>
)}
</div>
),
},
{
accessorKey: "prize",
header: t("claim.prize", "Prize"),
cell: ({ row }) => (
<div className="text-sm">
<div className="font-medium">{row.original.prize.name}</div>
<Badge className="mt-0.5" variant="secondary">
{prizeTypeLabel(row.original.prize.type, t)}
</Badge>
</div>
),
},
{
accessorKey: "claim_data",
header: t("claim.claimData", "Claim info"),
cell: ({ row }) => <ClaimDataCell data={row.original.claim_data} />,
},
{
accessorKey: "status",
header: t("claim.status", "Status"),
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge className={meta?.className} variant="outline">
{meta
? t(meta.labelKey, meta.labelDefault)
: row.original.status}
</Badge>
);
},
},
{
accessorKey: "fulfillment",
header: t("claim.fulfillment", "Tx / Delivery"),
cell: ({ row }) => (
<div className="max-w-[180px] break-all text-xs">
{row.original.tx_hash || row.original.delivery_ref || "--"}
{row.original.reject_reason && (
<div className="text-rose-600">
{row.original.reject_reason}
</div>
)}
</div>
),
},
{
accessorKey: "expires_at",
header: t("claim.expiresAt", "Expires"),
cell: ({ row }) =>
row.original.expires_at
? formatDate(row.original.expires_at)
: "--",
},
{
accessorKey: "created_at",
header: t("claim.createdAt", "Won at"),
cell: ({ row }) =>
row.original.created_at
? formatDate(row.original.created_at)
: "--",
},
]}
header={{ title: t("claim.tableTitle", "Claims") }}
params={[
{
key: "type",
options: typeOptions,
placeholder: t("claim.filterType", "Type"),
},
{
key: "status",
options: statusOptions,
placeholder: t("claim.filterStatus", "Status"),
},
{
key: "user_id",
placeholder: t("claim.filterUserId", "User ID"),
},
]}
request={async (pagination, filter) => {
const { data } = await getLotteryClaimList({
page: pagination.page,
size: pagination.size,
activity_id: activity.id,
type: (filter.type as API.LotteryPrizeType) || undefined,
status: (filter.status as API.LotteryClaimStatus) || undefined,
user_id: filter.user_id ? Number(filter.user_id) : undefined,
});
return {
list: data.data?.claims || [],
total: data.data?.total || 0,
};
}}
/>
{/* Reject dialog */}
<Dialog
onOpenChange={(open) => {
setRejectOpen(open);
if (!open) {
setCurrentRow(null);
rejectForm.reset({ reason: "" });
}
}}
open={rejectOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("claim.rejectTitle", "Reject claim")}</DialogTitle>
<DialogDescription>
{t(
"claim.rejectDescription",
"The reason is shown to the user, who may then resubmit."
)}
</DialogDescription>
</DialogHeader>
<Form {...rejectForm}>
<form
className="space-y-4"
onSubmit={rejectForm.handleSubmit(async (values) => {
if (!currentRow) return;
await rejectMutation.mutateAsync({
id: currentRow.id,
reason: values.reason.trim(),
});
})}
>
<FormField
control={rejectForm.control}
name="reason"
render={({ field }) => (
<FormItem>
<FormLabel>{t("claim.reason", "Reason")}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"claim.reasonPlaceholder",
"Reason shown to the user"
)}
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
onClick={() => setRejectOpen(false)}
type="button"
variant="outline"
>
{t("claim.cancel", "Cancel")}
</Button>
<Button disabled={rejectMutation.isPending} type="submit">
{t("claim.confirmReject", "Confirm reject")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Mark-paid dialog */}
<Dialog
onOpenChange={(open) => {
setPaidOpen(open);
if (!open) {
setCurrentRow(null);
paidForm.reset({ tx_hash: "", delivery_ref: "" });
}
}}
open={paidOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("claim.markPaidTitle", "Mark as fulfilled")}
</DialogTitle>
<DialogDescription>
{t(
"claim.markPaidDescription",
"Crypto requires a tx hash; physical requires a delivery ref; manual requires at least one."
)}
</DialogDescription>
</DialogHeader>
<Form {...paidForm}>
<form
className="space-y-4"
onSubmit={paidForm.handleSubmit(async (values) => {
if (!currentRow) return;
const txHash = values.tx_hash?.trim();
const deliveryRef = values.delivery_ref?.trim();
const prizeType = currentRow.prize.type;
if (prizeType === "crypto" && !txHash) {
paidForm.setError("tx_hash", {
message: t(
"claim.txHashRequired",
"Tx hash is required for crypto"
),
});
return;
}
if (prizeType === "physical" && !deliveryRef) {
paidForm.setError("delivery_ref", {
message: t(
"claim.deliveryRefRequired",
"Delivery ref is required for physical"
),
});
return;
}
if (!(txHash || deliveryRef)) {
paidForm.setError("tx_hash", {
message: t(
"claim.oneRefRequired",
"Fill at least one field"
),
});
return;
}
await paidMutation.mutateAsync({
id: currentRow.id,
tx_hash: txHash || undefined,
delivery_ref: deliveryRef || undefined,
});
})}
>
<FormField
control={paidForm.control}
name="tx_hash"
render={({ field }) => (
<FormItem>
<FormLabel>{t("claim.txHash", "Tx hash")}</FormLabel>
<FormControl>
<Input placeholder="0x..." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={paidForm.control}
name="delivery_ref"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("claim.deliveryRef", "Delivery ref")}
</FormLabel>
<FormControl>
<Input
placeholder={t(
"claim.deliveryRefPlaceholder",
"Tracking number"
)}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
onClick={() => setPaidOpen(false)}
type="button"
variant="outline"
>
{t("claim.cancel", "Cancel")}
</Button>
<Button disabled={paidMutation.isPending} type="submit">
{t("claim.confirmMarkPaid", "Confirm")}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</>
);
}
+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(
+64 -6
View File
@@ -15,6 +15,8 @@ import PrizeForm from "./prize-form";
const DEFAULT_GRID_SIZE = 9;
const CENTER_SLOT = 4;
const PERCENT_DECIMALS = 1;
const REQUIRED_TOTAL_PERCENT = 100;
const CENTS_PER_YUAN = 100;
interface PrizeGridProps {
activity: API.LotteryActivity;
@@ -36,6 +38,30 @@ function prizeTypeLabel(type: API.LotteryPrizeType, t: TranslateFn): string {
return labels[type] ?? type;
}
// prizeConfigSummary 把奖品的类型专属 config 渲染成一行可读文本,
// 让九宫格卡片显示真实配置值(免费时长天数 / 佣金金额),而不是只有类型徽章。
function prizeConfigSummary(
prize: API.LotteryPrize,
t: TranslateFn
): string | null {
const config = prize.config ?? {};
if (prize.type === "vpn_duration") {
const days = config.duration_days as number | undefined;
if (typeof days === "number" && days > 0) {
return `${days} ${t("prize.daysSuffix", "days")}`;
}
return null;
}
if (prize.type === "commission") {
const cents = config.amount_cents as number | undefined;
if (typeof cents === "number" && cents > 0) {
return `¥${(cents / CENTS_PER_YUAN).toFixed(2)}`;
}
return null;
}
return null;
}
interface FormState {
open: boolean;
slot: number;
@@ -84,6 +110,7 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
[randomPrizes]
);
const hasFallback = prizes.some((prize) => prize.is_fallback);
const totalIsValid = totalWeight === REQUIRED_TOTAL_PERCENT;
const gridSize = activity.grid_size || DEFAULT_GRID_SIZE;
const slots = Array.from({ length: gridSize }, (_, index) => index);
@@ -104,16 +131,23 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
return (
<div className="space-y-4">
<div className="rounded-lg border bg-muted/30 p-3">
<p className="mb-2 font-medium text-sm">
{t("prize.probabilityPreview", "Probability Preview")}
</p>
<div className="mb-2 flex items-center justify-between">
<p className="font-medium text-sm">
{t("prize.probabilityPreview", "Probability Preview")}
</p>
{randomPrizes.length > 0 && (
<Badge variant={totalIsValid ? "secondary" : "destructive"}>
{t("prize.probabilityTotal", "Total")}: {totalWeight}%
</Badge>
)}
</div>
{randomPrizes.length > 0 ? (
<div className="flex flex-wrap gap-2">
{randomPrizes.map((prize) => (
<Badge key={prize.id} variant="secondary">
{prize.name}: {prize.weight}/{totalWeight} (
{prize.name}:{" "}
{((prize.weight / totalWeight) * 100).toFixed(PERCENT_DECIMALS)}
%)
%
</Badge>
))}
</div>
@@ -126,6 +160,21 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
</p>
)}
</div>
{randomPrizes.length > 0 && !totalIsValid && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("prize.totalNot100Title", "Probabilities must total 100%")}
</AlertTitle>
<AlertDescription>
{t(
"prize.totalNot100Hint",
"The drawable prizes currently total {{total}}%. Adjust each prize's win probability so they sum to exactly 100%.",
{ total: totalWeight }
)}
</AlertDescription>
</Alert>
)}
{!hasFallback && (
<Alert>
<AlertTriangle className="h-4 w-4" />
@@ -185,8 +234,17 @@ export default function PrizeGrid({ activity }: PrizeGridProps) {
<Badge variant="secondary">
{prizeTypeLabel(prize.type, t)}
</Badge>
{(() => {
const summary = prizeConfigSummary(prize, t);
return summary ? (
<span className="line-clamp-1 font-medium text-primary text-xs">
{summary}
</span>
) : null;
})()}
<span className="text-muted-foreground text-xs">
{t("prize.weightShort", "Weight")}: {prize.weight} ·{" "}
{t("prize.weightShort", "Prob")}:{" "}
{prize.is_fallback ? "—" : `${prize.weight}%`} ·{" "}
{t("prize.stockShort", "Stock")}:{" "}
{prize.total_stock === null ? "∞" : prize.total_stock}
</span>