完善登录与状态回显处理

This commit is contained in:
2025-03-28 15:00:46 +08:00
parent e16ef8e509
commit a16faadaab
17 changed files with 463 additions and 285 deletions

View File

@@ -1,3 +1,3 @@
## TODO
客户端令牌保存到缓存中
保存客户端信息时用 jwt 序列化

View File

@@ -1,23 +1,38 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
import stylistic from '@stylistic/eslint-plugin'
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
});
})
// noinspection SpellCheckingInspection
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
...compat.extends('next/core-web-vitals', 'next/typescript'),
{
plugins: {
'@stylistic': stylistic,
},
rules: {
'semi': ['error', 'never'],
'@typescript-eslint/no-empty-object-type': '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

View File

@@ -32,15 +32,16 @@
"zod": "^3.24.2"
},
"devDependencies": {
"eslint": "^9",
"@eslint/eslintrc": "^3",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-config-next": "15.2.1",
"@next/eslint-plugin-next": "^15.2.1",
"@stylistic/eslint-plugin": "^4.2.0",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.2.1",
"eslint-plugin-react-hooks": "^5.2.0",
"tailwindcss": "^4",
"typescript": "^5"
},
@@ -50,4 +51,4 @@
"canvas"
]
}
}
}

21
pnpm-lock.yaml generated
View File

@@ -78,6 +78,9 @@ importers:
'@next/eslint-plugin-next':
specifier: ^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':
specifier: ^4
version: 4.0.9
@@ -710,6 +713,12 @@ packages:
'@standard-schema/utils@0.3.0':
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':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
@@ -2783,6 +2792,18 @@ snapshots:
'@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/helpers@0.5.15':

View File

@@ -1,18 +1,22 @@
'use server'
import {cookies} from 'next/headers'
import {ApiResponse, call} from '@/lib/api'
import {AuthContext} from '@/lib/auth'
export interface LoginParams {
username: string;
password: string;
remember?: boolean;
username: string
password: string
remember?: boolean
}
type LoginResp = {
token: string;
expires: number;
token: string
expires: number
auth: AuthContext
}
export async function login(props: LoginParams): Promise<ApiResponse> {
try {
// 尝试登录
@@ -24,9 +28,7 @@ export async function login(props: LoginParams): Promise<ApiResponse> {
if (!result.success) {
return result
}
const data = result.data
console.log('login', data)
// 计算过期时间
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',
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 {
success: true,

View File

@@ -6,8 +6,8 @@ import {ApiResponse, call} from '@/lib/api'
export interface VerifyParams {
phone: string;
captcha: string; // 添加验证码字段
phone: string
captcha: string // 添加验证码字段
}
export default async function verify(props: VerifyParams): Promise<ApiResponse> {

View File

@@ -76,7 +76,7 @@ export default function Captcha(props: CaptchaProps) {
</Button>
<Button
onClick={() => handleVerifyCaptcha()}
onClick={handleVerifyCaptcha}
>
</Button>

View File

@@ -79,26 +79,32 @@ export default function LoginPage(props: LoginPageProps) {
}
// 发送验证码
const resp = await verify({
phone: username,
captcha: captchaCode,
})
let resp: ApiResponse
try {
resp = await verify({
phone: username,
captcha: captchaCode,
})
}
catch (e) {
toast.error(`请求失败:${e}`)
return false
}
// 处理验证码发送结果
let waiting = 60
if (!resp.success) {
if (resp.status == 429) {
setShowCaptcha(false)
waiting = parseInt(resp.message)
console.log(resp.message)
toast.error('发送频率过快', {
description: '请稍后再试',
})
}
else {
if (resp.status != 429) {
toast.error(resp.message)
return true
}
setShowCaptcha(false)
waiting = parseInt(resp.message)
console.log(resp.message)
toast.error('发送频率过快', {
description: '请稍后再试',
})
}
else {
setShowCaptcha(false)
@@ -125,10 +131,6 @@ export default function LoginPage(props: LoginPageProps) {
return false
}, [username])
const setWaiting = (resp: ApiResponse<undefined>) => {
}
// 处理表单提交
const onSubmit = async (values: FormValues) => {
try {

View File

@@ -1,5 +1,5 @@
import Link from "next/link"
import Image from "next/image"
import Image, {StaticImageData} from 'next/image'
import Wrap from "@/components/wrap"
import h01 from '@/assets/header/help/01.svg'
import h02 from '@/assets/header/help/02.svg'
@@ -44,7 +44,7 @@ export default function HelpMenu() {
}
function Column(props: {
icon: any
icon: StaticImageData
title: string
items: {
lead: string
@@ -64,4 +64,4 @@ function Column(props: {
</ul>
</div>
)
}
}

View File

@@ -1,16 +1,89 @@
'use client'
import {useContext, useState} from 'react'
import {ReactNode, useContext, useState} from 'react'
import Wrap from '@/components/wrap'
import Image from 'next/image'
import anno from '@/assets/header/product/anno.svg'
import {Domestic, Oversea, Tab} from '@/app/(root)/@header/_server/product'
import Link from 'next/link'
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'
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() {
const [type, setType] = useState<TabType>('domestic')

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

View File

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

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

View File

@@ -1,161 +1,14 @@
'use client'
import {createContext, useCallback, useEffect, useMemo, useState} from 'react'
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)
import Provider from '@/app/(root)/@header/_client/provider'
import UserCenter from '@/app/(root)/@header/_server/user-center'
export type HeaderProps = {}
export default 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`}/>,
], [])
// ======================
// 渲染组件
// ======================
export default async function Header(props: HeaderProps) {
return (
<HeaderContext.Provider value={{setMenu}}>
<header className={`fixed top-0 w-full z-10`}>
<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="/" 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>
<header className={`fixed top-0 w-full z-10`}>
<Provider
userCenter={<UserCenter/>}
/>
</header>
)
}

View File

@@ -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响应类型
export type ApiResponse<T = undefined> = {
success: false

11
src/lib/auth.ts Normal file
View 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
}