首页添加二级路由

This commit is contained in:
Eamon
2026-08-03 10:53:12 +08:00
parent 0d5e28c785
commit 038e66d0cc
18 changed files with 1240 additions and 59 deletions

View File

@@ -1,5 +1,305 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { useMemo, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { Link } from "react-router-dom"
import { toast } from "sonner"
import { z } from "zod"
import DataTable from "@/components/data-table"
import Page from "@/components/page"
import { Button } from "@/components/ui/button"
import { Field, FieldError, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import type { ChannelRecord } from "@/lib/models/channel"
const filterSchema = z.object({
expired_at_end: z.string().optional(),
product: z.string().optional(),
plan: z.string().optional(),
account: z.string().optional(),
status: z.string().optional(),
})
type FormValues = z.infer<typeof filterSchema>
const initialData: ChannelRecord[] = [
{
id: "1",
startAt: "2025.04.09",
product: "极光IP",
plan: "测试卡(带宽)",
account: "ft8765",
password: "765修改",
ip: "甘肃-兰州125.75.111.127",
server: "p3.125.74.129.38.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "已过期",
online: "查看",
download: "下载",
status: "",
},
{
id: "2",
startAt: "2025.04.09",
product: "极光IP",
plan: "测试卡(带宽)",
account: "ft8765",
password: "765修改",
ip: "辽宁-辽阳123.190.161.192",
server: "p16.cqdwx4.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "0天00时37分",
online: "查看",
download: "下载",
status: "",
},
{
id: "3",
startAt: "2025.04.09",
product: "极光IP",
plan: "静态-项目-20M-月卡",
account: "ft8765",
password: "765修改",
ip: "黑龙江-鸡西100.100.4.249",
server: "p37.sdlw2.sdip.cc",
connections: 1,
expiredAt: "2025.04.09",
remaining: "0天00时37分",
online: "查看",
download: "下载",
status: "",
},
]
export default function ChannelsPage() {
return <Page>IP管理</Page>
const [filterValues, setFilterValues] = useState<FormValues>({})
const [status, setStatus] = useState<"load" | "done">("done")
// 行选择状态
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({})
const { control, handleSubmit, reset } = useForm<FormValues>({
resolver: zodResolver(filterSchema),
defaultValues: {
expired_at_end: "",
product: "",
plan: "",
account: "",
status: "",
},
})
const filteredData = useMemo(() => {
if (!filterValues || Object.values(filterValues).every(value => !value)) {
return initialData
}
return initialData.filter(record => {
if (filterValues.expired_at_end) {
return record.expiredAt <= filterValues.expired_at_end
}
if (
filterValues.product &&
!record.product.includes(filterValues.product)
) {
return false
}
if (filterValues.plan && !record.plan.includes(filterValues.plan)) {
return false
}
if (
filterValues.account &&
!record.account.includes(filterValues.account)
) {
return false
}
if (filterValues.status && record.status !== filterValues.status) {
return false
}
return true
})
}, [filterValues])
const onSubmit = (values: FormValues) => {
setStatus("load")
setTimeout(() => {
setFilterValues(values)
setRowSelection({})
setStatus("done")
}, 200)
}
const onReset = () => {
reset()
setFilterValues({})
setRowSelection({})
}
// 根据选中ID获取对应行数据
const getSelectedRows = () => {
const ids = Object.keys(rowSelection)
return filteredData.filter(item => ids.includes(item.id))
}
// 批量续费
const handleRenew = () => {
const rows = getSelectedRows()
console.log("续费选中数据:", rows)
}
// 单个退货
const handleReturn = () => {
const rows = getSelectedRows()
if (rows.length !== 1) {
toast.error("请选择一条数据进行退货")
return
}
console.log("退货数据:", rows[0])
}
return (
<Page>
<div>
<form className="bg-card rounded-lg" onSubmit={handleSubmit(onSubmit)}>
<div className="flex items-center justify-center gap-4 bg-amber-200 px-3 py-3">
<span className="text-orange-600 text-sm">
为了给您带来更好的服务体验,请完善QQ号和微信号
</span>
<Link to="/">立即完善</Link>
</div>
<div className="flex flex-wrap p-4 items-end gap-4">
<Controller
name="expired_at_end"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-48 flex-none"
>
<FieldLabel>到期时间</FieldLabel>
<Input type="datetime-local" step="1" {...field} />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="product"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-48 flex-none"
>
<FieldLabel>全部产品</FieldLabel>
<Input {...field} placeholder="请输入产品名称" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="plan"
control={control}
render={({ field, fieldState }) => (
<Field
data-invalid={fieldState.invalid}
className="w-48 flex-none"
>
<FieldLabel>全部套餐</FieldLabel>
<Input {...field} placeholder="请输入套餐名称" />
<FieldError>{fieldState.error?.message}</FieldError>
</Field>
)}
/>
<Controller
name="account"
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>
)}
/>
<div className="flex gap-2">
<Button type="submit">查询</Button>
<Button type="button" variant="outline" onClick={onReset}>
重置
</Button>
</div>
</div>
</form>
</div>
<div className="bg-card rounded-lg p-4">
<div className="flex gap-4 items-center">
<Button type="button" variant="outline" onClick={handleRenew}>
续费
</Button>
<Button type="button" variant="outline" onClick={handleReturn}>
退货
</Button>
<p className="text-orange-600 text-sm">
温馨提示:批量续费可通过筛选和搜索功能精准定位账号
</p>
</div>
<DataTable<ChannelRecord>
data={filteredData}
status={status}
columns={[
{ accessorKey: "startAt", header: "开通时间" },
{ accessorKey: "product", header: "产品" },
{ accessorKey: "plan", header: "套餐" },
{ accessorKey: "account", header: "账号" },
{ accessorKey: "password", header: "密码" },
{ accessorKey: "ip", header: "IP地址" },
{ accessorKey: "server", header: "服务器" },
{ accessorKey: "connections", header: "连接数" },
{ accessorKey: "expiredAt", header: "到期时间" },
{ accessorKey: "remaining", header: "剩余时间" },
{
accessorKey: "online",
header: "在线及拨线",
cell: ({ row }) => (
<Button size="sm" variant="outline">
{row.original.online}
</Button>
),
},
{ accessorKey: "status", header: "选择地区" },
{
accessorKey: "download",
header: "软件下载",
cell: ({ row }) => (
<Button size="sm" variant="ghost">
{row.original.download}
</Button>
),
},
]}
pagination={{
page: 0,
size: 10,
total: filteredData.length,
}}
enableSelection
getRowId={row => row.id}
rowSelection={rowSelection}
onSelectionChange={setRowSelection}
/>
</div>
</Page>
)
}

View File

@@ -27,6 +27,7 @@ import {
import { cn } from "@/lib/utils"
import logoAvatar from "./assets/logo-avatar.svg"
import { useLayout } from "./layout-context"
import { AdminNavMenu } from "./nav-menu"
export function Shell(props: { children: ReactNode }) {
const { navbar } = useLayout()
@@ -35,7 +36,7 @@ export function Shell(props: { children: ReactNode }) {
data-expand={navbar}
className={cn(
"transition-[grid-template-columns] duration-300 ease-in-out",
"w-full h-full grid",
"w-full h-full grid bg-card",
"grid-rows-[64px_1fr]",
"data-[expand=true]:grid-cols-[200px_1fr]",
"data-[expand=false]:grid-cols-[0px_1fr]",
@@ -57,7 +58,7 @@ export function Content(props: { children: ReactNode }) {
"absolute inset-0 overflow-hidden",
"mt-16",
"md:ml-16",
"lg:data-[expand=true]:ml-[200px]",
"lg:data-[expand=true]:ml-50",
)}
>
{props.children}
@@ -85,8 +86,8 @@ export function Mask() {
export function Header() {
const { navbar, toggleNavbar } = useLayout()
return (
<header className="flex-none h-16 overflow-hidden flex items-stretch">
<div className="flex-auto flex items-center gap-2">
<header className="flex-none h-16 flex items-stretch">
<div className="flex-none flex items-center">
<Button
variant="ghost"
className="h-9 ml-4 md:ml-0"
@@ -96,13 +97,15 @@ export function Header() {
</Button>
</div>
<AdminNavMenu />
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
<Link
{/* <Link
to="/"
className="flex-none h-16 flex items-center justify-center text-sm"
>
返回首页
</Link>
</Link> */}
<UserCenter />
</div>
</header>

View File

@@ -92,7 +92,7 @@ export default function DashboardPage() {
const [editDialogOpen, setEditDialogOpen] = useState(false)
const handleSaveInfo = (data: any) => {
console.log("保存的信息:", data)
// 这里调用你的 API 保存数据
// 这里调 API 保存数据
}
return (
<Page

View File

@@ -32,17 +32,17 @@ export default function Effects(props: { children?: ReactNode }) {
setNavbar(md.matches)
sm.addEventListener("change", (e) => {
sm.addEventListener("change", e => {
breakpoints.sm = e.matches
})
md.addEventListener("change", (e) => {
md.addEventListener("change", e => {
breakpoints.md = e.matches
setNavbar(e.matches)
})
lg.addEventListener("change", (e) => {
lg.addEventListener("change", e => {
breakpoints.lg = e.matches
})
xl.addEventListener("change", (e) => {
xl.addEventListener("change", e => {
breakpoints.xl = e.matches
})

View File

@@ -9,11 +9,11 @@ export default function AdminLayout() {
<Effects />
<div className="relative h-dvh overflow-hidden">
<Shell>
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20">
<div className="col-start-1 row-start-1 row-span-2 bg-card overflow-hidden relative z-20 flex">
<Navbar />
</div>
<div className="col-start-2 row-start-1 bg-card overflow-hidden relative z-20">
<div className="col-start-2 row-start-1 bg-card relative z-20">
<Header />
</div>

252
src/admin/nav-menu.tsx Normal file
View File

@@ -0,0 +1,252 @@
import { useCallback, useRef, useState } from "react"
import { createPortal } from "react-dom"
import { NavLink, useNavigate } from "react-router-dom"
import { cn } from "@/lib/utils"
export function AdminNavMenu() {
return (
<nav className="flex-auto flex items-center justify-center gap-1 max-lg:hidden">
<AdminNavItem to="/">首页</AdminNavItem>
<AdminMenuItem text="产品购买">
<ProductDropdown />
</AdminMenuItem>
<AdminNavItem to="/ipline">IP线路表</AdminNavItem>
<AdminNavItem to="/softDownload">软件下载</AdminNavItem>
<AdminNavItem to="/help">帮助/教程</AdminNavItem>
</nav>
)
}
function AdminNavItem({
to,
children,
}: {
to: string
children: React.ReactNode
}) {
return (
<NavLink
to={to}
className={({ isActive }) =>
cn(
"h-10 flex items-center px-3 text-sm rounded-md transition-colors duration-200",
isActive
? "text-blue-600 bg-blue-50"
: "text-slate-600 hover:text-blue-600 hover:bg-gray-100",
)
}
>
{children}
</NavLink>
)
}
function AdminMenuItem({
text,
children,
}: {
text: string
children: React.ReactNode
}) {
const [isOpen, setIsOpen] = useState(false)
const [pos, setPos] = useState({ top: 0, left: 0 })
const triggerRef = useRef<HTMLDivElement>(null)
const timeoutRef = useRef<number | null>(null)
const updatePosition = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect()
setPos({ top: rect.bottom + 8, left: rect.left + rect.width / 2 })
}
}, [])
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
updatePosition()
setIsOpen(true)
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
setIsOpen(false)
}, 150)
}
return (
<div
ref={triggerRef}
className="relative h-full flex items-center"
role="button"
tabIndex={0}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
className={cn(
"h-10 flex items-center gap-1 px-3 text-sm rounded-md transition-colors duration-200 pointer-events-none",
isOpen
? "text-blue-600 bg-blue-50"
: "text-slate-600 group-hover:text-blue-600 group-hover:bg-gray-100",
)}
>
<span>{text}</span>
<svg
className={cn(
"w-3 h-3 transition-transform duration-200",
isOpen && "rotate-180",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{createPortal(
<div
role="button"
tabIndex={-1}
className={cn(
"fixed transition-all duration-200 z-[9999]",
isOpen
? "opacity-100 visible translate-y-0"
: "opacity-0 invisible -translate-y-2 pointer-events-none",
)}
style={{
top: pos.top,
left: pos.left,
transform: "translateX(-50%)",
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-4 w-[560px]">
{children}
</div>
</div>,
document.body,
)}
</div>
)
}
function ProductDropdown() {
const navigate = useNavigate()
const handleClick = (path: string) => {
navigate(path)
}
return (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<ProductCard
title="动静态IP"
desc="千万级动态短效IP,自动去重,高并发采集"
tag="热门"
onClick={() => handleClick("/product/short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => handleClick("/product/long")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP,4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/global")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP,更易于调用与开发"
onClick={() => handleClick("/product/tunnel")}
/>
<ProductCard
title="动态Vps"
desc="IP独享,高带宽,灵活控制存活周期"
onClick={() => handleClick("/product/exclusive")}
/>
<ProductCard
title="静态独享IP"
desc="纯净IP,更符合跨境卖家需求的云主机"
onClick={() => handleClick("/product/static")}
/>
</div>
<div className="flex items-center flex-wrap gap-2 pt-2 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
<button
onClick={() => handleClick("/product/short")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
短效代理
</button>
<button
onClick={() => handleClick("/product/global")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
海外代理
</button>
<button
onClick={() => handleClick("/product/tunnel")}
className="text-xs text-blue-600 hover:underline whitespace-nowrap"
>
隧道代理
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/custom")}
className="text-xs text-orange-500 hover:underline font-medium whitespace-nowrap"
>
业务定制 →
</button>
</div>
</div>
)
}
function ProductCard({
title,
desc,
tag,
onClick,
}: {
title: string
desc: string
tag?: string
onClick: () => void
}) {
return (
<button
onClick={onClick}
className="group flex items-start gap-2 p-2 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-xs whitespace-nowrap">
{title}
</span>
{tag && (
<span className="text-[10px] bg-blue-100 text-blue-600 px-1.5 py-0.5 rounded-full shrink-0">
{tag}
</span>
)}
</div>
<p className="text-[11px] text-gray-500 mt-0.5 leading-relaxed line-clamp-2">
{desc}
</p>
</div>
</button>
)
}

View File

@@ -5,6 +5,8 @@ import {
useReactTable,
} from "@tanstack/react-table"
import { Loader } from "lucide-react"
import { useMemo } from "react"
import { Checkbox } from "@/components/ui/checkbox"
import { Pagination, type PaginationProps } from "@/components/ui/pagination"
import {
TableBody,
@@ -25,29 +27,87 @@ export type DataTableProps<T> = {
headRow?: string
dataRow?: string
}
/** 是否开启行选择复选框 */
enableSelection?: boolean
/** 受控选中状态 */
rowSelection?: Record<string, boolean>
/** 选中状态变化回调 */
onSelectionChange?: (selection: Record<string, boolean>) => void
/** 行唯一ID生成函数 */
getRowId?: (row: T, index: number) => string
}
export default function DataTable<T extends Record<string, unknown>>(
props: DataTableProps<T>,
) {
// 动态注入复选框列
const finalColumns = useMemo<ColumnDef<T>[]>(() => {
if (!props.enableSelection) return props.columns
const selectColumn: ColumnDef<T> = {
id: "select",
size: 40,
enableSorting: false,
enableHiding: false,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={value => table.toggleAllPageRowsSelected(!!value)}
aria-label="全选当前页"
className="rounded-none translate-y-0.5"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={value => row.toggleSelected(!!value)}
aria-label="选择行"
className="rounded-none translate-y-0.5"
/>
),
}
return [selectColumn, ...props.columns]
}, [props.enableSelection, props.columns])
const table = useReactTable({
data: props.data,
columns: props.columns,
columns: finalColumns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
rowCount: props.pagination?.total ?? props.data.length,
// 行选择核心配置
enableRowSelection: props.enableSelection ?? false,
getRowId: props.getRowId,
onRowSelectionChange: updaterOrValue => {
if (!props.onSelectionChange) return
const newSelection =
typeof updaterOrValue === "function"
? updaterOrValue(props.rowSelection ?? {})
: updaterOrValue
props.onSelectionChange(newSelection)
},
state: {
pagination: {
pageIndex: props.pagination?.page ?? 0,
pageSize: props.pagination?.size ?? props.data.length,
},
columnFilters: [],
...(props.rowSelection !== undefined && {
rowSelection: props.rowSelection,
}),
},
autoResetPageIndex: false,
})
console.log("data length:", props.data.length)
console.log("pageSize:", props.pagination?.size ?? props.data.length)
console.log("rows:", table.getRowModel().rows.length)
return (
<>
{/* 数据表 */}
@@ -55,7 +115,7 @@ export default function DataTable<T extends Record<string, unknown>>(
<TableRoot>
<TableHeader>
{table.getHeaderGroups().map(group => (
<TableRow key={group.id}>
<TableRow key={group.id} className={props.classNames?.headRow}>
{group.headers.map(header => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
@@ -73,7 +133,7 @@ export default function DataTable<T extends Record<string, unknown>>(
{props.status === "fail" ? (
<TableRow>
<TableCell
colSpan={props.columns.length}
colSpan={finalColumns.length}
className="text-center text-fail"
>
加载失败
@@ -82,7 +142,7 @@ export default function DataTable<T extends Record<string, unknown>>(
) : !props.data?.length ? (
<TableRow>
<TableCell
colSpan={props.columns.length}
colSpan={finalColumns.length}
className="text-center"
>
暂无数据

235
src/components/ui/field.tsx Normal file
View File

@@ -0,0 +1,235 @@
import { cva, type VariantProps } from "class-variance-authority"
import { useMemo } from "react"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
import { cn } from "@/lib/utils/index"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-4 has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className,
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-1.5 font-medium data-[variant=label]:text-sm data-[variant=legend]:text-base",
className,
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-5 data-[slot=checkbox-group]:gap-3 *:data-[slot=field-group]:gap-4",
className,
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-2 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: "flex-col *:w-full [&>.sr-only]:w-auto",
horizontal:
"flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
responsive:
"flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
},
},
defaultVariants: {
orientation: "vertical",
},
},
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-0.5 leading-snug",
className,
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-data-checked:border-primary/30 has-data-checked:bg-primary/5 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col",
className,
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm font-medium group-data-[disabled=true]/field:opacity-50",
className,
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-left text-sm leading-normal font-normal text-muted-foreground group-has-data-horizontal/field:text-balance [[data-variant=legend]+&]:-mt-1.5",
"last:mt-0 nth-last-2:-mt-1",
"[&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary",
className,
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className,
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="relative mx-auto block w-fit bg-background px-2 text-muted-foreground"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map(error => [error?.message, error])).values(),
]
if (uniqueErrors?.length === 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>,
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-sm font-normal text-destructive", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldContent,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSeparator,
FieldSet,
FieldTitle,
}

View File

@@ -1,5 +1,5 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import type * as React from "react"
import { cn } from "@/lib/utils/index"
@@ -12,7 +12,7 @@ function Label({
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
className,
)}
{...props}
/>

View File

@@ -0,0 +1,28 @@
"use client"
import { Separator as SeparatorPrimitive } from "radix-ui"
import type * as React from "react"
import { cn } from "@/lib/utils/index"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className,
)}
{...props}
/>
)
}
export { Separator }

View File

@@ -0,0 +1,43 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,5 +1,5 @@
import { useSyncExternalStore } from "react"
import { NavLink } from "react-router-dom"
import { useRef, useState, useSyncExternalStore } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import Wrap from "@/components/wrap"
import { cn } from "@/lib/utils"
@@ -15,8 +15,16 @@ export default function Header() {
() => false,
)
// ======================
// 菜单状态
// ======================
const [openMenu, setOpenMenu] = useState(false)
const handleEnter = () => setOpenMenu(true)
const handleLeave = () => setOpenMenu(false)
return (
<header className={cn("fixed top-0 left-0 w-screen z-10 flex flex-col ")}>
<header className={cn("fixed top-0 left-0 w-screen z-10 flex flex-col")}>
<div
className={cn(
`flex-none pointer-events-auto`,
@@ -30,11 +38,17 @@ export default function Header() {
<nav className="flex items-center justify-between gap-4 lg:gap-8">
<ul className="h-full items-stretch hidden lg:flex">
<NavItem to="/">首页</NavItem>
<NavItem to="/product">产品购买</NavItem>
<MenuItem
text="产品购买"
isOpen={openMenu}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
<ProductDropdown onNavigate={() => setOpenMenu(false)} />
</MenuItem>
<NavItem to="/ipline">IP线路表</NavItem>
<NavItem to="/softDownload">软件下载</NavItem>
<NavItem to="/help">帮助/教程</NavItem>
{/* <NavItem to="/client">下载客户端</NavItem> */}
</ul>
</nav>
<ProfileOrLogin />
@@ -53,15 +67,15 @@ const NavItem = ({
}) => (
<NavLink
to={to}
className={({ isActive }) =>
`h-full flex items-center relative px-3 ${
isActive
? "text-blue-500 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-0.5 after:bg-blue-500"
: "text-slate-700"
}`
}
className="group h-full flex items-center relative px-3 text-slate-700 hover:text-blue-500 transition-colors duration-200"
>
{children}
<span
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
"bg-transparent group-hover:bg-blue-500",
)}
/>
</NavLink>
)
@@ -89,3 +103,200 @@ function ProfileOrLogin() {
</div>
)
}
const MenuItem = ({
text,
children,
isOpen,
onMouseEnter,
onMouseLeave,
}: {
text: string
children: React.ReactNode
isOpen: boolean
onMouseEnter: () => void
onMouseLeave: () => void
}) => {
const timeoutRef = useRef<number | null>(null)
const handleMouseEnter = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
onMouseEnter()
}
const handleMouseLeave = () => {
timeoutRef.current = setTimeout(() => {
onMouseLeave()
}, 150)
}
return (
<li
className="group relative h-full flex items-center"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
className={cn(
"h-full flex items-center gap-1 px-3 text-lg transition-colors duration-200",
isOpen ? "text-blue-500" : "text-slate-700 hover:text-blue-500",
)}
>
<span>{text}</span>
<svg
className={cn(
"w-4 h-4 transition-transform duration-200",
isOpen ? "rotate-180" : "",
)}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<span
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 transition-colors duration-200",
isOpen ? "bg-blue-500" : "bg-transparent group-hover:bg-blue-500",
)}
/>
<div
className={cn(
"absolute top-full left-0 pt-3 w-screen max-w-4xl transition-all duration-200",
isOpen
? "opacity-100 visible translate-y-0"
: "opacity-0 invisible -translate-y-2 pointer-events-none",
)}
style={{
left: "50%",
transform: `translateX(-7%)`,
}}
>
<div className="bg-white rounded-xl shadow-2xl border border-gray-100/80 p-6">
{children}
</div>
</div>
</li>
)
}
const ProductDropdown = ({ onNavigate }: { onNavigate: () => void }) => {
const navigate = useNavigate()
const handleClick = (path: string) => {
navigate(path)
onNavigate()
}
return (
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<ProductCard
title="动静态IP"
desc="千万级动态短效IP,自动去重,高并发采集"
tag="热门"
onClick={() => handleClick("/product/short")}
/>
<ProductCard
title="新HTTP/S5"
desc="IP可用时长从数小时到365天全覆盖"
tag="稳定"
onClick={() => handleClick("/product/long")}
/>
<ProductCard
title="HTTP/S5"
desc="海量全球住宅优质IP,4G/5G真实手机IP"
tag="全球"
onClick={() => handleClick("/product/global")}
/>
<ProductCard
title="软路由成本价"
desc="云端自动切换IP,更易于调用与开发"
onClick={() => handleClick("/product/tunnel")}
/>
<ProductCard
title="动态Vps"
desc="IP独享,高带宽,灵活控制存活周期"
onClick={() => handleClick("/product/exclusive")}
/>
<ProductCard
title="静态独享IP"
desc="纯净IP,更符合跨境卖家需求的云主机"
onClick={() => handleClick("/product/static")}
/>
</div>
<div className="flex items-center flex-wrap gap-3 pt-3 border-t border-gray-100">
<span className="text-xs font-medium text-gray-400">热门推荐:</span>
<button
onClick={() => handleClick("/product/short")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
短效代理
</button>
<button
onClick={() => handleClick("/product/global")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
海外代理
</button>
<button
onClick={() => handleClick("/product/tunnel")}
className="text-sm text-blue-600 hover:underline whitespace-nowrap"
>
隧道代理
</button>
<span className="text-xs text-gray-300">|</span>
<button
onClick={() => handleClick("/custom")}
className="text-sm text-orange-500 hover:underline font-medium whitespace-nowrap"
>
业务定制 →
</button>
</div>
</div>
)
}
const ProductCard = ({
title,
desc,
tag,
onClick,
}: {
title: string
desc: string
tag?: string
onClick: () => void
}) => (
<button
onClick={onClick}
className="group flex items-start gap-3 p-3 rounded-lg hover:bg-gray-50 transition-all cursor-pointer border border-transparent hover:border-blue-200 text-left w-full"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-gray-800 group-hover:text-blue-600 transition-colors text-sm whitespace-nowrap">
{title}
</span>
{tag && (
<span className="text-[10px] bg-blue-100 text-blue-600 px-2 py-0.5 rounded-full shrink-0">
{tag}
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-1 leading-relaxed line-clamp-2">
{desc}
</p>
</div>
</button>
)

View File

@@ -79,12 +79,12 @@ export default function HomePage() {
<ProductTypeCard icon={s5} title="专属化产品定制" />
<ProductTypeCard icon={s6} title="专业的服务团队" />
</ul>
<div className="flex justify-between items-center gap-10 flex-wrap mt-16 p-20 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white">
<div className="flex max-md:flex-col justify-between items-center gap-6 md:gap-10 mt-10 lg:mt-16 p-6 md:p-12 lg:p-20 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white">
<div className="flex flex-col">
<h3 className="text-2xl font-bold mb-4">
<h3 className="text-xl lg:text-2xl font-bold mb-4">
全网产品最多,IP库最大
</h3>
<p className="text-gray-600 w-150 text-sm">
<p className="text-gray-600 max-w-full lg:w-150 text-sm">
旗下有极狐、极光、蘑菇、猎豹、麒麟、水滴、火狐、无极等优质动态独享、静态独享、单窗口IP产品
</p>
</div>
@@ -92,15 +92,15 @@ export default function HomePage() {
<img src={s7} alt="产品" className="w-20 h-20 rounded-lg" />
</div>
</div>
<div className="flex flex-col pt-24 ">
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
<div className="flex flex-col pt-12 lg:pt-24 ">
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
海量地区,遍布全国的优质IP代理资源
</h2>
<p className="text-gray-600 text-center text-sm">
遍布全国的代理服务器节点为用户提供智能帮助
</p>
</div>
<div className="pt-20">
<div className="pt-10 lg:pt-20">
<ul className="shadow-[0_0_20px_4px] shadow-blue-50 p-8 flex max-lg:flex-col">
<li className="flex-1 flex flex-col items-center justify-center max-lg:mb-4 ">
<p className="mt-9 max-lg:mt-2 text-3xl bg-linear-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2">
@@ -129,8 +129,8 @@ export default function HomePage() {
</ul>
</div>
<div className="flex flex-col pt-24 ">
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
<div className="flex flex-col pt-12 lg:pt-24 ">
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
满足您的多种业务需求
</h2>
<p className="text-gray-600 text-center text-sm">
@@ -138,12 +138,12 @@ export default function HomePage() {
</p>
</div>
<div className="flex mt-20 items-center justify-center">
<img src={s9} alt="图片" />
<div className="flex mt-10 lg:mt-20 items-center justify-center">
<img src={s9} alt="图片" className="max-w-full h-auto" />
</div>
<div className="flex flex-col pt-24 ">
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
<div className="flex flex-col pt-12 lg:pt-24 ">
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
为您提供质量保障
</h2>
<p className="text-gray-600 text-center text-sm">
@@ -156,7 +156,9 @@ export default function HomePage() {
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
<img src={s10} alt="售前咨询" className="p-6" />
</div>
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售前咨询</h2>
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
售前咨询
</h2>
<p className="text-xs text-gray-600">
注册即享新人福利,专业的客户经理,多维度为您提供在线代理相关
答疑
@@ -166,7 +168,9 @@ export default function HomePage() {
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
<img src={s11} alt="售中服务" className="p-6" />
</div>
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售中服务</h2>
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
售中服务
</h2>
<p className="text-xs text-gray-600">
全国100万+动态IP代理资源免费测试,先测后买让您安心使用
</p>
@@ -175,15 +179,17 @@ export default function HomePage() {
<div className="flex w-full mb-5 bg-blue-50 items-center justify-center">
<img src={s12} alt="售后保障" className="p-6" />
</div>
<h2 className="text-3xl font-bold mb-8 lg:mb-24">售后保障</h2>
<h2 className="text-xl lg:text-3xl font-bold mb-4 lg:mb-8">
售后保障
</h2>
<p className="text-xs text-gray-600">
1V1专属售后答疑,技术团队7*24小时在线支持提供专属解决方案
</p>
</div>
</div>
<div className="flex flex-col pt-24 ">
<h2 className="text-center text-3xl font-bold mb-8 lg:mb-24">
<div className="flex flex-col pt-12 lg:pt-24 ">
<h2 className="text-center text-2xl lg:text-3xl font-bold mb-6 lg:mb-16">
为您推荐聚IP的产品
</h2>
<p className="text-gray-600 text-center text-sm">
@@ -191,7 +197,7 @@ export default function HomePage() {
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 pt-30">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 lg:gap-8 pt-12 lg:pt-30">
<div className="flex flex-col shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white p-8">
<div className="mb-3">
<h4 className="text-xl font-semibold mb-2 group-hover:text-blue-600 transition-colors">
@@ -296,7 +302,9 @@ function PageSection(props: { title: string; children: ReactNode }) {
return (
<section>
<div className="max-w-308 mx-auto px-4 flex flex-col items-stretch">
<h2 className="text-center text-3xl mb-8 lg:mb-24">{props.title}</h2>
<h2 className="text-center text-2xl lg:text-3xl mb-6 lg:mb-16">
{props.title}
</h2>
{props.children}
</div>
</section>
@@ -305,7 +313,7 @@ function PageSection(props: { title: string; children: ReactNode }) {
function ProductTypeCard(props: { icon: string; title: string }) {
return (
<li className="flex flex-col items-center gap-5rounded-lg text-center gap-6">
<li className="flex flex-col items-center gap-5 rounded-lg text-center gap-6">
<img
src={props.icon}
alt={props.title}

16
src/lib/models/channel.ts Normal file
View File

@@ -0,0 +1,16 @@
export type ChannelRecord = {
id: string
startAt: string
product: string
plan: string
account: string
password: string
ip: string
server: string
connections: number
expiredAt: string
remaining: string
online: string
download: string
status: string
}