添加优惠券功能 & 更新折扣管理的接口

This commit is contained in:
Eamon
2026-03-27 15:51:40 +08:00
parent 453d687c4a
commit 76d2d480ed
10 changed files with 667 additions and 19 deletions

34
src/actions/coupon.ts Normal file
View File

@@ -0,0 +1,34 @@
import type { PageRecord } from "@/lib/api"
import type { Coupon } from "@/models/coupon"
import { callByUser } from "./base"
export async function getPagCoupon(params: { page: number; size: number }) {
return callByUser<PageRecord<Coupon>>("/api/admin/coupon/page", params)
}
export async function createCoupon(data: {
code: string
amount: number
remark?: string
min_amount?: number
expire_at?: Date
}) {
return callByUser<Coupon>("/api/admin/coupon/create", data)
}
export async function updateCoupon(data: {
code: string
amount: number
remark?: string
min_amount?: number
expire_at?: Date
status?: number
}) {
return callByUser<Coupon>("/api/admin/coupon/update", data)
}
export async function deleteCoupon(id: number) {
return callByUser<Coupon>("/api/admin/coupon/remove", {
id,
})
}

View File

@@ -5,7 +5,7 @@ import type { ProductDiscount } from "@/models/product_discount"
import { callByUser } from "./base" import { callByUser } from "./base"
export async function getAllProductDiscount() { export async function getAllProductDiscount() {
return callByUser<ProductDiscount[]>("/api/admin/product/discount/all") return callByUser<ProductDiscount[]>("/api/admin/discount/all")
} }
export async function getPageProductDiscount(params: { export async function getPageProductDiscount(params: {
@@ -13,7 +13,7 @@ export async function getPageProductDiscount(params: {
size: number size: number
}) { }) {
return callByUser<PageRecord<ProductDiscount>>( return callByUser<PageRecord<ProductDiscount>>(
"/api/admin/product/discount/page", "/api/admin/discount/page",
params, params,
) )
} }
@@ -22,7 +22,7 @@ export async function createProductDiscount(data: {
name: string name: string
discount: string discount: string
}) { }) {
return callByUser<ProductDiscount>("/api/admin/product/discount/create", { return callByUser<ProductDiscount>("/api/admin/discount/create", {
name: data.name, name: data.name,
discount: Number(data.discount), discount: Number(data.discount),
}) })
@@ -33,7 +33,7 @@ export async function updateProductDiscount(data: {
name?: string name?: string
discount?: string discount?: string
}) { }) {
return callByUser<ProductDiscount>("/api/admin/product/discount/update", { return callByUser<ProductDiscount>("/api/admin/discount/update", {
id: data.id, id: data.id,
name: data.name, name: data.name,
discount: data.discount ? Number(data.discount) : undefined, discount: data.discount ? Number(data.discount) : undefined,
@@ -41,7 +41,7 @@ export async function updateProductDiscount(data: {
} }
export async function deleteProductDiscount(id: number) { export async function deleteProductDiscount(id: number) {
return callByUser<ProductDiscount>("/api/admin/product/discount/remove", { return callByUser<ProductDiscount>("/api/admin/discount/remove", {
id, id,
}) })
} }

View File

@@ -8,7 +8,7 @@ export async function getPageUsers(params: { page: number; size: number }) {
export async function bindAdmin(params: { export async function bindAdmin(params: {
id: number id: number
phone?: string account?: string
name?: string name?: string
identified?: boolean identified?: boolean
enabled?: boolean enabled?: boolean

View File

@@ -108,6 +108,10 @@ export default function Appbar(props: { admin: Admin }) {
batch: "提取记录", batch: "提取记录",
channel: "IP管理", channel: "IP管理",
pools: "IP池管理", pools: "IP池管理",
coupon: "优惠券",
admin: "管理员",
permissions: "权限列表",
discount: "折扣管理",
} }
return labels[path] || path return labels[path] || path

View File

@@ -0,0 +1,202 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Plus } from "lucide-react"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { createCoupon } from "@/actions/coupon"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
const schema = z.object({
code: z.string().min(1, "请输入优惠券名称"),
amount: z.string().min(1, "请输入优惠券金额"),
remark: z.string().optional(),
min_amount: z.string().optional(),
expire_at: z.string().optional(),
})
export function CreateDiscount(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: "",
remark: "",
amount: "0",
min_amount: "0",
expire_at: "",
},
mode: "onChange",
})
const { control, handleSubmit, reset } = form
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const payload = {
code: data.code,
amount: Number(data.amount),
remark: data?.remark,
min_amount: Number(data?.min_amount),
expire_at: data?.expire_at ? new Date(data.expire_at) : undefined,
}
const resp = await createCoupon(payload)
if (resp.success) {
reset()
toast.success("优惠券创建成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
return (
<Dialog
open={open}
onOpenChange={newOpen => {
setOpen(newOpen)
if (!newOpen) {
reset()
}
}}
>
<DialogTrigger asChild>
<Button>
<Plus />
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="discount-create" onSubmit={handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={control}
name="code"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="remark"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="amount"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input
type="number"
min={0}
step={5}
{...field}
onChange={e => {
const value = e.target.value
if (value === "" || parseFloat(value) >= 0) {
field.onChange(value)
}
}}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="min_amount"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input
type="number"
min={0}
step={5}
{...field}
onChange={e => {
const value = e.target.value
if (value === "" || parseFloat(value) >= 0) {
field.onChange(value)
}
}}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="expire_at"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-30 pt-2">:</FieldLabel>
<Input
type="date"
min={new Date().toISOString().split("T")[0]}
{...field}
/>
<div className="flex-1">
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="discount-create">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,148 @@
"use client"
import { format } from "date-fns"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { deleteCoupon, getPagCoupon } from "@/actions/coupon"
import { DataTable, useDataTable } from "@/components/data-table"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import type { Coupon } from "@/models/coupon"
import { CreateDiscount } from "./create"
import { UpdateCoupon } from "./update"
export default function CouponPage() {
const table = useDataTable((page, size) => getPagCoupon({ page, size }))
return (
<div className="flex flex-col gap-3">
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreateDiscount onSuccess={table.refresh} />
</div>
</div>
<Suspense>
<DataTable<Coupon>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "所属用户", accessorKey: "user_id" },
{ header: "代码", accessorKey: "code" },
{ header: "备注", accessorKey: "remark" },
{ 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>
},
},
{
header: "过期时间",
accessorKey: "expire_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "更新时间",
accessorKey: "updated_at",
cell: ({ row }) =>
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
},
{
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<UpdateCoupon
coupon={row.original}
onSuccess={table.refresh}
/>
<DeleteCoupon
coupon={row.original}
onSuccess={table.refresh}
/>
</div>
),
},
]}
/>
</Suspense>
</div>
)
}
function DeleteCoupon({
coupon,
onSuccess,
}: {
coupon: Coupon
onSuccess?: () => void
}) {
const [loading, setLoading] = useState(false)
const handleConfirm = async () => {
setLoading(true)
try {
const resp = await deleteCoupon(coupon.id)
if (resp.success) {
toast.success("删除成功")
onSuccess?.()
} else {
toast.error(resp.message ?? "删除失败")
}
} catch (error) {
const message = error instanceof Error ? error.message : error
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.code}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirm}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@@ -0,0 +1,247 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { updateCoupon } from "@/actions/coupon"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogClose,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import type { Coupon } from "@/models/coupon"
const schema = z.object({
code: z.string().min(1, "请输入优惠券名称"),
amount: z.string().min(1, "请输入优惠券金额"),
remark: z.string().optional(),
min_amount: z.string().optional(),
expire_at: z.string().optional(),
status: z.string().optional(),
})
export function UpdateCoupon(props: {
coupon: Coupon
onSuccess?: () => void
}) {
const [open, setOpen] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
code: props.coupon.code || "",
remark: props.coupon.remark || "",
amount: String(props.coupon.amount || 0),
min_amount: String(props.coupon.min_amount || 0),
expire_at: props.coupon.expire_at
? new Date(props.coupon.expire_at).toISOString().split("T")[0]
: "",
status: String(props.coupon.status || "0"),
},
mode: "onChange",
})
const { control, handleSubmit, reset } = form
const onSubmit = async (data: z.infer<typeof schema>) => {
try {
const payload = {
id: props.coupon.id,
code: data.code,
amount: Number(data.amount),
remark: data.remark,
min_amount: Number(data.min_amount),
expire_at: data.expire_at ? new Date(data.expire_at) : undefined,
status: Number(data.status),
}
const resp = await updateCoupon(payload)
if (resp.success) {
toast.success("优惠券修改成功")
props.onSuccess?.()
setOpen(false)
} else {
toast.error(resp.message)
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`接口请求错误: ${message}`)
}
}
const handleOpenChange = (value: boolean) => {
if (value) {
reset({
code: props.coupon.code || "",
remark: props.coupon.remark || "",
amount: String(props.coupon.amount || 0),
min_amount: String(props.coupon.min_amount || 0),
expire_at: props.coupon.expire_at
? new Date(props.coupon.expire_at).toISOString().split("T")[0]
: "",
status: String(props.coupon.status || "0"),
})
}
setOpen(value)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form id="coupon-update" onSubmit={handleSubmit(onSubmit)}>
<FieldGroup>
<Controller
control={control}
name="code"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="remark"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="amount"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input
type="number"
min={0}
step={5}
{...field}
onChange={e => {
const value = e.target.value
if (value === "" || parseFloat(value) >= 0) {
field.onChange(value)
}
}}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="min_amount"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input
type="number"
min={0}
step={5}
{...field}
onChange={e => {
const value = e.target.value
if (value === "" || parseFloat(value) >= 0) {
field.onChange(value)
}
}}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="expire_at"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Input
type="date"
min={new Date().toISOString().split("T")[0]}
{...field}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
<Controller
control={control}
name="status"
render={({ field, fieldState }) => (
<div className="flex items-start gap-4">
<FieldLabel className="w-28 pt-2">:</FieldLabel>
<div className="flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="请选择状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">使</SelectItem>
<SelectItem value="1">使</SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</div>
</div>
)}
/>
</FieldGroup>
</form>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost"></Button>
</DialogClose>
<Button type="submit" form="coupon-update">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -16,7 +16,7 @@ import {
Shield, Shield,
ShoppingBag, ShoppingBag,
SquarePercent, SquarePercent,
SquarePercentIcon, TicketPercent,
Users, Users,
} from "lucide-react" } from "lucide-react"
import Link from "next/link" import Link from "next/link"
@@ -187,9 +187,9 @@ export default function Navigation() {
{/* 客户 */} {/* 客户 */}
<NavGroup title="客户"> <NavGroup title="客户">
<NavItem href="/user" icon={Users} label="客户认领" /> <NavItem href="/user" icon={Users} label="客户认领" />
<NavItem href="/cust" icon={ContactRound} label="客户管理" />
<NavItem href="/trade" icon={Activity} label="交易明细" /> <NavItem href="/trade" icon={Activity} label="交易明细" />
<NavItem href="/billing" icon={DollarSign} label="账单详情" /> <NavItem href="/billing" icon={DollarSign} label="账单详情" />
<NavItem href="/cust" icon={ContactRound} label="客户管理" />
</NavGroup> </NavGroup>
<NavSeparator /> <NavSeparator />
@@ -202,6 +202,7 @@ export default function Navigation() {
icon={SquarePercent} icon={SquarePercent}
label="折扣管理" label="折扣管理"
/> />
<NavItem href="/coupon" icon={TicketPercent} label="优惠券" />
<NavItem href="/resources" icon={Package} label="套餐管理" /> <NavItem href="/resources" icon={Package} label="套餐管理" />
<NavItem href="/batch" icon={ClipboardList} label="提取记录" /> <NavItem href="/batch" icon={ClipboardList} label="提取记录" />
<NavItem href="/channel" icon={Code} label="IP管理" /> <NavItem href="/channel" icon={Code} label="IP管理" />

View File

@@ -26,7 +26,7 @@ import { useFetch } from "@/hooks/data"
import type { User } from "@/models/user" import type { User } from "@/models/user"
type FilterValues = { type FilterValues = {
phone?: string account?: string
name?: string name?: string
identified?: boolean identified?: boolean
enabled?: boolean enabled?: boolean
@@ -34,7 +34,7 @@ type FilterValues = {
} }
const filterSchema = z.object({ const filterSchema = z.object({
phone: z.string().optional(), account: z.string().optional(),
name: z.string().optional(), name: z.string().optional(),
identified: z.string().optional(), identified: z.string().optional(),
enabled: z.string().optional(), enabled: z.string().optional(),
@@ -49,7 +49,7 @@ export default function UserPage() {
const { control, handleSubmit, reset } = useForm<FormValues>({ const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema), resolver: zodResolver(filterSchema),
defaultValues: { defaultValues: {
phone: "", account: "",
name: "", name: "",
identified: "all", identified: "all",
enabled: "all", enabled: "all",
@@ -71,7 +71,7 @@ export default function UserPage() {
const onFilter = handleSubmit(data => { const onFilter = handleSubmit(data => {
const result: FilterValues = {} const result: FilterValues = {}
if (data.phone) result.phone = data.phone if (data.account) result.account = data.account
if (data.name) result.name = data.name if (data.name) result.name = data.name
if (data.identified && data.identified !== "all") if (data.identified && data.identified !== "all")
result.identified = data.identified === "1" result.identified = data.identified === "1"
@@ -89,15 +89,15 @@ export default function UserPage() {
<form onSubmit={onFilter} className="bg-white p-4"> <form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<Controller <Controller
name="phone" name="account"
control={control} control={control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field <Field
data-invalid={fieldState.invalid} data-invalid={fieldState.invalid}
className="w-40 flex-none" className="w-40 flex-none"
> >
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入手机号" /> <Input {...field} placeholder="请输入号" />
<FieldError>{fieldState.error?.message}</FieldError> <FieldError>{fieldState.error?.message}</FieldError>
</Field> </Field>
)} )}
@@ -111,8 +111,8 @@ export default function UserPage() {
data-invalid={fieldState.invalid} data-invalid={fieldState.invalid}
className="w-40 flex-none" className="w-40 flex-none"
> >
<FieldLabel></FieldLabel> <FieldLabel>--</FieldLabel>
<Input {...field} placeholder="请输入账户" /> <Input {...field} placeholder="请输入用户名-手机号-邮箱" />
<FieldError>{fieldState.error?.message}</FieldError> <FieldError>{fieldState.error?.message}</FieldError>
</Field> </Field>
)} )}
@@ -189,7 +189,7 @@ export default function UserPage() {
variant="outline" variant="outline"
onClick={() => { onClick={() => {
reset({ reset({
phone: "", account: "",
name: "", name: "",
identified: "all", identified: "all",
enabled: "all", enabled: "all",
@@ -260,7 +260,7 @@ export default function UserPage() {
}, },
{ header: "联系方式", accessorKey: "contact_wechat" }, { header: "联系方式", accessorKey: "contact_wechat" },
{ {
header: "管理员", header: "客户经理",
cell: ({ row }) => row.original.admin?.name || "-", cell: ({ row }) => row.original.admin?.name || "-",
}, },
{ {

12
src/models/coupon.ts Normal file
View File

@@ -0,0 +1,12 @@
export type Coupon = {
id: number
created_at: Date
updated_at: Date
user_id: number
code: string
remark: string
amount: number
min_amount: number
status: number
expire_at: Date
}