@@ -0,0 +1,134 @@
import * as React from "react" ;
import { useAuth } from "@/lib/auth" ;
import * as authApi from "@/api/auth" ;
import { PageHeader } from "@/components/generic/PageHeader" ;
import { Button } from "@/components/ui/button" ;
import { Input } from "@/components/ui/input" ;
import { Label } from "@/components/ui/label" ;
import { Card , CardContent , CardDescription , CardHeader , CardTitle } from "@/components/ui/card" ;
import { Loader2 , ShieldCheck , Smartphone , MessageSquareText , QrCode } from "lucide-react" ;
type Binding = { phone : string ; phoneBound : boolean ; wxBound : boolean ; wxMiniBound : boolean } ;
export default function SettingsPage() {
const { user , loginWithResponse } = useAuth ( ) ;
const [ b , setB ] = React . useState < Binding > ( {
phone : "" , phoneBound : false , wxBound : false , wxMiniBound : false ,
} ) ;
const [ phone , setPhone ] = React . useState ( "" ) ;
const [ code , setCode ] = React . useState ( "" ) ;
const [ countdown , setCountdown ] = React . useState ( 0 ) ;
const [ busy , setBusy ] = React . useState ( false ) ;
const [ msg , setMsg ] = React . useState < string | null > ( null ) ;
const [ err , setErr ] = React . useState < string | null > ( null ) ;
const refresh = React . useCallback ( async ( ) = > {
try {
const r = await authApi . fetchMe ( ) ;
const u = r as unknown as Binding & { username? : string } ;
setB ( {
phone : ( u . phone as string ) || "" ,
phoneBound : ! ! ( u . phoneBound ? ? ( u . phone as string ) ) ,
wxBound : ! ! u . wxBound ,
wxMiniBound : ! ! u . wxMiniBound ,
} ) ;
} catch { /* 401 时 client 已登出 */ }
} , [ ] ) ;
React . useEffect ( ( ) = > { refresh ( ) ; } , [ refresh ] ) ;
const send = async ( ) = > {
setErr ( null ) ;
if ( ! /^1\d{10}$/ . test ( phone ) ) { setErr ( "请输入 11 位手机号" ) ; return ; }
setBusy ( true ) ;
try {
const r = await authApi . sendCode ( phone ) ;
setMsg ( ` 验证码已发送 ${ r . stub_code ? ` (演示: ${ r . stub_code } ) ` : "" } ` ) ;
setCountdown ( 60 ) ;
const t = window . setInterval ( ( ) = > setCountdown ( ( c ) = > ( c <= 1 ? ( window . clearInterval ( t ) , 0 ) : c - 1 ) ) , 1000 ) ;
} catch ( e ) { setErr ( e instanceof Error ? e . message : "发送失败" ) ; }
finally { setBusy ( false ) ; }
} ;
const doBind = async ( ) = > {
setErr ( null ) ;
if ( ! /^1\d{10}$/ . test ( phone ) ) { setErr ( "请输入 11 位手机号" ) ; return ; }
if ( ! code ) { setErr ( "请输入验证码" ) ; return ; }
setBusy ( true ) ;
try {
const res = await authApi . bindPhone ( phone , code ) ;
await loginWithResponse ( res ) ; // 写新令牌(可能合并成另一账号)
setMsg ( "手机号绑定成功" ) ; setPhone ( "" ) ; setCode ( "" ) ;
await refresh ( ) ;
} catch ( e ) { setErr ( e instanceof Error ? e . message : "绑定失败" ) ; }
finally { setBusy ( false ) ; }
} ;
const doUnbind = async ( type : "phone" | "wx" | "wx_mini" ) = > {
setErr ( null ) ; setMsg ( null ) ; setBusy ( true ) ;
try {
await authApi . unbind ( type ) ;
setMsg ( "已解绑" ) ;
await refresh ( ) ;
} catch ( e ) { setErr ( e instanceof Error ? e . message : "解绑失败" ) ; }
finally { setBusy ( false ) ; }
} ;
const items : { key : string ; label : string ; icon : React.ReactNode ; bound : boolean } [ ] = [
{ key : "phone" , label : "手机号" , icon : < Smartphone className = "h-4 w-4" / > , bound : b.phoneBound } ,
{ key : "wx_mini" , label : "小程序" , icon : < QrCode className = "h-4 w-4" / > , bound : b.wxMiniBound } ,
{ key : "wx" , label : "微信(开放平台)" , icon : < MessageSquareText className = "h-4 w-4" / > , bound : b.wxBound } ,
] ;
return (
< div className = "mx-auto max-w-2xl space-y-6" >
< PageHeader title = "个人中心" description = "账号与安全:绑定/解绑登录方式(同一账号,绝不重复创建)" / >
< Card >
< CardHeader > < CardTitle className = "t text-base" > < ShieldCheck className = "mr-1 inline h-4 w-4" / > 当 前 账 号 < / CardTitle >
< CardDescription > { user ? . username || "已登录" } < / CardDescription > < / CardHeader >
< CardContent className = "space-y-2" >
{ items . map ( ( it ) = > (
< div key = { it . key } className = "flex items-center justify-between rounded-md border px-3 py-2" >
< span className = "flex items-center gap-2 text-sm" > { it . icon } { it . label } < / span >
< div className = "flex items-center gap-2" >
< span className = { ` text-sm ${ it . bound ? "text-emerald-500" : "text-muted-foreground" } ` } > { it . bound ? "已绑定" : "未绑定" } < / span >
{ it . bound && it . key !== "phone" && < Button size = "sm" variant = "outline" disabled = { busy } onClick = { ( ) = > doUnbind ( it . key as never ) } > 解 绑 < / Button > }
< / div >
< / div >
) ) }
< p className = "text-xs text-muted-foreground" > 小 程 序 绑 定 : 用 同 一 手 机 号 登 录 过 小 程 序 ( 微 信 ) 后 会 与 当 前 账 号 自 动 合 并 ; 也 可 在 「 绑 定 手 机 号 」 后 到 小 程 序 登 录 同 一 账 号 完 成 关 联 。 < / p >
< / CardContent >
< / Card >
< Card >
< CardHeader > < CardTitle className = "text-base" > 绑 定 手 机 号 < / CardTitle >
< CardDescription > { b . phoneBound ? ` 已绑定 ${ b . phone } ` : "未绑定手机号" } < / CardDescription > < / CardHeader >
< CardContent className = "space-y-3" >
{ b . phoneBound ? (
< div className = "flex items-center justify-between" >
< span className = "text-sm" > { b . phone } < / span >
< Button variant = "outline" disabled = { busy } onClick = { ( ) = > doUnbind ( "phone" ) } > 解 绑 手 机 号 < / Button >
< / div >
) : (
< >
< div className = "space-y-1" >
< Label > 手 机 号 < / Label >
< Input value = { phone } onChange = { ( e ) = > setPhone ( e . target . value ) } placeholder = "11 位手机号" / >
< / div >
< div className = "space-y-1" >
< Label > 验 证 码 < / Label >
< div className = "flex gap-2" >
< Input value = { code } onChange = { ( e ) = > setCode ( e . target . value ) } placeholder = "验证码" / >
< Button variant = "outline" className = "shrink-0" disabled = { countdown > 0 || busy } onClick = { send } > { countdown > 0 ? ` ${ countdown } s ` : "获取验证码" } < / Button >
< / div >
< / div >
{ msg && < p className = "text-sm text-emerald-500" > { msg } < / p > }
{ err && < p className = "text-sm text-destructive" > { err } < / p > }
< Button className = "w-full" disabled = { busy || ! phone || ! code } onClick = { doBind } > { busy && < Loader2 className = "animate-spin" / > } 绑 定 < / Button >
< / >
) }
< / CardContent >
< / Card >
< / div >
) ;
}