11import { useState , useEffect , useRef , useCallback } from "react" ;
22import { useLocation } from "wouter" ;
33import { checkAuth , login } from "@/lib/api" ;
4+ import { ArrowRight , KeyRound , ShieldCheck , X } from "lucide-react" ;
45
56/**
67 * 管理后台暗门组件
@@ -21,6 +22,7 @@ export function AdminGate({
2122 const [ checking , setChecking ] = useState ( true ) ;
2223 const [ , setLocation ] = useLocation ( ) ;
2324 const inputRef = useRef < HTMLInputElement > ( null ) ;
25+ const modalRef = useRef < HTMLDivElement > ( null ) ;
2426
2527 // 打开时立即检查是否已登录
2628 useEffect ( ( ) => {
@@ -64,11 +66,35 @@ export function AdminGate({
6466 [ password , onClose , setLocation ]
6567 ) ;
6668
67- // ESC 关闭
69+ // ESC 关闭,并将键盘焦点限制在弹窗内
6870 useEffect ( ( ) => {
6971 if ( ! open ) return ;
7072 const handler = ( e : KeyboardEvent ) => {
71- if ( e . key === "Escape" ) onClose ( ) ;
73+ if ( e . key === "Escape" ) {
74+ onClose ( ) ;
75+ return ;
76+ }
77+
78+ if ( e . key !== "Tab" ) return ;
79+
80+ const focusableElements = Array . from (
81+ modalRef . current ?. querySelectorAll < HTMLElement > (
82+ 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
83+ ) || [ ]
84+ ) . filter ( ( element ) => ! element . hasAttribute ( "disabled" ) && element . offsetParent !== null ) ;
85+
86+ if ( focusableElements . length === 0 ) return ;
87+
88+ const first = focusableElements [ 0 ] ;
89+ const last = focusableElements [ focusableElements . length - 1 ] ;
90+
91+ if ( e . shiftKey && document . activeElement === first ) {
92+ e . preventDefault ( ) ;
93+ last . focus ( ) ;
94+ } else if ( ! e . shiftKey && document . activeElement === last ) {
95+ e . preventDefault ( ) ;
96+ first . focus ( ) ;
97+ }
7298 } ;
7399 window . addEventListener ( "keydown" , handler ) ;
74100 return ( ) => window . removeEventListener ( "keydown" , handler ) ;
@@ -80,75 +106,115 @@ export function AdminGate({
80106 < >
81107 { /* 背景遮罩 */ }
82108 < div
83- className = "fixed inset-0 z-50 bg-black/40 backdrop-blur-sm animate-in fade-in duration-200"
109+ className = "fixed inset-0 z-50 bg-background/55 backdrop-blur-[10px] animate-in fade-in duration-200"
84110 onClick = { onClose }
85111 />
86112
87113 { /* 密码框 */ }
88- < div className = "fixed z-50 left-1/2 top-[30%] -translate-x-1/2 w-[320px] animate-in fade-in slide-in-from-top-2 duration-200" >
89- < div className = "rounded-xl border border-border/40 bg-card/95 backdrop-blur-xl shadow-2xl p-[24px]" >
90- { checking ? (
91- < div className = "py-[16px] text-center text-[13px] text-muted-foreground/60" >
92- 验证中...
114+ < div
115+ ref = { modalRef }
116+ role = "dialog"
117+ aria-modal = "true"
118+ aria-labelledby = "admin-gate-title"
119+ className = "fixed left-1/2 top-1/2 z-50 w-[min(92vw,420px)] -translate-x-1/2 -translate-y-1/2 animate-in fade-in slide-in-from-top-2 duration-200"
120+ >
121+ < div className = "overflow-hidden rounded-md border border-border/35 bg-card/95 shadow-[0_28px_90px_oklch(0_0_0_/_38%)] backdrop-blur-xl" >
122+ < div className = "flex items-center justify-between border-b border-border/20 px-[18px] py-[14px]" >
123+ < div className = "flex items-center gap-[10px]" >
124+ < div className = "flex h-[32px] w-[32px] items-center justify-center rounded-md border border-cyan-400/20 bg-cyan-400/10 text-cyan-300" >
125+ < ShieldCheck className = "h-[16px] w-[16px]" />
126+ </ div >
127+ < div >
128+ < p id = "admin-gate-title" className = "text-[13px] font-semibold text-foreground" > 后台安全验证</ p >
129+ < p className = "text-[11px] text-muted-foreground/55" > Monolith Admin Gate</ p >
130+ </ div >
93131 </ div >
94- ) : (
95- < form onSubmit = { handleSubmit } aria-label = "管理员登录" >
96- < div className = "mb-[16px] text-center" >
97- < div className = "mx-auto mb-[10px] flex h-[36px] w-[36px] items-center justify-center rounded-lg bg-gradient-to-b from-foreground/8 to-foreground/4 border border-border/20" >
98- < span className = "text-[16px]" > 🔐</ span >
99- </ div >
100- < p className = "text-[12px] text-muted-foreground/50" >
101- 管理员验证
102- </ p >
132+ < button
133+ type = "button"
134+ onClick = { onClose }
135+ className = "flex h-[36px] w-[36px] items-center justify-center rounded-md text-muted-foreground/55 transition-colors hover:bg-accent/40 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
136+ aria-label = "关闭管理验证"
137+ >
138+ < X className = "h-[15px] w-[15px]" />
139+ </ button >
140+ </ div >
141+
142+ < div className = "p-[22px]" >
143+ { checking ? (
144+ < div className = "flex min-h-[180px] flex-col items-center justify-center gap-[12px] text-center text-[13px] text-muted-foreground/65" >
145+ < div className = "h-[28px] w-[28px] rounded-full border-2 border-foreground/10 border-t-cyan-400 animate-spin" />
146+ 正在检查登录状态
103147 </ div >
148+ ) : (
149+ < form onSubmit = { handleSubmit } aria-label = "管理员登录" >
150+ < div className = "mb-[18px]" >
151+ < div className = "mb-[12px] flex items-start gap-[12px]" >
152+ < div className = "mt-[2px] flex h-[44px] w-[44px] shrink-0 items-center justify-center rounded-md border border-border/30 bg-background/45 text-foreground" >
153+ < KeyRound className = "h-[18px] w-[18px]" />
154+ </ div >
155+ < div >
156+ < h2 className = "text-[18px] font-semibold leading-tight tracking-[-0.01em] text-foreground" > 进入管理后台</ h2 >
157+ < p className = "mt-[6px] text-[13px] leading-[1.7] text-muted-foreground/70" >
158+ 输入本地或生产环境配置的管理密码,验证通过后进入内容控制台。
159+ </ p >
160+ </ div >
161+ </ div >
162+ < div className = "grid grid-cols-3 gap-[6px] text-[10px] text-muted-foreground/50" >
163+ < span className = "rounded-md border border-border/20 bg-background/25 px-[8px] py-[6px] text-center" > 内容</ span >
164+ < span className = "rounded-md border border-border/20 bg-background/25 px-[8px] py-[6px] text-center" > 媒体</ span >
165+ < span className = "rounded-md border border-border/20 bg-background/25 px-[8px] py-[6px] text-center" > SEO</ span >
166+ </ div >
167+ </ div >
168+
169+ { /* 隐藏 username 字段:让 Bitwarden / 1Password / Chrome 等密码管理器识别为登录表单 */ }
170+ < input
171+ type = "text"
172+ name = "username"
173+ value = "admin"
174+ autoComplete = "username"
175+ readOnly
176+ hidden
177+ tabIndex = { - 1 }
178+ aria-hidden = "true"
179+ />
180+
181+ < input
182+ ref = { inputRef }
183+ id = "admin-gate-password"
184+ name = "password"
185+ type = "password"
186+ value = { password }
187+ onChange = { ( e ) => {
188+ setPassword ( e . target . value ) ;
189+ setError ( "" ) ;
190+ } }
191+ placeholder = "输入密码"
192+ autoComplete = "current-password"
193+ aria-label = "管理员密码"
194+ className = "h-[46px] w-full rounded-md border border-border/45 bg-background/55 px-[14px] pr-[48px] text-[15px] text-foreground outline-none transition-all placeholder:text-muted-foreground/35 focus:border-cyan-400/45 focus:ring-1 focus:ring-cyan-400/20"
195+ />
196+
197+ { error && (
198+ < p className = "mt-[10px] rounded-md border border-red-400/20 bg-red-400/8 px-[10px] py-[8px] text-center text-[12px] text-red-400/90" >
199+ { error }
200+ </ p >
201+ ) }
202+
203+ < button
204+ type = "submit"
205+ disabled = { loading || ! password . trim ( ) }
206+ className = "mt-[14px] flex h-[46px] w-full items-center justify-center gap-[8px] rounded-md bg-foreground text-[14px] font-medium text-background transition-all hover:-translate-y-[2px] hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring disabled:translate-y-0 disabled:opacity-40"
207+ >
208+ { loading ? "验证中..." : "进入控制台" }
209+ { ! loading && < ArrowRight className = "h-[15px] w-[15px]" /> }
210+ </ button >
104211
105- { /* 隐藏 username 字段:让 Bitwarden / 1Password / Chrome 等密码管理器识别为登录表单 */ }
106- < input
107- type = "text"
108- name = "username"
109- value = "admin"
110- autoComplete = "username"
111- readOnly
112- hidden
113- tabIndex = { - 1 }
114- aria-hidden = "true"
115- />
116-
117- < input
118- ref = { inputRef }
119- id = "admin-gate-password"
120- name = "password"
121- type = "password"
122- value = { password }
123- onChange = { ( e ) => {
124- setPassword ( e . target . value ) ;
125- setError ( "" ) ;
126- } }
127- placeholder = "输入密码"
128- autoComplete = "current-password"
129- aria-label = "管理员密码"
130- className = "h-[40px] w-full rounded-lg border border-border/40 bg-background/50 px-[14px] text-[14px] text-foreground placeholder:text-muted-foreground/30 outline-none focus:border-foreground/25 focus:ring-1 focus:ring-foreground/10 transition-all"
131- />
132-
133- { error && (
134- < p className = "mt-[8px] text-[12px] text-red-400/80 text-center" >
135- { error }
212+ < p className = "mt-[14px] text-center text-[11px] text-muted-foreground/35" >
213+ ESC 关闭 · Ctrl Shift A 呼出
136214 </ p >
137- ) }
138-
139- < button
140- type = "submit"
141- disabled = { loading || ! password . trim ( ) }
142- className = "mt-[12px] h-[36px] w-full rounded-lg bg-foreground text-background text-[13px] font-medium hover:opacity-90 disabled:opacity-40 transition-opacity"
143- >
144- { loading ? "验证中..." : "进入" }
145- </ button >
146-
147- < p className = "mt-[12px] text-center text-[11px] text-muted-foreground/25" >
148- 按 ESC 关闭
149- </ p >
150- </ form >
151- ) }
215+ </ form >
216+ ) }
217+ </ div >
152218 </ div >
153219 </ div >
154220 </ >
0 commit comments