fix(account): submit verification through backend
This commit is contained in:
+157
-98
@@ -3,7 +3,6 @@
|
|||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -12,59 +11,132 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select"
|
} from "@/components/ui/select"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import {
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
applyCurrentUserVerification,
|
||||||
|
getCurrentUserForLogin,
|
||||||
|
listCurrentUserVerifications,
|
||||||
|
uploadFile,
|
||||||
|
} from "@/lib/api"
|
||||||
|
import { toApiError } from "@/lib/errors"
|
||||||
|
import { notifyInfo, notifySuccess } from "@/lib/toast"
|
||||||
import type { UserRole } from "@/lib/types"
|
import type { UserRole } from "@/lib/types"
|
||||||
import { useAuthStore } from "@/store/auth"
|
import { useAuthStore } from "@/store/auth"
|
||||||
import { CheckCircle, Clock, ShieldCheck, Upload } from "lucide-react"
|
import { CheckCircle, Clock, ShieldCheck, Upload } from "lucide-react"
|
||||||
import { useEffect, useRef, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
|
|
||||||
|
type MaterialKey = "idCardFront" | "idCardBack" | "gameScreenshot"
|
||||||
|
|
||||||
|
function MaterialUpload({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
uploading,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
uploading: boolean
|
||||||
|
onSelect: (file: File) => Promise<void>
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-1 text-muted-foreground cursor-pointer hover:border-muted-foreground/50 transition-colors">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
disabled={uploading}
|
||||||
|
onChange={(event) => {
|
||||||
|
const target = event.currentTarget
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
void onSelect(file)
|
||||||
|
target.value = ""
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Upload className="h-5 w-5" />
|
||||||
|
<span className="text-[10px]">{uploading ? "上传中..." : label}</span>
|
||||||
|
{value && (
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
已上传
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function VerifyPage() {
|
export default function VerifyPage() {
|
||||||
const [verifyRole, setVerifyRole] = useState<UserRole | "">("")
|
const [verifyRole, setVerifyRole] = useState<UserRole | "">("")
|
||||||
const [realName, setRealName] = useState("")
|
const [idCardFront, setIdCardFront] = useState("")
|
||||||
const [idNumber, setIdNumber] = useState("")
|
const [idCardBack, setIdCardBack] = useState("")
|
||||||
const [gameProfile, setGameProfile] = useState("")
|
const [gameScreenshot, setGameScreenshot] = useState("")
|
||||||
|
const [uploading, setUploading] = useState<MaterialKey | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [verificationRecords, setVerificationRecords] = useState<
|
||||||
|
Awaited<ReturnType<typeof listCurrentUserVerifications>>
|
||||||
|
>([])
|
||||||
const verificationStatus = useAuthStore((state) => state.verificationStatus)
|
const verificationStatus = useAuthStore((state) => state.verificationStatus)
|
||||||
const verificationReasons = useAuthStore((state) => state.verificationReasons)
|
const verificationReasons = useAuthStore((state) => state.verificationReasons)
|
||||||
const verifiedRoles = useAuthStore((state) => state.verifiedRoles)
|
const verifiedRoles = useAuthStore((state) => state.verifiedRoles)
|
||||||
const submitVerification = useAuthStore((state) => state.submitVerification)
|
const login = useAuthStore((state) => state.login)
|
||||||
const approveVerification = useAuthStore((state) => state.approveVerification)
|
|
||||||
const timersRef = useRef<Map<UserRole, ReturnType<typeof setTimeout>>>(new Map())
|
|
||||||
|
|
||||||
useEffect(
|
useEffect(() => {
|
||||||
() => () => {
|
let cancelled = false
|
||||||
timersRef.current.forEach((timer) => {
|
|
||||||
clearTimeout(timer)
|
void listCurrentUserVerifications()
|
||||||
|
.then((records) => {
|
||||||
|
if (!cancelled) setVerificationRecords(records)
|
||||||
})
|
})
|
||||||
timersRef.current.clear()
|
.catch((error) => {
|
||||||
|
if (!cancelled) notifyInfo(toApiError(error).msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const uploadMaterial = async (key: MaterialKey, file: File) => {
|
||||||
|
setUploading(key)
|
||||||
|
try {
|
||||||
|
const url = await uploadFile(file, "verification")
|
||||||
|
if (key === "idCardFront") setIdCardFront(url)
|
||||||
|
if (key === "idCardBack") setIdCardBack(url)
|
||||||
|
if (key === "gameScreenshot") setGameScreenshot(url)
|
||||||
|
notifySuccess("材料已上传")
|
||||||
|
} catch (error) {
|
||||||
|
notifyInfo(toApiError(error).msg)
|
||||||
|
} finally {
|
||||||
|
setUploading(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitVerification = async (role: UserRole) => {
|
||||||
|
if (!idCardFront || !idCardBack) {
|
||||||
|
notifyInfo("请先上传身份证正反面")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
await applyCurrentUserVerification({
|
||||||
|
role,
|
||||||
|
materials: {
|
||||||
|
idCardFront,
|
||||||
|
idCardBack,
|
||||||
|
gameScreenshots: gameScreenshot ? [gameScreenshot] : undefined,
|
||||||
},
|
},
|
||||||
[],
|
})
|
||||||
)
|
const [records, updated] = await Promise.all([
|
||||||
|
listCurrentUserVerifications(),
|
||||||
const buildMaterials = () => {
|
getCurrentUserForLogin(),
|
||||||
const materials = {
|
])
|
||||||
realName,
|
setVerificationRecords(records)
|
||||||
idNumber,
|
login(updated, updated.verifiedRoles ?? [updated.role])
|
||||||
gameProfile,
|
notifySuccess("认证申请已提交")
|
||||||
idCardFront: "mock://idCardFront",
|
} catch (error) {
|
||||||
idCardBack: "mock://idCardBack",
|
notifyInfo(toApiError(error).msg)
|
||||||
gameScreenshot: "mock://gameScreenshot",
|
} finally {
|
||||||
} satisfies Record<string, string>
|
setSubmitting(false)
|
||||||
|
|
||||||
return materials
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const submitWithMockApproval = (role: UserRole) => {
|
|
||||||
submitVerification(role, buildMaterials())
|
|
||||||
const oldTimer = timersRef.current.get(role)
|
|
||||||
if (oldTimer) {
|
|
||||||
clearTimeout(oldTimer)
|
|
||||||
}
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
approveVerification(role)
|
|
||||||
timersRef.current.delete(role)
|
|
||||||
}, 3000)
|
|
||||||
timersRef.current.set(role, timer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleMeta = [
|
const roleMeta = [
|
||||||
@@ -72,6 +144,16 @@ export default function VerifyPage() {
|
|||||||
{ role: "owner", label: "店主认证" },
|
{ role: "owner", label: "店主认证" },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
const statusFor = (role: UserRole) => {
|
||||||
|
const record = verificationRecords.find((item) => item.role === role)
|
||||||
|
return record?.status ?? verificationStatus[role]
|
||||||
|
}
|
||||||
|
|
||||||
|
const reasonFor = (role: UserRole) => {
|
||||||
|
const record = verificationRecords.find((item) => item.role === role)
|
||||||
|
return record?.rejectReason ?? verificationReasons[role]
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||||
<h1 className="text-2xl font-bold">身份认证</h1>
|
<h1 className="text-2xl font-bold">身份认证</h1>
|
||||||
@@ -98,8 +180,8 @@ export default function VerifyPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{roleMeta.map((item) => {
|
{roleMeta.map((item) => {
|
||||||
const status = verificationStatus[item.role]
|
const status = statusFor(item.role)
|
||||||
const reason = verificationReasons[item.role]
|
const reason = reasonFor(item.role)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={item.role} className="p-3 space-y-2 shadow-none">
|
<Card key={item.role} className="p-3 space-y-2 shadow-none">
|
||||||
@@ -131,12 +213,8 @@ export default function VerifyPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "rejected" && (
|
{status === "rejected" && (
|
||||||
<Button
|
<Button variant="outline" size="sm" onClick={() => setVerifyRole(item.role)}>
|
||||||
variant="outline"
|
重新填写
|
||||||
size="sm"
|
|
||||||
onClick={() => submitWithMockApproval(item.role)}
|
|
||||||
>
|
|
||||||
重新提交
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
@@ -163,70 +241,51 @@ export default function VerifyPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="real-name">真实姓名</Label>
|
|
||||||
<Input
|
|
||||||
id="real-name"
|
|
||||||
placeholder="请输入真实姓名"
|
|
||||||
value={realName}
|
|
||||||
onChange={(event) => setRealName(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="id-number">身份证号</Label>
|
|
||||||
<Input
|
|
||||||
id="id-number"
|
|
||||||
placeholder="请输入身份证号"
|
|
||||||
value={idNumber}
|
|
||||||
onChange={(event) => setIdNumber(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>游戏资质(打手认证)</Label>
|
|
||||||
<Textarea
|
|
||||||
placeholder="请描述你的游戏经历、段位、擅长游戏等"
|
|
||||||
rows={3}
|
|
||||||
value={gameProfile}
|
|
||||||
onChange={(event) => setGameProfile(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>证明材料</Label>
|
<Label>证明材料</Label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-1 text-muted-foreground cursor-pointer hover:border-muted-foreground/50 transition-colors">
|
<MaterialUpload
|
||||||
<Upload className="h-5 w-5" />
|
label="身份证正面"
|
||||||
<span className="text-[10px]">身份证正面</span>
|
value={idCardFront}
|
||||||
|
uploading={uploading === "idCardFront"}
|
||||||
|
onSelect={(file) => uploadMaterial("idCardFront", file)}
|
||||||
|
/>
|
||||||
|
<MaterialUpload
|
||||||
|
label="身份证反面"
|
||||||
|
value={idCardBack}
|
||||||
|
uploading={uploading === "idCardBack"}
|
||||||
|
onSelect={(file) => uploadMaterial("idCardBack", file)}
|
||||||
|
/>
|
||||||
|
<MaterialUpload
|
||||||
|
label="游戏截图"
|
||||||
|
value={gameScreenshot}
|
||||||
|
uploading={uploading === "gameScreenshot"}
|
||||||
|
onSelect={(file) => uploadMaterial("gameScreenshot", file)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-1 text-muted-foreground cursor-pointer hover:border-muted-foreground/50 transition-colors">
|
<p className="text-xs text-muted-foreground">
|
||||||
<Upload className="h-5 w-5" />
|
身份证正反面为必填,游戏截图可用于打手认证
|
||||||
<span className="text-[10px]">身份证反面</span>
|
</p>
|
||||||
</div>
|
|
||||||
<div className="h-24 w-24 rounded-md border-2 border-dashed border-muted-foreground/25 flex flex-col items-center justify-center gap-1 text-muted-foreground cursor-pointer hover:border-muted-foreground/50 transition-colors">
|
|
||||||
<Upload className="h-5 w-5" />
|
|
||||||
<span className="text-[10px]">游戏截图</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">支持 JPG、PNG 格式,单张不超过 5MB</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={
|
disabled={
|
||||||
!verifyRole ||
|
!verifyRole ||
|
||||||
verificationStatus[verifyRole] === "pending" ||
|
submitting ||
|
||||||
verificationStatus[verifyRole] === "approved"
|
statusFor(verifyRole) === "pending" ||
|
||||||
|
statusFor(verifyRole) === "approved" ||
|
||||||
|
!idCardFront ||
|
||||||
|
!idCardBack
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!verifyRole) return
|
if (!verifyRole) return
|
||||||
submitWithMockApproval(verifyRole)
|
void submitVerification(verifyRole)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{verifyRole && verificationStatus[verifyRole] === "rejected"
|
{submitting
|
||||||
|
? "提交中..."
|
||||||
|
: verifyRole && statusFor(verifyRole) === "rejected"
|
||||||
? "重新提交认证申请"
|
? "重新提交认证申请"
|
||||||
: "提交认证申请"}
|
: "提交认证申请"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+8
-1
@@ -16,4 +16,11 @@ export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./revi
|
|||||||
export { getServiceById, listServices, listServicesByPlayer } from "./services"
|
export { getServiceById, listServices, listServicesByPlayer } from "./services"
|
||||||
export { getShopById, getShopByOwnerId, listShops } from "./shops"
|
export { getShopById, getShopByOwnerId, listShops } from "./shops"
|
||||||
export { getWalletBalance, listWalletTransactions } from "./transactions"
|
export { getWalletBalance, listWalletTransactions } from "./transactions"
|
||||||
export { getCurrentUserForLogin, getUserById, switchCurrentRole, updateCurrentUser } from "./users"
|
export {
|
||||||
|
applyCurrentUserVerification,
|
||||||
|
getCurrentUserForLogin,
|
||||||
|
getUserById,
|
||||||
|
listCurrentUserVerifications,
|
||||||
|
switchCurrentRole,
|
||||||
|
updateCurrentUser,
|
||||||
|
} from "./users"
|
||||||
|
|||||||
@@ -8,6 +8,25 @@ export type UpdateCurrentUserInput = {
|
|||||||
bio?: string
|
bio?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type VerificationMaterials = {
|
||||||
|
idCardFront: string
|
||||||
|
idCardBack: string
|
||||||
|
gameScreenshots?: string[]
|
||||||
|
voiceDemo?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VerificationRecord = {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
userNickname: string
|
||||||
|
role: UserRole
|
||||||
|
status: string
|
||||||
|
materials: Record<string, string>
|
||||||
|
rejectReason?: string
|
||||||
|
createdAt: string
|
||||||
|
reviewedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUserById(userId: string): Promise<User | undefined> {
|
export async function getUserById(userId: string): Promise<User | undefined> {
|
||||||
try {
|
try {
|
||||||
return await httpJson<User>(`/api/v1/users/${encodeURIComponent(userId)}`)
|
return await httpJson<User>(`/api/v1/users/${encodeURIComponent(userId)}`)
|
||||||
@@ -34,3 +53,20 @@ export async function switchCurrentRole(role: UserRole): Promise<void> {
|
|||||||
json: { role },
|
json: { role },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyCurrentUserVerification(input: {
|
||||||
|
role: UserRole
|
||||||
|
materials: VerificationMaterials
|
||||||
|
}): Promise<void> {
|
||||||
|
await httpJson<unknown>("/api/v1/users/me/verification", {
|
||||||
|
method: "POST",
|
||||||
|
json: input,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCurrentUserVerifications(): Promise<VerificationRecord[]> {
|
||||||
|
const res = await httpJson<{ list: VerificationRecord[] }>("/api/v1/users/me/verification", {
|
||||||
|
cache: "no-store",
|
||||||
|
})
|
||||||
|
return res.list
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user