产品和提取页面
This commit is contained in:
@@ -1,227 +0,0 @@
|
||||
import {ReactNode} from 'react'
|
||||
import Link from 'next/link'
|
||||
import Header from '@/app/(home)/header'
|
||||
import Wrap from '@/components/wrap'
|
||||
import Image from 'next/image'
|
||||
import Footer from './footer'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className={`overflow-auto flex flex-col items-stretch relative`}>
|
||||
{/* 页头 */}
|
||||
<Header/>
|
||||
|
||||
{/* 正文 */}
|
||||
<main className={`flex flex-col gap-16 lg:gap-32 mb-16 lg:mb-32`}>
|
||||
|
||||
{/* banner */}
|
||||
<section className={`w-full bg-[url('/banner.webp')] bg-cover bg-[center_right_40%]`}>
|
||||
<Wrap className={`pt-64 pb-48 max-md:pt-32 max-md:pb-24`}>
|
||||
<h1 className={`text-4xl font-medium`}>安全,稳定,快速,合规的代理服务器</h1>
|
||||
<p className={`mt-10 text-gray-500`}>遍布全国的代理服务器节点为用户提供智能可靠的IP代理服务</p>
|
||||
|
||||
<div className={`mt-24 max-md:mt-14 flex gap-8 max-md:flex-col`}>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>全国200+城市节点</span>
|
||||
</p>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>300+城市级精准定位</span>
|
||||
</p>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>低延迟&高并发提取</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={[
|
||||
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`,
|
||||
`bg-gradient-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl font-medium`,
|
||||
].join(' ')}>
|
||||
免费试用
|
||||
</button>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
{/* 数据展示 */}
|
||||
<Section title={`覆盖全国的IP资源及超大的带宽线路`}>
|
||||
<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 lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>全国城市线路数量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>350+</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>每日更新IP数量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>1,350,129</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>用户量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>26,578</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center`}>
|
||||
<p className={`text-xl`}>IP可用率</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>99%</p>
|
||||
</li>
|
||||
</ul>
|
||||
<img src={`/map.webp`} alt={`map`} className="w-[1200px]"/>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* 优势 1 */}
|
||||
<Section title={`HTTP安全合规的代理IP资源池`}>
|
||||
<ul
|
||||
className={[
|
||||
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8`,
|
||||
].join(' ')}>
|
||||
<Sec3Item
|
||||
icon={`s1-1`} title={`短期动态IP池`} terms={[
|
||||
{icon: `s1-check`, text: `IP时效3-30分钟(可定制)`},
|
||||
{icon: `s1-check`, text: `支持高并发提取`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-2`} title={`长期静态IP池`} terms={[
|
||||
{icon: `s1-check`, text: `IP覆盖全国各地`},
|
||||
{icon: `s1-check`, text: `平均响应时长:0.03s`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-3`} title={`固定IP池`} terms={[
|
||||
{icon: `s1-check`, text: `稳定长输不掉线`},
|
||||
{icon: `s1-check`, text: `全国热门静态IP线路`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-4`} title={`企业级定制池`} terms={[
|
||||
{icon: `s1-check`, text: `可视化监控设计`},
|
||||
{icon: `s1-check`, text: `技术团队现场支持`},
|
||||
]}/>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{/* 优势 2 */}
|
||||
<Section title={`HTTP 产品优势`}>
|
||||
<div className={`flex gap-36`}>
|
||||
<ul className={`flex-1 flex flex-col gap-6`}>
|
||||
<Sec4Item icon={`s4-1-1`} title={`安全合规`} description={`国内三大运营商支持`}/>
|
||||
<Sec4Item icon={`s4-1-2`} title={`稳定链接`} description={`IP纯净度高达99.9%`}/>
|
||||
<Sec4Item icon={`s4-1-3`} title={`超匿名性`} description={`稳定传输,保护隐私安全`}/>
|
||||
</ul>
|
||||
<img src={`/s4-1-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
|
||||
</div>
|
||||
|
||||
<div className={`flex gap-36`}>
|
||||
<img src={`/s4-2-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
|
||||
<ul className={`flex-1 flex flex-col gap-6`}>
|
||||
<Sec4Item icon={`s4-2-1`} title={`API接口文档`} description={`与第三方软件轻松集成`}/>
|
||||
<Sec4Item icon={`s4-2-2`} title={`多种编程语言代码`} description={`C语言、GO语言、Python...`}/>
|
||||
<Sec4Item icon={`s4-2-3`} title={`双重认证方式`} description={`API提取+账密认证`}/>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 行业资讯 */}
|
||||
<Section title={`行业资讯`}>
|
||||
<div className={`flex gap-8 max-md:gap-4`}>
|
||||
<button className={`px-4 max-md:-mx-4`}>
|
||||
<img src={`/next.svg`} alt={`prev`} className={`rotate-180`}/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={[
|
||||
`shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`,
|
||||
`flex p-14 md:gap-14 max-md:flex-col max-md:p-4`,
|
||||
].join(' ')}>
|
||||
<img src="/s3-main.webp" alt="tumb" className={`w-2/3 md:flex-1 md:w-0 object-cover max-md:self-center`}/>
|
||||
<div className={`flex-2 flex flex-col justify-between gap-4`}>
|
||||
<h3 className={`flex justify-between`}>
|
||||
<span className={`text-xl font-medium`}>我是标题</span>
|
||||
<sub className={`text-sm text-gray-500`}>2025-03-04</sub>
|
||||
</h3>
|
||||
<p className={`text-gray-400 md:leading-12`}>
|
||||
我是内容我是内容我是内容我是内容我是内容我是容我是内容我是内容内容我是内容我是内容我是内我是内容我是内容我是内容我是内容我是内容...
|
||||
</p>
|
||||
<div className={`flex justify-end`}>
|
||||
<a href="#" className={`text-sm text-gray-500 flex items-center gap-4`}>
|
||||
更多详情
|
||||
<img src={`/next.svg`} alt={`more`} className={`h-4 fill-gray-400`}/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={`px-4 max-md:-mx-4`}>
|
||||
<img src={`/next.svg`} alt={`prev`}/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Section(props: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className={`max-w-[1232px] mx-auto px-4 flex flex-col items-stretch`}>
|
||||
<h2 className={`text-center text-3xl font-medium mb-8 lg:mb-24`}>{props.title}</h2>
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Sec3Item(props: {
|
||||
icon: string,
|
||||
title: string,
|
||||
terms: {
|
||||
icon: string,
|
||||
text: string,
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={[
|
||||
`p-8 flex flex-col gap-5 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white rounded-lg`,
|
||||
`max-md:items-center`,
|
||||
].join(' ')}>
|
||||
<img src={`/${props.icon}.webp`} alt={`s1-1`} aria-hidden className="w-44 h-44 object-cover"/>
|
||||
<h3 className={`text-xl font-medium`}>{props.title}</h3>
|
||||
<div className={`flex flex-col gap-3`}>
|
||||
{props.terms.map((item, index) => {
|
||||
return (
|
||||
<p key={index} className={`text-sm text-gray-500 flex gap-3 items-center`}>
|
||||
<img src={`/${item.icon}.svg`} alt={`check`} aria-hidden className={`w-5 h-5`}/>
|
||||
<span>{item.text}</span>
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Sec4Item(props: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<li className={`flex gap-8 items-center p-4 lg:p-8 shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`}>
|
||||
<img src={`/${props.icon}.webp`} alt={`s2-1-1`} aria-hidden className="w-24 h-24 object-contain"/>
|
||||
<div className={`flex flex-col gap-3`}>
|
||||
<h3 className={`text-xl font-medium`}>{props.title}</h3>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
311
src/app/(root)/collect/form-section.tsx
Normal file
311
src/app/(root)/collect/form-section.tsx
Normal file
@@ -0,0 +1,311 @@
|
||||
'use client'
|
||||
import { z } from 'zod'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const schema = z.object({
|
||||
type: z.enum([`num`, `time`]),
|
||||
order: z.number(),
|
||||
region: z.string(),
|
||||
provider: z.string(),
|
||||
proto: z.string(),
|
||||
distinct: z.string(),
|
||||
format: z.enum([`txt`, `json`]),
|
||||
separator: z.string(),
|
||||
count: z.number(),
|
||||
})
|
||||
|
||||
type FormSectionProps = {}
|
||||
|
||||
export default function FormSection(props: FormSectionProps) {
|
||||
|
||||
const form = useForm<z.infer<typeof schema>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: `num`,
|
||||
order: 0,
|
||||
region: ``,
|
||||
provider: ``,
|
||||
proto: ``,
|
||||
distinct: ``,
|
||||
format: `txt`,
|
||||
separator: `,`,
|
||||
count: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (values: z.infer<typeof schema>) => {
|
||||
console.log(values)
|
||||
// 在这里处理表单提交
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className={`p-8 bg-white flex flex-col gap-4 rounded-lg`}>
|
||||
<ul role={`tablist`} className={`p-2 w-fit flex gap-2 bg-gray-100 rounded-lg`}>
|
||||
<li role={`tab`}>
|
||||
<button type="button" className={`px-4 h-10 bg-white rounded-md shadow-sm`}>
|
||||
短效动态IP提取
|
||||
</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button type="button" className={`px-4 h-10 rounded-md`}>
|
||||
长效静态IP提取
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p className={`px-4 h-10 bg-orange-50 flex gap-3 items-center rounded-lg`}>
|
||||
<img src={`/collect/warn.svg`} alt={`warn`} aria-hidden className={`w-5 h-5`} />
|
||||
<span className={`text-sm`}>提取IP前需要将本机IP添加到白名单后才可使用</span>
|
||||
</p>
|
||||
|
||||
<div className={`flex flex-col gap-y-4`}>
|
||||
{/* 套餐类型 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">套餐类型</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
|
||||
<RadioGroupItem value="num" id="num" className="mr-2" />
|
||||
<label htmlFor="num">包量套餐</label>
|
||||
</div>
|
||||
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
|
||||
<RadioGroupItem value="time" id="time" className="mr-2" />
|
||||
<label htmlFor="time">包时套餐</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 已购套餐 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">已购套餐</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="order"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={value => field.onChange(Number(value))}
|
||||
value={String(field.value)}
|
||||
>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="选择您的套餐" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">基础IP套餐</SelectItem>
|
||||
<SelectItem value="1">高级IP套餐</SelectItem>
|
||||
<SelectItem value="2">企业IP套餐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 地区筛选 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">地区筛选</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="region"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="选择地区" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cn">中国大陆</SelectItem>
|
||||
<SelectItem value="hk">香港</SelectItem>
|
||||
<SelectItem value="us">美国</SelectItem>
|
||||
<SelectItem value="all">不限</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 运营商筛选 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">运营商筛选</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="选择运营商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="telecom">电信</SelectItem>
|
||||
<SelectItem value="mobile">移动</SelectItem>
|
||||
<SelectItem value="unicom">联通</SelectItem>
|
||||
<SelectItem value="all">不限</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 协议类型 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">协议类型</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="proto"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="选择协议类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
<SelectItem value="https">HTTPS</SelectItem>
|
||||
<SelectItem value="socks5">SOCKS5</SelectItem>
|
||||
<SelectItem value="all">不限</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 去重选项 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">去重选项</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="distinct"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="h-10">
|
||||
<SelectValue placeholder="选择去重方式" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不去重</SelectItem>
|
||||
<SelectItem value="ip">按IP去重</SelectItem>
|
||||
<SelectItem value="domain">按域名去重</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 导出格式 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">导出格式</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="format"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
|
||||
<RadioGroupItem value="txt" id="txt" className="mr-2" />
|
||||
<label htmlFor="txt">TXT格式</label>
|
||||
</div>
|
||||
<div className={`px-4 h-10 border rounded-lg flex items-center`}>
|
||||
<RadioGroupItem value="json" id="json" className="mr-2" />
|
||||
<label htmlFor="json">JSON格式</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 分隔符 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">分隔符</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="separator"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input {...field} className="h-10" placeholder="输入分隔符,默认为逗号" />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 提取数量 */}
|
||||
<div className="flex items-center">
|
||||
<FormLabel className="w-24 flex-shrink-0">提取数量</FormLabel>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="count"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={e => field.onChange(Number(e.target.value))}
|
||||
className="h-10"
|
||||
placeholder="输入提取数量"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Button type="submit" className="w-32 h-10 bg-blue-500 text-white rounded-lg">提取IP</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
19
src/app/(root)/collect/page.tsx
Normal file
19
src/app/(root)/collect/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import BreadCrumb from '@/components/bread-crumb'
|
||||
import Wrap from '@/components/wrap'
|
||||
import FormSection from '@/app/(root)/collect/form-section'
|
||||
|
||||
export type CollectPageProps = {}
|
||||
|
||||
export default function CollectPage(props: CollectPageProps) {
|
||||
return (
|
||||
<main className={`mt-20 flex flex-col gap-4`}>
|
||||
<Wrap className="flex flex-col py-8 gap-8">
|
||||
<BreadCrumb items={[
|
||||
{label: 'IP 提取', href: '/collect'},
|
||||
]}/>
|
||||
<h2 className={`text-3xl text-center`}>提取 IP</h2>
|
||||
<FormSection/>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -134,7 +134,7 @@ export default function Header(props: HeaderProps) {
|
||||
<Wrap className="h-20 max-md:h-16 flex justify-between">
|
||||
<div className="flex justify-between gap-8">
|
||||
{/* logo */}
|
||||
<Link href="/" className={`flex items-center`}>
|
||||
<Link href="/public" className={`flex items-center`}>
|
||||
<img src={`/logo.svg`} alt={`logo`} className={`w-16 max-md:w-12 h-16 max-md:h-12 rounded-full bg-gray-100`}/>
|
||||
</Link>
|
||||
|
||||
22
src/app/(root)/layout.tsx
Normal file
22
src/app/(root)/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Header from '@/app/(root)/header'
|
||||
import Footer from '@/app/(root)/footer'
|
||||
import {ReactNode} from 'react'
|
||||
|
||||
export type RootLayoutProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function RootLayout(props: RootLayoutProps) {
|
||||
return (
|
||||
<div className={`overflow-auto flex flex-col items-stretch relative`}>
|
||||
{/* 页头 */}
|
||||
<Header/>
|
||||
|
||||
{/* 正文 */}
|
||||
{props.children}
|
||||
|
||||
{/* 页脚 */}
|
||||
<Footer/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
215
src/app/(root)/page.tsx
Normal file
215
src/app/(root)/page.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import {ReactNode} from 'react'
|
||||
import Wrap from '@/components/wrap'
|
||||
import Image from 'next/image'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className={`flex flex-col gap-16 lg:gap-32 mb-16 lg:mb-32`}>
|
||||
|
||||
{/* banner */}
|
||||
<section className={`w-full bg-[url('/banner.webp')] bg-cover bg-[center_right_40%]`}>
|
||||
<Wrap className={`pt-64 pb-48 max-md:pt-32 max-md:pb-24`}>
|
||||
<h1 className={`text-4xl font-medium`}>安全,稳定,快速,合规的代理服务器</h1>
|
||||
<p className={`mt-10 text-gray-500`}>遍布全国的代理服务器节点为用户提供智能可靠的IP代理服务</p>
|
||||
|
||||
<div className={`mt-24 max-md:mt-14 flex gap-8 max-md:flex-col`}>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>全国200+城市节点</span>
|
||||
</p>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>300+城市级精准定位</span>
|
||||
</p>
|
||||
<p className={`flex gap-4 items-center`}>
|
||||
<Image src={`/check.svg`} alt={`checkbox`} width={24} height={24}/>
|
||||
<span className={`lg:text-lg font-light`}>低延迟&高并发提取</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={[
|
||||
`mt-32 max-md:mt-20 w-96 max-md:w-full h-16 md:h-24 rounded-lg shadow-lg`,
|
||||
`bg-gradient-to-r from-blue-500 to-cyan-400 text-white text-xl lg:text-4xl font-medium`,
|
||||
].join(' ')}>
|
||||
免费试用
|
||||
</button>
|
||||
</Wrap>
|
||||
</section>
|
||||
|
||||
{/* 数据展示 */}
|
||||
<Section title={`覆盖全国的IP资源及超大的带宽线路`}>
|
||||
<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 lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>全国城市线路数量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>350+</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>每日更新IP数量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>1,350,129</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center lg:border-r max-lg:mb-4 border-gray-200`}>
|
||||
<p className={`text-xl`}>用户量</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>26,578</p>
|
||||
<div className={`lg:hidden w-24 border-b mt-4 border-gray-200`}></div>
|
||||
</li>
|
||||
<li className={`flex-1 flex flex-col items-center justify-center`}>
|
||||
<p className={`text-xl`}>IP可用率</p>
|
||||
<p className={`mt-9 max-lg:mt-2 text-5xl bg-gradient-to-t from-blue-500 to-cyan-400 bg-clip-text text-transparent font-bold pb-2 -mb-2`}>99%</p>
|
||||
</li>
|
||||
</ul>
|
||||
<img src={`/map.webp`} alt={`map`} className="w-[1200px]"/>
|
||||
</Section>
|
||||
|
||||
|
||||
{/* 优势 1 */}
|
||||
<Section title={`HTTP安全合规的代理IP资源池`}>
|
||||
<ul
|
||||
className={[
|
||||
`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8`,
|
||||
].join(' ')}>
|
||||
<Sec3Item
|
||||
icon={`s1-1`} title={`短期动态IP池`} terms={[
|
||||
{icon: `s1-check`, text: `IP时效3-30分钟(可定制)`},
|
||||
{icon: `s1-check`, text: `支持高并发提取`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-2`} title={`长期静态IP池`} terms={[
|
||||
{icon: `s1-check`, text: `IP覆盖全国各地`},
|
||||
{icon: `s1-check`, text: `平均响应时长:0.03s`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-3`} title={`固定IP池`} terms={[
|
||||
{icon: `s1-check`, text: `稳定长输不掉线`},
|
||||
{icon: `s1-check`, text: `全国热门静态IP线路`},
|
||||
]}/>
|
||||
<Sec3Item
|
||||
icon={`s1-4`} title={`企业级定制池`} terms={[
|
||||
{icon: `s1-check`, text: `可视化监控设计`},
|
||||
{icon: `s1-check`, text: `技术团队现场支持`},
|
||||
]}/>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
{/* 优势 2 */}
|
||||
<Section title={`HTTP 产品优势`}>
|
||||
<div className={`flex gap-36`}>
|
||||
<ul className={`flex-1 flex flex-col gap-6`}>
|
||||
<Sec4Item icon={`s4-1-1`} title={`安全合规`} description={`国内三大运营商支持`}/>
|
||||
<Sec4Item icon={`s4-1-2`} title={`稳定链接`} description={`IP纯净度高达99.9%`}/>
|
||||
<Sec4Item icon={`s4-1-3`} title={`超匿名性`} description={`稳定传输,保护隐私安全`}/>
|
||||
</ul>
|
||||
<img src={`/s4-1-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
|
||||
</div>
|
||||
|
||||
<div className={`flex gap-36`}>
|
||||
<img src={`/s4-2-main.webp`} alt={`s2-1-main`} className={`w-0 flex-1 object-contain max-lg:hidden`}/>
|
||||
<ul className={`flex-1 flex flex-col gap-6`}>
|
||||
<Sec4Item icon={`s4-2-1`} title={`API接口文档`} description={`与第三方软件轻松集成`}/>
|
||||
<Sec4Item icon={`s4-2-2`} title={`多种编程语言代码`} description={`C语言、GO语言、Python...`}/>
|
||||
<Sec4Item icon={`s4-2-3`} title={`双重认证方式`} description={`API提取+账密认证`}/>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 行业资讯 */}
|
||||
<Section title={`行业资讯`}>
|
||||
<div className={`flex gap-8 max-md:gap-4`}>
|
||||
<button className={`px-4 max-md:-mx-4`}>
|
||||
<img src={`/next.svg`} alt={`prev`} className={`rotate-180`}/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={[
|
||||
`shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`,
|
||||
`flex p-14 md:gap-14 max-md:flex-col max-md:p-4`,
|
||||
].join(' ')}>
|
||||
<img src="/s3-main.webp" alt="tumb" className={`w-2/3 md:flex-1 md:w-0 object-cover max-md:self-center`}/>
|
||||
<div className={`flex-2 flex flex-col justify-between gap-4`}>
|
||||
<h3 className={`flex justify-between`}>
|
||||
<span className={`text-xl font-medium`}>我是标题</span>
|
||||
<sub className={`text-sm text-gray-500`}>2025-03-04</sub>
|
||||
</h3>
|
||||
<p className={`text-gray-400 md:leading-12`}>
|
||||
我是内容我是内容我是内容我是内容我是内容我是容我是内容我是内容内容我是内容我是内容我是内我是内容我是内容我是内容我是内容我是内容...
|
||||
</p>
|
||||
<div className={`flex justify-end`}>
|
||||
<a href="#" className={`text-sm text-gray-500 flex items-center gap-4`}>
|
||||
更多详情
|
||||
<img src={`/next.svg`} alt={`more`} className={`h-4 fill-gray-400`}/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={`px-4 max-md:-mx-4`}>
|
||||
<img src={`/next.svg`} alt={`prev`}/>
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Section(props: {
|
||||
title: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className={`max-w-[1232px] mx-auto px-4 flex flex-col items-stretch`}>
|
||||
<h2 className={`text-center text-3xl font-medium mb-8 lg:mb-24`}>{props.title}</h2>
|
||||
{props.children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function Sec3Item(props: {
|
||||
icon: string,
|
||||
title: string,
|
||||
terms: {
|
||||
icon: string,
|
||||
text: string,
|
||||
}[]
|
||||
}) {
|
||||
return (
|
||||
<li
|
||||
className={[
|
||||
`p-8 flex flex-col gap-5 shadow-[4px_4px_20px_4px] shadow-blue-50 bg-white rounded-lg`,
|
||||
`max-md:items-center`,
|
||||
].join(' ')}>
|
||||
<img src={`/${props.icon}.webp`} alt={`s1-1`} aria-hidden className="w-44 h-44 object-cover"/>
|
||||
<h3 className={`text-xl font-medium`}>{props.title}</h3>
|
||||
<div className={`flex flex-col gap-3`}>
|
||||
{props.terms.map((item, index) => {
|
||||
return (
|
||||
<p key={index} className={`text-sm text-gray-500 flex gap-3 items-center`}>
|
||||
<img src={`/${item.icon}.svg`} alt={`check`} aria-hidden className={`w-5 h-5`}/>
|
||||
<span>{item.text}</span>
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Sec4Item(props: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<li className={`flex gap-8 items-center p-4 lg:p-8 shadow-[4px_4px_20px_4px] shadow-blue-50 rounded-lg`}>
|
||||
<img src={`/${props.icon}.webp`} alt={`s2-1-1`} aria-hidden className="w-24 h-24 object-contain"/>
|
||||
<div className={`flex flex-col gap-3`}>
|
||||
<h3 className={`text-xl font-medium`}>{props.title}</h3>
|
||||
<p>{props.description}</p>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
43
src/app/(root)/product/combo.tsx
Normal file
43
src/app/(root)/product/combo.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import {useState} from 'react'
|
||||
|
||||
export function Combo(props: {
|
||||
name: string
|
||||
level?: {
|
||||
number: number
|
||||
discount: number
|
||||
}[]
|
||||
}) {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<p className={`flex justify-between items-center`}>
|
||||
<span>{props.name}</span>
|
||||
<button
|
||||
className={`text-gray-500 text-sm`}
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? '收起' : '展开'}
|
||||
</button>
|
||||
</p>
|
||||
{props.level && (
|
||||
<ul className={[
|
||||
`flex flex-col gap-3 overflow-hidden`,
|
||||
`transition-[opacity,padding,max-height] transition-discrete duration-200 ease-in-out`,
|
||||
open
|
||||
? 'delay-[0s, 0s] opacity-100 py-3 max-h-80'
|
||||
: 'delay-[0s, 0.2s] opacity-0 p-0 max-h-0',
|
||||
].join(' ')}>
|
||||
{props.level.map((item, index) => (
|
||||
<li key={index} className={`flex flex-row justify-between items-center`}>
|
||||
<span className={`text-gray-500 text-sm`}>{item.number}</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>赠送 {item.discount} %</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
259
src/app/(root)/product/page.tsx
Normal file
259
src/app/(root)/product/page.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import BreadCrumb from '@/components/bread-crumb'
|
||||
import Wrap from '@/components/wrap'
|
||||
import {Combo} from '@/app/(root)/product/combo'
|
||||
|
||||
|
||||
export type ProductPageProps = {}
|
||||
|
||||
export default function ProductPage(props: ProductPageProps) {
|
||||
return (
|
||||
<main className={`mt-20`}>
|
||||
<Wrap className="flex flex-col py-8 gap-8">
|
||||
<BreadCrumb items={[
|
||||
{label: '产品中心', href: '/product'},
|
||||
]}/>
|
||||
|
||||
<h2 className={`text-3xl text-center`}>多种套餐选择</h2>
|
||||
|
||||
<ul role={`tablist`} className={`flex justify-center items-stretch bg-white rounded-lg`}>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>短效动态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>长效静态套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>固定套餐</button>
|
||||
</li>
|
||||
<li role={`tab`}>
|
||||
<button className={`h-14 px-8 text-lg`}>定制套餐</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<section role={`tabpanel`} className={`flex flex-row bg-white`}>
|
||||
<Left/>
|
||||
<Center/>
|
||||
<Right/>
|
||||
</section>
|
||||
</Wrap>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function Left() {
|
||||
return (
|
||||
<div className="flex-none basis-56 p-8 flex flex-col gap-4">
|
||||
<img src={`/product/banner.webp`} alt={`banner`} className={`w-full`}/>
|
||||
<h3 className={`text-lg`}>包量套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<Combo name={`3分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`5分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`10分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`15分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
<Combo name={`30分钟`} level={[
|
||||
{number: 30000, discount: 10},
|
||||
{number: 80000, discount: 20},
|
||||
{number: 200000, discount: 30},
|
||||
{number: 450000, discount: 40},
|
||||
{number: 1000000, discount: 50},
|
||||
{number: 1600000, discount: 65},
|
||||
]}/>
|
||||
</ul>
|
||||
<div className={`border-b border-gray-200`}></div>
|
||||
<h3 className={`text-lg`}>包时套餐</h3>
|
||||
<ul className={`flex flex-col gap-3`}>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>7天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>9折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>30天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>8折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>90天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>7折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>180天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>6折</span>
|
||||
</li>
|
||||
<li className={`flex justify-between`}>
|
||||
<span className={`text-sm text-gray-500`}>360天</span>
|
||||
<span className={`text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full`}>5折</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Center() {
|
||||
return (
|
||||
<div className={`flex-auto p-8 flex flex-col relative gap-4 `}>
|
||||
|
||||
<h3>计费方式</h3>
|
||||
<div className={`grid grid-cols-2 auto-cols-fr place-items-stretch gap-4`}>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
|
||||
<h4>包量套餐</h4>
|
||||
<p className={`text-sm text-gray-500`}>适用于短期或不定期高提取业务场景</p>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col items-start gap-2 cursor-pointer`}>
|
||||
<h4>包时套餐</h4>
|
||||
<p className={`text-sm text-gray-500`}>适用于每日提取量稳定的业务场景</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className={`mt-2`}>IP 时效</h3>
|
||||
<div className={`grid grid-cols-5 auto-cols-fr place-items-stretch gap-4`}>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
<button className={`p-4 bg-blue-50 border border-blue-500 rounded-lg flex flex-col gap-2 cursor-pointer`}>
|
||||
<span>3 分钟</span>
|
||||
<span className={`text-sm text-gray-500`}>¥0.005/IP</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 赠送 IP 数 */}
|
||||
<h3 className={`mt-2`}>赠送IP总数</h3>
|
||||
<div className={`flex gap-4`}>
|
||||
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>-</button>
|
||||
<input type="number" className={`w-40 h-10 border border-gray-200 rounded-sm`}/>
|
||||
<button className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}>+</button>
|
||||
</div>
|
||||
|
||||
{/* 产品特性 */}
|
||||
<h3 className={`mt-2`}>产品特性</h3>
|
||||
<div className={`grid grid-cols-3 auto-rows-fr gap-y-4`}>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>支持高并发提取</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>指定省份、城市或混播</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>账密+白名单验证</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>完备的API接口</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>IP时效3-30分钟(可定制)</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>IP资源定期筛选</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>完备的API接口</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>包量/包时计费方式</span>
|
||||
</p>
|
||||
<p className={`flex gap-2 items-center`}>
|
||||
<img src={`/product/check.svg`} alt={`check`} aria-hidden className={`w-4 h-4`}/>
|
||||
<span className={`text-sm text-gray-500`}>每日去重量:500万</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 左右的边框 */}
|
||||
<div className={`absolute inset-0 my-8 border-l border-r border-gray-200 pointer-events-none`}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Right() {
|
||||
return (
|
||||
<div className={`flex-none basis-80 p-8 flex flex-col gap-4`}>
|
||||
<h3>订单详情</h3>
|
||||
<ul className={`flex flex-col gap-4`}>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>套餐名称</span>
|
||||
<span className={`text-sm`}>包量套餐</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>套餐时长</span>
|
||||
<span className={`text-sm`}>3分钟</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>购买 IP 量</span>
|
||||
<span className={`text-sm`}>1000个</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>实到 IP 量</span>
|
||||
<span className={`text-sm`}>1000个</span>
|
||||
</li>
|
||||
<li className={`flex justify-between items-center`}>
|
||||
<span className={`text-sm text-gray-500`}>原价</span>
|
||||
<span className={`text-sm`}>¥50</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div className={`border-b border-gray-200`}></div>
|
||||
<p className={`flex justify-between items-center`}>
|
||||
<span>实付价格</span>
|
||||
<span className={`text-xl text-orange-500`}>¥50</span>
|
||||
</p>
|
||||
<div className={`flex gap-4`}>
|
||||
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
|
||||
<img src={`/product/alipay.svg`} alt={`alipay`} className={`w-5 h-5`}/>
|
||||
<span className={`text-sm`}>支付宝</span>
|
||||
</button>
|
||||
<button className={`flex-1 p-3 bg-blue-50 border border-blue-500 rounded-lg flex justify-center items-center gap-2`}>
|
||||
<img src={`/product/wechat.svg`} alt={`wechat`} className={`w-5 h-5`}/>
|
||||
<span className={`text-sm`}>微信</span>
|
||||
</button>
|
||||
</div>
|
||||
<button className={`mt-4 h-12 bg-blue-500 text-white rounded-lg`}>
|
||||
立即支付
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@plugin "tailwindcss-animate";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
body {
|
||||
color: hsl(0, 0%, 20%);
|
||||
color: hsl(0, 0%, 10%);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.13 0.028 261.692);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.13 0.028 261.692);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.13 0.028 261.692);
|
||||
--primary: oklch(0.21 0.034 264.665);
|
||||
--primary-foreground: oklch(0.985 0.002 247.839);
|
||||
--secondary: oklch(0.967 0.003 264.542);
|
||||
--secondary-foreground: oklch(0.21 0.034 264.665);
|
||||
--muted: oklch(0.967 0.003 264.542);
|
||||
--muted-foreground: oklch(0.551 0.027 264.364);
|
||||
--accent: oklch(0.967 0.003 264.542);
|
||||
--accent-foreground: oklch(0.21 0.034 264.665);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.928 0.006 264.531);
|
||||
--input: oklch(0.928 0.006 264.531);
|
||||
--ring: oklch(0.707 0.022 261.325);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.002 247.839);
|
||||
--sidebar-foreground: oklch(0.13 0.028 261.692);
|
||||
--sidebar-primary: oklch(0.21 0.034 264.665);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
|
||||
--sidebar-accent: oklch(0.967 0.003 264.542);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.034 264.665);
|
||||
--sidebar-border: oklch(0.928 0.006 264.531);
|
||||
--sidebar-ring: oklch(0.707 0.022 261.325);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.13 0.028 261.692);
|
||||
--foreground: oklch(0.985 0.002 247.839);
|
||||
--card: oklch(0.21 0.034 264.665);
|
||||
--card-foreground: oklch(0.985 0.002 247.839);
|
||||
--popover: oklch(0.21 0.034 264.665);
|
||||
--popover-foreground: oklch(0.985 0.002 247.839);
|
||||
--primary: oklch(0.928 0.006 264.531);
|
||||
--primary-foreground: oklch(0.21 0.034 264.665);
|
||||
--secondary: oklch(0.278 0.033 256.848);
|
||||
--secondary-foreground: oklch(0.985 0.002 247.839);
|
||||
--muted: oklch(0.278 0.033 256.848);
|
||||
--muted-foreground: oklch(0.707 0.022 261.325);
|
||||
--accent: oklch(0.278 0.033 256.848);
|
||||
--accent-foreground: oklch(0.985 0.002 247.839);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.551 0.027 264.364);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.034 264.665);
|
||||
--sidebar-foreground: oklch(0.985 0.002 247.839);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.002 247.839);
|
||||
--sidebar-accent: oklch(0.278 0.033 256.848);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.002 247.839);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.551 0.027 264.364);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-Cn">
|
||||
<body className={font.className}>
|
||||
<body className={`${font.className} bg-blue-50`}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
63
src/components/bread-crumb.tsx
Normal file
63
src/components/bread-crumb.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type BreadCrumbItem = {
|
||||
label: string
|
||||
href?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export type BreadCrumbProps = {
|
||||
items: BreadCrumbItem[]
|
||||
divider?: ReactNode
|
||||
showHomeIcon?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function BreadCrumb({
|
||||
items,
|
||||
divider = '/',
|
||||
showHomeIcon = true,
|
||||
className = '',
|
||||
}: BreadCrumbProps) {
|
||||
return (
|
||||
<nav className={`flex items-center text-sm ${className}`} aria-label="Breadcrumb">
|
||||
<ol className="flex items-center space-x-2">
|
||||
{showHomeIcon && (
|
||||
<li className="flex items-center">
|
||||
<Link href="/" className="text-gray-500 hover:text-gray-700 transition-colors">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M10.707 2.293a1 1 0 00-1.414 0l-7 7a1 1 0 001.414 1.414L4 10.414V17a1 1 0 001 1h2a1 1 0 001-1v-2a1 1 0 011-1h2a1 1 0 011 1v2a1 1 0 001 1h2a1 1 0 001-1v-6.586l.293.293a1 1 0 001.414-1.414l-7-7z" />
|
||||
</svg>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
|
||||
{items.map((item, index) => (
|
||||
<li key={index} className="flex items-center">
|
||||
{index > 0 || showHomeIcon ? (
|
||||
<span className="mx-2 text-gray-400">{divider}</span>
|
||||
) : null}
|
||||
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className="text-gray-600 hover:text-blue-600 transition-colors"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className="text-gray-900 font-medium"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export const Dropdown = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setIsOpen(!isOpen)}>
|
||||
打开菜单
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{/* 菜单内容 */}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
59
src/components/ui/button.tsx
Normal file
59
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
167
src/components/ui/form.tsx
Normal file
167
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
useFormState,
|
||||
type ControllerProps,
|
||||
type FieldPath,
|
||||
type FieldValues,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState } = useFormContext()
|
||||
const formState = useFormState({ name: fieldContext.name })
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
function FormLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
data-slot="form-label"
|
||||
data-error={!!error}
|
||||
className={cn("data-[error=true]:text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
data-slot="form-control"
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-description"
|
||||
id={formDescriptionId}
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message ?? "") : props.children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
data-slot="form-message"
|
||||
id={formMessageId}
|
||||
className={cn("text-destructive text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
21
src/components/ui/input.tsx
Normal file
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
185
src/components/ui/select.tsx
Normal file
185
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Select({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />
|
||||
}
|
||||
|
||||
function SelectGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />
|
||||
}
|
||||
|
||||
function SelectValue({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-sm font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user