完善登录与状态回显处理
This commit is contained in:
@@ -1,23 +1,38 @@
|
|||||||
import { dirname } from "path";
|
import { dirname } from 'path'
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from 'url'
|
||||||
import { FlatCompat } from "@eslint/eslintrc";
|
import { FlatCompat } from '@eslint/eslintrc'
|
||||||
|
import stylistic from '@stylistic/eslint-plugin'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
const compat = new FlatCompat({
|
const compat = new FlatCompat({
|
||||||
baseDirectory: __dirname,
|
baseDirectory: __dirname,
|
||||||
});
|
})
|
||||||
|
|
||||||
|
// noinspection SpellCheckingInspection
|
||||||
const eslintConfig = [
|
const eslintConfig = [
|
||||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||||
{
|
{
|
||||||
|
plugins: {
|
||||||
|
'@stylistic': stylistic,
|
||||||
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
'semi': ['error', 'never'],
|
||||||
'@typescript-eslint/no-empty-object-type': 'off',
|
'@typescript-eslint/no-empty-object-type': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': 'off',
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
'semi': ['error', 'never'],
|
'@stylistic/member-delimiter-style': ['error', {
|
||||||
|
multiline: {
|
||||||
|
delimiter: 'none',
|
||||||
|
requireLast: true,
|
||||||
|
},
|
||||||
|
singleline: {
|
||||||
|
delimiter: 'comma',
|
||||||
|
requireLast: false,
|
||||||
|
},
|
||||||
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig
|
||||||
|
|||||||
@@ -32,15 +32,16 @@
|
|||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"eslint": "^9",
|
||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-config-next": "15.2.1",
|
||||||
"@next/eslint-plugin-next": "^15.2.1",
|
"@next/eslint-plugin-next": "^15.2.1",
|
||||||
|
"@stylistic/eslint-plugin": "^4.2.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
|
||||||
"eslint-config-next": "15.2.1",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
|||||||
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@@ -78,6 +78,9 @@ importers:
|
|||||||
'@next/eslint-plugin-next':
|
'@next/eslint-plugin-next':
|
||||||
specifier: ^15.2.1
|
specifier: ^15.2.1
|
||||||
version: 15.2.1
|
version: 15.2.1
|
||||||
|
'@stylistic/eslint-plugin':
|
||||||
|
specifier: ^4.2.0
|
||||||
|
version: 4.2.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
|
||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.0.9
|
version: 4.0.9
|
||||||
@@ -710,6 +713,12 @@ packages:
|
|||||||
'@standard-schema/utils@0.3.0':
|
'@standard-schema/utils@0.3.0':
|
||||||
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
|
||||||
|
|
||||||
|
'@stylistic/eslint-plugin@4.2.0':
|
||||||
|
resolution: {integrity: sha512-8hXezgz7jexGHdo5WN6JBEIPHCSFyyU4vgbxevu4YLVS5vl+sxqAAGyXSzfNDyR6xMNSH5H1x67nsXcYMOHtZA==}
|
||||||
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: '>=9.0.0'
|
||||||
|
|
||||||
'@swc/counter@0.1.3':
|
'@swc/counter@0.1.3':
|
||||||
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
|
||||||
|
|
||||||
@@ -2783,6 +2792,18 @@ snapshots:
|
|||||||
|
|
||||||
'@standard-schema/utils@0.3.0': {}
|
'@standard-schema/utils@0.3.0': {}
|
||||||
|
|
||||||
|
'@stylistic/eslint-plugin@4.2.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)':
|
||||||
|
dependencies:
|
||||||
|
'@typescript-eslint/utils': 8.26.0(eslint@9.21.0(jiti@2.4.2))(typescript@5.8.2)
|
||||||
|
eslint: 9.21.0(jiti@2.4.2)
|
||||||
|
eslint-visitor-keys: 4.2.0
|
||||||
|
espree: 10.3.0
|
||||||
|
estraverse: 5.3.0
|
||||||
|
picomatch: 4.0.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
- typescript
|
||||||
|
|
||||||
'@swc/counter@0.1.3': {}
|
'@swc/counter@0.1.3': {}
|
||||||
|
|
||||||
'@swc/helpers@0.5.15':
|
'@swc/helpers@0.5.15':
|
||||||
|
|||||||
@@ -1,18 +1,22 @@
|
|||||||
'use server'
|
'use server'
|
||||||
import {cookies} from 'next/headers'
|
import {cookies} from 'next/headers'
|
||||||
import {ApiResponse, call} from '@/lib/api'
|
import {ApiResponse, call} from '@/lib/api'
|
||||||
|
import {AuthContext} from '@/lib/auth'
|
||||||
|
|
||||||
export interface LoginParams {
|
export interface LoginParams {
|
||||||
username: string;
|
username: string
|
||||||
password: string;
|
password: string
|
||||||
remember?: boolean;
|
remember?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type LoginResp = {
|
type LoginResp = {
|
||||||
token: string;
|
token: string
|
||||||
expires: number;
|
expires: number
|
||||||
|
auth: AuthContext
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export async function login(props: LoginParams): Promise<ApiResponse> {
|
export async function login(props: LoginParams): Promise<ApiResponse> {
|
||||||
try {
|
try {
|
||||||
// 尝试登录
|
// 尝试登录
|
||||||
@@ -24,9 +28,7 @@ export async function login(props: LoginParams): Promise<ApiResponse> {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = result.data
|
const data = result.data
|
||||||
console.log('login', data)
|
|
||||||
|
|
||||||
// 计算过期时间
|
// 计算过期时间
|
||||||
const current = Math.floor(Date.now() / 1000)
|
const current = Math.floor(Date.now() / 1000)
|
||||||
@@ -40,6 +42,12 @@ export async function login(props: LoginParams): Promise<ApiResponse> {
|
|||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
maxAge: Math.max(future, 0),
|
maxAge: Math.max(future, 0),
|
||||||
})
|
})
|
||||||
|
cookieStore.set('auth_info', JSON.stringify(data.auth), {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'strict',
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
maxAge: Math.max(future, 0),
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import {ApiResponse, call} from '@/lib/api'
|
|||||||
|
|
||||||
|
|
||||||
export interface VerifyParams {
|
export interface VerifyParams {
|
||||||
phone: string;
|
phone: string
|
||||||
captcha: string; // 添加验证码字段
|
captcha: string // 添加验证码字段
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function verify(props: VerifyParams): Promise<ApiResponse> {
|
export default async function verify(props: VerifyParams): Promise<ApiResponse> {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export default function Captcha(props: CaptchaProps) {
|
|||||||
取消
|
取消
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleVerifyCaptcha()}
|
onClick={handleVerifyCaptcha}
|
||||||
>
|
>
|
||||||
确认
|
确认
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -79,26 +79,32 @@ export default function LoginPage(props: LoginPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 发送验证码
|
// 发送验证码
|
||||||
const resp = await verify({
|
let resp: ApiResponse
|
||||||
phone: username,
|
try {
|
||||||
captcha: captchaCode,
|
resp = await verify({
|
||||||
})
|
phone: username,
|
||||||
|
captcha: captchaCode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
toast.error(`请求失败:${e}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// 处理验证码发送结果
|
// 处理验证码发送结果
|
||||||
let waiting = 60
|
let waiting = 60
|
||||||
if (!resp.success) {
|
if (!resp.success) {
|
||||||
if (resp.status == 429) {
|
if (resp.status != 429) {
|
||||||
setShowCaptcha(false)
|
|
||||||
waiting = parseInt(resp.message)
|
|
||||||
console.log(resp.message)
|
|
||||||
toast.error('发送频率过快', {
|
|
||||||
description: '请稍后再试',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
toast.error(resp.message)
|
toast.error(resp.message)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setShowCaptcha(false)
|
||||||
|
waiting = parseInt(resp.message)
|
||||||
|
console.log(resp.message)
|
||||||
|
toast.error('发送频率过快', {
|
||||||
|
description: '请稍后再试',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
setShowCaptcha(false)
|
setShowCaptcha(false)
|
||||||
@@ -125,10 +131,6 @@ export default function LoginPage(props: LoginPageProps) {
|
|||||||
return false
|
return false
|
||||||
}, [username])
|
}, [username])
|
||||||
|
|
||||||
const setWaiting = (resp: ApiResponse<undefined>) => {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// 处理表单提交
|
// 处理表单提交
|
||||||
const onSubmit = async (values: FormValues) => {
|
const onSubmit = async (values: FormValues) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image, {StaticImageData} from 'next/image'
|
||||||
import Wrap from "@/components/wrap"
|
import Wrap from "@/components/wrap"
|
||||||
import h01 from '@/assets/header/help/01.svg'
|
import h01 from '@/assets/header/help/01.svg'
|
||||||
import h02 from '@/assets/header/help/02.svg'
|
import h02 from '@/assets/header/help/02.svg'
|
||||||
@@ -44,7 +44,7 @@ export default function HelpMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Column(props: {
|
function Column(props: {
|
||||||
icon: any
|
icon: StaticImageData
|
||||||
title: string
|
title: string
|
||||||
items: {
|
items: {
|
||||||
lead: string
|
lead: string
|
||||||
|
|||||||
@@ -1,16 +1,89 @@
|
|||||||
'use client'
|
import {ReactNode, useContext, useState} from 'react'
|
||||||
|
|
||||||
import {useContext, useState} from 'react'
|
|
||||||
import Wrap from '@/components/wrap'
|
import Wrap from '@/components/wrap'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import anno from '@/assets/header/product/anno.svg'
|
import anno from '@/assets/header/product/anno.svg'
|
||||||
import {Domestic, Oversea, Tab} from '@/app/(root)/@header/_server/product'
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {merge} from '@/lib/utils'
|
import {merge} from '@/lib/utils'
|
||||||
import {HeaderContext} from '@/app/(root)/@header/page'
|
import prod from '@/assets/header/product/prod.svg'
|
||||||
|
import custom from '@/assets/header/product/custom.svg'
|
||||||
|
import {HeaderContext} from '@/app/(root)/@header/_client/provider'
|
||||||
|
|
||||||
type TabType = 'domestic' | 'oversea'
|
type TabType = 'domestic' | 'oversea'
|
||||||
|
|
||||||
|
export function Tab(props: {
|
||||||
|
selected: boolean
|
||||||
|
onSelect: () => void
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<li role="tab">
|
||||||
|
<button
|
||||||
|
className={[
|
||||||
|
`p-8 text-lg cursor-pointer border-r`,
|
||||||
|
props.selected ? `bg-gradient-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
|
||||||
|
].join(' ')}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Domestic(props: {}) {
|
||||||
|
return (
|
||||||
|
<section role="tabpanel" className="flex gap-16 mr-16">
|
||||||
|
<div className="w-64 flex flex-col">
|
||||||
|
<h3 className="mb-6 font-bold flex items-center gap-3">
|
||||||
|
<Image src={prod} alt={`产品`} className={`w-10 h-=10`}/>
|
||||||
|
<span>代理产品</span>
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<DomesticLink
|
||||||
|
label={`动态IP`}
|
||||||
|
desc={`全国300+城市级定位节点`}
|
||||||
|
href={`/product?type=dynamic`}
|
||||||
|
discount={45}
|
||||||
|
/>
|
||||||
|
<DomesticLink
|
||||||
|
label={`长效静态IP`}
|
||||||
|
desc={`IP 资源覆盖全国`}
|
||||||
|
href={`/product?type=dynamic`}
|
||||||
|
discount={45}
|
||||||
|
/>
|
||||||
|
<DomesticLink
|
||||||
|
label={`固定IP`}
|
||||||
|
desc={`全国300+城市级定位节点`}
|
||||||
|
href={`/product?type=static`}
|
||||||
|
discount={45}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-64 flex flex-col gap-4">
|
||||||
|
<h3 className="font-bold mb-2 flex items-center gap-3">
|
||||||
|
<Image src={custom} alt="定制" className="w-10 h-10"/>
|
||||||
|
<span>业务定制</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p>优质/企业/精选IP</p>
|
||||||
|
<p className="text-gray-400 text-sm">
|
||||||
|
超 1000 家企业共同信赖之选!大客户经理全
|
||||||
|
程 1 对 1 沟通,随时为您排忧解难,提供 24
|
||||||
|
小时不间断支持
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Oversea(props: {}) {
|
||||||
|
return (
|
||||||
|
<section role="tabpanel">
|
||||||
|
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductMenu() {
|
export default function ProductMenu() {
|
||||||
|
|
||||||
const [type, setType] = useState<TabType>('domestic')
|
const [type, setType] = useState<TabType>('domestic')
|
||||||
|
|||||||
144
src/app/(root)/@header/_client/provider.tsx
Normal file
144
src/app/(root)/@header/_client/provider.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
'use client'
|
||||||
|
import {createContext, ReactNode, useCallback, useEffect, useMemo, useState} from 'react'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Image from 'next/image'
|
||||||
|
import {LinkItem, MenuItem} from './navs'
|
||||||
|
import SolutionMenu from './solution'
|
||||||
|
import ProductMenu from './product'
|
||||||
|
import HelpMenu from './help'
|
||||||
|
import Wrap from '@/components/wrap'
|
||||||
|
import logo from '@/assets/logo.webp'
|
||||||
|
|
||||||
|
export const HeaderContext = createContext<{
|
||||||
|
setMenu: (value: boolean) => void
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
export type ProviderProps = {
|
||||||
|
userCenter: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Provider(props: ProviderProps) {
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 滚动条状态
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
const [scroll, setScroll] = useState(false) // Changed to false for client-side rendering
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
setScroll(window.scrollY > 48)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Initialize scroll state on client
|
||||||
|
setScroll(window.scrollY > 48)
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [handleScroll])
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 菜单状态
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
const [menu, setMenu] = useState(false)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const pages = useMemo(() => [
|
||||||
|
<ProductMenu key={`product`}/>,
|
||||||
|
<SolutionMenu key={`solution`}/>,
|
||||||
|
<HelpMenu key={`help`}/>,
|
||||||
|
], [])
|
||||||
|
|
||||||
|
// ======================
|
||||||
|
// 渲染组件
|
||||||
|
// ======================
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeaderContext.Provider value={{setMenu}}>
|
||||||
|
<div className={[
|
||||||
|
`transition-[background, shadow] duration-200 ease-in-out`,
|
||||||
|
menu
|
||||||
|
? `bg-[#fffe] backdrop-blur-sm`
|
||||||
|
: scroll
|
||||||
|
? `bg-[#fffe] backdrop-blur-sm shadow-lg`
|
||||||
|
: `bg-transparent shadow-none`,
|
||||||
|
].join(' ')}>
|
||||||
|
<Wrap className="h-20 max-md:h-16 flex justify-between">
|
||||||
|
<div className="flex justify-between gap-8">
|
||||||
|
{/* logo */}
|
||||||
|
<Link href="/public" className={`flex items-center`}>
|
||||||
|
<Image src={logo} alt={`logo`} height={48}/>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* 菜单 */}
|
||||||
|
<nav>
|
||||||
|
<ul className="h-full flex items-stretch max-lg:hidden">
|
||||||
|
<LinkItem text={`首页`} href={`/`}/>
|
||||||
|
<MenuItem
|
||||||
|
text={`产品订购`}
|
||||||
|
active={menu && page === 0}
|
||||||
|
onEnter={() => {
|
||||||
|
setMenu(true)
|
||||||
|
setPage(0)
|
||||||
|
}}
|
||||||
|
onLeave={() => {
|
||||||
|
return setMenu(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
text={`业务场景`}
|
||||||
|
active={menu && page === 1}
|
||||||
|
onEnter={() => {
|
||||||
|
setMenu(true)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
onLeave={() => {
|
||||||
|
return setMenu(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<MenuItem
|
||||||
|
text={`帮助中心`}
|
||||||
|
active={menu && page === 2}
|
||||||
|
onEnter={() => {
|
||||||
|
setMenu(true)
|
||||||
|
setPage(2)
|
||||||
|
}}
|
||||||
|
onLeave={() => {
|
||||||
|
return setMenu(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<LinkItem
|
||||||
|
text={`企业服务`} href={`#`}/>
|
||||||
|
<LinkItem
|
||||||
|
text={`推广返利`} href={`#`}/>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{/* 登录 */}
|
||||||
|
{props.userCenter}
|
||||||
|
</Wrap>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 下拉菜单 */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
`shadow-lg`,
|
||||||
|
`overflow-hidden bg-[#fffe] backdrop-blur-sm`,
|
||||||
|
`transition-[opacity,padding,height] transition-discrete duration-200 ease-in-out`,
|
||||||
|
menu
|
||||||
|
? `delay-[0s,0s,0s] opacity-100 py-8 h-auto`
|
||||||
|
: `delay-[0s,0s,0.2s] opacity-0 py-0 h-0`,
|
||||||
|
].join(' ')}
|
||||||
|
onPointerEnter={() => setMenu(true)}
|
||||||
|
onPointerLeave={() => setMenu(false)}
|
||||||
|
>
|
||||||
|
{pages[page]}
|
||||||
|
</div>
|
||||||
|
</HeaderContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import {ReactNode} from 'react'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import prod from '@/assets/header/product/prod.svg'
|
|
||||||
import Link from 'next/link'
|
|
||||||
import custom from '@/assets/header/product/custom.svg'
|
|
||||||
import {DomesticLink} from '@/app/(root)/@header/_client/product'
|
|
||||||
|
|
||||||
export function Tab(props: {
|
|
||||||
selected: boolean
|
|
||||||
onSelect: () => void
|
|
||||||
children: ReactNode
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<li role="tab">
|
|
||||||
<button
|
|
||||||
className={[
|
|
||||||
`p-8 text-lg cursor-pointer border-r`,
|
|
||||||
props.selected ? `bg-gradient-to-r from-transparent to-blue-200 border-blue-400` : `border-gray-200`,
|
|
||||||
].join(' ')}
|
|
||||||
onClick={props.onSelect}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Domestic(props: {}) {
|
|
||||||
return (
|
|
||||||
<section role="tabpanel" className="flex gap-16 mr-16">
|
|
||||||
<div className="w-64 flex flex-col">
|
|
||||||
<h3 className="mb-6 font-bold flex items-center gap-3">
|
|
||||||
<Image src={prod} alt={`产品`} className={`w-10 h-=10`}/>
|
|
||||||
<span>代理产品</span>
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<DomesticLink
|
|
||||||
label={`动态IP`}
|
|
||||||
desc={`全国300+城市级定位节点`}
|
|
||||||
href={`/product?type=dynamic`}
|
|
||||||
discount={45}
|
|
||||||
/>
|
|
||||||
<DomesticLink
|
|
||||||
label={`长效静态IP`}
|
|
||||||
desc={`IP 资源覆盖全国`}
|
|
||||||
href={`/product?type=dynamic`}
|
|
||||||
discount={45}
|
|
||||||
/>
|
|
||||||
<DomesticLink
|
|
||||||
label={`固定IP`}
|
|
||||||
desc={`全国300+城市级定位节点`}
|
|
||||||
href={`/product?type=static`}
|
|
||||||
discount={45}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-64 flex flex-col gap-4">
|
|
||||||
<h3 className="font-bold mb-2 flex items-center gap-3">
|
|
||||||
<Image src={custom} alt="定制" className="w-10 h-10"/>
|
|
||||||
<span>业务定制</span>
|
|
||||||
</h3>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<p>优质/企业/精选IP</p>
|
|
||||||
<p className="text-gray-400 text-sm">
|
|
||||||
超 1000 家企业共同信赖之选!大客户经理全
|
|
||||||
程 1 对 1 沟通,随时为您排忧解难,提供 24
|
|
||||||
小时不间断支持
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Oversea(props: {}) {
|
|
||||||
return (
|
|
||||||
<section role="tabpanel">
|
|
||||||
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
54
src/app/(root)/@header/_server/user-center.tsx
Normal file
54
src/app/(root)/@header/_server/user-center.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Link from 'next/link'
|
||||||
|
import {cookies} from 'next/headers'
|
||||||
|
import {Button} from '@/components/ui/button'
|
||||||
|
import {AuthContext} from '@/lib/auth'
|
||||||
|
|
||||||
|
export type UserCenterProps = {}
|
||||||
|
|
||||||
|
export default async function UserCenter(props: UserCenterProps) {
|
||||||
|
|
||||||
|
const store = await cookies()
|
||||||
|
const info = store.get('auth_info')?.value
|
||||||
|
const data = info ? JSON.parse(info) as AuthContext : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center`}>
|
||||||
|
{data == undefined
|
||||||
|
? <>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className={`w-24 h-12 flex items-center justify-center lg:text-lg`}
|
||||||
|
>
|
||||||
|
<span>登录</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className={[
|
||||||
|
`w-20 lg:w-24 h-10 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
|
||||||
|
`transition-colors duration-200 ease-in-out`,
|
||||||
|
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span>注册</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
: <>
|
||||||
|
<Button>
|
||||||
|
进入控制台
|
||||||
|
</Button>
|
||||||
|
{/* profile */}
|
||||||
|
<div>
|
||||||
|
<img
|
||||||
|
src={data.payload.avatar}
|
||||||
|
alt="User Avatar"
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{data.payload.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,161 +1,14 @@
|
|||||||
'use client'
|
import Provider from '@/app/(root)/@header/_client/provider'
|
||||||
import {createContext, useCallback, useEffect, useMemo, useState} from 'react'
|
import UserCenter from '@/app/(root)/@header/_server/user-center'
|
||||||
import Link from 'next/link'
|
|
||||||
import Image from 'next/image'
|
|
||||||
import {LinkItem, MenuItem} from './_server/navs'
|
|
||||||
import SolutionMenu from './_client/solution'
|
|
||||||
import ProductMenu from './_client/product'
|
|
||||||
import HelpMenu from './_client/help'
|
|
||||||
import Wrap from '@/components/wrap'
|
|
||||||
import logo from '@/assets/logo.webp'
|
|
||||||
|
|
||||||
export const HeaderContext = createContext<{
|
|
||||||
setMenu: (value: boolean) => void
|
|
||||||
} | null>(null)
|
|
||||||
|
|
||||||
export type HeaderProps = {}
|
export type HeaderProps = {}
|
||||||
|
|
||||||
export default function Header(props: HeaderProps) {
|
export default async function Header(props: HeaderProps) {
|
||||||
|
|
||||||
// ======================
|
|
||||||
// 滚动条状态
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
const [scroll, setScroll] = useState(false) // Changed to false for client-side rendering
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
setScroll(window.scrollY > 48)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize scroll state on client
|
|
||||||
setScroll(window.scrollY > 48)
|
|
||||||
window.addEventListener('scroll', handleScroll)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('scroll', handleScroll)
|
|
||||||
}
|
|
||||||
}, [handleScroll])
|
|
||||||
|
|
||||||
// ======================
|
|
||||||
// 菜单状态
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
const [menu, setMenu] = useState(false)
|
|
||||||
const [page, setPage] = useState(0)
|
|
||||||
const pages = useMemo(() => [
|
|
||||||
<ProductMenu key={`product`}/>,
|
|
||||||
<SolutionMenu key={`solution`}/>,
|
|
||||||
<HelpMenu key={`help`}/>,
|
|
||||||
], [])
|
|
||||||
|
|
||||||
// ======================
|
|
||||||
// 渲染组件
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HeaderContext.Provider value={{setMenu}}>
|
<header className={`fixed top-0 w-full z-10`}>
|
||||||
<header className={`fixed top-0 w-full z-10`}>
|
<Provider
|
||||||
<div className={[
|
userCenter={<UserCenter/>}
|
||||||
`transition-[background, shadow] duration-200 ease-in-out`,
|
/>
|
||||||
menu
|
</header>
|
||||||
? `bg-[#fffe] backdrop-blur-sm`
|
|
||||||
: scroll
|
|
||||||
? `bg-[#fffe] backdrop-blur-sm shadow-lg`
|
|
||||||
: `bg-transparent shadow-none`,
|
|
||||||
].join(' ')}>
|
|
||||||
<Wrap className="h-20 max-md:h-16 flex justify-between">
|
|
||||||
<div className="flex justify-between gap-8">
|
|
||||||
{/* logo */}
|
|
||||||
<Link href="/" className={`flex items-center`}>
|
|
||||||
<Image src={logo} alt={`logo`} height={48}/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* 菜单 */}
|
|
||||||
<nav>
|
|
||||||
<ul className="h-full flex items-stretch max-lg:hidden">
|
|
||||||
<LinkItem text={`首页`} href={`/`}/>
|
|
||||||
<MenuItem
|
|
||||||
text={`产品订购`}
|
|
||||||
active={menu && page === 0}
|
|
||||||
onEnter={() => {
|
|
||||||
setMenu(true)
|
|
||||||
setPage(0)
|
|
||||||
}}
|
|
||||||
onLeave={() => {
|
|
||||||
return setMenu(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
text={`业务场景`}
|
|
||||||
active={menu && page === 1}
|
|
||||||
onEnter={() => {
|
|
||||||
setMenu(true)
|
|
||||||
setPage(1)
|
|
||||||
}}
|
|
||||||
onLeave={() => {
|
|
||||||
return setMenu(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<MenuItem
|
|
||||||
text={`帮助中心`}
|
|
||||||
active={menu && page === 2}
|
|
||||||
onEnter={() => {
|
|
||||||
setMenu(true)
|
|
||||||
setPage(2)
|
|
||||||
}}
|
|
||||||
onLeave={() => {
|
|
||||||
return setMenu(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<LinkItem
|
|
||||||
text={`企业服务`} href={`#`}/>
|
|
||||||
<LinkItem
|
|
||||||
text={`推广返利`} href={`#`}/>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{/* 登录 */}
|
|
||||||
<div className={`flex items-center`}>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className={`w-24 h-12 flex items-center justify-center lg:text-lg`}
|
|
||||||
>
|
|
||||||
<span>登录</span>
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
href="/login"
|
|
||||||
className={[
|
|
||||||
`w-20 lg:w-24 h-10 lg:h-12 bg-gradient-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
|
|
||||||
`transition-colors duration-200 ease-in-out`,
|
|
||||||
`from-blue-500 to-cyan-400 hover:from-blue-500 hover:to-cyan-300`,
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
<span>注册</span>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</Wrap>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 下拉菜单 */}
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
`shadow-lg`,
|
|
||||||
`overflow-hidden bg-[#fffe] backdrop-blur-sm`,
|
|
||||||
`transition-[opacity,padding,height] transition-discrete duration-200 ease-in-out`,
|
|
||||||
menu
|
|
||||||
? `delay-[0s,0s,0s] opacity-100 py-8 h-auto`
|
|
||||||
: `delay-[0s,0s,0.2s] opacity-0 py-0 h-0`,
|
|
||||||
].join(' ')}
|
|
||||||
onPointerEnter={() => setMenu(true)}
|
|
||||||
onPointerLeave={() => setMenu(false)}
|
|
||||||
>
|
|
||||||
{pages[page]}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
</HeaderContext.Provider>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,82 @@ export async function call<R = undefined>(endpoint: string, data: unknown): Prom
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 使用用户令牌的API调用函数
|
||||||
|
export async function callWithUserToken<R = undefined>(
|
||||||
|
endpoint: string,
|
||||||
|
data: unknown,
|
||||||
|
userToken: string,
|
||||||
|
onTokenExpired?: () => void
|
||||||
|
): Promise<ApiResponse<R>> {
|
||||||
|
try {
|
||||||
|
// 发送请求
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${userToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`http://${API_BASE_URL}${endpoint}`, requestOptions)
|
||||||
|
|
||||||
|
// 如果返回401未授权,可能是用户令牌过期
|
||||||
|
if (response.status === 401) {
|
||||||
|
// 通知调用者令牌已过期,需要刷新或重新登录
|
||||||
|
if (onTokenExpired) {
|
||||||
|
onTokenExpired()
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: '用户会话已过期,请重新登录',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应数据
|
||||||
|
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||||
|
if (type.indexOf('application/json') !== -1) {
|
||||||
|
const json = await response.json()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('响应不成功', `status=${response.status}`, json)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: json.message || '请求失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: json,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type.indexOf('text/plain') !== -1) {
|
||||||
|
const text = await response.text()
|
||||||
|
if (!response.ok) {
|
||||||
|
console.log('响应不成功', `status=${response.status}`, text)
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
status: response.status,
|
||||||
|
message: text || '请求失败',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: undefined as unknown as R,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
console.error('API call with user token failed:', e)
|
||||||
|
throw new Error('服务调用失败', {cause: e})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 统一的API响应类型
|
// 统一的API响应类型
|
||||||
export type ApiResponse<T = undefined> = {
|
export type ApiResponse<T = undefined> = {
|
||||||
success: false
|
success: false
|
||||||
|
|||||||
11
src/lib/auth.ts
Normal file
11
src/lib/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export type AuthContext = {
|
||||||
|
payload: Payload
|
||||||
|
permissions: Record<string, never>
|
||||||
|
metadata: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Payload = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
avatar: string
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user