Files
hi-frontend/apps/admin/src/sections/lottery/activity-detail.tsx
T
shanshanzhong147 ab327f5118 feat: 后台抽奖活动管理(Stage 1)
- 服务层:12 个 /v1/admin/lottery 接口封装与 API 类型定义
- 活动列表:状态筛选、上架/暂停(带确认)、编辑、管理、发次数
- 奖品管理:3×3 九宫格可视化编辑、概率预览、保底奖警告、佣金元↔分转换、无限库存
- 规则设置:chance_sources 动态编辑,eligibility 简单/高级(JSON)双模式,本地 rulecaps 校验(深度≤8/节点≤64/≤8KB),Stage 1 未接线警示
- 手动发次数:自动生成幂等 source_ref
- 路由 /dashboard/lottery、Commerce 菜单项、en-US/zh-CN 双语文案
- 对接文档见 vpn/aaaa.txt(Stage 1)
2026-07-09 13:35:35 -07:00

120 lines
3.5 KiB
TypeScript

import { Badge } from "@workspace/ui/components/badge";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@workspace/ui/components/sheet";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { getLotteryActivityDetail } from "@workspace/ui/services/admin/lottery";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import PrizeGrid from "./prize-grid";
import RuleEditor from "./rule-editor";
interface ActivityDetailProps {
activity: API.LotteryActivity | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onChanged?: () => void;
}
type TranslateFn = (key: string, defaultValue: string) => string;
const STATUS_BADGE_VARIANTS: Record<
API.LotteryActivityStatus,
"default" | "secondary" | "destructive" | "outline"
> = {
draft: "secondary",
running: "default",
paused: "outline",
ended: "destructive",
};
function statusLabel(
status: API.LotteryActivityStatus,
t: TranslateFn
): string {
const labels: Record<API.LotteryActivityStatus, string> = {
draft: t("status.draft", "Draft"),
running: t("status.running", "Running"),
paused: t("status.paused", "Paused"),
ended: t("status.ended", "Ended"),
};
return labels[status] ?? status;
}
export default function ActivityDetail({
activity,
open,
onOpenChange,
onChanged,
}: ActivityDetailProps) {
const { t } = useTranslation("lottery");
// 抽屉打开时拉取详情作为数据源,避免列表行数据不完整时被全量 PUT 覆盖
const [detail, setDetail] = useState<API.LotteryActivity | null>(null);
const activityId = activity?.id;
useEffect(() => {
setDetail(null);
if (!(open && activityId)) return;
let cancelled = false;
getLotteryActivityDetail({ id: activityId })
.then(({ data }) => {
if (!cancelled && data.data) setDetail(data.data);
})
.catch(() => {
// Request layer already surfaces the error toast; fall back to prop data
});
return () => {
cancelled = true;
};
}, [open, activityId]);
const current = detail ?? activity;
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent className="w-[800px] max-w-full sm:max-w-3xl" side="right">
{current && (
<>
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
<span className="truncate">{current.title}</span>
<Badge
variant={STATUS_BADGE_VARIANTS[current.status] ?? "outline"}
>
{statusLabel(current.status, t)}
</Badge>
</SheetTitle>
</SheetHeader>
<div className="max-h-[calc(100dvh-88px)] overflow-y-auto px-6 pb-6">
<Tabs defaultValue="prizes">
<TabsList className="mb-4">
<TabsTrigger value="prizes">
{t("detail.prizesTab", "Prizes")}
</TabsTrigger>
<TabsTrigger value="rules">
{t("detail.rulesTab", "Rules")}
</TabsTrigger>
</TabsList>
<TabsContent value="prizes">
<PrizeGrid activity={current} />
</TabsContent>
<TabsContent value="rules">
<RuleEditor activity={current} onSaved={onChanged} />
</TabsContent>
</Tabs>
</div>
</>
)}
</SheetContent>
</Sheet>
);
}