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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user