Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, useMemo } from 'react'; | |
| import { | |
| Key, Plus, Trash2, Copy, Check, Shield, | |
| Loader2, RefreshCw, KeyRound, | |
| MoreVertical, Edit2, BookOpen, | |
| Zap, CreditCard, Terminal, | |
| Layers, Search, Filter, ArrowRight, | |
| BarChart3, Cpu, Activity, Clock, X | |
| } from 'lucide-react'; | |
| import { useStore } from '@/store/useStore'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { useTranslation } from 'react-i18next'; | |
| import PageContainer from '@/components/PageContainer'; | |
| interface ApiKey { | |
| id: string; | |
| key_name: string; | |
| key_secret: string; | |
| last_used: string | null; | |
| created_at: string; | |
| status: 'active' | 'inactive'; | |
| scopes: string[]; | |
| } | |
| type Tab = 'overview' | 'keys' | 'docs'; | |
| type Lang = 'curl' | 'python' | 'node'; | |
| export default function DeveloperHub() { | |
| const { t } = useTranslation(); | |
| const { token } = useStore(); | |
| const [activeTab, setActiveTab] = useState<Tab>('overview'); | |
| const [keys, setKeys] = useState<ApiKey[]>([]); | |
| const [loading, setLoading] = useState(true); | |
| const [isCreating, setIsCreating] = useState(false); | |
| const [newKeyName, setNewKeyName] = useState(''); | |
| const [createdKey, setCreatedKey] = useState<{ key_name: string, key_secret: string } | null>(null); | |
| const [copied, setCopied] = useState<string | null>(null); | |
| const [showMenuId, setShowMenuId] = useState<string | null>(null); | |
| const [editingId, setEditingId] = useState<string | null>(null); | |
| const [editName, setEditName] = useState(''); | |
| // Search & Filter | |
| const [searchQuery, setSearchQuery] = useState(''); | |
| const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all'); | |
| // Docs 相关 | |
| const [selectedKeyForDocs, setSelectedKeyForDocs] = useState<string>('YOUR_API_KEY'); | |
| const [selectedLang, setSelectedLang] = useState<Lang>('curl'); | |
| const [isRevealing, setIsRevealing] = useState<string | null>(null); | |
| const fetchKeys = async () => { | |
| try { | |
| const response = await fetch('/api/apikey/list', { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| setKeys(data.keys); | |
| } | |
| } catch { | |
| // Ignore | |
| } | |
| setLoading(false); | |
| }; | |
| useEffect(() => { | |
| fetchKeys(); | |
| }, [token]); | |
| // 点击外部关闭菜单 | |
| useEffect(() => { | |
| const handleClickOutside = () => setShowMenuId(null); | |
| document.addEventListener('click', handleClickOutside); | |
| return () => document.removeEventListener('click', handleClickOutside); | |
| }, []); | |
| const filteredKeys = useMemo(() => { | |
| return keys.filter(k => { | |
| const matchesSearch = k.key_name.toLowerCase().includes(searchQuery.toLowerCase()); | |
| const matchesStatus = statusFilter === 'all' || k.status === statusFilter; | |
| return matchesSearch && matchesStatus; | |
| }); | |
| }, [keys, searchQuery, statusFilter]); | |
| const handleCreate = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| if (!newKeyName.trim()) return; | |
| try { | |
| const response = await fetch('/api/apikey/create', { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ name: newKeyName.trim(), scopes: ['all'] }), | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| setCreatedKey(data.key); | |
| setIsCreating(false); | |
| setNewKeyName(''); | |
| fetchKeys(); | |
| } | |
| } catch { | |
| // Ignore | |
| } | |
| }; | |
| const handleDelete = async (id: string) => { | |
| if (!confirm(t('developer.keys.confirm_delete'))) return; | |
| try { | |
| await fetch(`/api/apikey/${id}`, { | |
| method: 'DELETE', | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| fetchKeys(); | |
| } catch { | |
| // Ignore | |
| } | |
| }; | |
| const handleToggleStatus = async (key: ApiKey) => { | |
| const newStatus = key.status === 'active' ? 'inactive' : 'active'; | |
| try { | |
| await fetch(`/api/apikey/${key.id}`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ status: newStatus }), | |
| }); | |
| fetchKeys(); | |
| } catch { | |
| // Ignore | |
| } | |
| }; | |
| const handleUpdateName = async (id: string) => { | |
| if (!editName.trim()) return; | |
| try { | |
| await fetch(`/api/apikey/${id}`, { | |
| method: 'PATCH', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${token}` | |
| }, | |
| body: JSON.stringify({ name: editName.trim() }), | |
| }); | |
| setEditingId(null); | |
| fetchKeys(); | |
| } catch { | |
| // Ignore | |
| } | |
| }; | |
| const handleRegenerate = async (id: string) => { | |
| if (!confirm(t('developer.keys.confirm_regenerate'))) return; | |
| try { | |
| const response = await fetch(`/api/apikey/${id}/regenerate`, { | |
| method: 'POST', | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| const keyName = keys.find(k => k.id === id)?.key_name || t('developer.keys.default_key_name'); | |
| setCreatedKey({ key_name: keyName, key_secret: data.key_secret }); | |
| fetchKeys(); | |
| } | |
| } catch { | |
| // Ignore | |
| } | |
| }; | |
| const handleRevealAndCopy = async (id: string) => { | |
| setIsRevealing(id); | |
| try { | |
| const response = await fetch(`/api/apikey/${id}/reveal`, { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| copyToClipboard(data.key_secret, id); | |
| } | |
| } catch { | |
| // Ignore | |
| } | |
| setIsRevealing(null); | |
| }; | |
| const copyToClipboard = (text: string, id: string = 'global') => { | |
| navigator.clipboard.writeText(text); | |
| setCopied(id); | |
| setTimeout(() => setCopied(null), 2000); | |
| }; | |
| const baseUrl = window.location.origin + '/api/v1'; | |
| // 获取显示的 Secret | |
| const getSecretForDocs = () => { | |
| if (selectedKeyForDocs === 'YOUR_API_KEY') return 'sk_********************'; | |
| const k = keys.find(k => k.id === selectedKeyForDocs); | |
| return k ? k.key_secret : 'sk_********************'; | |
| }; | |
| // 生成代码片段 | |
| const getCodeSnippet = (lang: Lang) => { | |
| const secret = getSecretForDocs(); | |
| const endpoint = `${baseUrl}/chat/completions`; | |
| switch (lang) { | |
| case 'curl': | |
| return `curl ${endpoint} \\ | |
| -H "Content-Type: application/json" \\ | |
| -H "Authorization: Bearer ${secret}" \\ | |
| -d '{ | |
| "model": "codex-v1", | |
| "messages": [{"role": "user", "content": "你好"}] | |
| }'`; | |
| case 'python': | |
| return `import openai | |
| client = openai.OpenAI( | |
| api_key="${secret}", | |
| base_url="${baseUrl}" | |
| ) | |
| response = client.chat.completions.create( | |
| model="codex-v1", | |
| messages=[{"role": "user", "content": "你好"}] | |
| ) | |
| print(response.choices[0].message.content)`; | |
| case 'node': | |
| return `import OpenAI from 'openai'; | |
| const client = new OpenAI({ | |
| apiKey: '${secret}', | |
| baseURL: '${baseUrl}' | |
| }); | |
| async function main() { | |
| const response = await client.chat.completions.create({ | |
| model: 'codex-v1', | |
| messages: [{ role: 'user', content: '你好' }], | |
| }); | |
| console.log(response.choices[0].message.content); | |
| } | |
| main();`; | |
| default: | |
| return ''; | |
| } | |
| }; | |
| return ( | |
| <PageContainer className="pb-24"> | |
| {/* Header & Tabs */} | |
| <div className="mb-12"> | |
| <div className="flex flex-col md:flex-row md:items-end justify-between gap-8 mb-8"> | |
| <div className="flex items-center gap-5"> | |
| <div className="p-3.5 bg-gradient-to-br from-blue-600 to-indigo-700 rounded-[1.25rem] text-white shadow-xl shadow-blue-100 ring-4 ring-blue-50"> | |
| <Layers size={32} /> | |
| </div> | |
| <div> | |
| <h1 className="text-3xl font-extrabold text-zinc-900 tracking-tight">{t('developer.title')}</h1> | |
| <p className="text-zinc-500 mt-2 text-lg font-medium">{t('developer.subtitle')}</p> | |
| </div> | |
| </div> | |
| <div className="flex p-1.5 bg-white rounded-2xl w-fit border border-zinc-200 shadow-sm"> | |
| {(['overview', 'keys', 'docs'] as Tab[]).map((tab) => ( | |
| <button | |
| key={tab} | |
| onClick={() => setActiveTab(tab)} | |
| className={`flex items-center gap-2.5 px-6 py-3 rounded-xl text-sm font-bold transition-all ${ | |
| activeTab === tab | |
| ? 'bg-zinc-900 text-white shadow-md' | |
| : 'text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50' | |
| }`} | |
| > | |
| {tab === 'overview' && <Activity size={18} />} | |
| {tab === 'keys' && <KeyRound size={18} />} | |
| {tab === 'docs' && <BookOpen size={18} />} | |
| {t(`developer.tabs.${tab}`)} | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| <AnimatePresence mode="wait"> | |
| {activeTab === 'overview' && ( | |
| <motion.div | |
| key="overview-tab" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="space-y-8" | |
| > | |
| {/* 快速开始卡片 */} | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> | |
| <div className="bg-gradient-to-br from-blue-600 to-blue-800 rounded-[2rem] p-8 text-white shadow-xl shadow-blue-200 relative overflow-hidden group"> | |
| <div className="absolute -right-4 -bottom-4 opacity-10 group-hover:scale-110 transition-transform duration-500"> | |
| <Cpu size={160} /> | |
| </div> | |
| <h3 className="text-xl font-bold mb-2">{t('developer.quick_start.get_key')}</h3> | |
| <p className="text-blue-100 text-sm leading-relaxed mb-6 opacity-80"> | |
| {t('developer.quick_start.get_key_desc')} | |
| </p> | |
| <button | |
| onClick={() => setActiveTab('keys')} | |
| className="flex items-center gap-2 bg-white text-blue-700 px-5 py-2.5 rounded-xl font-bold text-sm hover:shadow-lg transition-all" | |
| > | |
| {t('developer.quick_start.manage')} <ArrowRight size={16} /> | |
| </button> | |
| </div> | |
| <div className="bg-white border border-zinc-200 rounded-[2rem] p-8 shadow-sm hover:shadow-md transition-all group"> | |
| <div className="w-12 h-12 bg-orange-50 text-orange-600 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> | |
| <Zap size={24} /> | |
| </div> | |
| <h3 className="text-xl font-bold text-zinc-900 mb-2">{t('developer.docs.title')}</h3> | |
| <p className="text-zinc-500 text-sm leading-relaxed mb-6"> | |
| {t('developer.quick_start.interactive_docs_desc')} | |
| </p> | |
| <button | |
| onClick={() => setActiveTab('docs')} | |
| className="flex items-center gap-2 text-blue-600 font-bold text-sm hover:underline" | |
| > | |
| {t('developer.quick_start.read_docs')} <ArrowRight size={16} /> | |
| </button> | |
| </div> | |
| <div className="bg-white border border-zinc-200 rounded-[2rem] p-8 shadow-sm hover:shadow-md transition-all group"> | |
| <div className="w-12 h-12 bg-purple-50 text-purple-600 rounded-2xl flex items-center justify-center mb-6 group-hover:scale-110 transition-transform"> | |
| <BarChart3 size={24} /> | |
| </div> | |
| <h3 className="text-xl font-bold text-zinc-900 mb-2">{t('developer.quick_start.usage_overview')}</h3> | |
| <p className="text-zinc-500 text-sm leading-relaxed mb-6"> | |
| {t('developer.quick_start.usage_overview_desc')} | |
| </p> | |
| <button className="flex items-center gap-2 text-blue-600 font-bold text-sm hover:underline"> | |
| {t('developer.quick_start.view_stats')} <ArrowRight size={16} /> | |
| </button> | |
| </div> | |
| </div> | |
| {/* 模拟统计图表部分 */} | |
| <div className="bg-white border border-zinc-200 rounded-[2.5rem] p-10 shadow-sm"> | |
| <div className="flex items-center justify-between mb-8"> | |
| <div> | |
| <h3 className="text-xl font-bold text-zinc-900">{t('developer.quick_start.call_trend')}</h3> | |
| <p className="text-xs text-zinc-400 mt-1">{t('developer.quick_start.call_trend_desc')}</p> | |
| </div> | |
| <div className="flex gap-4"> | |
| <div className="flex items-center gap-2"> | |
| <div className="w-2.5 h-2.5 bg-blue-500 rounded-full"></div> | |
| <span className="text-xs font-bold text-zinc-500">{t('developer.quick_start.requests')}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="h-48 flex items-end justify-between gap-4 px-4"> | |
| {[45, 78, 56, 92, 120, 88, 65].map((val, i) => ( | |
| <div key={i} className="flex-1 flex flex-col items-center gap-3"> | |
| <motion.div | |
| initial={{ height: 0 }} | |
| animate={{ height: `${val}%` }} | |
| transition={{ delay: i * 0.1, duration: 1 }} | |
| className="w-full bg-blue-500/10 hover:bg-blue-500/20 rounded-t-xl relative group transition-colors cursor-pointer" | |
| > | |
| <div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-zinc-900 text-white text-[10px] px-2 py-1 rounded opacity-0 group-hover:opacity-100 transition-opacity"> | |
| {val * 10} req | |
| </div> | |
| </motion.div> | |
| <span className="text-[10px] font-bold text-zinc-400">03-{13-6+i}</span> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| {activeTab === 'keys' && ( | |
| <motion.div | |
| key="keys-tab" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="space-y-6" | |
| > | |
| {/* Keys Tab Header */} | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> | |
| <div className="flex items-center gap-4 flex-1 max-w-2xl"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={18} /> | |
| <input | |
| type="text" | |
| placeholder={t('developer.keys.search_placeholder')} | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| className="w-full pl-12 pr-4 py-3 bg-white border border-zinc-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all shadow-sm" | |
| /> | |
| </div> | |
| <div className="relative"> | |
| <select | |
| value={statusFilter} | |
| onChange={(e) => setStatusFilter(e.target.value as any)} | |
| className="appearance-none pl-10 pr-10 py-3 bg-white border border-zinc-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all shadow-sm font-bold text-sm text-zinc-600" | |
| > | |
| <option value="all">{t('developer.keys.status_all')}</option> | |
| <option value="active">{t('developer.keys.status_active')}</option> | |
| <option value="inactive">{t('developer.keys.status_inactive')}</option> | |
| </select> | |
| <Filter className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={16} /> | |
| </div> | |
| </div> | |
| <button | |
| onClick={() => setIsCreating(true)} | |
| className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-2xl hover:bg-blue-700 transition-all shadow-lg shadow-blue-100 font-bold text-sm" | |
| > | |
| <Plus size={20} /> | |
| {t('developer.keys.new_key')} | |
| </button> | |
| </div> | |
| {loading ? ( | |
| <div className="h-64 flex items-center justify-center"> | |
| <Loader2 size={32} className="animate-spin text-blue-600" /> | |
| </div> | |
| ) : ( | |
| <div className="bg-white border border-zinc-200 rounded-[2.5rem] shadow-sm overflow-hidden"> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-left"> | |
| <thead className="bg-zinc-50/50 border-b border-zinc-100"> | |
| <tr> | |
| <th className="px-8 py-5 text-xs font-bold text-zinc-400 uppercase tracking-wider">{t('developer.keys.table.name')}</th> | |
| <th className="px-8 py-5 text-xs font-bold text-zinc-400 uppercase tracking-wider">{t('developer.keys.table.key')}</th> | |
| <th className="px-8 py-5 text-xs font-bold text-zinc-400 uppercase tracking-wider">{t('developer.keys.table.status')}</th> | |
| <th className="px-8 py-5 text-xs font-bold text-zinc-400 uppercase tracking-wider">{t('developer.keys.table.last_used')}</th> | |
| <th className="px-8 py-5 text-right pr-10">{t('developer.keys.table.actions')}</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-zinc-50"> | |
| {filteredKeys.map((key) => ( | |
| <tr key={key.id} className="hover:bg-zinc-50/30 transition-colors group"> | |
| <td className="px-8 py-5"> | |
| {editingId === key.id ? ( | |
| <div className="flex items-center gap-2"> | |
| <input | |
| autoFocus | |
| className="px-3 py-1.5 text-sm border border-zinc-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none w-40" | |
| value={editName} | |
| onChange={(e) => setEditName(e.target.value)} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter') handleUpdateName(key.id); | |
| if (e.key === 'Escape') setEditingId(null); | |
| }} | |
| /> | |
| <button onClick={() => handleUpdateName(key.id)} className="p-1.5 text-green-600 hover:bg-green-50 rounded-lg"><Check size={16} /></button> | |
| <button onClick={() => setEditingId(null)} className="p-1.5 text-zinc-400 hover:bg-zinc-50 rounded-lg"><X size={16} /></button> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-2"> | |
| <span className="text-sm font-bold text-zinc-900">{key.key_name}</span> | |
| <button | |
| onClick={() => { setEditingId(key.id); setEditName(key.key_name); }} | |
| className="opacity-0 group-hover:opacity-100 p-1 text-zinc-400 hover:text-blue-600 transition-all" | |
| > | |
| <Edit2 size={12} /> | |
| </button> | |
| </div> | |
| )} | |
| <div className="text-[10px] text-zinc-400 mt-1">{t('developer.keys.table.created_at', { date: new Date(key.created_at).toLocaleDateString() })}</div> | |
| </td> | |
| <td className="px-8 py-5"> | |
| <div className="flex items-center gap-2"> | |
| <code className="bg-zinc-100 px-3 py-1.5 rounded-xl text-xs font-mono text-zinc-500 border border-zinc-200/50 shadow-inner min-w-[120px]"> | |
| {key.key_secret} | |
| </code> | |
| <button | |
| onClick={() => handleRevealAndCopy(key.id)} | |
| className={`p-2 rounded-xl transition-all border ${ | |
| copied === key.id | |
| ? 'bg-green-50 text-green-600 border-green-200' | |
| : 'bg-white text-zinc-400 border-zinc-200 hover:border-blue-400 hover:text-blue-600' | |
| }`} | |
| disabled={isRevealing === key.id} | |
| title="复制完整密钥" | |
| > | |
| {isRevealing === key.id ? ( | |
| <Loader2 size={16} className="animate-spin" /> | |
| ) : copied === key.id ? ( | |
| <Check size={16} /> | |
| ) : ( | |
| <Copy size={16} /> | |
| )} | |
| </button> | |
| </div> | |
| </td> | |
| <td className="px-8 py-5"> | |
| <button | |
| onClick={() => handleToggleStatus(key)} | |
| className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[11px] font-bold border transition-all ${ | |
| key.status === 'active' | |
| ? 'bg-green-50 text-green-700 border-green-200 hover:bg-green-100' | |
| : 'bg-zinc-100 text-zinc-500 border-zinc-200 hover:bg-zinc-200' | |
| }`} | |
| > | |
| <span className={`w-1.5 h-1.5 rounded-full ${key.status === 'active' ? 'bg-green-500' : 'bg-zinc-400'}`}></span> | |
| {key.status === 'active' ? t('developer.keys.status_active') : t('developer.keys.status_inactive')} | |
| </button> | |
| </td> | |
| <td className="px-8 py-5 text-xs text-zinc-500 font-medium"> | |
| <div className="flex items-center gap-2"> | |
| <Clock size={12} className="text-zinc-300" /> | |
| {key.last_used ? new Date(key.last_used).toLocaleString() : t('developer.keys.table.never_used')} | |
| </div> | |
| </td> | |
| <td className="px-8 py-5 text-right pr-10"> | |
| <div className="flex items-center justify-end gap-1"> | |
| <div className="relative"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setShowMenuId(showMenuId === key.id ? null : key.id); | |
| }} | |
| className="p-2 text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100 rounded-xl transition-all" | |
| > | |
| <MoreVertical size={18} /> | |
| </button> | |
| {showMenuId === key.id && ( | |
| <div className="absolute right-0 top-full mt-2 w-44 bg-white border border-zinc-200 rounded-2xl shadow-xl z-20 py-1.5 overflow-hidden ring-1 ring-black/5"> | |
| <button | |
| onClick={() => handleRegenerate(key.id)} | |
| className="w-full text-left px-4 py-2.5 text-xs font-bold text-zinc-700 hover:bg-zinc-50 flex items-center gap-2.5" | |
| > | |
| <RefreshCw size={14} className="text-zinc-400" /> {t('developer.keys.menu.regenerate')} | |
| </button> | |
| <div className="h-px bg-zinc-100 my-1" /> | |
| <button | |
| onClick={() => handleDelete(key.id)} | |
| className="w-full text-left px-4 py-2.5 text-xs font-bold text-red-600 hover:bg-red-50 flex items-center gap-2.5" | |
| > | |
| <Trash2 size={14} className="text-red-400" /> {t('developer.keys.menu.delete')} | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| {filteredKeys.length === 0 && ( | |
| <tr> | |
| <td colSpan={5} className="px-8 py-20 text-center"> | |
| <div className="flex flex-col items-center gap-4 text-zinc-300"> | |
| <div className="p-4 bg-zinc-50 rounded-full"> | |
| <Key size={40} className="opacity-20" /> | |
| </div> | |
| <div className="space-y-1"> | |
| <p className="text-sm font-bold text-zinc-500">{t('developer.keys.no_match')}</p> | |
| <p className="text-xs text-zinc-400">{t('developer.keys.no_match_desc')}</p> | |
| </div> | |
| </div> | |
| </td> | |
| </tr> | |
| )} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </motion.div> | |
| )} | |
| {activeTab === 'docs' && ( | |
| <motion.div | |
| key="docs-tab" | |
| initial={{ opacity: 0, y: 10 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| exit={{ opacity: 0, y: -10 }} | |
| className="space-y-10" | |
| > | |
| {/* Docs Tab Content */} | |
| <div className="flex flex-col md:flex-row md:items-center justify-between gap-6 bg-blue-50 border border-blue-100 rounded-[2.5rem] p-10 shadow-sm relative overflow-hidden"> | |
| <div className="absolute top-0 right-0 w-1/3 h-full bg-gradient-to-l from-blue-500/5 to-transparent pointer-events-none"></div> | |
| <div className="space-y-3 z-10"> | |
| <h3 className="text-2xl font-black flex items-center gap-3 text-blue-900"> | |
| <Terminal className="text-blue-600" size={28} /> | |
| {t('developer.docs.title')} | |
| </h3> | |
| <p className="text-blue-700/70 text-sm max-w-md font-medium"> | |
| {t('developer.docs.subtitle')} | |
| </p> | |
| </div> | |
| <div className="flex flex-col gap-2 z-10"> | |
| <label className="text-[10px] font-bold text-blue-400 uppercase tracking-[0.2em] ml-1">{t('developer.docs.select_key')}</label> | |
| <div className="flex items-center gap-3"> | |
| <select | |
| className="bg-white border border-blue-200 text-blue-900 text-sm font-bold rounded-2xl px-5 py-3 outline-none focus:ring-2 focus:ring-blue-500 min-w-[220px] transition-all shadow-sm" | |
| value={selectedKeyForDocs} | |
| onChange={(e) => setSelectedKeyForDocs(e.target.value)} | |
| > | |
| <option value="YOUR_API_KEY">{t('developer.docs.default_key')}</option> | |
| {keys.filter(k => k.status === 'active').map(k => ( | |
| <option key={k.id} value={k.id}>{k.key_name}</option> | |
| ))} | |
| </select> | |
| {selectedKeyForDocs !== 'YOUR_API_KEY' && ( | |
| <button | |
| onClick={async () => { | |
| const k = keys.find(k => k.id === selectedKeyForDocs); | |
| if (k) { | |
| setIsRevealing('docs-reveal'); | |
| const res = await fetch(`/api/apikey/${k.id}/reveal`, { | |
| headers: { 'Authorization': `Bearer ${token}` } | |
| }); | |
| const data = await res.json(); | |
| if (data.success) { | |
| copyToClipboard(data.key_secret, 'docs-reveal'); | |
| } | |
| setIsRevealing(null); | |
| } | |
| }} | |
| className="p-3 bg-white hover:bg-blue-50 rounded-2xl text-blue-600 hover:text-blue-700 transition-all border border-blue-200 shadow-sm" | |
| title="复制完整 Key" | |
| > | |
| {isRevealing === 'docs-reveal' ? <Loader2 size={18} className="animate-spin" /> : <Copy size={18} />} | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 gap-12"> | |
| {/* API 1: Chat */} | |
| <section className="space-y-8"> | |
| <div className="flex items-center justify-between border-b border-zinc-100 pb-6"> | |
| <div className="flex items-center gap-4"> | |
| <div className="p-3 bg-orange-50 text-orange-600 rounded-[1.25rem]"> | |
| <Zap size={24} /> | |
| </div> | |
| <div> | |
| <h4 className="text-xl font-bold text-zinc-900">{t('developer.docs.chat_title')}</h4> | |
| <p className="text-sm text-zinc-500 mt-1">{t('developer.docs.chat_desc')}</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <span className="px-3 py-1 bg-green-50 text-green-700 rounded-xl text-xs font-black border border-green-200">POST</span> | |
| <code className="text-xs font-bold text-zinc-400 bg-zinc-50 px-3 py-1 rounded-xl">/chat/completions</code> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-10"> | |
| <div className="space-y-6"> | |
| <div className="bg-white border border-zinc-200 rounded-[2rem] p-8 space-y-6 shadow-sm"> | |
| <div className="space-y-2"> | |
| <h5 className="text-[10px] font-bold text-zinc-400 uppercase tracking-[0.2em]">{t('developer.docs.auth_header')}</h5> | |
| <div className="bg-zinc-900 rounded-2xl p-5 font-mono text-[11px] text-zinc-300 leading-relaxed shadow-inner"> | |
| Authorization: Bearer {selectedKeyForDocs === 'YOUR_API_KEY' ? 'sk_********************' : keys.find(k => k.id === selectedKeyForDocs)?.key_secret} | |
| </div> | |
| </div> | |
| <div className="p-4 bg-blue-50/50 border border-blue-100 rounded-2xl flex gap-4"> | |
| <div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center text-blue-600 shrink-0 shadow-sm"> | |
| <Shield size={18} /> | |
| </div> | |
| <p className="text-[11px] text-blue-700/80 leading-relaxed"> | |
| {t('developer.docs.https_tip')} | |
| </p> | |
| </div> | |
| <div className="space-y-4"> | |
| <h5 className="text-[10px] font-bold text-zinc-400 uppercase tracking-[0.2em]">{t('developer.docs.params')}</h5> | |
| <div className="space-y-3"> | |
| {[ | |
| { name: 'messages', type: 'Array', desc: t('developer.docs.param_messages'), req: true }, | |
| { name: 'model', type: 'String', desc: t('developer.docs.param_model'), req: false }, | |
| { name: 'stream', type: 'Boolean', desc: t('developer.docs.param_stream'), req: false }, | |
| ].map((param) => ( | |
| <div key={param.name} className="flex justify-between items-center py-2 border-b border-zinc-50 last:border-0"> | |
| <div> | |
| <span className="text-xs font-bold text-zinc-900">{param.name}</span> | |
| <span className="text-[10px] text-zinc-400 ml-2">({param.type})</span> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <span className="text-[10px] text-zinc-500">{param.desc}</span> | |
| <span className={`text-[9px] font-bold px-1.5 py-0.5 rounded ${param.req ? 'bg-blue-50 text-blue-600' : 'bg-zinc-100 text-zinc-400'}`}> | |
| {param.req ? t('developer.docs.required') : t('developer.docs.optional')} | |
| </span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="space-y-6"> | |
| <div className="bg-zinc-50 rounded-[2.5rem] overflow-hidden shadow-sm border border-zinc-200 flex flex-col h-full"> | |
| <div className="flex items-center justify-between px-8 py-4 bg-white border-b border-zinc-200"> | |
| <div className="flex p-1 bg-zinc-100 rounded-xl border border-zinc-200"> | |
| {(['curl', 'python', 'node'] as Lang[]).map((lang) => ( | |
| <button | |
| key={lang} | |
| onClick={() => setSelectedLang(lang)} | |
| className={`px-4 py-1.5 rounded-lg text-[10px] font-black transition-all ${ | |
| selectedLang === lang | |
| ? 'bg-white text-blue-600 shadow-sm' | |
| : 'text-zinc-500 hover:text-zinc-700' | |
| }`} | |
| > | |
| {lang.toUpperCase()} | |
| </button> | |
| ))} | |
| </div> | |
| <button | |
| onClick={() => { | |
| const code = getCodeSnippet(selectedLang); | |
| const k = keys.find(k => k.id === selectedKeyForDocs); | |
| if (k) { | |
| handleRevealAndCopy(k.id); | |
| } else { | |
| copyToClipboard(code, `code-${selectedLang}`); | |
| } | |
| }} | |
| className="flex items-center gap-2 text-zinc-400 hover:text-blue-600 transition-colors" | |
| > | |
| {copied === `code-${selectedLang}` || (selectedKeyForDocs !== 'YOUR_API_KEY' && copied === selectedKeyForDocs) ? ( | |
| <><Check size={14} className="text-green-600" /> <span className="text-[10px] font-bold text-green-600">{t('developer.keys.modal.copied')}</span></> | |
| ) : ( | |
| <><Copy size={14} /> <span className="text-[10px] font-bold">{t('developer.keys.modal.copy_code')}</span></> | |
| )} | |
| </button> | |
| </div> | |
| <div className="p-8 flex-1 overflow-auto custom-scrollbar"> | |
| <pre className="text-[11px] text-zinc-700 font-mono leading-relaxed"> | |
| {getCodeSnippet(selectedLang)} | |
| </pre> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| {/* API 2: Quota */} | |
| <section className="space-y-8"> | |
| <div className="flex items-center justify-between border-b border-zinc-100 pb-6"> | |
| <div className="flex items-center gap-4"> | |
| <div className="p-3 bg-purple-50 text-purple-600 rounded-[1.25rem]"> | |
| <CreditCard size={24} /> | |
| </div> | |
| <div> | |
| <h4 className="text-xl font-bold text-zinc-900">{t('developer.docs.quota_title')}</h4> | |
| <p className="text-sm text-zinc-500 mt-1">{t('developer.docs.quota_desc')}</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <span className="px-3 py-1 bg-blue-50 text-blue-700 rounded-xl text-xs font-black border border-blue-200">GET</span> | |
| <code className="text-xs font-bold text-zinc-400 bg-zinc-50 px-3 py-1 rounded-xl">/user/quota</code> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 lg:grid-cols-2 gap-10"> | |
| <div className="bg-zinc-50 rounded-[2.5rem] p-8 shadow-sm border border-zinc-200"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{t('developer.docs.req_example')}</span> | |
| </div> | |
| <pre className="text-[11px] text-zinc-700 font-mono leading-relaxed bg-white p-6 rounded-2xl border border-zinc-100"> | |
| {`curl ${baseUrl}/user/quota \\ | |
| -H "Authorization: Bearer ${selectedKeyForDocs === 'YOUR_API_KEY' ? 'YOUR_API_KEY' : 'FULL_SECRET'}"`} | |
| </pre> | |
| </div> | |
| <div className="bg-white border border-zinc-200 rounded-[2.5rem] p-8 shadow-sm"> | |
| <div className="flex items-center justify-between mb-6"> | |
| <span className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{t('developer.docs.res_example')}</span> | |
| </div> | |
| <pre className="text-[11px] text-zinc-600 font-mono leading-relaxed bg-zinc-50 p-6 rounded-2xl border border-zinc-100 shadow-inner"> | |
| {`{ | |
| "email": "user@example.com", | |
| "plan": "pro", | |
| "quota_remaining": 492 | |
| }`} | |
| </pre> | |
| </div> | |
| </div> | |
| </section> | |
| </div> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| {/* 创建模态框 (与之前一致,保持简洁) */} | |
| <AnimatePresence> | |
| {isCreating && ( | |
| <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| className="bg-white rounded-[2.5rem] w-full max-w-md overflow-hidden shadow-2xl" | |
| > | |
| <div className="p-8 border-b border-zinc-100 flex items-center justify-between"> | |
| <h3 className="text-xl font-bold text-zinc-900">{t('developer.keys.modal.new_key_title')}</h3> | |
| <button onClick={() => setIsCreating(false)} className="p-2 text-zinc-400 hover:text-zinc-600 hover:bg-zinc-100 rounded-xl transition-all"> | |
| <X size={20} /> | |
| </button> | |
| </div> | |
| <form onSubmit={handleCreate} className="p-8 space-y-6"> | |
| <div className="space-y-2"> | |
| <label className="text-[10px] font-bold text-zinc-400 uppercase tracking-widest ml-1">{t('developer.keys.modal.key_name_label')}</label> | |
| <input | |
| required | |
| autoFocus | |
| value={newKeyName} | |
| onChange={e => setNewKeyName(e.target.value)} | |
| placeholder={t('developer.keys.modal.key_name_placeholder')} | |
| className="w-full px-5 py-4 bg-zinc-50 border border-zinc-200 rounded-2xl focus:ring-2 focus:ring-blue-500 outline-none transition-all" | |
| /> | |
| </div> | |
| <div className="flex gap-4 pt-2"> | |
| <button | |
| type="button" | |
| onClick={() => setIsCreating(false)} | |
| className="flex-1 py-4 bg-zinc-100 text-zinc-600 rounded-2xl font-bold hover:bg-zinc-200 transition-all" | |
| > | |
| {t('common.cancel')} | |
| </button> | |
| <button | |
| type="submit" | |
| className="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-bold hover:bg-blue-700 transition-all shadow-lg shadow-blue-200" | |
| > | |
| {t('developer.keys.modal.create_button')} | |
| </button> | |
| </div> | |
| </form> | |
| </motion.div> | |
| </div> | |
| )} | |
| {/* 成功展示模态框 (仅显示一次 Secret) */} | |
| {createdKey && ( | |
| <div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> | |
| <motion.div | |
| initial={{ opacity: 0, scale: 0.95 }} | |
| animate={{ opacity: 1, scale: 1 }} | |
| exit={{ opacity: 0, scale: 0.95 }} | |
| className="bg-white rounded-[2.5rem] w-full max-w-lg overflow-hidden shadow-2xl" | |
| > | |
| <div className="p-8 bg-green-50/50 border-b border-green-100"> | |
| <div className="flex items-center gap-4 text-green-700"> | |
| <div className="p-2 bg-white rounded-xl shadow-sm"> | |
| <Check size={24} /> | |
| </div> | |
| <h3 className="text-xl font-bold">{t('developer.keys.modal.success_title')}</h3> | |
| </div> | |
| </div> | |
| <div className="p-10 space-y-6 text-center"> | |
| <p className="text-sm text-zinc-600 leading-relaxed max-w-xs mx-auto"> | |
| {t('developer.keys.modal.success_desc')} | |
| </p> | |
| <div className="relative group"> | |
| <div className="w-full px-6 py-5 bg-zinc-900 text-zinc-100 rounded-[1.5rem] font-mono text-xs break-all pr-14 shadow-inner text-left leading-relaxed"> | |
| {createdKey.key_secret} | |
| </div> | |
| <button | |
| onClick={() => copyToClipboard(createdKey.key_secret, 'new_key')} | |
| className="absolute right-4 top-1/2 -translate-y-1/2 p-2.5 bg-white/10 hover:bg-white/20 rounded-xl text-zinc-100 transition-all border border-white/10" | |
| > | |
| {copied === 'new_key' ? <Check size={18} className="text-green-400" /> : <Copy size={18} />} | |
| </button> | |
| </div> | |
| <button | |
| onClick={() => setCreatedKey(null)} | |
| className="w-full py-4 bg-zinc-900 text-white rounded-2xl font-bold hover:bg-zinc-800 transition-all shadow-xl shadow-zinc-200 mt-4" | |
| > | |
| {t('developer.keys.modal.saved_button')} | |
| </button> | |
| </div> | |
| </motion.div> | |
| </div> | |
| )} | |
| </AnimatePresence> | |
| </PageContainer> | |
| ); | |
| } | |
| const CloseIcon = ({ size, className }: any) => { | |
| return ( | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width={size} | |
| height={size} | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| strokeWidth="2.5" | |
| strokeLinecap="round" | |
| strokeLinejoin="round" | |
| className={className} | |
| > | |
| <path d="M18 6 6 18"/><path d="m6 6 12 12"/> | |
| </svg> | |
| ); | |
| }; | |