16 Commits

Author SHA1 Message Date
Eamon-meng
5607217625 动态生成购买套餐 & 取消初次进后台修改密码的弹窗 & 添加总折扣字段 & 发布v1.5.0版本 2026-04-16 14:41:42 +08:00
319baea5e8 修复环境变量问题 2026-04-14 11:34:28 +08:00
Eamon-meng
9a8a1826c9 发布v1.4.0版本 2026-04-13 11:33:19 +08:00
Eamon-meng
c2a0310ee5 修复未认证提取ip的接口调用户会话 2026-04-13 10:23:19 +08:00
Eamon-meng
8ee8feb2bf 修改构建脚本路径 2026-04-02 17:56:48 +08:00
Eamon-meng
1e090f5c88 更新配置文件修复构建问题 2026-04-02 17:52:15 +08:00
Eamon-meng
665ce79e1d 发布v1.3.0版本 2026-04-02 17:35:51 +08:00
Eamon-meng
93110954bb 更新订单详情显示字段名称 2026-04-02 14:08:32 +08:00
Eamon-meng
8ce5f99a24 开启充值和余额支付功能 2026-03-31 16:11:47 +08:00
Eamon-meng
e27869fb4a 重新计算价格显示 2026-03-31 16:11:46 +08:00
Eamon-meng
01c4afd209 更新发布v1.2.3版本 2026-03-31 16:11:46 +08:00
2a959fa9cf 优化客户端请求机制 2026-03-31 16:09:43 +08:00
Eamon-meng
d9f267e257 调整桌面端产品订购的菜单栏布局 2026-03-14 18:00:27 +08:00
Eamon-meng
83530d7f1e 修改移动端菜单栏侧边栏布局 2026-03-14 15:25:23 +08:00
Eamon-meng
b2c36196b4 修复实名认证阶段的问题 2026-03-13 18:26:23 +08:00
Eamon-meng
d2d6c1709c 我的套餐新增状态筛选字段 2026-03-13 18:12:22 +08:00
49 changed files with 1255 additions and 518 deletions

4
.env.example Normal file
View File

@@ -0,0 +1,4 @@
# 开发环境配置
API_BASE_URL=http://192.168.3.42:8080
CLIENT_ID=web
CLIENT_SECRET=web

View File

@@ -122,7 +122,7 @@ type UserProfile = ExtraResp<typeof getProfile>
## 环境变量 ## 环境变量
需要配置: 需要配置:
- `NEXT_PUBLIC_API_BASE_URL` - 后端 API 地址 - `API_BASE_URL` - 后端 API 地址
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据 - `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
## 部署 ## 部署

2
.gitignore vendored
View File

@@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env
# vercel # vercel
.vercel .vercel

View File

@@ -16,6 +16,7 @@ COPY . .
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
RUN mv .env.example .env
RUN bun run build RUN bun run build
# 生产阶段 # 生产阶段
@@ -31,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN rm .env
USER nextjs USER nextjs

View File

@@ -1,31 +1,11 @@
## TODO ## TODO
- 导航栏
- 账单页面
- 实名认证响应
分离公共 api 接口 env 定义 分离公共 api 接口 env 定义
统一前端基础库类型api 统一前端基础库类型api
购买页固定套餐 购买页固定套餐
优惠问题
### 禁止直接依赖 form
`\[(.*,)?form(,.*)?\]`
### 次要
业务定制页面每月需求用量,可选项需要确认是否合理
帮助中心文档优化
购买与提取手机端优化,尽量一页展示全部
全部替换封装时间范围组件,检查结束时间字段手机端适配问题(需要尾部对齐)
树组件优化 树组件优化
## 目录结构 ## 目录结构

View File

@@ -1,6 +1,6 @@
{ {
"name": "lanhu-web", "name": "lanhu-web",
"version": "1.2.2", "version": "1.5.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack", "dev": "next dev -H 0.0.0.0 --turbopack",

View File

@@ -9,8 +9,8 @@ if ($confrim -ne "y") {
exit 0 exit 0
} }
docker build -t 43.226.58.254:53000/lanhu/web:latest . docker build -t repo.lanhuip.com:8554/lanhu/web:latest .
docker build -t 43.226.58.254:53000/lanhu/web:$($args[0]) . docker build -t repo.lanhuip.com:8554/lanhu/web:$($args[0]) .
docker push 43.226.58.254:53000/lanhu/web:latest docker push repo.lanhuip.com:8554/lanhu/web:latest
docker push 43.226.58.254:53000/lanhu/web:$($args[0]) docker push repo.lanhuip.com:8554/lanhu/web:$($args[0])

View File

@@ -2,15 +2,7 @@
import {cookies} from 'next/headers' import {cookies} from 'next/headers'
import {ApiResponse, UnauthorizedError} from '@/lib/api' import {ApiResponse, UnauthorizedError} from '@/lib/api'
import {User} from '@/lib/models' import {User} from '@/lib/models'
import {callByDevice, callByUser} from '@/actions/base' import {callByDevice, callByUser, TokenResp} from '@/actions/base'
type TokenResp = {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope?: string
}
export type LoginMode = 'phone_code' | 'password' export type LoginMode = 'phone_code' | 'password'

View File

@@ -1,8 +1,23 @@
'use server' 'use server'
import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api' import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api'
import {add, isBefore} from 'date-fns'
import {cookies, headers} from 'next/headers' import {cookies, headers} from 'next/headers'
import {cache} from 'react' import {cache} from 'react'
import {redirect} from 'next/navigation'
export type TokenResp = {
access_token: string
refresh_token: string
expires_in: number
token_type: string
scope?: string
}
export async function getApiUrl() {
return {
success: true,
data: API_BASE_URL,
} satisfies ApiResponse<string>
}
// ====================== // ======================
// public // public
@@ -26,6 +41,9 @@ const _callPublic = cache(async <R = undefined>(
// device // device
// ====================== // ======================
let token: string | null = null
let token_expire: Date | null = null
async function callByDevice<R = undefined>( async function callByDevice<R = undefined>(
endpoint: string, endpoint: string,
data: unknown, data: unknown,
@@ -37,18 +55,20 @@ const _callByDevice = cache(async <R = undefined>(
endpoint: string, endpoint: string,
data?: string, data?: string,
): Promise<ApiResponse<R>> => { ): Promise<ApiResponse<R>> => {
// 获取设备令牌 if (!token || !token_expire || isBefore(token_expire, new Date())) {
if (!CLIENT_ID || !CLIENT_SECRET) { const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
return { const resp = await call<TokenResp>(`${API_BASE_URL}/api/auth/token`, JSON.stringify({
success: false, grant_type: 'client_credentials',
status: 401, }), `Basic ${basic}`)
message: '未配置 CLIENT_ID 或 CLIENT_SECRET', if (!resp.success) {
return resp
} }
token = resp.data.access_token
token_expire = add(new Date(), {seconds: resp.data.expires_in})
} }
const token = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64url')
// 发起请求 // 发起请求
return call(`${API_BASE_URL}${endpoint}`, data, `Basic ${token}`) return call(`${API_BASE_URL}${endpoint}`, data, `Bearer ${token}`)
}) })
// ====================== // ======================
@@ -149,24 +169,6 @@ async function call<R = undefined>(url: string, body: RequestInit['body'], auth?
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`) throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
} }
async function postCall<R = undefined>(rawResp: Promise<ApiResponse<R>>) {
const header = await headers()
const pathname = header.get('x-pathname') || '/'
const resp = await rawResp
// 重定向到登录页
const match = [
RegExp(`^/admin.*`),
].some(item => item.test(pathname))
if (match && !resp.success && resp.status === 401) {
console.log('🚗🚗🚗🚗🚗 非正常重定向 🚗🚗🚗🚗🚗')
redirect('/login?force=true')
}
return resp
}
// 导出 // 导出
export { export {
callPublic, callPublic,

View File

@@ -32,5 +32,5 @@ export async function createChannels(params: {
city?: string city?: string
isp?: number isp?: number
}) { }) {
return callPublic<CreateChannelsResp[]>('/api/channel/create', params) return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
} }

12
src/actions/product.ts Normal file
View 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)
}

View File

@@ -24,6 +24,7 @@ export async function listResourceShort(props: {
size: number size: number
resource_no?: string resource_no?: string
type?: number type?: number
status?: number
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -37,6 +38,7 @@ export async function listResourceLong(props: {
size: number size: number
resource_no?: string resource_no?: string
type?: number type?: number
status?: number
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -89,7 +91,7 @@ export async function payClose(props: {
export async function getPrice(props: CreateResourceReq) { export async function getPrice(props: CreateResourceReq) {
return callByDevice<{ return callByDevice<{
price: string price: string
discounted_price?: string actual?: string
discounted?: number discounted?: string
}>('/api/resource/price', props) }>('/api/resource/price', props)
} }

View File

@@ -38,16 +38,16 @@ export async function Identify(props: {
} }
export async function update(props: { export async function update(props: {
username: string username?: string
email: string email?: string
contact_qq: string contact_qq?: string
contact_wechat: string contact_wechat?: string
}) { }) {
return await callByUser('/api/user/update', props) return await callByUser('/api/user/update', props)
} }
export async function updatePassword(props: { export async function updatePassword(props: {
phone: string // phone: string
code: string code: string
password: string password: string
}) { }) {

View File

@@ -1,6 +1,6 @@
'use server' 'use server'
import {ApiResponse} from '@/lib/api' import {ApiResponse} from '@/lib/api'
import {callByDevice} from '@/actions/base' import {callByDevice, callByUser} from '@/actions/base'
import {getCap} from '@/lib/cap' import {getCap} from '@/lib/cap'
export async function sendSMS(props: { export async function sendSMS(props: {
@@ -28,7 +28,7 @@ export async function sendSMS(props: {
} }
// 请求发送短信 // 请求发送短信
return await callByDevice('/api/auth/verify/sms', { return await callByDevice('/api/verify/sms', {
phone: props.phone, phone: props.phone,
purpose: 0, purpose: 0,
}) })
@@ -38,3 +38,35 @@ export async function sendSMS(props: {
throw new Error('验证码验证失败', {cause: error}) 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})
}
}

View File

@@ -34,20 +34,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard() { export default function LoginCard() {
const router = useRouter() const router = useRouter()
const refreshProfile = useProfileStore(store => store.refreshProfile) 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 [submitting, setSubmitting] = useState(false)
const updateLoginMode = (mode: LoginMode) => { const updateLoginMode = (mode: LoginMode) => {
sessionStorage.setItem('login_mode', mode) sessionStorage.setItem('login_mode', mode)
} }
useEffect(() => {
const mode = sessionStorage.getItem('login_mode')
if (mode) {
setMode(mode as LoginMode)
}
}, [])
const form = useForm<LoginSchema>({ const form = useForm<LoginSchema>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema), resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: { defaultValues: {
@@ -55,7 +47,16 @@ export default function LoginCard() {
password: '', password: '',
remember: false, 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) => { const handler = form.handleSubmit(async (data) => {
setSubmitting(true) setSubmitting(true)
try { try {
@@ -93,13 +94,14 @@ export default function LoginCard() {
<Tabs <Tabs
value={mode} value={mode}
onValueChange={(val) => { onValueChange={(val) => {
setMode(val as typeof mode) setMode(val as LoginMode)
form.reset({username: form.getValues('username'), password: '', remember: false}) form.reset({username: '', password: '', remember: false})
form.clearErrors()
}} }}
className="mb-6"> className="mb-6">
<TabsList className="w-full p-0 bg-white"> <TabsList className="w-full p-0 bg-white">
<Tab value="password"></Tab> <Tab value="password"></Tab>
<Tab value="phone_code"></Tab> <Tab value="phone_code">/</Tab>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}> <Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
@@ -124,6 +126,7 @@ export default function LoginCard() {
className="h-10" className="h-10"
placeholder="请输入验证码" placeholder="请输入验证码"
autoComplete="one-time-code" autoComplete="one-time-code"
disabled={submitting}
/> />
<SendMsgByUsername/> <SendMsgByUsername/>
</div> </div>
@@ -137,6 +140,7 @@ export default function LoginCard() {
placeholder="至少6位密码需包含字母和数字" placeholder="至少6位密码需包含字母和数字"
autoComplete="current-password" autoComplete="current-password"
minLength={6} minLength={6}
disabled={submitting}
/> />
<button <button
type="button" type="button"
@@ -162,6 +166,7 @@ export default function LoginCard() {
id={id} id={id}
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
disabled={submitting}
/> />
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<Label></Label> <Label></Label>

View File

@@ -17,8 +17,8 @@ export default function HelpMenu() {
icon={h01} icon={h01}
title="提取 IP" title="提取 IP"
items={[ items={[
{lead: '短效 IP 提取', href: '/collect?type=short'}, {lead: '短效/长效 IP 提取', href: '/collect?type=short'},
{lead: '长效 IP 提取', href: '/collect?type=long'}, // {lead: '长效 IP 提取', href: '/collect?type=long'},
]} ]}
/> />
<Column <Column

View File

@@ -1,31 +1,296 @@
import ProductMenu from './menu-product' 'use client'
import HelpMenu from './menu-help'
import SolutionMenu from './menu-solution' import {useContext, useState} from 'react'
import {useRouter} from 'next/navigation'
import {X} from 'lucide-react'
import {HeaderContext} from './common'
import Image, {StaticImageData} from 'next/image'
import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg'
import s01 from '@/assets/header/solution/01.svg'
import s02 from '@/assets/header/solution/02.svg'
import s03 from '@/assets/header/solution/03.svg'
import s04 from '@/assets/header/solution/04.svg'
import s05 from '@/assets/header/solution/05.svg'
import s06 from '@/assets/header/solution/06.svg'
import s07 from '@/assets/header/solution/07.svg'
import s08 from '@/assets/header/solution/08.svg'
import h01 from '@/assets/header/help/01.svg'
import h02 from '@/assets/header/help/02.svg'
import h03 from '@/assets/header/help/03.svg'
import {merge} from '@/lib/utils'
import Link from 'next/link'
import logo from '@/assets/logo.webp'
export type MobileMenuProps = {} export type MobileMenuProps = {}
export default function MobileMenu(props: MobileMenuProps) { export default function MobileMenu(props: MobileMenuProps) {
const ctx = useContext(HeaderContext)
const router = useRouter()
const [productTab, setProductTab] = useState<'domestic' | 'oversea'>('domestic')
if (!ctx) {
throw new Error(`HeaderContext not found`)
}
const navigate = (href: string) => {
ctx.setMenu(false)
router.push(href)
}
return ( return (
<div className="flex flex-col gap-8"> <div className="h-full flex flex-col bg-white">
<div className="flex flex-col gap-4"> <div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
<ProductMenu/> {/* logo */}
<Link href="/" className="flex items-center">
<Image src={logo} alt="logo" height={40} className="translate-y-0.5"/>
</Link>
<button
type="button"
className="rounded-md p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-50 transition-colors"
onClick={() => ctx.setMenu(false)}
aria-label="关闭菜单"
>
<X className="h-5 w-5"/>
</button>
</div> </div>
<div className="flex flex-col gap-4">
<MenuTitle title="帮助中心"/> <div className="flex-1 overflow-y-auto px-4 py-6 space-y-8">
<HelpMenu/> <div className="space-y-3">
</div> <h3 className="text-sm font-semibold text-gray-500 tracking-wide">
<div className="flex flex-col gap-4">
<MenuTitle title="业务场景"/> </h3>
<SolutionMenu/> <div className="flex rounded-lg bg-gray-100">
<button
className={merge(
'flex-1 py-2.5 text-sm font-medium rounded-md transition-all',
productTab === 'domestic'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900',
)}
onClick={() => setProductTab('domestic')}
>
</button>
<button
className={merge(
'flex-1 py-2.5 text-sm font-medium rounded-md transition-all',
productTab === 'oversea'
? 'bg-white text-blue-600 shadow-sm'
: 'text-gray-600 hover:text-gray-900',
)}
onClick={() => setProductTab('oversea')}
>
</button>
</div>
{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}
/>
<ProductItem
icon={custom}
label="优质/企业/精选IP"
badge="专属定制"
href="/custom"
onNavigate={navigate}
/>
</div>
)}
{productTab === 'oversea' && (
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
<p className="text-sm text-blue-600 text-center">
线
</p>
</div>
)}
</div>
<MenuSection title="业务场景">
<div className="grid grid-cols-2 gap-3">
<SolutionItem
icon={s01}
label="数据采集"
href="/data-capture"
onNavigate={navigate}
/>
<SolutionItem
icon={s02}
label="电商运营"
href="/e-commerce"
onNavigate={navigate}
/>
<SolutionItem
icon={s03}
label="市场调研"
href="/market-research"
onNavigate={navigate}
/>
<SolutionItem
icon={s04}
label="SEO优化"
href="/seo-optimization"
onNavigate={navigate}
/>
<SolutionItem
icon={s05}
label="社交媒体"
href="/social-media"
onNavigate={navigate}
/>
<SolutionItem
icon={s06}
label="广告投放"
href="/advertising"
onNavigate={navigate}
/>
<SolutionItem
icon={s07}
label="账号管理"
href="/account-management"
onNavigate={navigate}
/>
<SolutionItem
icon={s08}
label="网络测试"
href="/network-testing"
onNavigate={navigate}
/>
</div>
</MenuSection>
<MenuSection title="帮助中心">
<div className="space-y-2">
<HelpItem
icon={h01}
label="短效IP提取"
onClick={() => navigate('/collect?type=short')}
/>
<HelpItem
icon={h02}
label="操作指南"
onClick={() => navigate('/docs/profile-settings')}
/>
<HelpItem
icon={h03}
label="平台教程"
onClick={() => navigate('/docs/ios-proxy')}
/>
</div>
</MenuSection>
<div className="space-y-2 pt-2">
<OtherLink
label="业务定制"
href="/custom"
onNavigate={navigate}
/>
</div>
</div> </div>
</div> </div>
) )
} }
function MenuTitle(props: {title: string}) { function MenuSection(props: {title: string, children: React.ReactNode}) {
return ( return (
<h3 className="text-xl text-weak px-4"> <div className="space-y-3">
{props.title} <h3 className="text-sm font-semibold text-gray-500 tracking-wide">
</h3> {props.title}
</h3>
{props.children}
</div>
)
}
function ProductItem(props: {
icon: StaticImageData
label: string
badge?: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 rounded-lg border border-gray-100 bg-white px-4 py-3 text-left transition-all hover:border-blue-200 hover:shadow-sm"
onClick={() => props.onNavigate(props.href)}
>
<div className="shrink-0 w-8 h-8 bg-linear-to-br from-blue-50 to-cyan-50 rounded-lg flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="flex-1 font-medium text-sm text-gray-900">{props.label}</span>
{props.badge && (
<span className="text-xs text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.badge}
</span>
)}
</button>
)
}
function SolutionItem(props: {
icon: StaticImageData
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="flex flex-col items-center gap-2 p-3 rounded-lg border border-gray-100 hover:border-blue-200 hover:bg-blue-50/50 transition-all"
onClick={() => props.onNavigate(props.href)}
>
<div className="w-10 h-10 bg-linear-to-br from-blue-50 to-cyan-50 rounded-full flex items-center justify-center">
<Image src={props.icon} alt="" width={20} height={20} className="opacity-80"/>
</div>
<span className="text-xs font-medium text-gray-700">{props.label}</span>
</button>
)
}
function HelpItem(props: {
icon: StaticImageData
label: string
onClick: () => void
}) {
return (
<button
type="button"
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-lg hover:bg-gray-50 transition-colors"
onClick={props.onClick}
>
<Image src={props.icon} alt="" width={20} height={20} className="opacity-70"/>
<span className="text-sm text-gray-700">{props.label}</span>
</button>
)
}
function OtherLink(props: {
label: string
href: string
onNavigate: (href: string) => void
}) {
return (
<button
type="button"
className="w-full flex items-center px-3 py-2.5 text-sm text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
onClick={() => props.onNavigate(props.href)}
>
{props.label}
</button>
) )
} }

View File

@@ -1,13 +1,17 @@
'use client' 'use client'
import {ReactNode, useContext, useState} from 'react' import {ReactNode, useContext, useEffect, useState} from 'react'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Image, {StaticImageData} from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import prod from '@/assets/header/product/prod.svg' import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg' import custom from '@/assets/header/product/custom.svg'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {FragmentTitle, HeaderContext} from './common' import {HeaderContext} from './common'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type ProductItem = Product
type TabType = 'domestic' | 'oversea' type TabType = 'domestic' | 'oversea'
export default function ProductMenu() { export default function ProductMenu() {
@@ -52,33 +56,51 @@ export function Tab(props: {
} }
export function Domestic(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 ( return (
<section role="tabpanel" className="flex-auto flex flex-col lg:flex-row justify-evenly gap-3 lg:gap-0"> <section role="tabpanel" className="flex-auto">
<div className="w-full lg:w-64 flex flex-col"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<FragmentTitle img={prod} text="短效 IP"/> <div className="grid grid-cols-1 gap-3">
<DomesticLink {shortProduct && (
label="短效动态 IP" <ProductCard
desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。" icon={prod}
href="/product?type=short" label="短效动态 IP"
discount={45} discount="最低4.5折"
/> desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
</div> href={`/product?type=${shortProduct.code}`}
<div className="w-full lg:w-64 flex flex-col"> />
<FragmentTitle img={prod} text="长效 IP"/> )}
<DomesticLink {longProduct && (
label="长效动态 IP" <ProductCard
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。" icon={prod}
href="/product?type=long" label="长效动态 IP"
discount={45} discount="最低4.5折"
/> desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
</div> href={`/product?type=${longProduct.code}`}
<div className="w-full lg:w-64 flex flex-col"> />
<FragmentTitle img={custom} text="业务定制"/> )}
<DomesticLink </div>
label="优质/企业/精选IP" <div className="flex flex-col gap-3">
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持" <ProductCard
href="/custom" icon={custom}
/> label="业务定制"
discount="1V1 专属服务"
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持"
href="/custom"
/>
</div>
</div> </div>
</section> </section>
) )
@@ -92,11 +114,12 @@ export function Oversea(props: {}) {
) )
} }
export function DomesticLink(props: { export function ProductCard(props: {
icon: StaticImageData
label: string label: string
discount: string
desc: string desc: string
href: string href: string
discount?: number
}) { }) {
const router = useRouter() const router = useRouter()
const ctx = useContext(HeaderContext) const ctx = useContext(HeaderContext)
@@ -116,18 +139,24 @@ export function DomesticLink(props: {
`transition-colors duration-150 ease-in-out`, `transition-colors duration-150 ease-in-out`,
`p-4 rounded-lg flex flex-col gap-1 hover:bg-blue-50`, `p-4 rounded-lg flex flex-col gap-1 hover:bg-blue-50`,
)} )}
onClick={onClick}> onClick={onClick}
<p className="flex gap-2"> >
<span>{props.label}</span> <div className="flex items-start gap-3">
{props.discount && ( <div className="flex-none">
<span className="text-orange-500 text-xs text-light px-2 py-1 bg-orange-50 rounded-full"> <Image src={props.icon} alt="" width={30} height={30}/>
{props.discount}% </div>
</span> <div className="flex-1">
)} <div className="flex items-center justify-between gap-3">
</p> <span className="font-bold">{props.label}</span>
<p className="text-gray-400 text-sm"> <span className="text-xs font-medium text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.desc} {props.discount}
</p> </span>
</div>
<div className="mt-2 text-sm text-gray-400 space-y-1">
{props.desc}
</div>
</div>
</div>
</Link> </Link>
) )
} }

View File

@@ -16,7 +16,7 @@ export default function CollectPage(props: CollectPageProps) {
// </Wrap> // </Wrap>
// </main> // </main>
<HomePage path={[ <HomePage path={[
{label: '短效IP 提取', href: '/collect'}, {label: '短效/长效IP 提取', href: '/collect'},
]}> ]}>
<Wrap> <Wrap>
<Extract/> <Extract/>

View File

@@ -164,10 +164,10 @@ export default function Header(props: HeaderProps) {
</Wrap> </Wrap>
</div> </div>
{/* 下拉菜单 */} {/* 桌面端下拉菜单 */}
<div <div
className={merge( className={merge(
`flex-auto overflow-auto lg:flex-none lg:basis-72 shadow-lg box-content`, `hidden lg:flex flex-auto overflow-auto lg:flex-none lg:basis-72 shadow-lg box-content`,
`bg-[#fffe] backdrop-blur-sm`, `bg-[#fffe] backdrop-blur-sm`,
`transition-[opacity,padding] transition-discrete duration-200 ease-in-out`, `transition-[opacity,padding] transition-discrete duration-200 ease-in-out`,
menu menu
@@ -180,7 +180,20 @@ export default function Header(props: HeaderProps) {
{pages[page]} {pages[page]}
</div> </div>
{/* 遮罩层 */} {/* 移动端侧边栏菜单 */}
{menu && page === 3 && (
<div className="lg:hidden fixed inset-0 z-20 flex">
<div
className="flex-1 bg-black/40"
onPointerDown={enterMenuMask}
/>
<div className="w-72 max-w-[80vw] bg-white shadow-xl overflow-y-auto">
{pages[3]}
</div>
</div>
)}
{/* 遮罩层(桌面端) */}
<div className="flex-auto" onPointerEnter={enterMenuMask}/> <div className="flex-auto" onPointerEnter={enterMenuMask}/>
</HeaderContext.Provider> </HeaderContext.Provider>
@@ -269,7 +282,7 @@ function ProfileOrLogin() {
<span></span> <span></span>
</Link> </Link>
<Link <Link
href="/login" href="/login?tab=sms"
className={[ 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`, `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`, `transition-colors duration-200 ease-in-out`,

View File

@@ -2,7 +2,7 @@ import {ReactNode} from 'react'
import Header from './header' import Header from './header'
import Footer from './footer' import Footer from './footer'
import Script from 'next/script' import Script from 'next/script'
import {MessageCircleMoreIcon} from 'lucide-react'
export type HomeLayoutProps = { export type HomeLayoutProps = {
children: ReactNode children: ReactNode
} }
@@ -19,7 +19,21 @@ export default function HomeLayout(props: HomeLayoutProps) {
{/* 页脚 */} {/* 页脚 */}
<Footer/> <Footer/>
<Script id="qd2852138148beb7882a4a6a3e5ff5b569436003e7dc" src="https://wp.qiye.qq.com/qidian/2852138148/beb7882a4a6a3e5ff5b569436003e7dc" async defer></Script> {/* <Script id="qd2852138148beb7882a4a6a3e5ff5b569436003e7dc" src="https://wp.qiye.qq.com/qidian/2852138148/beb7882a4a6a3e5ff5b569436003e7dc" async defer></Script> */}
<a
href="https://wpa1.qq.com/K0s0cvwf?_type=wpa&qidian=true"
target="_blank"
rel="noopener noreferrer"
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-blue-600 hover:bg-blue-700 shadow-lg hover:shadow-xl transition-all duration-300 hover:scale-110 flex items-center justify-center group"
aria-label="在线客服"
>
<span className="text-white font-bold text-lg"></span>
<span className="text-white font-bold text-lg"></span>
<span className="absolute -top-2 -right-2 flex items-center justify-center w-6 h-6 bg-red-500 rounded-full text-white shadow-md">
<MessageCircleMoreIcon size={14}/>
</span>
</a>
</div> </div>
) )
} }

View File

@@ -59,7 +59,7 @@ export default async function UserCenter() {
</> </>
)} )}
</div> </div>
{/* <div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="text-sm text-weak"></h4> <h4 className="text-sm text-weak"></h4>
<div className="flex justify-between items-baseline"> <div className="flex justify-between items-baseline">
<p className="text-xl text-accent"> <p className="text-xl text-accent">
@@ -68,7 +68,7 @@ export default async function UserCenter() {
</p> </p>
<RechargeModal/> <RechargeModal/>
</div> </div>
</div> */} </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h4 className="text-sm text-weak"></h4> <h4 className="text-sm text-weak"></h4>
<div className="flex justify-around gap-2"> <div className="flex justify-around gap-2">

View File

@@ -2,7 +2,6 @@
import {ReactNode, Suspense, use, useState} from 'react' import {ReactNode, Suspense, use, useState} from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog' import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import UserCenter from '@/components/composites/user-center' import UserCenter from '@/components/composites/user-center'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
@@ -75,6 +74,7 @@ export function Content(props: {children: ReactNode}) {
} }
function ContentResolved() { function ContentResolved() {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
if (profile) if (profile)
return ( return (
<> <>
@@ -82,10 +82,10 @@ function ContentResolved() {
triggerClassName="hidden" triggerClassName="hidden"
defaultOpen={!profile.id_token} defaultOpen={!profile.id_token}
/> />
<ChangePasswordDialog {/* <ChangePasswordDialog
triggerClassName="hidden" triggerClassName="hidden"
defaultOpen={!profile.has_password} defaultOpen={!profile.has_password}
/> /> */}
</> </>
) )
} }

View File

@@ -98,6 +98,16 @@ export default function IdentifyPage(props: IdentifyPageProps) {
const profile = useProfileStore(store => store.profile) const profile = useProfileStore(store => store.profile)
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
// 重置认证流程
const handleDialogOpenChange = async (open: boolean) => {
setOpenDialog(open)
if (!open) {
setStep('form')
setTarget('')
await refreshProfile()
}
}
// ====================== // ======================
// render // render
// ====================== // ======================
@@ -125,7 +135,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
</div> </div>
<Suspense> <Suspense>
<IfNotIdentofy> <IfNotIdentofy>
<Dialog open={openDialog} onOpenChange={setOpenDialog}> <Dialog open={openDialog} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-full"></Button> <Button className="w-full"></Button>
</DialogTrigger> </DialogTrigger>
@@ -165,11 +175,8 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<div className="flex flex-col gap-4 items-center"> <div className="flex flex-col gap-4 items-center">
<canvas ref={canvas} width={256} height={256}/> <canvas ref={canvas} width={256} height={256}/>
<p className="text-sm text-gray-600"></p> <p className="text-sm text-gray-600"></p>
<Button onClick={async () => { <Button onClick={() => handleDialogOpenChange(false)}>
await refreshProfile()
setOpenDialog(false)
}}>
</Button> </Button>
</div> </div>
)} )}
@@ -223,7 +230,7 @@ export default function IdentifyPage(props: IdentifyPageProps) {
<p className="flex gap-2 items-center justify-between w-56 self-center"> <p className="flex gap-2 items-center justify-between w-56 self-center">
<span className="flex gap-2"> <span className="flex gap-2">
<span className="bg-primary/25 text-primary w-8 h-8 rounded-full flex items-center justify-center">03</span> <span className="bg-primary/25 text-primary w-8 h-8 rounded-full flex items-center justify-center">03</span>
<span></span> <span></span>
</span> </span>
<Image alt="步骤配图" src={step3}/> <Image alt="步骤配图" src={step3}/>
</p> </p>

View File

@@ -78,8 +78,8 @@ export function BasicForm(props: {
}) { }) {
const schema = z.object({ const schema = z.object({
username: z.string(), username: z.string(),
email: z.string(), email: z.string().email('请输入正确的邮箱格式').or(z.literal('')),
contact_qq: z.string(), contact_qq: z.string().regex(/^\d*$/, 'QQ号只能包含数字'),
contact_wechat: z.string(), contact_wechat: z.string(),
}) })
type Schema = z.infer<typeof schema> type Schema = z.infer<typeof schema>

View File

@@ -3,16 +3,17 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {CheckCircle} from 'lucide-react' import {CheckCircle} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import banner from '@/app/admin/identify/_assets/banner.webp' 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 {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import {Aftersale, BasicForm} from './clients' import {Aftersale, BasicForm} from './clients'
import {Button} from '@/components/ui/button'
import Link from 'next/link'
export type ProfilePageProps = {} export type ProfilePageProps = {}
export default async function ProfilePage(props: ProfilePageProps) { export default async function ProfilePage(props: ProfilePageProps) {
const profile = await getProfile() const profile = await getProfile()
if (!profile.success) { if (!profile.success) {
return ( return (
<Page> <Page>
@@ -22,6 +23,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
} }
const user = profile.data const user = profile.data
return ( return (
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col"> <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"> <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"> <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 "> <Card className="flex-1 ">
<CardHeader> <CardHeader>
<CardTitle className="font-normal"></CardTitle> <CardTitle className="font-normal"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8"> <CardContent className="flex-auto flex justify-between items-center px-8">
<p>{user.phone}</p> <p>{user.phone}</p>
<ChangePasswordDialog triggerClassName="w-24 h-9"/> <ChangePasswordDialog triggerClassName="w-24 h-9" phone={user?.phone}/>
</CardContent> </CardContent>
</Card> </Card>
@@ -66,10 +55,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
? ( ? (
<> <>
<p className="text-sm">使</p> <p className="text-sm">使</p>
<RealnameAuthDialog {/* <RealnameAuthDialog
defaultOpen={!user.id_token} // defaultOpen={!user.id_token}
triggerClassName="w-24" triggerClassName="w-24"
/> /> */}
<Link href="/admin/identify">
<Button></Button>
</Link>
</> </>
) )
: ( : (

View File

@@ -164,16 +164,21 @@ export default function RecordPage(props: RecordPageProps) {
cell: ({row}) => <div>{row.original.prov}</div>, cell: ({row}) => <div>{row.original.prov}</div>,
accessorKey: 'prov', accessorKey: 'prov',
}, },
{
header: '城市',
cell: ({row}) => <div>{row.original.city}</div>,
accessorKey: 'city',
},
{ {
header: '提取数量', header: '提取数量',
cell: ({row}) => <div>{row.original.count}</div>, cell: ({row}) => <div>{row.original.count}</div>,
accessorKey: 'count', accessorKey: 'count',
}, },
{ // {
header: '资源数量', // header: '资源数量',
cell: ({row}) => <div>{row.original.resource_id}</div>, // cell: ({row}) => <div>{row.original.resource_id}</div>,
accessorKey: 'resource_id', // accessorKey: 'resource_id',
}, // },
{ {
header: '提取时间', header: '提取时间',
cell: ({row}) => { cell: ({row}) => {

View File

@@ -12,6 +12,7 @@ import {Eraser, Search} from 'lucide-react'
export interface ResourceFilterValues { export interface ResourceFilterValues {
resource_no: string resource_no: string
type: 'expire' | 'quota' | 'all' type: 'expire' | 'quota' | 'all'
status: '0' | '1' | '2'
create_after?: Date create_after?: Date
create_before?: Date create_before?: Date
expire_after?: Date expire_after?: Date
@@ -41,13 +42,27 @@ export default function ResourceFilter({form, onSubmit, onReset}: ResourceFilter
<SelectValue placeholder="选择套餐类型"/> <SelectValue placeholder="选择套餐类型"/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all" ></SelectItem>
<SelectItem value="expire"></SelectItem> <SelectItem value="expire"></SelectItem>
<SelectItem value="quota"></SelectItem> <SelectItem value="quota"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
</FormField> </FormField>
<FormField name="status" label={<span className="text-sm"></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="w-24 h-9">
<SelectValue placeholder="选择状态"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="0"></SelectItem>
<SelectItem value="1"></SelectItem>
<SelectItem value="2"></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label className="text-sm"></Label> <Label className="text-sm"></Label>
<div className="flex items-center"> <div className="flex items-center">

View File

@@ -17,6 +17,7 @@ import {
ExpireBadge, ExpireBadge,
formatDateTime, formatDateTime,
getTodayUsage, getTodayUsage,
isValidResourcestatus,
isValidResourceType, isValidResourceType,
ResourceTypeBadge, ResourceTypeBadge,
} from './utils' } from './utils'
@@ -24,6 +25,7 @@ import {
const filterSchema = zod.object({ const filterSchema = zod.object({
resource_no: zod.string().optional().default(''), resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'), type: zod.enum(['expire', 'quota', 'all']).default('all'),
status: zod.enum(['0', '1', '2']).default('1'),
create_after: zod.date().optional(), create_after: zod.date().optional(),
create_before: zod.date().optional(), create_before: zod.date().optional(),
expire_after: zod.date().optional(), expire_after: zod.date().optional(),
@@ -47,12 +49,13 @@ export default function ResourceList({resourceType}: ResourceListProps) {
// 从 URL 参数初始化筛选条件 // 从 URL 参数初始化筛选条件
const params = useSearchParams() const params = useSearchParams()
const paramType = params.get('type') const paramType = params.get('type')
const paramStatus = params.get('status')
const form = useForm<ResourceFilterValues>({ const form = useForm<ResourceFilterValues>({
resolver: zodResolver(filterSchema), resolver: zodResolver(filterSchema),
defaultValues: { defaultValues: {
resource_no: params.get('resource_no') || '', resource_no: params.get('resource_no') || '',
type: isValidResourceType(paramType) ? paramType : 'all', type: isValidResourceType(paramType) ? paramType : 'all',
status: isValidResourcestatus(paramStatus) ? paramStatus : '1',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined, create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined, create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined, expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
@@ -71,6 +74,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
expire: 1, expire: 1,
quota: 2, quota: 2,
}[getValues('type')] }[getValues('type')]
const status = getValues('status')
const create_after = getValues('create_after') const create_after = getValues('create_after')
const create_before = getValues('create_before') const create_before = getValues('create_before')
const expire_after = getValues('expire_after') const expire_after = getValues('expire_after')
@@ -82,6 +86,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
page, page,
size, size,
type, type,
status: Number(status),
create_after, create_after,
create_before, create_before,
expire_after, expire_after,
@@ -116,6 +121,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const handleReset = () => { const handleReset = () => {
form.reset({ form.reset({
type: 'all', type: 'all',
status: '1',
resource_no: '', resource_no: '',
create_after: undefined, create_after: undefined,
create_before: undefined, create_before: undefined,
@@ -159,7 +165,7 @@ export default function ResourceList({resourceType}: ResourceListProps) {
const live = resourceKey === 'long' const live = resourceKey === 'long'
? (row.original as Resource<2>).long.live ? (row.original as Resource<2>).long.live
: (row.original as Resource<1>).short.live : (row.original as Resource<1>).short.live
return <span>{isLong ? `${live}小时` : `${live / 60}分钟`}</span> return <span>{isLong ? `${live}小时` : `${live}分钟`}</span>
}, },
}, },
{ {
@@ -213,6 +219,17 @@ export default function ResourceList({resourceType}: ResourceListProps) {
header: '开通时间', header: '开通时间',
cell: ({row}) => formatDateTime(row.original.created_at), 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>
)
},
},
] ]
// 短效资源增加到期时间列 // 短效资源增加到期时间列

View File

@@ -7,6 +7,11 @@ export function isValidResourceType(type: string | null): type is 'expire' | 'qu
return type === 'expire' || type === 'quota' || type === 'all' return type === 'expire' || type === 'quota' || type === 'all'
} }
// 状态
export function isValidResourcestatus(status: string | null): status is '0' | '1' | '2' {
return status === '0' || status === '1' || status === '2'
}
// 资源类型徽章 // 资源类型徽章
export function ResourceTypeBadge({type}: {type: number}) { export function ResourceTypeBadge({type}: {type: number}) {
if (type === 1) { if (type === 1) {

View File

@@ -8,6 +8,8 @@ import {LayoutStoreProvider} from '@/components/stores/layout'
import {ClientStoreProvider} from '@/components/stores/client' import {ClientStoreProvider} from '@/components/stores/client'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import Script from 'next/script' import Script from 'next/script'
import {AppStoreProvider} from '@/components/stores/app'
import {getApiUrl} from '@/actions/base'
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -30,12 +32,14 @@ export default async function RootLayout(props: Readonly<{
) )
} }
function StoreProviders(props: {children: ReactNode}) { async function StoreProviders(props: {children: ReactNode}) {
return ( return (
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}> <ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
<LayoutStoreProvider> <LayoutStoreProvider>
<ClientStoreProvider> <ClientStoreProvider>
{props.children} <AppStoreProvider url={await getApiUrl().then(r => r.data)}>
{props.children}
</AppStoreProvider>
</ClientStoreProvider> </ClientStoreProvider>
</LayoutStoreProvider> </LayoutStoreProvider>
</ProfileStoreProvider> </ProfileStoreProvider>

View File

@@ -4,7 +4,7 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTr
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input' 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 {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod' import * as z from 'zod'
import {toast} from 'sonner' import {toast} from 'sonner'
@@ -14,7 +14,6 @@ import dynamic from 'next/dynamic'
// 表单验证规则 // 表单验证规则
const schema = z.object({ const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
captcha: z.string().nonempty('请输入验证码'), captcha: z.string().nonempty('请输入验证码'),
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`), code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
password: z.string().min(6, `密码至少6位`), password: z.string().min(6, `密码至少6位`),
@@ -32,6 +31,7 @@ interface ChangePasswordDialogProps {
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
onSuccess?: () => void onSuccess?: () => void
phone?: string
} }
export function ChangePasswordDialog({ export function ChangePasswordDialog({
@@ -40,12 +40,28 @@ export function ChangePasswordDialog({
defaultOpen, defaultOpen,
onOpenChange, onOpenChange,
onSuccess, onSuccess,
phone,
}: ChangePasswordDialogProps) { }: ChangePasswordDialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen || false) const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
const router = useRouter() const router = useRouter()
const actualOpen = open !== undefined ? open : internalOpen 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>({ const form = useForm<Schema>({
@@ -59,7 +75,6 @@ export function ChangePasswordDialog({
), ),
), ),
defaultValues: { defaultValues: {
phone: '',
captcha: '', captcha: '',
code: '', code: '',
password: '', password: '',
@@ -71,7 +86,6 @@ export function ChangePasswordDialog({
const handler = async (value: Schema) => { const handler = async (value: Schema) => {
try { try {
const resp = await updatePassword({ const resp = await updatePassword({
phone: value.phone,
code: value.code, code: value.code,
password: value.password, password: value.password,
}) })
@@ -108,14 +122,12 @@ export function ChangePasswordDialog({
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
{/* 手机号输入 */} {phone && (
<FormField<Schema> name="phone" label="手机号" className="flex-auto"> <div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
{({field}) => ( <span className="text-sm text-gray-500"></span>
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/> <span className="text-sm font-medium">{phone}</span>
)} </div>
</FormField> )}
{/* 短信验证码 */}
<div className="flex gap-4 items-end"> <div className="flex gap-4 items-end">
<FormField<Schema> name="code" label="验证码" className="flex-auto"> <FormField<Schema> name="code" label="验证码" className="flex-auto">
{({field}) => ( {({field}) => (
@@ -124,15 +136,11 @@ export function ChangePasswordDialog({
</FormField> </FormField>
<SendMsgByPhone/> <SendMsgByPhone/>
</div> </div>
{/* 新密码 */}
<FormField<Schema> name="password" label="新密码" className="flex-auto"> <FormField<Schema> name="password" label="新密码" className="flex-auto">
{({field}) => ( {({field}) => (
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/> <Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
)} )}
</FormField> </FormField>
{/* 确认密码 */}
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto"> <FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
{({field}) => ( {({field}) => (
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/> <Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
@@ -144,8 +152,13 @@ export function ChangePasswordDialog({
theme="outline" theme="outline"
type="button" type="button"
onClick={() => { onClick={() => {
form.reset({
captcha: '',
code: '',
password: '',
confirm_password: '',
})
actualOnOpenChange(false) actualOnOpenChange(false)
form.reset()
}}> }}>
</Button> </Button>
@@ -159,8 +172,7 @@ export function ChangePasswordDialog({
function SendMsgByPhone() { function SendMsgByPhone() {
const {control} = useFormContext<Schema>() const {control} = useFormContext<Schema>()
const phone = useWatch({control, name: 'phone'}) return <SendMsg/>
return <SendMsg phone={phone}/>
} }
const SendMsg = dynamic(() => import('@/components/send-msg'), {ssr: false}) const SendMsg = dynamic(() => import('@/components/updateSend-msg'), {ssr: false})

View File

@@ -337,7 +337,6 @@ function SelectResource() {
setStatus('load') setStatus('load')
try { try {
const resp = await allResource() const resp = await allResource()
if (!resp.success) { if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试') throw new Error('获取套餐失败,请稍后再试')
} }
@@ -381,102 +380,103 @@ function SelectResource() {
<Loader className="animate-spin" size={20}/> <Loader className="animate-spin" size={20}/>
<span></span> <span></span>
</div> </div>
) : resources.map((resource, i) => ( ) : (
<> <>
<SelectItem {resources.map(resource => (
key={`${resource.id}`} <SelectItem
value={String(resource.id)} key={resource.id}
className="p-3"> value={String(resource.id)}
<div className="flex flex-col gap-2 w-72"> className="p-3">
{resource.type === 1 && resource.short.type === 1 && ( <div className="flex flex-col gap-2 w-72">
<> {resource.type === 1 && resource.short.type === 1 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Timer size={20}/> <div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Timer size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
</span> {format(resource.short.expire_at, 'yyyy-MM-dd HH:mm')}
<span>{intlFormatDistance(resource.short.expire_at, new Date())}</span> </span>
</div> <span>{intlFormatDistance(resource.short.expire_at, new Date())}</span>
</> </div>
)} </>
{resource.type === 1 && resource.short.type === 2 && ( )}
<> {resource.type === 1 && resource.short.type === 2 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Box size={20}/> <div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Box size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.short.used}
{' '} {resource.short.used}
/ {' '}
{resource.short.quota} /
</span> {resource.short.quota}
<span> </span>
<span>
{resource.short.quota - resource.short.used}
</span> {resource.short.quota - resource.short.used}
</div> </span>
</> </div>
)} </>
{resource.type === 2 && resource.long.type === 1 && ( )}
<> {resource.type === 2 && resource.long.type === 1 && (
<div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Timer size={20}/> <div className="flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Timer size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
</span> {format(resource.long.expire_at, 'yyyy-MM-dd HH:mm')}
<span>{intlFormatDistance(resource.long.expire_at, new Date())}</span> </span>
</div> <span>{intlFormatDistance(resource.long.expire_at, new Date())}</span>
</> </div>
)} </>
{resource.type === 2 && resource.long.type === 2 && ( )}
<> {resource.type === 2 && resource.long.type === 2 && (
<div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm"> <>
<Box size={20}/> <div className="flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm">
<span>{name(resource)}</span> <Box size={20}/>
</div> <span>{name(resource)}</span>
<div className="flex text-xs text-weak"> </div>
<span>{resource.resource_no}</span> <div className="flex text-xs text-weak">
</div> <span>{resource.resource_no}</span>
<div className="flex justify-between gap-2 text-xs text-weak"> </div>
<span> <div className="flex justify-between gap-2 text-xs text-weak">
<span>
{resource.long.used}
{' '} {resource.long.used}
/ {' '}
{resource.long.quota} /
</span> {resource.long.quota}
<span> </span>
<span>
{resource.long.quota - resource.long.used}
</span> {resource.long.quota - resource.long.used}
</div> </span>
</> </div>
)} </>
</div> )}
</SelectItem> </div>
{i < resources.length - 1 && <SelectSeparator className="m-1"/>} </SelectItem>
))}
</> </>
))} )}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@@ -651,9 +651,9 @@ function name(resource: Resource) {
// 短效套餐 // 短效套餐
switch (resource.short.type) { switch (resource.short.type) {
case 1: case 1:
return `短效包时 ${resource.short.live / 60} 分钟` return `短效包时 ${resource.short.live} 分钟`
case 2: case 2:
return `短效包量 ${resource.short.live / 60} 分钟` return `短效包量 ${resource.short.live} 分钟`
} }
break break

View File

@@ -46,7 +46,7 @@ export function DesktopPayment(props: PaymentModalProps) {
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
使 使
{decoration.text} {decoration.text}
{/* 扫码支付 */}
</p> </p>
<div className="w-full text-center space-y-2"> <div className="w-full text-center space-y-2">

View File

@@ -7,6 +7,7 @@ import {PaymentProps} from './type'
import {payClose} from '@/actions/resource' import {payClose} from '@/actions/resource'
import {useEffect} from 'react' import {useEffect} from 'react'
import {UniversalDesktopPayment} from './universal-desktop-payment' import {UniversalDesktopPayment} from './universal-desktop-payment'
import {useAppStore} from '@/components/stores/app'
export type PaymentModalProps = { export type PaymentModalProps = {
onConfirm: (showFail: boolean) => Promise<void> onConfirm: (showFail: boolean) => Promise<void>
@@ -34,9 +35,10 @@ export function PaymentModal(props: PaymentModalProps) {
} }
// SSE处理方式检查支付状态 // SSE处理方式检查支付状态
const apiUrl = useAppStore('apiUrl')
useEffect(() => { useEffect(() => {
const eventSource = new EventSource( 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) => { eventSource.onmessage = async (event) => {
switch (event.data) { switch (event.data) {
@@ -53,7 +55,7 @@ export function PaymentModal(props: PaymentModalProps) {
return () => { return () => {
eventSource.close() eventSource.close()
} }
}, [props]) }, [apiUrl, props])
return ( return (
<Dialog <Dialog
@@ -62,12 +64,12 @@ export function PaymentModal(props: PaymentModalProps) {
if (!open) handleClose() if (!open) handleClose()
}}> }}>
{/* {props.platform === TradePlatform.Mobile {props.platform === TradePlatform.Mobile
? <MobilePayment {...props} onClose={handleClose}/> ? <MobilePayment {...props} onClose={handleClose}/>
: <DesktopPayment {...props} onClose={handleClose}/> : <DesktopPayment {...props} onClose={handleClose}/>
} */} }
<UniversalDesktopPayment {...props} onClose={handleClose}/> {/* <UniversalDesktopPayment {...props} onClose={handleClose}/> */}
</Dialog> </Dialog>
) )
} }

View File

@@ -1,11 +1,12 @@
'use client' 'use client'
import {ReactNode} from 'react' import {ReactNode, useEffect, useState} from 'react'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import LongForm from '@/components/composites/purchase/long/form' import LongForm from '@/components/composites/purchase/long/form'
import ShortForm from '@/components/composites/purchase/short/form' import ShortForm from '@/components/composites/purchase/short/form'
import {usePathname, useRouter, useSearchParams} from 'next/navigation' import {usePathname, useRouter, useSearchParams} from 'next/navigation'
import SelfDesc from '@/components/features/self-desc' import SelfDesc from '@/components/features/self-desc'
import {listProduct, ProductItem} from '@/actions/product'
export type TabType = 'short' | 'long' | 'fixed' | 'custom' export type TabType = 'short' | 'long' | 'fixed' | 'custom'
export default function Purchase() { export default function Purchase() {
@@ -13,7 +14,8 @@ export default function Purchase() {
const path = usePathname() const path = usePathname()
const params = useSearchParams() 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 updateTab = (tab: string) => {
const newParams = new URLSearchParams(params) const newParams = new URLSearchParams(params)
@@ -21,27 +23,47 @@ export default function Purchase() {
router.push(`${path}?${newParams.toString()}`) router.push(`${path}?${newParams.toString()}`)
} }
useEffect(() => {
const fetchProducts = async () => {
const res = await listProduct({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const currentProduct = productList.find(item => item.code === tab)
const currentSkuList = currentProduct?.skus || []
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
short: ShortForm,
long: LongForm,
// static: StaticForm
}
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Tabs value={tab} onValueChange={updateTab} className="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"> <TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
<Tab value="short"></Tab> {productList.map(item => (
<Tab value="long"></Tab> <Tab key={item.code} value={item.code}>
<Tab value="fixed"></Tab> {item.name}
</Tab>
))}
{/* 固定的定制套餐tab */}
<Tab value="custom"></Tab> <Tab value="custom"></Tab>
</TabsList> </TabsList>
<TabsContent value="short"> {productList.map((item) => {
<ShortForm/> const Component = componentMap[item.code]
</TabsContent> const skuList = item.skus || []
<TabsContent value="long"> return (
<LongForm/> <TabsContent key={item.code} value={item.code}>
</TabsContent> {Component ? <Component skuList={skuList}/> : <div></div>}
<TabsContent value="fixed"> </TabsContent>
</TabsContent> )
})}
<TabsContent value="custom"> <TabsContent value="custom">
<SelfDesc onInquiry={() => { <SelfDesc onInquiry={() => router.push('/custom')}/>
router.push('/custom')
}}/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -10,72 +10,22 @@ import check from '../_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form' import {Schema} from '@/components/composites/purchase/long/form'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card' 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 form = useFormContext<Schema>()
const type = useWatch({name: 'type'}) const type = useWatch({name: 'type'})
useEffect(() => {
if (type === '1') {
form.setValue('daily_limit', 100)
}
else {
form.setValue('quota', 500)
}
}, [type, form])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
{/* 计费方式 */} {/* 计费方式 */}
<FormField <BillingMethod expireList={expireList}/>
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>
{/* IP 时效 */} {/* IP 时效 */}
<FormField <IpTime {...{map, liveList}}/>
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>
{/* 根据套餐类型显示不同表单项 */} {/* 根据套餐类型显示不同表单项 */}
{type === '2' ? ( {type === '2' ? (
@@ -126,26 +76,7 @@ export default function Center() {
) : ( ) : (
<> <>
{/* 包时:套餐时效 */} {/* 包时:套餐时效 */}
<FormField <ComboValidity expireList={expireList}/>
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>
{/* 包时:每日提取上限 */} {/* 包时:每日提取上限 */}
<FormField <FormField
@@ -244,3 +175,118 @@ export default function Center() {
</Card> </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>
)
}

View File

@@ -5,13 +5,14 @@ import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
// 定义表单验证架构 // 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['1', '4', '8', '12', '24']), live: z.string(),
quota: z.number().min(500, '购买数量不能少于 500 个'), 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 个'), daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']),
}) })
@@ -19,22 +20,39 @@ const schema = z.object({
// 从架构中推断类型 // 从架构中推断类型
export type Schema = z.infer<typeof schema> 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>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: '2', // 默认为包量套餐
live: '1', // 小时 live: liveList[0], // 分钟
expire: '0', // 天
quota: 500, quota: 500,
expire: '30', // 天
daily_limit: 100, daily_limit: 100,
pay_type: 'wechat', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/> <Center {...{liveList, map, expireList}}/>
<Right/> <Right/>
</Form> </Form>
) )

View File

@@ -22,8 +22,8 @@ export default function Right() {
const dailyLimit = useWatch({control, name: 'daily_limit'}) const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({ const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00', price: '0.00',
discounted_price: '0.00', actual: '0.00',
discounted: 0, discounted: '0.00',
}) })
useEffect(() => { useEffect(() => {
@@ -41,26 +41,38 @@ export default function Right() {
if (!resp.success) { if (!resp.success) {
throw new Error('获取价格失败') throw new Error('获取价格失败')
} }
setPriceData({ setPriceData({
price: resp.data.price, price: resp.data.price,
discounted_price: resp.data.discounted_price ?? resp.data.price ?? '', actual: resp.data.actual ?? resp.data.price ?? '',
discounted: resp.data.discounted, discounted: resp.data.discounted,
}) })
} }
catch (error) { catch (error) {
console.error('获取价格失败:', error)
setPriceData({ setPriceData({
price: '0.00', price: '0.00',
discounted_price: '0.00', actual: '0.00',
discounted: 0, discounted: '0.00',
}) })
} }
} }
price() price()
}, [dailyLimit, expire, live, quota, mode]) }, [dailyLimit, expire, live, quota, mode])
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 ( return (
<Card className={merge( <Card className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`, `flex-none basis-90 p-6 flex flex-col gap-6 relative`,
@@ -78,7 +90,7 @@ export default function Right() {
<span className="text-sm"> <span className="text-sm">
{live} {live}
{' '} {' '}
</span> </span>
</li> </li>
{mode === '2' ? ( {mode === '2' ? (
@@ -91,11 +103,19 @@ export default function Right() {
</span> </span>
</li> </li>
<li className="flex justify-between items-center"> <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"> <span className="text-sm">
{price} {price}
</span> </span>
</li> </li>
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{totalDiscount}
</span>
</li>
)}
</> </>
) : ( ) : (
<> <>
@@ -119,11 +139,11 @@ export default function Right() {
{price} {price}
</span> </span>
</li> </li>
{discounted === 1 ? '' : ( {hasDiscount && (
<li className="flex justify-between items-center"> <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"> <span className="text-sm">
-{discounted} -{totalDiscount}
</span> </span>
</li> </li>
)} )}
@@ -156,7 +176,7 @@ function BalanceOrLogin(props: {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
return profile ? ( return profile ? (
<> <>
{/* <FieldPayment/> */} <FieldPayment/>
<Pay <Pay
method={props.method} method={props.method}
balance={profile.balance} balance={profile.balance}
@@ -166,8 +186,8 @@ function BalanceOrLogin(props: {
long: { long: {
mode: Number(props.mode), mode: Number(props.mode),
live: Number(props.live), live: Number(props.live),
expire: Number(props.expire), expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? props.dailyLimit : props.quota, quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
}, },
}}/> }}/>
</> </>

View File

@@ -4,9 +4,17 @@ import {FormField} from '@/components/ui/form'
import Image from 'next/image' import Image from 'next/image'
import alipay from '../_assets/alipay.svg' import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg' import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg'
import RechargeModal from '@/components/composites/recharge'
import {useProfileStore} from '@/components/stores/profile'
import {use} from 'react'
import Link from 'next/link'
import {buttonVariants} from '@/components/ui/button'
export function FieldPayment() { export function FieldPayment() {
return ( const profile = use(useProfileStore(store => store.profile))
return profile ? (
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6"> <FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
@@ -15,7 +23,7 @@ export function FieldPayment() {
onValueChange={field.onChange} onValueChange={field.onChange}
className="flex flex-col gap-3"> className="flex flex-col gap-3">
{/* <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md"> <div className="w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md">
<p className="flex items-center gap-3"> <p className="flex items-center gap-3">
<Image src={balance} alt="余额icon"/> <Image src={balance} alt="余额icon"/>
<span className="text-sm text-gray-500"></span> <span className="text-sm text-gray-500"></span>
@@ -24,16 +32,15 @@ export function FieldPayment() {
<span className="text-xl">{profile.balance}</span> <span className="text-xl">{profile.balance}</span>
<RechargeModal/> <RechargeModal/>
</p> </p>
</div> */} </div>
<FormOption
{/* <FormOption
id={`${id}-balance`} id={`${id}-balance`}
value="balance" value="balance"
compare={field.value} compare={field.value}
className="p-3 w-full flex-row gap-2 justify-center"> className="p-3 w-full flex-row gap-2 justify-center">
<Image src={balance} alt="余额 icon"/> <Image src={balance} alt="余额 icon"/>
<span></span> <span></span>
</FormOption> */} </FormOption>
<FormOption <FormOption
id={`${id}-wechat`} id={`${id}-wechat`}
value="wechat" value="wechat"
@@ -53,5 +60,9 @@ export function FieldPayment() {
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
) )
} }

View File

@@ -10,19 +10,21 @@ import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form' import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card' 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 form = useFormContext<Schema>()
const type = useWatch({name: 'type'}) const type = useWatch({name: 'type'})
useEffect(() => { const expire = useWatch({name: 'expire'})
if (type === '1') { const isTime = type === '1'
form.setValue('daily_limit', 2000)
}
else {
form.setValue('quota', 10000)
}
}, [type, form])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
@@ -34,8 +36,17 @@ export default function Center() {
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} 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"> className="flex gap-4 max-md:flex-col">
<FormOption <FormOption
@@ -64,21 +75,35 @@ export default function Center() {
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
{liveList.map((live) => {
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/> const params = new URLSearchParams()
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/> params.set('mode', isTime ? 'time' : 'quota')
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/> params.set('live', live)
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/> params.set('expire', isTime ? expire : '0')
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/> 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> </RadioGroup>
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 根据套餐类型显示不同表单项 */}
{type === '2' ? ( {!isTime ? (
/* 包量IP 购买数量 */ /* 包量IP 购买数量 */
<FormField <FormField
className="space-y-4" className="space-y-4"
@@ -121,23 +146,18 @@ export default function Center() {
) : ( ) : (
<> <>
{/* 包时:套餐时效 */} {/* 包时:套餐时效 */}
<FormField <FormField className="space-y-4" name="expire" label="套餐时效">
className="space-y-4"
name="expire"
label="套餐时效">
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup id={id} value={field.value} onValueChange={field.onChange} className="flex gap-4 flex-wrap">
id={id} {expireList.map(day => (
defaultValue={field.value} <FormOption
onValueChange={field.onChange} key={day}
className="flex gap-4 flex-wrap"> id={`${id}-${day}`}
value={day}
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/> label={`${day}`}
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/> 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> </RadioGroup>
)} )}
</FormField> </FormField>
@@ -153,8 +173,8 @@ export default function Center() {
theme="outline" theme="outline"
type="button" type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg" 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))} onClick={() => form.setValue('daily_limit', Math.max(2000, Number(field.value) - 1000))}
disabled={Number(field.value) === 2_000}> disabled={Number(field.value) === 2000}>
<Minus/> <Minus/>
</Button> </Button>
<Input <Input
@@ -162,12 +182,12 @@ export default function Center() {
id={id} id={id}
type="number" type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center" className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={2_000} min={2000}
step={1_000} step={1000}
onBlur={(e) => { onBlur={(e) => {
const value = Number(e.target.value) const value = Number(e.target.value)
if (value < 2_000) { if (value < 2000) {
form.setValue('daily_limit', 2_000) form.setValue('daily_limit', 2000)
} }
}} }}
/> />
@@ -175,7 +195,7 @@ export default function Center() {
theme="outline" theme="outline"
type="button" type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg" 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/> <Plus/>
</Button> </Button>
</div> </div>
@@ -212,10 +232,6 @@ export default function Center() {
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP资源定期筛选</span> <span className="text-sm text-gray-500">IP资源定期筛选</span>
</p> </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"> <p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">/</span> <span className="text-sm text-gray-500">/</span>

View File

@@ -5,37 +5,61 @@ import Right from '@/components/composites/purchase/short/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
// 定义表单验证架构 // 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['180', '300', '600', '1200', '1800']), live: z.string(),
quota: z.number().min(10000, '购买数量不能少于10000个'), quota: z.number().min(10000, '购买数量不能少于 10000 个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.string(),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'), daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> 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>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: '2', // 默认为包量套餐
live: '180', // 分钟 live: liveList[0] || '',
quota: 10_000, // >= 10000 expire: '0', // 包量模式下无效
expire: '30', // 天 quota: 10_000, // >= 10000,
daily_limit: 2_000, // >= 2000 daily_limit: 2_000, // >= 2000
pay_type: 'wechat', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/> <Center {...{priceMap, liveList, expireList}}/>
<Right/> <Right {...{skuList, priceMap}}/>
</Form> </Form>
) )
} }

View File

@@ -11,8 +11,9 @@ import {Card} from '@/components/ui/card'
import {getPrice} from '@/actions/resource' import {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api' import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment' import {FieldPayment} from '../shared/field-payment'
import {ProductItem} from '@/actions/product'
export default function Right() { export default function Right({skuList}: {skuList: ProductItem['skus']}) {
const {control} = useFormContext<Schema>() const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'}) const method = useWatch({control, name: 'pay_type'})
const live = useWatch({control, name: 'live'}) const live = useWatch({control, name: 'live'})
@@ -22,8 +23,8 @@ export default function Right() {
const dailyLimit = useWatch({control, name: 'daily_limit'}) const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({ const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00', price: '0.00',
discounted_price: '0.00', actual: '0.00',
discounted: 0, discounted: '0.00',
}) })
useEffect(() => { useEffect(() => {
@@ -45,7 +46,7 @@ export default function Right() {
const data = priceResponse.data const data = priceResponse.data
setPriceData({ setPriceData({
price: data.price, price: data.price,
discounted_price: data.discounted_price ?? data.price ?? '', actual: data.actual ?? data.price ?? '',
discounted: data.discounted, discounted: data.discounted,
}) })
} }
@@ -53,15 +54,29 @@ export default function Right() {
console.error('获取价格失败:', error) console.error('获取价格失败:', error)
setPriceData({ setPriceData({
price: '0.00', price: '0.00',
discounted_price: '0.00', actual: '0.00',
discounted: 0, discounted: '0.00',
}) })
} }
} }
price() price()
}, [expire, live, quota, mode, dailyLimit]) }, [expire, live, quota, mode, dailyLimit])
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 ( return (
<Card className={merge( <Card className={merge(
@@ -78,7 +93,7 @@ export default function Right() {
<li className="flex justify-between items-center"> <li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span> <span className="text-sm text-gray-500">IP </span>
<span className="text-sm"> <span className="text-sm">
{Number(live) / 60} {live}
{' '} {' '}
</span> </span>
@@ -93,11 +108,19 @@ export default function Right() {
</span> </span>
</li> </li>
<li className="flex justify-between items-center"> <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"> <span className="text-sm">
{price} {price}
</span> </span>
</li> </li>
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-{totalDiscount}
</span>
</li>
)}
</> </>
) : ( ) : (
<> <>
@@ -121,11 +144,11 @@ export default function Right() {
{price} {price}
</span> </span>
</li> </li>
{discounted === 1 ? '' : ( {hasDiscount && (
<li className="flex justify-between items-center"> <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"> <span className="text-sm">
-{discounted === 1 ? '' : discounted} -{totalDiscount}
</span> </span>
</li> </li>
)} )}
@@ -158,7 +181,7 @@ function BalanceOrLogin(props: {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
return profile ? ( return profile ? (
<> <>
{/* <FieldPayment/> */} <FieldPayment/>
<Pay <Pay
method={props.method} method={props.method}
balance={profile.balance} balance={profile.balance}
@@ -168,8 +191,8 @@ function BalanceOrLogin(props: {
short: { short: {
mode: Number(props.mode), mode: Number(props.mode),
live: Number(props.live), live: Number(props.live),
expire: Number(props.expire), expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? props.dailyLimit : props.quota, quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
}, },
}}/> }}/>
</> </>

View 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])
}

View 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>
)
}

View File

@@ -1,7 +1,15 @@
// 定义后端服务URL和OAuth2配置 // 定义后端服务URL和OAuth2配置
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL const _api_base_url = process.env.API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID if (!_api_base_url) throw new Error('API_BASE_URL is not set')
const CLIENT_SECRET = process.env.CLIENT_SECRET const API_BASE_URL = _api_base_url
const _client_id = process.env.CLIENT_ID
if (!_client_id) throw new Error('CLIENT_ID is not set')
const CLIENT_ID = _client_id
const _client_secret = process.env.CLIENT_SECRET
if (!_client_secret) throw new Error('CLIENT_SECRET is not set')
const CLIENT_SECRET = _client_secret
// 统一的API响应类型 // 统一的API响应类型
type ApiResponse<T = undefined> = { type ApiResponse<T = undefined> = {

View File

@@ -8,4 +8,5 @@ export type Batch = {
time: string time: string
user_id: number user_id: number
prov: string prov: string
city: string
} }

View 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
View 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[]
}