企业级官网全栈(React·Next.js·Tailwind·Axios·Headless UI·RHF·i18n)实战教程-第五篇:登录态与权限控制

第五篇:登录态与权限控制

本篇是整个企业官网体系中最核心的一篇

如果第四篇解决的是「用户如何进来」,这一篇解决的是:

用户进来之后,你如何知道他是谁、能做什么、能不能访问某个页面。


一、本篇目标

我们将完整解决以下问题:

  • 登录成功后,登录态如何保存

  • JWT / Cookie / Token 如何取舍

  • Next.js App Router 下如何做 middleware 鉴权

  • [locale] 场景下的国际化路由保护

  • Header 如何根据登录态自动切换

  • 如何设计「受保护路由」而不是到处写判断


1.为什么不用 LocalStorage(重要)

XSS 风险极高
middleware 访问不到
SSR 无法读取

企业级项目一律不用 LocalStorage 存登录态


2.推荐方案(本教程采用)

JWT + HttpOnly Cookie

  • JWT:后端签发

  • Cookie:HttpOnly + Secure

  • 前端:

    • 看不到 token 内容

    • 但可以知道"是否已登录"

优势:

  • 防 XSS

  • proxy(middleware) 可读取

  • SSR / RSC 安全


三、登录接口约定(前后端共识)

bash 复制代码
POST /auth/login

成功响应

bash 复制代码
Set-Cookie: auth_token=xxx; HttpOnly; Path=/; Secure
javascript 复制代码
{
  "user": {
    "id": "u_123",
    "email": "user@example.com",
    "name": "User"
  }
}

前端 不保存 token,只关心 user 信息


四、登录态读取策略(三层)

层级 作用 能否访问 Cookie
proxy(middleware) 路由拦截
server component SSR
client component UI ❌(只能通过接口)

五、middleware 鉴权(含 [locale]

1.受保护路由定义

javascript 复制代码
// src/lib/auth/route.ts
export const protectedRoutes = ['/user', '/settings']

export function isProtectedPath(pathname: string) {
    return protectedRoutes.some((route) => pathname.startsWith(route))
}

2.proxy.ts(middleware)(核心代码)

javascript 复制代码
import { defaultLocale, locales } from '@/i18n/config';
import { isProtectedPath } from '@/lib/auth/route';
import createMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';

const intlMiddleware = createMiddleware({
    locales,
    defaultLocale,
    localePrefix: 'always'
})

const PUBLIC_FILE = /\.(.*)$/

// Next.js 15+ proxy 约定:导出默认函数处理请求
export default function proxy(request: NextRequest) {
    const { pathname } = request.nextUrl

    if (
        PUBLIC_FILE.test(pathname) ||
        pathname.startsWith('/api')
    ) {
        // For public files and API routes, only apply i18n
        return intlMiddleware(request);
    }

    const locale = pathname.split('/')[1]
    const pathnameWithoutLocale = pathname.replace(`/${locale}`, '') || '/'

    const token = request.cookies.get('auth_token')?.value

    if (isProtectedPath(pathnameWithoutLocale) && !token) {
        const loginUrl = new URL(`/${locale}/login`, request.url)
        return NextResponse.redirect(loginUrl)
    }

    // For other requests, apply i18n
    return intlMiddleware(request);
}

export const config = {
    matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}

✔ 同时支持国际化

✔ 不依赖客户端

✔ 企业级标准写法


六、获取当前用户信息(Server Action / API)

1. /auth/me 接口

复制代码
GET /auth/me
javascript 复制代码
{
  "id": "u_123",
  "email": "user@example.com",
  "name": "User"
}

2.前端封装

javascript 复制代码
// src/types/user.ts
export interface User {
    id: string
    email: string
    name: string
    role: 'admin' | 'user'
}
javascript 复制代码
// src/services/auth.service.ts
import { LoginFormValues } from '@/forms/schemas/login.schema'
import { RegisterFormValues } from '@/forms/schemas/register.schema'
import { http } from '@/lib/http/client'
import { ApiSingleResponse } from '@/types'
import { User } from '@/types/user'

interface LoginResponse {
    token: string
    user: User
}

export const authService = {
    login(data: LoginFormValues) {
        return http.post<ApiSingleResponse<LoginResponse>>('/auth/login', data)
    },

    register(data: RegisterFormValues) {
        return http.post<ApiSingleResponse<LoginResponse>>('/auth/register', data)
    },

    fetchCurrentUser() {
        return http.get<ApiSingleResponse<LoginResponse>>('/auth/me')
    }
}

七、全局登录态管理(不使用 Redux)

登录态不是全局状态,是请求状态

我们用:

  • React Context + 请求

  • 页面级拉取


1.AuthContext

添加cookies-next和@types/cookie依赖
javascript 复制代码
pnpm add cookies-next
pnpm add -D @types/cookie
/src/context/AuthContext.tsx
javascript 复制代码
'use client'

import { fetchCurrentUser } from '@/services/auth.service'
import { User } from '@/types/user'
import { deleteCookie, getCookie, setCookie } from 'cookies-next'
import { useRouter } from 'next/navigation'
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'

interface AuthContextType {
  user: User | null
  loading: boolean
  login: (user: User, token: string) => void
  logout: () => void
  setUser: (user: User | null) => void
  isAuthenticated: boolean
}

const AuthContext = createContext<AuthContextType | null>(null)

interface AuthProviderProps {
  children: ReactNode
}

export function AuthProvider({ children }: AuthProviderProps) {
  const [user, setUser] = useState<User | null>(null)
  const [loading, setLoading] = useState<boolean>(true)
  const router = useRouter()

  // 初始化:从 Cookie 取 Token,调用 /api/me 获取用户信息
  useEffect(() => {
    const initUser = async () => {
      try {
        // 1. 从 Cookie 获取 Token
        const token = getCookie('auth_token')
        if (!token) {
          setLoading(false)
          return
        }

        // 2. 调用 /api/me 获取用户信息
        const res = await fetchCurrentUser()

        if (!res.data) throw new Error('用户信息获取失败')
        const userData = res.data

        // 3. 将用户信息存入 Context
        setUser(userData)
      } catch (error) {
        console.error('初始化用户状态失败:', error)
        // Token 无效时清除 Cookie
        deleteCookie('auth_token')
      } finally {
        setLoading(false)
      }
    }

    initUser()
  }, [])

  // 登录方法:存储用户到 Context,Token 存入 Cookie
  const login = (userInfo: User, token: string) => {
    setUser(userInfo)
    // 设置 Cookie(配置有效期、域名等,根据业务调整)
    setCookie('auth_token', token, {
      maxAge: 60 * 60 * 24 * 7, // 7天有效期
      path: '/', // 全站可用
      secure: process.env.NODE_ENV === 'production', // 生产环境仅 HTTPS
      httpOnly: false, // 客户端需要读取的话设为 false(若仅服务端用可设为 true)
      sameSite: 'lax',
    })
    router.push('/') // 登录后跳转首页
  }

  // 登出方法:清空 Context 和 Cookie
  const logout = () => {
    setUser(null)
    deleteCookie('auth_token')
    router.push('/login') // 登出后跳登录页
  }

  // 简化鉴权判断
  const isAuthenticated = !!user

  return (
    <AuthContext.Provider
      value={{
        user,
        loading,
        login,
        logout,
        setUser,
        isAuthenticated,
      }}
    >
      {children}
    </AuthContext.Provider>
  )
}

export function useAuth() {
  const context = useContext(AuthContext)
  if (!context) {
    throw new Error('useAuth 必须在 AuthProvider 内部使用')
  }
  return context
}

八、Header 登录态联动(关键)

1.在 layout 中注入 user

javascript 复制代码
import '@/app/globals.css'

import { Footer } from '@/components/layout/Footer'
import { Header } from '@/components/layout/Header'
import { AuthProvider } from '@/context/AuthContext'
import { Locale, locales } from '@/i18n/config'
import { NextIntlClientProvider } from 'next-intl'
import { notFound } from 'next/navigation'

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ locale: Locale }>
}) {
  const { locale } = await params

  if (!locales.includes(locale as Locale)) {
    notFound()
  }

  const messages = (await import(`@/i18n/messages/${locale}.json`)).default

  return (
    <html lang={locale}>
      <body className="min-h-screen bg-background text-foreground">
         <NextIntlClientProvider locale={locale} messages={messages}>
           <AuthProvider>
             <Header />
             {children}
             <Footer />
           </AuthProvider>
         </NextIntlClientProvider>
      </body>
    </html>
  )
}

2.Header 中使用

javascript 复制代码
'use client'

import { useAuth } from '@/context/AuthContext'
import { useTranslations } from 'next-intl'
import Link from 'next/link'
import { MainNav } from './MainNav'
import { UserMenu } from './UserMenu'

// 临时模拟登录态(后续会替换为真实 auth)
const mockUser = {
  // email: 'user@example.com',
  email: '',
}

export function Header() {
  const { user } = useAuth()

  const isLoggedIn = Boolean(user)
  const t = useTranslations()

  return (
    <header className="border-b">
      <div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6">
        <div className="flex items-center space-x-8">
          <Link href="/" className="text-lg font-bold">
            Enterprise
          </Link>
          <MainNav />
        </div>

        <div>
          {!isLoggedIn ? (
            <Link href="/login" className="text-sm font-medium">
              {t('auth.login')}
            </Link>
          ) : (
            <div className="flex items-center space-x-3">
              <Link href="/user" className="text-sm">
                {t('user_menu.user_center')}
              </Link>
              <span className="text-muted-foreground">|</span>
              <UserMenu email={user?.email || ''} />
            </div>
          )}
        </div>
      </div>
    </header>
  )
}

九、修改相关页面和代码

login/page.tsx

javascript 复制代码
// src/app/[locale]/(auth)/login/page.tsx
'use client'

import { CaptchaCanvas } from '@/components/form/CaptchaCanvas'
import { PasswordInput } from '@/components/form/PasswordInput'
import { TextInput } from '@/components/form/TextInput'
import { Button } from '@/components/ui/button'
import { useAuth } from '@/context/AuthContext'
import { useLoginForm } from '@/forms/hooks/useLoginForm'
import { authService } from '@/services/auth.service'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { Controller } from 'react-hook-form'

export default function LoginPage() {
  const form = useLoginForm()
  const t = useTranslations()
  const [loading, setLoading] = useState(false)
  const { login } = useAuth()

  const onSubmit = form.handleSubmit(async (data) => {
    setLoading(true)
    try {
      const res = await authService.login(data)
      console.log(res)
      const { token, user } = res.data.result.data
      login(user, token)
    } catch (error) {
      console.error('登录出错:', error)
    } finally {
      setLoading(false)
    }
  })

  return (
    <div className="mx-auto max-w-md py-16">
      <h1 className="mb-6 text-2xl font-bold">{t('login.title')}</h1>
      <form onSubmit={onSubmit} className="mx-auto mt-10 max-w-md space-y-4">
        <Controller
          control={form.control}
          name="email"
          render={({ field, fieldState }) => (
            <div>
              <TextInput
                type="email"
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.email')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Controller
          control={form.control}
          name="password"
          render={({ field, fieldState }) => (
            <div>
              <PasswordInput
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.password')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Controller
          control={form.control}
          name="captcha"
          render={({ field, fieldState }) => (
            <div>
              <CaptchaCanvas
                value={field.value}
                onChange={field.onChange}
                placeholder={t('register.captcha')}
              />
              {fieldState.error && (
                <p className="text-xs text-destructive">{t(fieldState.error?.message || '')}</p>
              )}
            </div>
          )}
        />

        <Button type="submit" className="w-full" disabled={loading}>
          {t('login.submit')}
        </Button>
      </form>
    </div>
  )
}

register/pag.tsx

javascript 复制代码
'use client'

import { Button } from '@/components/ui/button'
import { useAuth } from '@/context/AuthContext'
import { FormAgree } from '@/forms/components/FormAgree'
import { FormInput } from '@/forms/components/FormInput'
import { useRegisterForm } from '@/forms/hooks/useRegisterForm'
import { authService } from '@/services/auth.service'
import { useTranslations } from 'next-intl'
import { useState } from 'react'
import { FormProvider } from 'react-hook-form'

export default function RegisterPage() {
  const methods = useRegisterForm()
  const t = useTranslations('register')
  const [countdown, setCountdown] = useState(0)
  const [isSending, setIsSending] = useState(false)
  const [loading, setLoading] = useState(false)
  const { login } = useAuth()

  const startCountdown = () => {
    setCountdown(60)
    const timer = setInterval(() => {
      setCountdown((prev) => {
        if (prev <= 1) {
          clearInterval(timer)
          return 0
        }
        return prev - 1
      })
    }, 1000)
  }

  const handleSendCode = async () => {
    if (countdown > 0) return

    const phone = methods.getValues('phone')
    if (!phone) {
      methods.setError('phone', { message: 'form.phone.required' })
      return
    }

    setIsSending(true)
    try {
      await new Promise((resolve) => setTimeout(resolve, 1000))
      startCountdown()
    } catch (error) {
      console.error('Failed to send verification code:', error)
    } finally {
      setIsSending(false)
    }
  }

  const onSubmit = methods.handleSubmit(async (values) => {
    try {
      setLoading(true)
      const res = await authService.register(values)
      console.log(res)
      const { token, user } = res.data.result.data
      login(user, token)
    } catch (error) {
      console.error('注册出错:', error)
    } finally {
      setLoading(false)
    }
  })

  return (
    <div className="mx-auto max-w-md py-16">
      <h1 className="mb-6 text-2xl font-bold">{t('title')}</h1>

      <FormProvider {...methods}>
        <form onSubmit={onSubmit} className="space-y-4">
          <FormInput
            label={t('email')}
            {...methods.register('email')}
            error={methods.formState.errors.email}
          />

          <FormInput
            label={t('phone')}
            {...methods.register('phone')}
            error={methods.formState.errors.phone}
          />

          <FormInput
            type="password"
            label={t('password')}
            {...methods.register('password')}
            error={methods.formState.errors.password}
          />

          <FormInput
            label={t('captcha')}
            {...methods.register('captcha')}
            error={methods.formState.errors.captcha}
          />

          <div className="space-y-1">
            <div
              className={`flex gap-2 ${methods.formState.errors.code ? 'items-center' : 'items-end'}`}
            >
              <FormInput
                label={t('code')}
                className="flex-1"
                {...methods.register('code')}
                error={methods.formState.errors.code}
              />
              <Button
                type="button"
                variant="outline"
                onClick={handleSendCode}
                disabled={countdown > 0 || isSending}
                className="px-3 shrink-0"
              >
                {countdown > 0 ? `${countdown}s` : isSending ? t('sending') : t('send_code')}
              </Button>
            </div>
          </div>

          <FormAgree
            name="agree"
            control={methods.control}
            label={t('agree_terms')}
            error={methods.formState.errors.agree}
          />

          <Button type="submit" className="w-full" disabled={loading}>
            {t('submit')}
          </Button>
        </form>
      </FormProvider>
    </div>
  )
}

修改axios,添加interceptors.request处理

javascript 复制代码
// src/lib/http/client.ts
import axios from 'axios'
import { getCookie } from 'cookies-next'

if (!process.env.NEXT_PUBLIC_API_BASE) {
    throw new Error('NEXT_PUBLIC_API_BASE is not defined')
}


export const http = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_BASE,
    timeout: 10000,
    withCredentials: true, // 为将来登录态做准备
})

http.interceptors.request.use(
    config => {
        const token = getCookie('auth_token')
        if (token) {
            config.headers['Authorization'] = `Bearer ${token}`
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)

http.interceptors.response.use(
    response => {
        if (response.data && response.data.header && typeof response.data.header.isSuccess === 'boolean') {
            const apiResponse = response.data;
            if (!apiResponse.header.isSuccess) {
                alert(apiResponse.header.message || 'API Error');
                throw new Error(apiResponse.header.message || 'API Error');
            }
        }
        return response;
    },
    error => {
        console.error(error)
        const message =
            error.response?.data?.message ||
            error.message ||
            'Network Error'
        return Promise.reject(new Error(message))
    }
)

九、受保护页面示例

javascript 复制代码
// app/[locale]/(user)/user/page.tsx
export default function UserPage() {
  return <div>用户中心(必须登录)</div>
}

是否登录?proxy 已经替你挡在门外


十、常见误区总结(非常重要)

❌ 用 LocalStorage 存 token

❌ 在组件里到处写 if (!user) redirect

❌ 用 Redux 管登录态

✅ Cookie + proxy(middleware)

✅ 请求即状态

✅ Header 自然联动


十一、到这里你已经具备的能力

  • 企业级登录态设计能力

  • App Router + proxy 鉴权

  • 国际化路由保护

  • 可扩展的权限体系


附录:Mock.js 模拟 API(本地开发)

本附录用于补全第五篇与第四篇的开发体验

  • 后端未完成

  • 但前端需要完整跑通:登录 / 注册 / 鉴权 / Header 联动

我们将引入 Mock.js + Axios 拦截 来模拟真实 API。


一、为什么选择 Mock.js(而不是 MSW)

在你的当前阶段:

  • 需要快速返回数据

  • 不需要模拟复杂网络层

  • 希望逻辑尽量简单、可控

Mock.js 更适合本教程阶段

等进入第七 / 八篇(真实后端联调)时,可以再切换到 MSW。


二、安装依赖

复制代码
pnpm add mockjs
pnpm add -D @types/mockjs

三、Mock 目录结构(推荐)

javascript 复制代码
src/
├─ mock/
│  ├─ modules          
│  │  ├─ user.mock.ts       # 用户 增删改查
│  │  └─ auth.mock.ts       # 登录 / 注册 / me
│  │
│  ├─ index.ts           # Mock 入口
│  └─ setup.ts           # 初始化
│  
└─ providers.tsx

四、Mock 初始化入口

src/mock/setup.ts

javascript 复制代码
import './modules/auth.mock';
import './modules/user.mock';

src/mock/index.ts

javascript 复制代码
export async function setupMock() {
    if (
        process.env.NODE_ENV !== 'development' ||
        process.env.NEXT_PUBLIC_USE_MOCK !== 'true'
    ) {
        return
    }

    if (typeof window === 'undefined') return

    const { setupAuthMock } = await import('./modules/auth.mock')
    const { setupUserMock } = await import('./modules/user.mock')
    setupAuthMock()
    setupUserMock()

    console.info('[Mock] API mock enabled')
}

五、Mock 实现

src/mock/modules/auth.mock.ts

javascript 复制代码
import { ApiResponseHeader, ApiSingleResult } from '@/types/api-response';
import { User } from '@/types/user';
import Mock from 'mockjs';

const users = [
    {
        id: '1',
        email: 'admin@example.com',
        password: '123456',
        name: 'Admin',
        role: 'admin',
    },
]

function createApiResponse<T>(
    data: T,
    code: number = 200,
    message: string = 'Success'
): {
    header: ApiResponseHeader;
    result: ApiSingleResult<T>;
} {
    return {
        header: {
            code,
            message,
            isSuccess: code === 200,
        },
        result: {
            data,
        },
    }
}

export function setupAuthMock() {
    Mock.mock('/api/auth/login', 'post', (req) => {
        const body = JSON.parse(req.body)

        const user = users.find(
            (u) => u.email === body.email && u.password === body.password
        )

        if (!user) {
            return createApiResponse(null, 401, 'Invalid credentials')
        }

        const loginResult = {
            token: 'mock-jwt-token',
            user: {
                id: user.id,
                email: user.email,
                name: user.name,
                role: user.role,
            },
        }

        return createApiResponse(loginResult)
    })

    Mock.mock('/api/auth/me', 'get', () => {
        const userData: User = users[0] as User

        return createApiResponse(userData)
    })

    Mock.mock('/api/auth/logout', 'post', () => {
        return createApiResponse(null)
    })

    Mock.mock('/api/auth/register', 'post', (req) => {
        const body = JSON.parse(req.body)
        const user: User = {
            id: Mock.mock('@guid'),
            email: body.email,
            name: body.name,
            role: 'user',
        }
        const registerResult = {
            token: 'mock-jwt-token',
            user: user,
        }
        return createApiResponse(registerResult)
    })
}

src/mock/modules/user.mock.ts

javascript 复制代码
// src/mock/modules/user.mock.ts
import { ApiPagedListResult, ApiResponseHeader, ApiSingleResult } from '@/types/api-response';
import { User } from '@/types/user';
import Mock from 'mockjs';

const mockUsers: User[] = Mock.mock({
    'list|10-20': [
        {
            'id|+1': 1,
            email: '@email',
            name: '@cname',
            'role|1': ['admin', 'user', 'moderator'],
        },
    ],
}).list.map((item: { id: number, email: string, name: string, role: string }) => ({
    id: item.id.toString(),
    email: item.email,
    name: item.name,
    role: item.role,
}));

function createSingleApiResponse<T>(
    data: T,
    code: number = 200,
    message: string = 'Success'
): {
    header: ApiResponseHeader;
    result: ApiSingleResult<T>;
} {
    return {
        header: {
            code,
            message,
            isSuccess: code === 200,
        },
        result: {
            data,
        },
    };
}

function createPagedListApiResponse<T>(
    data: T[],
    page: number,
    size: number,
    total: number,
    code: number = 200,
    message: string = 'Success'
): {
    header: ApiResponseHeader;
    result: ApiPagedListResult<T>;
} {
    const totalPage = Math.ceil(total / size);

    return {
        header: {
            code,
            message,
            isSuccess: code === 200,
        },
        result: {
            data,
            pagination: {
                page,
                size,
                total,
                totalPage,
            },
        },
    };
}

export function setupUserMock() {
    Mock.mock(RegExp('/api/users/\\d+'), 'get', (options) => {
        const url = options.url;
        const userId = url.split('/').pop();

        const user = mockUsers.find(u => u.id === userId);

        if (!user) {
            return createSingleApiResponse(null, 404, 'User not found');
        }

        return createSingleApiResponse(user);
    });

    Mock.mock('/api/users', 'get', (options) => {
        const url = new URL(options.url, 'http://localhost');
        const page = parseInt(url.searchParams.get('page') || '1');
        const size = parseInt(url.searchParams.get('size') || '10');

        const start = (page - 1) * size;
        const end = start + size;
        const pagedUsers = mockUsers.slice(start, end);

        return createPagedListApiResponse(
            pagedUsers,
            page,
            size,
            mockUsers.length
        );
    });

    Mock.mock('/api/users', 'post', (req) => {
        const body = JSON.parse(req.body);

        const newUser: User = {
            id: (mockUsers.length + 1).toString(),
            ...body,
        };

        mockUsers.push(newUser);

        return createSingleApiResponse(newUser);
    });

    Mock.mock(RegExp('/api/users/\\d+'), 'put', (req) => {
        const url = req.url;
        const userId = url.split('/').pop();
        const body = JSON.parse(req.body);

        const userIndex = mockUsers.findIndex(u => u.id === userId);

        if (userIndex === -1) {
            return createSingleApiResponse(null, 404, 'User not found');
        }

        mockUsers[userIndex] = { ...mockUsers[userIndex], ...body };

        return createSingleApiResponse(mockUsers[userIndex]);
    });
}

⚠️ 注意:Mock.js 无法真正设置 HttpOnly Cookie

我们在本地开发阶段:

  • 假设 cookie 已存在

  • 专注前端流程与 UI


六、在 Next.js 中启用 Mock(关键)

1.创建src/app/[locale]/providers.tsx

javascript 复制代码
'use client'

import { setupMock } from '@/mock'
import { useEffect } from 'react'

export function Providers({ children }: { children: React.ReactNode }) {
  useEffect(() => {
    setupMock()
  }, [])

  return <>{children}</>
}

1.在 src/app/[locale]/layout.tsx 中添加providers

javascript 复制代码
import '@/app/globals.css'

import { Footer } from '@/components/layout/Footer'
import { Header } from '@/components/layout/Header'
import { AuthProvider } from '@/context/AuthContext'
import { Locale, locales } from '@/i18n/config'
import { NextIntlClientProvider } from 'next-intl'
import { notFound } from 'next/navigation'
import { Providers } from './providers'

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode
  params: Promise<{ locale: Locale }>
}) {
  const { locale } = await params

  if (!locales.includes(locale as Locale)) {
    notFound()
  }

  const messages = (await import(`@/i18n/messages/${locale}.json`)).default

  return (
    <html lang={locale}>
      <body className="min-h-screen bg-background text-foreground">
        <Providers>
          <NextIntlClientProvider locale={locale} messages={messages}>
            <AuthProvider>
              <Header />
              {children}
              <Footer />
            </AuthProvider>
          </NextIntlClientProvider>
        </Providers>
      </body>
    </html>
  )
}

2.Axios 不需要任何改动

javascript 复制代码
// src/lib/http/client.ts
import axios from 'axios'
import { getCookie } from 'cookies-next'

if (!process.env.NEXT_PUBLIC_API_BASE) {
    throw new Error('NEXT_PUBLIC_API_BASE is not defined')
}


export const http = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_BASE,
    timeout: 10000,
    withCredentials: true, // 为将来登录态做准备
})

http.interceptors.request.use(
    config => {
        const token = getCookie('auth_token')
        if (token) {
            config.headers['Authorization'] = `Bearer ${token}`
        }
        return config
    },
    error => {
        return Promise.reject(error)
    }
)

http.interceptors.response.use(
    response => {
        if (response.data && response.data.header && typeof response.data.header.isSuccess === 'boolean') {
            const apiResponse = response.data;
            if (!apiResponse.header.isSuccess) {
                alert(apiResponse.header.message || 'API Error');
                throw new Error(apiResponse.header.message || 'API Error');
            }
        }
        return response;
    },
    error => {
        console.error(error)
        const message =
            error.response?.data?.message ||
            error.message ||
            'Network Error'
        return Promise.reject(new Error(message))
    }
)

Mock.js 会直接拦截请求。


七、登录流程现在如何跑通

复制代码
Login Page
   ↓
react-hook-form submit
   ↓
authService.login()
   ↓
Mock.js 返回 user
   ↓
/auth/me 可获取用户
   ↓
Header 显示登录态

✔ 不依赖后端

✔ 页面与权限逻辑全部可验证


八、常见问题(你一定会遇到)

Q1:proxy(middleware) 能用 Mock 吗?

❌ 不能。

原因:

  • proxy(middleware) 运行在 Edge

  • Mock.js 运行在浏览器

👉 本地阶段:

  • proxy(middleware) 只验证「是否有 cookie」

  • 不验证 token 真伪


开发阶段可选方案:

  • A:proxy(middleware) 中放行 mock 用户

  • B:临时写死 auth_token

示例(开发用):

javascript 复制代码
const token = request.cookies.get('auth_token')?.value

九、Mock 的生命周期(非常重要)

阶段 是否使用 Mock
UI / 表单 / 权限 ✅ 必须
后端联调 ❌ 移除
生产环境 ❌ 绝对不能

十、Mock 与真实 API切换

修改.env.local为

javascript 复制代码
NEXT_PUBLIC_API_BASE=http://localhost:4000/api
NEXT_PUBLIC_USE_MOCK=false
NEXT_PUBLIC_APP_NAME=Enterprise Web local
NEXT_PUBLIC_APP_ENV=local

修改 .env.development为

javascript 复制代码
NEXT_PUBLIC_API_BASE=/api
NEXT_PUBLIC_USE_MOCK=true
NEXT_PUBLIC_APP_NAME=Enterprise Web Development
NEXT_PUBLIC_APP_ENV=development

修改.env.production为

javascript 复制代码
NEXT_PUBLIC_API_BASE=https://api.enterprise.com
NEXT_PUBLIC_USE_MOCK=false
NEXT_PUBLIC_APP_NAME=Enterprise Web
NEXT_PUBLIC_APP_ENV=production

安装dotenv依赖

javascript 复制代码
pnpm add dotenv --save-dev

在package.json中添加脚本

javascript 复制代码
"scripts": {
    "dev": "next dev",
    "dev:local": "dotenv -e .env.local -- next dev",
    "dev:development": "dotenv -e .env.development -- next dev",
    "dev:staging": "dotenv -e .env.staging -- next dev",
    "dev:production": "dotenv -e .env.production -- next dev",
    "build": "next build",
    "build:local": "dotenv -e .env.local -- next build",
    "build:development": "dotenv -e .env.development -- next build",
    "build:staging": "dotenv -e .env.staging -- next build",
    "build:production": "dotenv -e .env.production -- next build",
    "start": "next start",
    "start:local": "dotenv -e .env.local -- next start",
    "start:development": "dotenv -e .env.development -- next start",
    "start:staging": "dotenv -e .env.staging -- next start",
    "start:production": "dotenv -e .env.production -- next start",
    "lint": "eslint",
    "lint:fix": "eslint --fix",
    "lint:check": "eslint --check",
    "lint:format": "prettier --write",
    "lint:format:check": "prettier --check"
  },

运行

javascript 复制代码
pnpm dev:development

时,将使用mock.js

运行

javascript 复制代码
pnpm dev:local
pnpm dev:production

时,将使用真实的api

十、你现在的工程状态(总结)

你现在已经拥有:

  • 可运行的登录 / 注册 / Header 联动

  • proxy(middleware) + locale 鉴权逻辑

  • 完整前端闭环(即使没有后端)

这已经是真实企业项目的前端开发节奏


下一篇预告

第六章:Metadata 与 SEO(企业级优化)

相关推荐
jingling5552 小时前
uni-app 安卓端完美接入卫星地图:解决图层缺失与层级过高难题
android·前端·javascript·uni-app
哟哟耶耶2 小时前
component-编辑数据页面(操作按钮-编辑,保存,取消) Object.assign浅拷贝复制
前端·javascript·vue.js
bjzhang752 小时前
使用 HTML + JavaScript 实现可编辑表格
前端·javascript·html
GDAL2 小时前
js的markdown js库对比分析
javascript·markdown
指尖跳动的光2 小时前
js如何判空?
前端·javascript
十一.36611 小时前
131-133 定时器的应用
前端·javascript·html
2503_9284115612 小时前
12.22 wxml语法
开发语言·前端·javascript
光影少年13 小时前
Vue2 Diff和Vue 3 Diff实现及底层原理
前端·javascript·vue.js