first commit
This commit is contained in:
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SiliconFlow API Key 验证器</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
31
frontend/package.json
Normal file
31
frontend/package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "siliconflow-api-key-validator-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.0.0",
|
||||
"lucide-react": "^0.292.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"tailwind-merge": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.24",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.4.19"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
145
frontend/src/App.tsx
Normal file
145
frontend/src/App.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { AlertCircle, RefreshCw } from 'lucide-react';
|
||||
import { ApiKeyForm } from './components/ApiKeyForm';
|
||||
import { ApiKeyCard } from './components/ApiKeyCard';
|
||||
import { Stats } from './components/Stats';
|
||||
import { apiKeyService } from './services/api';
|
||||
import type { ApiKey } from './types/api';
|
||||
|
||||
function App() {
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isValidatingAll, setIsValidatingAll] = useState(false);
|
||||
|
||||
const loadApiKeys = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await apiKeyService.getAllApiKeys();
|
||||
setApiKeys(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载失败');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKeys();
|
||||
}, []);
|
||||
|
||||
const handleAddSuccess = async () => {
|
||||
await loadApiKeys();
|
||||
};
|
||||
|
||||
const handleUpdate = (id: string, updated: ApiKey) => {
|
||||
setApiKeys(prev => prev.map(key => key.id === id ? updated : key));
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setApiKeys(prev => prev.filter(key => key.id !== id));
|
||||
};
|
||||
|
||||
const handleValidateAll = async () => {
|
||||
setIsValidatingAll(true);
|
||||
try {
|
||||
const results = await apiKeyService.validateAllApiKeys();
|
||||
setApiKeys(results);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '批量验证失败');
|
||||
} finally {
|
||||
setIsValidatingAll(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
SiliconFlow API Key 管理器
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
管理和验证您的 SiliconFlow API Key,实时监控账户状态和余额。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900"></div>
|
||||
<p className="mt-2 text-gray-600">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 rounded-md p-4 mb-4">
|
||||
<div className="flex">
|
||||
<AlertCircle className="h-5 w-5 text-red-400 mr-3" />
|
||||
<div className="text-sm text-red-700">
|
||||
<p className="font-medium">加载失败</p>
|
||||
<p>{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Stats apiKeys={apiKeys} />
|
||||
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
API Keys ({apiKeys.length})
|
||||
</h2>
|
||||
{apiKeys.length > 0 && (
|
||||
<button
|
||||
onClick={handleValidateAll}
|
||||
disabled={isValidatingAll}
|
||||
className="flex items-center px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 mr-2 ${isValidatingAll ? 'animate-spin' : ''}`} />
|
||||
批量验证
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="text-gray-400 mb-4">
|
||||
<svg className="h-12 w-12 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">暂无 API Key</h3>
|
||||
<p className="text-gray-600 mb-4">添加您的第一个API Key开始使用</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:gap-6 grid-cols-1 lg:grid-cols-2 xl:grid-cols-3">
|
||||
{apiKeys.map((apiKey) => (
|
||||
<ApiKeyCard
|
||||
key={apiKey.id}
|
||||
apiKey={apiKey}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-8">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
添加新 API Key
|
||||
</h3>
|
||||
<ApiKeyForm onSuccess={handleAddSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
147
frontend/src/components/ApiKeyCard.tsx
Normal file
147
frontend/src/components/ApiKeyCard.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useState } from 'react';
|
||||
import { CheckCircle2, XCircle, Loader2, Trash2, RefreshCw } from 'lucide-react';
|
||||
import { apiKeyService } from '../services/api';
|
||||
import type { ApiKey } from '../types/api';
|
||||
|
||||
interface ApiKeyCardProps {
|
||||
apiKey: ApiKey;
|
||||
onUpdate: (id: string, updated: ApiKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ApiKeyCard({ apiKey, onUpdate, onDelete }: ApiKeyCardProps) {
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const handleValidate = async () => {
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const updated = await apiKeyService.validateApiKey(apiKey.id);
|
||||
onUpdate(apiKey.id, updated);
|
||||
} catch (error) {
|
||||
console.error('验证失败:', error);
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirm("确定要删除这个API Key吗?此操作不可恢复。")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await apiKeyService.deleteApiKey(apiKey.id);
|
||||
onDelete(apiKey.id);
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) => {
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
style: 'currency',
|
||||
currency: 'CNY',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-gray-200 rounded-lg shadow-sm hover:shadow-md transition-shadow">
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-semibold text-gray-900 truncate">
|
||||
{apiKey.name}
|
||||
</h3>
|
||||
{apiKey.description && (
|
||||
<p className="mt-1 text-sm text-gray-600 line-clamp-2">
|
||||
{apiKey.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center ml-4">
|
||||
<button
|
||||
onClick={handleValidate}
|
||||
disabled={isValidating}
|
||||
className="p-2 mr-2 text-blue-600 hover:bg-blue-50 rounded-md disabled:opacity-50"
|
||||
title="验证API Key"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isValidating ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="p-2 text-red-600 hover:bg-red-50 rounded-md disabled:opacity-50"
|
||||
title="删除API Key"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center mb-3">
|
||||
{apiKey.isValid ? (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
有效
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
无效
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 直接显示完整的API Key */}
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">API Key:</label>
|
||||
<div className="p-2 bg-gray-50 border border-gray-200 rounded-md text-xs font-mono text-gray-600 break-all">
|
||||
{apiKey.key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKey.error ? (
|
||||
<div className="mb-3 p-2 bg-red-50 border border-red-200 rounded-md">
|
||||
<p className="text-sm text-red-600">{apiKey.error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{apiKey.balance !== undefined && (
|
||||
<div className="mb-3 p-3 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
账户余额:
|
||||
<span className="font-semibold text-green-600 ml-1">
|
||||
{formatCurrency(apiKey.balance)}
|
||||
</span>
|
||||
</p>
|
||||
{apiKey.user && (
|
||||
<p className="text-sm text-gray-500">
|
||||
用户: {apiKey.user.name} ({apiKey.user.email})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>
|
||||
最后验证: {formatDate(apiKey.lastChecked)}
|
||||
</span>
|
||||
<span>
|
||||
验证次数: {apiKey.checkCount}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
frontend/src/components/ApiKeyForm.tsx
Normal file
105
frontend/src/components/ApiKeyForm.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Loader2 } from 'lucide-react';
|
||||
import { apiKeyService } from '../services/api';
|
||||
import { cn } from '../utils/cn';
|
||||
|
||||
interface ApiKeyFormProps {
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ApiKeyForm({ onSuccess }: ApiKeyFormProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
key: '',
|
||||
description: '',
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiKeyService.createApiKey(formData);
|
||||
setFormData({ name: '', key: '', description: '' });
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '未知错误');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
API Key 名称 *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="例如: 生产环境 API"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
API Key *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono text-sm"
|
||||
placeholder="sk-... 或sf-xxxxx"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
描述 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
rows={2}
|
||||
placeholder="用于管理此API Key的描述信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-2 bg-red-100 border border-red-300 rounded-md text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
)}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
添加中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加 API Key
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
72
frontend/src/components/Stats.tsx
Normal file
72
frontend/src/components/Stats.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle, XCircle, DollarSign, Zap } from 'lucide-react';
|
||||
import type { ApiKey } from '../types/api';
|
||||
|
||||
interface StatsProps {
|
||||
apiKeys: ApiKey[];
|
||||
}
|
||||
|
||||
export function Stats({ apiKeys }: StatsProps) {
|
||||
const totalCount = apiKeys.length;
|
||||
const validCount = apiKeys.filter(key => key.isValid).length;
|
||||
const invalidCount = totalCount - validCount;
|
||||
const totalBalance = apiKeys.reduce((sum, key) => sum + (key.balance || 0), 0);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
name: '总 API Key',
|
||||
value: totalCount,
|
||||
icon: Zap,
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
},
|
||||
{
|
||||
name: '有效',
|
||||
value: validCount,
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
name: '无效',
|
||||
value: invalidCount,
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-50',
|
||||
},
|
||||
{
|
||||
name: '总余额',
|
||||
value: `$${totalBalance.toFixed(2)}`,
|
||||
icon: DollarSign,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4 mb-8">
|
||||
{stats.map((stat) => {
|
||||
const Icon = stat.icon;
|
||||
return (
|
||||
<div
|
||||
key={stat.name}
|
||||
className="relative overflow-hidden rounded-lg bg-white px-4 py-5 shadow border-l-4 hover:shadow-md transition-shadow"
|
||||
style={{ borderColor: stat.color.replace('text-', '').split('-')[1] ? `#${stat.color.replace('text-', '').split('-')[1]}-500` : 'gray' }}
|
||||
>
|
||||
<dt>
|
||||
<div className={`absolute rounded-md p-3 ${stat.bgColor}`}>
|
||||
<Icon className={`h-6 w-6 ${stat.color}`} aria-hidden="true" />
|
||||
</div>
|
||||
<p className="ml-16 truncate text-sm font-medium text-gray-500">
|
||||
{stat.name}
|
||||
</p>
|
||||
</dt>
|
||||
<dd className="ml-16 flex items-baseline">
|
||||
<p className="text-2xl font-semibold text-gray-900">{stat.value}</p>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
frontend/src/index.css
Normal file
53
frontend/src/index.css
Normal file
@@ -0,0 +1,53 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 84% 4.9%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
56
frontend/src/services/api.ts
Normal file
56
frontend/src/services/api.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import axios from 'axios';
|
||||
import type { ApiKey, ApiResponse, CreateApiKeyRequest } from '../types/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const message = error.response?.data?.error || error.message || '未知错误';
|
||||
return Promise.reject(new Error(message));
|
||||
}
|
||||
);
|
||||
|
||||
export const apiKeyService = {
|
||||
async getAllApiKeys(): Promise<ApiKey[]> {
|
||||
const response = await api.get<ApiResponse<ApiKey[]>>('/api-keys');
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '获取API Key失败');
|
||||
}
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
async createApiKey(data: CreateApiKeyRequest): Promise<ApiKey> {
|
||||
const response = await api.post<ApiResponse<ApiKey>>('/api-keys', data);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '创建API Key失败');
|
||||
}
|
||||
return response.data.data!;
|
||||
},
|
||||
|
||||
async validateApiKey(id: string): Promise<ApiKey> {
|
||||
const response = await api.post<ApiResponse<ApiKey>>(`/api-keys/${id}/validate`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '验证API Key失败');
|
||||
}
|
||||
return response.data.data!;
|
||||
},
|
||||
|
||||
async validateAllApiKeys(): Promise<ApiKey[]> {
|
||||
const response = await api.post<ApiResponse<ApiKey[]>>('/api-keys/validate-all');
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '批量验证失败');
|
||||
}
|
||||
return response.data.data || [];
|
||||
},
|
||||
|
||||
async deleteApiKey(id: string): Promise<void> {
|
||||
const response = await api.delete<ApiResponse<never>>(`/api-keys/${id}`);
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.error || '删除API Key失败');
|
||||
}
|
||||
},
|
||||
};
|
||||
29
frontend/src/types/api.ts
Normal file
29
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export interface ApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
isValid: boolean;
|
||||
balance?: number;
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
lastChecked: string;
|
||||
checkCount: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
key: string;
|
||||
description?: string;
|
||||
}
|
||||
6
frontend/src/utils/cn.ts
Normal file
6
frontend/src/utils/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
45
frontend/tailwind.config.js
Normal file
45
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,45 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
border: "hsl(var(--border))",
|
||||
background: "hsl(var(--background))",
|
||||
foreground: "hsl(var(--foreground))",
|
||||
primary: {
|
||||
DEFAULT: "hsl(var(--primary))",
|
||||
foreground: "hsl(var(--primary-foreground))",
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: "hsl(var(--secondary))",
|
||||
foreground: "hsl(var(--secondary-foreground))",
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: "hsl(var(--destructive))",
|
||||
foreground: "hsl(var(--destructive-foreground))",
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted))",
|
||||
foreground: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: "hsl(var(--accent))",
|
||||
foreground: "hsl(var(--accent-foreground))",
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: "hsl(var(--popover))",
|
||||
foreground: "hsl(var(--popover-foreground))",
|
||||
},
|
||||
card: {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
21
frontend/tsconfig.json
Normal file
21
frontend/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
frontend/vite.config.ts
Normal file
19
frontend/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist'
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user