完善优惠券发放功能

This commit is contained in:
Eamon
2026-05-25 18:12:38 +08:00
parent 05c927111b
commit e0bdcabbfe
9 changed files with 210 additions and 368 deletions

View File

@@ -56,3 +56,7 @@ export async function getUserCoupon(data: {
data,
)
}
export async function getCouponList(data: { page: number; size: number }) {
return callByUser<PageRecord<Coupon>>("/api/admin/coupon-user/page ", data)
}

View File

@@ -18,6 +18,7 @@ import {
Shield,
ShoppingBag,
SquarePercent,
TicketCheck,
TicketPercent,
Users,
} from "lucide-react"
@@ -228,6 +229,11 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
label: "优惠券",
requiredScope: ScopeCouponRead,
},
{
href: "/couponList",
icon: TicketCheck,
label: "已发放优惠券",
},
{
href: "/resources",
icon: Package,

View File

@@ -81,6 +81,7 @@ export default function Appbar(props: { admin: Admin }) {
statistics: "数据统计",
balance: "余额明细",
gateway: "网关列表",
couponList: "已发放优惠券",
}
return labels[path] || path

View File

@@ -1,109 +0,0 @@
"use client"
import { useState } from "react"
import { toast } from "sonner"
import { getPagCoupon } from "@/actions/coupon"
import { DataTable, useDataTable } from "@/components/data-table"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import type { Coupon } from "@/models/coupon"
export function IssueCouponForUser(props: {
userId: number
userPhone: string
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const table = useDataTable<Coupon>((page, size) =>
getPagCoupon({ page, size }),
)
const handleIssue = async (coupon: Coupon) => {
// try {
// const result = await issueCouponToUser({
// user_id: props.userId,
// coupon_id: coupon.id,
// coupon_name: coupon.name,
// })
// if (result.success) {
// toast.success(`成功发放「${coupon.name}」给用户 ${props.userPhone}`)
// props.onSuccess?.()
// setOpen(false)
// } else {
// toast.error(result.message || "发放失败")
// }
// } catch (error) {
// const message = error instanceof Error ? error.message : "发放失败"
// toast.error(`发放优惠券失败: ${message}`)
// }
}
return (
<Dialog
open={open}
onOpenChange={newOpen => {
setOpen(newOpen)
if (newOpen) {
table.refresh()
}
}}
>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent
className="max-h-[85vh] overflow-y-auto"
style={{ width: "auto", minWidth: "800px", maxWidth: "90vw" }}
>
<DialogHeader>
<DialogTitle> {props.userPhone}</DialogTitle>
</DialogHeader>
<DataTable<Coupon>
{...table}
columns={[
{ header: "优惠券名称", accessorKey: "name" },
{ header: "优惠券金额", accessorKey: "amount" },
{ header: "最低消费金额", accessorKey: "min_amount" },
{
header: "状态",
accessorKey: "status",
cell: ({ row }) => {
const status = row.original.status
if (status === 0) {
return <span className="text-yellow-600"></span>
}
if (status === 1) {
return <span className="text-green-600"></span>
}
return <span>-</span>
},
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
cell: ({ row }) => (
<Button size="sm" onClick={() => handleIssue(row.original)}>
</Button>
),
},
]}
/>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,41 +1,12 @@
"use client"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { Suspense } from "react"
import { getUserCoupon } from "@/actions/coupon"
import { DataTable, useDataTable } from "@/components/data-table"
import { Page } from "@/components/page"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { Coupon } from "@/models/coupon"
import { type Coupon, getStatus } from "@/models/coupon"
import { formatDate } from "@/models/formatDate"
export default function CouponPage() {
const router = useRouter()
@@ -50,22 +21,6 @@ export default function CouponPage() {
size,
})
})
console.log(table, "table")
const expireLabel = (coupon: Coupon) => {
switch (coupon.expire_type) {
case 0:
return "永久有效"
case 1:
return coupon.expire_at
? format(new Date(coupon.expire_at), "yyyy-MM-dd")
: ""
case 2:
return coupon.expire_in ? `发放后${coupon.expire_in}` : ""
default:
return ""
}
}
return (
<Page>
@@ -82,194 +37,39 @@ export default function CouponPage() {
<DataTable<Coupon>
{...table}
columns={[
{ header: "优惠券名称", accessorKey: "name" },
{ header: "券码", accessorKey: "code" },
{ header: "金额", accessorKey: "amount" },
{ header: "最低消费", accessorKey: "min_amount" },
{ header: "优惠券名称", accessorKey: "coupon.name" },
{ header: "金额", accessorKey: "coupon.amount" },
{ header: "最低消费", accessorKey: "coupon.min_amount" },
{
header: "状态",
accessorKey: "coupon_status",
header: "优惠券使用状态",
accessorKey: "coupon.status",
cell: ({ row }) => {
const { text, color } = getStatus(row.original.status, "use")
return <span className={color}>{text}</span>
},
},
{
header: "过期信息",
id: "expire",
cell: ({ row }) => expireLabel(row.original),
accessorKey: "expire_at",
cell: ({ row }) => formatDate(row.original.expire_at),
},
{
header: "发放时间",
accessorKey: "issued_at",
// cell: ({ row }) =>
// format(new Date(row.original.issued_at), "yyyy-MM-dd HH:mm:ss"),
accessorKey: "coupon.created_at",
cell: ({ row }) => formatDate(row.original.coupon.created_at),
},
{
header: "备注",
accessorKey: "remark",
cell: ({ row }) => (
<span className="text-gray-500">
{row.original.remark || "-"}
{row.original.remark || ""}
</span>
),
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateStatusDialog
coupon={row.original}
userId={Number(userId)}
onSuccess={table.refresh}
/>
<RevokeDialog
coupon={row.original}
userId={Number(userId)}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</Page>
)
}
function UpdateStatusDialog({
coupon,
userId,
onSuccess,
}: {
coupon: Coupon
userId: number
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const [selectedStatus, setSelectedStatus] =
useState(
// String(coupon.coupon_status),
)
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
// setLoading(true)
// try {
// const status = Number(selectedStatus)
// const result = await updateUserCouponStatus({
// user_id: userId,
// coupon_id: coupon.id,
// coupon_status: status,
// })
// if (result.success) {
// toast.success("状态更新成功")
// onSuccess?.()
// setOpen(false)
// } else {
// toast.error(result.message || "更新失败")
// }
// } catch (error) {
// const message = error instanceof Error ? error.message : "更新失败"
// toast.error(`状态更新失败: ${message}`)
// } finally {
// setLoading(false)
// }
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-sm text-gray-600 mb-3">
: {coupon.name} ({coupon.code})
</p>
<Select value={selectedStatus}>
<SelectTrigger>
<SelectValue placeholder="请选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1">使</SelectItem>
<SelectItem value="2">使</SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
</div>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button onClick={handleConfirm} disabled={loading}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevokeDialog({
coupon,
userId,
onSuccess,
}: {
coupon: Coupon
userId: number
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
// setLoading(true)
// try {
// const result = await revokeUserCoupon({
// user_id: userId,
// coupon_id: coupon.id,
// })
// if (result.success) {
// toast.success("撤销成功")
// onSuccess?.()
// } else {
// toast.error(result.message || "撤销失败")
// }
// } catch (error) {
// const message = error instanceof Error ? error.message : "撤销失败"
// toast.error(`撤销失败: ${message}`)
// } finally {
// setLoading(false)
// }
}
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button size="sm" variant="destructive" disabled={loading}>
</Button>
</AlertDialogTrigger>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{coupon.name}({coupon.code})
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -1,5 +1,4 @@
"use client"
import { format } from "date-fns"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteCoupon, getPagCoupon } from "@/actions/coupon"
@@ -19,7 +18,8 @@ import {
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { ScopeCouponWriteAssign } from "@/lib/scopes"
import type { Coupon } from "@/models/coupon"
import { type Coupon, getExpireType, getStatus } from "@/models/coupon"
import { formatDate } from "@/models/formatDate"
import { CreateDiscount } from "./create"
import { ReleaseCoupon } from "./release"
import { UpdateCoupon } from "./update"
@@ -47,34 +47,13 @@ export default function CouponPage() {
header: "优惠券状态",
accessorKey: "status",
cell: ({ row }) => {
const status = row.original.status
if (status === 0) {
return <span className="text-yellow-600"></span>
}
if (status === 1) {
return <span className="text-green-600"></span>
}
return <span>-</span>
const { text, color } = getStatus(row.original.status, "coupon")
return <span className={color}>{text}</span>
},
},
{
header: "过期类型",
accessorFn: row => {
switch (row.expire_type) {
case 0:
return "不过期"
case 1:
return "固定日期"
case 2:
return "相对日期"
default:
return ""
}
},
},
{
header: "过期时长(天)",
accessorKey: "expire_in",
cell: ({ row }) => getExpireType(row.original.expire_type),
},
{
header: "过期时间",
@@ -82,16 +61,11 @@ export default function CouponPage() {
cell: ({ row }) => {
const coupon = row.original
if (coupon.expire_type === 2 && coupon.expire_in) {
const expireDate = new Date(coupon.created_at)
expireDate.setDate(expireDate.getDate() + coupon.expire_in)
return format(expireDate, "yyyy-MM-dd HH:mm:ss")
return `${coupon.expire_in}`
}
if (coupon.expire_type === 1 && coupon.expire_at) {
return format(
new Date(coupon.expire_at),
"yyyy-MM-dd HH:mm:ss",
)
return formatDate(row.original.expire_at)
}
return <span></span>
},
@@ -99,11 +73,7 @@ export default function CouponPage() {
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(
new Date(row.original.created_at),
"yyyy-MM-dd HH:mm:ss",
),
cell: ({ row }) => formatDate(row.original.created_at),
},
{
id: "action",
@@ -115,16 +85,16 @@ export default function CouponPage() {
coupon={row.original}
onSuccess={table.refresh}
/>
<DeleteCoupon
coupon={row.original}
onSuccess={table.refresh}
/>
<Auth scope={ScopeCouponWriteAssign}>
<ReleaseCoupon
coupon={row.original}
onSuccess={table.refresh}
/>
</Auth>
<DeleteCoupon
coupon={row.original}
onSuccess={table.refresh}
/>
</div>
),
},

View File

@@ -0,0 +1,64 @@
"use client"
import { Suspense } from "react"
import { getCouponList } from "@/actions/coupon"
import { DataTable, useDataTable } from "@/components/data-table"
import { Page } from "@/components/page"
import { type Coupon, getExpireTypeText, getStatus } from "@/models/coupon"
import { formatDate } from "@/models/formatDate"
export default function CouponList() {
const table = useDataTable((page, size) => getCouponList({ page, size }))
console.log(table, "table")
return (
<Page>
<Suspense>
<DataTable<Coupon>
{...table}
columns={[
{ header: "优惠券名称", accessorKey: "coupon.name" },
// { header: "优惠券数量", accessorKey: "coupon.count" },
{ header: "优惠券金额", accessorKey: "coupon.amount" },
{ header: "最低消费金额", accessorKey: "coupon.min_amount" },
{ header: "用户", accessorKey: "user.name" },
// {
// header: "优惠券状态",
// cell: ({ row }) => {
// const { text, color } = getStatus(
// row.original.coupon.status,
// "coupon",
// )
// return <span className={color}>{text}</span>
// },
// },
{
header: "使用状态",
cell: ({ row }) => {
const { text, color } = getStatus(row.original.status, "use")
return <span className={color}>{text}</span>
},
},
{
header: "过期类型",
cell: ({ row }) => {
const expireType = row.original.coupon.expire_type
const expireAt = row.original.expire_at
return getExpireTypeText(expireType, expireAt)
},
},
{
header: "过期时间",
accessorKey: "expire_at",
cell: ({ row }) => formatDate(row.original.expire_at),
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) => formatDate(row.original.created_at),
},
]}
/>
</Suspense>
</Page>
)
}

View File

@@ -1,3 +1,5 @@
import type { User } from "./user"
export type Coupon = {
id: number
name: string
@@ -13,4 +15,97 @@ export type Coupon = {
updated_at: Date
expire_at: Date
expire_in: number
coupon: useCoupon
user: User
}
type useCoupon = {
id: number
name: string
expire_type: number
status: number
created_at: Date
}
// 优惠券使用状态
export const couponUseStatusMap = {
0: { text: "未使用", color: "text-green-600" },
1: { text: "已使用", color: "text-blue-600" },
2: { text: "已禁用", color: "text-green-600" },
} as const
// 优惠券状态
export const couponStatusMap = {
0: { text: "禁用", color: "text-yellow-600" },
1: { text: "正常", color: "text-green-600" },
} as const
// 优惠券过期类型
export const expireTypeMap = {
0: "不过期",
1: "固定日期",
2: "相对日期",
} as const
// 优惠券状态 & 使用状态
export const getStatus = (status: number, type: "coupon" | "use") => {
if (type === "coupon") {
return (
couponStatusMap[status as keyof typeof couponStatusMap] || {
text: "",
color: "text-gray-400",
}
)
}
return (
couponUseStatusMap[status as keyof typeof couponUseStatusMap] || {
text: "",
color: "text-gray-400",
}
)
}
export const getDaysToExpire = (expireAt: Date | string): number => {
if (!expireAt) return 0
const targetDate = new Date(expireAt)
const now = new Date()
const targetDay = new Date(
targetDate.getFullYear(),
targetDate.getMonth(),
targetDate.getDate(),
)
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const diffTime = targetDay.getTime() - today.getTime()
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
}
// 过期类型
export const getExpireType = (expireType: number): string => {
return expireTypeMap[expireType as keyof typeof expireTypeMap] || ""
}
// 获取过期类型显示(带天数)
export const getExpireTypeText = (
expireType: number,
expireAt: Date,
): string => {
const typeText = getExpireType(expireType)
console.log(typeText, "typeText")
if (expireType === 0) return typeText
const days = getDaysToExpire(expireAt)
console.log(days, "days")
if (days === 0) {
return `${typeText}`
} else if (days > 0) {
return `${typeText}${days}天后)`
} else {
return `${typeText}(已过期${Math.abs(days)}天)`
}
}

11
src/models/formatDate.ts Normal file
View File

@@ -0,0 +1,11 @@
import { format } from "date-fns"
export const formatDate = (
date: Date | string | null | undefined,
formatStr: string = "yyyy-MM-dd HH:mm:ss",
): string => {
if (!date) return ""
const d = new Date(date)
if (!Number.isNaN(d.getTime())) return format(d, formatStr)
return ""
}