完善认证功能,添加实名认证和回调处理逻辑
This commit is contained in:
21
src/actions/auth/identify.ts
Normal file
21
src/actions/auth/identify.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {callByUser, callPublic} from '@/actions/base'
|
||||
|
||||
export async function Identify(props: {
|
||||
type: number
|
||||
name: string
|
||||
iden_no: string
|
||||
}) {
|
||||
return await callByUser<{
|
||||
identified: boolean
|
||||
target: string
|
||||
}>('/api/user/identify', props)
|
||||
}
|
||||
|
||||
export async function IdentifyCallback(props: {
|
||||
id: string
|
||||
}) {
|
||||
return await callPublic<{
|
||||
success: boolean
|
||||
message: string
|
||||
}>('/api/user/identify/callback', props)
|
||||
}
|
||||
@@ -238,6 +238,48 @@ async function callByUser<R = undefined>(
|
||||
|
||||
// endregion
|
||||
|
||||
// ======================
|
||||
// region no token
|
||||
// ======================
|
||||
|
||||
// 不需要令牌的公共API调用函数
|
||||
async function callPublic<R = undefined>(
|
||||
endpoint: string,
|
||||
data?: unknown,
|
||||
): Promise<ApiResponse<R>> {
|
||||
try {
|
||||
// 发送请求
|
||||
const requestOptions: RequestInit = {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, requestOptions)
|
||||
|
||||
// 检查响应状态
|
||||
if (!response.ok) {
|
||||
console.log('公共接口响应不成功', `status=${response.status}`, await response.text())
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
success: false,
|
||||
message: response.status >= 500 ? '服务器错误' : '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
return handleResponse(response)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Public API call failed:', e)
|
||||
throw new Error('服务调用失败', { cause: e })
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
|
||||
// 统一响应解析
|
||||
async function handleResponse<R = undefined>(response: Response): Promise<ApiResponse<R>> {
|
||||
|
||||
@@ -286,4 +328,5 @@ export {
|
||||
getUserToken,
|
||||
callByDevice,
|
||||
callByUser,
|
||||
callPublic,
|
||||
}
|
||||
|
||||
76
src/app/(api)/identify/callback/page.tsx
Normal file
76
src/app/(api)/identify/callback/page.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
import {useEffect, useState} from 'react'
|
||||
import {useSearchParams} from 'next/navigation'
|
||||
import {IdentifyCallback} from '@/actions/auth/identify'
|
||||
import {Card, CardContent} from '@/components/ui/card'
|
||||
import {CheckCircle, AlertCircle, Loader2} from 'lucide-react'
|
||||
|
||||
export type PageProps = {}
|
||||
|
||||
export default function Page(props: PageProps) {
|
||||
const params = useSearchParams()
|
||||
const success = params.get('success') === 'true'
|
||||
const id = params.get('id') || ''
|
||||
|
||||
const [result, setResult] = useState({
|
||||
status: 'load',
|
||||
message: '处理中',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (success) {
|
||||
IdentifyCallback({id}).then(resp => {
|
||||
if (!resp.success) {
|
||||
setResult({
|
||||
status: 'fail',
|
||||
message: resp.message || '获取活体检测结果失败',
|
||||
})
|
||||
}
|
||||
else {
|
||||
const data = resp.data
|
||||
setResult({
|
||||
status: data.success ? 'done' : 'fail',
|
||||
message: data.message || data.success
|
||||
? '认证已完成'
|
||||
: '认证失败',
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
else {
|
||||
setResult({
|
||||
status: 'fail',
|
||||
message: '未完成认证',
|
||||
})
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`w-full min-h-screen flex justify-center items-center bg-blue-50`}>
|
||||
<Card className="w-full max-w-xs border-none shadow-none bg-white -translate-y-1/3">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-6">
|
||||
{result.status === 'load' ? (<>
|
||||
<Loader2 className="w-16 h-16 text-primary animate-spin"/>
|
||||
<p className={`text-primary text-xl`}>{result.message}</p>
|
||||
<p className={`text-weak text-sm`}>
|
||||
请保持网络畅通
|
||||
</p>
|
||||
</>) : result.status === 'done' ? (<>
|
||||
<CheckCircle className="w-16 h-16 text-done"/>
|
||||
<p className={`text-done text-xl`}>{result.message}</p>
|
||||
<p className={`text-weak text-sm`}>
|
||||
认证已完成,您现在可以关闭此页面
|
||||
</p>
|
||||
</>) : (<>
|
||||
<AlertCircle className="w-16 h-16 text-fail"/>
|
||||
<p className={`text-fail text-xl`}>{result.message}</p>
|
||||
<p className={`text-weak text-sm`}>
|
||||
认证失败,请重新发起认证
|
||||
</p>
|
||||
</>)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,12 +1,102 @@
|
||||
'use client'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import banner from './_assets/banner.webp'
|
||||
import personal from './_assets/personal.webp'
|
||||
import Image from 'next/image'
|
||||
import Page from '@/components/page'
|
||||
import {Dialog, DialogContent, DialogFooter, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import zod from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {Identify} from '@/actions/auth/identify'
|
||||
import {toast} from 'sonner'
|
||||
import {useContext, useEffect, useRef, useState} from 'react'
|
||||
import * as qrcode from 'qrcode'
|
||||
import {AuthContext} from '@/components/providers/AuthProvider'
|
||||
|
||||
export type IdentifyPageProps = {}
|
||||
|
||||
export default async function IdentifyPage(props: IdentifyPageProps) {
|
||||
export default function IdentifyPage(props: IdentifyPageProps) {
|
||||
|
||||
// ======================
|
||||
// 填写信息
|
||||
// ======================
|
||||
|
||||
const schema = zod.object({
|
||||
type: zod.enum([`personal`, `enterprise`], {errorMap: () => ({message: `请选择认证类型`})}).default('personal'),
|
||||
name: zod.string().min(2, {message: `姓名至少2个字符`}),
|
||||
iden_no: zod.string().length(18, {message: `身份证号码必须为18位`}),
|
||||
})
|
||||
type Schema = zod.infer<typeof schema>
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: `personal`,
|
||||
name: ``,
|
||||
iden_no: ``,
|
||||
},
|
||||
})
|
||||
|
||||
const handler = form.handleSubmit(
|
||||
async (value) => {
|
||||
const resp = await Identify({
|
||||
type: value.type === `personal` ? 1 : 2,
|
||||
name: value.name,
|
||||
iden_no: value.iden_no,
|
||||
})
|
||||
if (resp.success) {
|
||||
form.reset()
|
||||
if (!resp.data?.identified) {
|
||||
setStep('scan')
|
||||
setTarget(resp.data.target)
|
||||
}
|
||||
else {
|
||||
toast.success('认证已完成')
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.log(resp.message)
|
||||
toast.error(`认证信息提交失败:请稍后重试`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ======================
|
||||
// 扫码认证
|
||||
// ======================
|
||||
|
||||
const [step, setStep] = useState<'form' | 'scan'>('form')
|
||||
const [target, setTarget] = useState('')
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
const canvas = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas.current && target) {
|
||||
qrcode.toCanvas(canvas.current, target, {
|
||||
width: 256,
|
||||
margin: 0,
|
||||
}, (error) => {
|
||||
if (error) {
|
||||
console.error(error)
|
||||
toast.error(`二维码生成失败:请稍后重试`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [target])
|
||||
|
||||
// ======================
|
||||
// 用户数据
|
||||
// ======================
|
||||
|
||||
const ctx = useContext(AuthContext)
|
||||
console.log('render identify page')
|
||||
|
||||
// ======================
|
||||
// render
|
||||
// ======================
|
||||
|
||||
return (
|
||||
<Page mode={`blank`}>
|
||||
<div className={`flex-3/4 flex flex-col bg-white rounded-lg overflow-hidden gap-16`}>
|
||||
@@ -26,7 +116,62 @@ export default async function IdentifyPage(props: IdentifyPageProps) {
|
||||
<h3 className={`text-center text-lg font-bold`}>个人认证</h3>
|
||||
<p className={`text-sm text-gray-600`}>平台授权支付宝安全认证,不会泄露您的认证信息</p>
|
||||
</div>
|
||||
<Button className={`w-full`}>去认证</Button>
|
||||
{ctx.profile?.id_token ? (
|
||||
<div className={`flex flex-col gap-4`}>
|
||||
<p className={`text-sm text-gray-600`}>已完成实名认证</p>
|
||||
</div>
|
||||
) : (
|
||||
<Dialog open={openDialog} onOpenChange={setOpenDialog}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className={`w-full`}>去认证</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>
|
||||
{step === 'form' ? `实名认证` : `扫码完成认证`}
|
||||
</DialogTitle>
|
||||
{step === 'form' && (
|
||||
<Form form={form} handler={handler} className={`flex flex-col gap-4`}>
|
||||
<FormField<Schema> name={`name`} label={`姓名`}>
|
||||
{({id, field}) => (
|
||||
<input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder={`请输入姓名`}
|
||||
className={`border rounded p-2 w-full`}
|
||||
autoComplete={`name`}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField<Schema> name={`iden_no`} label={`身份证号`}>
|
||||
{({id, field}) => (
|
||||
<input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder={`请输入身份证号`}
|
||||
className={`border rounded p-2 w-full`}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type={`submit`} className={`w-full mt-4`}>提交</Button>
|
||||
</DialogFooter>
|
||||
</Form>
|
||||
)}
|
||||
{step === 'scan' && (
|
||||
<div className={`flex flex-col gap-4 items-center`}>
|
||||
<canvas ref={canvas} width={256} height={256}/>
|
||||
<p className={`text-sm text-gray-600`}>请扫码完成认证</p>
|
||||
<Button onClick={async () => {
|
||||
await ctx.refreshProfile()
|
||||
setOpenDialog(false)
|
||||
}}>
|
||||
已完成认证
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,16 +32,16 @@ export default async function DashboardLayout(props: DashboardLayoutProps) {
|
||||
<header className={`flex-none basis-20 flex items-stretch`}>
|
||||
{/* logo */}
|
||||
<div className={`flex-none basis-60 flex items-center justify-center`}>
|
||||
<Image src={logo} alt={`logo`} height={40}/>
|
||||
<Link href={'/'}><Image src={logo} alt={`logo`} height={40}/></Link>
|
||||
</div>
|
||||
|
||||
{/* title */}
|
||||
<div className={`flex-auto overflow-hidden flex items-center`}>
|
||||
<div className={`flex-auto overflow-hidden flex items-center pl-4`}>
|
||||
欢迎来到,蓝狐代理
|
||||
</div>
|
||||
|
||||
{/* profile */}
|
||||
<div className={`flex-none basis-80 flex items-center justify-end`}>
|
||||
<div className={`flex-none basis-80 flex items-center justify-end pr-4`}>
|
||||
<Profile/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -14,25 +14,31 @@ import {
|
||||
import {merge} from '@/lib/utils'
|
||||
import {Label} from '@/components/ui/label'
|
||||
|
||||
import {BaseSyntheticEvent, ComponentProps, createContext, ReactNode, useContext, useId} from 'react'
|
||||
import React, {BaseSyntheticEvent, ComponentProps, createContext, ReactNode, useContext, useId} from 'react'
|
||||
|
||||
type FormProps<T extends FieldValues> = {
|
||||
form: UseFormReturn<T>
|
||||
onSubmit: SubmitHandler<T>
|
||||
onSubmit?: SubmitHandler<T>
|
||||
onError?: SubmitErrorHandler<T>
|
||||
handler?: (e?: React.BaseSyntheticEvent) => Promise<void>
|
||||
} & Omit<ComponentProps<'form'>, 'onSubmit' | 'onError'>
|
||||
|
||||
function Form<T extends FieldValues>(rawProps: FormProps<T>) {
|
||||
|
||||
const {children, onSubmit, onError, ...props} = rawProps
|
||||
const {children, onSubmit, onError, handler, ...props} = rawProps
|
||||
const form = props.form
|
||||
|
||||
const handle = handler || form.handleSubmit(
|
||||
onSubmit || (_ => {}),
|
||||
onError,
|
||||
)
|
||||
|
||||
return (
|
||||
<FormProvider {...form}>
|
||||
<form {...props} onSubmit={event => {
|
||||
<form {...props} onSubmit={async event => {
|
||||
event.preventDefault()
|
||||
form.handleSubmit(onSubmit, onError)(event)
|
||||
event.stopPropagation()
|
||||
await handle(event)
|
||||
}}>
|
||||
{children}
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user