添加表单查询和调整表格字段以及功能

This commit is contained in:
Eamon
2026-03-26 15:27:52 +08:00
parent a9e9ddd04b
commit 453d687c4a
28 changed files with 3013 additions and 384 deletions

View File

@@ -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<PageRecord<Batch>>("/api/admin/batch/page", params)
}

View File

@@ -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<PageRecord<Billing>>("/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<ProductSku[]>("/api/admin/product/sku/all", requestParams)
}

View File

@@ -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<PageRecord<Channel>>("/api/admin/channel/page", params)
}

25
src/actions/cust.ts Normal file
View File

@@ -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<PageRecord<Cust>>("/api/admin/user/page", params)
}
export async function updateCust(data: {
id: number
phone: string
email: string
}) {
return callByUser<PageRecord<Cust>>("/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<PageRecord<Cust>>("/api/admin/user/create", data)
}

View File

@@ -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<PageRecord<Resources>>(
"/api/admin/resource/long/page",
params,
)
}
export async function listResourceShort(params: {
page: number
size: number
}) {
export async function listResourceShort(params: ResourceListParams) {
return callByUser<PageRecord<Resources>>(
"/api/admin/resource/short/page",
params,
)
}
export async function updateResource(data: { id: number; active?: boolean }) {
return callByUser<Resources>("/api/admin/resource/update", data)
}

View File

@@ -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<PageRecord<Trade>>("/api/admin/trade/page", params)
}

View File

@@ -6,7 +6,14 @@ export async function getPageUsers(params: { page: number; size: number }) {
return callByUser<PageRecord<User>>("/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,
})

View File

@@ -102,8 +102,10 @@ export default function Appbar(props: { admin: Admin }) {
nodes: "节点列表",
trade: "交易明细",
billing: "账单详情",
cust: "客户管理",
product: "产品管理",
resources: "套餐管理",
batch: "使用记录",
batch: "提取记录",
channel: "IP管理",
pools: "IP池管理",
}

View File

@@ -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<typeof filterSchema>
export default function BatchPage() {
const [filters, setFilters] = useState<APIFilterParams>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
user_phone: "",
batch_no: "",
prov: "",
city: "",
isp: "all",
created_at_start: "",
created_at_end: "",
},
})
const table = useDataTable<Batch>((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 (
<Suspense fallback={<div>Loading...</div>}>
<DataTable<Batch>
{...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"),
},
]}
/>
</Suspense>
<div className="space-y-3">
{/* 筛选表单 */}
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="batch_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入批次号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="resource_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入套餐号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="user_phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入会员号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="prov"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-32 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入省份" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="city"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-32 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入城市" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="isp"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-32">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
user_phone: "",
batch_no: "",
prov: "",
city: "",
isp: "all",
created_at_start: "",
created_at_end: "",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense fallback={<div>Loading...</div>}>
<DataTable<Batch>
{...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"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -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<typeof filterSchema>
export default function BillingPage() {
const table = useDataTable<Billing>((page, size) =>
getPageBill({ page, size }),
const [filters, setFilters] = useState<FilterValues>({})
const [skuOptions, setSkuOptions] = useState<SkuOption[]>([])
const [loading, setLoading] = useState(true)
const [skuProductCode, setSkuProductCode] = useState<ProductCode>(
ProductCode.All,
)
const { control, handleSubmit, reset } = useForm<FilterSchema>({
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<Billing>((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 (
<Suspense>
<DataTable<Billing>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "账单号", accessorKey: "bill_no" },
{
header: "账单详情",
accessorKey: "info",
cell: ({ row }) => {
const bill = row.original
<div className="space-y-3">
{/* 筛选表单 */}
<form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入会员号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="resource_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入套餐号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="inner_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入订单号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
return (
<div className="flex items-center gap-2">
{/* 类型展示 */}
<div className="shrink-0">
{bill.type === 1 && (
<div className="flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16} />
<span></span>
</div>
)}
{bill.type === 2 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16} />
<span>退</span>
</div>
)}
{bill.type === 3 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md">
<CreditCard size={16} />
<span></span>
</div>
)}
<Controller
name="product_code"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-32 flex-none"
>
<FieldLabel></FieldLabel>
<Select
value={skuProductCode}
onValueChange={value => {
setSkuProductCode(value as ProductCode)
// 同步到表单值
field.onChange(value)
}}
>
<SelectTrigger>
<SelectValue
placeholder={"请选择"}
defaultValue={
skuProductCode === ProductCode.All ? "全部" : ""
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value={ProductCode.All}></SelectItem>
<SelectItem value={ProductCode.Short}></SelectItem>
<SelectItem value={ProductCode.Long}></SelectItem>
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="sku_code"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-32 flex-none"
>
<FieldLabel></FieldLabel>
<Select
value={loading ? undefined : field.value || "all"}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder={loading ? "加载中..." : "全部"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{skuOptions.map(option => (
<SelectItem
key={option.resource_code}
value={option.resource_code}
>
{option.resource_name || option.resource_code}
</SelectItem>
))}
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="bill_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入账单号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
bill_no: "",
inner_no: "",
created_at_start: "",
created_at_end: "",
phone: "",
resource_no: "",
product_code: ProductCode.All,
sku_code: "all",
})
setSkuProductCode(ProductCode.All)
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Billing>
{...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 (
<div className="flex gap-1">
<span
className={
amount > 0 ? "text-green-500" : "text-orange-500"
}
>
{amount.toFixed(2)}
</span>
</div>
{/* 账单详情 */}
<div className="text-sm">{bill.info}</div>
</div>
)
)
},
},
},
{
header: "支付信息",
accessorKey: "amount",
cell: ({ row }) => {
const amount =
typeof row.original.amount === "string"
? parseFloat(row.original.amount)
: row.original.amount || 0
return (
<div className="flex gap-1">
<span
className={
amount > 0 ? "text-green-500" : "text-orange-500"
}
>
{amount.toFixed(2)}
</span>
</div>
)
{
header: "实付金额",
accessorKey: "actual",
cell: ({ row }) => {
const actual =
typeof row.original.actual === "string"
? parseFloat(row.original.actual)
: row.original.actual || 0
return (
<div className="flex gap-1">
<span
className={
actual > 0 ? "text-green-500" : "text-orange-500"
}
>
{actual.toFixed(2)}
</span>
</div>
)
},
},
},
// { 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"),
},
]}
/>
</Suspense>
{ 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"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -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<typeof filterSchema>
// 运营商映射
const ispMap: Record<number, string> = {
1: "电信",
2: "联通",
3: "移动",
}
export default function ChannelPage() {
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
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<Channel>((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 (
<Suspense>
<DataTable<Channel>
{...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 (
<span>
{ip}:{port}{" "}
</span>
)
},
},
{
header: "认证方式",
cell: ({ row }) => {
const channel = row.original
<div className="space-y-3">
{/* 筛选表单 */}
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="batch_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入批次号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
const hasWhitelist =
channel.whitelists && channel.whitelists.trim() !== ""
const hasAuth = channel.username && channel.password
<Controller
name="user_phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入会员号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
return (
<div className="flex flex-col gap-1 min-w-0">
{hasWhitelist ? (
<div className="flex flex-col">
<span></span>
<div className="flex flex-wrap gap-1 max-w-50">
{channel.whitelists.split(",").map(ip => (
<Badge key={ip.trim()} variant="secondary">
{ip.trim()}
</Badge>
))}
<Controller
name="resource_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入套餐号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="proxy_host"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel>IP</FieldLabel>
<Input {...field} placeholder="请输入代理IP" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="proxy_port"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-32 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入代理端口" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="node_ip"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入节点" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="expired_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="expired_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
batch_no: "",
user_phone: "",
resource_no: "",
filter_prov: "",
filter_city: "",
filter_isp: "all",
proxy_host: "",
proxy_port: "",
node_ip: "",
expired_at_start: "",
expired_at_end: "",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Channel>
{...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 (
<div className="text-sm">
{parts.length > 0 ? parts.join(" / ") : "不限"}
</div>
)
},
},
{
header: "网关地址",
accessorKey: "host",
cell: ({ row }) => {
const ip = row.original.host
const port = row.original.port
return (
<span>
{ip}:{port}{" "}
</span>
)
},
},
{
header: "认证方式",
cell: ({ row }) => {
const channel = row.original
const hasWhitelist =
channel.whitelists && channel.whitelists.trim() !== ""
const hasAuth = channel.username && channel.password
return (
<div className="flex flex-col gap-1 min-w-0">
{hasWhitelist ? (
<div className="flex flex-col">
<span></span>
<div className="flex flex-wrap gap-1 max-w-50">
{channel.whitelists.split(",").map(ip => (
<Badge key={ip.trim()} variant="secondary">
{ip.trim()}
</Badge>
))}
</div>
</div>
</div>
) : hasAuth ? (
<div className="flex flex-col">
<span></span>
<Badge variant="secondary">
{channel.username}:{channel.password}
</Badge>
</div>
) : (
<span className="text-sm text-gray-400"></span>
)}
</div>
)
) : hasAuth ? (
<div className="flex flex-col">
<span></span>
<Badge variant="secondary">
{channel.username}:{channel.password}
</Badge>
</div>
) : (
<span className="text-sm text-gray-400"></span>
)}
</div>
)
},
},
},
{ 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"),
},
]}
/>
</Suspense>
{
header: "过期时间",
accessorKey: "expired_at",
cell: ({ row }) =>
format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -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<typeof addUserSchema>
type FormValues = z.infer<typeof filterSchema>
export default function UserPage() {
const [filters, setFilters] = useState<FilterValues>({})
const [editingRowId, setEditingRowId] = useState<number | null>(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<FormValues>({
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<AddUserFormValues>({
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<Cust>(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 (
<div className="space-y-3">
<form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入手机号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入账户" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="identified"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="enabled"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
phone: "",
name: "",
identified: "all",
enabled: "all",
created_at_start: "",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
<Button type="button" onClick={openAddDialog}>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Cust>
{...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 (
<Input
value={editPhone}
onChange={e => 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 (
<Input
value={editEmail}
onChange={e => 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 (
<span
className={
balance > 0 ? "text-green-500" : "text-orange-500"
}
>
{balance.toFixed(2)}
</span>
)
},
},
{
header: "实名状态",
accessorKey: "id_type",
cell: ({ row }) => (
<Badge
variant={row.original.id_type === 1 ? "default" : "secondary"}
className={
row.original.id_type === 1
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{row.original.id_type === 1 ? "已认证" : "未认证"}
</Badge>
),
},
{
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 (
<div className="flex gap-2">
<Button
size="sm"
onClick={() => saveEdit(row.original)}
disabled={isSaving}
>
</Button>
<Button
size="sm"
variant="outline"
onClick={cancelEdit}
disabled={isSaving}
>
</Button>
</div>
)
}
return (
<Button size="sm" onClick={() => startEdit(row.original)}>
</Button>
)
},
},
]}
/>
</Suspense>
{/* 添加用户对话框 */}
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form onSubmit={onAddUser} className="space-y-4">
<Controller
name="username"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel> *</FieldLabel>
<Input
{...field}
placeholder="请输入账号"
autoComplete="off"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="password"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel> *</FieldLabel>
<Input
{...field}
type="password"
placeholder="请输入密码至少6位"
autoComplete="off"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="phone"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel> *</FieldLabel>
<Input {...field} placeholder="请输入手机号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="name"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入姓名" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="email"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入邮箱" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="contact_wechat"
control={addControl}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel>/</FieldLabel>
<Input {...field} placeholder="请输入微信或联系方式" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<FieldGroup className="flex-row justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => setIsAddDialogOpen(false)}
>
</Button>
<Button type="submit" disabled={isAdding}>
{isAdding ? "添加中..." : "确定添加"}
</Button>
</FieldGroup>
</form>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -7,6 +7,7 @@ import {
ClipboardList,
Code,
ComputerIcon,
ContactRound,
DollarSign,
Home,
KeyRound,
@@ -164,7 +165,6 @@ export default function Navigation() {
</span>
)}
</div>
{/* Navigation Menu */}
<ScrollArea className="flex-1 py-3">
<nav className="space-y-3">
@@ -186,9 +186,10 @@ export default function Navigation() {
{/* 客户 */}
<NavGroup title="客户">
<NavItem href="/user" icon={Users} label="客户管理" />
<NavItem href="/user" icon={Users} label="客户认领" />
<NavItem href="/trade" icon={Activity} label="交易明细" />
<NavItem href="/billing" icon={DollarSign} label="账单详情" />
<NavItem href="/cust" icon={ContactRound} label="客户管理" />
</NavGroup>
<NavSeparator />
@@ -202,7 +203,7 @@ export default function Navigation() {
label="折扣管理"
/>
<NavItem href="/resources" icon={Package} label="套餐管理" />
<NavItem href="/batch" icon={ClipboardList} label="使用记录" />
<NavItem href="/batch" icon={ClipboardList} label="提取记录" />
<NavItem href="/channel" icon={Code} label="IP管理" />
</NavGroup>

View File

@@ -48,6 +48,8 @@ function Products(props: {
const refresh = useCallback(async () => {
const resp = await getAllProduct()
console.log(resp, "产品管理的resp")
if (resp.success) {
setList(resp.data)
}

View File

@@ -1,10 +1,174 @@
"use client"
import { Suspense } from "react"
import { listResourceLong, listResourceShort } from "@/actions/resources"
import { zodResolver } from "@hookform/resolvers/zod"
import { ColumnDef } from "@tanstack/react-table"
import { format, isBefore, isSameDay } from "date-fns"
import { Box, Loader2, Timer } from "lucide-react"
import { Suspense, useCallback, useMemo, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import {
listResourceLong,
listResourceShort,
updateResource,
} from "@/actions/resources"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
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(),
})
.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 FilterFormValues = z.infer<typeof filterSchema>
interface FilterParams {
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 (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md">
<Timer size={20} />
<span></span>
</div>
)
}
if (type === 2) {
return (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md">
<Box size={20} />
<span></span>
</div>
)
}
return null
}
// 过期徽章
function ExpireBadge({ expireAt }: { expireAt: Date | null | undefined }) {
if (!expireAt) return null
if (isBefore(expireAt, new Date())) {
return <Badge variant="destructive"></Badge>
}
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 (
<div>
@@ -41,21 +205,379 @@ interface ResourceListProps {
function ResourceList({ resourceType }: ResourceListProps) {
const isLong = resourceType === "long"
const listFn = isLong ? listResourceLong : listResourceShort
const table = useDataTable<Resources>((page, size) => listFn({ page, size }))
const [filters, setFilters] = useState<FilterParams>({})
const [updatingId, setUpdatingId] = useState<number | null>(null)
const { control, handleSubmit, reset } = useForm<FilterFormValues>({
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<Resources>(fetchResources)
console.log(table, "我的套餐的table")
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 = {}
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: "ID", accessorKey: "id" },
{
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 (
<div className="flex flex-col gap-1">
<div>{name}</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">{resourceNo}</span>
<ExpireBadge expireAt={expireAt} />
</div>
</div>
)
},
},
{
header: "类型",
cell: ({ row }: { row: { original: Resources } }) => {
return <ResourceTypeBadge resource={row.original} />
},
},
{
header: "IP时效",
cell: ({ row }: { row: { original: Resources } }) => {
const detail = getResourceDetail(row.original)
const live = detail?.live
if (live === undefined) return "-"
return <span>{isLong ? `${live}小时` : `${live}分钟`}</span>
},
},
{
header: "使用情况",
cell: ({ row }: { row: { original: Resources } }) => {
const detail = getResourceDetail(row.original)
const type = getResourceType(row.original)
if (!detail) return <span>-</span>
if (type === 1) {
// 包时
const todayUsage = getTodayUsage(detail.last_at, detail.daily || 0)
return (
<div className="flex gap-1">
<span>
{todayUsage}/{detail.quota}
</span>
</div>
)
} else {
// 包量
if (isLong) {
return (
<div className="flex gap-1">
{detail.used < detail.quota ? (
<span className="text-green-500"></span>
) : (
<span className="text-red-500"></span>
)}
<span>|</span>
<span>
{detail.used}/{detail.quota}
</span>
</div>
)
} else {
return (
<div className="flex gap-1">
<span>
{detail.used}/{detail.quota}
</span>
</div>
)
}
}
},
},
{
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))
},
},
]
: []),
{
header: "状态",
cell: ({ row }: { row: { original: Resources } }) => {
const resource = row.original
const isLoading = updatingId === resource.id
const currentActive = resource.active
return (
<div className="flex items-center gap-2">
<Select
value={currentActive ? "0" : "1"}
onValueChange={val => handleStatusChange(resource, val)}
disabled={isLoading}
>
<SelectTrigger className="min-w-20">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="min-w-(--radix-select-trigger-width)">
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
</SelectContent>
</Select>
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)}
</div>
)
},
},
],
[isLong, updatingId, handleStatusChange],
)
return (
<Suspense>
<DataTable<Resources>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "套餐编号", accessorKey: "resource_no" },
{ header: "状态", accessorKey: "active" },
{ header: "类型", accessorKey: "type" },
{ header: "创建时间", accessorKey: "created_at" },
{ header: "更新时间", accessorKey: "updated_at" },
]}
/>
</Suspense>
<div className="space-y-3">
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="user_phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入会员号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="resource_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入套餐号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="status"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-32">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="type"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-32">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="expired"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-32">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
user_phone: "",
resource_no: "",
status: "all",
type: "all",
created_at_start: "",
created_at_end: "",
expired: "all",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense fallback={<div className="text-center p-4">...</div>}>
<DataTable<Resources> {...table} columns={columns} />
</Suspense>
</div>
)
}

View File

@@ -1,129 +1,386 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { CheckCircle, Clock, XCircle } from "lucide-react"
import { Suspense } from "react"
import { Suspense, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { getPageTrade } 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_phone?: string
inner_no?: string
method?: number
platform?: number
status?: number
created_at_start?: Date
created_at_end?: Date
}
const filterSchema = z
.object({
user_phone: z
.string()
.optional()
.transform(val => val?.trim()),
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<typeof filterSchema>
export default function TradePage() {
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
user_phone: "",
inner_no: "",
method: "all",
platform: "all",
status: "all",
created_at_start: "",
created_at_end: "",
},
})
const table = useDataTable<Trade>((page, size) =>
getPageTrade({ page, size }),
getPageTrade({ page, size, ...filters }),
)
console.log(table, "交易明细的table")
const onFilter = handleSubmit(data => {
const result: FilterValues = {}
if (data.user_phone?.trim()) result.user_phone = data.user_phone.trim()
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 (
<Suspense>
<DataTable<Trade>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "套餐号", accessorKey: "inner_no" },
{
header: "支付方式",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "其他",
4: "支付宝",
5: "微信",
}
return (
<div>{methodMap[row.original.method as number] || "未知"}</div>
)
},
},
{
header: "支付金额",
accessorKey: "payment",
cell: ({ row }) => {
const payment =
typeof row.original.payment === "string"
? parseFloat(row.original.payment)
: row.original.payment || 0
return (
<div className="flex gap-1">
<span
className={
payment > 0 ? "text-green-500" : "text-orange-500"
}
>
{payment.toFixed(2)}
</span>
</div>
)
},
},
{
header: "支付平台",
accessorKey: "platform",
cell: ({ row }) => {
const platform = row.original.platform
if (!platform) return <span>-</span>
return (
<div className="flex items-center gap-2">
{platform === 1 ? (
<span></span>
) : platform === 2 ? (
<span></span>
) : (
<span>-</span>
)}
</div>
)
},
},
// { header: "已退款", accessorKey: "refunded" },
{
header: "支付状态",
accessorKey: "status",
cell: ({ row }) => {
const status = row.original.status
<div className="space-y-3">
{/* 筛选表单 */}
<form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="user_phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入会员号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
switch (status) {
case 0:
return (
<div className="flex items-center gap-2 text-yellow-600">
<Clock className="h-4 w-4" />
<span></span>
</div>
)
case 1:
return (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span></span>
</div>
)
case 2:
return (
<div className="flex items-center gap-2 text-gray-500">
<XCircle className="h-4 w-4" />
<span></span>
</div>
)
default:
return <span className="text-gray-400">-</span>
}
<Controller
name="inner_no"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入订单号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="method"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
<SelectItem value="3"></SelectItem>
<SelectItem value="4"></SelectItem>
<SelectItem value="5"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="platform"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="status"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_start"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="created_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input type="date" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
user_phone: "",
inner_no: "",
method: "all",
platform: "all",
status: "all",
created_at_start: "",
created_at_end: "",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Trade>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{
header: "会员号",
accessorFn: row => row.user?.phone || "-",
},
},
{ header: "购买套餐", accessorKey: "subject" },
{ header: "类型", accessorKey: "type" },
{
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"),
},
]}
/>
</Suspense>
{
header: "订单号",
accessorKey: "inner_no",
},
{
header: "渠道订单号",
accessorKey: "outer_no",
},
{
header: "支付渠道",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "商福通",
4: "商福通渠道支付宝",
5: "商福通渠道微信",
}
return (
<div>
{methodMap[row.original.method as number] || "未知"}
</div>
)
},
},
{
header: "支付金额",
accessorKey: "payment",
cell: ({ row }) => {
const payment =
typeof row.original.payment === "string"
? parseFloat(row.original.payment)
: row.original.payment || 0
return (
<div className="flex gap-1">
<span
className={
payment > 0 ? "text-green-500" : "text-orange-500"
}
>
{payment.toFixed(2)}
</span>
</div>
)
},
},
{
header: "支付平台",
accessorKey: "platform",
cell: ({ row }) => {
const platform = row.original.platform
if (!platform) return <span>-</span>
return (
<div className="flex items-center gap-2">
{platform === 1 ? (
<span></span>
) : platform === 2 ? (
<span></span>
) : (
<span>-</span>
)}
</div>
)
},
},
{
header: "支付状态",
accessorKey: "status",
cell: ({ row }) => {
const status = row.original.status
switch (status) {
case 0:
return (
<div className="flex items-center gap-2 text-yellow-600">
<Clock className="h-4 w-4" />
<span></span>
</div>
)
case 1:
return (
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span></span>
</div>
)
case 2:
return (
<div className="flex items-center gap-2 text-gray-500">
<XCircle className="h-4 w-4" />
<span></span>
</div>
)
default:
return <span className="text-gray-400">-</span>
}
},
},
{ header: "购买套餐", accessorKey: "subject" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -1,25 +1,214 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { Suspense } from "react"
import { Suspense, useCallback, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
import { bindAdmin, getPageUsers } from "@/actions/user"
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { useFetch } from "@/hooks/data"
import type { User } from "@/models/user"
type FilterValues = {
phone?: string
name?: string
identified?: boolean
enabled?: boolean
assigned?: boolean
}
const filterSchema = z.object({
phone: z.string().optional(),
name: z.string().optional(),
identified: z.string().optional(),
enabled: z.string().optional(),
assigned: z.string().optional(),
})
type FormValues = z.infer<typeof filterSchema>
export default function UserPage() {
const table = useDataTable<User>((page, size) => getPageUsers({ page, size }))
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
phone: "",
name: "",
identified: "all",
enabled: "all",
assigned: "all",
},
})
const fetchUsers = useCallback(
(page: number, size: number) => getPageUsers({ page, size, ...filters }),
[filters],
)
const table = useDataTable<User>(fetchUsers)
const bind = useFetch(table, (id: number) => bindAdmin({ id }), {
done: "用户已认领",
fail: "用户认领失败",
})
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"
if (data.assigned && data.assigned !== "all")
result.assigned = data.assigned === "has"
setFilters(result)
table.pagination.onPageChange(1)
})
return (
<div>
<div className="space-y-3">
<form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="phone"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入手机号" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="name"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-40 flex-none"
>
<FieldLabel></FieldLabel>
<Input {...field} placeholder="请输入账户" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="identified"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="enabled"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="0"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="assigned"
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid} className="w-24">
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue placeholder="全部" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="has"></SelectItem>
<SelectItem value="none"></SelectItem>
</SelectContent>
</Select>
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
onClick={() => {
reset({
phone: "",
name: "",
identified: "all",
enabled: "all",
assigned: "all",
})
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<User>
{...table}
columns={[
{ header: "ID", accessorKey: "id" },
{ header: "账号", accessorKey: "username" },
{ header: "手机", accessorKey: "phone" },
{ header: "邮箱", accessorKey: "email" },
@@ -28,60 +217,67 @@ export default function UserPage() {
header: "余额",
accessorKey: "balance",
cell: ({ row }) => {
const balance =
typeof row.original.balance === "string"
? parseFloat(row.original.balance)
: row.original.balance || 0
const balance = Number(row.original.balance) || 0
return (
<div className="flex gap-1">
<span
className={
balance > 0 ? "text-green-500" : "text-orange-500"
}
>
{balance.toFixed(2)}
</span>
</div>
<span
className={
balance > 0 ? "text-green-500" : "text-orange-500"
}
>
{balance.toFixed(2)}
</span>
)
},
},
{
header: "认证状态",
header: "实名状态",
accessorKey: "id_type",
cell: ({ row }) => (
<Badge
variant={row.original.id_type === 1 ? "default" : "secondary"}
className={
row.original.id_type === 1
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{row.original.id_type === 1 ? "已认证" : "未认证"}
</Badge>
),
},
{
header: "身份证号",
accessorKey: "id_no",
cell: ({ row }) => {
const status = row.original.id_type
return (
<Badge
variant={status === 1 ? "default" : "secondary"}
className={
status === 1
? "bg-green-100 text-green-800"
: "bg-gray-100 text-gray-800"
}
>
{status === 1 ? "已认证" : "未认证"}
</Badge>
)
const idNo = row.original.id_no
return idNo ? `${idNo.slice(0, 6)}****${idNo.slice(-4)}` : "-"
},
},
{
header: "账号状态",
accessorKey: "status",
cell: ({ row }) => {
const status = row.original.status
return status === 1 ? "正常" : ""
},
cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"),
},
{ header: "联系方式", accessorKey: "contact_wechat" },
{
header: "管理员",
cell: ({ row }) => row.original.admin?.name,
cell: ({ row }) => row.original.admin?.name || "-",
},
{
header: "最后登录时间",
accessorKey: "last_login",
cell: ({ row }) =>
format(new Date(row.original.last_login), "yyyy-MM-dd HH:mm"),
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: "创建时间",
@@ -95,7 +291,7 @@ export default function UserPage() {
header: "操作",
cell: ctx => (
<Button
size={"sm"}
size="sm"
onClick={() => bind(ctx.row.original.id)}
disabled={!!ctx.row.original.admin_id}
>

View File

@@ -1,5 +1,5 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { toast } from "sonner"
import { useStatus } from "@/hooks/data"
import type { ApiResponse, PageRecord } from "@/lib/api"
@@ -13,7 +13,10 @@ export function useDataTable<T>(
const [page, setPage] = useState(1)
const [size, setSize] = useState(10)
const [total, setTotal] = useState(0)
const fetchRef = useRef(fetch)
useEffect(() => {
fetchRef.current = fetch
}, [fetch])
const refresh = useCallback(
async (_page?: number, _size?: number) => {
setStatus("load")

View File

@@ -121,13 +121,11 @@ function Pagination({
const paginationItems = generatePaginationItems()
return (
<div
className={`flex flex-wrap items-center justify-end gap-4 ${className || ""}`}
>
<div className={`flex flex-wrap items-center gap-4 ${className || ""}`}>
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
{total}
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
<SelectTrigger className="h-8 w-20 bg-card">
<SelectTrigger className="h-8 w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>

View File

@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import type * as React from "react"
import { cn } from "@/lib/utils"
@@ -27,7 +27,7 @@ function TabsList({
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
className,
)}
{...props}
/>
@@ -43,7 +43,7 @@ function TabsTrigger({
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
className,
)}
{...props}
/>

View File

@@ -1,3 +1,10 @@
export const BASE_URL = process.env.API_BASE_URL
export const CLIENT_ID = process.env.CLIENT_ID
export const CLIENT_SECRET = process.env.CLIENT_SECRET
// 产品代码枚举
export enum ProductCode {
All = "all",
Short = "short",
Long = "long",
}

View File

@@ -1,3 +1,6 @@
import type { Resource } from "./resources"
import type { User } from "./user"
export type Batch = {
id: number
batch_no: string
@@ -8,4 +11,6 @@ export type Batch = {
count: string
resource_id: string
time: string
user?: User
resource?: Resource
}

View File

@@ -1,7 +1,18 @@
import type { Resource } from "./resources"
import type { Trade } from "./trade"
import type { User } from "./user"
export type Billing = {
id: string
bill_no: string
type: number
info: string
amount: number
actual: number
inner_no: string
created_at: Date
updated_at: Date
user: User
resource?: Resource
trade?: Trade
}

View File

@@ -1,3 +1,6 @@
import type { Resource } from "./resources"
import type { User } from "./user"
export type Channel = {
id: number
batch_no: string
@@ -12,4 +15,9 @@ export type Channel = {
created_at: Date
updated_at: Date
expired_at: Date
edge_id: number
edge_ref: string
ip: string
user?: User
resource?: Resource
}

28
src/models/cust.ts Normal file
View File

@@ -0,0 +1,28 @@
export type Cust = {
id: number
admin_id?: number
phone: string
admin?: Admin
has_password: boolean
username: string
email: string
name: string
avatar: string
status: number
balance: number
id_type: number
id_no: string
id_token: string
contact_qq: string
contact_wechat: string
last_login: Date
last_login_host: string
last_login_agent: string
last_login_ip: string
created_at: Date
updated_at: Date
}
export type Admin = {
name: string
password: string
}

View File

@@ -1,8 +1,56 @@
export type Resources = {
import type { ProductSku } from "./product_sku"
import type { User } from "./user"
type ResourceBase = {
id: number
user_id: number
resource_no: string
active: string
type: string
active: boolean
created_at: Date
updated_at: Date
deleted_at: Date | null
user: User
}
type ResourceShort = {
id: number
resource_id: number
type: number
live: number
quota: number
used: number
daily: number
last_at: Date | null
expire_at: Date
sku?: ProductSku
}
type ResourceLong = {
id: number
resource_id: number
type: number
live: number
quota: number
used: number
daily: number
last_at: Date | null
expire_at: Date
sku?: ProductSku
}
export type Resource<T extends 1 | 2 = 1 | 2> = ResourceBase &
(T extends 1
? {
type: 1
short: ResourceShort
}
: T extends 2
? {
type: 2
long: ResourceLong
}
: {})
export type Resources = Resource<1> | Resource<2>
export type ResourcesShort = ResourceShort
export type ResourcesLong = ResourceLong

View File

@@ -1,10 +1,16 @@
import type { User } from "./user"
export type Trade = {
id: number
inner_no: string
method: number
payment: string
platform: number
type: number
subject: string
status: number
created_at: Date
canceled_at: Date
updated_at: Date
user?: User
}

View File

@@ -20,6 +20,7 @@ export type User = {
last_login: Date
last_login_host: string
last_login_agent: string
last_login_ip: string
created_at: Date
updated_at: Date
}