12 Commits

Author SHA1 Message Date
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
31 changed files with 606 additions and 273 deletions

4
.env.example Normal file
View File

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

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
# 生产阶段 # 生产阶段

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.3.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,16 @@
'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
}
// ====================== // ======================
// public // public
@@ -26,6 +34,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 +48,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 +162,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

@@ -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

View File

@@ -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,
}) })

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">
<h3 className="text-sm font-semibold text-gray-500 tracking-wide">
</h3>
<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 className="flex flex-col gap-4">
<MenuTitle title="业务场景"/>
<SolutionMenu/>
</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">
<h3 className="text-sm font-semibold text-gray-500 tracking-wide">
{props.title} {props.title}
</h3> </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,12 +1,13 @@
'use client' 'use client'
import {ReactNode, useContext, useState} from 'react' import {ReactNode, useContext, 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'
type TabType = 'domestic' | 'oversea' type TabType = 'domestic' | 'oversea'
@@ -53,33 +54,34 @@ export function Tab(props: {
export function Domestic(props: {}) { export function Domestic(props: {}) {
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 <ProductCard
icon={prod}
label="短效动态 IP" label="短效动态 IP"
discount="最低4.5折"
desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。" desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
href="/product?type=short" href="/product?type=short"
discount={45}
/> />
</div> <ProductCard
<div className="w-full lg:w-64 flex flex-col"> icon={prod}
<FragmentTitle img={prod} text="长效 IP"/> label="长效静态 IP"
<DomesticLink discount="最低4.5折"
label="长效动态 IP"
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。" desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
href="/product?type=long" href="/product?type=long"
discount={45}
/> />
</div> </div>
<div className="w-full lg:w-64 flex flex-col"> <div className="flex flex-col gap-3">
<FragmentTitle img={custom} text="业务定制"/> <ProductCard
<DomesticLink icon={custom}
label="优质/企业/精选IP" label="业务定制"
discount="1V1 专属服务"
desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持" desc="超 1000 家企业共同信赖之选!大客户经理全程 1 对 1 沟通,随时为您排忧解难,提供 24 小时不间断支持"
href="/custom" href="/custom"
/> />
</div> </div>
</div>
</section> </section>
) )
} }
@@ -92,11 +94,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 +119,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>
<div className="flex-1">
<div className="flex items-center justify-between gap-3">
<span className="font-bold">{props.label}</span>
<span className="text-xs font-medium text-orange-600 bg-orange-50 px-2 py-1 rounded-full">
{props.discount}
</span> </span>
)} </div>
</p> <div className="mt-2 text-sm text-gray-400 space-y-1">
<p className="text-gray-400 text-sm">
{props.desc} {props.desc}
</p> </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>

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

@@ -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

@@ -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>
}, },
}, },
{ {

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

@@ -337,6 +337,7 @@ function SelectResource() {
setStatus('load') setStatus('load')
try { try {
const resp = await allResource() const resp = await allResource()
console.log(resp, '/api/resource/all')
if (!resp.success) { if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试') throw new Error('获取套餐失败,请稍后再试')
@@ -381,10 +382,11 @@ function SelectResource() {
<Loader className="animate-spin" size={20}/> <Loader className="animate-spin" size={20}/>
<span></span> <span></span>
</div> </div>
) : resources.map((resource, i) => ( ) : (
<> <>
{resources.map(resource => (
<SelectItem <SelectItem
key={`${resource.id}`} key={resource.id}
value={String(resource.id)} value={String(resource.id)}
className="p-3"> className="p-3">
<div className="flex flex-col gap-2 w-72"> <div className="flex flex-col gap-2 w-72">
@@ -474,9 +476,9 @@ function SelectResource() {
)} )}
</div> </div>
</SelectItem> </SelectItem>
{i < resources.length - 1 && <SelectSeparator className="m-1"/>}
</>
))} ))}
</>
)}
</SelectContent> </SelectContent>
</Select> </Select>
)} )}
@@ -651,9 +653,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

@@ -62,12 +62,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

@@ -28,7 +28,7 @@ export default function LongForm() {
quota: 500, quota: 500,
expire: '30', // 天 expire: '30', // 天
daily_limit: 100, daily_limit: 100,
pay_type: 'wechat', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })

View File

@@ -32,12 +32,13 @@ export default function Right() {
const resp = await getPrice({ const resp = await getPrice({
type: 2, type: 2,
long: { long: {
live: Number(live), live: Number(live) * 60,
mode: Number(mode), mode: Number(mode),
quota: mode === '1' ? Number(dailyLimit) : Number(quota), quota: mode === '1' ? Number(dailyLimit) : Number(quota),
expire: mode === '1' ? Number(expire) : undefined, expire: mode === '1' ? Number(expire) : undefined,
}, },
}) })
if (!resp.success) { if (!resp.success) {
throw new Error('获取价格失败') throw new Error('获取价格失败')
} }
@@ -49,6 +50,7 @@ export default function Right() {
}) })
} }
catch (error) { catch (error) {
console.error('获取价格失败:', error)
setPriceData({ setPriceData({
price: '0.00', price: '0.00',
discounted_price: '0.00', discounted_price: '0.00',
@@ -114,19 +116,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>
{discounted === 1 ? '' : ( {/* {discounted === 1 ? '' : (
<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} -¥{discounted}
</span> </span>
</li> </li>
)} )} */}
</> </>
)} )}
</ul> </ul>
@@ -156,7 +158,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}
@@ -165,9 +167,9 @@ function BalanceOrLogin(props: {
type: 2, type: 2,
long: { long: {
mode: Number(props.mode), mode: Number(props.mode),
live: Number(props.live), live: Number(props.live) * 60,
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

@@ -47,6 +47,7 @@ export default function Pay(props: PayProps) {
payment_method: method, payment_method: method,
payment_platform: TradePlatform.Desktop, payment_platform: TradePlatform.Desktop,
} }
console.log(req, 'req')
const resp = await prepareResource(req) const resp = await prepareResource(req)

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

@@ -68,11 +68,11 @@ export default function Center() {
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">
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/> <FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/> <FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/> <FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/> <FormOption id={`${id}-20`} value="15" label="15 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/> <FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>

View File

@@ -9,7 +9,7 @@ import {zodResolver} from '@hookform/resolvers/zod'
// 定义表单验证架构 // 定义表单验证架构
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.enum(['3', '5', '10', '15', '30']),
quota: z.number().min(10000, '购买数量不能少于10000个'), quota: z.number().min(10000, '购买数量不能少于10000个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.enum(['7', '15', '30', '90', '180', '365']),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'), daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
@@ -24,11 +24,11 @@ export default function ShortForm() {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: '2', // 默认为包量套餐
live: '180', // 分钟 live: '3', // 分钟
quota: 10_000, // >= 10000 quota: 10_000, // >= 10000
expire: '30', // 天 expire: '30', // 天
daily_limit: 2_000, // >= 2000 daily_limit: 2_000, // >= 2000
pay_type: 'wechat', // 余额支付 pay_type: 'balance', // 余额支付
}, },
}) })

View File

@@ -38,6 +38,7 @@ export default function Right() {
expire: mode === '1' ? Number(expire) : undefined, expire: mode === '1' ? Number(expire) : undefined,
}, },
}) })
if (!priceResponse.success) { if (!priceResponse.success) {
throw new Error('获取价格失败') throw new Error('获取价格失败')
} }
@@ -78,7 +79,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>
@@ -116,19 +117,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>
{discounted === 1 ? '' : ( {/* {discounted === 1 ? '' : (
<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} -¥{discounted === 1 ? '' : discounted}
</span> </span>
</li> </li>
)} )} */}
</> </>
)} )}
</ul> </ul>
@@ -158,7 +159,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 +169,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

@@ -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.NEXT_PUBLIC_API_BASE_URL
const CLIENT_ID = process.env.CLIENT_ID if (!_api_base_url) throw new Error('NEXT_PUBLIC_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> = {