Files
admin/src/app/(root)/coupon/issue.tsx

347 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { Suspense, useCallback, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import z from "zod"
import { issueCoupon } from "@/actions/coupon"
import { getPageUser } from "@/actions/user"
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 { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import type { Coupon } from "@/models/coupon"
import type { User } from "@/models/user"
interface UserQueryParams {
account?: string
name?: string
}
const filterSchema = z.object({
phone: z.string().optional(),
name: z.string().optional(),
})
type FormValues = z.infer<typeof filterSchema>
export function IssueCoupon(props: { coupon: Coupon; onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const [userList, setUserList] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [currentFilters, setCurrentFilters] = useState<UserQueryParams>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
phone: "",
name: "",
},
})
const fetchUsers = useCallback(async (filters: UserQueryParams = {}) => {
setLoading(true)
try {
setOpen(true)
const res = await getPageUser(filters)
if (res.success) {
const data = Array.isArray(res.data) ? res.data : [res.data]
setUserList(data)
} else {
toast.error(res.message || "获取用户失败")
setUserList([])
}
} catch (error) {
const message = error instanceof Error ? error.message : error
toast.error(`获取用户失败: ${message}`)
} finally {
setLoading(false)
}
}, [])
const onFilter = handleSubmit((data: FormValues) => {
const params: UserQueryParams = {}
if (data.phone?.trim()) params.account = data.phone.trim()
if (data.name?.trim()) params.name = data.name.trim()
if (Object.keys(params).length === 0) {
toast.info("请至少输入一个搜索条件")
return
}
setCurrentFilters(params)
fetchUsers(params)
})
const handleReset = useCallback(() => {
reset()
setCurrentFilters({})
setUserList([])
}, [reset])
const handleIssueCoupon = useCallback(
async (users: User[], coupon: Coupon) => {
console.log(coupon, "couponcouponcoupon")
const targetUser = users[0]
if (!targetUser || !targetUser.id) {
toast.error("用户信息无效")
return
}
if (!coupon || !coupon.id) {
toast.error("优惠券信息无效")
return
}
if (coupon.status !== 1) {
toast.error("优惠券不可用,请检查优惠券状态")
return
}
try {
const result = await issueCoupon({
coupon_id: coupon.id,
user_id: targetUser.id,
})
console.log({
coupon_id: coupon.id,
user_id: targetUser.id,
})
console.log(result, "resultresultresultresultresult")
if (result.success) {
toast.success(
`成功发放优惠券给用户 ${targetUser.phone || targetUser.username}`,
)
setOpen(false)
handleReset()
props.onSuccess?.()
} else {
toast.error("发放失败,请稍后重试")
}
} catch (error) {
const message = error instanceof Error ? error.message : "发放失败"
toast.error(`发放优惠券失败: ${message}`)
}
},
[props.onSuccess, handleReset],
)
return (
<Dialog
open={open}
onOpenChange={newOpen => {
setOpen(newOpen)
if (!newOpen) {
handleReset()
}
}}
>
<DialogTrigger asChild>
<Button size="sm" variant="secondary">
</Button>
</DialogTrigger>
<DialogContent
className="max-h-[85vh] overflow-y-auto"
style={{ width: "auto", minWidth: "800px", maxWidth: "90vw" }}
>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={onFilter} className="bg-card rounded-lg">
<div className="flex items-end gap-4">
<Controller
name="phone"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-40 flex">
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入手机号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Button type="button" variant="outline" onClick={handleReset}>
</Button>
<Button type="submit"></Button>
</div>
</form>
<Suspense fallback={<div className="py-8 text-center">...</div>}>
{loading ? (
<div className="py-8 text-center">...</div>
) : userList.length === 0 ? (
<div className="py-8 text-center text-gray-500"></div>
) : (
<div className="space-y-4 max-h-[60vh] overflow-y-auto pr-1">
{userList.map(user => (
<div
key={user.id}
className="border rounded-lg overflow-hidden hover:shadow-md transition-shadow"
>
<div className="bg-gray-50 px-4 py-3 border-b flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-3">
<span className="font-medium text-base">
{user.phone || "未绑定手机"}
</span>
<div className="flex gap-2">
<Badge
variant={user.id_type === 1 ? "default" : "secondary"}
className={
user.id_type === 1
? "bg-green-100 text-green-700 hover:bg-green-100"
: "bg-gray-100 text-gray-600 hover:bg-gray-100"
}
>
{user.id_type === 1 ? "✓ 已认证" : "○ 未认证"}
</Badge>
<Badge
variant={
user.status === 1 ? "default" : "destructive"
}
className={
user.status === 1
? "bg-green-100 text-green-700 hover:bg-green-100"
: "bg-red-100 text-red-700 hover:bg-red-100"
}
>
{user.status === 1 ? "正常" : "禁用"}
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500">
:
<span
className={`ml-1 font-semibold ${
Number(user.balance) > 0
? "text-green-600"
: "text-orange-600"
}`}
>
¥{(Number(user.balance) || 0).toFixed(2)}
</span>
</span>
</div>
</div>
<div className="p-4">
<div className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.name || ""}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.username || ""}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.admin?.name || ""}
</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{(() => {
const sourceMap: Record<number, string> = {
0: "官网注册",
1: "管理员添加",
2: "代理商注册",
3: "代理商添加",
}
return sourceMap[user.source] ?? "官网注册"
})()}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.contact_wechat || ""}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.created_at
? format(new Date(user.created_at), "yyyy-MM-dd")
: ""}
</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
</span>
<span className="text-gray-900">
{user.last_login
? format(
new Date(user.last_login),
"yyyy-MM-dd HH:mm",
)
: "-"}
</span>
</div>
<div className="text-sm">
<span className="text-gray-500 w-20 inline-block">
IP
</span>
<span className="text-gray-900">
{user.last_login_ip || "-"}
</span>
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
</Suspense>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" onClick={handleReset}>
</Button>
</DialogClose>
<Button
type="button"
onClick={() => handleIssueCoupon(userList, props.coupon)}
disabled={!userList || userList.length === 0}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}