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)
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user