新增余额明细页面,修复页面useId不更新的问题

This commit is contained in:
Eamon
2026-04-11 14:57:45 +08:00
parent 790180a847
commit ed95f0520d
23 changed files with 780 additions and 215 deletions

32
src/actions/balance.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { PageRecord } from "@/lib/api"
import type { Balance } from "@/models/balance"
import { callByUser } from "./base"
export async function getPageBalance(params: {
page: number
size: number
user_phone?: string
bill_id?: string
created_at_start?: Date
created_at_end?: Date
}) {
return callByUser<PageRecord<Balance>>(
"/api/admin/balance-activity/page",
params,
)
}
export async function getBalance(params: {
page: number
size: number
user_id: number
user_phone?: string
bill_id?: string
created_at_start?: Date
created_at_end?: Date
}) {
return callByUser<PageRecord<Balance>>(
"/api/admin/balance-activity/page/of-user",
params,
)
}

View File

@@ -1,9 +1,9 @@
import type { PageRecord } from "@/lib/api"
import type { Cust } from "@/models/cust"
import type { User } from "@/models/user"
import { callByUser } from "./base"
export async function getPageCusts(params: { page: number; size: number }) {
return callByUser<PageRecord<Cust>>("/api/admin/user/page", params)
return callByUser<PageRecord<User>>("/api/admin/user/page", params)
}
export async function updateCust(data: {
id: number
@@ -16,7 +16,7 @@ export async function updateCust(data: {
contact_qq?: string
contact_wechat?: string
}) {
return callByUser<PageRecord<Cust>>("/api/admin/user/update", data)
return callByUser<PageRecord<User>>("/api/admin/user/update", data)
}
export async function createCust(data: {
@@ -32,11 +32,11 @@ export async function createCust(data: {
contact_qq?: string
contact_wechat?: string
}) {
return callByUser<PageRecord<Cust>>("/api/admin/user/create", data)
return callByUser<PageRecord<User>>("/api/admin/user/create", data)
}
export async function getDeposit(params: { user_id: number; amount: string }) {
return callByUser<PageRecord<Cust>>(
return callByUser<PageRecord<User>>(
"/api/admin/user/update/balance-inc",
params,
)
@@ -46,7 +46,7 @@ export async function getDeduction(params: {
user_id: number
amount: string
}) {
return callByUser<PageRecord<Cust>>(
return callByUser<PageRecord<User>>(
"/api/admin/user/update/balance-dec",
params,
)

View File

@@ -32,14 +32,36 @@ export async function updateResource(data: { id: number; active?: boolean }) {
return callByUser<Resources>("/api/admin/resource/update", data)
}
export async function ResourceLong(params: ResourceListParams) {
export async function ResourceLong(params: {
page: number
size: number
user_id: number
user_phone?: string
resource_no?: string
active?: boolean
mode?: number
created_at_start?: Date
created_at_end?: Date
expired?: boolean
}) {
return callByUser<PageRecord<Resources>>(
"/api/admin/resource/long/page/of-user",
params,
)
}
export async function ResourceShort(params: ResourceListParams) {
export async function ResourceShort(params: {
page: number
size: number
user_id: number
user_phone?: string
resource_no?: string
active?: boolean
mode?: number
created_at_start?: Date
created_at_end?: Date
expired?: boolean
}) {
return callByUser<PageRecord<Resources>>(
"/api/admin/resource/short/page/of-user",
params,

View File

@@ -30,3 +30,10 @@ export async function getTrade(params: {
}) {
return callByUser<PageRecord<Trade>>("/api/admin/trade/page/of-user", params)
}
export async function getTradeComplete(params: {
user_id: number
trade_no: string
method: number
}) {
return callByUser<PageRecord<Trade>>("/api/admin/trade/complete", params)
}

View File

@@ -87,6 +87,7 @@ export default function Appbar(props: { admin: Admin }) {
permissions: "权限列表",
discount: "折扣管理",
statistics: "数据统计",
balance: "余额明细",
}
return labels[path] || path

View File

@@ -0,0 +1,231 @@
"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 z from "zod"
import { getPageBalance } from "@/actions/balance"
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 type { Balance } from "@/models/balance"
type FilterValues = {
user_phone?: string
bill_id?: string
admin_id?: string
created_at_start?: Date
created_at_end?: Date
}
const filterSchema = z
.object({
phone: z.string().optional(),
bill_id: z.string().optional(),
admin_id: 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 FormValues = z.infer<typeof filterSchema>
export default function BalancePage() {
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
phone: "",
bill_id: "",
admin_id: "",
created_at_start: "",
created_at_end: "",
},
})
const fetchUsers = useCallback(
(page: number, size: number) => getPageBalance({ page, size, ...filters }),
[filters],
)
const table = useDataTable<Balance>(fetchUsers)
console.log(table, "table")
const onFilter = handleSubmit(data => {
const result: FilterValues = {}
if (data.phone) result.user_phone = data.phone
if (data.bill_id) result.bill_id = data.bill_id
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 (
<div className="space-y-3">
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="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="bill_id"
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()
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Balance>
{...table}
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "",
},
{
header: "账单号",
accessorFn: row => row.bill?.bill_no || "",
},
{
header: "管理员",
accessorKey: "admin_id",
accessorFn: row => row.admin?.name || "",
},
{
header: "变动金额",
accessorKey: "amount",
cell: ({ row }) => {
const amount = row.original.amount
const isPositive = Number(amount) > 0
return (
<div className="flex items-center gap-1">
<span
className={`font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{Number(amount).toFixed(2)}
</span>
</div>
)
},
},
{
header: "余额变化",
accessorKey: "balance_prev",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
¥{Number(row.original.balance_prev).toFixed(2)}
</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-foreground">
¥{Number(row.original.balance_curr).toFixed(2)}
</span>
</div>
),
},
{
header: "备注",
accessorKey: "remark",
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -252,7 +252,7 @@ export default function BatchPage() {
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "-",
accessorFn: row => row.user?.phone || "",
},
{ header: "套餐号", accessorKey: "resource.resource_no" },
{ header: "批次号", accessorKey: "batch_no" },

View File

@@ -97,7 +97,7 @@ export default function BillingPage() {
})
useEffect(() => {
setLoading(true)
setLoading(true)
getSkuList({
product_code: skuProductCode,
})
@@ -328,7 +328,7 @@ export default function BillingPage() {
<DataTable<Billing>
{...table}
columns={[
{ header: "会员号", accessorFn: row => row.user?.phone || "-" },
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
{ header: "套餐号", accessorKey: "resource.resource_no" },
{
header: "账单详情",

View File

@@ -0,0 +1,224 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import z from "zod"
import { getBalance } from "@/actions/balance"
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 type { Balance } from "@/models/balance"
type FilterValues = {
user_phone?: string
bill_id?: string
created_at_start?: Date
created_at_end?: Date
}
const filterSchema = z
.object({
phone: z.string().optional(),
bill_id: z.string().optional(),
admin_id: 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 FormValues = z.infer<typeof filterSchema>
export default function BalancePage() {
const searchParams = useSearchParams()
const router = useRouter()
const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone")
console.log(userPhone, "userPhone")
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
phone: "",
bill_id: "",
admin_id: "",
created_at_start: "",
created_at_end: "",
},
})
const table = useDataTable<Balance>((page, size) =>
getBalance({ page, size, user_id: Number(userId), ...filters }),
)
console.log(table, "仅用户的table")
const onFilter = handleSubmit(data => {
const result: FilterValues = {}
if (data.phone) result.user_phone = data.phone
if (data.bill_id) result.bill_id = data.bill_id
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 (
<div className="space-y-3">
<Button
onClick={() => {
router.back()
}}
className="gap-2"
>
</Button>
<span className="ml-2 text-gray-600">: {userPhone}</span>
<form onSubmit={onFilter} className="bg-white p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4">
<Controller
name="bill_id"
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()
setFilters({})
table.pagination.onPageChange(1)
}}
>
</Button>
</FieldGroup>
</form>
<Suspense>
<DataTable<Balance>
{...table}
columns={[
{
header: "账单号",
accessorFn: row => row.bill?.bill_no || "",
},
{
header: "管理员",
accessorKey: "admin_id",
accessorFn: row => row.admin?.name || "",
},
{
header: "变动金额",
accessorKey: "amount",
cell: ({ row }) => {
const amount = row.original.amount
const isPositive = Number(amount) > 0
return (
<div className="flex items-center gap-1">
<span
className={`font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{Number(amount).toFixed(2)}
</span>
</div>
)
},
},
{
header: "余额变化",
accessorKey: "balance_prev",
cell: ({ row }) => (
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-sm">
¥{Number(row.original.balance_prev).toFixed(2)}
</span>
<span className="text-muted-foreground"></span>
<span className="font-medium text-foreground">
¥{Number(row.original.balance_curr).toFixed(2)}
</span>
</div>
),
},
{
header: "备注",
accessorKey: "remark",
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>
</div>
)
}

View File

@@ -25,7 +25,6 @@ import {
import type { Batch } from "@/models/batch"
type APIFilterParams = {
user_id: number
phone?: string
batch_no?: string
resource_no?: string
@@ -67,9 +66,7 @@ type FilterSchema = z.infer<typeof filterSchema>
export default function BatchPage() {
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const [filters, setFilters] = useState<APIFilterParams>({
user_id: Number(userId),
})
const [filters, setFilters] = useState<APIFilterParams>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
@@ -85,13 +82,11 @@ export default function BatchPage() {
})
const table = useDataTable<Batch>((page, size) =>
getBatch({ page, size, ...filters }),
getBatch({ page, size, user_id: Number(userId), ...filters }),
)
const onFilter = handleSubmit(data => {
const result: APIFilterParams = {
user_id: Number(userId),
}
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()
@@ -225,7 +220,7 @@ export default function BatchPage() {
variant="outline"
onClick={() => {
reset()
setFilters({ user_id: Number(userId) })
setFilters({})
table.pagination.onPageChange(1)
}}
>
@@ -240,7 +235,7 @@ export default function BatchPage() {
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "-",
accessorFn: row => row.user?.phone || "",
},
{ header: "套餐号", accessorKey: "resource.resource_no" },
{ header: "批次号", accessorKey: "batch_no" },

View File

@@ -28,7 +28,6 @@ import { ProductCode } from "@/lib/base"
import type { Billing } from "@/models/billing"
type FilterValues = {
user_id: number
bill_no?: string
user_phone?: string
trade_inner_no?: string
@@ -80,9 +79,7 @@ type FilterSchema = z.infer<typeof filterSchema>
export default function BillingPage() {
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const [filters, setFilters] = useState<FilterValues>({
user_id: Number(userId),
})
const [filters, setFilters] = useState<FilterValues>({})
const [skuOptions, setSkuOptions] = useState<SkuOption[]>([])
const [loading, setLoading] = useState(true)
const [skuProductCode, setSkuProductCode] = useState<ProductCode>(
@@ -131,13 +128,11 @@ export default function BillingPage() {
}, [skuProductCode])
const table = useDataTable<Billing>((page, size) =>
getBill({ page, size, ...filters }),
getBill({ page, size, user_id: Number(userId), ...filters }),
)
const onFilter = handleSubmit(data => {
const result: FilterValues = {
user_id: Number(userId),
}
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()
@@ -309,7 +304,7 @@ export default function BillingPage() {
onClick={() => {
reset()
setSkuProductCode(ProductCode.All)
setFilters({ user_id: Number(userId) })
setFilters({})
table.pagination.onPageChange(1)
}}
>

View File

@@ -19,7 +19,6 @@ import { Input } from "@/components/ui/input"
import type { Channel } from "@/models/channel"
type FilterValues = {
user_id: number
batch_no?: string
user_phone?: string
resource_no?: string
@@ -67,9 +66,7 @@ const ispMap: Record<number, string> = {
export default function ChannelPage() {
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const [filters, setFilters] = useState<FilterValues>({
user_id: Number(userId),
})
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
@@ -85,13 +82,11 @@ export default function ChannelPage() {
})
const table = useDataTable<Channel>((page, size) =>
getChannel({ page, size, ...filters }),
getChannel({ page, size, user_id: Number(userId), ...filters }),
)
const onFilter = handleSubmit(data => {
const result: FilterValues = {
user_id: Number(userId),
}
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()
@@ -218,7 +213,7 @@ export default function ChannelPage() {
variant="outline"
onClick={() => {
reset()
setFilters({ user_id: Number(userId) })
setFilters({})
table.pagination.onPageChange(1)
}}
>
@@ -233,7 +228,7 @@ export default function ChannelPage() {
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "-",
accessorFn: row => row.user?.phone || "",
},
{ header: "套餐号", accessorKey: "resource.resource_no" },
{ header: "批次号", accessorKey: "batch_no" },

View File

@@ -7,8 +7,8 @@ import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { getPageUser } from "@/actions/user"
import { DeductionDialog } from "@/app/(root)/cust/deduction"
import { DepositDialog } from "@/app/(root)/cust/deposit"
// import { DeductionDialog } from "@/app/(root)/cust/deduction"
// import { DepositDialog } from "@/app/(root)/cust/deposit"
import { UpdateDialog } from "@/app/(root)/cust/update"
import { Auth } from "@/components/auth"
import { DataTable } from "@/components/data-table"
@@ -22,6 +22,7 @@ import {
} from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import {
ScopeBalanceActivityReadOfUser,
ScopeBatchReadOfUser,
ScopeBillReadOfUser,
ScopeChannelReadOfUser,
@@ -29,14 +30,10 @@ import {
ScopeTradeReadOfUser,
ScopeUserWrite,
ScopeUserWriteBalance,
ScopeUserWriteBalanceDec,
ScopeUserWriteBalanceInc,
} from "@/lib/scopes"
import type { User } from "@/models/user"
import { AddUserDialog } from "../../cust/create"
// import { ResourcesDialog } from "./resourcesDialog"
interface UserQueryParams {
account?: string
name?: string
@@ -52,10 +49,10 @@ type FormValues = z.infer<typeof filterSchema>
export default function UserQueryPage() {
const [userList, setUserList] = useState<User[]>([])
const [loading, setLoading] = useState(false)
const [depositDialog, setDepositDialog] = useState(false)
const [deposit, setDeposit] = useState<User | null>(null)
const [deductionDialog, setDeductionDialog] = useState(false)
const [deduction, setDeduction] = useState<User | null>(null)
// const [depositDialog, setDepositDialog] = useState(false)
// const [deposit, setDeposit] = useState<User | null>(null)
// const [deductionDialog, setDeductionDialog] = useState(false)
// const [deduction, setDeduction] = useState<User | null>(null)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [currentEditUser, setCurrentEditUser] = useState<User | null>(null)
const [currentFilters, setCurrentFilters] = useState<UserQueryParams>({})
@@ -149,15 +146,15 @@ export default function UserQueryPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="button" variant="outline" onClick={handleReset}>
</Button>
<Auth scope={ScopeUserWrite}>
<Button type="button" onClick={() => setIsAddDialogOpen(true)}>
</Button>
</Auth>
<Button type="button" variant="outline" onClick={handleReset}>
</Button>
<Button type="submit"></Button>
</FieldGroup>
</form>
@@ -166,10 +163,26 @@ export default function UserQueryPage() {
data={userList || []}
status={loading ? "load" : "done"}
columns={[
{ header: "账号", accessorKey: "username" },
{ header: "手机", accessorKey: "phone" },
{ header: "邮箱", accessorKey: "email" },
{ header: "姓名", accessorKey: "name" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "客户来源",
accessorKey: "source",
cell: ({ row }) => {
const sourceMap: Record<number, string> = {
0: "官网注册",
1: "管理员添加",
2: "代理商注册",
3: "代理商添加",
}
return sourceMap[row.original.source] ?? "未知"
},
},
{
header: "余额",
accessorKey: "balance",
@@ -186,6 +199,17 @@ export default function UserQueryPage() {
)
},
},
{ header: "账号", accessorKey: "username" },
{
header: "账号状态",
accessorKey: "status",
cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"),
},
{
header: "客户经理",
cell: ({ row }) => row.original.admin?.name || "",
},
{ header: "姓名", accessorKey: "name" },
{
header: "实名状态",
accessorKey: "id_type",
@@ -202,37 +226,6 @@ export default function UserQueryPage() {
</Badge>
),
},
{
header: "身份证号",
accessorKey: "id_no",
cell: ({ row }) => {
const idNo = row.original.id_no
return idNo ? `${idNo.slice(0, 6)}****${idNo.slice(-4)}` : ""
},
},
{
header: "客户来源",
accessorKey: "source",
cell: ({ row }) => {
const sourceMap: Record<number, string> = {
0: "官网注册",
1: "管理员添加",
2: "代理商注册",
3: "代理商添加",
}
return sourceMap[row.original.source] ?? "未知"
},
},
{
header: "账号状态",
accessorKey: "status",
cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"),
},
{ header: "联系方式", accessorKey: "contact_wechat" },
{
header: "客户经理",
cell: ({ row }) => row.original.admin?.name || "",
},
{
header: "最后登录时间",
accessorKey: "last_login",
@@ -249,12 +242,7 @@ export default function UserQueryPage() {
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: "联系方式", accessorKey: "contact_wechat" },
{
id: "action",
meta: { pin: "right" },
@@ -262,7 +250,7 @@ export default function UserQueryPage() {
cell: ({ row }) => {
return (
<div className="flex flex-wrap gap-2 w-75">
<Auth scope={ScopeUserWriteBalanceInc}>
{/* <Auth scope={ScopeUserWriteBalanceInc}>
<Button
size="sm"
onClick={() => {
@@ -284,7 +272,7 @@ export default function UserQueryPage() {
>
扣款
</Button>
</Auth>
</Auth> */}
<Auth scope={ScopeUserWriteBalance}>
<Button
size="sm"
@@ -352,6 +340,18 @@ export default function UserQueryPage() {
IP管理
</Button>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/balance?userId=${row.original.id}&phone=${row.original.phone}`,
)
}}
>
</Button>
</Auth>
</div>
)
},
@@ -371,19 +371,19 @@ export default function UserQueryPage() {
onSuccess={refreshTable}
/>
<DepositDialog
{/* <DepositDialog
open={depositDialog}
onOpenChange={setDepositDialog}
currentUser={deposit}
onSuccess={refreshTable}
/>
/> */}
<DeductionDialog
{/* <DeductionDialog
open={deductionDialog}
onOpenChange={setDeductionDialog}
currentUser={deduction}
onSuccess={refreshTable}
/>
/> */}
</div>
)
}

View File

@@ -60,7 +60,6 @@ const filterSchema = z
type FormValues = z.infer<typeof filterSchema>
interface FilterParams {
user_id: number
user_phone?: string
resource_no?: string
active?: boolean
@@ -205,9 +204,7 @@ interface ResourceListProps {
function ResourceList({ resourceType }: ResourceListProps) {
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const [filters, setFilters] = useState<FilterParams>({
user_id: Number(userId),
})
const [filters, setFilters] = useState<FilterParams>({})
const isLong = resourceType === "long"
const listFn = isLong ? ResourceLong : ResourceShort
const [updatingId, setUpdatingId] = useState<number | null>(null)
@@ -226,9 +223,9 @@ function ResourceList({ resourceType }: ResourceListProps) {
const fetchResources = useCallback(
(page: number, size: number) => {
return listFn({ page, size, ...filters })
return listFn({ page, size, user_id: Number(userId), ...filters })
},
[listFn, filters],
[listFn, filters, userId],
)
const table = useDataTable<Resources>(fetchResources)
@@ -263,9 +260,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
)
const onFilter = handleSubmit(data => {
const result: FilterParams = {
user_id: Number(userId),
}
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") {
@@ -555,7 +550,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
variant="outline"
onClick={() => {
reset()
setFilters({ user_id: Number(userId) })
setFilters({})
table.pagination.onPageChange(1)
}}
>

View File

@@ -26,7 +26,6 @@ import {
import type { Trade } from "@/models/trade"
type FilterValues = {
user_id: number
inner_no?: string
method?: number
platform?: number
@@ -66,9 +65,7 @@ type FilterSchema = z.infer<typeof filterSchema>
export default function TradePage() {
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const [filters, setFilters] = useState<FilterValues>({
user_id: Number(userId),
})
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
@@ -84,17 +81,15 @@ export default function TradePage() {
const fetchTrades = useCallback(
async (page: number, size: number) => {
return getTrade({ page, size, ...filters })
return getTrade({ page, size, user_id: Number(userId), ...filters })
},
[filters],
[filters, userId],
)
const table = useDataTable<Trade>(fetchTrades)
const onFilter = handleSubmit(data => {
const result: FilterValues = {
user_id: Number(userId),
}
const result: FilterValues = {}
if (data.inner_no?.trim()) result.inner_no = data.inner_no.trim()
if (data.method && data.method !== "all")
result.method = Number(data.method)
@@ -235,7 +230,7 @@ export default function TradePage() {
variant="outline"
onClick={() => {
reset()
setFilters({ user_id: Number(userId) })
setFilters({})
table.pagination.onPageChange(1)
}}
>

View File

@@ -22,9 +22,9 @@ import type { User } from "@/models/user"
const Schema = z.object({
deduction: z
.string()
.min(1, "请输入额")
.min(1, "请输入扣款金额")
.refine(val => !Number.isNaN(Number(val)), "请输入有效的数字")
.refine(val => Number(val) >= 0, "额不能为负数"),
.refine(val => Number(val) >= 0, "额不能为负数"),
})
type FormValues = z.infer<typeof Schema>
@@ -95,7 +95,7 @@ export function DeductionDialog({
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{currentUser?.name || currentUser?.username}
{currentUser?.name || currentUser?.username}
</DialogDescription>
</DialogHeader>
@@ -104,30 +104,20 @@ export function DeductionDialog({
<Field data-invalid={!!errors.deduction}>
<FieldLabel></FieldLabel>
<Input
type="number"
step="0.01"
min="0"
type="text"
placeholder="请输入扣款金额"
{...register("deduction", {
setValueAs: value => {
if (!value) return ""
const num = Number(value)
if (Number.isNaN(num)) return value
return num.toFixed(2)
},
})}
{...register("deduction")}
onInput={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
if (value.startsWith("-")) {
value = value.replace("-", "")
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const parts = value.split(".")
if (parts[1] && parts[1].length > 2) {
value = `${parts[0]}.${parts[1].slice(0, 2)}`
}
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
setValue("deduction", value)
}}
/>

View File

@@ -104,30 +104,20 @@ export function DepositDialog({
<Field data-invalid={!!errors.deposit}>
<FieldLabel></FieldLabel>
<Input
type="number"
step="0.01"
min="0"
type="text" // 改为 text避免 number 输入冲突
placeholder="请输入充值金额"
{...register("deposit", {
setValueAs: value => {
if (!value) return ""
const num = Number(value)
if (Number.isNaN(num)) return value
return num.toFixed(2)
},
})}
{...register("deposit")}
onInput={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
if (value.startsWith("-")) {
value = value.replace("-", "")
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const parts = value.split(".")
if (parts[1] && parts[1].length > 2) {
value = `${parts[0]}.${parts[1].slice(0, 2)}`
}
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
setValue("deposit", value)
}}
/>

View File

@@ -26,6 +26,7 @@ import {
SelectValue,
} from "@/components/ui/select"
import {
ScopeBalanceActivityReadOfUser,
ScopeBatchReadOfUser,
ScopeBillReadOfUser,
ScopeChannelReadOfUser,
@@ -77,7 +78,7 @@ const filterSchema = z
type FormValues = z.infer<typeof filterSchema>
export default function UserPage() {
export default function CustPage() {
const [filters, setFilters] = useState<FilterValues>({})
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
@@ -229,7 +230,11 @@ export default function UserPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Auth scope={ScopeUserWrite}>
<Button type="button" onClick={() => setIsAddDialogOpen(true)}>
</Button>
</Auth>
<Button
type="button"
variant="outline"
@@ -241,11 +246,7 @@ export default function UserPage() {
>
</Button>
<Auth scope={ScopeUserWrite}>
<Button type="button" onClick={() => setIsAddDialogOpen(true)}>
</Button>
</Auth>
<Button type="submit"></Button>
</FieldGroup>
</form>
@@ -253,10 +254,14 @@ export default function UserPage() {
<DataTable<User>
{...table}
columns={[
{ header: "账号", accessorKey: "username" },
{ header: "手机", accessorKey: "phone" },
{ header: "邮箱", accessorKey: "email" },
{ header: "姓名", accessorKey: "name" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
// { header: "邮箱", accessorKey: "email" },
{
header: "客户来源",
accessorKey: "source",
@@ -286,7 +291,22 @@ export default function UserPage() {
)
},
},
{ header: "折扣", accessorKey: "discount.name" },
{ header: "账号", accessorKey: "username" },
{
header: "账号状态",
accessorKey: "status",
cell: ({ row }) => (row.original.status === 1 ? "正常" : "禁用"),
},
{ header: "客户经理", accessorKey: "admin.name" },
{ header: "姓名", accessorKey: "name" },
{
header: "身份证号",
accessorKey: "id_no",
cell: ({ row }) => {
const idNo = row.original.id_no
return idNo ? `${idNo.slice(0, 6)}****${idNo.slice(-4)}` : ""
},
},
{
header: "实名状态",
accessorKey: "id_type",
@@ -303,36 +323,6 @@ export default function UserPage() {
</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: "联系方式",
cell: ({ row }) => {
const qq = row.original.contact_qq || ""
const wechat = row.original.contact_wechat || ""
const hasQQ = qq.trim() !== ""
const hasWechat = wechat.trim() !== ""
if (!hasQQ && !hasWechat) return null
return (
<div className="space-y-1">
{hasWechat && <div>{wechat}</div>}
{hasQQ && <div>QQ{qq}</div>}
</div>
)
},
},
{ header: "客户经理", accessorKey: "admin.name" },
{
header: "最后登录时间",
accessorKey: "last_login",
@@ -349,11 +339,22 @@ export default function UserPage() {
accessorKey: "last_login_ip",
cell: ({ row }) => row.original.last_login_ip || "",
},
{ header: "折扣", accessorKey: "discount.name" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
header: "联系方式",
cell: ({ row }) => {
const qq = row.original.contact_qq || ""
const wechat = row.original.contact_wechat || ""
const hasQQ = qq.trim() !== ""
const hasWechat = wechat.trim() !== ""
if (!hasQQ && !hasWechat) return null
return (
<div className="space-y-1">
{hasWechat && <div>{wechat}</div>}
{hasQQ && <div>QQ{qq}</div>}
</div>
)
},
},
{
id: "action",
@@ -451,6 +452,18 @@ export default function UserPage() {
IP管理
</Button>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/balance?userId=${row.original.id}&phone=${row.original.phone}`,
)
}}
>
</Button>
</Auth>
</div>
)
},

View File

@@ -6,8 +6,6 @@ import {
ChevronsRight,
CircleDollarSign,
ClipboardList,
ClipboardMinus,
Code,
ComputerIcon,
ContactRound,
DollarSign,
@@ -16,11 +14,9 @@ import {
KeyRound,
type LucideIcon,
Package,
PackageSearch,
ScanSearch,
Shield,
ShoppingBag,
SquareActivity,
SquarePercent,
TicketPercent,
Users,
@@ -42,6 +38,7 @@ import {
import {
ScopeAdminRead,
ScopeAdminRoleRead,
ScopeBalanceActivity,
ScopeBatchRead,
ScopeBillRead,
ScopeChannelRead,
@@ -194,6 +191,12 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
label: "交易明细",
requiredScope: ScopeTradeRead,
},
{
href: "/balance",
icon: CircleDollarSign,
label: "余额明细",
requiredScope: ScopeBalanceActivity,
},
{
href: "/billing",
icon: DollarSign,

View File

@@ -283,7 +283,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
() => [
{
header: "会员号",
accessorFn: (row: Resources) => row.user?.phone || "-",
accessorFn: (row: Resources) => row.user?.phone || "",
},
{
header: "套餐",

View File

@@ -2,10 +2,12 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { CheckCircle, Clock, XCircle } from "lucide-react"
import { Suspense, useState } from "react"
import { Suspense, useCallback, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { getPageTrade } from "@/actions/trade"
import { getPageTrade, getTradeComplete } from "@/actions/trade"
import { Auth } from "@/components/auth"
import { DataTable, useDataTable } from "@/components/data-table"
import { Button } from "@/components/ui/button"
import {
@@ -22,6 +24,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ScopeTradeWriteComplete } from "@/lib/scopes"
import type { Trade } from "@/models/trade"
type FilterValues = {
@@ -82,9 +85,14 @@ export default function TradePage() {
},
})
const table = useDataTable<Trade>((page, size) =>
getPageTrade({ page, size, ...filters }),
const fetchTrades = useCallback(
(page: number, size: number) => {
return getPageTrade({ page, size, ...filters })
},
[filters],
)
const table = useDataTable<Trade>(fetchTrades)
const onFilter = handleSubmit(data => {
const result: FilterValues = {}
@@ -105,6 +113,27 @@ export default function TradePage() {
table.pagination.onPageChange(1)
})
const [completingId, setCompletingId] = useState<string | null>(null)
const handleComplete = async (trade: Trade) => {
if (completingId) return
setCompletingId(trade.inner_no)
try {
const result = await getTradeComplete({
user_id: Number(trade.user_id),
trade_no: trade.inner_no,
method: trade.method,
})
if (result.success) {
toast.success("订单已完成")
} else {
toast.error(result.message || "操作失败")
}
} catch (error) {
console.error("完成订单失败:", error)
toast.error("网络错误,请稍后重试")
}
}
return (
<div className="space-y-3">
{/* 筛选表单 */}
@@ -260,7 +289,7 @@ export default function TradePage() {
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "-",
accessorFn: row => row.user?.phone || "",
},
{
header: "订单号",
@@ -368,6 +397,30 @@ export default function TradePage() {
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
cell: ({ row }) => {
const isPending = row.original.status === 0
const isLoading = completingId === row.original.inner_no
return (
<div className="flex gap-2">
<Auth scope={ScopeTradeWriteComplete}>
{isPending && (
<Button
size="sm"
onClick={() => handleComplete(row.original)}
disabled={isLoading}
>
{isLoading ? "处理中..." : "完成订单"}
</Button>
)}
</Auth>
</div>
)
},
},
]}
/>
</Suspense>

View File

@@ -73,9 +73,15 @@ export const ScopeTrade = "trade"
export const ScopeTradeRead = "trade:read" // 读取交易列表
export const ScopeTradeReadOfUser = "trade:read:of_user" // 读取指定用户的交易列表
export const ScopeTradeWrite = "trade:write" // 写入交易
export const ScopeTradeWriteComplete = "trade:write:complete" // 完成交易
// 账单
export const ScopeBill = "bill"
export const ScopeBillRead = "bill:read" // 读取账单列表
export const ScopeBillReadOfUser = "bill:read:of_user" // 读取指定用户的账单列表
export const ScopeBillWrite = "bill:write" // 写入账单
export const ScopeBillWrite = "bill:write" // 写入账单
// 余额变动
export const ScopeBalanceActivity = "balance_activity"
export const ScopeBalanceActivityRead = "balance_activity:read" // 读取余额变动列表
export const ScopeBalanceActivityReadOfUser = "balance_activity:read:of_user" // 读取指定用户的余额变动列表

18
src/models/balance.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { Admin } from "./admin"
import type { Billing } from "./billing"
import type { User } from "./user"
export type Balance = {
id: number
user_id: string
bill_id: string
admin_id: string
amount: number
balance_prev: number
balance_curr: number
remark: string
created_at: Date
user?: User
admin?: Admin
bill?: Billing
}