Spaces:
Running
Running
| import { useState, useEffect } from 'react'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { | |
| Key, Plus, Trash2, Copy, Check, Shield, Clock, Zap, AlertCircle, RefreshCw, AlertTriangle, Eye, EyeOff | |
| } from 'lucide-react'; | |
| import { useAuth } from '../context/AuthContext'; | |
| import { cn } from '../lib/utils'; | |
| export default function Keys() { | |
| const { token: adminToken } = useAuth(); | |
| const [keys, setKeys] = useState([]); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isGenerating, setIsGenerating] = useState(false); | |
| // Form State | |
| const [keyName, setKeyName] = useState(''); | |
| const [customKey, setCustomKey] = useState(''); | |
| const [enableRateLimit, setEnableRateLimit] = useState(false); | |
| const [maxRequests, setMaxRequests] = useState(100); | |
| const [windowSeconds, setWindowSeconds] = useState(60); | |
| const [copiedKey, setCopiedKey] = useState(''); | |
| const [message, setMessage] = useState({ type: '', content: '' }); | |
| // UI State | |
| const [deleteConfirm, setDeleteConfirm] = useState({ isOpen: false, key: null }); | |
| const [visibleKeys, setVisibleKeys] = useState(new Set()); | |
| const fetchKeys = async () => { | |
| setIsLoading(true); | |
| try { | |
| const res = await fetch('/admin/keys', { | |
| headers: { 'X-Admin-Token': adminToken } | |
| }); | |
| const data = await res.json(); | |
| setKeys(data); | |
| } catch (error) { | |
| console.error('Failed to fetch keys', error); | |
| setMessage({ type: 'error', content: '加载密钥失败' }); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchKeys(); | |
| }, [adminToken]); | |
| const generateKey = async () => { | |
| setIsGenerating(true); | |
| try { | |
| const body = { | |
| name: keyName, | |
| key: customKey || undefined, | |
| rate_limit: enableRateLimit ? { | |
| requests: parseInt(maxRequests), | |
| window: parseInt(windowSeconds) | |
| } : undefined | |
| }; | |
| const res = await fetch('/admin/keys/generate', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'X-Admin-Token': adminToken | |
| }, | |
| body: JSON.stringify(body) | |
| }); | |
| const data = await res.json(); | |
| if (data.key) { | |
| setMessage({ type: 'success', content: '密钥生成成功' }); | |
| setKeyName(''); | |
| setCustomKey(''); | |
| setEnableRateLimit(false); | |
| fetchKeys(); | |
| } else { | |
| setMessage({ type: 'error', content: data.error || '生成失败' }); | |
| } | |
| } catch (error) { | |
| setMessage({ type: 'error', content: '请求失败: ' + error.message }); | |
| } finally { | |
| setIsGenerating(false); | |
| } | |
| }; | |
| const handleDeleteClick = (key) => { | |
| setDeleteConfirm({ isOpen: true, key }); | |
| }; | |
| const confirmDelete = async () => { | |
| if (!deleteConfirm.key) return; | |
| try { | |
| const res = await fetch(`/admin/keys/${deleteConfirm.key}`, { | |
| method: 'DELETE', | |
| headers: { 'X-Admin-Token': adminToken } | |
| }); | |
| if (res.ok) { | |
| fetchKeys(); | |
| setDeleteConfirm({ isOpen: false, key: null }); | |
| } | |
| } catch (error) { | |
| console.error('Delete failed', error); | |
| } | |
| }; | |
| const copyToClipboard = (text) => { | |
| navigator.clipboard.writeText(text); | |
| setCopiedKey(text); | |
| setTimeout(() => setCopiedKey(''), 2000); | |
| }; | |
| const toggleKeyVisibility = (key) => { | |
| setVisibleKeys(prev => { | |
| const newSet = new Set(prev); | |
| if (newSet.has(key)) { | |
| newSet.delete(key); | |
| } else { | |
| newSet.add(key); | |
| } | |
| return newSet; | |
| }); | |
| }; | |
| const maskKey = (key) => { | |
| if (visibleKeys.has(key)) return key; | |
| return key.substring(0, 3) + '•'.repeat(20) + key.substring(key.length - 4); | |
| }; | |
| return ( | |
| <div className="space-y-8 relative"> | |
| {/* Delete Confirmation Modal */} | |
| <AnimatePresence> | |
| {deleteConfirm.isOpen && ( | |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/5 backdrop-blur-sm"> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| className="bg-white rounded-xl shadow-xl max-w-md w-full overflow-hidden border border-zinc-200" | |
| > | |
| <div className="p-8 text-center"> | |
| <div className="w-12 h-12 bg-red-50 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-100"> | |
| <AlertTriangle className="w-6 h-6 text-red-600" /> | |
| </div> | |
| <h3 className="text-lg font-semibold text-zinc-900 mb-2">确认删除密钥?</h3> | |
| <p className="text-zinc-500 text-sm mb-8 leading-relaxed"> | |
| 您确定要删除密钥 <code className="bg-zinc-100 px-2 py-1 rounded text-zinc-700 border border-zinc-200 font-mono">{deleteConfirm.key?.substring(0, 8)}...</code> 吗?<br /> | |
| 此操作无法撤销,相关应用将立即失去访问权限。 | |
| </p> | |
| <div className="flex gap-4 justify-center"> | |
| <button | |
| onClick={() => setDeleteConfirm({ isOpen: false, key: null })} | |
| className="px-6 py-2.5 bg-white border border-zinc-200 hover:bg-zinc-50 text-zinc-700 font-medium rounded-lg transition-colors text-sm" | |
| > | |
| 取消 | |
| </button> | |
| <button | |
| onClick={confirmDelete} | |
| className="px-6 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors shadow-sm text-sm" | |
| > | |
| 确认删除 | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| <div className="flex justify-between items-end pb-2"> | |
| <div> | |
| <h2 className="text-2xl font-semibold text-zinc-900 tracking-tight">密钥管理</h2> | |
| <p className="text-base text-zinc-500 mt-1">生成和管理 API 访问密钥</p> | |
| </div> | |
| <button | |
| onClick={fetchKeys} | |
| className="p-2.5 text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100 rounded-lg transition-colors" | |
| > | |
| <RefreshCw className={cn("w-5 h-5", isLoading && "animate-spin")} /> | |
| </button> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> | |
| {/* Generator Card */} | |
| <div className="lg:col-span-1"> | |
| <div className="bg-white rounded-xl border border-zinc-200 p-6 shadow-sm sticky top-24"> | |
| <h3 className="font-medium text-zinc-900 mb-6 flex items-center gap-2 text-base"> | |
| <Plus className="w-5 h-5" /> | |
| 生成新密钥 | |
| </h3> | |
| <div className="space-y-5"> | |
| <div> | |
| <label className="block text-sm font-medium text-zinc-700 mb-2">密钥名称</label> | |
| <input | |
| type="text" | |
| value={keyName} | |
| onChange={(e) => setKeyName(e.target.value)} | |
| placeholder="例如: 我的应用密钥" | |
| className="w-full px-4 py-2.5 bg-zinc-50 border border-zinc-200 rounded-lg focus:ring-2 focus:ring-zinc-900/5 focus:border-zinc-900 outline-none transition-all text-sm placeholder:text-zinc-400" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-sm font-medium text-zinc-700 mb-2">自定义密钥 (可选)</label> | |
| <div className="relative"> | |
| <Key className="absolute left-3.5 top-3 w-4 h-4 text-zinc-400" /> | |
| <input | |
| type="text" | |
| value={customKey} | |
| onChange={(e) => setCustomKey(e.target.value)} | |
| placeholder="留空自动生成" | |
| className="w-full pl-10 pr-4 py-2.5 bg-zinc-50 border border-zinc-200 rounded-lg focus:ring-2 focus:ring-zinc-900/5 focus:border-zinc-900 outline-none transition-all font-mono text-sm placeholder:text-zinc-400" | |
| /> | |
| </div> | |
| </div> | |
| <div className="p-4 bg-zinc-50/50 rounded-lg border border-zinc-100"> | |
| <label className="flex items-center gap-3 cursor-pointer mb-3"> | |
| <input | |
| type="checkbox" | |
| checked={enableRateLimit} | |
| onChange={(e) => setEnableRateLimit(e.target.checked)} | |
| className="w-4 h-4 rounded border-zinc-300 text-zinc-900 focus:ring-zinc-900" | |
| /> | |
| <span className="text-sm font-medium text-zinc-700">启用频率限制</span> | |
| </label> | |
| <AnimatePresence> | |
| {enableRateLimit && ( | |
| <motion.div | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: 'auto' }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className="space-y-4 overflow-hidden pt-1" | |
| > | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-500 mb-1.5">最大请求</label> | |
| <input | |
| type="number" | |
| value={maxRequests} | |
| onChange={(e) => setMaxRequests(e.target.value)} | |
| min="1" | |
| className="w-full px-3 py-2 bg-white border border-zinc-200 rounded-md text-sm focus:border-zinc-900 outline-none" | |
| /> | |
| </div> | |
| <div> | |
| <label className="block text-xs font-medium text-zinc-500 mb-1.5">窗口(秒)</label> | |
| <input | |
| type="number" | |
| value={windowSeconds} | |
| onChange={(e) => setWindowSeconds(e.target.value)} | |
| min="1" | |
| className="w-full px-3 py-2 bg-white border border-zinc-200 rounded-md text-sm focus:border-zinc-900 outline-none" | |
| /> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| <button | |
| onClick={generateKey} | |
| disabled={isGenerating} | |
| className="w-full py-2.5 bg-zinc-900 hover:bg-zinc-800 text-white font-medium rounded-lg transition-all disabled:opacity-50 shadow-sm hover:shadow-md text-sm mt-2" | |
| > | |
| {isGenerating ? '生成中...' : '生成密钥'} | |
| </button> | |
| <AnimatePresence> | |
| {message.content && ( | |
| <motion.div | |
| initial={{ opacity: 0, height: 0 }} | |
| animate={{ opacity: 1, height: 'auto' }} | |
| exit={{ opacity: 0, height: 0 }} | |
| className={cn( | |
| "flex items-center gap-2 p-3 rounded-lg text-sm font-medium", | |
| message.type === 'error' ? "bg-red-50 text-red-600 border border-red-100" : "bg-emerald-50 text-emerald-600 border border-emerald-100" | |
| )} | |
| > | |
| <AlertCircle className="w-4 h-4" /> | |
| {message.content} | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Keys List */} | |
| <div className="lg:col-span-2"> | |
| <div className="bg-white rounded-xl border border-zinc-200 overflow-hidden shadow-sm flex flex-col min-h-[600px]"> | |
| <div className="px-6 py-4 border-b border-zinc-100 bg-zinc-50/30 flex justify-between items-center"> | |
| <span className="text-sm font-medium text-zinc-600">密钥列表</span> | |
| <span className="text-xs text-zinc-500 bg-zinc-100 px-2.5 py-1 rounded-full font-medium">{keys.length} ACTIVE</span> | |
| </div> | |
| <div className="flex-1 overflow-y-auto"> | |
| {keys.length === 0 ? ( | |
| <div className="h-full flex flex-col items-center justify-center p-12 text-center"> | |
| <div className="w-16 h-16 bg-zinc-50 rounded-full flex items-center justify-center mb-4"> | |
| <Key className="w-8 h-8 text-zinc-300" /> | |
| </div> | |
| <p className="text-base text-zinc-500 font-medium">暂无 API 密钥</p> | |
| <p className="text-sm text-zinc-400 mt-1">请在左侧创建您的第一个密钥</p> | |
| </div> | |
| ) : ( | |
| <div className="divide-y divide-zinc-100"> | |
| {keys.map((k) => ( | |
| <motion.div | |
| key={k.key} | |
| layout | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| className="p-6 hover:bg-zinc-50/50 transition-colors group" | |
| > | |
| <div className="flex items-start justify-between gap-6"> | |
| <div className="flex-1 min-w-0 space-y-4"> | |
| <div className="flex items-center gap-3"> | |
| <h3 className="font-semibold text-zinc-900 text-base">{k.name || '未命名密钥'}</h3> | |
| {k.rate_limit ? ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium bg-amber-50 text-amber-700 border border-amber-100"> | |
| <Shield className="w-3.5 h-3.5" /> | |
| {k.rate_limit.requests}/{k.rate_limit.window}s | |
| </span> | |
| ) : ( | |
| <span className="inline-flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium bg-emerald-50 text-emerald-700 border border-emerald-100"> | |
| <Zap className="w-3.5 h-3.5" /> | |
| 无限制 | |
| </span> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <code className="bg-zinc-100 px-3 py-1.5 rounded-md text-sm font-mono text-zinc-600 border border-zinc-200/50 tracking-wide"> | |
| {maskKey(k.key)} | |
| </code> | |
| <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <button | |
| onClick={() => toggleKeyVisibility(k.key)} | |
| className="p-2 text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100 rounded-lg transition-colors" | |
| title={visibleKeys.has(k.key) ? "隐藏密钥" : "显示密钥"} | |
| > | |
| {visibleKeys.has(k.key) ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />} | |
| </button> | |
| <button | |
| onClick={() => copyToClipboard(k.key)} | |
| className="p-2 text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100 rounded-lg transition-colors" | |
| title="复制密钥" | |
| > | |
| {copiedKey === k.key ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />} | |
| </button> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 text-xs text-zinc-400"> | |
| <Clock className="w-3.5 h-3.5" /> | |
| <span>创建于: {new Date(k.created).toLocaleString()}</span> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => handleDeleteClick(k.key)} | |
| className="p-2.5 text-zinc-400 hover:text-red-600 hover:bg-red-50 rounded-xl opacity-0 group-hover:opacity-100 transition-all" | |
| title="删除密钥" | |
| > | |
| <Trash2 className="w-5 h-5" /> | |
| </button> | |
| </div> | |
| </motion.div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |