fix(account): submit verification through backend
This commit is contained in:
+156
-97
@@ -3,7 +3,6 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
@@ -12,59 +11,132 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
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 { useAuthStore } from "@/store/auth"
|
||||
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() {
|
||||
const [verifyRole, setVerifyRole] = useState<UserRole | "">("")
|
||||
const [realName, setRealName] = useState("")
|
||||
const [idNumber, setIdNumber] = useState("")
|
||||
const [gameProfile, setGameProfile] = useState("")
|
||||
const [idCardFront, setIdCardFront] = useState("")
|
||||
const [idCardBack, setIdCardBack] = 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 verificationReasons = useAuthStore((state) => state.verificationReasons)
|
||||
const verifiedRoles = useAuthStore((state) => state.verifiedRoles)
|
||||
const submitVerification = useAuthStore((state) => state.submitVerification)
|
||||
const approveVerification = useAuthStore((state) => state.approveVerification)
|
||||
const timersRef = useRef<Map<UserRole, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const login = useAuthStore((state) => state.login)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
timersRef.current.forEach((timer) => {
|
||||
clearTimeout(timer)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
void listCurrentUserVerifications()
|
||||
.then((records) => {
|
||||
if (!cancelled) setVerificationRecords(records)
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) notifyInfo(toApiError(error).msg)
|
||||
})
|
||||
timersRef.current.clear()
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const buildMaterials = () => {
|
||||
const materials = {
|
||||
realName,
|
||||
idNumber,
|
||||
gameProfile,
|
||||
idCardFront: "mock://idCardFront",
|
||||
idCardBack: "mock://idCardBack",
|
||||
gameScreenshot: "mock://gameScreenshot",
|
||||
} satisfies Record<string, string>
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return materials
|
||||
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 submitWithMockApproval = (role: UserRole) => {
|
||||
submitVerification(role, buildMaterials())
|
||||
const oldTimer = timersRef.current.get(role)
|
||||
if (oldTimer) {
|
||||
clearTimeout(oldTimer)
|
||||
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(),
|
||||
getCurrentUserForLogin(),
|
||||
])
|
||||
setVerificationRecords(records)
|
||||
login(updated, updated.verifiedRoles ?? [updated.role])
|
||||
notifySuccess("认证申请已提交")
|
||||
} catch (error) {
|
||||
notifyInfo(toApiError(error).msg)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
approveVerification(role)
|
||||
timersRef.current.delete(role)
|
||||
}, 3000)
|
||||
timersRef.current.set(role, timer)
|
||||
}
|
||||
|
||||
const roleMeta = [
|
||||
@@ -72,6 +144,16 @@ export default function VerifyPage() {
|
||||
{ role: "owner", label: "店主认证" },
|
||||
] 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 (
|
||||
<div className="container mx-auto max-w-2xl px-4 py-8 space-y-6">
|
||||
<h1 className="text-2xl font-bold">身份认证</h1>
|
||||
@@ -98,8 +180,8 @@ export default function VerifyPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{roleMeta.map((item) => {
|
||||
const status = verificationStatus[item.role]
|
||||
const reason = verificationReasons[item.role]
|
||||
const status = statusFor(item.role)
|
||||
const reason = reasonFor(item.role)
|
||||
|
||||
return (
|
||||
<Card key={item.role} className="p-3 space-y-2 shadow-none">
|
||||
@@ -131,12 +213,8 @@ export default function VerifyPage() {
|
||||
)}
|
||||
|
||||
{status === "rejected" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => submitWithMockApproval(item.role)}
|
||||
>
|
||||
重新提交
|
||||
<Button variant="outline" size="sm" onClick={() => setVerifyRole(item.role)}>
|
||||
重新填写
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
@@ -163,72 +241,53 @@ export default function VerifyPage() {
|
||||
</Select>
|
||||
</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">
|
||||
<Label>证明材料</Label>
|
||||
<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">
|
||||
<Upload className="h-5 w-5" />
|
||||
<span className="text-[10px]">身份证正面</span>
|
||||
</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 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>
|
||||
<MaterialUpload
|
||||
label="身份证正面"
|
||||
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>
|
||||
<p className="text-xs text-muted-foreground">支持 JPG、PNG 格式,单张不超过 5MB</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
身份证正反面为必填,游戏截图可用于打手认证
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={
|
||||
!verifyRole ||
|
||||
verificationStatus[verifyRole] === "pending" ||
|
||||
verificationStatus[verifyRole] === "approved"
|
||||
submitting ||
|
||||
statusFor(verifyRole) === "pending" ||
|
||||
statusFor(verifyRole) === "approved" ||
|
||||
!idCardFront ||
|
||||
!idCardBack
|
||||
}
|
||||
onClick={() => {
|
||||
if (!verifyRole) return
|
||||
submitWithMockApproval(verifyRole)
|
||||
void submitVerification(verifyRole)
|
||||
}}
|
||||
>
|
||||
{verifyRole && verificationStatus[verifyRole] === "rejected"
|
||||
? "重新提交认证申请"
|
||||
: "提交认证申请"}
|
||||
{submitting
|
||||
? "提交中..."
|
||||
: verifyRole && statusFor(verifyRole) === "rejected"
|
||||
? "重新提交认证申请"
|
||||
: "提交认证申请"}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
+8
-1
@@ -16,4 +16,11 @@ export { listReviews, listReviewsByOrder, listReviewsByTargetUser } from "./revi
|
||||
export { getServiceById, listServices, listServicesByPlayer } from "./services"
|
||||
export { getShopById, getShopByOwnerId, listShops } from "./shops"
|
||||
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
|
||||
}
|
||||
|
||||
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> {
|
||||
try {
|
||||
return await httpJson<User>(`/api/v1/users/${encodeURIComponent(userId)}`)
|
||||
@@ -34,3 +53,20 @@ export async function switchCurrentRole(role: UserRole): Promise<void> {
|
||||
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