From a16faadaab75cd597ba799b103a6c862e11a1fc7 Mon Sep 17 00:00:00 2001 From: luorijun Date: Fri, 28 Mar 2025 15:00:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=99=BB=E5=BD=95=E4=B8=8E?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E5=9B=9E=E6=98=BE=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- eslint.config.mjs | 35 ++-- package.json | 9 +- pnpm-lock.yaml | 21 +++ src/actions/auth/login.ts | 22 ++- src/actions/auth/verify.ts | 4 +- src/app/(auth)/login/captcha.tsx | 2 +- src/app/(auth)/login/page.tsx | 36 ++-- src/app/(root)/@header/_client/help.tsx | 6 +- .../@header/{_server => _client}/navs.tsx | 0 src/app/(root)/@header/_client/product.tsx | 83 ++++++++- src/app/(root)/@header/_client/provider.tsx | 144 ++++++++++++++++ src/app/(root)/@header/_server/product.tsx | 80 --------- .../(root)/@header/_server/user-center.tsx | 54 ++++++ src/app/(root)/@header/page.tsx | 163 +----------------- src/lib/api.ts | 76 ++++++++ src/lib/auth.ts | 11 ++ 17 files changed, 463 insertions(+), 285 deletions(-) rename src/app/(root)/@header/{_server => _client}/navs.tsx (100%) create mode 100644 src/app/(root)/@header/_client/provider.tsx delete mode 100644 src/app/(root)/@header/_server/product.tsx create mode 100644 src/app/(root)/@header/_server/user-center.tsx create mode 100644 src/lib/auth.ts diff --git a/README.md b/README.md index 3ac25e2..17f3fed 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ ## TODO -客户端令牌保存到缓存中 +保存客户端信息时用 jwt 序列化 diff --git a/eslint.config.mjs b/eslint.config.mjs index 3c31c93..1b4a285 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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 diff --git a/package.json b/package.json index 8947085..1803ac8 100644 --- a/package.json +++ b/package.json @@ -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" ] } -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c71989..0825811 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts index 313b4fa..e44b7f1 100644 --- a/src/actions/auth/login.ts +++ b/src/actions/auth/login.ts @@ -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 { try { // 尝试登录 @@ -24,9 +28,7 @@ export async function login(props: LoginParams): Promise { 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 { 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, diff --git a/src/actions/auth/verify.ts b/src/actions/auth/verify.ts index d1c052f..f6f5b7b 100644 --- a/src/actions/auth/verify.ts +++ b/src/actions/auth/verify.ts @@ -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 { diff --git a/src/app/(auth)/login/captcha.tsx b/src/app/(auth)/login/captcha.tsx index f00833c..ba45a77 100644 --- a/src/app/(auth)/login/captcha.tsx +++ b/src/app/(auth)/login/captcha.tsx @@ -76,7 +76,7 @@ export default function Captcha(props: CaptchaProps) { 取消 diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index 1dd5fde..b6b72d9 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -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) => { - - } - // 处理表单提交 const onSubmit = async (values: FormValues) => { try { diff --git a/src/app/(root)/@header/_client/help.tsx b/src/app/(root)/@header/_client/help.tsx index cf5c79a..61b9f39 100644 --- a/src/app/(root)/@header/_client/help.tsx +++ b/src/app/(root)/@header/_client/help.tsx @@ -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: { ) -} \ No newline at end of file +} diff --git a/src/app/(root)/@header/_server/navs.tsx b/src/app/(root)/@header/_client/navs.tsx similarity index 100% rename from src/app/(root)/@header/_server/navs.tsx rename to src/app/(root)/@header/_client/navs.tsx diff --git a/src/app/(root)/@header/_client/product.tsx b/src/app/(root)/@header/_client/product.tsx index 30f5d7a..af399da 100644 --- a/src/app/(root)/@header/_client/product.tsx +++ b/src/app/(root)/@header/_client/product.tsx @@ -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 ( +
  • + +
  • + ) +} + +export function Domestic(props: {}) { + return ( +
    +
    +

    + {`产品`} + 代理产品 +

    + + + + +
    +
    +

    + 定制 + 业务定制 +

    +
    +

    优质/企业/精选IP

    +

    + 超 1000 家企业共同信赖之选!大客户经理全 + 程 1 对 1 沟通,随时为您排忧解难,提供 24 + 小时不间断支持 +

    +
    +
    +
    + ) +} + +export function Oversea(props: {}) { + return ( +
    + +
    + ) +} + export default function ProductMenu() { const [type, setType] = useState('domestic') diff --git a/src/app/(root)/@header/_client/provider.tsx b/src/app/(root)/@header/_client/provider.tsx new file mode 100644 index 0000000..aa6c18a --- /dev/null +++ b/src/app/(root)/@header/_client/provider.tsx @@ -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(() => [ + , + , + , + ], []) + + // ====================== + // 渲染组件 + // ====================== + + return ( + +
    + +
    + {/* logo */} + + {`logo`} + + + {/* 菜单 */} + +
    + {/* 登录 */} + {props.userCenter} +
    +
    + + {/* 下拉菜单 */} +
    setMenu(true)} + onPointerLeave={() => setMenu(false)} + > + {pages[page]} +
    +
    + ) +} + + + + diff --git a/src/app/(root)/@header/_server/product.tsx b/src/app/(root)/@header/_server/product.tsx deleted file mode 100644 index de89e6f..0000000 --- a/src/app/(root)/@header/_server/product.tsx +++ /dev/null @@ -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 ( -
  • - -
  • - ) -} - -export function Domestic(props: {}) { - return ( -
    -
    -

    - {`产品`} - 代理产品 -

    - - - - -
    -
    -

    - 定制 - 业务定制 -

    -
    -

    优质/企业/精选IP

    -

    - 超 1000 家企业共同信赖之选!大客户经理全 - 程 1 对 1 沟通,随时为您排忧解难,提供 24 - 小时不间断支持 -

    -
    -
    -
    - ) -} - -export function Oversea(props: {}) { - return ( -
    - -
    - ) -} diff --git a/src/app/(root)/@header/_server/user-center.tsx b/src/app/(root)/@header/_server/user-center.tsx new file mode 100644 index 0000000..a12b859 --- /dev/null +++ b/src/app/(root)/@header/_server/user-center.tsx @@ -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 ( +
    + {data == undefined + ? <> + + 登录 + + + 注册 + + + : <> + + {/* profile */} +
    + User Avatar +

    + {data.payload.name} +

    +
    + + } +
    + ) +} diff --git a/src/app/(root)/@header/page.tsx b/src/app/(root)/@header/page.tsx index 1d29d5f..07f4227 100644 --- a/src/app/(root)/@header/page.tsx +++ b/src/app/(root)/@header/page.tsx @@ -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(() => [ - , - , - , - ], []) - - // ====================== - // 渲染组件 - // ====================== - +export default async function Header(props: HeaderProps) { return ( - -
    -
    - -
    - {/* logo */} - - {`logo`} - - - {/* 菜单 */} - -
    - {/* 登录 */} -
    - - 登录 - - - 注册 - -
    -
    -
    - - {/* 下拉菜单 */} -
    setMenu(true)} - onPointerLeave={() => setMenu(false)} - > - {pages[page]} -
    -
    -
    +
    + } + /> +
    ) } - - - - diff --git a/src/lib/api.ts b/src/lib/api.ts index 34ec99c..b247121 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -123,6 +123,82 @@ export async function call(endpoint: string, data: unknown): Prom } } +// 使用用户令牌的API调用函数 +export async function callWithUserToken( + endpoint: string, + data: unknown, + userToken: string, + onTokenExpired?: () => void +): Promise> { + 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 = { success: false diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..c94f32a --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,11 @@ +export type AuthContext = { + payload: Payload + permissions: Record + metadata: Record +} + +export type Payload = { + id: number + name: string + avatar: string +}