登录
首页 >  文章 >  前端

在 Nextjs App Router 中使用 Authjs 进行用户身份验证

来源:dev.to

时间:2024-11-21 18:49:09 494浏览 收藏

golang学习网今天将给大家带来《在 Nextjs App Router 中使用 Authjs 进行用户身份验证》,感兴趣的朋友请继续看下去吧!以下内容将会涉及到等等知识点,如果你是正在学习文章或者已经是大佬级别了,都非常欢迎也希望大家都能给我建议评论哈~希望能帮助到大家!

目录

初始设置

  • 安装
  • 配置
    • nextauthconfig 设置
    • 路由处理程序设置
    • 中间件
    • 在服务器端组件中获取会话
    • 在客户端组件中获取会话
  • 文件夹结构

实施身份验证:凭据和 google oauth

  • 设置 prisma
  • 凭证
  • 添加 google oauth 提供商
    • 设置 google oauth 应用程序
    • 设置重定向 uri
    • 设置环境变量
    • 设置提供商
  • 创建登录和注册页面
  • 文件夹结构

初始设置

安装

npm install next-auth@beta
// env.local
auth_secret=generatetd_random_value

配置

nextauthconfig 设置

// src/auth.ts
import nextauth from "next-auth"

export const config = {
  providers: [],
}

export const { handlers, signin, signout, auth } = nextauth(config)

它应该放在src文件夹内

providers 在 auth.js 中表示是可用于登录用户的服务。用户可以通过四种方式登录。

  • 使用内置的 oauth 提供程序(例如 github、google 等...)
  • 使用自定义 oauth 提供程序
  • 使用电子邮件
  • 使用凭证

https://authjs.dev/reference/nextjs#providers

路由处理程序设置

// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth" // referring to the auth.ts we just created
export const { get, post } = handlers

此文件用于使用 next.js app router 设置路由处理程序。

中间件

// src/middleware.ts
import { auth } from "@/auth"

export default auth((req) => {
  // add your logic here
}

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

在src文件夹内写入
如果写在 src 文件夹之外,中间件将无法工作。

中间件是一个允许您在请求完成之前运行代码的函数。它对于保护路由和处理整个应用程序的身份验证特别有用。

matcher一个配置选项,用于指定哪些路由中间件应应用于。它有助于仅在必要的路由上运行中间件来优化性能
示例匹配器: ['/dashboard/:path*'] 仅将中间件应用于仪表板路由。

https://authjs.dev/getting-started/session-management/protecting?framework=express#nextjs-middleware

在服务器端组件中获取会话

// src/app/page.tsx
import { auth } from "@/auth"
import { redirect } from "next/navigation"

export default async function page() {
  const session = await auth()

  if (!session) {
    redirect('/login')
  }

  return (
    <div>
      <h1>hello world!</h1>
      <img src={session.user.image} alt="user avatar" />
    </div>
  )
}

在客户端组件中获取会话

// src/app/page.tsx
"use client"
import { usesession } from "next-auth/react"
import { userouter } from "next/navigation"

export default async function page() {
  const { data: session } = usesession()
  const router = userouter()

  if (!session.user) {
    router.push('/login')
  }

  return (
    <div>
      <h1>hello world!</h1>
      <img src={session.user.image} alt="user avatar" />
    </div>
  )
}

// src/app/layout.tsx
import type { metadata } from "next";
import "./globals.css";
import { sessionprovider } from "next-auth/react"

export const metadata: metadata = {
  title: "create next app",
  description: "generated by create next app",
};

export default function rootlayout({
  children,
}: readonly<{
  children: react.reactnode;
}>) {
  return (
    <html lang="en">
      <body>
        <sessionprovider>
          {children}
        </sessionprovider>
      </body>
    </html>
  );
}

文件夹结构

/src
  /app
    /api
      /auth
        [...nextauth]
          /route.ts  // route handler
    layout.tsx
    page.tsx

  auth.ts  // provider, callback, logic etc
  middleware.ts  // a function before request

实施身份验证:凭据和 google oauth

设置棱镜

// prisma/schema.prisma

model user {
  id            string    @id @default(cuid())
  name          string?
  email         string?   @unique
  emailverified datetime?
  image         string?
  password      string?
  accounts      account[]
  sessions      session[]
}

model account {
  // ... (standard auth.js account model)
}

model session {
  // ... (standard auth.js session model)
}

// ... (other necessary models)

// src/lib/prisma.ts

import { prismaclient } from "@prisma/client"

const globalforprisma = globalthis as unknown as { prisma: prismaclient }

export const prisma = globalforprisma.prisma || new prismaclient()

if (process.env.node_env !== "production") globalforprisma.prisma = prisma

证书

凭证,在身份验证的上下文中,指的是使用用户提供的信息验证用户身份的方法,通常是用户名(或电子邮件)和密码。

我们可以在 src/auth.ts 中添加凭据。

// src/auth.ts

import nextauth from "next-auth";
import type { nextauthconfig } from "next-auth";
import credentials from "next-auth/providers/credentials"
import { prismaadapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
import bcrypt from 'bcryptjs';

export const config = {
  adapter: prismaadapter(prisma),
  providers: [
    credentials({
      credentials: {
        email: { label: "email", type: "text" },
        password: { label: "password", type: "password" }
      },
      authorize: async (credentials): promise<any> => {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        try {
          const user = await prisma.user.findunique({
            where: {
              email: credentials.email as string
            }
          })

          if (!user || !user.hashedpassword) {
            return null
          }

          const ispasswordvalid = await bcrypt.compare(
            credentials.password as string,
            user.hashedpassword
          )

          if (!ispasswordvalid) {
            return null
          }

          return {
            id: user.id as string,
            email: user.email as string,
            name: user.name as string,
          }
        } catch (error) {
          console.error('error during authentication:', error)
          return null 
        }
      }
    })
  ],
  secret: process.env.auth_secret,
  pages: {
    signin: '/login',
  },
  session: {
    strategy: "jwt",
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.email = user.email
        token.name = user.name
      }
      return token
    },
    async session({ session, token }) {
      if (session.user) {
        session.user.id = token.id as string
        session.user.email = token.email as string
        session.user.name = token.name as string
      }
      return session
    },
  },
} satisfies nextauthconfig;

export const { handlers, auth, signin, signout } = nextauth(config);

适配器:

  • 将身份验证系统连接到数据库或数据存储解决方案的模块。

秘密:

  • 这是一个随机字符串,用于哈希令牌、签名/加密 cookie 以及生成加密密钥。
  • 这对于安全至关重要,应该保密。
  • 在本例中,它是使用环境变量 auth_secret 设置的。

页面:

  • 此对象允许您自定义身份验证页面的 url。
  • 在您的示例中,signin: '/login' 表示登录页面将位于 '/login' 路由,而不是默认的 '/api/auth/signin'。

会话:

  • 这配置了会话的处理方式。
  • 策略:“jwt”表示 json web token 将用于会话管理而不是数据库会话。

回调:

  • 这些是在身份验证流程中的各个点调用的函数,允许您自定义流程。

jwt 回调:

  • 它在创建或更新 jwt 时运行。
  • 在您的代码中,它将用户信息(id、电子邮件、姓名)添加到令牌中。

会话回调:

  • 每当检查会话时都会运行。
  • 您的代码正在将用户信息从令牌添加到会话对象。

添加 google oauth 提供商

设置 google oauth 应用程序

从 gcp console 创建新的 oauth 客户端 id > api 和服务 > 凭据

在 Nextjs App Router 中使用 Authjs 进行用户身份验证

创建后,保存您的客户端 id 和客户端密钥以供以后使用。

设置重定向 uri

当我们在本地工作时,设置http://localhost:3000/api/auth/callback/google

生产环境中,只需将 http://localhost:3000 替换为 https://-----即可。

在 Nextjs App Router 中使用 Authjs 进行用户身份验证

设置环境变量

// .env.local
google_client_id={client_id}
google_client_secret={client_secret}

设置提供商

// src/auth.ts

import googleprovider from "next-auth/providers/google"  // add this import.

export const { handlers, auth } = nextauth({
  adapter: prismaadapter(prisma),
  providers: [
    credentialsprovider({
      // ... (previous credentials configuration)
    }),
    googleprovider({
      clientid: process.env.google_client_id,
      clientsecret: process.env.google_client_secret,
    }),
  ],
  // ... other configurations
})

https://authjs.dev/getting-started/authentication/oauth

创建登录和注册页面

//// ui pages
// src/app/login/loginpage.tsx
import link from 'next/link'
import { loginform } from '@/components/auth/loginform'
import { separator } from '@/components/auth/separator'
import { authlayout } from '@/components/auth/authlayout'
import { googleauthbutton } from '@/components/auth/googleauthbutton'

export default function loginpage() {
  return (
    <authlayout title="welcome back!">
      <loginform />
      <separator />
      <googleauthbutton text="sign in with google" />
      <div classname="mt-6 text-center">
        <p classname="text-sm text-gray-400">
          do not have an account?{' '}
          <link href="/signup" classname="pl-1.5 font-medium text-[#3ba55c] hover:text-[#2d7d46]">
            sign up
          </link>
        </p>
      </div>
    </authlayout>
  )
}


// src/app/signup/signuppage.tsx
import link from 'next/link'
import { signupform } from '@/components/auth/signupform'
import { separator } from '@/components/auth/separator'
import { authlayout } from '@/components/auth/authlayout'
import { googleauthbutton } from '@/components/auth/googleauthbutton'

export default function signuppage() {
  return (
    <authlayout title="welcome!">
      <signupform />
      <separator />
      <googleauthbutton text="sign up with google" />
      <div classname="mt-6 text-center">
        <p classname="text-sm text-gray-400">
          already have an account?{' '}
          <link href="/login" classname="pl-1.5 font-medium text-[#3ba55c] hover:text-[#2d7d46]">
            sign in
          </link>
        </p>
      </div>
    </authlayout>
  )
}

//// components
// src/components/auth/authlayout.tsx
import react from 'react'

interface authlayoutprops {
  children: react.reactnode
  title: string
}

export const authlayout: react.fc<authlayoutprops> = ({ children, title }) => {
  return (
    <div classname="min-h-screen bg-[#36393f] flex flex-col justify-center py-12 sm:px-6 lg:px-8">
      <div classname="sm:mx-auto sm:w-full sm:max-w-md">
        <h2 classname="mt-6 text-center text-3xl font-extrabold text-white">
          {title}
        </h2>
      </div>

      <div classname="mt-8 sm:mx-auto sm:w-full sm:max-w-md">
        <div classname="bg-[#2f3136] py-8 px-4 shadow sm:rounded-lg sm:px-10">
          {children}
        </div>
      </div>
    </div>
  )
}

// src/components/auth/googleauthbutton.tsx
import { signin } from "@/auth"
import { button } from "@/components/ui/button"

interface googleauthbuttonprops {
  text: string
}

export const googleauthbutton: react.fc<googleauthbuttonprops> = ({ text }) => {
  return (
    <form
      action={async () => {
        "use server"
        await signin("google", { redirectto: '/' })
      }}
    >
      <button
        classname="my-1 w-full bg-white text-gray-700 hover:bg-slate-100"
      >
        <svg classname="h-5 w-5 mr-2" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
          <path d="m22.56 12.25c0-.78-.07-1.53-.2-2.25h12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285f4"/>
          <path d="m12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53h2.18v2.84c3.99 20.53 7.7 23 12 23z" fill="#34a853"/>
          <path d="m5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09v7.07h2.18c1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#fbbc05"/>
          <path d="m12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15c17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#ea4335"/>
          <path d="m1 1h22v22h1z" fill="none"/>
        </svg>
        {text}
      </button>
    </form>
  )
}

// src/components/auth/loginform.tsx
'use client'

import { usetransition } from "react"
import { useform } from "react-hook-form"
import {
  form,
  formcontrol,
  formfield,
  formitem,
  formlabel,
  formmessage,
} from "@/components/ui/form"
import { input } from "@/components/ui/input"
import { button } from "@/components/ui/button"
import { loginresolver, loginschema } from "@/schema/login"
import { usestate } from "react"
import { userouter } from "next/navigation"
import { formerror } from "@/components/auth/formerror"
import { formsuccess } from "@/components/auth/formsuccess"
import { login } from "@/app/actions/auth/login"
import { loader2 } from "lucide-react"

export const loginform = () => {
  const [error, seterror] = usestate<string | undefined>('')
  const [success, setsuccess] = usestate<string | undefined>('')
  const [ispending, starttransition] = usetransition()
  const router = userouter();

  const form = useform<loginschema>({
    defaultvalues: { email: '', password: ''},
    resolver: loginresolver,
  })

  const onsubmit = (formdata: loginschema) => { 
    starttransition(() => {
      seterror('')
      setsuccess('')
      login(formdata)
        .then((data) => {
          if (data.success) {
            setsuccess(data.success)
            router.push('/setup')
          } else if (data.error) {
            seterror(data.error)
          }
        })
        .catch((data) => {
          seterror(data.error)
        })
    })
  }

  return (
    <form {...form}>
      <form onsubmit={form.handlesubmit(onsubmit)}>
        <div classname="space-y-3">
          <formfield
            control={form.control}
            name="email"
            render={({ field }) => (
              <formitem>
                <formlabel classname="text-white">email address</formlabel>
                <formcontrol>
                  <input
                    placeholder="enter your email address" 
                    {...field} 
                    disabled={ispending}
                    classname="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
                  />
                </formcontrol>
                <formmessage />
              </formitem>
            )}
          />
          <formfield
            control={form.control}
            name="password"
            render={({ field }) => (
              <formitem>
                <formlabel classname="text-white">password</formlabel>
                <formcontrol>
                  <input 
                    type="password"
                    placeholder="enter your password" 
                    {...field} 
                    disabled={ispending}
                    classname="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
                  />
                </formcontrol>
                <formmessage />
              </formitem>
            )}
          />
          <formerror message={error} />
          <formsuccess message={success} />
        </div>
        <button
          type="submit"
          disabled={ispending}
          classname="mt-8 w-full bg-[#3ba55c] hover:bg-[#2d7d46] text-white"
        >
          {ispending ? (
            <>
              <loader2 classname="mr-2 h-4 w-4 animate-spin" />
              loading...
            </>
          ) : (
            'login'
          )}
        </button>
      </form>
    </form>
  )
}


// src/components/auth/signupform.tsx
'use client'

import { usetransition } from "react"
import { useform } from "react-hook-form"
import {
  form,
  formcontrol,
  formfield,
  formitem,
  formlabel,
  formmessage,
} from "@/components/ui/form"
import { input } from "@/components/ui/input"
import { button } from "@/components/ui/button"
import { signupresolver, signupschema } from "@/schema/signup"
import { usestate } from "react"
import { userouter } from "next/navigation"
import { formerror } from "@/components/auth/formerror"
import { formsuccess } from "@/components/auth/formsuccess"
import { signup } from "@/app/actions/auth/signup"
import { loader2 } from "lucide-react"

export const signupform = () => {
  const [error, seterror] = usestate<string | undefined>('')
  const [success, setsuccess] = usestate<string | undefined>('')
  const [ispending, starttransition] = usetransition()
  const router = userouter();

  const form = useform<signupschema>({
    defaultvalues: { name: '', email: '', password: ''},
    resolver: signupresolver,
  })

  const onsubmit = async (formdata: signupschema) => { 
    starttransition(() => {
      seterror('')
      setsuccess('')
      signup(formdata)
        .then((data) => {
          if (data.success) {
            setsuccess(data.success)
            router.push('/login')
          } else if (data.error) {
            seterror(data.error)
          }
        })
        .catch((data) => {
          seterror(data.error)
        })
    })
  }

  return (
    <form {...form}>
      <form onsubmit={form.handlesubmit(onsubmit)}>
        <div classname="space-y-3">
          <formfield
            control={form.control}
            name="name"
            render={({ field }) => (
              <formitem>
                <formlabel classname="text-white">username</formlabel>
                <formcontrol>
                  <input
                    placeholder="enter your name" 
                    {...field} 
                    disabled={ispending}
                    classname="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
                  />
                </formcontrol>
                <formmessage />
              </formitem>
            )}
          />
          <formfield
            control={form.control}
            name="email"
            render={({ field }) => (
              <formitem>
                <formlabel classname="text-white">email address</formlabel>
                <formcontrol>
                  <input
                    placeholder="enter your email address" 
                    {...field} 
                    disabled={ispending}
                    classname="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
                  />
                </formcontrol>
                <formmessage />
              </formitem>
            )}
          />
          <formfield
            control={form.control}
            name="password"
            render={({ field }) => (
              <formitem>
                <formlabel classname="text-white">password</formlabel>
                <formcontrol>
                  <input 
                    type="password"
                    placeholder="enter your password" 
                    {...field} 
                    disabled={ispending}
                    classname="bg-[#40444b] text-white border-gray-600 focus:border-2 focus:border-[#2d7d46]"
                  />
                </formcontrol>
                <formmessage />
              </formitem>
            )}
          />
          <formerror message={error} />
          <formsuccess message={success} />
        </div>
        <button
          type="submit"
          disabled={ispending}
          classname="mt-8 w-full bg-[#3ba55c] hover:bg-[#2d7d46] text-white"
        >
          {ispending ? (
            <>
              <loader2 classname="mr-2 h-4 w-4 animate-spin" />
              loading...
            </>
          ) : (
            'sign up'
          )}
        </button>
      </form>
    </form>
  )
}


// src/components/auth/formsuccess.tsx
import { checkcircledicon } from "@radix-ui/react-icons";

interface formsuccessprops {
  message?: string;
}

export const formsuccess = ({ message }: formsuccessprops) => {
  if (!message) return null;

  return (
    <div classname="bg-emerald-500/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-emerald-500">
      <checkcircledicon classname="h-4 w-4" />
      <p>{message}</p>
    </div>
  );
};


// src/components/auth/formerror.tsx
import { exclamationtriangleicon } from "@radix-ui/react-icons";

interface formerrorprops {
  message?: string;
}

export const formerror = ({ message }: formerrorprops) => {
  if (!message) return null;

  return (
    <div classname="bg-destructive/15 p-3 rounded-md flex items-center gap-x-2 text-sm text-destructive">
      <exclamationtriangleicon classname="h-4 w-4" />
      <p>{message}</p>
    </div>
  );
};


// src/components/auth/separator.tsx
export const separator = () => {
  return (
    <div classname="my-4 relative">
      <div classname="absolute inset-0 flex items-center">
        <div classname="w-full border-t border-gray-600" />
      </div>
      <div classname="relative flex justify-center text-sm">
        <span classname="px-2 bg-[#2f3136] text-gray-400">or continue with</span>
      </div>
    </div>
  )
}

//// actions
// src/app/actions/auth/login.ts
'use server'

import { loginschema, loginschema } from '@/schema/login'
import { signin } from '@/auth'


export const login = async (formdata: loginschema) => {
  const email = formdata['email'] as string
  const password = formdata['password'] as string

  const validatedfields = loginschema.safeparse({
    email: formdata.email as string,
    password: formdata.password as string,
  })

  if (!validatedfields.success) {
    return { 
      errors: validatedfields.error.flatten().fielderrors,
      message: 'login failed. please check your input.'
    }
  }

  try {
    const result = await signin('credentials', {
      redirect: false,
      callbackurl: '/setup',
      email,
      password
    })

    if (result?.error) {
      return { error : 'invalid email or password'}
    } else {
      return { success : 'login successfully'}
    }
  } catch {
    return { error : 'login failed'}
  }
}

// src/app/actions/auth/signup.ts
'use server'

import bcrypt from 'bcryptjs'
import { signupschema, signupschema } from "@/schema/signup"
import { prisma } from '@/lib/prisma';

export const signup = async (formdata: signupschema) => {
  const validatedfields = signupschema.safeparse({
    name: formdata.name as string,
    email: formdata.email as string,
    password: formdata.password as string,
  })

  if (!validatedfields.success) {
    return { 
      errors: validatedfields.error.flatten().fielderrors,
      message: 'sign up failed. please check your input.'
    }
  }

  try {
    const hashedpassword = await bcrypt.hash(validatedfields.data.password, 10);
    const existinguser = await prisma.user.findunique({
      where: { email: validatedfields.data.email }
    })

    if (existinguser) {
      return { error: 'user already exists!' }
    }

    await prisma.user.create({
      data: {
        name:  validatedfields.data.name,
        email:  validatedfields.data.email,
        hashedpassword: hashedpassword,
      },

    });

    return { success: 'user created successfully!' }
  } catch (error) {
    return { error : `sign up failed`}
  }
}
//// validations
// src/schema/login.ts
import * as z from 'zod';
import { zodresolver } from '@hookform/resolvers/zod'; 

export const loginschema = z.object({
  email: z.string().email('this is not valid email address'),
  password: z
    .string()
    .min(8, { message: 'password must contain at least 8 characters' }),
});

export type loginschema = z.infer<typeof loginschema>;
export const loginresolver = zodresolver(loginschema);

// src/schema/signup.ts
import * as z from 'zod';
import { zodresolver } from '@hookform/resolvers/zod'; 

export const signupschema = z.object({
  name: z.string().min(1, {
    message: 'name is required'
  }),
  email: z.string().email('this is not valid email address'),
  password: z
    .string()
    .min(8, { message: 'password must contain at least 8 characters' }),
});

export type signupschema = z.infer<typeof signupschema>;
export const signupresolver = zodresolver(signupschema);

// src/middleware.ts
import { nextresponse } from 'next/server'
import { auth } from "@/auth"

export default auth((req) => {
  const { nexturl, auth: session } = req
  const isloggedin = !!session
  const isloginpage = nexturl.pathname === "/login"
  const issignuppage = nexturl.pathname === "/signup"
  const issetuppage = nexturl.pathname === "/setup"

  // if trying to access /setup while not logged in
  if (!isloggedin && issetuppage) {
    const loginurl = new url("/login", nexturl.origin)
    return nextresponse.redirect(loginurl)
  }

  // if trying to access /login or /signup while already logged in
  if (isloggedin && (isloginpage || issignuppage)) {
    const dashboardurl = new url("/", nexturl.origin)
    return nextresponse.redirect(dashboardurl)
  }

  // for all other cases, allow the request to pass through
  return nextresponse.next()
})

export const config = {
  matcher: ["/login","/signup", "/setup", "/"],
};

文件夹结构

/src
  /app
    /actions
      /login.ts  // Login Action
      /signup.ts  // Signup Action
    /api
      /auth
        [...nextauth]
          /route.ts
    /login
      page.tsx  // Login Page
    /signup
      page.tsx  // Sign Up Page
    layout.tsx
    page.tsx

  /components
    /auth
      AuthLayout.tsx
      GoogleAuthButton.tsx
      LoginForm.tsx
      SignupForm.tsx
      FormSuccess.tsx
      FormError.tsx
      Separator.tsx

  /schema
    login.ts
    signup.ts

  auth.ts  // in src folder
  middleware.ts  // in src folder

今天关于《在 Nextjs App Router 中使用 Authjs 进行用户身份验证》的内容就介绍到这里了,是不是学起来一目了然!想要了解更多关于的内容请关注golang学习网公众号!

声明:本文转载于:dev.to 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>