From 453d687c4ae1f08935ac5be8adb015d517c19766 Mon Sep 17 00:00:00 2001 From: Eamon <17516219072@163.com> Date: Thu, 26 Mar 2026 15:27:52 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=A1=A8=E5=8D=95=E6=9F=A5?= =?UTF-8?q?=E8=AF=A2=E5=92=8C=E8=B0=83=E6=95=B4=E8=A1=A8=E6=A0=BC=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=E4=BB=A5=E5=8F=8A=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/actions/batch.ts | 13 +- src/actions/bill.ts | 23 +- src/actions/channel.ts | 13 +- src/actions/cust.ts | 25 ++ src/actions/resources.ts | 23 +- src/actions/trade.ts | 12 +- src/actions/user.ts | 9 +- src/app/(root)/appbar.tsx | 4 +- src/app/(root)/batch/page.tsx | 295 ++++++++++++-- src/app/(root)/billing/page.tsx | 471 ++++++++++++++++++---- src/app/(root)/channel/page.tsx | 446 ++++++++++++++++----- src/app/(root)/cust/page.tsx | 608 +++++++++++++++++++++++++++++ src/app/(root)/navigation.tsx | 7 +- src/app/(root)/product/page.tsx | 2 + src/app/(root)/resources/page.tsx | 554 +++++++++++++++++++++++++- src/app/(root)/trade/page.tsx | 483 +++++++++++++++++------ src/app/(root)/user/page.tsx | 270 +++++++++++-- src/components/data-table/hooks.ts | 7 +- src/components/ui/pagination.tsx | 6 +- src/components/ui/tabs.tsx | 6 +- src/lib/base/index.ts | 7 + src/models/batch.ts | 5 + src/models/billing.ts | 11 + src/models/channel.ts | 8 + src/models/cust.ts | 28 ++ src/models/resources.ts | 54 ++- src/models/trade.ts | 6 + src/models/user.ts | 1 + 28 files changed, 3013 insertions(+), 384 deletions(-) create mode 100644 src/actions/cust.ts create mode 100644 src/app/(root)/cust/page.tsx create mode 100644 src/models/cust.ts diff --git a/src/actions/batch.ts b/src/actions/batch.ts index 2399fee..3775f89 100644 --- a/src/actions/batch.ts +++ b/src/actions/batch.ts @@ -2,6 +2,17 @@ import type { PageRecord } from "@/lib/api" import type { Batch } from "@/models/batch" import { callByUser } from "./base" -export async function getPageBatch(params: { page: number; size: number }) { +export async function getPageBatch(params: { + page: number + size: 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", params) } diff --git a/src/actions/bill.ts b/src/actions/bill.ts index f845bef..f6390f9 100644 --- a/src/actions/bill.ts +++ b/src/actions/bill.ts @@ -1,7 +1,28 @@ import type { PageRecord } from "@/lib/api" +import { ProductCode } from "@/lib/base" import type { Billing } from "@/models/billing" +import type { ProductSku } from "@/models/product_sku" import { callByUser } from "./base" -export async function getPageBill(params: { page: number; size: number }) { +export async function getPageBill(params: { + page: number + size: 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", params) } + +export async function getSkuList(params: { product_code?: ProductCode }) { + const requestParams = { + ...params, + ...(params.product_code === ProductCode.All && { product_code: undefined }), + } + return callByUser("/api/admin/product/sku/all", requestParams) +} diff --git a/src/actions/channel.ts b/src/actions/channel.ts index bfedf9d..fbb4161 100644 --- a/src/actions/channel.ts +++ b/src/actions/channel.ts @@ -2,6 +2,17 @@ import type { PageRecord } from "@/lib/api" import type { Channel } from "@/models/channel" import { callByUser } from "./base" -export async function getPageChannel(params: { page: number; size: number }) { +export async function getPageChannel(params: { + page: number + size: 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", params) } diff --git a/src/actions/cust.ts b/src/actions/cust.ts new file mode 100644 index 0000000..d746f0b --- /dev/null +++ b/src/actions/cust.ts @@ -0,0 +1,25 @@ +import type { PageRecord } from "@/lib/api" +import type { Cust } from "@/models/cust" +import { callByUser } from "./base" + +export async function getPageCusts(params: { page: number; size: number }) { + return callByUser>("/api/admin/user/page", params) +} +export async function updateCust(data: { + id: number + phone: string + email: string +}) { + return callByUser>("/api/admin/user/updateCust", data) +} + +export async function createCust(data: { + username: string + password: string + phone: string + email?: string + name?: string + contact_wechat?: string +}) { + return callByUser>("/api/admin/user/create", data) +} diff --git a/src/actions/resources.ts b/src/actions/resources.ts index 1d9c0b7..a18c80a 100644 --- a/src/actions/resources.ts +++ b/src/actions/resources.ts @@ -2,19 +2,32 @@ import type { PageRecord } from "@/lib/api" import type { Resources } from "@/models/resources" import { callByUser } from "./base" -export async function listResourceLong(params: { page: number; size: number }) { +export interface ResourceListParams { + page: number + size: number + user_phone?: string + resource_no?: string + active?: boolean + mode?: number + created_at_start?: Date + created_at_end?: Date + expired?: boolean +} + +export async function listResourceLong(params: ResourceListParams) { return callByUser>( "/api/admin/resource/long/page", params, ) } -export async function listResourceShort(params: { - page: number - size: number -}) { +export async function listResourceShort(params: ResourceListParams) { return callByUser>( "/api/admin/resource/short/page", params, ) } + +export async function updateResource(data: { id: number; active?: boolean }) { + return callByUser("/api/admin/resource/update", data) +} diff --git a/src/actions/trade.ts b/src/actions/trade.ts index 5a8f59c..a5e1202 100644 --- a/src/actions/trade.ts +++ b/src/actions/trade.ts @@ -2,6 +2,16 @@ import type { PageRecord } from "@/lib/api" import type { Trade } from "@/models/trade" import { callByUser } from "./base" -export async function getPageTrade(params: { page: number; size: number }) { +export async function getPageTrade(params: { + page: number + size: 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", params) } diff --git a/src/actions/user.ts b/src/actions/user.ts index 3c71c69..b6b8b47 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -6,7 +6,14 @@ export async function getPageUsers(params: { page: number; size: number }) { return callByUser>("/api/admin/user/page", params) } -export async function bindAdmin(params: { id: number }) { +export async function bindAdmin(params: { + id: number + phone?: string + name?: string + identified?: boolean + enabled?: boolean + assigned?: boolean +}) { return callByUser("/api/admin/user/bind", { user_id: params.id, }) diff --git a/src/app/(root)/appbar.tsx b/src/app/(root)/appbar.tsx index 0afcb10..c373106 100644 --- a/src/app/(root)/appbar.tsx +++ b/src/app/(root)/appbar.tsx @@ -102,8 +102,10 @@ export default function Appbar(props: { admin: Admin }) { nodes: "节点列表", trade: "交易明细", billing: "账单详情", + cust: "客户管理", + product: "产品管理", resources: "套餐管理", - batch: "使用记录", + batch: "提取记录", channel: "IP管理", pools: "IP池管理", } diff --git a/src/app/(root)/batch/page.tsx b/src/app/(root)/batch/page.tsx index 67a51f2..3c5c3f0 100644 --- a/src/app/(root)/batch/page.tsx +++ b/src/app/(root)/batch/page.tsx @@ -1,36 +1,285 @@ "use client" +import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" -import { Suspense } from "react" +import { Suspense, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" import { getPageBatch } 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 = { + 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 [filters, setFilters] = useState({}) + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + user_phone: "", + batch_no: "", + prov: "", + city: "", + isp: "all", + created_at_start: "", + created_at_end: "", + }, + }) + const table = useDataTable((page, size) => - getPageBatch({ page, size }), + getPageBatch({ page, size, ...filters }), ) + console.log(table, "使用记录") + + const onFilter = handleSubmit(data => { + const result: APIFilterParams = {} + 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 ( - Loading...}> - - {...table} - columns={[ - { header: "ID", accessorKey: "id" }, - { header: "批次号", accessorKey: "batch_no" }, - { header: "省份", accessorKey: "prov" }, - { header: "城市", accessorKey: "city" }, - { header: "提取IP", accessorKey: "ip" }, - { header: "运营商", accessorKey: "isp" }, - { header: "提取数量", accessorKey: "count" }, - { header: "资源数量", accessorKey: "resource_id" }, - { - header: "提取时间", - accessorKey: "time", - cell: ({ row }) => - format(new Date(row.original.time), "yyyy-MM-dd HH:mm"), - }, - ]} - /> - +
+ {/* 筛选表单 */} +
+
+ ( + + 批次号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {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: "ID", accessorKey: "id" }, + { + 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)/billing/page.tsx b/src/app/(root)/billing/page.tsx index 2ed1431..c255ae6 100644 --- a/src/app/(root)/billing/page.tsx +++ b/src/app/(root)/billing/page.tsx @@ -1,95 +1,406 @@ "use client" +import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" -import { CreditCard } from "lucide-react" -import { Suspense } from "react" -import { getPageBill } from "@/actions/bill" +import { Suspense, useEffect, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { getPageBill, 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 = { + 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 table = useDataTable((page, size) => - getPageBill({ page, size }), + const [filters, setFilters] = useState({}) + 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) + console.log(skuProductCode, "skuProductCode") + 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, + })), + ) + console.log(resp.data, "skus") + }) + .catch(e => { + console.error("获取套餐类型失败:", e) + toast.error( + `获取套餐类型失败:${e instanceof Error ? e.message : String(e)}`, + ) + setSkuOptions([]) + }) + .finally(() => { + setLoading(false) + }) + }, [skuProductCode]) + + const table = useDataTable((page, size) => + getPageBill({ page, size, ...filters }), + ) + + console.log(table, "账单详情的table") + const onFilter = handleSubmit(data => { + console.log(data, "data") + + const result: FilterValues = {} + 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 ( - - - {...table} - columns={[ - { header: "ID", accessorKey: "id" }, - { header: "账单号", accessorKey: "bill_no" }, - { - header: "账单详情", - accessorKey: "info", - cell: ({ row }) => { - const bill = row.original +
+ {/* 筛选表单 */} +
+
+ ( + + 会员号 + + {fieldState.error?.message} + + )} + /> + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + ( + + 订单号 + + {fieldState.error?.message} + + )} + /> - return ( -
- {/* 类型展示 */} -
- {bill.type === 1 && ( -
- - 消费 -
- )} - {bill.type === 2 && ( -
- - 退款 -
- )} - {bill.type === 3 && ( -
- - 充值 -
- )} + ( + + 产品类型 + + + )} + /> + + ( + + 套餐类型 + + {fieldState.error?.message} + + )} + /> + ( + + 账单号 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + + + + + + {...table} + columns={[ + { header: "ID", accessorKey: "id" }, + { header: "会员号", accessorFn: row => row.user?.phone || "-" }, + { header: "套餐号", accessorKey: "resource.resource_no" }, + { + 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)} +
- - {/* 账单详情 */} -
{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: "resource_id" }, - { - 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: "套餐名称", accessorKey: "info" }, + { header: "账单号", accessorKey: "bill_no" }, + { header: "订单号", accessorKey: "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)/channel/page.tsx b/src/app/(root)/channel/page.tsx index 99ea68f..9c0784b 100644 --- a/src/app/(root)/channel/page.tsx +++ b/src/app/(root)/channel/page.tsx @@ -1,107 +1,375 @@ "use client" +import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" -import { Suspense } from "react" +import { Suspense, useState } from "react" +import { Controller, useForm } from "react-hook-form" +import { z } from "zod" import { getPageChannel } 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 = { + 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(), + filter_prov: z.string().optional(), + filter_city: z.string().optional(), + filter_isp: 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: ["created_at_end"], + }) + } + } + }) + +type FilterSchema = z.infer + +// 运营商映射 +const ispMap: Record = { + 1: "电信", + 2: "联通", + 3: "移动", +} + export default function ChannelPage() { + const [filters, setFilters] = useState({}) + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + batch_no: "", + user_phone: "", + resource_no: "", + filter_prov: "", + filter_city: "", + filter_isp: "all", + proxy_port: "", + proxy_host: "", + node_ip: "", + expired_at_start: "", + expired_at_end: "", + }, + }) + const table = useDataTable((page, size) => - getPageChannel({ page, size }), + getPageChannel({ page, size, ...filters }), ) + console.log(table, "IP管理的table") + + const onFilter = handleSubmit(data => { + console.log(data, "data") + + const result: FilterValues = {} + + 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 ( - - - {...table} - columns={[ - { header: "ID", accessorKey: "id" }, - { header: "批次号", accessorKey: "batch_no" }, - { header: "省份", accessorKey: "filter_prov" }, - { header: "城市", accessorKey: "filter_city" }, - { - header: "运营商", - accessorKey: "filter_isp", - cell: ({ row }) => { - const value = row.getValue("filter_isp") - if (!value || value === "all") return "不限" - if (value === 1) return "电信" - if (value === 2) return "联通" - if (value === 3) return "移动" - return String(value) - }, - }, - { - header: "代理地址", - accessorKey: "host", - cell: ({ row }) => { - const ip = row.original.host - const port = row.original.port - return ( - - {ip}:{port}{" "} - - ) - }, - }, - { - header: "认证方式", - cell: ({ row }) => { - const channel = row.original +
+ {/* 筛选表单 */} +
+
+ ( + + 批次号 + + {fieldState.error?.message} + + )} + /> - const hasWhitelist = - channel.whitelists && channel.whitelists.trim() !== "" - const hasAuth = channel.username && channel.password + ( + + 会员号 + + {fieldState.error?.message} + + )} + /> - return ( -
- {hasWhitelist ? ( -
- 白名单 -
- {channel.whitelists.split(",").map(ip => ( - - {ip.trim()} - - ))} + ( + + 套餐号 + + {fieldState.error?.message} + + )} + /> + + ( + + 代理IP + + {fieldState.error?.message} + + )} + /> + + ( + + 代理端口 + + {fieldState.error?.message} + + )} + /> + + ( + + 节点 + + {fieldState.error?.message} + + )} + /> + + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + + + + + + {...table} + columns={[ + { header: "ID", accessorKey: "id" }, + { + 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: "自动配置", + accessorFn: row => { + const prov = row.filter_prov + const city = row.filter_city + const isp = row.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(" / ") : "不限" + }, + 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 }) => { + const ip = row.original.host + const port = row.original.port + return ( + + {ip}:{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} - -
- ) : ( - 无认证 - )} -
- ) + ) : hasAuth ? ( +
+ 账号密码 + + {channel.username}:{channel.password} + +
+ ) : ( + 无认证 + )} +
+ ) + }, }, - }, - { header: "资源数量", accessorKey: "resource_id" }, - { - 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: "过期时间", - accessorKey: "expired_at", - cell: ({ row }) => - format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"), - }, - ]} - /> - + { + header: "过期时间", + accessorKey: "expired_at", + cell: ({ row }) => + format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"), + }, + ]} + /> + +
) } diff --git a/src/app/(root)/cust/page.tsx b/src/app/(root)/cust/page.tsx new file mode 100644 index 0000000..3039399 --- /dev/null +++ b/src/app/(root)/cust/page.tsx @@ -0,0 +1,608 @@ +"use client" +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 { createCust, getPageCusts, updateCust } from "@/actions/cust" +import { DataTable, useDataTable } from "@/components/data-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +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 { Cust } from "@/models/cust" + +type FilterValues = { + phone?: string + name?: string + identified?: boolean + enabled?: boolean + created_at_start?: Date + created_at_end?: Date +} + +const filterSchema = z + .object({ + phone: z.string().optional(), + name: z.string().optional(), + identified: z.string().optional(), + enabled: 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"], + }) + } + } + }) + +const addUserSchema = z.object({ + username: z.string().min(1, "账号不能为空"), + password: z.string().min(6, "密码至少6位"), + phone: z.string().regex(/^1[3-9]\d{9}$/, "请输入正确的手机号格式"), + email: z.string().email("请输入正确的邮箱格式").optional().or(z.literal("")), + name: z.string().optional(), + contact_wechat: z.string().optional(), +}) + +type AddUserFormValues = z.infer + +type FormValues = z.infer + +export default function UserPage() { + const [filters, setFilters] = useState({}) + const [editingRowId, setEditingRowId] = useState(null) + const [editPhone, setEditPhone] = useState("") + const [editEmail, setEditEmail] = useState("") + const [isSaving, setIsSaving] = useState(false) + const [isAddDialogOpen, setIsAddDialogOpen] = useState(false) + const [isAdding, setIsAdding] = useState(false) + + const { control, handleSubmit, reset } = useForm({ + resolver: zodResolver(filterSchema), + defaultValues: { + phone: "", + name: "", + identified: "all", + enabled: "all", + created_at_start: "", + created_at_end: "", + }, + }) + + // 添加用户表单 + const { + control: addControl, + handleSubmit: handleAddSubmit, + reset: resetAddForm, + formState: { errors: addErrors }, + } = useForm({ + resolver: zodResolver(addUserSchema), + defaultValues: { + username: "", + password: "", + phone: "", + email: "", + name: "", + contact_wechat: "", + }, + }) + + const fetchUsers = useCallback( + (page: number, size: number) => getPageCusts({ page, size, ...filters }), + [filters], + ) + + const table = useDataTable(fetchUsers) + + const onFilter = handleSubmit(data => { + const result: FilterValues = {} + + if (data.phone) result.phone = data.phone + if (data.name) result.name = data.name + if (data.identified && data.identified !== "all") + result.identified = data.identified === "1" + if (data.enabled && data.enabled !== "all") + result.enabled = data.enabled === "1" + + setFilters(result) + table.pagination.onPageChange(1) + }) + + const refreshTable = useCallback(() => { + table.pagination.onPageChange(table.pagination.page) + }, [table.pagination]) + + // 开始编辑行 + const startEdit = (row: Cust) => { + setEditingRowId(row.id) + setEditPhone(row.phone || "") + setEditEmail(row.email || "") + } + + // 取消编辑 + const cancelEdit = () => { + setEditingRowId(null) + setEditPhone("") + setEditEmail("") + } + + // 保存编辑 + const saveEdit = async (row: Cust) => { + const phoneRegex = /^1[3-9]\d{9}$/ + if (editPhone && !phoneRegex.test(editPhone)) { + toast.error("请输入正确的手机号格式") + return + } + + const emailRegex = /^[^\s@]+@([^\s@]+\.)+[^\s@]+$/ + if (editEmail && !emailRegex.test(editEmail)) { + toast.error("请输入正确的邮箱格式") + return + } + + setIsSaving(true) + try { + const result = await updateCust({ + id: row.id, + phone: editPhone, + email: editEmail, + }) + if (result.success) { + toast.success("更新成功") + table.pagination.onPageChange(table.pagination.page) + cancelEdit() + } else { + toast.error(result.message || "更新失败") + } + } catch (error) { + toast.error("更新失败,请稍后重试") + console.error(error) + } finally { + setIsSaving(false) + } + } + + const onAddUser = handleAddSubmit(async data => { + const payload = { + username: data.username, + password: data.password, + phone: data.phone || "", + email: data.email || "", + name: data.name || "", + contact_wechat: data.contact_wechat || "", + } + + setIsAdding(true) + try { + const result = await createCust(payload) + if (result?.success) { + toast.success("添加用户成功") + setIsAddDialogOpen(false) + resetAddForm() + refreshTable() + } else { + toast.error(result?.message || "添加失败") + } + } catch (error) { + toast.error("添加失败,请稍后重试") + console.error(error) + } finally { + setIsAdding(false) + } + }) + + // 打开添加对话框时重置表单 + const openAddDialog = () => { + resetAddForm() + setIsAddDialogOpen(true) + } + + return ( +
+
+
+ ( + + 手机号 + + {fieldState.error?.message} + + )} + /> + + ( + + 账户 + + {fieldState.error?.message} + + )} + /> + + ( + + 实名状态 + + {fieldState.error?.message} + + )} + /> + + ( + + 账号状态 + + {fieldState.error?.message} + + )} + /> + ( + + 开始时间 + + {fieldState.error?.message} + + )} + /> + + ( + + 结束时间 + + {fieldState.error?.message} + + )} + /> +
+ + + + + + +
+ + + + {...table} + columns={[ + { header: "ID", accessorKey: "id" }, + { header: "账号", accessorKey: "username" }, + { header: "密码", accessorKey: "admin.password" }, + { + header: "手机", + accessorKey: "phone", + cell: ({ row }) => { + const isEditing = editingRowId === row.original.id + if (isEditing) { + return ( + setEditPhone(e.target.value)} + placeholder="手机号" + className="w-32" + /> + ) + } + return row.original.phone || "-" + }, + }, + { + header: "邮箱", + accessorKey: "email", + cell: ({ row }) => { + const isEditing = editingRowId === row.original.id + if (isEditing) { + return ( + setEditEmail(e.target.value)} + placeholder="邮箱" + className="w-40" + /> + ) + } + return row.original.email || "-" + }, + }, + { header: "姓名", accessorKey: "name" }, + { + header: "余额", + accessorKey: "balance", + cell: ({ row }) => { + const balance = Number(row.original.balance) || 0 + return ( + 0 ? "text-green-500" : "text-orange-500" + } + > + ¥{balance.toFixed(2)} + + ) + }, + }, + { + header: "实名状态", + accessorKey: "id_type", + cell: ({ row }) => ( + + {row.original.id_type === 1 ? "已认证" : "未认证"} + + ), + }, + { + header: "身份证号", + accessorKey: "id_no", + cell: ({ row }) => { + const idNo = row.original.id_no + return idNo ? `${idNo.slice(0, 6)}****${idNo.slice(-4)}` : "-" + }, + }, + { + header: "账号状态", + accessorKey: "status", + cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"), + }, + { header: "联系方式", accessorKey: "contact_wechat" }, + { header: "客户来源", accessorKey: "" }, + { header: "客户经理", accessorKey: "admin.name" }, + { + header: "最后登录时间", + accessorKey: "last_login", + cell: ({ row }) => + row.original.last_login + ? format( + new Date(row.original.last_login), + "yyyy-MM-dd HH:mm", + ) + : "-", + }, + { + header: "最后登录IP", + accessorKey: "last_login_ip", + cell: ({ row }) => row.original.last_login_ip || "-", + }, + { + header: "创建时间", + accessorKey: "created_at", + cell: ({ row }) => + format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"), + }, + { + header: "操作", + cell: ({ row }) => { + const isEditing = editingRowId === row.original.id + if (isEditing) { + return ( +
+ + +
+ ) + } + return ( + + ) + }, + }, + ]} + /> +
+ {/* 添加用户对话框 */} + + + + 添加用户 + +
+ ( + + 账号 * + + {fieldState.error?.message} + + )} + /> + ( + + 密码 * + + {fieldState.error?.message} + + )} + /> + ( + + 手机号 * + + {fieldState.error?.message} + + )} + /> + ( + + 姓名 + + {fieldState.error?.message} + + )} + /> + ( + + 邮箱 + + {fieldState.error?.message} + + )} + /> + ( + + 微信/联系方式 + + {fieldState.error?.message} + + )} + /> + + + + + +
+
+
+ ) +} diff --git a/src/app/(root)/navigation.tsx b/src/app/(root)/navigation.tsx index 763d8b2..85986f7 100644 --- a/src/app/(root)/navigation.tsx +++ b/src/app/(root)/navigation.tsx @@ -7,6 +7,7 @@ import { ClipboardList, Code, ComputerIcon, + ContactRound, DollarSign, Home, KeyRound, @@ -164,7 +165,6 @@ export default function Navigation() { )}
- {/* Navigation Menu */}