Compare commits
5 Commits
v1.3.0
...
8b65a1745c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b65a1745c | ||
|
|
5607217625 | ||
| 319baea5e8 | |||
|
|
9a8a1826c9 | ||
|
|
c2a0310ee5 |
@@ -1,4 +1,4 @@
|
||||
# 开发环境配置
|
||||
NEXT_PUBLIC_API_BASE_URL=http://192.168.3.42:8080
|
||||
API_BASE_URL=http://192.168.3.42:8080
|
||||
CLIENT_ID=web
|
||||
CLIENT_SECRET=web
|
||||
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -122,7 +122,7 @@ type UserProfile = ExtraResp<typeof getProfile>
|
||||
## 环境变量
|
||||
|
||||
需要配置:
|
||||
- `NEXT_PUBLIC_API_BASE_URL` - 后端 API 地址
|
||||
- `API_BASE_URL` - 后端 API 地址
|
||||
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
|
||||
|
||||
## 部署
|
||||
|
||||
@@ -32,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
RUN rm .env
|
||||
|
||||
USER nextjs
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lanhu-web",
|
||||
"version": "1.3.0",
|
||||
"version": "1.6.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0 --turbopack",
|
||||
|
||||
@@ -12,6 +12,13 @@ export type TokenResp = {
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export async function getApiUrl() {
|
||||
return {
|
||||
success: true,
|
||||
data: API_BASE_URL,
|
||||
} satisfies ApiResponse<string>
|
||||
}
|
||||
|
||||
// ======================
|
||||
// public
|
||||
// ======================
|
||||
|
||||
@@ -32,5 +32,5 @@ export async function createChannels(params: {
|
||||
city?: string
|
||||
isp?: number
|
||||
}) {
|
||||
return callPublic<CreateChannelsResp[]>('/api/channel/create', params)
|
||||
return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
|
||||
}
|
||||
|
||||
12
src/actions/product.ts
Normal file
12
src/actions/product.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import {callByUser, callPublic} from './base'
|
||||
import {Product} from '@/lib/models/product'
|
||||
|
||||
export type ProductItem = Product
|
||||
|
||||
export async function listProduct(props: {}) {
|
||||
return callByUser<Product[]>('/api/product/list', props)
|
||||
}
|
||||
|
||||
export async function listProductHome(props: {}) {
|
||||
return callPublic<Product[]>('/api/product/list', props)
|
||||
}
|
||||
@@ -89,9 +89,17 @@ export async function payClose(props: {
|
||||
}
|
||||
|
||||
export async function getPrice(props: CreateResourceReq) {
|
||||
return callByDevice<{
|
||||
return callByUser<{
|
||||
price: string
|
||||
discounted_price?: string
|
||||
discounted?: number
|
||||
actual?: string
|
||||
discounted?: string
|
||||
}>('/api/resource/price', props)
|
||||
}
|
||||
|
||||
export async function getPriceHome(props: CreateResourceReq) {
|
||||
return callByDevice<{
|
||||
price: string
|
||||
actual?: string
|
||||
discounted?: string
|
||||
}>('/api/resource/price', props)
|
||||
}
|
||||
|
||||
@@ -38,16 +38,16 @@ export async function Identify(props: {
|
||||
}
|
||||
|
||||
export async function update(props: {
|
||||
username: string
|
||||
email: string
|
||||
contact_qq: string
|
||||
contact_wechat: string
|
||||
username?: string
|
||||
email?: string
|
||||
contact_qq?: string
|
||||
contact_wechat?: string
|
||||
}) {
|
||||
return await callByUser('/api/user/update', props)
|
||||
}
|
||||
|
||||
export async function updatePassword(props: {
|
||||
phone: string
|
||||
// phone: string
|
||||
code: string
|
||||
password: string
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use server'
|
||||
import {ApiResponse} from '@/lib/api'
|
||||
import {callByDevice} from '@/actions/base'
|
||||
import {callByDevice, callByUser} from '@/actions/base'
|
||||
import {getCap} from '@/lib/cap'
|
||||
|
||||
export async function sendSMS(props: {
|
||||
@@ -38,3 +38,35 @@ export async function sendSMS(props: {
|
||||
throw new Error('验证码验证失败', {cause: error})
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateSendSMS(props: {
|
||||
captcha: string
|
||||
}): Promise<ApiResponse> {
|
||||
try {
|
||||
// 人机验证
|
||||
if (!props.captcha?.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
message: '请输入验证码',
|
||||
}
|
||||
}
|
||||
|
||||
const cap = await getCap()
|
||||
const valid = await cap.validateToken(props.captcha)
|
||||
if (!valid) {
|
||||
return {
|
||||
success: false,
|
||||
status: 400,
|
||||
message: '验证码错误或已过期',
|
||||
}
|
||||
}
|
||||
|
||||
// 请求发送短信
|
||||
return await callByUser('/api/verify/sms/password', {})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('验证码验证失败:', error)
|
||||
throw new Error('验证码验证失败', {cause: error})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,20 +34,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
|
||||
export default function LoginCard() {
|
||||
const router = useRouter()
|
||||
const refreshProfile = useProfileStore(store => store.refreshProfile)
|
||||
const [mode, setMode] = useState<LoginMode>('phone_code')
|
||||
const [mode, setMode] = useState<LoginMode>('password')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const updateLoginMode = (mode: LoginMode) => {
|
||||
sessionStorage.setItem('login_mode', mode)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const mode = sessionStorage.getItem('login_mode')
|
||||
if (mode) {
|
||||
setMode(mode as LoginMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const form = useForm<LoginSchema>({
|
||||
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
|
||||
defaultValues: {
|
||||
@@ -55,7 +47,16 @@ export default function LoginCard() {
|
||||
password: '',
|
||||
remember: false,
|
||||
},
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const savedMode = sessionStorage.getItem('login_mode') as LoginMode
|
||||
if (savedMode && savedMode === 'phone_code') {
|
||||
setMode(savedMode)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handler = form.handleSubmit(async (data) => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
@@ -93,13 +94,14 @@ export default function LoginCard() {
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(val) => {
|
||||
setMode(val as typeof mode)
|
||||
form.reset({username: form.getValues('username'), password: '', remember: false})
|
||||
setMode(val as LoginMode)
|
||||
form.reset({username: '', password: '', remember: false})
|
||||
form.clearErrors()
|
||||
}}
|
||||
className="mb-6">
|
||||
<TabsList className="w-full p-0 bg-white">
|
||||
<Tab value="password">密码登录</Tab>
|
||||
<Tab value="phone_code">验证码登录</Tab>
|
||||
<Tab value="phone_code">验证码登录/注册</Tab>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
|
||||
@@ -124,6 +126,7 @@ export default function LoginCard() {
|
||||
className="h-10"
|
||||
placeholder="请输入验证码"
|
||||
autoComplete="one-time-code"
|
||||
disabled={submitting}
|
||||
/>
|
||||
<SendMsgByUsername/>
|
||||
</div>
|
||||
@@ -137,6 +140,7 @@ export default function LoginCard() {
|
||||
placeholder="至少6位密码,需包含字母和数字"
|
||||
autoComplete="current-password"
|
||||
minLength={6}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -162,6 +166,7 @@ export default function LoginCard() {
|
||||
id={id}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<div className="space-y-1 leading-none">
|
||||
<Label>保持登录</Label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {useContext, useState} from 'react'
|
||||
import {useContext, useEffect, useState} from 'react'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {X} from 'lucide-react'
|
||||
import {HeaderContext} from './common'
|
||||
@@ -21,6 +21,8 @@ import h03 from '@/assets/header/help/03.svg'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Link from 'next/link'
|
||||
import logo from '@/assets/logo.webp'
|
||||
import {Product} from '@/lib/models/product'
|
||||
import {listProductHome} from '@/actions/product'
|
||||
|
||||
export type MobileMenuProps = {}
|
||||
|
||||
@@ -37,7 +39,18 @@ export default function MobileMenu(props: MobileMenuProps) {
|
||||
ctx.setMenu(false)
|
||||
router.push(href)
|
||||
}
|
||||
|
||||
const [productList, setProductList] = useState<Product[]>([])
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
const res = await listProductHome({})
|
||||
if (res.success) {
|
||||
setProductList(res.data)
|
||||
}
|
||||
}
|
||||
fetchProducts()
|
||||
}, [])
|
||||
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
|
||||
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-white">
|
||||
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
|
||||
@@ -87,20 +100,24 @@ export default function MobileMenu(props: MobileMenuProps) {
|
||||
|
||||
{productTab === 'domestic' && (
|
||||
<div className="space-y-2">
|
||||
<ProductItem
|
||||
icon={prod}
|
||||
label="短效动态IP"
|
||||
badge="最低4.5折"
|
||||
href="/product?type=short"
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
<ProductItem
|
||||
icon={prod}
|
||||
label="长效静态IP"
|
||||
badge="最低4.5折"
|
||||
href="/product?type=long"
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
{shortProduct && (
|
||||
<ProductItem
|
||||
icon={prod}
|
||||
label="短效动态IP"
|
||||
badge="最低4.5折"
|
||||
href={`/product?type=${shortProduct.code}`}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
)}
|
||||
{longProduct && (
|
||||
<ProductItem
|
||||
icon={prod}
|
||||
label="长效静态IP"
|
||||
badge="最低4.5折"
|
||||
href={`/product?type=${longProduct.code}`}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
)}
|
||||
<ProductItem
|
||||
icon={custom}
|
||||
label="优质/企业/精选IP"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import {ReactNode, useContext, useState} from 'react'
|
||||
import {ReactNode, useContext, useEffect, useState} from 'react'
|
||||
import Wrap from '@/components/wrap'
|
||||
import Image, {StaticImageData} from 'next/image'
|
||||
import Link from 'next/link'
|
||||
@@ -8,7 +8,10 @@ import prod from '@/assets/header/product/prod.svg'
|
||||
import custom from '@/assets/header/product/custom.svg'
|
||||
import {useRouter} from 'next/navigation'
|
||||
import {HeaderContext} from './common'
|
||||
import {Product} from '@/lib/models/product'
|
||||
import {listProductHome} from '@/actions/product'
|
||||
|
||||
export type ProductItem = Product
|
||||
type TabType = 'domestic' | 'oversea'
|
||||
|
||||
export default function ProductMenu() {
|
||||
@@ -53,24 +56,41 @@ export function Tab(props: {
|
||||
}
|
||||
|
||||
export function Domestic(props: {}) {
|
||||
const [productList, setProductList] = useState<Product[]>([])
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
const res = await listProductHome({})
|
||||
if (res.success) {
|
||||
setProductList(res.data)
|
||||
}
|
||||
}
|
||||
fetchProducts()
|
||||
}, [])
|
||||
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
|
||||
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
|
||||
|
||||
return (
|
||||
<section role="tabpanel" className="flex-auto">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<ProductCard
|
||||
icon={prod}
|
||||
label="短效动态 IP"
|
||||
discount="最低4.5折"
|
||||
desc="全国 300+ 城市级定位节点,IP 池资源充足,自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
|
||||
href="/product?type=short"
|
||||
/>
|
||||
<ProductCard
|
||||
icon={prod}
|
||||
label="长效静态 IP"
|
||||
discount="最低4.5折"
|
||||
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
|
||||
href="/product?type=long"
|
||||
/>
|
||||
{shortProduct && (
|
||||
<ProductCard
|
||||
icon={prod}
|
||||
label="短效动态 IP"
|
||||
discount="最低4.5折"
|
||||
desc="全国 300+ 城市级定位节点,IP 池资源充足,自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
|
||||
href={`/product?type=${shortProduct.code}`}
|
||||
/>
|
||||
)}
|
||||
{longProduct && (
|
||||
<ProductCard
|
||||
icon={prod}
|
||||
label="长效动态 IP"
|
||||
discount="最低4.5折"
|
||||
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
|
||||
href={`/product?type=${longProduct.code}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<ProductCard
|
||||
|
||||
@@ -282,7 +282,7 @@ function ProfileOrLogin() {
|
||||
<span>登录</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/login"
|
||||
href="/login?tab=sms"
|
||||
className={[
|
||||
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
|
||||
`transition-colors duration-200 ease-in-out`,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import {ReactNode, Suspense, use, useState} from 'react'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
||||
import UserCenter from '@/components/composites/user-center'
|
||||
import {Button} from '@/components/ui/button'
|
||||
@@ -75,6 +74,7 @@ export function Content(props: {children: ReactNode}) {
|
||||
}
|
||||
function ContentResolved() {
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
if (profile)
|
||||
return (
|
||||
<>
|
||||
@@ -82,10 +82,10 @@ function ContentResolved() {
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.id_token}
|
||||
/>
|
||||
<ChangePasswordDialog
|
||||
{/* <ChangePasswordDialog
|
||||
triggerClassName="hidden"
|
||||
defaultOpen={!profile.has_password}
|
||||
/>
|
||||
/> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,8 +78,8 @@ export function BasicForm(props: {
|
||||
}) {
|
||||
const schema = z.object({
|
||||
username: z.string(),
|
||||
email: z.string(),
|
||||
contact_qq: z.string(),
|
||||
email: z.string().email('请输入正确的邮箱格式').or(z.literal('')),
|
||||
contact_qq: z.string().regex(/^\d*$/, 'QQ号只能包含数字'),
|
||||
contact_wechat: z.string(),
|
||||
})
|
||||
type Schema = z.infer<typeof schema>
|
||||
|
||||
@@ -3,16 +3,17 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import {CheckCircle} from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import banner from '@/app/admin/identify/_assets/banner.webp'
|
||||
import RechargeModal from '@/components/composites/recharge'
|
||||
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
|
||||
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
|
||||
import {getProfile} from '@/actions/auth'
|
||||
import {Aftersale, BasicForm} from './clients'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
|
||||
export type ProfilePageProps = {}
|
||||
|
||||
export default async function ProfilePage(props: ProfilePageProps) {
|
||||
const profile = await getProfile()
|
||||
|
||||
if (!profile.success) {
|
||||
return (
|
||||
<Page>
|
||||
@@ -22,6 +23,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
||||
}
|
||||
|
||||
const user = profile.data
|
||||
|
||||
return (
|
||||
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
|
||||
<div className="flex-3/4 flex flex-col gap-4">
|
||||
@@ -34,26 +36,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
||||
|
||||
{/* 块信息 */}
|
||||
<div className="flex gap-4 max-md:flex-col max-sm:flex-col">
|
||||
|
||||
{/* <Card className="flex-1 ">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-normal">账户余额(元)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-auto flex justify-between items-center px-8">
|
||||
<p className="text-xl">{user.balance}</p>
|
||||
<RechargeModal classNames={{
|
||||
trigger: `h-10 px-6`,
|
||||
}}/>
|
||||
</CardContent>
|
||||
</Card> */}
|
||||
|
||||
<Card className="flex-1 ">
|
||||
<CardHeader>
|
||||
<CardTitle className="font-normal">修改密码</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-auto flex justify-between items-center px-8">
|
||||
<p>{user.phone}</p>
|
||||
<ChangePasswordDialog triggerClassName="w-24 h-9"/>
|
||||
<ChangePasswordDialog triggerClassName="w-24 h-9" phone={user?.phone}/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -66,10 +55,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
|
||||
? (
|
||||
<>
|
||||
<p className="text-sm">为了保障您的账户安全和正常使用服务,请您尽快完成实名认证</p>
|
||||
<RealnameAuthDialog
|
||||
defaultOpen={!user.id_token}
|
||||
{/* <RealnameAuthDialog
|
||||
// defaultOpen={!user.id_token}
|
||||
triggerClassName="w-24"
|
||||
/>
|
||||
/> */}
|
||||
<Link href="/admin/identify">
|
||||
<Button>立即认证</Button>
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
|
||||
@@ -164,16 +164,21 @@ export default function RecordPage(props: RecordPageProps) {
|
||||
cell: ({row}) => <div>{row.original.prov}</div>,
|
||||
accessorKey: 'prov',
|
||||
},
|
||||
{
|
||||
header: '城市',
|
||||
cell: ({row}) => <div>{row.original.city}</div>,
|
||||
accessorKey: 'city',
|
||||
},
|
||||
{
|
||||
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}) => <div>{row.original.resource_id}</div>,
|
||||
// accessorKey: 'resource_id',
|
||||
// },
|
||||
{
|
||||
header: '提取时间',
|
||||
cell: ({row}) => {
|
||||
|
||||
@@ -219,6 +219,17 @@ export default function ResourceList({resourceType}: ResourceListProps) {
|
||||
header: '开通时间',
|
||||
cell: ({row}) => formatDateTime(row.original.created_at),
|
||||
},
|
||||
{
|
||||
header: '状态',
|
||||
cell: ({row}) => {
|
||||
const isActive = row.original.active
|
||||
return (
|
||||
<span className={isActive ? 'text-green-500' : 'text-red-500'}>
|
||||
{isActive ? '启用' : '禁用'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// 短效资源增加到期时间列
|
||||
|
||||
@@ -8,6 +8,8 @@ import {LayoutStoreProvider} from '@/components/stores/layout'
|
||||
import {ClientStoreProvider} from '@/components/stores/client'
|
||||
import {getProfile} from '@/actions/auth'
|
||||
import Script from 'next/script'
|
||||
import {AppStoreProvider} from '@/components/stores/app'
|
||||
import {getApiUrl} from '@/actions/base'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
@@ -30,12 +32,14 @@ export default async function RootLayout(props: Readonly<{
|
||||
)
|
||||
}
|
||||
|
||||
function StoreProviders(props: {children: ReactNode}) {
|
||||
async function StoreProviders(props: {children: ReactNode}) {
|
||||
return (
|
||||
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
|
||||
<LayoutStoreProvider>
|
||||
<ClientStoreProvider>
|
||||
{props.children}
|
||||
<AppStoreProvider url={await getApiUrl().then(r => r.data)}>
|
||||
{props.children}
|
||||
</AppStoreProvider>
|
||||
</ClientStoreProvider>
|
||||
</LayoutStoreProvider>
|
||||
</ProfileStoreProvider>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTr
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {useForm, useFormContext, useWatch} from 'react-hook-form'
|
||||
import {useForm, useFormContext} from 'react-hook-form'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import {toast} from 'sonner'
|
||||
@@ -14,7 +14,6 @@ import dynamic from 'next/dynamic'
|
||||
|
||||
// 表单验证规则
|
||||
const schema = z.object({
|
||||
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
|
||||
captcha: z.string().nonempty('请输入验证码'),
|
||||
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
|
||||
password: z.string().min(6, `密码至少6位`),
|
||||
@@ -32,6 +31,7 @@ interface ChangePasswordDialogProps {
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
phone?: string
|
||||
}
|
||||
|
||||
export function ChangePasswordDialog({
|
||||
@@ -40,12 +40,28 @@ export function ChangePasswordDialog({
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
phone,
|
||||
}: ChangePasswordDialogProps) {
|
||||
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
|
||||
const router = useRouter()
|
||||
|
||||
const actualOpen = open !== undefined ? open : internalOpen
|
||||
const actualOnOpenChange = onOpenChange || setInternalOpen
|
||||
const actualOnOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
form.reset({
|
||||
captcha: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
}
|
||||
if (onOpenChange) {
|
||||
onOpenChange(open)
|
||||
}
|
||||
else {
|
||||
setInternalOpen(open)
|
||||
}
|
||||
}
|
||||
|
||||
// 表单初始化
|
||||
const form = useForm<Schema>({
|
||||
@@ -59,7 +75,6 @@ export function ChangePasswordDialog({
|
||||
),
|
||||
),
|
||||
defaultValues: {
|
||||
phone: '',
|
||||
captcha: '',
|
||||
code: '',
|
||||
password: '',
|
||||
@@ -71,7 +86,6 @@ export function ChangePasswordDialog({
|
||||
const handler = async (value: Schema) => {
|
||||
try {
|
||||
const resp = await updatePassword({
|
||||
phone: value.phone,
|
||||
code: value.code,
|
||||
password: value.password,
|
||||
})
|
||||
@@ -108,14 +122,12 @@ export function ChangePasswordDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改密码</DialogTitle>
|
||||
</DialogHeader>
|
||||
{/* 手机号输入 */}
|
||||
<FormField<Schema> name="phone" label="手机号" className="flex-auto">
|
||||
{({field}) => (
|
||||
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 短信验证码 */}
|
||||
{phone && (
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
|
||||
<span className="text-sm text-gray-500">当前用户手机号:</span>
|
||||
<span className="text-sm font-medium">{phone}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-4 items-end">
|
||||
<FormField<Schema> name="code" label="验证码" className="flex-auto">
|
||||
{({field}) => (
|
||||
@@ -124,15 +136,11 @@ export function ChangePasswordDialog({
|
||||
</FormField>
|
||||
<SendMsgByPhone/>
|
||||
</div>
|
||||
|
||||
{/* 新密码 */}
|
||||
<FormField<Schema> name="password" label="新密码" className="flex-auto">
|
||||
{({field}) => (
|
||||
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 确认密码 */}
|
||||
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
|
||||
{({field}) => (
|
||||
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
|
||||
@@ -144,8 +152,13 @@ export function ChangePasswordDialog({
|
||||
theme="outline"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
form.reset({
|
||||
captcha: '',
|
||||
code: '',
|
||||
password: '',
|
||||
confirm_password: '',
|
||||
})
|
||||
actualOnOpenChange(false)
|
||||
form.reset()
|
||||
}}>
|
||||
关闭
|
||||
</Button>
|
||||
@@ -159,8 +172,7 @@ export function ChangePasswordDialog({
|
||||
|
||||
function SendMsgByPhone() {
|
||||
const {control} = useFormContext<Schema>()
|
||||
const phone = useWatch({control, name: 'phone'})
|
||||
return <SendMsg phone={phone}/>
|
||||
return <SendMsg/>
|
||||
}
|
||||
|
||||
const SendMsg = dynamic(() => import('@/components/send-msg'), {ssr: false})
|
||||
const SendMsg = dynamic(() => import('@/components/updateSend-msg'), {ssr: false})
|
||||
|
||||
@@ -337,8 +337,6 @@ function SelectResource() {
|
||||
setStatus('load')
|
||||
try {
|
||||
const resp = await allResource()
|
||||
console.log(resp, '/api/resource/all')
|
||||
|
||||
if (!resp.success) {
|
||||
throw new Error('获取套餐失败,请稍后再试')
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export function DesktopPayment(props: PaymentModalProps) {
|
||||
<p className="text-sm text-gray-600">
|
||||
请使用
|
||||
{decoration.text}
|
||||
扫码支付
|
||||
{/* 扫码支付 */}
|
||||
</p>
|
||||
|
||||
<div className="w-full text-center space-y-2">
|
||||
|
||||
@@ -7,6 +7,7 @@ import {PaymentProps} from './type'
|
||||
import {payClose} from '@/actions/resource'
|
||||
import {useEffect} from 'react'
|
||||
import {UniversalDesktopPayment} from './universal-desktop-payment'
|
||||
import {useAppStore} from '@/components/stores/app'
|
||||
|
||||
export type PaymentModalProps = {
|
||||
onConfirm: (showFail: boolean) => Promise<void>
|
||||
@@ -34,9 +35,10 @@ export function PaymentModal(props: PaymentModalProps) {
|
||||
}
|
||||
|
||||
// SSE处理方式检查支付状态
|
||||
const apiUrl = useAppStore('apiUrl')
|
||||
useEffect(() => {
|
||||
const eventSource = new EventSource(
|
||||
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
|
||||
`${apiUrl}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
|
||||
)
|
||||
eventSource.onmessage = async (event) => {
|
||||
switch (event.data) {
|
||||
@@ -53,7 +55,7 @@ export function PaymentModal(props: PaymentModalProps) {
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [props])
|
||||
}, [apiUrl, props])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
import {ReactNode} from 'react'
|
||||
import {ReactNode, use, useEffect, useState} from 'react'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
|
||||
import LongForm from '@/components/composites/purchase/long/form'
|
||||
import ShortForm from '@/components/composites/purchase/short/form'
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation'
|
||||
import SelfDesc from '@/components/features/self-desc'
|
||||
import {listProduct, listProductHome, ProductItem} from '@/actions/product'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
export type TabType = 'short' | 'long' | 'fixed' | 'custom'
|
||||
|
||||
export default function Purchase() {
|
||||
@@ -13,35 +15,58 @@ export default function Purchase() {
|
||||
const path = usePathname()
|
||||
const params = useSearchParams()
|
||||
|
||||
const tab = params.get('type') as TabType || 'short'
|
||||
const [productList, setProductList] = useState<ProductItem[]>([])
|
||||
const tab = (params.get('type') as TabType) || productList[0]?.code || 'short'
|
||||
|
||||
const updateTab = (tab: string) => {
|
||||
const newParams = new URLSearchParams(params)
|
||||
newParams.set('type', tab)
|
||||
router.push(`${path}?${newParams.toString()}`)
|
||||
}
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
useEffect(() => {
|
||||
const fetchProducts = async () => {
|
||||
const res = profile
|
||||
? await listProduct({})
|
||||
: await listProductHome({})
|
||||
|
||||
if (res.success) {
|
||||
setProductList(res.data)
|
||||
}
|
||||
}
|
||||
fetchProducts()
|
||||
}, [profile])
|
||||
|
||||
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
|
||||
short: ShortForm,
|
||||
long: LongForm,
|
||||
// static: StaticForm
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Tabs value={tab} onValueChange={updateTab} className="gap-4">
|
||||
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
|
||||
<Tab value="short">短效动态</Tab>
|
||||
<Tab value="long">长效静态</Tab>
|
||||
<Tab value="fixed">固定套餐</Tab>
|
||||
{productList.map(item => (
|
||||
<Tab key={item.code} value={item.code}>
|
||||
{item.name}
|
||||
</Tab>
|
||||
))}
|
||||
{/* 固定的定制套餐tab */}
|
||||
<Tab value="custom">定制套餐</Tab>
|
||||
</TabsList>
|
||||
<TabsContent value="short">
|
||||
<ShortForm/>
|
||||
</TabsContent>
|
||||
<TabsContent value="long">
|
||||
<LongForm/>
|
||||
</TabsContent>
|
||||
<TabsContent value="fixed">
|
||||
</TabsContent>
|
||||
{productList.map((item) => {
|
||||
const Component = componentMap[item.code]
|
||||
const skuList = item.skus || []
|
||||
return (
|
||||
<TabsContent key={item.code} value={item.code}>
|
||||
{Component ? <Component skuList={skuList}/> : <div>页面待开发中</div>}
|
||||
</TabsContent>
|
||||
)
|
||||
})}
|
||||
|
||||
<TabsContent value="custom">
|
||||
<SelfDesc onInquiry={() => {
|
||||
router.push('/custom')
|
||||
}}/>
|
||||
<SelfDesc onInquiry={() => router.push('/custom')}/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -10,72 +10,22 @@ 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() {
|
||||
export default function Center({map, expireList, liveList}: {
|
||||
map: Map<string, string>
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
}) {
|
||||
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">
|
||||
|
||||
{/* 计费方式 */}
|
||||
<FormField
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex gap-4 max-md:flex-col">
|
||||
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<BillingMethod expireList={expireList}/>
|
||||
{/* IP 时效 */}
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="live"
|
||||
label="IP 时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
|
||||
<FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-8`} value="8" label="8 小时" description="¥1.2/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-12`} value="12" label="12 小时" description="¥1.8/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-24`} value="24" label="24 小时" description="¥3.5/IP" compare={field.value}/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
<IpTime {...{map, liveList}}/>
|
||||
|
||||
{/* 根据套餐类型显示不同表单项 */}
|
||||
{type === '2' ? (
|
||||
@@ -126,26 +76,7 @@ export default function Center() {
|
||||
) : (
|
||||
<>
|
||||
{/* 包时:套餐时效 */}
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="expire"
|
||||
label="套餐时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex gap-4 flex-wrap">
|
||||
|
||||
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
|
||||
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
|
||||
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
|
||||
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
|
||||
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
|
||||
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
<ComboValidity expireList={expireList}/>
|
||||
|
||||
{/* 包时:每日提取上限 */}
|
||||
<FormField
|
||||
@@ -244,3 +175,118 @@ export default function Center() {
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingMethod(props: {
|
||||
expireList: string[]
|
||||
}) {
|
||||
const {setValue} = useFormContext<Schema>()
|
||||
return (
|
||||
<FormField
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
if (v === '2') {
|
||||
setValue('expire', '0')
|
||||
}
|
||||
else if (props.expireList.length > 0) {
|
||||
setValue('expire', props.expireList[0])
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 max-md:flex-col">
|
||||
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
function IpTime({map, liveList}: {
|
||||
map: Map<string, string>
|
||||
liveList: string[]}) {
|
||||
const {control, getValues} = useFormContext<Schema>()
|
||||
const values = useWatch({control})
|
||||
|
||||
return (
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="live"
|
||||
label="IP 时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup id={id} value={field.value} onValueChange={field.onChange} className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
{liveList.map((live) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('mode', {
|
||||
1: 'time',
|
||||
2: 'quota',
|
||||
}[values.type || '2'])
|
||||
params.set('live', live || '0')
|
||||
params.set('expire', values.expire || '0')
|
||||
const price = map.get(params.toString())
|
||||
return (
|
||||
<FormOption
|
||||
key={live}
|
||||
id={live}
|
||||
value={live}
|
||||
label={`${Number(live) / 60} 小时`}
|
||||
description={price && `¥${price}/IP`}
|
||||
compare={field.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboValidity({expireList}: {expireList: string[]}) {
|
||||
return (
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="expire"
|
||||
label="套餐时效"
|
||||
>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val)
|
||||
}}
|
||||
className="flex gap-4 flex-wrap"
|
||||
>
|
||||
{expireList.map(item => (
|
||||
<FormOption
|
||||
key={item}
|
||||
id={`${id}-${item}`}
|
||||
value={item}
|
||||
label={`${item} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,13 +5,14 @@ import Right from '@/components/composites/purchase/long/right'
|
||||
import {Form} from '@/components/ui/form'
|
||||
import * as z from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
|
||||
// 定义表单验证架构
|
||||
const schema = z.object({
|
||||
type: z.enum(['1', '2']).default('2'),
|
||||
live: z.enum(['1', '4', '8', '12', '24']),
|
||||
live: z.string(),
|
||||
quota: z.number().min(500, '购买数量不能少于 500 个'),
|
||||
expire: z.enum(['7', '15', '30', '90', '180', '365']),
|
||||
expire: z.string(),
|
||||
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
||||
})
|
||||
@@ -19,14 +20,31 @@ const schema = z.object({
|
||||
// 从架构中推断类型
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
export default function LongForm() {
|
||||
export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
|
||||
if (!skuList) throw new Error('没有套餐数据')
|
||||
|
||||
const map = new Map<string, string>()
|
||||
// const _modeList = new Set<string>()
|
||||
const _liveList = new Set<number>()
|
||||
const _expireList = new Set<number>()
|
||||
for (const sku of skuList) {
|
||||
const params = new URLSearchParams(sku.code)
|
||||
// _modeList.add(params.get('mode') || '')
|
||||
_liveList.add(Number(params.get('live')))
|
||||
_expireList.add(Number(params.get('expire')))
|
||||
map.set(sku.code, sku.price)
|
||||
}
|
||||
// const modeList = Array.from(_modeList).filter(Boolean)
|
||||
const liveList = Array.from(_liveList).filter(Boolean).map(String)
|
||||
const expireList = Array.from(_expireList).filter(Boolean).map(String)
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: '2', // 默认为包量套餐
|
||||
live: '1', // 小时
|
||||
live: liveList[0], // 分钟
|
||||
expire: '0', // 天
|
||||
quota: 500,
|
||||
expire: '30', // 天
|
||||
daily_limit: 100,
|
||||
pay_type: 'balance', // 余额支付
|
||||
},
|
||||
@@ -34,7 +52,7 @@ export default function LongForm() {
|
||||
|
||||
return (
|
||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||
<Center/>
|
||||
<Center {...{liveList, map, expireList}}/>
|
||||
<Right/>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ 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} from '@/actions/resource'
|
||||
import {getPrice, getPriceHome} from '@/actions/resource'
|
||||
import {ExtraResp} from '@/lib/api'
|
||||
import {FieldPayment} from '../shared/field-payment'
|
||||
|
||||
@@ -22,30 +22,38 @@ export default function Right() {
|
||||
const dailyLimit = useWatch({control, name: 'daily_limit'})
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
|
||||
price: '0.00',
|
||||
discounted_price: '0.00',
|
||||
discounted: 0,
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
useEffect(() => {
|
||||
const price = async () => {
|
||||
try {
|
||||
const resp = await getPrice({
|
||||
type: 2,
|
||||
long: {
|
||||
live: Number(live) * 60,
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const resp = profile
|
||||
? await getPrice({
|
||||
type: 2,
|
||||
long: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
}) : await getPriceHome({
|
||||
type: 1,
|
||||
short: {
|
||||
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 ?? '',
|
||||
actual: resp.data.actual ?? resp.data.price ?? '',
|
||||
discounted: resp.data.discounted,
|
||||
})
|
||||
}
|
||||
@@ -53,16 +61,28 @@ export default function Right() {
|
||||
console.error('获取价格失败:', error)
|
||||
setPriceData({
|
||||
price: '0.00',
|
||||
discounted_price: '0.00',
|
||||
discounted: 0,
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
}
|
||||
}
|
||||
price()
|
||||
}, [dailyLimit, expire, live, quota, mode])
|
||||
}, [dailyLimit, expire, live, quota, mode, profile])
|
||||
|
||||
const {price, discounted_price: discountedPrice = '', discounted} = priceData
|
||||
const {price, actual: discountedPrice = ''} = priceData
|
||||
// 计算总折扣价(原价 - 实付价格)
|
||||
const calculateTotalDiscount = () => {
|
||||
const originalPrice = parseFloat(price)
|
||||
const actualPrice = parseFloat(discountedPrice)
|
||||
if (isNaN(originalPrice) || isNaN(actualPrice)) {
|
||||
return '0.00'
|
||||
}
|
||||
const discount = originalPrice - actualPrice
|
||||
return discount.toFixed(2)
|
||||
}
|
||||
|
||||
const totalDiscount = calculateTotalDiscount()
|
||||
const hasDiscount = parseFloat(totalDiscount) > 0
|
||||
return (
|
||||
<Card className={merge(
|
||||
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
|
||||
@@ -80,7 +100,7 @@ export default function Right() {
|
||||
<span className="text-sm">
|
||||
{live}
|
||||
{' '}
|
||||
小时
|
||||
分钟
|
||||
</span>
|
||||
</li>
|
||||
{mode === '2' ? (
|
||||
@@ -93,11 +113,19 @@ 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 text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -116,19 +144,19 @@ 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 text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{/* {discounted === 1 ? '' : (
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{discounted}
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)} */}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
@@ -167,7 +195,7 @@ function BalanceOrLogin(props: {
|
||||
type: 2,
|
||||
long: {
|
||||
mode: Number(props.mode),
|
||||
live: Number(props.live) * 60,
|
||||
live: Number(props.live),
|
||||
expire: props.mode === '1' ? Number(props.expire) : undefined,
|
||||
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
|
||||
},
|
||||
|
||||
@@ -47,7 +47,6 @@ export default function Pay(props: PayProps) {
|
||||
payment_method: method,
|
||||
payment_platform: TradePlatform.Desktop,
|
||||
}
|
||||
console.log(req, 'req')
|
||||
|
||||
const resp = await prepareResource(req)
|
||||
|
||||
|
||||
@@ -10,19 +10,21 @@ 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() {
|
||||
export default function Center({
|
||||
priceMap,
|
||||
liveList,
|
||||
expireList,
|
||||
}: {
|
||||
priceMap: Map<string, string>
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
}) {
|
||||
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])
|
||||
const expire = useWatch({name: 'expire'})
|
||||
const isTime = type === '1'
|
||||
|
||||
return (
|
||||
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
||||
|
||||
@@ -34,8 +36,17 @@ export default function Center() {
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
if (v === '2') {
|
||||
form.setValue('expire', '0')
|
||||
}
|
||||
else if (expireList.length > 0) {
|
||||
form.setValue('expire', expireList[0])
|
||||
form.setValue('daily_limit', 2000)
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 max-md:flex-col">
|
||||
|
||||
<FormOption
|
||||
@@ -64,21 +75,35 @@ export default function Center() {
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
|
||||
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-20`} value="15" label="15 分钟" description="¥0.03/IP" compare={field.value}/>
|
||||
<FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
|
||||
{liveList.map((live) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('mode', isTime ? 'time' : 'quota')
|
||||
params.set('live', live)
|
||||
params.set('expire', isTime ? expire : '0')
|
||||
const price = priceMap.get(params.toString())
|
||||
const minutes = Number(live)
|
||||
const hours = minutes / 60
|
||||
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
|
||||
return (
|
||||
<FormOption
|
||||
key={live}
|
||||
id={`${id}-${live}`}
|
||||
value={live}
|
||||
label={label}
|
||||
description={price && `¥${price}${!isTime ? '/IP' : ''}`}
|
||||
compare={field.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 根据套餐类型显示不同表单项 */}
|
||||
{type === '2' ? (
|
||||
{!isTime ? (
|
||||
/* 包量:IP 购买数量 */
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
@@ -121,23 +146,18 @@ export default function Center() {
|
||||
) : (
|
||||
<>
|
||||
{/* 包时:套餐时效 */}
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="expire"
|
||||
label="套餐时效">
|
||||
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex gap-4 flex-wrap">
|
||||
|
||||
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
|
||||
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
|
||||
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
|
||||
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
|
||||
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
|
||||
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
|
||||
<RadioGroup id={id} value={field.value} onValueChange={field.onChange} className="flex gap-4 flex-wrap">
|
||||
{expireList.map(day => (
|
||||
<FormOption
|
||||
key={day}
|
||||
id={`${id}-${day}`}
|
||||
value={day}
|
||||
label={`${day} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
@@ -153,8 +173,8 @@ export default function Center() {
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('daily_limit', Math.max(2_000, Number(field.value) - 1_000))}
|
||||
disabled={Number(field.value) === 2_000}>
|
||||
onClick={() => form.setValue('daily_limit', Math.max(2000, Number(field.value) - 1000))}
|
||||
disabled={Number(field.value) === 2000}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
@@ -162,12 +182,12 @@ export default function Center() {
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={2_000}
|
||||
step={1_000}
|
||||
min={2000}
|
||||
step={1000}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (value < 2_000) {
|
||||
form.setValue('daily_limit', 2_000)
|
||||
if (value < 2000) {
|
||||
form.setValue('daily_limit', 2000)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -175,7 +195,7 @@ export default function Center() {
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('daily_limit', Number(field.value) + 1_000)}>
|
||||
onClick={() => form.setValue('daily_limit', Number(field.value) + 1000)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -212,10 +232,6 @@ export default function Center() {
|
||||
<Image src={check} 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">
|
||||
<Image src={check} 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">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">包量/包时计费方式</span>
|
||||
|
||||
@@ -5,28 +5,52 @@ import Right from '@/components/composites/purchase/short/right'
|
||||
import {Form} from '@/components/ui/form'
|
||||
import * as z from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
|
||||
// 定义表单验证架构
|
||||
const schema = z.object({
|
||||
type: z.enum(['1', '2']).default('2'),
|
||||
live: z.enum(['3', '5', '10', '15', '30']),
|
||||
quota: z.number().min(10000, '购买数量不能少于10000个'),
|
||||
expire: z.enum(['7', '15', '30', '90', '180', '365']),
|
||||
daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
||||
live: z.string(),
|
||||
quota: z.number().min(10000, '购买数量不能少于 10000 个'),
|
||||
expire: z.string(),
|
||||
daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
|
||||
})
|
||||
|
||||
// 从架构中推断类型
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
export default function ShortForm() {
|
||||
export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
|
||||
if (!skuList?.length) throw new Error('没有套餐数据')
|
||||
|
||||
const priceMap = new Map<string, string>()
|
||||
const _liveList = new Set<number>()
|
||||
const _expireList = new Set<number>()
|
||||
|
||||
for (const sku of skuList) {
|
||||
const params = new URLSearchParams(sku.code)
|
||||
const mode = params.get('mode')
|
||||
const live = params.get('live')
|
||||
const expire = params.get('expire')
|
||||
|
||||
if (live && live !== '0') {
|
||||
_liveList.add(Number(live))
|
||||
}
|
||||
if (mode === 'time' && expire && expire !== '0') {
|
||||
_expireList.add(Number(expire))
|
||||
}
|
||||
priceMap.set(sku.code, sku.price)
|
||||
}
|
||||
|
||||
const liveList = Array.from(_liveList).filter(Boolean).map(String)
|
||||
const expireList = Array.from(_expireList).filter(Boolean).map(String)
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: '2', // 默认为包量套餐
|
||||
live: '3', // 分钟
|
||||
quota: 10_000, // >= 10000
|
||||
expire: '30', // 天
|
||||
live: liveList[0] || '',
|
||||
expire: '0', // 包量模式下无效
|
||||
quota: 10_000, // >= 10000,
|
||||
daily_limit: 2_000, // >= 2000
|
||||
pay_type: 'balance', // 余额支付
|
||||
},
|
||||
@@ -34,7 +58,7 @@ export default function ShortForm() {
|
||||
|
||||
return (
|
||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||
<Center/>
|
||||
<Center {...{priceMap, liveList, expireList}}/>
|
||||
<Right/>
|
||||
</Form>
|
||||
)
|
||||
|
||||
@@ -8,9 +8,10 @@ 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 {getPrice} from '@/actions/resource'
|
||||
import {getPrice, getPriceHome} from '@/actions/resource'
|
||||
import {ExtraResp} from '@/lib/api'
|
||||
import {FieldPayment} from '../shared/field-payment'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
|
||||
export default function Right() {
|
||||
const {control} = useFormContext<Schema>()
|
||||
@@ -22,22 +23,33 @@ export default function Right() {
|
||||
const dailyLimit = useWatch({control, name: 'daily_limit'})
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
|
||||
price: '0.00',
|
||||
discounted_price: '0.00',
|
||||
discounted: 0,
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
const priceResponse = profile
|
||||
? await getPrice({
|
||||
type: 1,
|
||||
short: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
})
|
||||
: await getPriceHome({
|
||||
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('获取价格失败')
|
||||
@@ -46,7 +58,7 @@ export default function Right() {
|
||||
const data = priceResponse.data
|
||||
setPriceData({
|
||||
price: data.price,
|
||||
discounted_price: data.discounted_price ?? data.price ?? '',
|
||||
actual: data.actual ?? data.price ?? '',
|
||||
discounted: data.discounted,
|
||||
})
|
||||
}
|
||||
@@ -54,15 +66,29 @@ export default function Right() {
|
||||
console.error('获取价格失败:', error)
|
||||
setPriceData({
|
||||
price: '0.00',
|
||||
discounted_price: '0.00',
|
||||
discounted: 0,
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
}
|
||||
}
|
||||
price()
|
||||
}, [expire, live, quota, mode, dailyLimit])
|
||||
}, [expire, live, quota, mode, dailyLimit, profile])
|
||||
|
||||
const {price, discounted_price: discountedPrice = '', discounted} = priceData
|
||||
const {price, actual: discountedPrice = ''} = priceData
|
||||
|
||||
// 计算总折扣价(原价 - 实付价格)
|
||||
const calculateTotalDiscount = () => {
|
||||
const originalPrice = parseFloat(price)
|
||||
const actualPrice = parseFloat(discountedPrice)
|
||||
if (isNaN(originalPrice) || isNaN(actualPrice)) {
|
||||
return '0.00'
|
||||
}
|
||||
const discount = originalPrice - actualPrice
|
||||
return discount.toFixed(2)
|
||||
}
|
||||
|
||||
const totalDiscount = calculateTotalDiscount()
|
||||
const hasDiscount = parseFloat(totalDiscount) > 0
|
||||
|
||||
return (
|
||||
<Card className={merge(
|
||||
@@ -94,11 +120,19 @@ 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 text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -117,19 +151,19 @@ 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 text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{/* {discounted === 1 ? '' : (
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{discounted === 1 ? '' : discounted}
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)} */}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
38
src/components/stores/app.tsx
Normal file
38
src/components/stores/app.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import {createStore, StoreApi} from 'zustand/vanilla'
|
||||
import {createContext, ReactNode, useContext, useState} from 'react'
|
||||
import {useStore} from 'zustand/react'
|
||||
|
||||
// store
|
||||
type AppStoreState = {
|
||||
apiUrl: string
|
||||
}
|
||||
|
||||
export function createAppStore(url: string) {
|
||||
return createStore<AppStoreState>()(() => ({
|
||||
apiUrl: url,
|
||||
}))
|
||||
}
|
||||
|
||||
// provider
|
||||
const AppStoreContext = createContext<StoreApi<AppStoreState> | null>(null)
|
||||
|
||||
export function AppStoreProvider(props: {
|
||||
url: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
const [store] = useState(() => createAppStore(props.url))
|
||||
return (
|
||||
<AppStoreContext.Provider value={store}>
|
||||
{props.children}
|
||||
</AppStoreContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppStore(name: keyof AppStoreState) {
|
||||
const context = useContext(AppStoreContext)
|
||||
if (!context) {
|
||||
throw new Error('AppStoreContext 没有正确初始化')
|
||||
}
|
||||
return useStore(context, c => c[name])
|
||||
}
|
||||
78
src/components/updateSend-msg.tsx
Normal file
78
src/components/updateSend-msg.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client'
|
||||
import {useEffect, useRef, useState} from 'react'
|
||||
import Cap from '@cap.js/widget'
|
||||
import {updateSendSMS} from '@/actions/verify'
|
||||
import {toast} from 'sonner'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {LoaderIcon} from 'lucide-react'
|
||||
|
||||
export default function SendMsg() {
|
||||
const [countdown, setCountdown] = useState(0)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [mode, setMode] = useState<'ready' | 'wait' | 'check'>('ready')
|
||||
const cap = useRef(new Cap({apiEndpoint: '/'}))
|
||||
cap.current.addEventListener('progress', (event) => {
|
||||
setProgress(event.detail.progress)
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
const sendCode = async () => {
|
||||
try {
|
||||
setMode('check')
|
||||
|
||||
// 完成挑战
|
||||
const result = await cap.current.solve()
|
||||
if (!result.success || !cap.current.token) {
|
||||
throw new Error('人机验证失败')
|
||||
}
|
||||
|
||||
// 发送验证码
|
||||
const resp = await updateSendSMS({
|
||||
captcha: cap.current.token,
|
||||
})
|
||||
if (!resp.success) {
|
||||
throw new Error(`验证码发送失败: ${resp.message}`)
|
||||
}
|
||||
|
||||
setMode('wait')
|
||||
setCountdown(60)
|
||||
toast.success('验证码已发送')
|
||||
}
|
||||
catch (e) {
|
||||
setMode('ready')
|
||||
toast.error('验证码发送失败', {
|
||||
description: (e as Error).message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 计时
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (countdown > 0) {
|
||||
setCountdown(countdown - 1)
|
||||
}
|
||||
else if (mode === 'wait') {
|
||||
setMode('ready')
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [countdown, mode])
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
theme="outline"
|
||||
className="whitespace-nowrap h-10"
|
||||
disabled={countdown > 0}
|
||||
onClick={sendCode}
|
||||
>
|
||||
{mode === 'check' && <LoaderIcon className="animate-spin"/>}
|
||||
<span>
|
||||
{mode === 'check' && '检查登录环境'}
|
||||
{mode === 'wait' && `${countdown}秒后重发`}
|
||||
{mode === 'ready' && '获取验证码'}
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// 定义后端服务URL和OAuth2配置
|
||||
const _api_base_url = process.env.NEXT_PUBLIC_API_BASE_URL
|
||||
if (!_api_base_url) throw new Error('NEXT_PUBLIC_API_BASE_URL is not set')
|
||||
const _api_base_url = process.env.API_BASE_URL
|
||||
if (!_api_base_url) throw new Error('API_BASE_URL is not set')
|
||||
const API_BASE_URL = _api_base_url
|
||||
|
||||
const _client_id = process.env.CLIENT_ID
|
||||
|
||||
@@ -8,4 +8,5 @@ export type Batch = {
|
||||
time: string
|
||||
user_id: number
|
||||
prov: string
|
||||
city: string
|
||||
}
|
||||
|
||||
10
src/lib/models/product-sku.ts
Normal file
10
src/lib/models/product-sku.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type ProductSku = {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
price: string
|
||||
price_min: string
|
||||
product_id: number
|
||||
discount_id: number
|
||||
status: number
|
||||
}
|
||||
11
src/lib/models/product.ts
Normal file
11
src/lib/models/product.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {ProductSku} from './product-sku'
|
||||
|
||||
export type Product = {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
description: string
|
||||
sort: number
|
||||
status: number
|
||||
skus?: ProductSku[]
|
||||
}
|
||||
Reference in New Issue
Block a user