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:
2026-07-09 13:35:35 -07:00
parent dfe1bafd1c
commit ab327f5118
18 changed files with 3315 additions and 0 deletions
+265
View File
@@ -0,0 +1,265 @@
import { Badge } from "@workspace/ui/components/badge";
import { Button } from "@workspace/ui/components/button";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import {
ProTable,
type ProTableActions,
} from "@workspace/ui/composed/pro-table/pro-table";
import {
createLotteryActivity,
getLotteryActivityList,
pauseLotteryActivity,
publishLotteryActivity,
updateLotteryActivity,
} from "@workspace/ui/services/admin/lottery";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { formatDate } from "@/utils/common";
import ActivityDetail from "./activity-detail";
import ActivityForm from "./activity-form";
import GrantDialog from "./grant-dialog";
export default function Lottery() {
const { t } = useTranslation("lottery");
const [loading, setLoading] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [grantOpen, setGrantOpen] = useState(false);
const [selectedActivity, setSelectedActivity] =
useState<API.LotteryActivity | null>(null);
const ref = useRef<ProTableActions>(null);
const statusConfig: Record<
API.LotteryActivityStatus,
{
label: string;
variant: "secondary" | "destructive" | "outline";
className?: string;
}
> = {
draft: { label: t("status.draft", "Draft"), variant: "secondary" },
running: {
label: t("status.running", "Running"),
variant: "outline",
className: "border-transparent bg-green-500 text-white",
},
paused: {
label: t("status.paused", "Paused"),
variant: "outline",
className: "border-transparent bg-yellow-500 text-white",
},
ended: { label: t("status.ended", "Ended"), variant: "destructive" },
};
const statusOptions = [
{ label: t("statusAll", "All"), value: "all" },
{ label: t("status.draft", "Draft"), value: "draft" },
{ label: t("status.running", "Running"), value: "running" },
{ label: t("status.paused", "Paused"), value: "paused" },
{ label: t("status.ended", "Ended"), value: "ended" },
];
return (
<>
<ProTable<API.LotteryActivity, { status: string }>
action={ref}
actions={{
render: (row) => {
const rowActions: React.ReactNode[] = [];
if (row.status === "draft") {
rowActions.push(
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"publishWarning",
"Once published, the activity becomes visible to users."
)}
key="publish"
onConfirm={async () => {
try {
await publishLotteryActivity({ id: row.id });
toast.success(t("publishSuccess", "Publish Success"));
ref.current?.refresh();
} catch {
// Request layer already surfaces the error toast
}
}}
title={t(
"confirmPublish",
"Are you sure you want to publish this activity?"
)}
trigger={<Button size="sm">{t("publish", "Publish")}</Button>}
/>
);
}
if (row.status === "running") {
rowActions.push(
<ConfirmButton
cancelText={t("cancel", "Cancel")}
confirmText={t("confirm", "Confirm")}
description={t(
"pauseWarning",
"Users will not be able to draw while the activity is paused."
)}
key="pause"
onConfirm={async () => {
try {
await pauseLotteryActivity({ id: row.id });
toast.success(t("pauseSuccess", "Pause Success"));
ref.current?.refresh();
} catch {
// Request layer already surfaces the error toast
}
}}
title={t(
"confirmPause",
"Are you sure you want to pause this activity?"
)}
trigger={
<Button size="sm" variant="destructive">
{t("pause", "Pause")}
</Button>
}
/>
);
}
rowActions.push(
<ActivityForm
initialValues={row}
key="edit"
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await updateLotteryActivity({ ...values, id: row.id });
toast.success(t("updateSuccess", "Update Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("editActivity", "Edit Activity")}
trigger={t("edit", "Edit")}
/>,
<Button
key="manage"
onClick={() => {
setSelectedActivity(row);
setDetailOpen(true);
}}
size="sm"
variant="outline"
>
{t("manage", "Manage")}
</Button>,
<Button
key="grant"
onClick={() => {
setSelectedActivity(row);
setGrantOpen(true);
}}
size="sm"
variant="outline"
>
{t("grantChances", "Grant Chances")}
</Button>
);
return rowActions;
},
}}
columns={[
{
accessorKey: "id",
header: t("id", "ID"),
},
{
accessorKey: "title",
header: t("title", "Title"),
},
{
accessorKey: "status",
header: t("statusLabel", "Status"),
cell: ({ row }) => {
const status = statusConfig[row.original.status];
return (
<Badge className={status.className} variant={status.variant}>
{status.label}
</Badge>
);
},
},
{
accessorKey: "start_at",
header: t("activityTime", "Activity Time"),
cell: ({ row }) =>
`${formatDate(row.original.start_at)} - ${formatDate(
row.original.end_at
)}`,
},
{
accessorKey: "grid_size",
header: t("gridSize", "Grid Size"),
},
]}
header={{
toolbar: (
<ActivityForm
loading={loading}
onSubmit={async (values) => {
setLoading(true);
try {
await createLotteryActivity(values);
toast.success(t("createSuccess", "Create Success"));
ref.current?.refresh();
setLoading(false);
return true;
} catch (_error) {
setLoading(false);
return false;
}
}}
title={t("createActivity", "Create Activity")}
trigger={t("create", "Create")}
/>
),
}}
params={[
{
key: "status",
placeholder: t("statusLabel", "Status"),
options: statusOptions,
},
]}
request={async (pagination, filters) => {
const { data } = await getLotteryActivityList({
...pagination,
...filters,
status:
filters?.status && filters.status !== "all"
? filters.status
: undefined,
});
return {
list: data.data?.list || [],
total: data.data?.total || 0,
};
}}
/>
<ActivityDetail
activity={selectedActivity}
onChanged={() => ref.current?.refresh()}
onOpenChange={setDetailOpen}
open={detailOpen}
/>
<GrantDialog
activity={selectedActivity}
onOpenChange={setGrantOpen}
open={grantOpen}
/>
</>
);
}