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 01591adb86
commit a30d83505f
8 changed files with 1224 additions and 73 deletions
+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>