更新操作按钮为菜单栏 & 调整页面表格顺序

This commit is contained in:
Eamon
2026-04-11 17:07:03 +08:00
parent ed95f0520d
commit 4307efae98
22 changed files with 675 additions and 395 deletions

View File

@@ -30,10 +30,3 @@ 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

@@ -54,15 +54,15 @@ export default function AdminPage() {
{ header: "用户名", accessorKey: "username" },
{
header: "姓名",
accessorFn: row => row.name ?? "-",
accessorFn: row => row.name ?? "",
},
{
header: "手机号",
accessorFn: row => row.phone ?? "-",
accessorFn: row => row.phone ?? "",
},
{
header: "邮箱",
accessorFn: row => row.email ?? "-",
accessorFn: row => row.email ?? "",
},
{
header: "状态",

View File

@@ -146,7 +146,7 @@ export default function BalancePage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -101,7 +101,7 @@ export default function BatchPage() {
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
@@ -231,7 +231,7 @@ export default function BatchPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -308,7 +308,7 @@ export default function BillingPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
@@ -328,8 +328,14 @@ export default function BillingPage() {
<DataTable<Billing>
{...table}
columns={[
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{ header: "套餐号", accessorKey: "resource.resource_no" },
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
{
header: "账单详情",
accessorKey: "info",
@@ -445,12 +451,6 @@ export default function BillingPage() {
)
},
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>

View File

@@ -219,7 +219,7 @@ export default function ChannelPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -143,7 +143,7 @@ export default function BalancePage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
@@ -65,7 +65,9 @@ type FilterSchema = z.infer<typeof filterSchema>
export default function BatchPage() {
const searchParams = useSearchParams()
const router = useRouter()
const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone")
const [filters, setFilters] = useState<APIFilterParams>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
@@ -104,7 +106,16 @@ export default function BatchPage() {
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
@@ -214,7 +225,7 @@ export default function BatchPage() {
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { CreditCard, Wallet } from "lucide-react"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
@@ -77,8 +77,10 @@ const filterSchema = z
type FilterSchema = z.infer<typeof filterSchema>
export default function BillingPage() {
const router = useRouter()
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone")
const [filters, setFilters] = useState<FilterValues>({})
const [skuOptions, setSkuOptions] = useState<SkuOption[]>([])
const [loading, setLoading] = useState(true)
@@ -154,6 +156,15 @@ export default function BillingPage() {
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">
<div className="flex flex-wrap items-end gap-4">
<Controller
@@ -297,7 +308,7 @@ export default function BillingPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
@@ -317,6 +328,12 @@ export default function BillingPage() {
<DataTable<Billing>
{...table}
columns={[
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{ header: "套餐号", accessorKey: "resource.resource_no" },
{
header: "账单详情",
@@ -433,12 +450,6 @@ export default function BillingPage() {
)
},
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
]}
/>
</Suspense>

View File

@@ -1,7 +1,7 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
@@ -64,8 +64,10 @@ const ispMap: Record<number, string> = {
}
export default function ChannelPage() {
const router = useRouter()
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone")
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
@@ -104,6 +106,16 @@ export default function ChannelPage() {
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
@@ -207,7 +219,7 @@ export default function ChannelPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -7,13 +7,17 @@ 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 { UpdateDialog } from "@/app/(root)/cust/update"
import { Auth } from "@/components/auth"
import { DataTable } from "@/components/data-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Field,
FieldError,
@@ -49,10 +53,6 @@ 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 [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [currentEditUser, setCurrentEditUser] = useState<User | null>(null)
const [currentFilters, setCurrentFilters] = useState<UserQueryParams>({})
@@ -91,7 +91,7 @@ export default function UserQueryPage() {
if (data.phone?.trim()) params.account = data.phone.trim()
if (data.name?.trim()) params.name = data.name.trim()
if (Object.keys(params).length === 0) {
toast.info("请至少输入一个筛选条件")
toast.info("请至少输入一个搜索条件")
return
}
setCurrentFilters(params)
@@ -154,7 +154,7 @@ export default function UserQueryPage() {
<Button type="button" variant="outline" onClick={handleReset}>
</Button>
<Button type="submit"></Button>
<Button type="submit"></Button>
</FieldGroup>
</form>
@@ -249,30 +249,7 @@ export default function UserQueryPage() {
header: "操作",
cell: ({ row }) => {
return (
<div className="flex flex-wrap gap-2 w-75">
{/* <Auth scope={ScopeUserWriteBalanceInc}>
<Button
size="sm"
onClick={() => {
setDeposit(row.original)
setDepositDialog(true)
}}
>
充值
</Button>
</Auth>
<Auth scope={ScopeUserWriteBalanceDec}>
<Button
size="sm"
variant="destructive"
onClick={() => {
setDeduction(row.original)
setDeductionDialog(true)
}}
>
扣款
</Button>
</Auth> */}
<div className="flex gap-2">
<Auth scope={ScopeUserWriteBalance}>
<Button
size="sm"
@@ -284,74 +261,81 @@ export default function UserQueryPage() {
</Button>
</Auth>
<Auth scope={ScopeTradeReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(`/client/trade?userId=${row.original.id}`)
}}
>
</Button>
</Auth>
<Auth scope={ScopeBillReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/billing?userId=${row.original.id}`,
)
}}
>
</Button>
</Auth>
<Auth scope={ScopeResourceRead}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/resources?userId=${row.original.id}`,
)
}}
>
</Button>
</Auth>
<Auth scope={ScopeBatchReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(`/client/batch?userId=${row.original.id}`)
}}
>
</Button>
</Auth>
<Auth scope={ScopeChannelReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/channel?userId=${row.original.id}`,
)
}}
>
IP管理
</Button>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/balance?userId=${row.original.id}&phone=${row.original.phone}`,
)
}}
>
</Button>
</Auth>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-8">
<Auth scope={ScopeTradeReadOfUser}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/trade?userId=${row.original.id}`,
)
}}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBillReadOfUser}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/billing?userId=${row.original.id}`,
)
}}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeResourceRead}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/resources?userId=${row.original.id}`,
)
}}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBatchReadOfUser}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/batch?userId=${row.original.id}`,
)
}}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeChannelReadOfUser}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/channel?userId=${row.original.id}`,
)
}}
>
IP管理
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<DropdownMenuItem
onClick={() => {
router.push(
`/client/balance?userId=${row.original.id}&phone=${row.original.phone}`,
)
}}
>
</DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},
@@ -370,20 +354,6 @@ export default function UserQueryPage() {
currentUser={currentEditUser}
onSuccess={refreshTable}
/>
{/* <DepositDialog
open={depositDialog}
onOpenChange={setDepositDialog}
currentUser={deposit}
onSuccess={refreshTable}
/> */}
{/* <DeductionDialog
open={deductionDialog}
onOpenChange={setDeductionDialog}
currentUser={deduction}
onSuccess={refreshTable}
/> */}
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { format, isBefore, isSameDay } from "date-fns"
import { Box, Loader2, Timer } from "lucide-react"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useCallback, useMemo, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
@@ -421,9 +421,20 @@ function ResourceList({ resourceType }: ResourceListProps) {
],
[isLong, updatingId, handleStatusChange],
)
const router = useRouter()
const userPhone = searchParams.get("phone")
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
@@ -544,7 +555,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -2,7 +2,7 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { format } from "date-fns"
import { CheckCircle, Clock, XCircle } from "lucide-react"
import { useSearchParams } from "next/navigation"
import { useRouter, useSearchParams } from "next/navigation"
import { Suspense, useCallback, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { z } from "zod"
@@ -63,8 +63,10 @@ const filterSchema = z
type FilterSchema = z.infer<typeof filterSchema>
export default function TradePage() {
const router = useRouter()
const searchParams = useSearchParams()
const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone")
const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FilterSchema>({
@@ -107,7 +109,16 @@ export default function TradePage() {
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">
<div className="flex flex-wrap items-end gap-4">
<Controller
@@ -224,7 +235,7 @@ export default function TradePage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
@@ -243,32 +254,17 @@ export default function TradePage() {
<DataTable<Trade>
{...table}
columns={[
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
header: "订单号",
accessorKey: "inner_no",
},
{
header: "渠道订单号",
accessorKey: "outer_no",
},
{
header: "支付渠道",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "商福通",
4: "商福通渠道支付宝",
5: "商福通渠道微信",
}
return (
<div>
{methodMap[row.original.method as number] || "未知"}
</div>
)
},
},
{ header: "购买套餐", accessorKey: "subject" },
{
header: "支付金额",
accessorKey: "payment",
@@ -290,19 +286,6 @@ export default function TradePage() {
)
},
},
{
header: "支付平台",
accessorKey: "platform",
cell: ({ row }) => {
const platform = row.original.platform
if (!platform) return <span>-</span>
return platform === 1
? "电脑网站"
: platform === 2
? "手机网站"
: "-"
},
},
{
header: "支付状态",
accessorKey: "status",
@@ -335,12 +318,40 @@ export default function TradePage() {
}
},
},
{ header: "购买套餐", accessorKey: "subject" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
header: "支付平台",
accessorKey: "platform",
cell: ({ row }) => {
const platform = row.original.platform
if (!platform) return <span>-</span>
return platform === 1
? "电脑网站"
: platform === 2
? "手机网站"
: "-"
},
},
{
header: "支付渠道",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "商福通",
4: "商福通渠道支付宝",
5: "商福通渠道微信",
}
return (
<div>
{methodMap[row.original.method as number] || "未知"}
</div>
)
},
},
{
header: "渠道订单号",
accessorKey: "outer_no",
},
]}
/>

View File

@@ -104,7 +104,7 @@ export function DepositDialog({
<Field data-invalid={!!errors.deposit}>
<FieldLabel></FieldLabel>
<Input
type="text" // 改为 text避免 number 输入冲突
type="text"
placeholder="请输入充值金额"
{...register("deposit")}
onInput={(e: React.ChangeEvent<HTMLInputElement>) => {

View File

@@ -11,6 +11,12 @@ import { Auth } from "@/components/auth"
import { DataTable, useDataTable } from "@/components/data-table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Field,
FieldError,
@@ -246,7 +252,7 @@ export default function CustPage() {
>
</Button>
<Button type="submit"></Button>
<Button type="submit"></Button>
</FieldGroup>
</form>
@@ -361,109 +367,124 @@ export default function CustPage() {
meta: { pin: "right" },
header: "操作",
cell: ({ row }) => {
const user = row.original
return (
<div className="flex flex-wrap gap-2 w-75">
<div className="flex gap-2">
<Auth scope={ScopeUserWriteBalanceInc}>
<Button
size="sm"
onClick={() => {
setDeposit(row.original)
setDeposit(user)
setDepositDialog(true)
}}
>
</Button>
</Auth>
<Auth scope={ScopeUserWriteBalanceDec}>
<Button
size="sm"
onClick={() => {
setDeduction(row.original)
setDeduction(user)
setDeductionDialog(true)
}}
>
</Button>
</Auth>
<Auth scope={ScopeUserWriteBalance}>
<Button
size="sm"
onClick={() => {
setCurrentEditUser(row.original)
setCurrentEditUser(user)
setIsEditDialogOpen(true)
}}
>
</Button>
</Auth>
<Auth scope={ScopeTradeReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(`/client/trade?userId=${row.original.id}`)
}}
>
</Button>
</Auth>
<Auth scope={ScopeBillReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/billing?userId=${row.original.id}`,
)
}}
>
</Button>
</Auth>
<Auth scope={ScopeResourceRead}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/resources?userId=${row.original.id}`,
)
}}
>
</Button>
</Auth>
<Auth scope={ScopeBatchReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(`/client/batch?userId=${row.original.id}`)
}}
>
</Button>
</Auth>
<Auth scope={ScopeChannelReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/channel?userId=${row.original.id}`,
)
}}
>
IP管理
</Button>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<Button
size="sm"
onClick={() => {
router.push(
`/client/balance?userId=${row.original.id}&phone=${row.original.phone}`,
)
}}
>
</Button>
</Auth>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline">
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-8">
<Auth scope={ScopeTradeReadOfUser}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/trade?userId=${user.id}&phone=${user.phone}`,
)
}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBillReadOfUser}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/billing?userId=${user.id}&phone=${user.phone}`,
)
}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeResourceRead}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/resources?userId=${user.id}&phone=${user.phone}`,
)
}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBatchReadOfUser}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/batch?userId=${user.id}&phone=${user.phone}`,
)
}
>
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeChannelReadOfUser}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/channel?userId=${user.id}&phone=${user.phone}`,
)
}
>
IP管理
</DropdownMenuItem>
</Auth>
<Auth scope={ScopeBalanceActivityReadOfUser}>
<DropdownMenuItem
onClick={() =>
router.push(
`/client/balance?userId=${user.id}&phone=${user.phone}`,
)
}
>
</DropdownMenuItem>
</Auth>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
},

View File

@@ -43,16 +43,17 @@ const schema = z.object({
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
)
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"),
discount_id: z.string().optional(),
price_min: z
.string()
.optional()
.or(z.literal(""))
.min(1, "请输入最低价格")
.refine(
v => !v || (!Number.isNaN(Number(v)) && Number(v) > 0),
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数价格",
),
)
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"),
})
export function CreateProductSku(props: {
@@ -159,12 +160,25 @@ export function CreateProductSku(props: {
name="price"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-price"></FieldLabel>
<FieldLabel htmlFor="sku-update-price"></FieldLabel>
<Input
id="sku-create-price"
id="sku-update-price"
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
field.onChange(value)
}}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
@@ -172,7 +186,6 @@ export function CreateProductSku(props: {
</Field>
)}
/>
<Controller
control={form.control}
name="price_min"
@@ -181,9 +194,22 @@ export function CreateProductSku(props: {
<FieldLabel htmlFor="sku-create-price"></FieldLabel>
<Input
id="sku-create-price"
placeholder="请输入单价"
placeholder="请输入最低价格"
{...field}
aria-invalid={fieldState.invalid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
field.onChange(value)
}}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />

View File

@@ -57,7 +57,7 @@ function Products(props: {
}, [refresh])
return (
<section className="flex-none basis-64 bg-background rounded-lg">
<section className="flex-none basis-50 bg-background rounded-lg">
<header className="pl-3 pr-1 h-10 border-b flex items-center justify-between">
<h3 className="text-sm"></h3>
</header>

View File

@@ -43,7 +43,8 @@ const schema = z.object({
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数单价",
),
)
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"),
discount_id: z.string().optional(),
price_min: z
.string()
@@ -51,7 +52,8 @@ const schema = z.object({
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数价格",
),
)
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"),
})
export function UpdateProductSku(props: {
@@ -97,19 +99,6 @@ export function UpdateProductSku(props: {
: null,
price_min: data.price_min,
})
console.log({
id: props.sku.id,
code: data.code,
name: data.name,
price: data.price,
discount_id:
data.discount_id && data.discount_id !== ""
? Number(data.discount_id)
: null,
price_min: data.price_min,
})
console.log(resp, "resp")
if (resp.success) {
toast.success("套餐修改成功")
@@ -182,6 +171,19 @@ export function UpdateProductSku(props: {
placeholder="请输入单价"
{...field}
aria-invalid={fieldState.invalid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
field.onChange(value)
}}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
@@ -200,6 +202,19 @@ export function UpdateProductSku(props: {
placeholder="请输入最低价格"
{...field}
aria-invalid={fieldState.invalid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
let value = e.target.value
value = value.replace(/[^\d.]/g, "")
const dotCount = (value.match(/\./g) || []).length
if (dotCount > 1) {
value = value.slice(0, value.lastIndexOf("."))
}
if (value.includes(".")) {
const [int, dec] = value.split(".")
value = `${int}.${dec.slice(0, 2)}`
}
field.onChange(value)
}}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />

View File

@@ -542,7 +542,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
/>
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -4,10 +4,8 @@ import { format } from "date-fns"
import { CheckCircle, Clock, XCircle } from "lucide-react"
import { Suspense, useCallback, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { getPageTrade, getTradeComplete } from "@/actions/trade"
import { Auth } from "@/components/auth"
import { getPageTrade } from "@/actions/trade"
import { DataTable, useDataTable } from "@/components/data-table"
import { Button } from "@/components/ui/button"
import {
@@ -24,7 +22,6 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { ScopeTradeWriteComplete } from "@/lib/scopes"
import type { Trade } from "@/models/trade"
type FilterValues = {
@@ -113,30 +110,8 @@ 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">
{/* 筛选表单 */}
<form onSubmit={onFilter} className="bg-white p-4">
<div className="flex flex-wrap items-end gap-4">
<Controller
@@ -268,7 +243,7 @@ export default function TradePage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"
@@ -288,35 +263,14 @@ export default function TradePage() {
{...table}
columns={[
{
header: "会员号",
accessorFn: row => row.user?.phone || "",
},
{
header: "订单号",
accessorKey: "inner_no",
},
{
header: "渠道订单号",
accessorKey: "outer_no",
},
{
header: "支付渠道",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "商福通",
4: "商福通渠道支付宝",
5: "商福通渠道微信",
}
return (
<div>
{methodMap[row.original.method as number] || "未知"}
</div>
)
},
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{ header: "会员号", accessorFn: row => row.user?.phone || "" },
{ header: "订单号", accessorKey: "inner_no" },
{ header: "购买套餐", accessorKey: "subject" },
{
header: "支付金额",
accessorKey: "payment",
@@ -338,25 +292,6 @@ export default function TradePage() {
)
},
},
{
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",
@@ -390,37 +325,44 @@ export default function TradePage() {
}
},
},
{ header: "购买套餐", accessorKey: "subject" },
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
header: "支付平台",
accessorKey: "platform",
cell: ({ row }) => {
const isPending = row.original.status === 0
const isLoading = completingId === row.original.inner_no
const platform = row.original.platform
if (!platform) return <span>-</span>
return (
<div className="flex gap-2">
<Auth scope={ScopeTradeWriteComplete}>
{isPending && (
<Button
size="sm"
onClick={() => handleComplete(row.original)}
disabled={isLoading}
>
{isLoading ? "处理中..." : "完成订单"}
</Button>
)}
</Auth>
<div className="flex items-center gap-2">
{platform === 1 ? (
<span></span>
) : platform === 2 ? (
<span></span>
) : (
<span>-</span>
)}
</div>
)
},
},
{
header: "支付渠道",
accessorKey: "method",
cell: ({ row }) => {
const methodMap: Record<number, string> = {
1: "支付宝",
2: "微信",
3: "商福通",
4: "商福通渠道支付宝",
5: "商福通渠道微信",
}
return (
<div>
{methodMap[row.original.method as number] || "未知"}
</div>
)
},
},
{ header: "渠道订单号", accessorKey: "outer_no" },
]}
/>
</Suspense>

View File

@@ -80,7 +80,7 @@ export default function UserPage() {
</div>
<FieldGroup className="flex-row justify-start mt-4 gap-2">
<Button type="submit"></Button>
<Button type="submit"></Button>
<Button
type="button"
variant="outline"

View File

@@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}