first commit

This commit is contained in:
小贺
2025-09-05 21:07:34 +08:00
commit c1a493222c
31 changed files with 1580 additions and 0 deletions

13
frontend/index.html Normal file
View 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
View 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"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

145
frontend/src/App.tsx Normal file
View 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;

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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>,
)

View 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
View 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
View 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));
}

View 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
View 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" }]
}

View 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
View 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'
}
})