283 lines
9.7 KiB
TypeScript
283 lines
9.7 KiB
TypeScript
"use client"
|
||
|
||
import { Badge } from "@/components/ui/badge"
|
||
import { Button } from "@/components/ui/button"
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||
import { Label } from "@/components/ui/label"
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select"
|
||
import { StatusBadge } from "@/components/ui/status-badge"
|
||
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, ShieldCheck, Upload } from "lucide-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-lg border border-border/60 bg-muted/20 flex flex-col items-center justify-center gap-1.5 text-muted-foreground cursor-pointer hover:bg-accent/50 hover:border-border 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 opacity-70" />
|
||
<span className="text-[10px] font-medium">{uploading ? "上传中..." : label}</span>
|
||
{value && (
|
||
<Badge variant="success" className="text-[10px] px-1.5 py-0 mt-0.5">
|
||
已上传
|
||
</Badge>
|
||
)}
|
||
</label>
|
||
)
|
||
}
|
||
|
||
export default function VerifyPage() {
|
||
const [verifyRole, setVerifyRole] = useState<UserRole | "">("")
|
||
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 login = useAuthStore((state) => state.login)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
|
||
void listCurrentUserVerifications()
|
||
.then((records) => {
|
||
if (!cancelled) setVerificationRecords(records)
|
||
})
|
||
.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(),
|
||
getCurrentUserForLogin(),
|
||
])
|
||
setVerificationRecords(records)
|
||
login(updated, updated.verifiedRoles ?? [updated.role])
|
||
notifySuccess("认证申请已提交")
|
||
} catch (error) {
|
||
notifyInfo(toApiError(error).msg)
|
||
} finally {
|
||
setSubmitting(false)
|
||
}
|
||
}
|
||
|
||
const roleMeta = [
|
||
{ role: "player", label: "打手认证" },
|
||
{ 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-8">
|
||
<h1 className="text-2xl font-bold">身份认证</h1>
|
||
|
||
<section className="space-y-4">
|
||
<h2 className="text-base font-semibold">认证说明</h2>
|
||
<div className="rounded-xl border border-border/60 bg-muted/10 p-4 space-y-3 text-sm text-muted-foreground">
|
||
<div className="flex items-start gap-2">
|
||
<ShieldCheck className="h-4 w-4 shrink-0 mt-0.5 text-primary" />
|
||
<span>通过身份认证后,你可以成为打手或店主,开始接单或经营店铺</span>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
<CheckCircle className="h-4 w-4 shrink-0 mt-0.5 text-primary" />
|
||
<span>认证信息仅用于平台审核,不会对外展示</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="space-y-4">
|
||
<h2 className="text-base font-semibold">认证状态</h2>
|
||
<div className="rounded-xl border border-border/60 divide-y divide-border/60">
|
||
{roleMeta.map((item) => {
|
||
const status = statusFor(item.role)
|
||
const reason = reasonFor(item.role)
|
||
|
||
return (
|
||
<div key={item.role} className="flex flex-col gap-2 p-4">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-sm font-medium">{item.label}</span>
|
||
{status === "approved" || verifiedRoles.includes(item.role) ? (
|
||
<StatusBadge status="success">已通过</StatusBadge>
|
||
) : status === "pending" ? (
|
||
<StatusBadge status="info">审核中</StatusBadge>
|
||
) : status === "rejected" ? (
|
||
<StatusBadge status="destructive">未通过</StatusBadge>
|
||
) : (
|
||
<StatusBadge status="neutral">未申请</StatusBadge>
|
||
)}
|
||
</div>
|
||
|
||
{status === "rejected" && (
|
||
<div className="flex items-center justify-between mt-2">
|
||
<p className="text-xs text-muted-foreground">驳回原因:{reason}</p>
|
||
<Button variant="outline" size="sm" onClick={() => setVerifyRole(item.role)}>
|
||
重新填写
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
</section>
|
||
|
||
<Card className="border-border/60 shadow-sm">
|
||
<CardHeader>
|
||
<CardTitle className="text-base">申请认证</CardTitle>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
<div className="space-y-3">
|
||
<Label>认证类型</Label>
|
||
<Select value={verifyRole} onValueChange={(value) => setVerifyRole(value as UserRole)}>
|
||
<SelectTrigger>
|
||
<SelectValue placeholder="选择认证类型" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="player">打手认证</SelectItem>
|
||
<SelectItem value="owner">店主认证</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-3">
|
||
<Label>证明材料</Label>
|
||
<div className="flex flex-wrap gap-3">
|
||
<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">
|
||
身份证正反面为必填,游戏截图可用于打手认证
|
||
</p>
|
||
</div>
|
||
|
||
<Button
|
||
className="w-full"
|
||
disabled={
|
||
!verifyRole ||
|
||
submitting ||
|
||
statusFor(verifyRole) === "pending" ||
|
||
statusFor(verifyRole) === "approved" ||
|
||
!idCardFront ||
|
||
!idCardBack
|
||
}
|
||
onClick={() => {
|
||
if (!verifyRole) return
|
||
void submitVerification(verifyRole)
|
||
}}
|
||
>
|
||
{submitting
|
||
? "提交中..."
|
||
: verifyRole && statusFor(verifyRole) === "rejected"
|
||
? "重新提交认证申请"
|
||
: "提交认证申请"}
|
||
</Button>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
)
|
||
}
|