添加表单查询和调整表格字段以及功能
This commit is contained in:
@@ -2,6 +2,17 @@ import type { PageRecord } from "@/lib/api"
|
|||||||
import type { Batch } from "@/models/batch"
|
import type { Batch } from "@/models/batch"
|
||||||
import { callByUser } from "./base"
|
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)
|
return callByUser<PageRecord<Batch>>("/api/admin/batch/page", params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,28 @@
|
|||||||
import type { PageRecord } from "@/lib/api"
|
import type { PageRecord } from "@/lib/api"
|
||||||
|
import { ProductCode } from "@/lib/base"
|
||||||
import type { Billing } from "@/models/billing"
|
import type { Billing } from "@/models/billing"
|
||||||
|
import type { ProductSku } from "@/models/product_sku"
|
||||||
import { callByUser } from "./base"
|
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)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,17 @@ import type { PageRecord } from "@/lib/api"
|
|||||||
import type { Channel } from "@/models/channel"
|
import type { Channel } from "@/models/channel"
|
||||||
import { callByUser } from "./base"
|
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)
|
return callByUser<PageRecord<Channel>>("/api/admin/channel/page", params)
|
||||||
}
|
}
|
||||||
|
|||||||
25
src/actions/cust.ts
Normal file
25
src/actions/cust.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -2,19 +2,32 @@ import type { PageRecord } from "@/lib/api"
|
|||||||
import type { Resources } from "@/models/resources"
|
import type { Resources } from "@/models/resources"
|
||||||
import { callByUser } from "./base"
|
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>>(
|
return callByUser<PageRecord<Resources>>(
|
||||||
"/api/admin/resource/long/page",
|
"/api/admin/resource/long/page",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listResourceShort(params: {
|
export async function listResourceShort(params: ResourceListParams) {
|
||||||
page: number
|
|
||||||
size: number
|
|
||||||
}) {
|
|
||||||
return callByUser<PageRecord<Resources>>(
|
return callByUser<PageRecord<Resources>>(
|
||||||
"/api/admin/resource/short/page",
|
"/api/admin/resource/short/page",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function updateResource(data: { id: number; active?: boolean }) {
|
||||||
|
return callByUser<Resources>("/api/admin/resource/update", data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,16 @@ import type { PageRecord } from "@/lib/api"
|
|||||||
import type { Trade } from "@/models/trade"
|
import type { Trade } from "@/models/trade"
|
||||||
import { callByUser } from "./base"
|
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)
|
return callByUser<PageRecord<Trade>>("/api/admin/trade/page", params)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ export async function getPageUsers(params: { page: number; size: number }) {
|
|||||||
return callByUser<PageRecord<User>>("/api/admin/user/page", params)
|
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", {
|
return callByUser("/api/admin/user/bind", {
|
||||||
user_id: params.id,
|
user_id: params.id,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -102,8 +102,10 @@ export default function Appbar(props: { admin: Admin }) {
|
|||||||
nodes: "节点列表",
|
nodes: "节点列表",
|
||||||
trade: "交易明细",
|
trade: "交易明细",
|
||||||
billing: "账单详情",
|
billing: "账单详情",
|
||||||
|
cust: "客户管理",
|
||||||
|
product: "产品管理",
|
||||||
resources: "套餐管理",
|
resources: "套餐管理",
|
||||||
batch: "使用记录",
|
batch: "提取记录",
|
||||||
channel: "IP管理",
|
channel: "IP管理",
|
||||||
pools: "IP池管理",
|
pools: "IP池管理",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +1,285 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
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 { getPageBatch } from "@/actions/batch"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
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"
|
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() {
|
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) =>
|
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 (
|
return (
|
||||||
<Suspense fallback={<div>Loading...</div>}>
|
<div className="space-y-3">
|
||||||
<DataTable<Batch>
|
{/* 筛选表单 */}
|
||||||
{...table}
|
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
|
||||||
columns={[
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
{ header: "ID", accessorKey: "id" },
|
<Controller
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
name="batch_no"
|
||||||
{ header: "省份", accessorKey: "prov" },
|
control={control}
|
||||||
{ header: "城市", accessorKey: "city" },
|
render={({ field, fieldState }) => (
|
||||||
{ header: "提取IP", accessorKey: "ip" },
|
<Field
|
||||||
{ header: "运营商", accessorKey: "isp" },
|
data-invalid={fieldState.invalid}
|
||||||
{ header: "提取数量", accessorKey: "count" },
|
className="w-40 flex-none"
|
||||||
{ header: "资源数量", accessorKey: "resource_id" },
|
>
|
||||||
{
|
<FieldLabel>批次号</FieldLabel>
|
||||||
header: "提取时间",
|
<Input {...field} placeholder="请输入批次号" />
|
||||||
accessorKey: "time",
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
cell: ({ row }) =>
|
</Field>
|
||||||
format(new Date(row.original.time), "yyyy-MM-dd HH:mm"),
|
)}
|
||||||
},
|
/>
|
||||||
]}
|
<Controller
|
||||||
/>
|
name="resource_no"
|
||||||
</Suspense>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,95 +1,406 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CreditCard } from "lucide-react"
|
import { Suspense, useEffect, useState } from "react"
|
||||||
import { Suspense } from "react"
|
import { Controller, useForm } from "react-hook-form"
|
||||||
import { getPageBill } from "@/actions/bill"
|
import { toast } from "sonner"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { getPageBill, getSkuList } from "@/actions/bill"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
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"
|
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() {
|
export default function BillingPage() {
|
||||||
const table = useDataTable<Billing>((page, size) =>
|
const [filters, setFilters] = useState<FilterValues>({})
|
||||||
getPageBill({ page, size }),
|
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 (
|
return (
|
||||||
<Suspense>
|
<div className="space-y-3">
|
||||||
<DataTable<Billing>
|
{/* 筛选表单 */}
|
||||||
{...table}
|
<form onSubmit={onFilter} className="bg-white p-4">
|
||||||
columns={[
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
{ header: "ID", accessorKey: "id" },
|
<Controller
|
||||||
{ header: "账单号", accessorKey: "bill_no" },
|
name="phone"
|
||||||
{
|
control={control}
|
||||||
header: "账单详情",
|
render={({ field, fieldState }) => (
|
||||||
accessorKey: "info",
|
<Field
|
||||||
cell: ({ row }) => {
|
data-invalid={fieldState.invalid}
|
||||||
const bill = row.original
|
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 (
|
<Controller
|
||||||
<div className="flex items-center gap-2">
|
name="product_code"
|
||||||
{/* 类型展示 */}
|
control={control}
|
||||||
<div className="shrink-0">
|
render={({ field, fieldState }) => (
|
||||||
{bill.type === 1 && (
|
<Field
|
||||||
<div className="flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md">
|
data-invalid={fieldState.invalid}
|
||||||
<CreditCard size={16} />
|
className="w-32 flex-none"
|
||||||
<span>消费</span>
|
>
|
||||||
</div>
|
<FieldLabel>产品类型</FieldLabel>
|
||||||
)}
|
<Select
|
||||||
{bill.type === 2 && (
|
value={skuProductCode}
|
||||||
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md">
|
onValueChange={value => {
|
||||||
<CreditCard size={16} />
|
setSkuProductCode(value as ProductCode)
|
||||||
<span>退款</span>
|
// 同步到表单值
|
||||||
</div>
|
field.onChange(value)
|
||||||
)}
|
}}
|
||||||
{bill.type === 3 && (
|
>
|
||||||
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md">
|
<SelectTrigger>
|
||||||
<CreditCard size={16} />
|
<SelectValue
|
||||||
<span>充值</span>
|
placeholder={"请选择"}
|
||||||
</div>
|
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>
|
||||||
|
)
|
||||||
{/* 账单详情 */}
|
},
|
||||||
<div className="text-sm">{bill.info}</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
header: "实付金额",
|
||||||
header: "支付信息",
|
accessorKey: "actual",
|
||||||
accessorKey: "amount",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => {
|
const actual =
|
||||||
const amount =
|
typeof row.original.actual === "string"
|
||||||
typeof row.original.amount === "string"
|
? parseFloat(row.original.actual)
|
||||||
? parseFloat(row.original.amount)
|
: row.original.actual || 0
|
||||||
: row.original.amount || 0
|
return (
|
||||||
return (
|
<div className="flex gap-1">
|
||||||
<div className="flex gap-1">
|
<span
|
||||||
<span
|
className={
|
||||||
className={
|
actual > 0 ? "text-green-500" : "text-orange-500"
|
||||||
amount > 0 ? "text-green-500" : "text-orange-500"
|
}
|
||||||
}
|
>
|
||||||
>
|
¥{actual.toFixed(2)}
|
||||||
¥{amount.toFixed(2)}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
},
|
||||||
},
|
},
|
||||||
},
|
{ header: "套餐名称", accessorKey: "info" },
|
||||||
// { header: "资源数量", accessorKey: "resource_id" },
|
{ header: "账单号", accessorKey: "bill_no" },
|
||||||
{
|
{ header: "订单号", accessorKey: "trade.inner_no" },
|
||||||
header: "创建时间",
|
{
|
||||||
accessorKey: "created_at",
|
header: "创建时间",
|
||||||
cell: ({ row }) =>
|
accessorKey: "created_at",
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
cell: ({ row }) =>
|
||||||
},
|
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
||||||
{
|
},
|
||||||
header: "更新时间",
|
]}
|
||||||
accessorKey: "updated_at",
|
/>
|
||||||
cell: ({ row }) =>
|
</Suspense>
|
||||||
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
|
</div>
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,107 +1,375 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
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 { getPageChannel } from "@/actions/channel"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Badge } from "@/components/ui/badge"
|
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"
|
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() {
|
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) =>
|
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 (
|
return (
|
||||||
<Suspense>
|
<div className="space-y-3">
|
||||||
<DataTable<Channel>
|
{/* 筛选表单 */}
|
||||||
{...table}
|
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
|
||||||
columns={[
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
{ header: "ID", accessorKey: "id" },
|
<Controller
|
||||||
{ header: "批次号", accessorKey: "batch_no" },
|
name="batch_no"
|
||||||
{ header: "省份", accessorKey: "filter_prov" },
|
control={control}
|
||||||
{ header: "城市", accessorKey: "filter_city" },
|
render={({ field, fieldState }) => (
|
||||||
{
|
<Field
|
||||||
header: "运营商",
|
data-invalid={fieldState.invalid}
|
||||||
accessorKey: "filter_isp",
|
className="w-40 flex-none"
|
||||||
cell: ({ row }) => {
|
>
|
||||||
const value = row.getValue("filter_isp")
|
<FieldLabel>批次号</FieldLabel>
|
||||||
if (!value || value === "all") return "不限"
|
<Input {...field} placeholder="请输入批次号" />
|
||||||
if (value === 1) return "电信"
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
if (value === 2) return "联通"
|
</Field>
|
||||||
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
|
|
||||||
|
|
||||||
const hasWhitelist =
|
<Controller
|
||||||
channel.whitelists && channel.whitelists.trim() !== ""
|
name="user_phone"
|
||||||
const hasAuth = channel.username && channel.password
|
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 (
|
<Controller
|
||||||
<div className="flex flex-col gap-1 min-w-0">
|
name="resource_no"
|
||||||
{hasWhitelist ? (
|
control={control}
|
||||||
<div className="flex flex-col">
|
render={({ field, fieldState }) => (
|
||||||
<span>白名单</span>
|
<Field
|
||||||
<div className="flex flex-wrap gap-1 max-w-50">
|
data-invalid={fieldState.invalid}
|
||||||
{channel.whitelists.split(",").map(ip => (
|
className="w-40 flex-none"
|
||||||
<Badge key={ip.trim()} variant="secondary">
|
>
|
||||||
{ip.trim()}
|
<FieldLabel>套餐号</FieldLabel>
|
||||||
</Badge>
|
<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>
|
||||||
</div>
|
) : hasAuth ? (
|
||||||
) : hasAuth ? (
|
<div className="flex flex-col">
|
||||||
<div className="flex flex-col">
|
<span>账号密码</span>
|
||||||
<span>账号密码</span>
|
<Badge variant="secondary">
|
||||||
<Badge variant="secondary">
|
{channel.username}:{channel.password}
|
||||||
{channel.username}:{channel.password}
|
</Badge>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<span className="text-sm text-gray-400">无认证</span>
|
||||||
<span className="text-sm text-gray-400">无认证</span>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{ header: "资源数量", accessorKey: "resource_id" },
|
header: "过期时间",
|
||||||
{
|
accessorKey: "expired_at",
|
||||||
header: "创建时间",
|
cell: ({ row }) =>
|
||||||
accessorKey: "created_at",
|
format(new Date(row.original.expired_at), "yyyy-MM-dd HH:mm"),
|
||||||
cell: ({ row }) =>
|
},
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
]}
|
||||||
},
|
/>
|
||||||
{
|
</Suspense>
|
||||||
header: "更新时间",
|
</div>
|
||||||
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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
608
src/app/(root)/cust/page.tsx
Normal file
608
src/app/(root)/cust/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Code,
|
Code,
|
||||||
ComputerIcon,
|
ComputerIcon,
|
||||||
|
ContactRound,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Home,
|
Home,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
@@ -164,7 +165,6 @@ export default function Navigation() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation Menu */}
|
{/* Navigation Menu */}
|
||||||
<ScrollArea className="flex-1 py-3">
|
<ScrollArea className="flex-1 py-3">
|
||||||
<nav className="space-y-3">
|
<nav className="space-y-3">
|
||||||
@@ -186,9 +186,10 @@ export default function Navigation() {
|
|||||||
|
|
||||||
{/* 客户 */}
|
{/* 客户 */}
|
||||||
<NavGroup title="客户">
|
<NavGroup title="客户">
|
||||||
<NavItem href="/user" icon={Users} label="客户管理" />
|
<NavItem href="/user" icon={Users} label="客户认领" />
|
||||||
<NavItem href="/trade" icon={Activity} label="交易明细" />
|
<NavItem href="/trade" icon={Activity} label="交易明细" />
|
||||||
<NavItem href="/billing" icon={DollarSign} label="账单详情" />
|
<NavItem href="/billing" icon={DollarSign} label="账单详情" />
|
||||||
|
<NavItem href="/cust" icon={ContactRound} label="客户管理" />
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|
||||||
<NavSeparator />
|
<NavSeparator />
|
||||||
@@ -202,7 +203,7 @@ export default function Navigation() {
|
|||||||
label="折扣管理"
|
label="折扣管理"
|
||||||
/>
|
/>
|
||||||
<NavItem href="/resources" icon={Package} 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管理" />
|
<NavItem href="/channel" icon={Code} label="IP管理" />
|
||||||
</NavGroup>
|
</NavGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ function Products(props: {
|
|||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const resp = await getAllProduct()
|
const resp = await getAllProduct()
|
||||||
|
console.log(resp, "产品管理的resp")
|
||||||
|
|
||||||
if (resp.success) {
|
if (resp.success) {
|
||||||
setList(resp.data)
|
setList(resp.data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,174 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { Suspense } from "react"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { listResourceLong, listResourceShort } from "@/actions/resources"
|
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 { 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import type { Resources } from "@/models/resources"
|
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() {
|
export default function ResourcesPage() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -41,21 +205,379 @@ interface ResourceListProps {
|
|||||||
function ResourceList({ resourceType }: ResourceListProps) {
|
function ResourceList({ resourceType }: ResourceListProps) {
|
||||||
const isLong = resourceType === "long"
|
const isLong = resourceType === "long"
|
||||||
const listFn = isLong ? listResourceLong : listResourceShort
|
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 (
|
return (
|
||||||
<Suspense>
|
<div className="space-y-3">
|
||||||
<DataTable<Resources>
|
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
|
||||||
{...table}
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
columns={[
|
<Controller
|
||||||
{ header: "ID", accessorKey: "id" },
|
name="user_phone"
|
||||||
{ header: "套餐编号", accessorKey: "resource_no" },
|
control={control}
|
||||||
{ header: "状态", accessorKey: "active" },
|
render={({ field, fieldState }) => (
|
||||||
{ header: "类型", accessorKey: "type" },
|
<Field
|
||||||
{ header: "创建时间", accessorKey: "created_at" },
|
data-invalid={fieldState.invalid}
|
||||||
{ header: "更新时间", accessorKey: "updated_at" },
|
className="w-40 flex-none"
|
||||||
]}
|
>
|
||||||
/>
|
<FieldLabel>会员号</FieldLabel>
|
||||||
</Suspense>
|
<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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,129 +1,386 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
import { format } from "date-fns"
|
||||||
import { CheckCircle, Clock, XCircle } from "lucide-react"
|
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 { getPageTrade } from "@/actions/trade"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
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"
|
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() {
|
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) =>
|
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 (
|
return (
|
||||||
<Suspense>
|
<div className="space-y-3">
|
||||||
<DataTable<Trade>
|
{/* 筛选表单 */}
|
||||||
{...table}
|
<form onSubmit={onFilter} className="bg-white p-4">
|
||||||
columns={[
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
{ header: "ID", accessorKey: "id" },
|
<Controller
|
||||||
{ header: "套餐号", accessorKey: "inner_no" },
|
name="user_phone"
|
||||||
{
|
control={control}
|
||||||
header: "支付方式",
|
render={({ field, fieldState }) => (
|
||||||
accessorKey: "method",
|
<Field
|
||||||
cell: ({ row }) => {
|
data-invalid={fieldState.invalid}
|
||||||
const methodMap: Record<number, string> = {
|
className="w-40 flex-none"
|
||||||
1: "支付宝",
|
>
|
||||||
2: "微信",
|
<FieldLabel>会员号</FieldLabel>
|
||||||
3: "其他",
|
<Input {...field} placeholder="请输入会员号" />
|
||||||
4: "支付宝",
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
5: "微信",
|
</Field>
|
||||||
}
|
)}
|
||||||
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
|
|
||||||
|
|
||||||
switch (status) {
|
<Controller
|
||||||
case 0:
|
name="inner_no"
|
||||||
return (
|
control={control}
|
||||||
<div className="flex items-center gap-2 text-yellow-600">
|
render={({ field, fieldState }) => (
|
||||||
<Clock className="h-4 w-4" />
|
<Field
|
||||||
<span>待支付</span>
|
data-invalid={fieldState.invalid}
|
||||||
</div>
|
className="w-40 flex-none"
|
||||||
)
|
>
|
||||||
case 1:
|
<FieldLabel>订单号</FieldLabel>
|
||||||
return (
|
<Input {...field} placeholder="请输入订单号" />
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
<FieldError>{fieldState.error?.message}</FieldError>
|
||||||
<CheckCircle className="h-4 w-4" />
|
</Field>
|
||||||
<span>支付成功</span>
|
)}
|
||||||
</div>
|
/>
|
||||||
)
|
|
||||||
case 2:
|
<Controller
|
||||||
return (
|
name="method"
|
||||||
<div className="flex items-center gap-2 text-gray-500">
|
control={control}
|
||||||
<XCircle className="h-4 w-4" />
|
render={({ field, fieldState }) => (
|
||||||
<span>取消支付</span>
|
<Field data-invalid={fieldState.invalid} className="w-24">
|
||||||
</div>
|
<FieldLabel>支付渠道</FieldLabel>
|
||||||
)
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
default:
|
<SelectTrigger>
|
||||||
return <span className="text-gray-400">-</span>
|
<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: "订单号",
|
||||||
{ header: "类型", accessorKey: "type" },
|
accessorKey: "inner_no",
|
||||||
{
|
},
|
||||||
header: "创建时间",
|
{
|
||||||
accessorKey: "created_at",
|
header: "渠道订单号",
|
||||||
cell: ({ row }) =>
|
accessorKey: "outer_no",
|
||||||
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
|
},
|
||||||
},
|
{
|
||||||
{
|
header: "支付渠道",
|
||||||
header: "更新时间",
|
accessorKey: "method",
|
||||||
accessorKey: "updated_at",
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) =>
|
const methodMap: Record<number, string> = {
|
||||||
format(new Date(row.original.updated_at), "yyyy-MM-dd HH:mm"),
|
1: "支付宝",
|
||||||
},
|
2: "微信",
|
||||||
]}
|
3: "商福通",
|
||||||
/>
|
4: "商福通渠道支付宝",
|
||||||
</Suspense>
|
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,214 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { format } from "date-fns"
|
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 { bindAdmin, getPageUsers } from "@/actions/user"
|
||||||
import { DataTable, useDataTable } from "@/components/data-table"
|
import { DataTable, useDataTable } from "@/components/data-table"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
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 { useFetch } from "@/hooks/data"
|
||||||
import type { User } from "@/models/user"
|
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() {
|
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 }), {
|
const bind = useFetch(table, (id: number) => bindAdmin({ id }), {
|
||||||
done: "用户已认领",
|
done: "用户已认领",
|
||||||
fail: "用户认领失败",
|
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 (
|
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>
|
<Suspense>
|
||||||
<DataTable<User>
|
<DataTable<User>
|
||||||
{...table}
|
{...table}
|
||||||
columns={[
|
columns={[
|
||||||
|
{ header: "ID", accessorKey: "id" },
|
||||||
{ header: "账号", accessorKey: "username" },
|
{ header: "账号", accessorKey: "username" },
|
||||||
{ header: "手机", accessorKey: "phone" },
|
{ header: "手机", accessorKey: "phone" },
|
||||||
{ header: "邮箱", accessorKey: "email" },
|
{ header: "邮箱", accessorKey: "email" },
|
||||||
@@ -28,60 +217,67 @@ export default function UserPage() {
|
|||||||
header: "余额",
|
header: "余额",
|
||||||
accessorKey: "balance",
|
accessorKey: "balance",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const balance =
|
const balance = Number(row.original.balance) || 0
|
||||||
typeof row.original.balance === "string"
|
|
||||||
? parseFloat(row.original.balance)
|
|
||||||
: row.original.balance || 0
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-1">
|
<span
|
||||||
<span
|
className={
|
||||||
className={
|
balance > 0 ? "text-green-500" : "text-orange-500"
|
||||||
balance > 0 ? "text-green-500" : "text-orange-500"
|
}
|
||||||
}
|
>
|
||||||
>
|
¥{balance.toFixed(2)}
|
||||||
¥{balance.toFixed(2)}
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "认证状态",
|
header: "实名状态",
|
||||||
accessorKey: "id_type",
|
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 }) => {
|
cell: ({ row }) => {
|
||||||
const status = row.original.id_type
|
const idNo = row.original.id_no
|
||||||
return (
|
return idNo ? `${idNo.slice(0, 6)}****${idNo.slice(-4)}` : "-"
|
||||||
<Badge
|
|
||||||
variant={status === 1 ? "default" : "secondary"}
|
|
||||||
className={
|
|
||||||
status === 1
|
|
||||||
? "bg-green-100 text-green-800"
|
|
||||||
: "bg-gray-100 text-gray-800"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{status === 1 ? "已认证" : "未认证"}
|
|
||||||
</Badge>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "账号状态",
|
header: "账号状态",
|
||||||
accessorKey: "status",
|
accessorKey: "status",
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"),
|
||||||
const status = row.original.status
|
|
||||||
return status === 1 ? "正常" : ""
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ header: "联系方式", accessorKey: "contact_wechat" },
|
{ header: "联系方式", accessorKey: "contact_wechat" },
|
||||||
{
|
{
|
||||||
header: "管理员",
|
header: "管理员",
|
||||||
cell: ({ row }) => row.original.admin?.name,
|
cell: ({ row }) => row.original.admin?.name || "-",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: "最后登录时间",
|
header: "最后登录时间",
|
||||||
accessorKey: "last_login",
|
accessorKey: "last_login",
|
||||||
cell: ({ row }) =>
|
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: "创建时间",
|
header: "创建时间",
|
||||||
@@ -95,7 +291,7 @@ export default function UserPage() {
|
|||||||
header: "操作",
|
header: "操作",
|
||||||
cell: ctx => (
|
cell: ctx => (
|
||||||
<Button
|
<Button
|
||||||
size={"sm"}
|
size="sm"
|
||||||
onClick={() => bind(ctx.row.original.id)}
|
onClick={() => bind(ctx.row.original.id)}
|
||||||
disabled={!!ctx.row.original.admin_id}
|
disabled={!!ctx.row.original.admin_id}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client"
|
"use client"
|
||||||
import { useCallback, useEffect, useState } from "react"
|
import { useCallback, useEffect, useRef, useState } from "react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { useStatus } from "@/hooks/data"
|
import { useStatus } from "@/hooks/data"
|
||||||
import type { ApiResponse, PageRecord } from "@/lib/api"
|
import type { ApiResponse, PageRecord } from "@/lib/api"
|
||||||
@@ -13,7 +13,10 @@ export function useDataTable<T>(
|
|||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [size, setSize] = useState(10)
|
const [size, setSize] = useState(10)
|
||||||
const [total, setTotal] = useState(0)
|
const [total, setTotal] = useState(0)
|
||||||
|
const fetchRef = useRef(fetch)
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRef.current = fetch
|
||||||
|
}, [fetch])
|
||||||
const refresh = useCallback(
|
const refresh = useCallback(
|
||||||
async (_page?: number, _size?: number) => {
|
async (_page?: number, _size?: number) => {
|
||||||
setStatus("load")
|
setStatus("load")
|
||||||
|
|||||||
@@ -121,13 +121,11 @@ function Pagination({
|
|||||||
const paginationItems = generatePaginationItems()
|
const paginationItems = generatePaginationItems()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex flex-wrap items-center gap-4 ${className || ""}`}>
|
||||||
className={`flex flex-wrap items-center justify-end gap-4 ${className || ""}`}
|
|
||||||
>
|
|
||||||
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
<div className="flex-none flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
共 {total} 条记录,每页
|
共 {total} 条记录,每页
|
||||||
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
|
<Select value={size.toString()} onValueChange={handlePageSizeChange}>
|
||||||
<SelectTrigger className="h-8 w-20 bg-card">
|
<SelectTrigger className="h-8 w-20">
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||||
|
import type * as React from "react"
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ function TabsList({
|
|||||||
data-slot="tabs-list"
|
data-slot="tabs-list"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
@@ -43,7 +43,7 @@ function TabsTrigger({
|
|||||||
data-slot="tabs-trigger"
|
data-slot="tabs-trigger"
|
||||||
className={cn(
|
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",
|
"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}
|
{...props}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
export const BASE_URL = process.env.API_BASE_URL
|
export const BASE_URL = process.env.API_BASE_URL
|
||||||
export const CLIENT_ID = process.env.CLIENT_ID
|
export const CLIENT_ID = process.env.CLIENT_ID
|
||||||
export const CLIENT_SECRET = process.env.CLIENT_SECRET
|
export const CLIENT_SECRET = process.env.CLIENT_SECRET
|
||||||
|
|
||||||
|
// 产品代码枚举
|
||||||
|
export enum ProductCode {
|
||||||
|
All = "all",
|
||||||
|
Short = "short",
|
||||||
|
Long = "long",
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { Resource } from "./resources"
|
||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
export type Batch = {
|
export type Batch = {
|
||||||
id: number
|
id: number
|
||||||
batch_no: string
|
batch_no: string
|
||||||
@@ -8,4 +11,6 @@ export type Batch = {
|
|||||||
count: string
|
count: string
|
||||||
resource_id: string
|
resource_id: string
|
||||||
time: string
|
time: string
|
||||||
|
user?: User
|
||||||
|
resource?: Resource
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
|
import type { Resource } from "./resources"
|
||||||
|
import type { Trade } from "./trade"
|
||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
export type Billing = {
|
export type Billing = {
|
||||||
|
id: string
|
||||||
|
bill_no: string
|
||||||
type: number
|
type: number
|
||||||
info: string
|
info: string
|
||||||
amount: number
|
amount: number
|
||||||
|
actual: number
|
||||||
|
inner_no: string
|
||||||
created_at: Date
|
created_at: Date
|
||||||
updated_at: Date
|
updated_at: Date
|
||||||
|
user: User
|
||||||
|
resource?: Resource
|
||||||
|
trade?: Trade
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { Resource } from "./resources"
|
||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
export type Channel = {
|
export type Channel = {
|
||||||
id: number
|
id: number
|
||||||
batch_no: string
|
batch_no: string
|
||||||
@@ -12,4 +15,9 @@ export type Channel = {
|
|||||||
created_at: Date
|
created_at: Date
|
||||||
updated_at: Date
|
updated_at: Date
|
||||||
expired_at: Date
|
expired_at: Date
|
||||||
|
edge_id: number
|
||||||
|
edge_ref: string
|
||||||
|
ip: string
|
||||||
|
user?: User
|
||||||
|
resource?: Resource
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/models/cust.ts
Normal file
28
src/models/cust.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -1,8 +1,56 @@
|
|||||||
export type Resources = {
|
import type { ProductSku } from "./product_sku"
|
||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
|
type ResourceBase = {
|
||||||
id: number
|
id: number
|
||||||
|
user_id: number
|
||||||
resource_no: string
|
resource_no: string
|
||||||
active: string
|
active: boolean
|
||||||
type: string
|
|
||||||
created_at: Date
|
created_at: Date
|
||||||
updated_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
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import type { User } from "./user"
|
||||||
|
|
||||||
export type Trade = {
|
export type Trade = {
|
||||||
id: number
|
id: number
|
||||||
inner_no: string
|
inner_no: string
|
||||||
method: number
|
method: number
|
||||||
payment: string
|
payment: string
|
||||||
platform: number
|
platform: number
|
||||||
|
type: number
|
||||||
|
subject: string
|
||||||
status: number
|
status: number
|
||||||
created_at: Date
|
created_at: Date
|
||||||
|
canceled_at: Date
|
||||||
updated_at: Date
|
updated_at: Date
|
||||||
|
user?: User
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export type User = {
|
|||||||
last_login: Date
|
last_login: Date
|
||||||
last_login_host: string
|
last_login_host: string
|
||||||
last_login_agent: string
|
last_login_agent: string
|
||||||
|
last_login_ip: string
|
||||||
created_at: Date
|
created_at: Date
|
||||||
updated_at: Date
|
updated_at: Date
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user