10 Commits

29 changed files with 930 additions and 316 deletions

View File

@@ -32,6 +32,7 @@ const eslintConfig = defineConfig([
'@stylistic/block-spacing': 'off',
'@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/no-unused-vars': 'off',
'@react-hooks/set-state-in-effect': 'off',
},
},
])

8
src/actions/batch.ts Normal file
View File

@@ -0,0 +1,8 @@
'use server'
import {PageRecord} from '@/lib/api'
import {Batch} from '@/lib/models/batch'
import {callByUser} from './base'
export async function pageBatch(props: {page: number, size: number}) {
return callByUser<PageRecord<Batch>>('/api/batch/page', props)
}

View File

@@ -2,6 +2,7 @@
import {callByUser, callPublic} from '@/actions/base'
import {Channel} from '@/lib/models'
import {PageRecord} from '@/lib/api'
import {Batch} from '@/lib/models/batch'
export async function listChannels(props: {
page: number

View File

@@ -5,9 +5,8 @@ import {callByUser} from './base'
import {listAnnouncements} from './announcement'
type statisticsResourceUsageReq = {
resource_no?: string
create_after?: Date
create_before?: Date
time_start?: Date
time_end?: Date
}
type statisticsResourceUsageResp = {
@@ -59,10 +58,7 @@ export async function listInitialization(): Promise<ApiResponse<listInitializati
message: '公告数据获取失败',
}
}
const usage = await statisticsResourceUsage({
create_after: new Date(),
create_before: new Date(),
})
const usage = await statisticsResourceUsage({})
if (!usage.success) {
return {
success: false,

View File

@@ -87,5 +87,9 @@ export async function payClose(props: {
}
export async function getPrice(props: CreateResourceReq) {
return callByDevice<{price: string}>('/api/resource/price', props)
return callByDevice<{
price: string
discounted_price?: string
discounted?: number
}>('/api/resource/price', props)
}

View File

@@ -21,7 +21,6 @@ async function createWhitelist(props: {
host: string
remark?: string
}) {
console.log(props)
return await callByUser('/api/whitelist/create', props)
}
@@ -30,7 +29,6 @@ async function updateWhitelist(props: {
host?: string
remark?: string
}) {
console.log(props)
return await callByUser('/api/whitelist/update', props)
}

View File

@@ -18,8 +18,22 @@ export default function Footer(props: FooterProps) {
<p className="text-sm text-gray-400">/177 9666 8888</p>
<p className="text-sm text-gray-400">QQ 70177252</p>
<h3 className="hidden sm:block"></h3>
<p className="text-sm text-gray-400 hidden sm:block"></p>
<p className="text-sm text-gray-400 hidden sm:block"></p>
<a
href="https://work.weixin.qq.com/kfid/kfc458bc58e79e5093f"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-400 hidden sm:block cursor-pointer hover:text-white transition-colors"
>
</a>
<a
href="https://work.weixin.qq.com/kfid/kfc458bc58e79e5093f"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-400 hidden sm:block cursor-pointer hover:text-white transition-colors"
>
</a>
</div>
<SiteNavList
@@ -27,7 +41,7 @@ export default function Footer(props: FooterProps) {
items={[
{name: `产品订购`, href: `/product`},
{name: `获取代理`, href: `/collect`},
{name: `帮助中心`, href: `/docs`},
{name: `帮助中心`, href: `/docs/faq-general`},
{name: `企业服务`, href: `/custom`},
]}
/>
@@ -42,14 +56,14 @@ export default function Footer(props: FooterProps) {
<SiteNavList
title="使用案例"
items={[
{name: `数据抓取`, href: `/data-capture`},
{name: `媒体矩阵`, href: `#`},
{name: `广告验证`, href: `#`},
{name: `价格监控`, href: `#`},
{name: `市场调研`, href: `#`},
{name: `金融数据`, href: `#`},
{name: `SEO优化`, href: `#`},
{name: `测试`, href: `#`},
{name: `数据采集`, href: `/data-capture`},
{name: `电商运营`, href: `/e-commerce`},
{name: `市场调研`, href: `/market-research`},
{name: `SEO优化`, href: `/seo-optimization`},
{name: `社交媒体`, href: `/social-media`},
{name: `广告投放`, href: `/advertising`},
{name: `账号管理`, href: `/account-management`},
{name: `测试`, href: `/network-testing`},
]}
/>
<SiteNavList
@@ -62,13 +76,25 @@ export default function Footer(props: FooterProps) {
/>
</div>
<div className="flex-none mt-6 pt-6 border-t border-gray-700 flex flex-col text-gray-300">
<div className="flex-none mt-6 pt-6 border-t border-gray-700 flex text-center flex-col text-gray-300">
<p className="text-xs">
IP服务使IP从事的任何行为均不代表蓝狐代理IP的意志和观点
<br/>
使
</p>
<p className={`text-xs mt-3 `}> | ICP备17004061号-17 | B1-20190663</p>
{/* <p className={`text-xs mt-3 `}>版权所有 河南华连网络科技有限公司 | 豫ICP备17004061号-17 | 增值电信业务经营许可证B1-20190663</p> */}
<p className={`text-xs mt-3 `}>
|
<a
href="https://beian.miit.gov.cn/#/Integrated/index"
target="_blank"
rel="noopener noreferrer"
className="hover:underline"
>
ICP备17004061号-17
</a>
| B1-20190663
</p>
</div>
</Wrap>
</footer>

View File

@@ -15,6 +15,11 @@ export default function ProductPage(props: ProductPageProps) {
<HomePage path={[
{label: '产品中心', href: '/product'},
]}>
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<h1 className="text-center text-3xl md:text-4xl lg:text-5xl font-bold mb-4 md:mb-4">
</h1>
</div>
<Wrap>
<Suspense>
<Purchase/>

View File

@@ -1,24 +1,20 @@
'use client'
import {Tabs, TabsList, TabsTrigger, TabsContent} from '@/components/ui/tabs'
import Image from 'next/image'
import soon from '../_assets/coming-soon.svg'
import DatePicker from '@/components/date-picker'
import {Card, CardContent} from '@/components/ui/card'
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {zodResolver} from '@hookform/resolvers/zod'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import {merge} from '@/lib/utils'
import {compareAsc, format, addDays} from 'date-fns'
import {Button} from '@/components/ui/button'
import {useState} from 'react'
import {statisticsResourceUsage} from '@/actions/dashboard'
import {ExtraResp} from '@/lib/api'
import {toast} from 'sonner'
import {compareAsc, format, subDays} from 'date-fns'
import {Label} from '@/components/ui/label'
import {ChartConfig, ChartContainer} from '@/components/ui/chart'
import {CartesianGrid, XAxis, YAxis, Tooltip, Area, AreaChart, Legend} from 'recharts'
import mask from '../_assets/Mask group.webp'
import Image from 'next/image'
type ChartsProps = {
initialData?: ExtraResp<typeof statisticsResourceUsage>
@@ -27,38 +23,38 @@ type ChartsProps = {
export default function Charts({initialData}: ChartsProps) {
// const [submittedData, setSubmittedData] = useState<ExtraReq<typeof listAccount>>()
const [submittedData, setSubmittedData] = useState<ExtraResp<typeof statisticsResourceUsage>>(initialData || [])
const formSchema = zod.object({
resource_no: zod.string().optional(),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
const filterSchema = zod.object({
time_start: zod.date().optional(),
time_end: zod.date().optional(),
})
type FormValues = zod.infer<typeof formSchema>
type FilterSchema = zod.infer<typeof filterSchema>
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
const filterForm = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
time_start: undefined,
time_end: undefined,
},
})
const handler = form.handleSubmit(
async (value) => {
const today = new Date()
const sevenDaysAgo = subDays(today, 7)
const res = {
resource_no: value.resource_no ?? '',
create_after: value.create_after ?? sevenDaysAgo,
create_before: value.create_before ?? today,
}
const handler = filterForm.handleSubmit(async () => {
const {time_start, time_end} = filterForm.getValues()
const resp = await statisticsResourceUsage(res)
if (!resp.success) {
toast.error('接口请求失败:' + resp.message)
return
}
if (!resp.data || resp.data.length === 0) {
toast.info('没有查询到相关数据')
setSubmittedData([])
return
}
const params = {
time_start: time_start,
time_end: time_end ? addDays(time_end, 1) : undefined,
}
const resp = await statisticsResourceUsage(params)
if (!resp.success) {
toast.error('接口请求失败:' + resp.message)
return
}
if (!resp.data || resp.data.length === 0) {
toast.info('没有查询到相关数据')
setSubmittedData([])
return
}
if (resp.success && resp.data) {
const formattedData = resp.data.map(item => ({
...item,
date: item.date,
@@ -66,67 +62,60 @@ export default function Charts({initialData}: ChartsProps) {
}))
formattedData.sort((a, b) => compareAsc(a.date, b.date))
setSubmittedData(formattedData)
},
)
}
else {
throw new Error('获取数据失败')
}
})
return (
<Card className="h-full">
<CardContent className="overflow-hidden">
<Tabs defaultValue="dynamic" className="h-full gap-4">
<TabsList className="h-9">
<TabsTrigger value="dynamic" className="data-[state=active]:text-primary">
</TabsTrigger>
<TabsTrigger value="static" className="data-[state=active]:text-primary">
</TabsTrigger>
</TabsList>
<Form<FormValues> className={merge(`flex items-end gap-4 flex-wrap`)} handler={handler} form={form} >
<FormField name="resource_no" label={<span className="text-sm"></span>}>
{({field}) => (
<Input {...field} className="h-9"/>
)}
</FormField>
<div className="flex flex-col gap-2">
<Label className="text-sm"></Label>
<div className="flex items-center">
<FormField name="create_after">
{({field}) => (
<DatePicker
{...field}
className="w-36"
placeholder="开始时间"
format="yyyy-MM-dd"
/>
)}
</FormField>
<span className="px-1">-</span>
<FormField name="create_before">
{({field}) => (
<DatePicker
{...field}
className="w-36"
placeholder="结束时间"
format="yyyy-MM-dd"
/>
)}
</FormField>
</div>
<CardContent className="overflow-hidden flex flex-col">
<CardHeader className="flex-none">
<CardTitle>
<Image src={mask} alt="Mask group" priority/>
</CardTitle>
</CardHeader>
<Form form={filterForm} handler={handler} className="flex-none flex flex-wrap justify-end mb-4 gap-4">
<fieldset className="flex flex-col gap-2 items-start">
<div className="flex gap-1 items-center">
<FormField<FilterSchema, 'time_start'> name="time_start">
{({field}) => (
<DatePicker
placeholder="选择开始时间"
{...field}
format="yyyy-MM-dd"
/>
)}
</FormField>
<span>-</span>
<FormField<FilterSchema, 'time_end'> name="time_end">
{({field}) => (
<DatePicker
placeholder="选择结束时间"
{...field}
format="yyyy-MM-dd"
/>
)}
</FormField>
</div>
<div className="flex gap-4">
<Button className="h-9" type="submit">
<span></span>
</Button>
</div>
</Form>
<TabsContent value="dynamic" className="overflow-hidden">
{submittedData && <DashboardChart data={submittedData}/>}
</TabsContent>
<TabsContent value="static" className="flex flex-col items-center justify-center gap-2">
<Image alt="coming soon" src={soon}/>
<p></p>
</TabsContent>
</Tabs>
</fieldset>
<Button className="h-9" type="submit"></Button>
<Button
theme="outline"
className="h-9"
onClick={() => {
filterForm.reset({
time_start: undefined,
time_end: undefined,
})
handler()
}}>
</Button>
</Form>
{submittedData && <DashboardChart data={submittedData}/>}
</CardContent>
</Card>
)
@@ -157,7 +146,7 @@ function DashboardChart(props: DashboardChartProps) {
}
})
return (
<ChartContainer config={config} className="w-full h-full">
<ChartContainer config={config} className="w-full flex-auto overflow-hidden">
<AreaChart data={chartData} margin={{top: 0, right: 20, left: 0, bottom: 0}}>
<CartesianGrid vertical={false}/>
<XAxis

View File

@@ -0,0 +1,22 @@
import {Badge} from '@/components/ui/badge'
import {Channel} from '@/lib/models'
import {isBefore} from 'date-fns'
export default function Addr({channel}: {
channel: Channel
}) {
const ip = channel.host
const port = channel.port
const expired = isBefore(channel.expired_at, new Date())
return (
<div className={`${expired ? 'text-weak' : ''}`}>
<span>{ip}:{port}</span>
{expired && (
<Badge variant="secondary">
</Badge>
)}
</div>
)
}

View File

@@ -49,7 +49,6 @@ export default function BillsPage(props: BillsPageProps) {
})
const onSubmit = async (value: FilterSchema) => {
console.log(value)
await refresh(1, data.size)
}
@@ -65,7 +64,6 @@ export default function BillsPage(props: BillsPageProps) {
const res = await listBills({
page, size, type, create_after, create_before, trade_id,
})
console.log(res, 'res')
if (res.success) {
setData(res.data)

View File

@@ -1,6 +1,6 @@
import {ReactNode} from 'react'
import {Metadata} from 'next'
import {Suspense} from 'react'
export async function generateMetadata(): Promise<Metadata> {
return {
title: 'IP管理 - 蓝狐代理',

View File

@@ -1,5 +1,5 @@
'use client'
import {useEffect, useState} from 'react'
import {Suspense, useCallback, useEffect, useState} from 'react'
import {useStatus} from '@/lib/states'
import {PageRecord} from '@/lib/api'
import {Channel} from '@/lib/models'
@@ -17,6 +17,7 @@ import {Button} from '@/components/ui/button'
import {EraserIcon, SearchIcon} from 'lucide-react'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Badge} from '@/components/ui/badge'
import Addr from '../_components/addr'
export type ChannelsPageProps = {}
export default function ChannelsPage(props: ChannelsPageProps) {
@@ -31,68 +32,9 @@ export default function ChannelsPage(props: ChannelsPageProps) {
total: 0,
list: [],
})
// 检查是否过期
const isExpired = (expiredAt: string | Date) => {
const date = typeof expiredAt === 'string' ? new Date(expiredAt) : expiredAt
return isBefore(date, new Date())
}
const refresh = async (page: number, size: number) => {
try {
setStatus('load')
// 筛选条件
const filter = filterForm.getValues()
const auth_type = filter.auth_type ? parseInt(filter.auth_type) : undefined
const expired_status = filter.expired_status
// 请求数据
console.log({
page, size, ...filter, auth_type,
})
const resp = await listChannels({
page, size, ...filter, auth_type,
})
console.log(resp, 'ip管理的respresprespresp')
if (!resp.success) {
throw new Error(resp.message)
}
let filteredList = resp.data.list
if (expired_status !== undefined && expired_status !== 'all') {
filteredList = resp.data.list.filter((channel) => {
const expired = isExpired(channel.expired_at)
return !expired
})
resp.data.total = filteredList.length
}
// 更新数据
setData({
...resp.data,
list: filteredList,
})
setStatus('done')
}
catch (e) {
setStatus('fail')
console.error(e)
toast.error('获取提取结果失败', {
description: (e as Error).message,
})
}
}
useEffect(() => {
refresh(data.page, data.size).then()
}, [])
// ======================
// filter
// ======================
const filterSchema = z.object({
auth_type: z.enum(['0', '1', '2']),
expired_status: z.enum(['all', 'active']).default('all'),
@@ -100,7 +42,6 @@ export default function ChannelsPage(props: ChannelsPageProps) {
expire_before: z.date().optional(),
})
type FilterSchema = z.infer<typeof filterSchema>
const filterForm = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
@@ -110,6 +51,41 @@ export default function ChannelsPage(props: ChannelsPageProps) {
expire_before: undefined,
},
})
const refresh = useCallback(async (page: number, size: number) => {
try {
setStatus('load')
// 筛选条件
const filter = filterForm.getValues()
const auth_type = filter.auth_type ? parseInt(filter.auth_type) : undefined
// 请求数据
const resp = await listChannels({
page, size, ...filter, auth_type,
})
if (!resp.success) {
throw new Error(resp.message)
}
// 更新数据
setData(resp.data)
setStatus('done')
}
catch (e) {
setStatus('fail')
console.error(e)
toast.error('获取提取结果失败', {
description: (e as Error).message,
})
}
}, [setStatus, filterForm])
useEffect(() => {
refresh(data.page, data.size).then()
}, [data.page, data.size, refresh])
const filterHandler = filterForm.handleSubmit(async (value) => {
await refresh(1, data.size)
})
@@ -186,81 +162,67 @@ export default function ChannelsPage(props: ChannelsPageProps) {
</Form>
</section>
<DataTable
status={status}
data={data.list}
pagination={{
page: data.page,
size: data.size,
total: data.total,
onPageChange: page => refresh(page, data.size),
onSizeChange: size => refresh(1, size),
}}
columns={[
{
header: '代理地址',
cell: ({row}) => {
const channel = row.original
const ip = channel.host
const port = channel.port
const expired = isExpired(channel.expired_at)
return (
<div className={`${expired ? 'text-weak' : ''}`}>
<span>{ip}:{port}</span>
{expired && (
<Badge variant="secondary">
</Badge>
)}
</div>
)
<Suspense>
<DataTable
status={status}
data={data.list}
pagination={{
page: data.page,
size: data.size,
total: data.total,
onPageChange: page => refresh(page, data.size),
onSizeChange: size => refresh(1, size),
}}
columns={[
{
header: '代理地址',
cell: ({row}) => <Addr channel={row.original}/>,
},
},
{
header: '认证方式',
cell: ({row}) => {
const channel = row.original
const hasWhitelist = channel.whitelists && channel.whitelists.trim() !== ''
const hasAuth = channel.username && channel.password
{
header: '认证方式',
cell: ({row}) => {
const channel = row.original
const hasWhitelist = channel.whitelists && channel.whitelists.trim() !== ''
const hasAuth = channel.username && channel.password
return (
<div className="flex flex-col gap-1 min-w-0">
{hasWhitelist ? (
<div className="flex flex-col">
<span ></span>
<div className="flex flex-wrap gap-1 max-w-[200px]">
{channel.whitelists.split(',').map((ip, index) => (
<Badge key={index} variant="secondary">
{ip.trim()}
</Badge >
))}
return (
<div className="flex flex-col gap-1 min-w-0">
{hasWhitelist ? (
<div className="flex flex-col">
<span ></span>
<div className="flex flex-wrap gap-1 max-w-[200px]">
{channel.whitelists.split(',').map((ip, index) => (
<Badge key={index} variant="secondary">
{ip.trim()}
</Badge >
))}
</div>
</div>
</div>
) : hasAuth ? (
<div className="flex flex-col">
<span></span>
<Badge variant="secondary">
{channel.username}:{channel.password}
</Badge >
</div>
) : (
<span className="text-sm text-gray-400"></span>
)}
</div>
)
) : hasAuth ? (
<div className="flex flex-col">
<span></span>
<Badge variant="secondary">
{channel.username}:{channel.password}
</Badge >
</div>
) : (
<span className="text-sm text-gray-400"></span>
)}
</div>
)
},
},
},
{
header: '提取时间',
cell: ({row}) => format(row.original.created_at, 'yyyy-MM-dd HH:mm'),
},
{
header: '过期时间',
cell: ({row}) => format(row.original.expired_at, 'yyyy-MM-dd HH:mm:ss'),
},
]}
/>
{
header: '提取时间',
cell: ({row}) => format(row.original.created_at, 'yyyy-MM-dd HH:mm'),
},
{
header: '过期时间',
cell: ({row}) => format(row.original.expired_at, 'yyyy-MM-dd HH:mm:ss'),
},
]}
/>
</Suspense>
</Page>
)
}

View File

@@ -180,7 +180,7 @@ export function Navbar() {
<NavTitle label="资源管理"/>
<NavItem href="/admin/resources" icon={<Package size={20}/>} label="我的套餐" expand={navbar}/>
<NavItem href="/admin/channels" icon={<Eye size={20}/>} label="IP 管理" expand={navbar}/>
<NavItem href="/admin" icon={<Archive size={20}/>} label="提取记录" expand={navbar}/>
<NavItem href="/admin/record" icon={<Archive size={20}/>} label="提取记录" expand={navbar}/>
{/* <NavTitle label="数据统计"/>
<NavItem href="/admin" icon={<ArchiveRestore size={20}/>} label="使用记录" expand={navbar}/> */}
</TooltipProvider>

View File

@@ -14,6 +14,7 @@ import z from 'zod'
import * as qrcode from 'qrcode'
import {Card, CardHeader, CardTitle, CardContent} from '@/components/ui/card'
import {QrCodeIcon} from 'lucide-react'
import Image from 'next/image'
export function Aftersale(props: {
profile: User
@@ -59,7 +60,7 @@ export function Aftersale(props: {
<div className="flex flex-col gap-4 items-center">
<p></p>
<div>
<canvas ref={canvasRef} width="180" height="180" className="mx-auto bg-muted"/>
<Image src="/img/qrcode.jpg" alt="logo" width={80} height={80} unoptimized className="flex-none size-20 sm:size-44 bg-gray-100"/>
</div>
<p className="text-xs text-weak">

View File

@@ -0,0 +1,16 @@
import {ReactNode} from 'react'
import {Metadata} from 'next'
import {Suspense} from 'react'
export async function generateMetadata(): Promise<Metadata> {
return {
title: '提取记录 - 蓝狐代理',
}
}
export type RecordLayoutProps = {
children: ReactNode
}
export default async function RecordLayout(props: RecordLayoutProps) {
return <Suspense>{props.children}</Suspense>
}

View File

@@ -0,0 +1,188 @@
'use client'
import {useCallback, useEffect, useState} from 'react'
import {useStatus} from '@/lib/states'
import {PageRecord} from '@/lib/api'
import Page from '@/components/page'
import DataTable from '@/components/data-table'
import {toast} from 'sonner'
import {Batch} from '@/lib/models/batch'
import {format} from 'date-fns'
import {Form, FormField} from '@/components/ui/form'
import {z} from 'zod'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button'
import {EraserIcon, SearchIcon} from 'lucide-react'
import {pageBatch} from '@/actions/batch'
export type RecordPageProps = {}
export default function RecordPage(props: RecordPageProps) {
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<Batch>>({
page: 1,
size: 10,
total: 0,
list: [],
})
// ======================
// filter
// ======================
const filterSchema = z.object({
time_start: z.date().optional(),
time_end: z.date().optional(),
})
type FilterSchema = z.infer<typeof filterSchema>
const filterForm = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
time_start: undefined,
time_end: undefined,
},
})
const fetchRecords = useCallback(async (page: number, size: number) => {
try {
setStatus('load')
// 获取筛选条件
const filter = filterForm.getValues()
const result = await pageBatch({
page,
size,
...filter,
})
if (result.success && result.data) {
setData(result.data)
}
else {
throw new Error('获取数据失败')
}
setStatus('done')
}
catch (error) {
setStatus('fail')
console.error(error)
toast.error('获取提取结果失败', {
description: (error as Error).message,
})
}
}, [filterForm, setStatus])
const filterHandler = filterForm.handleSubmit(async () => {
// 重置到第一页进行筛选
await fetchRecords(1, data.size)
})
useEffect(() => {
fetchRecords(data.page, data.size).then()
}, [data.page, data.size, fetchRecords])
return (
<Page>
{/* 筛选表单 */}
<section className="flex justify-between">
<div></div>
<Form form={filterForm} handler={filterHandler} className="flex-auto flex flex-wrap gap-4 items-end">
<fieldset className="flex flex-col gap-2 items-start">
<div>
<legend className="block text-sm"></legend>
</div>
<div className="flex gap-1 items-center">
<FormField<FilterSchema, 'time_start'> name="time_start">
{({field}) => (
<DatePicker
placeholder="选择开始时间"
{...field}
format="yyyy-MM-dd"
/>
)}
</FormField>
<span>-</span>
<FormField<FilterSchema, 'time_end'> name="time_end">
{({field}) => (
<DatePicker
placeholder="选择结束时间"
{...field}
format="yyyy-MM-dd"
/>
)}
</FormField>
</div>
</fieldset>
<Button className="h-9" type="submit">
<SearchIcon/>
</Button>
<Button
theme="outline"
className="h-9"
onClick={() => {
filterForm.reset()
fetchRecords(1, data.size)
}}>
<EraserIcon/>
</Button>
</Form>
</section>
<DataTable
status={status}
data={data.list}
pagination={{
page: data.page,
size: data.size,
total: data.total,
onPageChange: page => fetchRecords(page, data.size),
onSizeChange: size => fetchRecords(1, size),
}}
columns={[
{
header: '批次号',
cell: ({row}) => <div>{row.original.batch_no}</div>,
accessorKey: 'batch_no',
},
{
header: 'IP地址',
cell: ({row}) => <div>{row.original.ip}</div>,
accessorKey: 'ip',
},
{
header: '运营商',
cell: ({row}) => <div>{row.original.isp}</div>,
accessorKey: 'isp',
},
{
header: '地区',
cell: ({row}) => <div>{row.original.prov}</div>,
accessorKey: 'prov',
},
{
header: '提取数量',
cell: ({row}) => <div>{row.original.count}</div>,
accessorKey: 'count',
},
{
header: '资源数量',
cell: ({row}) => <div>{row.original.resource_id}</div>,
accessorKey: 'resource_id',
},
{
header: '提取时间',
cell: ({row}) => {
return <div>{format(new Date(row.original.time), 'yyyy-MM-dd HH:mm:ss')}</div>
},
accessorKey: 'time',
},
]}
/>
</Page>
)
}

View File

@@ -55,7 +55,6 @@ export default function WhitelistPage(props: WhitelistPageProps) {
setWait(true)
try {
const resp = await listWhitelist({page, size})
console.log(resp, '白名单resp')
if (!resp.success) {
throw new Error(resp.message)

View File

@@ -337,10 +337,8 @@ function SelectResource() {
setStatus('load')
try {
const resp = await allResource()
console.log(resp, '套餐管理resprespresp')
if (!resp.success) {
console.log(11111)
throw new Error('获取套餐失败,请稍后再试')
}
setResources(resp.data ?? [])
@@ -543,16 +541,12 @@ function ApplyLink() {
const handler = form.handleSubmit(
// eslint-disable-next-line react-hooks/refs
async (values: z.infer<typeof schema>) => {
console.log(values, 'values')
const params = link(values)
console.log(params, 'paramsparams')
switch (type.current) {
case 'copy':
const url = new URL(window.location.href).origin
const text = `${url}${params}`
console.log(text, 'text')
// 使用 clipboard API 复制链接
let copied = false

View File

@@ -39,7 +39,6 @@ export function PaymentModal(props: PaymentModalProps) {
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
)
eventSource.onmessage = async (event) => {
console.log(event, 'eventeventevent')
switch (event.data) {
case '1':
props.onConfirm?.(true)

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

View File

@@ -0,0 +1,244 @@
'use client'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {useForm} from 'react-hook-form'
import {z} from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import Image from 'next/image'
import check from '@/assets/check-accent.svg'
import banner from '../_assets/Mask-group.webp'
import group from '../_assets/Group.webp'
import {merge} from '@/lib/utils'
import FreeTrial from '@/components/free-trial'
const formSchema = z.object({
companyName: z.string().min(2, '企业名称至少2个字符'),
contactName: z.string().min(2, '联系人姓名至少2个字符'),
phone: z.string().min(11, '请输入11位手机号码').max(11, '手机号码长度不正确'),
monthlyUsage: z.string().min(1, '请选择您需要的用量'),
purpose: z.string().min(1, '输入用途'),
})
type FormValues = z.infer<typeof formSchema>
export default function CollectPage() {
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
companyName: '',
contactName: '',
phone: '',
monthlyUsage: '',
purpose: '',
},
})
return (
<>
<div className="bg-white rounded-lg shadow-md overflow-hidden p-6">
<div className="text-center mb-4">
<h1 className="text-2xl font-bold">IP服务商</h1>
<p className="text-gray-600 font-medium mt-2">
IP代理使用体验
</p>
</div>
<div className="flex flex-col md:flex-row md:gap-4">
<div className="w-full md:w-1/3 mb-6 md:mb-0">
<div className="relative h-full w-full min-h-[200px] md:min-h-[300px] rounded-xl overflow-hidden">
<Image
src={banner}
alt="宣传图"
fill
className="object-cover"
priority
sizes="(max-width: 768px) 100vw, 33vw"
/>
</div>
</div>
<div className="w-full md:w-2/3 flex flex-col gap-4">
<p className="text-sm md:text-base text-gray-600 leading-relaxed">
IP领域访IP资源IP稳定使7×24
</p>
<div className="mt-2 md:mt-4">
<Button className="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white px-4 md:px-6 py-2 md:py-3 rounded-md">
</Button>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mt-2 md:mt-6">
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span>IP时效3-30()</span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
<span></span>
</div>
</div>
</div>
</div>
</div>
<div className="text-center">
<h2 className="text-2xl font-semibold mb-6 mt-6"></h2>
</div>
<div className="bg-white rounded-lg shadow-md p-6">
<Form form={form}>
<div className="mx-auto max-w-xl space-y-6">
{/* 企业名称 */}
<FormField name="companyName">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入企业名称"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 联系人姓名 */}
<FormField name="contactName">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入联系人姓名"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 联系人手机号码 */}
<FormField name="phone">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入手机号码"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
{/* 每月需求用量 */}
<FormField name="monthlyUsage">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger
id={id}
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs">
<SelectValue placeholder="请选择您需要的用量"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="less20">20</SelectItem>
<SelectItem value="20-100">20~100</SelectItem>
<SelectItem value="100-500">100~500</SelectItem>
<SelectItem value="more500">500</SelectItem>
</SelectContent>
</Select>
</div>
)}
</FormField>
{/* 用途 */}
<FormField name="purpose">
{({id, field}) => (
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
<label
htmlFor={id}
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
<span className="text-red-500">*</span>
<span></span>
</label>
<Input
{...field}
id={id}
placeholder="请输入用途,例如:爬虫"
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
</div>
)}
</FormField>
<div className="pt-4 flex justify-center">
<Button type="submit" className="bg-blue-600 hover:bg-blue-700 px-8">
</Button>
</div>
</div>
</Form>
</div>
<div className="relative mt-8 rounded-lg overflow-hidden">
<div className="h-40 md:h-48 relative">
<div
className="absolute inset-0 bg-no-repeat"
style={{
backgroundImage: `url(${group.src})`,
backgroundPosition: 'center',
backgroundSize: 'cover',
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-full max-w-4xl px-6 flex flex-col md:flex-row items-center gap-4 justify-between md:gap-10">
<div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left">
5000IP
</div>
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap')}/>
</div>
</div>
</div>
</div>
</>
)
}

View File

@@ -10,11 +10,19 @@ import check from '../_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {useEffect} from 'react'
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => {
if (type === '1') {
form.setValue('daily_limit', 100)
}
else {
form.setValue('quota', 500)
}
}, [type, form])
return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
@@ -97,6 +105,12 @@ export default function Center() {
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={minValue}
step={step}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 500) {
form.setValue('quota', 500)
}
}}
/>
<Button
theme="outline"
@@ -156,9 +170,20 @@ export default function Center() {
<Minus/>
</Button>
<div className="w-40 h-10 border border-gray-200 rounded-sm flex items-center justify-center">
{value}
</div>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={100}
step={100}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 100) {
form.setValue('daily_limit', 100)
}
}}
/>
<Button
theme="outline"

View File

@@ -1,5 +1,5 @@
'use client'
import {Suspense, use, useContext, useMemo} from 'react'
import {Suspense, use, useContext, useEffect, useMemo, useState} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
@@ -17,6 +17,8 @@ import {merge} from '@/lib/utils'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/long/form'
import {Card} from '@/components/ui/card'
import {getPrice, CreateResourceReq} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
export default function Right() {
const {control} = useFormContext<Schema>()
@@ -26,22 +28,47 @@ export default function Right() {
const quota = useWatch({control, name: 'quota'})
const expire = useWatch({control, name: 'expire'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
const price = useMemo(() => {
const base = {
1: 30,
4: 80,
8: 120,
12: 180,
24: 350,
}[live]
const factor = {
1: Number(expire) * dailyLimit,
2: quota,
}[mode]
return (base * factor / 100).toFixed(2)
useEffect(() => {
const price = async () => {
try {
const resp = await getPrice({
type: 2,
long: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!resp.success) {
throw new Error('获取价格失败')
}
setPriceData({
price: resp.data.price,
discounted_price: resp.data.discounted_price ?? resp.data.price ?? '',
discounted: resp.data.discounted,
})
}
catch (error) {
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [dailyLimit, expire, live, quota, mode])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
@@ -63,13 +90,21 @@ export default function Right() {
</span>
</li>
{mode === '2' ? (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
@@ -86,19 +121,32 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span></span>
<span className="text-xl text-orange-500">
{price}
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, price, mode, live, quota, expire, dailyLimit}}/>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
@@ -106,7 +154,7 @@ export default function Right() {
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
price: string
discountedPrice: string
mode: string
live: string
quota: number
@@ -165,7 +213,7 @@ function BalanceOrLogin(props: {
<Pay
method={props.method}
balance={profile.balance}
amount={props.price}
amount={props.discountedPrice}
resource={{
type: 2,
long: {

View File

@@ -10,11 +10,19 @@ import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card'
import {useEffect} from 'react'
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => {
if (type === '1') {
form.setValue('daily_limit', 2000)
}
else {
form.setValue('quota', 10000)
}
}, [type, form])
return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
@@ -92,7 +100,14 @@ export default function Center() {
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={10000}
step={5000}/>
step={5000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 10000) {
form.setValue('quota', 10000)
}
}}
/>
<Button
theme="outline"
type="button"
@@ -149,6 +164,12 @@ export default function Center() {
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={2_000}
step={1_000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 2_000) {
form.setValue('daily_limit', 2_000)
}
}}
/>
<Button
theme="outline"

View File

@@ -1,5 +1,5 @@
'use client'
import {Suspense, use, useMemo} from 'react'
import {Suspense, use, useEffect, useMemo, useState} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
@@ -16,24 +16,59 @@ import {merge} from '@/lib/utils'
import Pay from '@/components/composites/purchase/pay'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {CreateResourceReq, getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
export default function Right() {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const live = useWatch({control, name: 'live'})
const mode = useWatch({control, name: 'type'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const expire = useWatch({control, name: 'expire'})
const quota = useWatch({control, name: 'quota'})
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
const price = useMemo(() => {
const base = live === '180' ? 150 : Number(live)
const factor = {
1: Number(expire) * dailyLimit,
2: quota,
}[mode]
return (base * factor / 30000).toFixed(2)
}, [dailyLimit, expire, live, quota, mode])
useEffect(() => {
const price = async () => {
try {
const priceResponse = await getPrice({
type: 1,
short: {
live: Number(live),
mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!priceResponse.success) {
throw new Error('获取价格失败')
}
const data = priceResponse.data
setPriceData({
price: data.price,
discounted_price: data.discounted_price ?? data.price ?? '',
discounted: data.discounted,
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [expire, live, quota, mode, dailyLimit])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
return (
<Card className={merge(
@@ -56,13 +91,21 @@ export default function Right() {
</span>
</li>
{mode === '2' ? (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">
{quota}
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
</>
) : (
<>
<li className="flex justify-between items-center">
@@ -79,19 +122,32 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{discounted === 1 ? '' : discounted}
</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span></span>
<span className="text-xl text-orange-500">
{price}
{discountedPrice}
</span>
</p>
<Suspense>
<BalanceOrLogin {...{method, price, mode, live, quota, expire, dailyLimit}}/>
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
</Suspense>
</Card>
)
@@ -99,7 +155,7 @@ export default function Right() {
function BalanceOrLogin(props: {
method: 'wechat' | 'alipay' | 'balance'
price: string
discountedPrice: string
mode: string
live: string
quota: number
@@ -158,7 +214,7 @@ function BalanceOrLogin(props: {
<Pay
method={props.method}
balance={profile.balance}
amount={props.price}
amount={props.discountedPrice}
resource={{
type: 1,
short: {

View File

@@ -6,6 +6,7 @@ import {DayPicker} from 'react-day-picker'
import {merge} from '@/lib/utils'
import {buttonVariants} from '@/components/ui/button'
import {zhCN} from 'date-fns/locale'
function Calendar({
className,
@@ -15,6 +16,7 @@ function Calendar({
}: React.ComponentProps<typeof DayPicker>) {
return (
<DayPicker
locale={zhCN}
showOutsideDays={showOutsideDays}
className={merge('p-3', className)}
classNames={{

11
src/lib/models/batch.ts Normal file
View File

@@ -0,0 +1,11 @@
export type Batch = {
batch_no: string
ip: string
id: number
count: number
isp: string
resource_id: number
time: string
user_id: number
prov: string
}