7 Commits

24 changed files with 577 additions and 173 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "lanhu-admin", "name": "lanhu-admin",
"version": "1.2.0", "version": "1.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 -p 3001 --turbopack", "dev": "next dev -H 0.0.0.0 -p 3001 --turbopack",

View File

@@ -6,7 +6,7 @@ export async function getPageBalance(params: {
page: number page: number
size: number size: number
user_phone?: string user_phone?: string
bill_id?: string bill_no?: string
created_at_start?: Date created_at_start?: Date
created_at_end?: Date created_at_end?: Date
}) { }) {
@@ -21,7 +21,7 @@ export async function getBalance(params: {
size: number size: number
user_id: number user_id: number
user_phone?: string user_phone?: string
bill_id?: string bill_no?: string
created_at_start?: Date created_at_start?: Date
created_at_end?: Date created_at_end?: Date
}) { }) {

26
src/actions/gateway.ts Normal file
View File

@@ -0,0 +1,26 @@
"use server"
import type { PageRecord } from "@/lib/api"
import type { Gateway } from "@/models/gateway"
import { callByUser } from "./base"
export async function getGatewayPage(params: { page: number; size: number }) {
return callByUser<PageRecord<Gateway>>("/api/admin/proxy/page", params)
}
export async function createGateway(data: {
mac: string
ip: string
host?: string
type: number
status: number
}) {
return callByUser<Gateway>("/api/admin/proxy/create", data)
}
export async function deletegateway(id: number) {
return callByUser<Gateway>("/api/admin/proxy/remove", {
id,
})
}

View File

@@ -45,6 +45,7 @@ export async function updateProductSku(data: {
price?: string price?: string
discount_id?: number | null discount_id?: number | null
price_min?: string price_min?: string
count_min?: number | null
}) { }) {
return callByUser<ProductSku>("/api/admin/product/sku/update", { return callByUser<ProductSku>("/api/admin/product/sku/update", {
id: data.id, id: data.id,
@@ -53,6 +54,7 @@ export async function updateProductSku(data: {
price: data.price, price: data.price,
discount_id: data.discount_id, discount_id: data.discount_id,
price_min: data.price_min, price_min: data.price_min,
count_min: data.count_min,
}) })
} }

View File

@@ -36,7 +36,6 @@ import { UpdateAdmin } from "./update"
export default function AdminPage() { export default function AdminPage() {
const table = useDataTable((page, size) => getPageAdmin({ page, size })) const table = useDataTable((page, size) => getPageAdmin({ page, size }))
console.log(table, "table")
return ( return (
<Page> <Page>

View File

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

View File

@@ -19,7 +19,7 @@ import type { Balance } from "@/models/balance"
type FilterValues = { type FilterValues = {
user_phone?: string user_phone?: string
bill_id?: string bill_no?: string
admin_id?: string admin_id?: string
created_at_start?: Date created_at_start?: Date
created_at_end?: Date created_at_end?: Date
@@ -28,7 +28,7 @@ type FilterValues = {
const filterSchema = z const filterSchema = z
.object({ .object({
phone: z.string().optional(), phone: z.string().optional(),
bill_id: z.string().optional(), bill_no: z.string().optional(),
admin_id: z.string().optional(), admin_id: z.string().optional(),
created_at_start: z.string().optional(), created_at_start: z.string().optional(),
created_at_end: z.string().optional(), created_at_end: z.string().optional(),
@@ -56,7 +56,7 @@ export default function BalancePage() {
resolver: zodResolver(filterSchema), resolver: zodResolver(filterSchema),
defaultValues: { defaultValues: {
phone: "", phone: "",
bill_id: "", bill_no: "",
admin_id: "", admin_id: "",
created_at_start: "", created_at_start: "",
created_at_end: "", created_at_end: "",
@@ -70,11 +70,10 @@ export default function BalancePage() {
const table = useDataTable<Balance>(fetchUsers) const table = useDataTable<Balance>(fetchUsers)
console.log(table, "table")
const onFilter = handleSubmit(data => { const onFilter = handleSubmit(data => {
const result: FilterValues = {} const result: FilterValues = {}
if (data.phone) result.user_phone = data.phone if (data.phone?.trim()) result.user_phone = data.phone.trim()
if (data.bill_id) result.bill_id = data.bill_id if (data.bill_no?.trim()) result.bill_no = data.bill_no.trim()
if (data.created_at_start) if (data.created_at_start)
result.created_at_start = new Date(data.created_at_start) result.created_at_start = new Date(data.created_at_start)
if (data.created_at_end) if (data.created_at_end)
@@ -102,7 +101,7 @@ export default function BalancePage() {
)} )}
/> />
<Controller <Controller
name="bill_id" name="bill_no"
control={control} control={control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field <Field

View File

@@ -259,7 +259,7 @@ export default function BatchPage() {
{ header: "批次号", accessorKey: "batch_no" }, { header: "批次号", accessorKey: "batch_no" },
{ header: "省份", accessorKey: "prov" }, { header: "省份", accessorKey: "prov" },
{ header: "城市", accessorKey: "city" }, { header: "城市", accessorKey: "city" },
{ header: "提取IP", accessorKey: "ip" }, { header: "用户IP", accessorKey: "ip" },
{ header: "运营商", accessorKey: "isp" }, { header: "运营商", accessorKey: "isp" },
{ header: "提取数量", accessorKey: "count" }, { header: "提取数量", accessorKey: "count" },
{ {

View File

@@ -20,7 +20,7 @@ import type { Balance } from "@/models/balance"
type FilterValues = { type FilterValues = {
user_phone?: string user_phone?: string
bill_id?: string bill_no?: string
created_at_start?: Date created_at_start?: Date
created_at_end?: Date created_at_end?: Date
} }
@@ -28,7 +28,7 @@ type FilterValues = {
const filterSchema = z const filterSchema = z
.object({ .object({
phone: z.string().optional(), phone: z.string().optional(),
bill_id: z.string().optional(), bill_no: z.string().optional(),
admin_id: z.string().optional(), admin_id: z.string().optional(),
created_at_start: z.string().optional(), created_at_start: z.string().optional(),
created_at_end: z.string().optional(), created_at_end: z.string().optional(),
@@ -55,14 +55,13 @@ export default function BalancePage() {
const router = useRouter() const router = useRouter()
const userId = searchParams.get("userId") const userId = searchParams.get("userId")
const userPhone = searchParams.get("phone") const userPhone = searchParams.get("phone")
console.log(userPhone, "userPhone")
const [filters, setFilters] = useState<FilterValues>({}) const [filters, setFilters] = useState<FilterValues>({})
const { control, handleSubmit, reset } = useForm<FormValues>({ const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema), resolver: zodResolver(filterSchema),
defaultValues: { defaultValues: {
phone: "", phone: "",
bill_id: "", bill_no: "",
admin_id: "", admin_id: "",
created_at_start: "", created_at_start: "",
created_at_end: "", created_at_end: "",
@@ -72,12 +71,11 @@ export default function BalancePage() {
const table = useDataTable<Balance>((page, size) => const table = useDataTable<Balance>((page, size) =>
getBalance({ page, size, user_id: Number(userId), ...filters }), getBalance({ page, size, user_id: Number(userId), ...filters }),
) )
console.log(table, "仅用户的table")
const onFilter = handleSubmit(data => { const onFilter = handleSubmit(data => {
const result: FilterValues = {} const result: FilterValues = {}
if (data.phone) result.user_phone = data.phone if (data.phone?.trim()) result.user_phone = data.phone.trim()
if (data.bill_id) result.bill_id = data.bill_id if (data.bill_no?.trim()) result.bill_no = data.bill_no.trim()
if (data.created_at_start) if (data.created_at_start)
result.created_at_start = new Date(data.created_at_start) result.created_at_start = new Date(data.created_at_start)
if (data.created_at_end) if (data.created_at_end)
@@ -102,7 +100,7 @@ export default function BalancePage() {
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg"> <form onSubmit={onFilter} className="bg-card p-4 rounded-lg">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<Controller <Controller
name="bill_id" name="bill_no"
control={control} control={control}
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field <Field

View File

@@ -176,7 +176,7 @@ export default function UserQueryPage() {
2: "代理商注册", 2: "代理商注册",
3: "代理商添加", 3: "代理商添加",
} }
return sourceMap[row.original.source] ?? "未知" return sourceMap[row.original.source] ?? "官网注册"
}, },
}, },
{ {

View File

@@ -109,8 +109,8 @@ export default function CustPage() {
const onFilter = handleSubmit(data => { const onFilter = handleSubmit(data => {
const result: FilterValues = {} const result: FilterValues = {}
if (data.account) result.account = data.account if (data.account?.trim()) result.account = data.account.trim()
if (data.name) result.name = data.name if (data.name?.trim()) result.name = data.name.trim()
if (data.identified && data.identified !== "all") if (data.identified && data.identified !== "all")
result.identified = data.identified === "1" result.identified = data.identified === "1"
if (data.enabled && data.enabled !== "all") if (data.enabled && data.enabled !== "all")

View File

@@ -0,0 +1,245 @@
"use client"
import { zodResolver } from "@hookform/resolvers/zod"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import z from "zod"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} 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 { toast } from "sonner"
import { createGateway } from "@/actions/gateway"
const schema = z.object({
mac: z.string().regex(/^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$/, {
message: "MAC地址格式不正确请使用如 00:11:22:AA:BB:CC 或 00-11-22-AA-BB-CC 的格式"
}),
ip: z.string().regex(/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/, {
message: "IP地址格式不正确请使用如 192.168.1.1 的格式"
}),
host: z.string().regex(/^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
message: "域名格式不正确,请使用如 example.com 的格式"
}).or(z.literal("")),
type: z.string().optional(),
status: z.string().optional(),
})
export default function CreatePage(props: { onSuccess?: () => void }) {
const [open, setOpen] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const form = useForm({
resolver: zodResolver(schema),
defaultValues: {
mac: "",
ip: "",
host: "",
type: "1",
status: "0",
},
})
const onSubmit = async (data: z.infer<typeof schema>) => {
setIsLoading(true)
try {
const payload = {
mac: data.mac.trim(),
ip: data.ip.trim(),
host: data?.host.trim(),
type: data.type ? Number(data.type) : 0,
status: data.status ? Number(data.status) : 0,
}
const res = await createGateway(payload)
if (res.success) {
toast.success("添加网关成功")
setOpen(false)
props.onSuccess?.()
form.reset()
}else {
toast.error(res.message || "添加失败")
}
} catch (error) {
console.error("添加网关失败:", error)
const message = error instanceof Error ? error.message : error
toast.error(`添加失败: ${message}`)
} finally {
setIsLoading(false)
}
}
const handleCancel = () => {
setOpen(false)
form.reset()
}
const statusOptions = [
{ value: "0", label: "离线" },
{ value: "1", label: "在线" },
]
const typeOptions = [
{ value: "1", label: "自有" },
{ value: "2", label: "白银" },
]
return (
<Dialog
open={open}
onOpenChange={newOpen => {
setOpen(newOpen)
if (!newOpen) {
form.reset()
}
}}
>
<DialogTrigger asChild>
<Button></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<form
id="gateway-create"
onSubmit={form.handleSubmit(onSubmit)}
>
<FieldGroup>
<Controller
control={form.control}
name="mac"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="gateway-create-mac">MAC地址:</FieldLabel>
<Input
id="gateway-create-mac"
placeholder="请输入MAC地址00:11:22:33:44:55"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && fieldState.error && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="ip"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="gateway-create-ip">IP地址:</FieldLabel>
<Input
id="gateway-create-ip"
placeholder="请输入IP地址192.168.1.1"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && fieldState.error && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="host"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="gateway-create-host">:</FieldLabel>
<Input
id="gateway-create-host"
placeholder="请输入域名example.com"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && fieldState.error && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
control={form.control}
name="type"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full h-9">
<SelectValue placeholder="请选择网关类型" />
</SelectTrigger>
<SelectContent>
{typeOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && fieldState.error && (
<FieldError>{fieldState.error?.message}</FieldError>
)}
</Field>
)}
/>
<Controller
control={form.control}
name="status"
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel></FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-full h-9">
<SelectValue placeholder="请选择网关状态" />
</SelectTrigger>
<SelectContent>
{statusOptions.map(option => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid && fieldState.error && (
<FieldError>{fieldState.error?.message}</FieldError>
)}
</Field>
)}
/>
</FieldGroup>
</form>
<DialogFooter className="gap-2">
<Button variant="ghost" onClick={handleCancel} disabled={isLoading}>
</Button>
<Button type="submit" form="gateway-create" disabled={isLoading}>
{isLoading ? "添加中..." : "添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,77 @@
"use client"
import { format } from "date-fns"
import { Suspense, useState } from "react"
import { toast } from "sonner"
import { getGatewayPage } from "@/actions/gateway"
import { Auth } from "@/components/auth"
import { DataTable, useDataTable } from "@/components/data-table"
import { Page } from "@/components/page"
import { Button } from "@/components/ui/button"
import { ScopeProxyWrite } from "@/lib/scopes"
import type { Gateway } from "@/models/gateway"
import CreatePage from "./create"
export default function GatewayPage() {
const [loading, setLoading] = useState(false)
const table = useDataTable((page, size) => getGatewayPage({ page, size }))
return (
<Page>
<Auth scope={ScopeProxyWrite}>
<div className="flex justify-between items-stretch">
<div className="flex gap-3">
<CreatePage onSuccess={table.refresh} />
</div>
</div>
</Auth>
<Suspense>
<DataTable<Gateway>
{...table}
status={loading ? "load" : "done"}
columns={[
{
header: "域名",
accessorKey: "host",
},
{ header: "IP地址", accessorKey: "ip" },
{
header: "MAC地址",
accessorKey: "mac",
},
{
header: "类型",
accessorKey: "type",
cell: ({ row }) => (row.original.type === 1 ? "自有" : "白银"),
},
{
header: "状态",
accessorKey: "status",
cell: ({ row }) => (row.original.status === 0 ? "离线" : "在线"),
},
{
header: "创建时间",
accessorKey: "created_at",
cell: ({ row }) =>
format(new Date(row.original.created_at), "yyyy-MM-dd HH:mm"),
},
{
id: "action",
meta: { pin: "right" },
header: "操作",
cell: ({ row }) => (
<div className="flex gap-2">
<Button className="bg-green-600/60 hover:bg-green-600/60 active:bg-green-600/60">
</Button>
<Button></Button>
</div>
),
},
]}
/>
</Suspense>
</Page>
)
}

View File

@@ -9,6 +9,7 @@ import {
ComputerIcon, ComputerIcon,
ContactRound, ContactRound,
DollarSign, DollarSign,
DoorClosedIcon,
FolderCode, FolderCode,
Home, Home,
KeyRound, KeyRound,
@@ -46,6 +47,7 @@ import {
ScopeDiscountRead, ScopeDiscountRead,
ScopePermissionRead, ScopePermissionRead,
ScopeProductRead, ScopeProductRead,
ScopeProxyRead,
ScopeResourceRead, ScopeResourceRead,
ScopeTradeRead, ScopeTradeRead,
ScopeUserRead, ScopeUserRead,
@@ -249,6 +251,12 @@ const menuSections: { title: string; items: NavItemProps[] }[] = [
{ {
title: "系统", title: "系统",
items: [ items: [
{
href: "/gateway",
icon: DoorClosedIcon,
label: "网关列表",
requiredScope:ScopeProxyRead
},
{ {
href: "/admin", href: "/admin",
icon: Shield, icon: Shield,

View File

@@ -139,7 +139,7 @@ function PermissionTable() {
}, [data]) }, [data])
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3 overflow-auto">
{process.env.NODE_ENV === "development" && ( {process.env.NODE_ENV === "development" && (
<div> <div>
<Button variant="outline" size="sm" onClick={handleCopy}> <Button variant="outline" size="sm" onClick={handleCopy}>
@@ -150,7 +150,7 @@ function PermissionTable() {
)} )}
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="h-10"> <TableRow className="h-10 sticky top-0 bg-background">
<TableHead></TableHead> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
</TableRow> </TableRow>

View File

@@ -34,27 +34,39 @@ import {
import type { ProductDiscount } from "@/models/product_discount" import type { ProductDiscount } from "@/models/product_discount"
import type { SelectedProduct } from "./type" import type { SelectedProduct } from "./type"
const schema = z.object({ const schema = z
code: z.string().min(1, "请输入套餐编码"), .object({
name: z.string().min(1, "请输入套餐名称"), code: z.string().min(1, "请输入套餐编码"),
price: z name: z.string().min(1, "请输入套餐名称"),
.string() price: z
.min(1, "请输入单价") .string()
.refine( .min(1, "请输入单价")
v => !Number.isNaN(Number(v)) && Number(v) > 0, .refine(
"请输入有效的正数单价", v => !Number.isNaN(Number(v)) && Number(v) > 0,
) "请输入有效的正数单价",
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"), ),
discount_id: z.string().optional(), discount_id: z.string().optional(),
price_min: z count_min: z.string().min(1, "请输入最低购买数量"),
.string() price_min: z
.min(1, "请输入最低价格") .string()
.refine( .min(1, "请输入最低价格")
v => !Number.isNaN(Number(v)) && Number(v) > 0, .refine(
"请输入有效的正数价格", v => !Number.isNaN(Number(v)) && Number(v) > 0,
) "请输入有效的正数价格",
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"), ),
}) })
.refine(
data => {
const price = Number(data.price)
const priceMin = Number(data.price_min)
if (isNaN(price) || isNaN(priceMin)) return true
return price >= priceMin
},
{
message: "单价不能低于最低价格",
path: ["price"],
},
)
export function CreateProductSku(props: { export function CreateProductSku(props: {
product?: SelectedProduct product?: SelectedProduct
@@ -166,19 +178,6 @@ export function CreateProductSku(props: {
placeholder="请输入单价" placeholder="请输入单价"
{...field} {...field}
aria-invalid={fieldState.invalid} 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 && ( {fieldState.invalid && (
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
@@ -197,19 +196,6 @@ export function CreateProductSku(props: {
placeholder="请输入最低价格" placeholder="请输入最低价格"
{...field} {...field}
aria-invalid={fieldState.invalid} 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 && ( {fieldState.invalid && (
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
@@ -217,7 +203,26 @@ export function CreateProductSku(props: {
</Field> </Field>
)} )}
/> />
<Controller
control={form.control}
name="count_min"
render={({ field, fieldState }) => (
<Field>
<FieldLabel htmlFor="sku-create-price">
</FieldLabel>
<Input
id="sku-create-price"
placeholder="请输入最低购买数量"
{...field}
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller <Controller
control={form.control} control={form.control}
name="discount_id" name="discount_id"

View File

@@ -99,7 +99,6 @@ function ProductSkus(props: {
) )
const table = useDataTable(action) const table = useDataTable(action)
console.log(table, "table")
return ( return (
<div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3"> <div className="flex-auto overflow-hidden flex flex-col items-stretch gap-3">
@@ -130,18 +129,19 @@ function ProductSkus(props: {
), ),
}, },
{ header: "套餐名称", accessorKey: "name" }, { header: "套餐名称", accessorKey: "name" },
{ header: "单价", accessorFn: row => Number(row.price).toFixed(2) }, { header: "单价", accessorFn: row => Number(row.price) },
{ header: "折扣", accessorFn: row => row.discount?.name ?? "—" }, { header: "折扣", accessorFn: row => row.discount?.name ?? "—" },
{ {
header: "最终价格", header: "折后价",
accessorFn: row => { accessorFn: row => {
const value = row.discount const value = row.discount
? (Number(row.price) * Number(row.discount.discount)) / 100 ? (Number(row.price) * Number(row.discount.discount)) / 100
: Number(row.price) : Number(row.price)
return Number(value.toFixed(2)) return Number(value)
}, },
}, },
{ header: "最低价格", accessorKey: "price_min" }, { header: "最低价格", accessorKey: "price_min" },
{ header: "最低购买数量", accessorKey: "count_min" },
{ {
header: "创建时间", header: "创建时间",
accessorFn: row => format(row.created_at, "yyyy-MM-dd HH:mm"), accessorFn: row => format(row.created_at, "yyyy-MM-dd HH:mm"),

View File

@@ -34,27 +34,48 @@ import {
import type { ProductDiscount } from "@/models/product_discount" import type { ProductDiscount } from "@/models/product_discount"
import type { ProductSku } from "@/models/product_sku" import type { ProductSku } from "@/models/product_sku"
const schema = z.object({ const schema = z
code: z.string().min(1, "请输入套餐编码"), .object({
name: z.string().min(1, "请输入套餐名称"), code: z.string().min(1, "请输入套餐编码"),
price: z name: z.string().min(1, "请输入套餐名称"),
.string() price: z
.min(1, "请输入单价") .string()
.refine( .min(1, "请输入单价")
v => !Number.isNaN(Number(v)) && Number(v) > 0, .refine(
"请输入有效的正数单价", v => !Number.isNaN(Number(v)) && Number(v) > 0,
) "请输入有效的正数单价",
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"), ),
discount_id: z.string().optional(), discount_id: z.string().optional(),
price_min: z count_min: z
.string() .string()
.min(1, "请输入最低价格") .min(1, "请输入最低购买数量")
.refine( .refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0, v =>
"请输入有效的正数价格", !Number.isNaN(Number(v)) &&
) Number.isInteger(Number(v)) &&
.refine(val => /^\d+(\.\d{1,2})?$/.test(val), "价格最多只能保留两位小数"), Number(v) > 0,
}) "请输入有效的正整数",
),
price_min: z
.string()
.min(1, "请输入最低价格")
.refine(
v => !Number.isNaN(Number(v)) && Number(v) > 0,
"请输入有效的正数价格",
),
})
.refine(
data => {
const price = Number(data.price)
const priceMin = Number(data.price_min)
if (isNaN(price) || isNaN(priceMin)) return true
return price >= priceMin
},
{
message: "单价不能低于最低价格",
path: ["price"],
},
)
export function UpdateProductSku(props: { export function UpdateProductSku(props: {
sku: ProductSku sku: ProductSku
@@ -71,6 +92,7 @@ export function UpdateProductSku(props: {
price: props.sku.price, price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "", discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
price_min: props.sku.price_min ?? "", price_min: props.sku.price_min ?? "",
count_min: String(props.sku.count_min),
}, },
}) })
@@ -85,8 +107,6 @@ export function UpdateProductSku(props: {
}, [open]) }, [open])
const onSubmit = async (data: z.infer<typeof schema>) => { const onSubmit = async (data: z.infer<typeof schema>) => {
console.log(data, "data")
try { try {
const resp = await updateProductSku({ const resp = await updateProductSku({
id: props.sku.id, id: props.sku.id,
@@ -98,7 +118,9 @@ export function UpdateProductSku(props: {
? Number(data.discount_id) ? Number(data.discount_id)
: null, : null,
price_min: data.price_min, price_min: data.price_min,
count_min: Number(data.count_min),
}) })
console.log(resp, "resp")
if (resp.success) { if (resp.success) {
toast.success("套餐修改成功") toast.success("套餐修改成功")
@@ -121,6 +143,7 @@ export function UpdateProductSku(props: {
price: props.sku.price, price: props.sku.price,
discount_id: props.sku.discount ? String(props.sku.discount.id) : "", discount_id: props.sku.discount ? String(props.sku.discount.id) : "",
price_min: props.sku.price_min ?? "", price_min: props.sku.price_min ?? "",
count_min: props.sku.count_min ? String(props.sku.count_min) : "",
}) })
} }
setOpen(value) setOpen(value)
@@ -171,19 +194,6 @@ export function UpdateProductSku(props: {
placeholder="请输入单价" placeholder="请输入单价"
{...field} {...field}
aria-invalid={fieldState.invalid} 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 && ( {fieldState.invalid && (
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />
@@ -196,25 +206,34 @@ export function UpdateProductSku(props: {
name="price_min" name="price_min"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<Field> <Field>
<FieldLabel htmlFor="sku-create-price"></FieldLabel> <FieldLabel htmlFor="sku-update-price-min">
</FieldLabel>
<Input <Input
id="sku-create-price" id="sku-update-price-min"
placeholder="请输入最低价格" placeholder="请输入最低价格"
{...field} {...field}
aria-invalid={fieldState.invalid} aria-invalid={fieldState.invalid}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { />
let value = e.target.value {fieldState.invalid && (
value = value.replace(/[^\d.]/g, "") <FieldError errors={[fieldState.error]} />
const dotCount = (value.match(/\./g) || []).length )}
if (dotCount > 1) { </Field>
value = value.slice(0, value.lastIndexOf(".")) )}
} />
if (value.includes(".")) { <Controller
const [int, dec] = value.split(".") control={form.control}
value = `${int}.${dec.slice(0, 2)}` name="count_min"
} render={({ field, fieldState }) => (
field.onChange(value) <Field>
}} <FieldLabel htmlFor="sku-update-count-min">
</FieldLabel>
<Input
id="sku-update-count-min"
placeholder="请输入最低购买数量"
{...field}
aria-invalid={fieldState.invalid}
/> />
{fieldState.invalid && ( {fieldState.invalid && (
<FieldError errors={[fieldState.error]} /> <FieldError errors={[fieldState.error]} />

View File

@@ -171,7 +171,7 @@ function getTodayUsage(lastAt: Date | null | undefined, daily: number) {
export default function ResourcesPage() { export default function ResourcesPage() {
return ( return (
<Page> <Page>
<Tabs defaultValue="short"> <Tabs defaultValue="short" className="overflow-hidden">
<TabsList className="bg-card"> <TabsList className="bg-card">
<TabsTrigger value="short" className="h-10 px-4 shadow-none"> <TabsTrigger value="short" className="h-10 px-4 shadow-none">
@@ -181,7 +181,10 @@ export default function ResourcesPage() {
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="short" className="flex flex-col gap-4"> <TabsContent
value="short"
className="flex flex-col gap-4 overflow-hidden"
>
<ResourceList resourceType="short" /> <ResourceList resourceType="short" />
</TabsContent> </TabsContent>
<TabsContent value="long" className="flex flex-col gap-4"> <TabsContent value="long" className="flex flex-col gap-4">
@@ -222,7 +225,7 @@ function ResourceList({ resourceType }: ResourceListProps) {
) )
const table = useDataTable<Resources>(fetchResources) const table = useDataTable<Resources>(fetchResources)
console.log(table, "我的套餐的table")
const refreshTable = useCallback(() => { const refreshTable = useCallback(() => {
setFilters(prev => ({ ...prev })) setFilters(prev => ({ ...prev }))
}, []) }, [])
@@ -417,8 +420,8 @@ function ResourceList({ resourceType }: ResourceListProps) {
) )
return ( return (
<div className="space-y-3"> <div className="flex flex-col gap-3 overflow-hidden">
<form onSubmit={onFilter} className="bg-card p-4 rounded-lg"> <form onSubmit={onFilter} className="bg-card p-4 rounded-lg flex-none">
<div className="flex flex-wrap items-end gap-4"> <div className="flex flex-wrap items-end gap-4">
<Controller <Controller
name="user_phone" name="user_phone"
@@ -554,7 +557,13 @@ function ResourceList({ resourceType }: ResourceListProps) {
</form> </form>
<Suspense fallback={<div className="text-center p-4">...</div>}> <Suspense fallback={<div className="text-center p-4">...</div>}>
<DataTable<Resources> {...table} columns={columns} /> <DataTable<Resources>
{...table}
columns={columns}
classNames={{
root: "flex-auto overflow-hidden",
}}
/>
</Suspense> </Suspense>
</div> </div>
) )

View File

@@ -55,7 +55,7 @@ export default function UserPage() {
const onFilter = handleSubmit(data => { const onFilter = handleSubmit(data => {
const result: FilterValues = {} const result: FilterValues = {}
if (data.phone) result.phone = data.phone if (data.phone?.trim()) result.phone = data.phone.trim()
setFilters(result) setFilters(result)
table.pagination.onPageChange(1) table.pagination.onPageChange(1)
}) })

View File

@@ -45,20 +45,27 @@ function ProductShortCode<T extends { code: string }>(
const { field, fieldState } = props const { field, fieldState } = props
const params = new URLSearchParams(field.value) const params = new URLSearchParams(field.value)
const mode = params.get("mode") || "quota"
const live = params.get("live") || "0"
const expire = params.get("expire") || "0"
const setParams = (data: { const setParams = (data: {
mode?: string mode?: string
live?: string live?: string
expire?: string expire?: string
}) => { }) => {
if (data.mode) params.set("mode", data.mode) const newParams = new URLSearchParams()
if (data.live) params.set("live", data.live) newParams.set("mode", data.mode || mode)
if (data.expire) params.set("expire", data.expire) newParams.set("live", data.live || live)
field.onChange(params.toString()) newParams.set("expire", data.expire || expire)
console.log(newParams.toString())
field.onChange(newParams.toString())
} }
const onModeChange = (value: string) => { const onModeChange = (value: string) => {
setParams({ mode: value }) setParams({ mode: value })
} }
const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => { const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0" let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") { if (value.length > 1 && value[0] === "0") {
@@ -67,6 +74,7 @@ function ProductShortCode<T extends { code: string }>(
if (!/^([0-9]+)$/.test(value)) return if (!/^([0-9]+)$/.test(value)) return
setParams({ live: value }) setParams({ live: value })
} }
const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => { const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0" let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") { if (value.length > 1 && value[0] === "0") {
@@ -82,10 +90,7 @@ function ProductShortCode<T extends { code: string }>(
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Select <Select defaultValue={mode} onValueChange={onModeChange}>
defaultValue={params.get("mode") ?? "quota"}
onValueChange={onModeChange}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择套餐类型" /> <SelectValue placeholder="请选择套餐类型" />
</SelectTrigger> </SelectTrigger>
@@ -97,20 +102,12 @@ function ProductShortCode<T extends { code: string }>(
</Field> </Field>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Input <Input type="number" value={live} onChange={onLiveChange} />
type="number"
value={params.get("live") ?? "0"}
onChange={onLiveChange}
/>
</Field> </Field>
{params.get("mode") === "time" && ( {params.get("mode") === "time" && (
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Input <Input type="number" value={expire} onChange={onExpireChange} />
type="number"
value={params.get("expire") ?? "0"}
onChange={onExpireChange}
/>
</Field> </Field>
)} )}
{fieldState.error && <FieldError errors={[fieldState.error]} />} {fieldState.error && <FieldError errors={[fieldState.error]} />}
@@ -125,20 +122,31 @@ function ProductLongCode<T extends { code: string }>(
const { field, fieldState } = props const { field, fieldState } = props
const params = new URLSearchParams(field.value) const params = new URLSearchParams(field.value)
const mode = params.get("mode") || "quota"
const live = params.get("live") || "0"
const expire = params.get("expire") || "0"
const setParams = (data: { const setParams = (data: {
mode?: string mode?: string
live?: string live?: string
expire?: string expire?: string
}) => { }) => {
if (data.mode) params.set("mode", data.mode) const newParams = new URLSearchParams()
if (data.live) params.set("live", data.live) newParams.set("mode", data.mode || mode)
if (data.expire) params.set("expire", data.expire) newParams.set("live", data.live || live)
field.onChange(params.toString()) newParams.set("expire", data.expire || expire)
console.log(newParams.toString())
field.onChange(newParams.toString())
} }
const onModeChange = (value: string) => { const onModeChange = (value: string) => {
setParams({ mode: value }) if (value === "quota") {
setParams({ mode: value, expire: "0" })
} else {
setParams({ mode: value })
}
} }
const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => { const onLiveChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0" let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") { if (value.length > 1 && value[0] === "0") {
@@ -147,6 +155,7 @@ function ProductLongCode<T extends { code: string }>(
if (!/^([0-9]+)$/.test(value)) return if (!/^([0-9]+)$/.test(value)) return
setParams({ live: value }) setParams({ live: value })
} }
const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => { const onExpireChange = (e: ChangeEvent<HTMLInputElement>) => {
let value = e.target.value || "0" let value = e.target.value || "0"
if (value.length > 1 && value[0] === "0") { if (value.length > 1 && value[0] === "0") {
@@ -162,10 +171,7 @@ function ProductLongCode<T extends { code: string }>(
<FieldGroup> <FieldGroup>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Select <Select defaultValue={mode} onValueChange={onModeChange}>
defaultValue={params.get("mode") ?? "quota"}
onValueChange={onModeChange}
>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="请选择套餐类型" /> <SelectValue placeholder="请选择套餐类型" />
</SelectTrigger> </SelectTrigger>
@@ -177,20 +183,12 @@ function ProductLongCode<T extends { code: string }>(
</Field> </Field>
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Input <Input type="number" value={live} onChange={onLiveChange} />
type="number"
value={params.get("live") ?? "0"}
onChange={onLiveChange}
/>
</Field> </Field>
{params.get("mode") === "time" && ( {params.get("mode") === "time" && (
<Field> <Field>
<FieldLabel></FieldLabel> <FieldLabel></FieldLabel>
<Input <Input type="number" value={expire} onChange={onExpireChange} />
type="number"
value={params.get("expire") ?? "0"}
onChange={onExpireChange}
/>
</Field> </Field>
)} )}
{fieldState.error && <FieldError errors={[fieldState.error]} />} {fieldState.error && <FieldError errors={[fieldState.error]} />}

View File

@@ -73,7 +73,6 @@ export const ScopeTrade = "trade"
export const ScopeTradeRead = "trade:read" // 读取交易列表 export const ScopeTradeRead = "trade:read" // 读取交易列表
export const ScopeTradeReadOfUser = "trade:read:of_user" // 读取指定用户的交易列表 export const ScopeTradeReadOfUser = "trade:read:of_user" // 读取指定用户的交易列表
export const ScopeTradeWrite = "trade:write" // 写入交易 export const ScopeTradeWrite = "trade:write" // 写入交易
export const ScopeTradeWriteComplete = "trade:write:complete" // 完成交易
// 账单 // 账单
export const ScopeBill = "bill" export const ScopeBill = "bill"
@@ -85,3 +84,9 @@ export const ScopeBillWrite = "bill:write" // 写入账单
export const ScopeBalanceActivity = "balance_activity" export const ScopeBalanceActivity = "balance_activity"
export const ScopeBalanceActivityRead = "balance_activity:read" // 读取余额变动列表 export const ScopeBalanceActivityRead = "balance_activity:read" // 读取余额变动列表
export const ScopeBalanceActivityReadOfUser = "balance_activity:read:of_user" // 读取指定用户的余额变动列表 export const ScopeBalanceActivityReadOfUser = "balance_activity:read:of_user" // 读取指定用户的余额变动列表
// 代理
export const ScopeProxy = "proxy"
export const ScopeProxyRead = "proxy:read" // 读取代理列表
export const ScopeProxyWrite = "proxy:write" // 写入代理
export const ScopeProxyWriteStatus = "proxy:write:status" // 更改代理状态

11
src/models/gateway.ts Normal file
View File

@@ -0,0 +1,11 @@
export type Gateway = {
id: number
version: string
mac: string
ip: string
host: string
type: number
status: number
meta: string
created_at: Date
}

View File

@@ -11,4 +11,6 @@ export type ProductSku = Model & {
product?: Product product?: Product
price_min?: string price_min?: string
discount?: ProductDiscount discount?: ProductDiscount
sort: number
count_min: number
} }