diff --git a/src/actions/resources.ts b/src/actions/resources.ts index dfa034a..cd98ebc 100644 --- a/src/actions/resources.ts +++ b/src/actions/resources.ts @@ -12,6 +12,7 @@ export interface ResourceListParams { created_at_start?: Date created_at_end?: Date expired?: boolean + resource_type?: number } export async function listResourceLong(params: ResourceListParams) { @@ -71,3 +72,7 @@ export async function ResourceShort(params: { params, ) } + +export async function listResource(params: ResourceListParams) { + return callByUser>("/api/admin/resource/page", params) +} diff --git a/src/app/(root)/_navigation/index.tsx b/src/app/(root)/_navigation/index.tsx index ce3d072..2ce5eb4 100644 --- a/src/app/(root)/_navigation/index.tsx +++ b/src/app/(root)/_navigation/index.tsx @@ -251,6 +251,12 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ label: "套餐管理", requiredScope: ScopeResourceRead, }, + { + href: "/resources-new", + icon: Package, + label: "新套餐管理", + requiredScope: ScopeResourceRead, + }, { href: "/batch", icon: ClipboardList, diff --git a/src/app/(root)/appbar.tsx b/src/app/(root)/appbar.tsx index d746895..c40dd1e 100644 --- a/src/app/(root)/appbar.tsx +++ b/src/app/(root)/appbar.tsx @@ -84,6 +84,7 @@ export default function Appbar(props: { admin: Admin }) { gateway: "网关列表", couponList: "已发放优惠券", new: "新建文章", + "resources-new": "新套餐管理", } if (labels[path]) return labels[path] diff --git a/src/app/(root)/billing/page.tsx b/src/app/(root)/billing/page.tsx index ca6e296..276e2ff 100644 --- a/src/app/(root)/billing/page.tsx +++ b/src/app/(root)/billing/page.tsx @@ -80,9 +80,6 @@ type FilterSchema = z.infer export default function BillingPage() { const searchParams = useSearchParams() - const innerNo = searchParams.get("inner_no") - const billNo = searchParams.get("bill_no") - const resourceNo = searchParams.get("resource_no") const [skuOptions, setSkuOptions] = useState([]) const [loading, setLoading] = useState(true) const [skuProductCode, setSkuProductCode] = useState( @@ -90,19 +87,37 @@ export default function BillingPage() { ) const router = useRouter() - const { control, handleSubmit, reset, getValues } = useForm({ - resolver: zodResolver(filterSchema), - defaultValues: { - bill_no: billNo || "", - inner_no: innerNo || "", - created_at_start: "", - created_at_end: "", - phone: "", - resource_no: resourceNo || "", - sku_code: "all", - product_code: "", - }, - }) + const { control, handleSubmit, reset, getValues, setValue } = + useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + bill_no: searchParams.get("bill_no") || "", + inner_no: searchParams.get("inner_no") || "", + created_at_start: "", + created_at_end: "", + phone: "", + resource_no: searchParams.get("resource_no") || "", + sku_code: "all", + product_code: "", + }, + }) + + useEffect(() => { + const params = { + resource_no: searchParams.get("resource_no"), + bill_no: searchParams.get("bill_no"), + inner_no: searchParams.get("inner_no"), + } + if (params.resource_no !== getValues("resource_no")) { + setValue("resource_no", params.resource_no || "") + } + if (params.bill_no !== getValues("bill_no")) { + setValue("bill_no", params.bill_no || "") + } + if (params.inner_no !== getValues("inner_no")) { + setValue("inner_no", params.inner_no || "") + } + }, [searchParams, setValue, getValues]) useEffect(() => { setLoading(true) diff --git a/src/app/(root)/resources-new/page.tsx b/src/app/(root)/resources-new/page.tsx new file mode 100644 index 0000000..e04f205 --- /dev/null +++ b/src/app/(root)/resources-new/page.tsx @@ -0,0 +1,630 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { format, isBefore, isSameDay } from "date-fns" +import { Box, Loader2, Timer } from "lucide-react" +import { useRouter, useSearchParams } from "next/navigation" +import { Suspense, useCallback, useMemo, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { listResource, updateResource } from "@/actions/resources" +import { DataTable, useDataTable } from "@/components/data-table" +import { Page } from "@/components/page" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Field, + 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 { Resources } from "@/models/resources" + +const filterSchema = z + .object({ + user_phone: z.string().optional(), + resource_no: z.string().optional(), + status: z.string().optional(), + type: z.string().optional(), + created_at_start: z.string().optional(), + created_at_end: z.string().optional(), + expired: z.string().optional(), + resource_type: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (data.created_at_start && data.created_at_end) { + const start = new Date(data.created_at_start) + const end = new Date(data.created_at_end) + + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "结束时间不能早于开始时间", + path: ["created_at_end"], + }) + } + } + }) + +type FormValues = z.infer + +interface FilterParams { + user_phone?: string + resource_no?: string + active?: boolean + mode?: number + created_at_start?: Date + created_at_end?: Date + expired?: boolean + resource_type?: number +} + +// 获取资源类型 +function getResourceType(resource: Resources): number { + if ("short" in resource && resource.short) { + return resource.short.type + } + if ("long" in resource && resource.long) { + return resource.long.type + } + return resource.type +} + +// 获取资源详情对象 +function getResourceDetail(resource: Resources) { + if ("short" in resource && resource.short) { + return resource.short + } + if ("long" in resource && resource.long) { + return resource.long + } + return null +} + +// 获取过期时间 +function getExpireAt(resource: Resources): Date | null | undefined { + if ("short" in resource && resource.short) { + return resource.short.expire_at + } + if ("long" in resource && resource.long) { + return resource.long.expire_at + } + return undefined +} + +function getName(resource: Resources): string | null | undefined { + if ("short" in resource && resource.short) { + return resource.short.sku?.name + } + if ("long" in resource && resource.long) { + return resource.long.sku?.name + } + return undefined +} + +// 获取最近使用时间 +function getLastAt(resource: Resources): Date | null | undefined { + if ("short" in resource && resource.short) { + return resource.short.last_at + } + if ("long" in resource && resource.long) { + return resource.long.last_at + } + return undefined +} + +// 资源类型徽章 +function ResourceTypeBadge({ resource }: { resource: Resources }) { + const type = getResourceType(resource) + if (type === 1) { + return ( +
+ + 包时 +
+ ) + } + if (type === 2) { + return ( +
+ + 包量 +
+ ) + } + return null +} + +// 过期徽章 +function ExpireBadge({ expireAt }: { expireAt: Date | null | undefined }) { + if (!expireAt) return null + if (isBefore(expireAt, new Date())) { + return 过期 + } + return null +} + +// 格式化日期 +function formatDateTime(date: Date | null | undefined) { + if (!date) return "" + return format(date, "yyyy-MM-dd HH:mm:ss") +} + +// 计算今日使用量 +function getTodayUsage(lastAt: Date | null | undefined, daily: number) { + if (lastAt && isSameDay(lastAt, new Date())) { + return daily + } + return 0 +} + +export default function ResourcesPage() { + return ( + + + + ) +} + +function ResourceList() { + const searchParams = useSearchParams() + const resourceNo = searchParams.get("resource_no") + const listFn = listResource + const [updatingId, setUpdatingId] = useState(null) + const router = useRouter() + const { control, handleSubmit, reset, getValues } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + user_phone: "", + resource_no: resourceNo || "", + status: "all", + type: "all", + created_at_start: "", + created_at_end: "", + expired: "all", + resource_type: "all", + }, + }) + + const fetchResources = useCallback( + (page: number, size: number) => { + const result: FilterParams = {} + const filters = getValues() + if (filters.user_phone?.trim()) + result.user_phone = filters.user_phone.trim() + if (filters.resource_no?.trim()) + result.resource_no = filters.resource_no.trim() + if (filters.status && filters.status !== "all") { + result.active = filters.status === "0" + } + if (filters.type && filters.type !== "all") { + result.mode = Number(filters.type) + } + if (filters.resource_type && filters.resource_type !== "all") { + result.resource_type = Number(filters.resource_type) + } + if (filters.expired && filters.expired !== "all") { + result.expired = filters.expired === "1" + } + if (filters.created_at_start) + result.created_at_start = new Date(filters.created_at_start) + if (filters.created_at_end) + result.created_at_end = new Date(filters.created_at_end) + return listFn({ page, size, ...result }) + }, + [listFn, getValues], + ) + + const table = useDataTable(fetchResources) + + const handleStatusChange = useCallback( + async (resource: Resources, newStatusValue: string) => { + const newActive = newStatusValue === "0" + if (newActive === resource.active) return + setUpdatingId(resource.id) + try { + await updateResource({ + id: resource.id, + active: newActive, + }) + toast.success("更新成功", { + description: `资源状态已更新为${newActive ? "启用" : "禁用"}`, + }) + table.refresh() + } catch (error) { + console.error("更新状态失败:", error) + toast.error("更新失败", { + description: error instanceof Error ? error.message : "请稍后重试", + }) + } finally { + setUpdatingId(null) + } + }, + [table], + ) + const handleCheckipChange = useCallback( + async (resource: Resources) => { + const newCheckip = !resource.checkip + setUpdatingId(resource.id) + try { + await updateResource({ + id: resource.id, + checkip: newCheckip, + }) + toast.success("更新成功", { + description: `IP检查已${newCheckip ? "启用IP检查" : "停用IP检查"}`, + }) + table.refresh() + } catch (error) { + console.error("更新IP检查状态失败:", error) + toast.error("更新失败", { + description: error instanceof Error ? error.message : "请稍后重试", + }) + } finally { + setUpdatingId(null) + } + }, + [table], + ) + const onFilter = handleSubmit(() => { + table.pagination.onPageChange(1) + }) + + const columns = useMemo( + () => [ + { + header: "会员号", + accessorFn: (row: Resources) => row.user?.phone || "", + }, + { + header: "套餐", + cell: ({ row }: { row: { original: Resources } }) => { + const resourceNo = row.original.resource_no + const name = getName(row.original) + const expireAt = getExpireAt(row.original) + return ( +
+
{name}
+
+ + + {resourceNo} + + + { + router.push(`/billing?resource_no=${resourceNo}`) + }} + > + 账单详情 + + { + router.push(`/batch?resource_no=${resourceNo}`) + }} + > + 提取记录 + + { + router.push(`/channel?resource_no=${resourceNo}`) + }} + > + IP管理 + + + + +
+
+ ) + }, + }, + { + header: "产品", + cell: ({ row }: { row: { original: Resources } }) => { + const isLong = row.original.code === "long" + return {isLong ? "长效" : "短效"} + }, + }, + { + header: "类型", + cell: ({ row }: { row: { original: Resources } }) => { + return + }, + }, + { + header: "IP时效", + cell: ({ row }: { row: { original: Resources } }) => { + const detail = getResourceDetail(row.original) + const live = detail?.live + if (live === undefined) return "-" + return {`${live}分钟`} + }, + }, + { + header: "使用情况", + cell: ({ row }: { row: { original: Resources } }) => { + const detail = getResourceDetail(row.original) + const type = getResourceType(row.original) + + if (!detail) return - + + if (type === 1) { + // 包时 + const todayUsage = getTodayUsage(detail.last_at, detail.daily || 0) + return ( +
+ + {todayUsage}/{detail.quota} + +
+ ) + } else { + return ( +
+ {detail.used < detail.quota ? ( + 正常 + ) : ( + 已用完 + )} + | + + {detail.used}/{detail.quota} + +
+ ) + } + }, + }, + { + header: "最近使用时间", + cell: ({ row }: { row: { original: Resources } }) => { + const lastAt = getLastAt(row.original) + return lastAt ? formatDateTime(lastAt) : "暂未使用" + }, + }, + { + header: "开通时间", + cell: ({ row }: { row: { original: Resources } }) => { + return formatDateTime(row.original.created_at) + }, + }, + { + header: "到期时间", + cell: ({ row }: { row: { original: Resources } }) => { + return formatDateTime(getExpireAt(row.original)) + }, + }, + { + id: "action", + meta: { pin: "right" }, + header: "操作", + cell: ({ row }: { row: { original: Resources } }) => { + const resource = row.original + const isLoading = updatingId === resource.id + const currentActive = resource.active + return ( +
+ + {isLoading && ( + + )} + +
+ ) + }, + }, + ], + [updatingId, handleStatusChange, handleCheckipChange, router], + ) + + return ( +
+
+
+ ( + + 会员号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 产品 + + {fieldState.error?.message} + + )} + /> + ( + + 类型 + + {fieldState.error?.message} + + )} + /> + ( + + 状态 + + {fieldState.error?.message} + + )} + /> + ( + + 是否过期 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + +
+ + 加载中...
}> + + {...table} + columns={columns} + classNames={{ + root: "flex-auto overflow-hidden", + }} + /> + + + ) +} diff --git a/src/app/(root)/trade/page.tsx b/src/app/(root)/trade/page.tsx index 706f427..d5a5449 100644 --- a/src/app/(root)/trade/page.tsx +++ b/src/app/(root)/trade/page.tsx @@ -439,7 +439,6 @@ function CheckOrder(props: { trade: Trade; onSuccess: () => void }) { trade_no: props.trade.inner_no, method: Number(props.trade.method), }) - console.log(res, "res") if (res.success) { setData(res.data) @@ -486,7 +485,6 @@ function CheckOrder(props: { trade: Trade; onSuccess: () => void }) { method: Number(props.trade.method), user_id: Number(props.trade.user_id), }) - console.log(result, "resultresultresult") if (result.success) { toast.success("补到余额完成") @@ -510,7 +508,6 @@ function CheckOrder(props: { trade: Trade; onSuccess: () => void }) { method: Number(props.trade.method), user_id: Number(props.trade.user_id), }) - console.log(result, "resultresultresult") if (result.success) { toast.success("取消订单完成") diff --git a/src/models/resources.ts b/src/models/resources.ts index c3456a8..22a1fef 100644 --- a/src/models/resources.ts +++ b/src/models/resources.ts @@ -11,6 +11,7 @@ type ResourceBase = { deleted_at: Date | null user: User checkip: boolean + code: string } type ResourceShort = {