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,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user