diff --git a/src/actions/batch.ts b/src/actions/batch.ts index 3775f89..ff1dee4 100644 --- a/src/actions/batch.ts +++ b/src/actions/batch.ts @@ -16,3 +16,19 @@ export async function getPageBatch(params: { }) { return callByUser>("/api/admin/batch/page", params) } + +export async function getBatch(params: { + page: number + size: number + user_id: number + user_phone?: string + resource_no?: string + batch_no?: string + prov?: string + city?: string + isp?: string + created_at_start?: Date + created_at_end?: Date +}) { + return callByUser>("/api/admin/batch/page/of-user", params) +} diff --git a/src/actions/bill.ts b/src/actions/bill.ts index f6390f9..05f030c 100644 --- a/src/actions/bill.ts +++ b/src/actions/bill.ts @@ -26,3 +26,19 @@ export async function getSkuList(params: { product_code?: ProductCode }) { } return callByUser("/api/admin/product/sku/all", requestParams) } + +export async function getBill(params: { + page: number + size: number + user_id: number + bill_no?: string + user_phone?: string + trade_inner_no?: string + resource_no?: string + sku_code?: string + product_code?: string + created_at_start?: Date + created_at_end?: Date +}) { + return callByUser>("/api/admin/bill/page/of-user", params) +} diff --git a/src/actions/channel.ts b/src/actions/channel.ts index fbb4161..b469cd8 100644 --- a/src/actions/channel.ts +++ b/src/actions/channel.ts @@ -16,3 +16,21 @@ export async function getPageChannel(params: { }) { return callByUser>("/api/admin/channel/page", params) } +export async function getChannel(params: { + page: number + size: number + user_id: number + batch_no?: string + user_phone?: string + resource_no?: string + proxy_port?: number + proxy_host?: string + node_ip?: string + expired_at_start?: Date + expired_at_end?: Date +}) { + return callByUser>( + "/api/admin/channel/page/of-user", + params, + ) +} diff --git a/src/actions/resources.ts b/src/actions/resources.ts index a18c80a..6811bb9 100644 --- a/src/actions/resources.ts +++ b/src/actions/resources.ts @@ -31,3 +31,17 @@ export async function listResourceShort(params: ResourceListParams) { export async function updateResource(data: { id: number; active?: boolean }) { return callByUser("/api/admin/resource/update", data) } + +export async function ResourceLong(params: ResourceListParams) { + return callByUser>( + "/api/admin/resource/long/page/of-user", + params, + ) +} + +export async function ResourceShort(params: ResourceListParams) { + return callByUser>( + "/api/admin/resource/short/page/of-user", + params, + ) +} diff --git a/src/actions/trade.ts b/src/actions/trade.ts index a5e1202..75965e8 100644 --- a/src/actions/trade.ts +++ b/src/actions/trade.ts @@ -15,3 +15,18 @@ export async function getPageTrade(params: { }) { return callByUser>("/api/admin/trade/page", params) } + +export async function getTrade(params: { + page: number + size: number + user_id: number + user_phone?: string + inner_no?: string + method?: number + platform?: number + status?: number + created_at_start?: Date + created_at_end?: Date +}) { + return callByUser>("/api/admin/trade/page/of-user", params) +} diff --git a/src/actions/user.ts b/src/actions/user.ts index 0ceffd6..4d9077a 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -23,6 +23,6 @@ export async function bindAdmin(params: { }) } -export async function getPageUser(params: object) { +export async function getPageUser(params: { phone?: string; name?: string }) { return callByUser("/api/admin/user/get", params) } diff --git a/src/app/(root)/appbar.tsx b/src/app/(root)/appbar.tsx index ac88f61..e6fea67 100644 --- a/src/app/(root)/appbar.tsx +++ b/src/app/(root)/appbar.tsx @@ -45,14 +45,15 @@ export default function Appbar(props: { admin: Admin }) { return () => document.removeEventListener("mousedown", handleClickOutside) }, []) - // 根据路径生成面包屑 const generateBreadcrumbs = () => { const paths = pathname.split("/").filter(Boolean) - + const hiddenSegments = ["client"] + const filteredPaths = paths.filter(path => !hiddenSegments.includes(path)) const breadcrumbs = [ { path: "/", label: "首页" }, - ...paths.map((path, index) => { - const url = `/${paths.slice(0, index + 1).join("/")}` + ...filteredPaths.map((path, index) => { + const originalIndex = paths.findIndex(p => p === path) + const url = `/${paths.slice(0, originalIndex + 1).join("/")}` const label = getBreadcrumbLabel(path) return { path: url, label } }), @@ -67,7 +68,7 @@ export default function Appbar(props: { admin: Admin }) { content: "内容管理", articles: "文章管理", media: "媒体库", - user: "用户管理", + user: "客户认领", roles: "角色权限", settings: "系统设置", logs: "系统日志", diff --git a/src/app/(root)/batch/page.tsx b/src/app/(root)/batch/page.tsx index 0fde74a..851a68b 100644 --- a/src/app/(root)/batch/page.tsx +++ b/src/app/(root)/batch/page.tsx @@ -24,7 +24,7 @@ import { import type { Batch } from "@/models/batch" type APIFilterParams = { - phone?: string + user_phone?: string batch_no?: string resource_no?: string prov?: string @@ -84,7 +84,7 @@ export default function BatchPage() { const onFilter = handleSubmit(data => { const result: APIFilterParams = {} - if (data.user_phone?.trim()) result.phone = data.user_phone.trim() + if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim() if (data.batch_no?.trim()) result.batch_no = data.batch_no.trim() if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim() if (data.prov?.trim()) result.prov = data.prov.trim() diff --git a/src/app/(root)/client/batch/page.tsx b/src/app/(root)/client/batch/page.tsx new file mode 100644 index 0000000..f1aae1d --- /dev/null +++ b/src/app/(root)/client/batch/page.tsx @@ -0,0 +1,263 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { useSearchParams } from "next/navigation" +import { Suspense, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" +import { getBatch } from "@/actions/batch" +import { DataTable, useDataTable } from "@/components/data-table" +import { Button } from "@/components/ui/button" +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 { Batch } from "@/models/batch" + +type APIFilterParams = { + user_id: number + phone?: string + batch_no?: string + resource_no?: string + prov?: string + city?: string + isp?: string + created_at_start?: Date + created_at_end?: Date +} + +const filterSchema = z + .object({ + user_phone: z.string().optional(), + resource_no: z.string().optional(), + batch_no: z.string().optional(), + prov: z.string().optional(), + city: z.string().optional(), + isp: z.string().optional(), + created_at_start: z.string().optional(), + created_at_end: 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 FilterSchema = z.infer + +export default function BatchPage() { + const searchParams = useSearchParams() + const userId = searchParams.get("userId") + const [filters, setFilters] = useState({ + user_id: Number(userId), + }) + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + resource_no: "", + batch_no: "", + prov: "", + city: "", + isp: "all", + created_at_start: "", + created_at_end: "", + }, + }) + + const table = useDataTable((page, size) => + getBatch({ page, size, ...filters }), + ) + + const onFilter = handleSubmit(data => { + const result: APIFilterParams = { + user_id: Number(userId), + } + if (data.user_phone?.trim()) result.phone = data.user_phone.trim() + if (data.batch_no?.trim()) result.batch_no = data.batch_no.trim() + if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim() + if (data.prov?.trim()) result.prov = data.prov.trim() + if (data.city?.trim()) result.city = data.city.trim() + if (data.isp && data.isp !== "all") result.isp = data.isp + if (data.created_at_start) + result.created_at_start = new Date(data.created_at_start) + if (data.created_at_end) + result.created_at_end = new Date(data.created_at_end) + + setFilters(result) + table.pagination.onPageChange(1) + }) + + return ( +
+ {/* 筛选表单 */} +
+
+ ( + + 批次号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 省份 + + {fieldState.error?.message} + + )} + /> + ( + + 城市 + + {fieldState.error?.message} + + )} + /> + ( + + 运营商 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + +
+ + Loading...
}> + + {...table} + columns={[ + { + header: "会员号", + accessorFn: row => row.user?.phone || "-", + }, + { header: "套餐号", accessorKey: "resource.resource_no" }, + { header: "批次号", accessorKey: "batch_no" }, + { header: "省份", accessorKey: "prov" }, + { header: "城市", accessorKey: "city" }, + { header: "提取IP", accessorKey: "ip" }, + { header: "运营商", accessorKey: "isp" }, + { header: "提取数量", accessorKey: "count" }, + { + header: "提取时间", + accessorKey: "time", + cell: ({ row }) => + format(new Date(row.original.time), "yyyy-MM-dd HH:mm"), + }, + ]} + /> + + + ) +} diff --git a/src/app/(root)/client/billing/page.tsx b/src/app/(root)/client/billing/page.tsx new file mode 100644 index 0000000..a3c7aad --- /dev/null +++ b/src/app/(root)/client/billing/page.tsx @@ -0,0 +1,452 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { CreditCard, Wallet } from "lucide-react" +import { useSearchParams } from "next/navigation" +import { Suspense, useEffect, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { getBill, getSkuList } from "@/actions/bill" +import { DataTable, useDataTable } from "@/components/data-table" +import { Button } from "@/components/ui/button" +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 { ProductCode } from "@/lib/base" +import type { Billing } from "@/models/billing" + +type FilterValues = { + user_id: number + bill_no?: string + user_phone?: string + trade_inner_no?: string + resource_no?: string + sku_code?: string + product_code?: string + created_at_start?: Date + created_at_end?: Date +} + +type SkuOption = { + resource_code: string + resource_name: string +} + +const filterSchema = z + .object({ + phone: z + .string() + .optional() + .transform(val => val?.trim()), + bill_no: z + .string() + .optional() + .transform(val => val?.trim()), + resource_no: z.string().optional(), + inner_no: z.string().optional(), + created_at_start: z.string().optional(), + created_at_end: z.string().optional(), + product_code: z.string().optional(), + sku_code: 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 FilterSchema = z.infer + +export default function BillingPage() { + const searchParams = useSearchParams() + const userId = searchParams.get("userId") + const [filters, setFilters] = useState({ + user_id: Number(userId), + }) + const [skuOptions, setSkuOptions] = useState([]) + const [loading, setLoading] = useState(true) + const [skuProductCode, setSkuProductCode] = useState( + ProductCode.All, + ) + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + bill_no: "", + inner_no: "", + created_at_start: "", + created_at_end: "", + phone: "", + resource_no: "", + sku_code: "all", + product_code: "", + }, + }) + + useEffect(() => { + setLoading(true) + getSkuList({ + product_code: skuProductCode, + }) + .then(resp => { + if (!resp.success) { + throw new Error(resp.message) + } + setSkuOptions( + resp.data.map(sku => ({ + resource_code: sku.code, + resource_name: sku.name, + })), + ) + }) + .catch(e => { + console.error("获取套餐类型失败:", e) + toast.error( + `获取套餐类型失败:${e instanceof Error ? e.message : String(e)}`, + ) + setSkuOptions([]) + }) + .finally(() => { + setLoading(false) + }) + }, [skuProductCode]) + + const table = useDataTable((page, size) => + getBill({ page, size, ...filters }), + ) + + const onFilter = handleSubmit(data => { + const result: FilterValues = { + user_id: Number(userId), + } + if (data.phone?.trim()) result.user_phone = data.phone.trim() + if (data.inner_no?.trim()) result.trade_inner_no = data.inner_no.trim() + if (data.bill_no?.trim()) result.bill_no = data.bill_no.trim() + if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim() + if (data.product_code && data.product_code !== ProductCode.All) { + result.product_code = data.product_code + } + if (data.sku_code && data.sku_code !== "all") { + result.sku_code = data.sku_code + } + if (data.created_at_start) + result.created_at_start = new Date(data.created_at_start) + if (data.created_at_end) + result.created_at_end = new Date(data.created_at_end) + + setFilters(result) + table.pagination.onPageChange(1) + }) + + return ( +
+
+
+ ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 账单号 + + {fieldState.error?.message} + + )} + /> + ( + + 订单号 + + {fieldState.error?.message} + + )} + /> + + ( + + 产品类型 + + + )} + /> + + ( + + 套餐类型 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + +
+ + + + {...table} + columns={[ + { header: "套餐号", accessorKey: "resource.resource_no" }, + { + header: "账单详情", + accessorKey: "info", + cell: ({ row }) => { + const bill = row.original + + return ( +
+
+ {bill.type === 1 && ( +
+ + 消费 +
+ )} + {bill.type === 2 && ( +
+ + 退款 +
+ )} + {bill.type === 3 && ( +
+ + 充值 +
+ )} +
+
{bill.info}
+
+ ) + }, + }, + { + header: "应付金额", + accessorKey: "amount", + cell: ({ row }) => { + const amount = + typeof row.original.amount === "string" + ? parseFloat(row.original.amount) + : row.original.amount || 0 + return ( +
+ 0 ? "text-green-500" : "text-orange-500" + } + > + ¥{amount.toFixed(2)} + +
+ ) + }, + }, + { + header: "实付金额", + accessorKey: "actual", + cell: ({ row }) => { + const actual = + typeof row.original.actual === "string" + ? parseFloat(row.original.actual) + : row.original.actual || 0 + return ( +
+ 0 ? "text-green-500" : "text-orange-500" + } + > + ¥{actual.toFixed(2)} + +
+ ) + }, + }, + { header: "账单号", accessorKey: "bill_no" }, + { + header: "订单号", + accessorKey: "trade.inner_no", + cell: ({ row }) => { + const bill = row.original + + return ( +
+
+ {bill.trade?.acquirer === 1 && ( +
+ + 支付宝 +
+ )} + {bill.trade?.acquirer === 2 && ( +
+ + 微信 +
+ )} + {bill.trade?.acquirer === 3 && ( +
+ + 银联 +
+ )} + {!bill.trade?.acquirer && ( +
+ + 余额 +
+ )} +
+
{bill.trade?.inner_no}
+
+ ) + }, + }, + { + header: "创建时间", + accessorKey: "created_at", + cell: ({ row }) => + format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"), + }, + ]} + /> +
+
+ ) +} diff --git a/src/app/(root)/client/channel/page.tsx b/src/app/(root)/client/channel/page.tsx new file mode 100644 index 0000000..c96d020 --- /dev/null +++ b/src/app/(root)/client/channel/page.tsx @@ -0,0 +1,320 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { useSearchParams } from "next/navigation" +import { Suspense, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" +import { getChannel } from "@/actions/channel" +import { DataTable, useDataTable } from "@/components/data-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Field, + FieldError, + FieldGroup, + FieldLabel, +} from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import type { Channel } from "@/models/channel" + +type FilterValues = { + user_id: number + batch_no?: string + user_phone?: string + resource_no?: string + proxy_host?: string + proxy_port?: number + node_ip?: string + expired_at_start?: Date + expired_at_end?: Date +} + +const filterSchema = z + .object({ + batch_no: z.string().optional(), + user_phone: z.string().optional(), + resource_no: z.string().optional(), + proxy_host: z.string().optional(), + proxy_port: z.string().optional(), + node_ip: z.string().optional(), + expired_at_start: z.string().optional(), + expired_at_end: z.string().optional(), + }) + .superRefine((data, ctx) => { + if (data.expired_at_start && data.expired_at_end) { + const start = new Date(data.expired_at_start) + const end = new Date(data.expired_at_end) + + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "结束时间不能早于开始时间", + path: ["expired_at_end"], + }) + } + } + }) + +type FilterSchema = z.infer + +const ispMap: Record = { + 1: "电信", + 2: "联通", + 3: "移动", +} + +export default function ChannelPage() { + const searchParams = useSearchParams() + const userId = searchParams.get("userId") + const [filters, setFilters] = useState({ + user_id: Number(userId), + }) + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + batch_no: "", + user_phone: "", + resource_no: "", + proxy_port: "", + proxy_host: "", + node_ip: "", + expired_at_start: "", + expired_at_end: "", + }, + }) + + const table = useDataTable((page, size) => + getChannel({ page, size, ...filters }), + ) + + const onFilter = handleSubmit(data => { + const result: FilterValues = { + user_id: Number(userId), + } + if (data.batch_no?.trim()) result.batch_no = data.batch_no.trim() + if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim() + if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim() + if (data.proxy_host?.trim()) result.proxy_host = data.proxy_host.trim() + if (data.proxy_port?.trim()) + result.proxy_port = Number(data.proxy_port.trim()) + if (data.node_ip?.trim()) result.node_ip = data.node_ip.trim() + if (data.expired_at_start) + result.expired_at_start = new Date(data.expired_at_start) + if (data.expired_at_end) + result.expired_at_end = new Date(data.expired_at_end) + setFilters(result) + table.pagination.onPageChange(1) + }) + + return ( +
+
+
+ ( + + 批次号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 代理IP + + {fieldState.error?.message} + + )} + /> + ( + + 代理端口 + + {fieldState.error?.message} + + )} + /> + ( + + 节点 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + +
+ + + + {...table} + columns={[ + { + header: "会员号", + accessorFn: row => row.user?.phone || "-", + }, + { header: "套餐号", accessorKey: "resource.resource_no" }, + { header: "批次号", accessorKey: "batch_no" }, + { + header: "节点", + accessorFn: row => row.ip || row.edge_ref || row.edge_id, + }, + + { + header: "自动配置", + cell: ({ row }) => { + const prov = row.original.filter_prov + const city = row.original.filter_city + const isp = row.original.filter_isp + const parts = [] + if (prov && prov !== "all") parts.push(prov) + if (city && city !== "all") parts.push(city) + if (isp && isp !== "all") { + parts.push(ispMap[Number(isp)] || isp) + } + return ( +
+ {parts.length > 0 ? parts.join(" / ") : "不限"} +
+ ) + }, + }, + { + header: "网关地址", + accessorKey: "host", + cell: ({ row }) => { + return ( + + {row.original.host}:{row.original.port} + + ) + }, + }, + { + header: "认证方式", + cell: ({ row }) => { + const channel = row.original + const hasWhitelist = + channel.whitelists && channel.whitelists.trim() !== "" + const hasAuth = channel.username && channel.password + return ( +
+ {hasWhitelist ? ( +
+ 白名单 +
+ {channel.whitelists.split(",").map(ip => ( + + {ip.trim()} + + ))} +
+
+ ) : hasAuth ? ( +
+ 账号密码 + + {channel.username}:{channel.password} + +
+ ) : ( + 无认证 + )} +
+ ) + }, + }, + { + header: "过期时间", + accessorKey: "expired_at", + cell: ({ row }) => + format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"), + }, + ]} + /> +
+
+ ) +} diff --git a/src/app/(root)/userQuery/page.tsx b/src/app/(root)/client/cust/page.tsx similarity index 71% rename from src/app/(root)/userQuery/page.tsx rename to src/app/(root)/client/cust/page.tsx index 69d6a4f..2bd1a5a 100644 --- a/src/app/(root)/userQuery/page.tsx +++ b/src/app/(root)/client/cust/page.tsx @@ -1,7 +1,8 @@ "use client" import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" -import { Suspense, useCallback, useEffect, useState } from "react" +import { useRouter } from "next/navigation" +import { Suspense, useCallback, useState } from "react" import { Controller, useForm } from "react-hook-form" import { toast } from "sonner" import { z } from "zod" @@ -21,12 +22,20 @@ import { } from "@/components/ui/field" import { Input } from "@/components/ui/input" import { + ScopeBatchReadOfUser, + ScopeBillReadOfUser, + ScopeChannelReadOfUser, + ScopeResourceRead, + ScopeTradeReadOfUser, + ScopeUserWrite, ScopeUserWriteBalance, ScopeUserWriteBalanceDec, ScopeUserWriteBalanceInc, } from "@/lib/scopes" -import type { Cust } from "@/models/cust" import type { User } from "@/models/user" +import { AddUserDialog } from "../../cust/create" + +// import { ResourcesDialog } from "./resourcesDialog" interface UserQueryParams { account?: string @@ -34,7 +43,7 @@ interface UserQueryParams { } const filterSchema = z.object({ - account: z.string().optional(), + phone: z.string().optional(), name: z.string().optional(), }) @@ -44,18 +53,19 @@ export default function UserQueryPage() { const [userList, setUserList] = useState([]) const [loading, setLoading] = useState(false) const [depositDialog, setDepositDialog] = useState(false) - const [deposit, setDeposit] = useState(null) - + const [deposit, setDeposit] = useState(null) const [deductionDialog, setDeductionDialog] = useState(false) - const [deduction, setDeduction] = useState(null) + const [deduction, setDeduction] = useState(null) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [currentEditUser, setCurrentEditUser] = useState(null) + const [currentEditUser, setCurrentEditUser] = useState(null) const [currentFilters, setCurrentFilters] = useState({}) + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) + const router = useRouter() const { control, handleSubmit, reset } = useForm({ resolver: zodResolver(filterSchema), defaultValues: { - account: "", + phone: "", name: "", }, }) @@ -64,8 +74,6 @@ export default function UserQueryPage() { setLoading(true) try { const res = await getPageUser(filters) - console.log(res, "res") - if (res.success) { const data = Array.isArray(res.data) ? res.data : [res.data] setUserList(data) @@ -81,26 +89,28 @@ export default function UserQueryPage() { } }, []) - useEffect(() => { - fetchUsers() - }, [fetchUsers]) - const onFilter = handleSubmit((data: FormValues) => { const params: UserQueryParams = {} - if (data.account?.trim()) params.account = data.account.trim() + 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 refreshTable = useCallback(() => { - fetchUsers(currentFilters) + if (Object.keys(currentFilters).length > 0) { + fetchUsers(currentFilters) + } }, [fetchUsers, currentFilters]) const handleReset = () => { reset() setCurrentFilters({}) - fetchUsers() + setUserList([]) } return ( @@ -108,15 +118,15 @@ export default function UserQueryPage() {
( - 账号/手机号/邮箱 - + 手机号 + {fieldState.error?.message} )} @@ -143,6 +153,11 @@ export default function UserQueryPage() { + + + @@ -246,7 +261,7 @@ export default function UserQueryPage() { header: "操作", cell: ({ row }) => { return ( -
+
+ + + + + + + + + + + + + + +
) }, @@ -287,7 +359,11 @@ export default function UserQueryPage() { ]} /> - + { + 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_id: number + user_phone?: string + resource_no?: string + active?: boolean + mode?: number + created_at_start?: Date + created_at_end?: Date + expired?: boolean +} + +// 获取资源类型 +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") +} + +// 计算今日使用量 +function getTodayUsage(lastAt: Date | null | undefined, daily: number) { + if (lastAt && isSameDay(lastAt, new Date())) { + return daily + } + return 0 +} + +export default function ResourcesPage() { + return ( +
+ + + + 短效套餐 + + + 长效套餐 + + + + + + + + + +
+ ) +} + +interface ResourceListProps { + resourceType: "long" | "short" +} + +function ResourceList({ resourceType }: ResourceListProps) { + const searchParams = useSearchParams() + const userId = searchParams.get("userId") + const [filters, setFilters] = useState({ + user_id: Number(userId), + }) + const isLong = resourceType === "long" + const listFn = isLong ? ResourceLong : ResourceShort + const [updatingId, setUpdatingId] = useState(null) + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + user_phone: "", + resource_no: "", + status: "all", + type: "all", + created_at_start: "", + created_at_end: "", + expired: "all", + }, + }) + + const fetchResources = useCallback( + (page: number, size: number) => { + return listFn({ page, size, ...filters }) + }, + [listFn, filters], + ) + + const table = useDataTable(fetchResources) + const refreshTable = useCallback(() => { + setFilters(prev => ({ ...prev })) + }, []) + + 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 ? "启用" : "禁用"}`, + }) + refreshTable() + } catch (error) { + console.error("更新状态失败:", error) + toast.error("更新失败", { + description: error instanceof Error ? error.message : "请稍后重试", + }) + } finally { + setUpdatingId(null) + } + }, + [refreshTable], + ) + + const onFilter = handleSubmit(data => { + const result: FilterParams = { + user_id: Number(userId), + } + if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim() + if (data.resource_no?.trim()) result.resource_no = data.resource_no.trim() + if (data.status && data.status !== "all") { + result.active = data.status === "0" + } + if (data.type && data.type !== "all") { + result.mode = Number(data.type) + } + if (data.expired && data.expired !== "all") { + result.expired = data.expired === "1" + } + if (data.created_at_start) + result.created_at_start = new Date(data.created_at_start) + if (data.created_at_end) + result.created_at_end = new Date(data.created_at_end) + + setFilters(result) + 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} + +
+
+ ) + }, + }, + { + 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 {isLong ? `${live}小时` : `${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 { + // 包量 + if (isLong) { + return ( +
+ {detail.used < detail.quota ? ( + 正常 + ) : ( + 已用完 + )} + | + + {detail.used}/{detail.quota} + +
+ ) + } else { + return ( +
+ + {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) + }, + }, + ...(!isLong + ? [ + { + 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 && ( + + )} +
+ ) + }, + }, + ], + [isLong, updatingId, handleStatusChange], + ) + + return ( +
+
+
+ ( + + 会员号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 状态 + + {fieldState.error?.message} + + )} + /> + ( + + 类型 + + {fieldState.error?.message} + + )} + /> + ( + + 是否过期 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + +
+ + 加载中...
}> + {...table} columns={columns} /> + +
+ ) +} diff --git a/src/app/(root)/client/trade/page.tsx b/src/app/(root)/client/trade/page.tsx new file mode 100644 index 0000000..c95720e --- /dev/null +++ b/src/app/(root)/client/trade/page.tsx @@ -0,0 +1,355 @@ +"use client" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { CheckCircle, Clock, XCircle } from "lucide-react" +import { useSearchParams } from "next/navigation" +import { Suspense, useCallback, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" +import { getTrade } from "@/actions/trade" +import { DataTable, useDataTable } from "@/components/data-table" +import { Button } from "@/components/ui/button" +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 { Trade } from "@/models/trade" + +type FilterValues = { + user_id: number + inner_no?: string + method?: number + platform?: number + status?: number + created_at_start?: Date + created_at_end?: Date +} + +const filterSchema = z + .object({ + inner_no: z + .string() + .optional() + .transform(val => val?.trim()), + method: z.string().optional(), + platform: z.string().optional(), + status: z.string().optional(), + created_at_start: z.string().optional(), + created_at_end: 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 FilterSchema = z.infer + +export default function TradePage() { + const searchParams = useSearchParams() + const userId = searchParams.get("userId") + const [filters, setFilters] = useState({ + user_id: Number(userId), + }) + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + inner_no: "", + method: "all", + platform: "all", + status: "all", + created_at_start: "", + created_at_end: "", + }, + }) + + const fetchTrades = useCallback( + async (page: number, size: number) => { + return getTrade({ page, size, ...filters }) + }, + [filters], + ) + + const table = useDataTable(fetchTrades) + + const onFilter = handleSubmit(data => { + const result: FilterValues = { + user_id: Number(userId), + } + if (data.inner_no?.trim()) result.inner_no = data.inner_no.trim() + if (data.method && data.method !== "all") + result.method = Number(data.method) + if (data.platform && data.platform !== "all") + result.platform = Number(data.platform) + if (data.status && data.status !== "all") + result.status = Number(data.status) + if (data.created_at_start) + result.created_at_start = new Date(data.created_at_start) + if (data.created_at_end) + result.created_at_end = new Date(data.created_at_end) + setFilters(result) + table.pagination.onPageChange(1) + }) + + return ( +
+ {/* 筛选表单 */} +
+
+ ( + + 订单号 + + {fieldState.error?.message} + + )} + /> + + ( + + 支付渠道 + + {fieldState.error?.message} + + )} + /> + + ( + + 支付平台 + + {fieldState.error?.message} + + )} + /> + + ( + + 支付状态 + + {fieldState.error?.message} + + )} + /> + + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + +
+ + + + {...table} + columns={[ + { + header: "订单号", + accessorKey: "inner_no", + }, + { + header: "渠道订单号", + accessorKey: "outer_no", + }, + { + header: "支付渠道", + accessorKey: "method", + cell: ({ row }) => { + const methodMap: Record = { + 1: "支付宝", + 2: "微信", + 3: "商福通", + 4: "商福通渠道支付宝", + 5: "商福通渠道微信", + } + return ( +
+ {methodMap[row.original.method as number] || "未知"} +
+ ) + }, + }, + { + header: "支付金额", + accessorKey: "payment", + cell: ({ row }) => { + const payment = + typeof row.original.payment === "string" + ? parseFloat(row.original.payment) + : row.original.payment || 0 + return ( +
+ 0 ? "text-green-500" : "text-orange-500" + } + > + ¥{payment.toFixed(2)} + +
+ ) + }, + }, + { + header: "支付平台", + accessorKey: "platform", + cell: ({ row }) => { + const platform = row.original.platform + if (!platform) return - + return platform === 1 + ? "电脑网站" + : platform === 2 + ? "手机网站" + : "-" + }, + }, + { + header: "支付状态", + accessorKey: "status", + cell: ({ row }) => { + const status = row.original.status + switch (status) { + case 0: + return ( +
+ + 待支付 +
+ ) + case 1: + return ( +
+ + 支付成功 +
+ ) + case 2: + return ( +
+ + 取消支付 +
+ ) + default: + return - + } + }, + }, + { header: "购买套餐", accessorKey: "subject" }, + { + header: "创建时间", + accessorKey: "created_at", + cell: ({ row }) => + format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"), + }, + ]} + /> +
+
+ ) +} diff --git a/src/app/(root)/cust/deduction.tsx b/src/app/(root)/cust/deduction.tsx index 4e2ae06..204645e 100644 --- a/src/app/(root)/cust/deduction.tsx +++ b/src/app/(root)/cust/deduction.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/dialog" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import type { Cust } from "@/models/cust" +import type { User } from "@/models/user" const Schema = z.object({ deduction: z @@ -32,7 +32,7 @@ type FormValues = z.infer interface UpdateDeductionDialogProps { open: boolean onOpenChange: (open: boolean) => void - currentUser: Cust | null + currentUser: User | null onSuccess: () => void } diff --git a/src/app/(root)/cust/deposit.tsx b/src/app/(root)/cust/deposit.tsx index b2d4b77..5f79c21 100644 --- a/src/app/(root)/cust/deposit.tsx +++ b/src/app/(root)/cust/deposit.tsx @@ -17,7 +17,7 @@ import { } from "@/components/ui/dialog" import { Field, FieldError, FieldLabel } from "@/components/ui/field" import { Input } from "@/components/ui/input" -import type { Cust } from "@/models/cust" +import type { User } from "@/models/user" const Schema = z.object({ deposit: z @@ -32,7 +32,7 @@ type FormValues = z.infer interface UpdateDepositDialogProps { open: boolean onOpenChange: (open: boolean) => void - currentUser: Cust | null + currentUser: User | null onSuccess: () => void } diff --git a/src/app/(root)/cust/page.tsx b/src/app/(root)/cust/page.tsx index 71bdd67..8a6b343 100644 --- a/src/app/(root)/cust/page.tsx +++ b/src/app/(root)/cust/page.tsx @@ -2,6 +2,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" +import { useRouter } from "next/navigation" import { Suspense, useCallback, useState } from "react" import { Controller, useForm } from "react-hook-form" import { z } from "zod" @@ -25,12 +26,17 @@ import { SelectValue, } from "@/components/ui/select" import { + ScopeBatchReadOfUser, + ScopeBillReadOfUser, + ScopeChannelReadOfUser, + ScopeResourceRead, + ScopeTradeReadOfUser, ScopeUserWrite, ScopeUserWriteBalance, ScopeUserWriteBalanceDec, ScopeUserWriteBalanceInc, } from "@/lib/scopes" -import type { Cust } from "@/models/cust" +import type { User } from "@/models/user" import { AddUserDialog } from "./create" import { DeductionDialog } from "./deduction" import { DepositDialog } from "./deposit" @@ -75,12 +81,12 @@ export default function UserPage() { const [filters, setFilters] = useState({}) const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) - const [currentEditUser, setCurrentEditUser] = useState(null) + const [currentEditUser, setCurrentEditUser] = useState(null) const [depositDialog, setDepositDialog] = useState(false) - const [deposit, setDeposit] = useState(null) - + const [deposit, setDeposit] = useState(null) + const router = useRouter() const [deductionDialog, setDeductionDialog] = useState(false) - const [deduction, setDeduction] = useState(null) + const [deduction, setDeduction] = useState(null) const { control, handleSubmit, reset } = useForm({ resolver: zodResolver(filterSchema), defaultValues: { @@ -98,7 +104,7 @@ export default function UserPage() { [filters], ) - const table = useDataTable(fetchUsers) + const table = useDataTable(fetchUsers) const onFilter = handleSubmit(data => { const result: FilterValues = {} @@ -244,7 +250,7 @@ export default function UserPage() { - + {...table} columns={[ { header: "账号", accessorKey: "username" }, @@ -355,7 +361,7 @@ export default function UserPage() { header: "操作", cell: ({ row }) => { return ( -
+
+ + + + + + + + + + + + +
) }, diff --git a/src/app/(root)/cust/update.tsx b/src/app/(root)/cust/update.tsx index 4052fb0..458146b 100644 --- a/src/app/(root)/cust/update.tsx +++ b/src/app/(root)/cust/update.tsx @@ -30,7 +30,7 @@ import { SelectValue, } from "@/components/ui/select" import type { Admin } from "@/models/admin" -import type { Cust } from "@/models/cust" +import type { User } from "@/models/user" import type { ProductDiscount } from "@/models/product_discount" // 表单验证规则 @@ -69,7 +69,7 @@ export type EditUserFormValues = z.infer interface EditUserDialogProps { open: boolean onOpenChange: (open: boolean) => void - currentUser: Cust | null + currentUser: User | null onSuccess: () => void } diff --git a/src/app/(root)/layout.tsx b/src/app/(root)/layout.tsx index 3ed97f7..cb5e819 100644 --- a/src/app/(root)/layout.tsx +++ b/src/app/(root)/layout.tsx @@ -20,7 +20,7 @@ async function Layout(props: { children: ReactNode }) { const profile = await getProfile() if (!profile.success) throw new Error("页面渲染失败:无法获取账号信息") return ( -
+
{/* 侧边栏 */} diff --git a/src/app/(root)/navigation.tsx b/src/app/(root)/navigation.tsx index 4d76368..560b1f7 100644 --- a/src/app/(root)/navigation.tsx +++ b/src/app/(root)/navigation.tsx @@ -4,18 +4,23 @@ import { BarChart3, ChevronsLeft, ChevronsRight, + CircleDollarSign, ClipboardList, + ClipboardMinus, Code, ComputerIcon, ContactRound, DollarSign, + FolderCode, Home, KeyRound, type LucideIcon, Package, + PackageSearch, ScanSearch, Shield, ShoppingBag, + SquareActivity, SquarePercent, TicketPercent, Users, @@ -100,8 +105,6 @@ interface NavItemProps { } function NavItem({ href, icon: Icon, label, requiredScope }: NavItemProps) { - // console.log(requiredScope, "requiredScope") - const { collapsed, isActive } = useNavigation() const active = isActive(href) @@ -174,7 +177,7 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ requiredScope: ScopeUserReadNotBind, }, { - href: "/userQuery", + href: "/client/cust", icon: ScanSearch, label: "客户查询", requiredScope: ScopeUserReadOne, @@ -234,7 +237,7 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ }, { href: "/channel", - icon: Code, + icon: FolderCode, label: "IP管理", requiredScope: ScopeChannelRead, }, @@ -269,9 +272,11 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [ export default function Navigation() { const [collapsed, setCollapsed] = useState(false) const pathname = usePathname() - const isActive = (path: string) => { - return path === "/" ? pathname === path : pathname.startsWith(path) + if (path === "/") { + return pathname === path + } + return pathname === path || pathname.startsWith(path + "/") } const contextValue: NavigationContextType = { @@ -285,12 +290,12 @@ export default function Navigation() {