feat: Implement initial full-stack application structure including frontend pages, components, hooks, API integration, and backend services for account pooling and management.

This commit is contained in:
2026-01-30 07:40:35 +08:00
commit f4448bbef2
106 changed files with 19282 additions and 0 deletions

28
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { Routes, Route } from 'react-router-dom'
import { ConfigProvider, RecordsProvider } from './context'
import { Layout } from './components/layout'
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, TeamProcess } from './pages'
function App() {
return (
<ConfigProvider>
<RecordsProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="upload" element={<Upload />} />
<Route path="records" element={<Records />} />
<Route path="accounts" element={<Accounts />} />
<Route path="monitor" element={<Monitor />} />
<Route path="team" element={<TeamProcess />} />
<Route path="config" element={<Config />} />
<Route path="config/s2a" element={<S2AConfig />} />
<Route path="config/email" element={<EmailConfig />} />
</Route>
</Routes>
</RecordsProvider>
</ConfigProvider>
)
}
export default App

View File

@@ -0,0 +1,294 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { ChatGPTClient, getStatusText, getStatusColor, createChatGPTClient } from './chatgpt'
import type { AccountInput } from '../types'
describe('ChatGPTClient', () => {
let client: ChatGPTClient
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
client = new ChatGPTClient()
fetchMock = vi.fn()
globalThis.fetch = fetchMock as typeof fetch
})
afterEach(() => {
vi.restoreAllMocks()
})
describe('checkAccount', () => {
it('should return active status for HTTP 200 with account info', async () => {
const mockResponse = {
accounts: [
{
account_id: 'test-account-id',
entitlement: {
subscription_plan: 'plus',
},
},
],
}
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockResponse),
})
const result = await client.checkAccount('valid-token')
expect(result.status).toBe('active')
expect(result.accountId).toBe('test-account-id')
expect(result.planType).toBe('plus')
expect(result.error).toBeUndefined()
})
it('should return active status for HTTP 200 without account info', async () => {
const mockResponse = {
accounts: [],
}
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve(mockResponse),
})
const result = await client.checkAccount('valid-token')
expect(result.status).toBe('active')
expect(result.accountId).toBeUndefined()
expect(result.planType).toBe('unknown')
})
it('should return token_expired status for HTTP 401', async () => {
fetchMock.mockResolvedValueOnce({
status: 401,
statusText: 'Unauthorized',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('expired-token')
expect(result.status).toBe('token_expired')
expect(result.error).toBe('Token 已过期')
})
it('should return banned status for HTTP 403', async () => {
fetchMock.mockResolvedValueOnce({
status: 403,
statusText: 'Forbidden',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('banned-token')
expect(result.status).toBe('banned')
expect(result.error).toBe('账号已被封禁')
})
it('should return error status for other HTTP codes', async () => {
fetchMock.mockResolvedValueOnce({
status: 500,
statusText: 'Internal Server Error',
json: () => Promise.reject(new Error('No JSON')),
})
const result = await client.checkAccount('some-token')
expect(result.status).toBe('error')
expect(result.error).toBe('HTTP 500: Internal Server Error')
})
it('should return error status for network errors', async () => {
fetchMock.mockRejectedValueOnce(new Error('Network error'))
const result = await client.checkAccount('some-token')
expect(result.status).toBe('error')
expect(result.error).toBe('Network error')
})
it('should return error status for empty token', async () => {
const result = await client.checkAccount('')
expect(result.status).toBe('error')
expect(result.error).toBe('缺少 token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('should return error status for whitespace-only token', async () => {
const result = await client.checkAccount(' ')
expect(result.status).toBe('error')
expect(result.error).toBe('缺少 token')
expect(fetchMock).not.toHaveBeenCalled()
})
it('should use correct API endpoint and headers', async () => {
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
await client.checkAccount('test-token')
expect(fetchMock).toHaveBeenCalledWith('/api/chatgpt/accounts/check/v4-2023-04-27', {
method: 'GET',
headers: {
Authorization: 'Bearer test-token',
'Content-Type': 'application/json',
},
})
})
it('should handle JSON parse errors gracefully for HTTP 200', async () => {
fetchMock.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.reject(new Error('Invalid JSON')),
})
const result = await client.checkAccount('valid-token')
// Should still return active since HTTP 200
expect(result.status).toBe('active')
expect(result.planType).toBe('unknown')
})
})
describe('batchCheck', () => {
it('should return empty array for empty input', async () => {
const results = await client.batchCheck([], { concurrency: 5 })
expect(results).toEqual([])
expect(fetchMock).not.toHaveBeenCalled()
})
it('should check all accounts and return results in order', async () => {
const accounts: AccountInput[] = [
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
{ account: 'user3@test.com', password: 'pass3', token: 'token3' },
]
// Mock responses for each account
fetchMock
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () =>
Promise.resolve({
accounts: [{ account_id: 'id1', entitlement: { subscription_plan: 'plus' } }],
}),
})
.mockResolvedValueOnce({
status: 401,
statusText: 'Unauthorized',
})
.mockResolvedValueOnce({
status: 403,
statusText: 'Forbidden',
})
const results = await client.batchCheck(accounts, { concurrency: 3 })
expect(results).toHaveLength(3)
expect(results[0].status).toBe('active')
expect(results[0].accountId).toBe('id1')
expect(results[1].status).toBe('token_expired')
expect(results[2].status).toBe('banned')
})
it('should call onProgress callback for each account', async () => {
const accounts: AccountInput[] = [
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
]
fetchMock
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
.mockResolvedValueOnce({
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
})
const onProgress = vi.fn()
await client.batchCheck(accounts, { concurrency: 2, onProgress })
expect(onProgress).toHaveBeenCalledTimes(2)
})
it('should respect concurrency limit', async () => {
const accounts: AccountInput[] = Array.from({ length: 10 }, (_, i) => ({
account: `user${i}@test.com`,
password: `pass${i}`,
token: `token${i}`,
}))
let maxConcurrent = 0
let currentConcurrent = 0
fetchMock.mockImplementation(async () => {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 10))
currentConcurrent--
return {
status: 200,
statusText: 'OK',
json: () => Promise.resolve({ accounts: [] }),
}
})
await client.batchCheck(accounts, { concurrency: 3 })
// Max concurrent should not exceed the concurrency limit
expect(maxConcurrent).toBeLessThanOrEqual(3)
})
})
})
describe('getStatusText', () => {
it('should return correct Chinese text for each status', () => {
expect(getStatusText('pending')).toBe('待检查')
expect(getStatusText('checking')).toBe('检查中')
expect(getStatusText('active')).toBe('正常')
expect(getStatusText('banned')).toBe('封禁')
expect(getStatusText('token_expired')).toBe('过期')
expect(getStatusText('error')).toBe('错误')
})
})
describe('getStatusColor', () => {
it('should return correct color for each status', () => {
expect(getStatusColor('pending')).toBe('gray')
expect(getStatusColor('checking')).toBe('blue')
expect(getStatusColor('active')).toBe('green')
expect(getStatusColor('banned')).toBe('red')
expect(getStatusColor('token_expired')).toBe('orange')
expect(getStatusColor('error')).toBe('yellow')
})
})
describe('createChatGPTClient', () => {
it('should create a new ChatGPTClient instance', () => {
const client = createChatGPTClient()
expect(client).toBeInstanceOf(ChatGPTClient)
})
it('should create a client with custom base URL', () => {
const client = createChatGPTClient('https://custom.api.com')
expect(client).toBeInstanceOf(ChatGPTClient)
})
})

253
frontend/src/api/chatgpt.ts Normal file
View File

@@ -0,0 +1,253 @@
import type { AccountInput, AccountStatus, CheckedAccount, CheckResult } from '../types'
import type { ChatGPTCheckResponse } from './types'
/**
* ChatGPT API 检查端点
* 通过 nginx 代理访问,避免 CORS 问题
* 原始 API: https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
*/
const CHATGPT_CHECK_API = '/api/chatgpt/accounts/check/v4-2023-04-27'
/**
* HTTP 状态码到账号状态的映射
* 根据 requirements.md A3 定义:
* - HTTP 200 → active (账号正常)
* - HTTP 401 → token_expired (Token 已过期)
* - HTTP 403 → banned (账号被封禁)
* - 其他 → error (网络错误等)
*/
function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
switch (httpStatus) {
case 200:
return 'active'
case 401:
return 'token_expired'
case 403:
return 'banned'
default:
return 'error'
}
}
/**
* 获取 HTTP 状态码对应的错误消息
*/
function getErrorMessageForStatus(httpStatus: number, statusText: string): string | undefined {
switch (httpStatus) {
case 200:
return undefined
case 401:
return 'Token 已过期'
case 403:
return '账号已被封禁'
default:
return `HTTP ${httpStatus}: ${statusText}`
}
}
/**
* ChatGPT API 客户端
* 用于检查 ChatGPT 账号状态
*/
export class ChatGPTClient {
private baseUrl: string
constructor(baseUrl: string = '') {
this.baseUrl = baseUrl
}
/**
* 检查单个账号状态
* @param token - ChatGPT access_token
* @returns CheckResult 包含状态、account_id、plan_type 等信息
*
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
* Headers: Authorization: Bearer {token}
*
* 状态映射 (requirements.md A3):
* - HTTP 200 → active
* - HTTP 401 → token_expired
* - HTTP 403 → banned
* - 其他 → error
*/
async checkAccount(token: string): Promise<CheckResult> {
// 处理空 token 的情况
if (!token || token.trim() === '') {
return {
status: 'error',
error: '缺少 token',
}
}
try {
const response = await fetch(`${this.baseUrl}${CHATGPT_CHECK_API}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
})
const status = mapHttpStatusToAccountStatus(response.status)
const errorMessage = getErrorMessageForStatus(response.status, response.statusText)
if (response.status === 200) {
try {
const data: ChatGPTCheckResponse = await response.json()
const accountInfo = data.accounts?.[0]
if (accountInfo) {
return {
status: 'active',
accountId: accountInfo.account_id,
planType: accountInfo.entitlement?.subscription_plan || 'free',
}
}
// 200 响应但没有账号信息
return {
status: 'active',
accountId: undefined,
planType: 'unknown',
}
} catch {
// JSON 解析失败,但 HTTP 200 仍视为 active
return {
status: 'active',
accountId: undefined,
planType: 'unknown',
}
}
}
return {
status,
error: errorMessage,
}
} catch (error) {
return {
status: 'error',
error: error instanceof Error ? error.message : '网络错误',
}
}
}
/**
* 批量检查账号(带并发控制)
* @param accounts - 待检查的账号列表
* @param options.concurrency - 并发数量(默认 20
* @param options.onProgress - 进度回调,每检查完一个账号调用一次
* @returns 检查完成的账号列表
*
* 使用队列 + Promise 实现并发控制,确保任意时刻活跃请求数 ≤ concurrency
*/
async batchCheck(
accounts: AccountInput[],
options: {
concurrency: number
onProgress?: (result: CheckedAccount, index: number) => void
}
): Promise<CheckedAccount[]> {
const { concurrency, onProgress } = options
// 空数组直接返回
if (accounts.length === 0) {
return []
}
const results: CheckedAccount[] = new Array(accounts.length)
const queue: number[] = [...Array(accounts.length).keys()]
let activeCount = 0
let completedCount = 0
return new Promise((resolve) => {
const processNext = () => {
// 所有任务完成
if (completedCount === accounts.length) {
resolve(results)
return
}
// 启动新任务,直到达到并发限制或队列为空
while (activeCount < concurrency && queue.length > 0) {
const index = queue.shift()!
activeCount++
// 异步处理单个账号
this.processAccount(accounts[index], index)
.then((checkedAccount) => {
results[index] = checkedAccount
onProgress?.(checkedAccount, index)
})
.finally(() => {
activeCount--
completedCount++
// 继续处理下一个
processNext()
})
}
}
// 开始处理
processNext()
})
}
/**
* 处理单个账号检查
* @private
*/
private async processAccount(account: AccountInput, index: number): Promise<CheckedAccount> {
const checkResult = await this.checkAccount(account.token)
return {
...account,
id: index,
status: checkResult.status,
accountId: checkResult.accountId,
planType: checkResult.planType,
error: checkResult.error,
}
}
}
/**
* 解析账号状态为中文描述
*/
export function getStatusText(status: AccountStatus): string {
const statusMap: Record<AccountStatus, string> = {
pending: '待检查',
checking: '检查中',
active: '正常',
banned: '封禁',
token_expired: '过期',
error: '错误',
}
return statusMap[status] || status
}
/**
* 获取状态对应的颜色类名
*/
export function getStatusColor(status: AccountStatus): string {
const colorMap: Record<AccountStatus, string> = {
pending: 'gray',
checking: 'blue',
active: 'green',
banned: 'red',
token_expired: 'orange',
error: 'yellow',
}
return colorMap[status] || 'gray'
}
/**
* 创建 ChatGPT 客户端实例
* @param baseUrl - 可选的基础 URL默认为空使用相对路径
*/
export function createChatGPTClient(baseUrl: string = ''): ChatGPTClient {
return new ChatGPTClient(baseUrl)
}
// 导出默认客户端实例(使用相对路径,通过 nginx 代理)
export const chatGPTClient = new ChatGPTClient()

View File

@@ -0,0 +1,4 @@
// API Layer barrel export
export * from './types'
export * from './s2a'
export * from './chatgpt'

155
frontend/src/api/s2a.ts Normal file
View File

@@ -0,0 +1,155 @@
import type {
DashboardStatsResponse,
DashboardTrendResponse,
AccountListResponse,
AccountResponse,
CreateAccountPayload,
OAuthCreatePayload,
GroupResponse,
ProxyResponse,
TestAccountResponse,
} from './types'
import type { AccountListParams } from '../types'
// 使用后端代理 API 来避免 CORS 问题
const PROXY_BASE = 'http://localhost:8088/api/s2a/proxy'
export class S2AClient {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
constructor(_config: { baseUrl: string; apiKey: string }) {
// 不再使用直接配置,通过后端代理
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
// 将 /api/v1/admin/* 转换为代理路径
const proxyEndpoint = endpoint.replace('/api/v1/admin', '')
const url = `${PROXY_BASE}${proxyEndpoint}`
const headers: HeadersInit = {
'Content-Type': 'application/json',
...options.headers,
}
const response = await fetch(url, {
...options,
headers,
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.message || errorData.error || `HTTP ${response.status}: ${response.statusText}`
)
}
return response.json()
}
// Dashboard APIs
async getDashboardStats(): Promise<DashboardStatsResponse> {
return this.request<DashboardStatsResponse>('/dashboard/stats')
}
async getDashboardTrend(granularity: 'day' | 'hour' = 'day'): Promise<DashboardTrendResponse> {
return this.request<DashboardTrendResponse>(
`/dashboard/trend?granularity=${granularity}`
)
}
// Account APIs
async getAccounts(params: AccountListParams = {}): Promise<AccountListResponse> {
const searchParams = new URLSearchParams()
if (params.page) searchParams.set('page', params.page.toString())
if (params.page_size) searchParams.set('page_size', params.page_size.toString())
if (params.platform) searchParams.set('platform', params.platform)
if (params.type) searchParams.set('type', params.type)
if (params.status) searchParams.set('status', params.status)
if (params.search) searchParams.set('search', params.search)
const queryString = searchParams.toString()
const endpoint = `/accounts${queryString ? `?${queryString}` : ''}`
return this.request<AccountListResponse>(endpoint)
}
async getAccount(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}`)
}
async createAccount(data: CreateAccountPayload): Promise<AccountResponse> {
return this.request<AccountResponse>('/accounts', {
method: 'POST',
body: JSON.stringify(data),
})
}
async createFromOAuth(data: OAuthCreatePayload): Promise<AccountResponse> {
return this.request<AccountResponse>('/openai/create-from-oauth', {
method: 'POST',
body: JSON.stringify(data),
})
}
async updateAccount(id: number, data: Partial<CreateAccountPayload>): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
}
async deleteAccount(id: number): Promise<void> {
await this.request<void>(`/accounts/${id}`, {
method: 'DELETE',
})
}
async testAccount(id: number): Promise<TestAccountResponse> {
return this.request<TestAccountResponse>(`/accounts/${id}/test`, {
method: 'POST',
})
}
async refreshAccountToken(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}/refresh`, {
method: 'POST',
})
}
async clearAccountError(id: number): Promise<AccountResponse> {
return this.request<AccountResponse>(`/accounts/${id}/clear-error`, {
method: 'POST',
})
}
// Group APIs
async getGroups(): Promise<GroupResponse[]> {
const response = await this.request<{ data: GroupResponse[] }>('/groups/all')
return response.data || []
}
// Proxy APIs
async getProxies(): Promise<ProxyResponse[]> {
const response = await this.request<{ data: ProxyResponse[] }>('/proxies/all')
return response.data || []
}
async testProxy(id: number): Promise<TestAccountResponse> {
return this.request<TestAccountResponse>(`/proxies/${id}/test`, {
method: 'POST',
})
}
// Connection test
async testConnection(): Promise<boolean> {
try {
await this.getDashboardStats()
return true
} catch {
return false
}
}
}
// 创建默认客户端实例的工厂函数
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
return new S2AClient({ baseUrl, apiKey })
}

522
frontend/src/api/types.ts Normal file
View File

@@ -0,0 +1,522 @@
// =============================================================================
// S2A API 响应类型
// 基于 requirements.md Appendix A2 定义
// =============================================================================
// -----------------------------------------------------------------------------
// Dashboard 相关接口响应
// -----------------------------------------------------------------------------
/**
* Dashboard Stats 响应
* API: GET /api/v1/admin/dashboard/stats
* 获取号池统计账号数、请求数、Token消耗等
*/
export interface DashboardStatsResponse {
total_accounts: number
normal_accounts: number
error_accounts: number
ratelimit_accounts: number
overload_accounts: number
today_requests: number
today_tokens: number
today_cost: number
total_requests: number
total_tokens: number
total_cost: number
rpm: number
tpm: number
}
/**
* Dashboard Trend 响应
* API: GET /api/v1/admin/dashboard/trend
* 获取使用趋势(支持 granularity=day/hour
*/
export interface DashboardTrendResponse {
data: TrendDataPoint[]
}
export interface TrendDataPoint {
date: string
requests: number
tokens: number
cost: number
}
/**
* Dashboard Models 响应
* API: GET /api/v1/admin/dashboard/models
* 获取模型使用统计
*/
export interface DashboardModelsResponse {
data: ModelUsageStats[]
}
export interface ModelUsageStats {
model: string
requests: number
tokens: number
cost: number
percentage: number
}
/**
* Dashboard Users Trend 响应
* API: GET /api/v1/admin/dashboard/users-trend
* 获取用户使用趋势
*/
export interface DashboardUsersTrendResponse {
data: UsersTrendDataPoint[]
}
export interface UsersTrendDataPoint {
date: string
active_users: number
new_users: number
total_users: number
}
// -----------------------------------------------------------------------------
// 账号管理接口响应
// -----------------------------------------------------------------------------
/**
* 账号列表响应
* API: GET /api/v1/admin/accounts
* 获取账号列表(支持分页、筛选)
*/
export interface AccountListResponse {
data: AccountResponse[]
total: number
page: number
page_size: number
}
/**
* 单个账号响应
* API: GET /api/v1/admin/accounts/:id
* 对应 requirements.md A5 Account 数据结构
*/
export interface AccountResponse {
id: number
name: string
notes?: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
credentials: Record<string, unknown>
extra?: Record<string, unknown>
proxy_id?: number
concurrency: number
priority: number
rate_multiplier?: number
status: 'active' | 'inactive' | 'error'
error_message?: string
schedulable: boolean
last_used_at?: string
expires_at?: string
auto_pause_on_expired: boolean
created_at: string
updated_at: string
current_concurrency?: number
current_window_cost?: number
active_sessions?: number
}
/**
* 账号统计响应
* API: GET /api/v1/admin/accounts/:id/stats
* 获取账号使用统计
*/
export interface AccountStatsResponse {
account_id: number
total_requests: number
total_tokens: number
total_cost: number
today_requests: number
today_tokens: number
today_cost: number
last_used_at?: string
error_count: number
success_rate: number
}
/**
* 创建账号请求
* API: POST /api/v1/admin/accounts
* 对应 requirements.md A4 access_token 类型账号
*/
export interface CreateAccountPayload {
name: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'access_token'
credentials: {
access_token: string
refresh_token?: string
email?: string
}
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
auto_pause_on_expired?: boolean
}
/**
* 批量更新账号请求
* API: POST /api/v1/admin/accounts/bulk-update
*/
export interface BulkUpdateAccountsPayload {
ids: number[]
updates: {
status?: 'active' | 'inactive'
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
}
/**
* 批量更新账号响应
*/
export interface BulkUpdateAccountsResponse {
success: boolean
updated_count: number
failed_count: number
errors?: string[]
}
// -----------------------------------------------------------------------------
// OpenAI OAuth 接口响应
// -----------------------------------------------------------------------------
/**
* OAuth 创建账号请求
* API: POST /api/v1/admin/openai/create-from-oauth
* 对应 requirements.md A4 OAuth 类型账号
*/
export interface OAuthCreatePayload {
session_id: string
code: string
name?: string
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
/**
* 生成 OAuth 授权 URL 请求
* API: POST /api/v1/admin/openai/generate-auth-url
*/
export interface GenerateAuthUrlPayload {
redirect_uri?: string
}
/**
* 生成 OAuth 授权 URL 响应
*/
export interface GenerateAuthUrlResponse {
auth_url: string
session_id: string
}
/**
* 交换授权码请求
* API: POST /api/v1/admin/openai/exchange-code
*/
export interface ExchangeCodePayload {
session_id: string
code: string
}
/**
* 交换授权码响应
*/
export interface ExchangeCodeResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type: string
}
/**
* 刷新 Token 请求
* API: POST /api/v1/admin/openai/refresh-token
*/
export interface RefreshTokenPayload {
refresh_token: string
}
/**
* 刷新 Token 响应
*/
export interface RefreshTokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type: string
}
// -----------------------------------------------------------------------------
// 分组管理接口响应
// -----------------------------------------------------------------------------
/**
* 分组响应
* API: GET /api/v1/admin/groups, GET /api/v1/admin/groups/all
*/
export interface GroupResponse {
id: number
name: string
description?: string
created_at: string
updated_at: string
}
/**
* 分组统计响应
* API: GET /api/v1/admin/groups/:id/stats
*/
export interface GroupStatsResponse {
group_id: number
total_accounts: number
active_accounts: number
error_accounts: number
total_requests: number
total_tokens: number
total_cost: number
}
// -----------------------------------------------------------------------------
// 代理管理接口响应
// -----------------------------------------------------------------------------
/**
* 代理响应
* API: GET /api/v1/admin/proxies, GET /api/v1/admin/proxies/all
*/
export interface ProxyResponse {
id: number
name: string
url: string
status: 'active' | 'inactive' | 'error'
created_at: string
updated_at: string
}
/**
* 测试代理/账号响应
* API: POST /api/v1/admin/proxies/:id/test, POST /api/v1/admin/accounts/:id/test
*/
export interface TestAccountResponse {
success: boolean
message?: string
latency?: number
}
// -----------------------------------------------------------------------------
// 运维监控接口响应
// -----------------------------------------------------------------------------
/**
* 并发统计响应
* API: GET /api/v1/admin/ops/concurrency
*/
export interface OpsConcurrencyResponse {
total_concurrency: number
used_concurrency: number
available_concurrency: number
accounts: AccountConcurrencyInfo[]
}
export interface AccountConcurrencyInfo {
account_id: number
account_name: string
max_concurrency: number
current_concurrency: number
}
/**
* 账号可用性响应
* API: GET /api/v1/admin/ops/account-availability
*/
export interface OpsAccountAvailabilityResponse {
total_accounts: number
available_accounts: number
unavailable_accounts: number
availability_rate: number
accounts: AccountAvailabilityInfo[]
}
export interface AccountAvailabilityInfo {
account_id: number
account_name: string
status: 'available' | 'unavailable' | 'rate_limited' | 'error'
reason?: string
}
/**
* 实时流量响应
* API: GET /api/v1/admin/ops/realtime-traffic
*/
export interface OpsRealtimeTrafficResponse {
current_rpm: number
current_tpm: number
peak_rpm: number
peak_tpm: number
requests_last_minute: number
tokens_last_minute: number
}
/**
* 运维仪表盘概览响应
* API: GET /api/v1/admin/ops/dashboard/overview
*/
export interface OpsDashboardOverviewResponse {
health_score: number
total_accounts: number
healthy_accounts: number
warning_accounts: number
error_accounts: number
current_load: number
max_load: number
alerts: OpsAlert[]
}
export interface OpsAlert {
id: string
level: 'info' | 'warning' | 'error' | 'critical'
message: string
timestamp: string
account_id?: number
}
/**
* 错误趋势响应
* API: GET /api/v1/admin/ops/dashboard/error-trend
*/
export interface OpsErrorTrendResponse {
data: ErrorTrendDataPoint[]
}
export interface ErrorTrendDataPoint {
date: string
total_errors: number
rate_limit_errors: number
auth_errors: number
network_errors: number
other_errors: number
}
// =============================================================================
// ChatGPT API 响应类型
// 基于 requirements.md Requirement 1.2 定义
// =============================================================================
/**
* 账号检查响应
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
* Headers: Authorization: Bearer {token}
*/
export interface ChatGPTCheckResponse {
accounts: ChatGPTAccountInfo[]
}
export interface ChatGPTAccountInfo {
account_id: string
account: ChatGPTAccountDetails
features: string[]
entitlement: ChatGPTEntitlement
last_active_subscription: ChatGPTLastActiveSubscription
is_eligible_for_yearly_plus_subscription: boolean
}
export interface ChatGPTAccountDetails {
account_user_id: string
processor: {
a001: {
has_customer_object: boolean
}
}
account_user_role: string
plan_type: string
is_most_recent_expired_subscription_gratis: boolean
has_previously_paid_subscription: boolean
name: string | null
profile_picture_id: string | null
profile_picture_url: string | null
structure: string
is_deactivated: boolean
is_disabled: boolean
// SAM (Security Account Management) 相关字段
is_sam_enforced: boolean
is_sam_enabled: boolean
is_sam_compliant: boolean
is_sam_grace_period: boolean
is_sam_grace_period_expired: boolean
is_sam_grace_period_expiring_soon: boolean
is_sam_grace_period_expiring_today: boolean
is_sam_grace_period_expiring_tomorrow: boolean
is_sam_grace_period_expiring_in_two_days: boolean
is_sam_grace_period_expiring_in_three_days: boolean
is_sam_grace_period_expiring_in_four_days: boolean
is_sam_grace_period_expiring_in_five_days: boolean
is_sam_grace_period_expiring_in_six_days: boolean
is_sam_grace_period_expiring_in_seven_days: boolean
}
export interface ChatGPTEntitlement {
subscription_id: string | null
has_active_subscription: boolean
subscription_plan: string
expires_at: string | null
}
export interface ChatGPTLastActiveSubscription {
subscription_id: string | null
purchase_origin_platform: string
will_renew: boolean
}
// =============================================================================
// 通用 API 类型
// =============================================================================
/**
* API 错误响应
* 通用错误响应格式
*/
export interface ApiErrorResponse {
error: string
message?: string
code?: string
details?: Record<string, unknown>
}
/**
* 分页请求参数
*/
export interface PaginationParams {
page?: number
page_size?: number
}
/**
* 分页响应包装
*/
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
page_size: number
total_pages: number
}
/**
* 通用列表响应包装
*/
export interface ListResponse<T> {
data: T[]
}

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import Button from './Button'
describe('Button', () => {
describe('rendering', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('renders with default props', () => {
render(<Button>Default Button</Button>)
const button = screen.getByRole('button')
// Default variant is primary, default size is md
expect(button).toHaveClass('bg-primary-600')
expect(button).toHaveClass('px-4', 'py-2')
})
})
describe('variants', () => {
it('renders primary variant correctly', () => {
render(<Button variant="primary">Primary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-primary-600', 'text-white')
})
it('renders secondary variant correctly', () => {
render(<Button variant="secondary">Secondary</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-slate-100', 'text-slate-900')
})
it('renders danger variant correctly', () => {
render(<Button variant="danger">Danger</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('bg-error-500', 'text-white')
})
it('renders ghost variant correctly', () => {
render(<Button variant="ghost">Ghost</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('text-slate-700', 'bg-transparent')
})
it('renders outline variant correctly', () => {
render(<Button variant="outline">Outline</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('border', 'border-slate-300', 'text-slate-700')
})
})
describe('sizes', () => {
it('renders small size correctly', () => {
render(<Button size="sm">Small</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-3', 'py-1.5', 'text-sm')
})
it('renders medium size correctly', () => {
render(<Button size="md">Medium</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-4', 'py-2', 'text-sm')
})
it('renders large size correctly', () => {
render(<Button size="lg">Large</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('px-6', 'py-3', 'text-base')
})
})
describe('loading state', () => {
it('shows spinner when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
// Check for the spinner (Loader2 icon with animate-spin class)
const spinner = button.querySelector('.animate-spin')
expect(spinner).toBeInTheDocument()
})
it('disables button when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('sets aria-busy when loading', () => {
render(<Button loading>Loading</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-busy', 'true')
})
it('hides icon when loading', () => {
const icon = <span data-testid="test-icon">Icon</span>
render(
<Button loading icon={icon}>
With Icon
</Button>
)
expect(screen.queryByTestId('test-icon')).not.toBeInTheDocument()
})
})
describe('disabled state', () => {
it('disables button when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toBeDisabled()
})
it('applies disabled styles', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed')
})
it('sets aria-disabled when disabled', () => {
render(<Button disabled>Disabled</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('aria-disabled', 'true')
})
it('does not call onClick when disabled', () => {
const handleClick = vi.fn()
render(
<Button disabled onClick={handleClick}>
Disabled
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('icon support', () => {
it('renders icon when provided', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
})
it('renders icon before text', () => {
const icon = <span data-testid="test-icon"></span>
render(<Button icon={icon}>With Icon</Button>)
const button = screen.getByRole('button')
const iconElement = screen.getByTestId('test-icon')
// Icon should be a child of the button
expect(button).toContainElement(iconElement)
})
})
describe('click handling', () => {
it('calls onClick when clicked', () => {
const handleClick = vi.fn()
render(<Button onClick={handleClick}>Click me</Button>)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).toHaveBeenCalledTimes(1)
})
it('does not call onClick when loading', () => {
const handleClick = vi.fn()
render(
<Button loading onClick={handleClick}>
Loading
</Button>
)
fireEvent.click(screen.getByRole('button'))
expect(handleClick).not.toHaveBeenCalled()
})
})
describe('custom className', () => {
it('applies custom className', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class')
})
it('merges custom className with default classes', () => {
render(<Button className="custom-class">Custom</Button>)
const button = screen.getByRole('button')
expect(button).toHaveClass('custom-class', 'bg-primary-600')
})
})
describe('forwarded ref', () => {
it('forwards ref to button element', () => {
const ref = vi.fn()
render(<Button ref={ref}>Ref Button</Button>)
expect(ref).toHaveBeenCalled()
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLButtonElement)
})
})
describe('HTML button attributes', () => {
it('passes through type attribute', () => {
render(<Button type="submit">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('type', 'submit')
})
it('passes through form attribute', () => {
render(<Button form="my-form">Submit</Button>)
const button = screen.getByRole('button')
expect(button).toHaveAttribute('form', 'my-form')
})
it('passes through aria-label', () => {
render(<Button aria-label="Close dialog">×</Button>)
const button = screen.getByRole('button', { name: 'Close dialog' })
expect(button).toBeInTheDocument()
})
})
})

View File

@@ -0,0 +1,158 @@
import { type ButtonHTMLAttributes, forwardRef } from 'react'
import { Loader2 } from 'lucide-react'
/**
* Button component variants
* - primary: Main action button using design system primary color (Blue-600)
* - secondary: Secondary action button with subtle background
* - outline: Bordered button with transparent background
* - danger: Destructive action button using design system error color (Red-500)
* - ghost: Minimal button with no background, only hover state
*/
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
/**
* Button component sizes
* - sm: Small button for compact UIs
* - md: Medium button (default)
* - lg: Large button for prominent actions
*/
export type ButtonSize = 'sm' | 'md' | 'lg'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style variant of the button */
variant?: ButtonVariant
/** Size of the button */
size?: ButtonSize
/** Shows a loading spinner and disables the button */
loading?: boolean
/** Optional icon to display before the button text */
icon?: React.ReactNode
}
/**
* A reusable Button component with support for multiple variants, sizes,
* loading state, and disabled state. Uses TailwindCSS with design system colors.
*
* @example
* // Primary button
* <Button variant="primary">Submit</Button>
*
* @example
* // Loading state
* <Button loading>Processing...</Button>
*
* @example
* // With icon
* <Button icon={<PlusIcon />}>Add Item</Button>
*/
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className = '',
variant = 'primary',
size = 'md',
loading = false,
disabled,
icon,
children,
...props
},
ref
) => {
// Base styles applied to all button variants
const baseStyles = [
'inline-flex items-center justify-center',
'font-medium rounded-lg',
'transition-colors duration-200',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
'disabled:opacity-50 disabled:cursor-not-allowed',
].join(' ')
// Variant-specific styles using design system colors
const variantStyles: Record<ButtonVariant, string> = {
// Primary: Blue-600 (#2563EB) - Main action button
primary: [
'bg-primary-600 text-white',
'hover:bg-primary-700',
'focus:ring-primary-500',
'dark:bg-primary-500 dark:hover:bg-primary-600',
].join(' '),
// Secondary: Slate background - Secondary action button
secondary: [
'bg-slate-100 text-slate-900',
'hover:bg-slate-200',
'focus:ring-slate-500',
'dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
].join(' '),
// Outline: Bordered button with transparent background
outline: [
'border border-slate-300 text-slate-700 bg-transparent',
'hover:bg-slate-50',
'focus:ring-slate-500',
'dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
// Danger: Red-500 (#EF4444) - Destructive action button
danger: [
'bg-error-500 text-white',
'hover:bg-error-600',
'focus:ring-error-500',
'dark:bg-error-500 dark:hover:bg-error-600',
].join(' '),
// Ghost: Transparent background - Minimal button
ghost: [
'text-slate-700 bg-transparent',
'hover:bg-slate-100',
'focus:ring-slate-500',
'dark:text-slate-300 dark:hover:bg-slate-800',
].join(' '),
}
// Size-specific styles
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-6 py-3 text-base gap-2',
}
// Spinner size based on button size
const spinnerSizeStyles: Record<ButtonSize, string> = {
sm: 'h-3.5 w-3.5',
md: 'h-4 w-4',
lg: 'h-5 w-5',
}
const isDisabled = disabled || loading
return (
<button
ref={ref}
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
disabled={isDisabled}
aria-busy={loading}
aria-disabled={isDisabled}
{...props}
>
{loading ? (
<Loader2
className={`${spinnerSizeStyles[size]} animate-spin`}
aria-hidden="true"
/>
) : icon ? (
<span className="flex-shrink-0" aria-hidden="true">
{icon}
</span>
) : null}
{children}
</button>
)
}
)
Button.displayName = 'Button'
export default Button

View File

@@ -0,0 +1,84 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
padding?: 'none' | 'sm' | 'md' | 'lg'
hoverable?: boolean
variant?: 'default' | 'glass'
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className = '', padding = 'md', hoverable = false, variant = 'default', children, ...props }, ref) => {
const paddingStyles = {
none: '',
sm: 'p-3',
md: 'p-4',
lg: 'p-6',
}
const baseStyles = variant === 'glass'
? 'glass-card'
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-sm rounded-xl'
const hoverStyles = hoverable ? 'card-hover cursor-pointer' : ''
return (
<div
ref={ref}
className={`${baseStyles} ${paddingStyles[padding]} ${hoverStyles} ${className}`}
{...props}
>
{children}
</div>
)
}
)
Card.displayName = 'Card'
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={`flex items-center justify-between mb-4 ${className}`} {...props}>
{children}
</div>
)
}
)
CardHeader.displayName = 'CardHeader'
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {}
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
({ className = '', children, ...props }, ref) => {
return (
<h3
ref={ref}
className={`text-lg font-semibold text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</h3>
)
}
)
CardTitle.displayName = 'CardTitle'
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {}
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
({ className = '', children, ...props }, ref) => {
return (
<div ref={ref} className={className} {...props}>
{children}
</div>
)
}
)
CardContent.displayName = 'CardContent'
export default Card

View File

@@ -0,0 +1,61 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { AlertTriangle, RefreshCw } from 'lucide-react'
import Button from './Button'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div className="min-h-[400px] flex items-center justify-center p-8">
<div className="text-center max-w-md">
<div className="h-16 w-16 mx-auto mb-4 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">
</h2>
<p className="text-slate-500 dark:text-slate-400 mb-4">
{this.state.error?.message || '发生了一个意外错误'}
</p>
<Button onClick={this.handleReset} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
</div>
</div>
)
}
return this.props.children
}
}

View File

@@ -0,0 +1,100 @@
import { type InputHTMLAttributes, type TextareaHTMLAttributes, forwardRef } from 'react'
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
hint?: string
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={inputId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<input
ref={ref}
id={inputId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Input.displayName = 'Input'
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string
error?: string
hint?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className = '', label, error, hint, id, ...props }, ref) => {
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={textareaId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<textarea
ref={ref}
id={textareaId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
placeholder-slate-400 dark:placeholder-slate-500
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
resize-none
${className}`}
{...props}
/>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Textarea.displayName = 'Textarea'
export default Input

View File

@@ -0,0 +1,68 @@
import { type HTMLAttributes, forwardRef } from 'react'
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
value: number
max?: number
size?: 'sm' | 'md' | 'lg'
color?: 'blue' | 'green' | 'yellow' | 'red'
showLabel?: boolean
label?: string
}
const Progress = forwardRef<HTMLDivElement, ProgressProps>(
(
{
className = '',
value,
max = 100,
size = 'md',
color = 'blue',
showLabel = false,
label,
...props
},
ref
) => {
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
const sizeStyles = {
sm: 'h-1.5',
md: 'h-2.5',
lg: 'h-4',
}
const colorStyles = {
blue: 'bg-blue-600 dark:bg-blue-500',
green: 'bg-green-600 dark:bg-green-500',
yellow: 'bg-yellow-500 dark:bg-yellow-400',
red: 'bg-red-600 dark:bg-red-500',
}
return (
<div ref={ref} className={className} {...props}>
{(showLabel || label) && (
<div className="flex justify-between mb-1">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{label || '进度'}
</span>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{percentage.toFixed(0)}%
</span>
</div>
)}
<div
className={`w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden ${sizeStyles[size]}`}
>
<div
className={`${sizeStyles[size]} ${colorStyles[color]} rounded-full transition-all duration-300 ease-out`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
)
}
)
Progress.displayName = 'Progress'
export default Progress

View File

@@ -0,0 +1,74 @@
import { type SelectHTMLAttributes, forwardRef } from 'react'
import { ChevronDown } from 'lucide-react'
export interface SelectOption {
value: string | number
label: string
disabled?: boolean
}
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
label?: string
error?: string
hint?: string
options: SelectOption[]
placeholder?: string
}
const Select = forwardRef<HTMLSelectElement, SelectProps>(
({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => {
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors appearance-none
bg-white dark:bg-slate-800
text-slate-900 dark:text-slate-100
${
error
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
}
focus:outline-none focus:ring-2 focus:ring-offset-0
disabled:opacity-50 disabled:cursor-not-allowed
pr-10
${className}`}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options.map((option) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</option>
))}
</select>
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
</div>
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
{hint && !error && (
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
)}
</div>
)
}
)
Select.displayName = 'Select'
export default Select

View File

@@ -0,0 +1,66 @@
import { type HTMLAttributes, forwardRef } from 'react'
import type { AccountStatus } from '../../types'
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
status: AccountStatus | 'active' | 'inactive' | 'error'
size?: 'sm' | 'md'
}
const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
({ className = '', status, size = 'md', ...props }, ref) => {
const getStatusConfig = (status: string) => {
const configs: Record<string, { label: string; color: string }> = {
pending: {
label: '待检查',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
checking: {
label: '检查中',
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
},
active: {
label: '正常',
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
banned: {
label: '封禁',
color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
token_expired: {
label: '过期',
color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
},
error: {
label: '错误',
color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
},
inactive: {
label: '停用',
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
},
}
return configs[status] || configs.error
}
const config = getStatusConfig(status)
const sizeStyles = {
sm: 'px-2 py-0.5 text-xs',
md: 'px-2.5 py-1 text-xs',
}
return (
<span
ref={ref}
className={`inline-flex items-center font-medium rounded-full ${config.color} ${sizeStyles[size]} ${className}`}
{...props}
>
{config.label}
</span>
)
}
)
StatusBadge.displayName = 'StatusBadge'
export default StatusBadge

View File

@@ -0,0 +1,110 @@
import {
type HTMLAttributes,
type ThHTMLAttributes,
type TdHTMLAttributes,
forwardRef,
} from 'react'
export interface TableProps extends HTMLAttributes<HTMLTableElement> {}
const Table = forwardRef<HTMLTableElement, TableProps>(
({ className = '', children, ...props }, ref) => {
return (
<div className="overflow-x-auto">
<table ref={ref} className={`w-full text-sm text-left ${className}`} {...props}>
{children}
</table>
</div>
)
}
)
Table.displayName = 'Table'
export interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
({ className = '', children, ...props }, ref) => {
return (
<thead
ref={ref}
className={`text-xs text-slate-700 uppercase bg-slate-50 dark:bg-slate-700 dark:text-slate-400 ${className}`}
{...props}
>
{children}
</thead>
)
}
)
TableHeader.displayName = 'TableHeader'
export interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {}
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
({ className = '', children, ...props }, ref) => {
return (
<tbody ref={ref} className={className} {...props}>
{children}
</tbody>
)
}
)
TableBody.displayName = 'TableBody'
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
hoverable?: boolean
}
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
({ className = '', hoverable = true, children, ...props }, ref) => {
return (
<tr
ref={ref}
className={`border-b border-slate-200 dark:border-slate-700 ${
hoverable ? 'hover:bg-slate-50 dark:hover:bg-slate-700/50' : ''
} ${className}`}
{...props}
>
{children}
</tr>
)
}
)
TableRow.displayName = 'TableRow'
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {}
export const TableHead = forwardRef<HTMLTableCellElement, TableHeadProps>(
({ className = '', children, ...props }, ref) => {
return (
<th ref={ref} className={`px-4 py-3 font-medium ${className}`} {...props}>
{children}
</th>
)
}
)
TableHead.displayName = 'TableHead'
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {}
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
({ className = '', children, ...props }, ref) => {
return (
<td
ref={ref}
className={`px-4 py-3 text-slate-900 dark:text-slate-100 ${className}`}
{...props}
>
{children}
</td>
)
}
)
TableCell.displayName = 'TableCell'
export default Table

View File

@@ -0,0 +1,48 @@
import type { LucideIcon } from 'lucide-react'
export interface TabItem {
id: string
label: string
icon?: LucideIcon
count?: number
}
interface TabsProps {
tabs: TabItem[]
activeTab: string
onChange: (id: string) => void
className?: string
}
export function Tabs({ tabs, activeTab, onChange, className = '' }: TabsProps) {
return (
<div className={`flex gap-2 border-b border-slate-200 dark:border-slate-800 ${className}`}>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => onChange(tab.id)}
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200 ${activeTab === tab.id
? 'text-blue-600 dark:text-blue-400'
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
}`}
>
{tab.icon && <tab.icon className="h-4 w-4" />}
{tab.label}
{tab.count !== undefined && (
<span
className={`px-2 py-0.5 text-xs rounded-full ${activeTab === tab.id
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
}`}
>
{tab.count}
</span>
)}
{activeTab === tab.id && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
)}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,141 @@
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react'
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
type ToastType = 'success' | 'error' | 'warning' | 'info'
interface Toast {
id: string
type: ToastType
message: string
duration?: number
}
interface ToastContextValue {
toasts: Toast[]
addToast: (type: ToastType, message: string, duration?: number) => void
removeToast: (id: string) => void
success: (message: string) => void
error: (message: string) => void
warning: (message: string) => void
info: (message: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within a ToastProvider')
}
return context
}
interface ToastProviderProps {
children: ReactNode
}
export function ToastProvider({ children }: ToastProviderProps) {
const [toasts, setToasts] = useState<Toast[]>([])
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const addToast = useCallback(
(type: ToastType, message: string, duration = 3000) => {
const id = Math.random().toString(36).substring(2, 9)
const toast: Toast = { id, type, message, duration }
setToasts((prev) => [...prev, toast])
if (duration > 0) {
setTimeout(() => removeToast(id), duration)
}
},
[removeToast]
)
const success = useCallback((message: string) => addToast('success', message), [addToast])
const error = useCallback((message: string) => addToast('error', message), [addToast])
const warning = useCallback((message: string) => addToast('warning', message), [addToast])
const info = useCallback((message: string) => addToast('info', message), [addToast])
return (
<ToastContext.Provider
value={{ toasts, addToast, removeToast, success, error, warning, info }}
>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
)
}
interface ToastContainerProps {
toasts: Toast[]
onRemove: (id: string) => void
}
function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
if (toasts.length === 0) return null
return (
<div className="fixed bottom-6 right-6 z-[9999] space-y-3">
{toasts.map((toast) => (
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
))}
</div>
)
}
interface ToastItemProps {
toast: Toast
onRemove: (id: string) => void
}
const typeStyles: Record<ToastType, { bg: string; icon: typeof CheckCircle }> = {
success: {
bg: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
icon: CheckCircle,
},
error: {
bg: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
icon: XCircle,
},
warning: {
bg: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
icon: AlertTriangle,
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
icon: Info,
},
}
const iconColors: Record<ToastType, string> = {
success: 'text-green-500',
error: 'text-red-500',
warning: 'text-yellow-500',
info: 'text-blue-500',
}
function ToastItem({ toast, onRemove }: ToastItemProps) {
const { bg, icon: Icon } = typeStyles[toast.type]
const iconColor = iconColors[toast.type]
return (
<div
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg animate-fadeIn min-w-[300px] max-w-md ${bg}`}
>
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor}`} />
<p className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
{toast.message}
</p>
<button
onClick={() => onRemove(toast.id)}
className="p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
aria-label="关闭"
>
<X className="h-4 w-4 text-slate-400" />
</button>
</div>
)
}

View File

@@ -0,0 +1,33 @@
export { default as Button } from './Button'
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
export { default as Card, CardHeader, CardTitle, CardContent } from './Card'
export type { CardProps, CardHeaderProps, CardTitleProps, CardContentProps } from './Card'
export { default as Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table'
export type {
TableProps,
TableHeaderProps,
TableBodyProps,
TableRowProps,
TableHeadProps,
TableCellProps,
} from './Table'
export { default as Progress } from './Progress'
export type { ProgressProps } from './Progress'
export { default as StatusBadge } from './StatusBadge'
export type { StatusBadgeProps } from './StatusBadge'
export { default as Input, Textarea } from './Input'
export type { InputProps, TextareaProps } from './Input'
export { default as Select } from './Select'
export type { SelectProps, SelectOption } from './Select'
export { ErrorBoundary } from './ErrorBoundary'
export { ToastProvider, useToast } from './Toast'
export { Tabs } from './Tabs'
export type { TabItem } from './Tabs'

View File

@@ -0,0 +1,64 @@
import { Users, CheckCircle, XCircle, AlertTriangle, Zap, Activity } from 'lucide-react'
import type { DashboardStats } from '../../types'
import StatsCard from './StatsCard'
interface PoolStatusProps {
stats: DashboardStats | null
loading: boolean
error?: string | null
}
export default function PoolStatus({ stats, loading, error }: PoolStatusProps) {
if (error) {
return (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
<XCircle className="h-5 w-5" />
<span className="font-medium"></span>
</div>
<p className="mt-1 text-sm text-red-600 dark:text-red-300">{error}</p>
</div>
)
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
<StatsCard
title="总账号数"
value={stats?.total_accounts ?? 0}
icon={Users}
color="blue"
loading={loading}
/>
<StatsCard
title="正常账号"
value={stats?.normal_accounts ?? 0}
icon={CheckCircle}
color="green"
loading={loading}
/>
<StatsCard
title="错误账号"
value={stats?.error_accounts ?? 0}
icon={XCircle}
color="red"
loading={loading}
/>
<StatsCard
title="限流账号"
value={stats?.ratelimit_accounts ?? 0}
icon={AlertTriangle}
color="yellow"
loading={loading}
/>
<StatsCard
title="今日请求"
value={stats?.today_requests ?? 0}
icon={Activity}
color="slate"
loading={loading}
/>
<StatsCard title="RPM" value={stats?.rpm ?? 0} icon={Zap} color="blue" loading={loading} />
</div>
)
}

View File

@@ -0,0 +1,82 @@
import { Clock, CheckCircle, XCircle } from 'lucide-react'
import { Link } from 'react-router-dom'
import type { AddRecord } from '../../types'
import { formatRelativeTime } from '../../utils/format'
import { Card, CardHeader, CardTitle, Button } from '../common'
interface RecentRecordsProps {
records: AddRecord[]
loading?: boolean
}
export default function RecentRecords({ records, loading = false }: RecentRecordsProps) {
const recentRecords = records.slice(0, 5)
return (
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
<Link to="/records">
<Button variant="ghost" size="sm">
</Button>
</Link>
</CardHeader>
{loading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
))}
</div>
) : recentRecords.length === 0 ? (
<div className="text-center py-8">
<Clock className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400"></p>
<Link to="/upload" className="mt-4 inline-block">
<Button size="sm"></Button>
</Link>
</div>
) : (
<div className="space-y-3">
{recentRecords.map((record) => (
<div
key={record.id}
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-700"
>
<div className="flex items-center gap-3">
<div
className={`p-2 rounded-lg ${
record.failed === 0
? 'bg-green-100 dark:bg-green-900/30'
: 'bg-yellow-100 dark:bg-yellow-900/30'
}`}
>
{record.failed === 0 ? (
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
) : (
<XCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
)}
</div>
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{record.source === 'manual' ? '手动上传' : '自动补号'}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{formatRelativeTime(record.timestamp)}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
+{record.success}
</p>
{record.failed > 0 && <p className="text-xs text-red-500"> {record.failed}</p>}
</div>
</div>
))}
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,96 @@
import type { LucideIcon } from 'lucide-react'
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
import { Card } from '../common'
interface StatsCardProps {
title: string
value: number | string
icon: LucideIcon
trend?: 'up' | 'down' | 'stable'
trendValue?: string
color?: 'blue' | 'green' | 'yellow' | 'red' | 'slate'
loading?: boolean
}
export default function StatsCard({
title,
value,
icon: Icon,
trend,
trendValue,
color = 'blue',
loading = false,
}: StatsCardProps) {
const colorStyles = {
blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/40',
icon: 'text-blue-600 dark:text-blue-400',
gradient: 'from-blue-500/20 to-blue-600/20',
},
green: {
bg: 'bg-green-50 dark:bg-green-900/20 group-hover:bg-green-100 dark:group-hover:bg-green-900/40',
icon: 'text-green-600 dark:text-green-400',
gradient: 'from-green-500/20 to-green-600/20',
},
yellow: {
bg: 'bg-yellow-50 dark:bg-yellow-900/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-900/40',
icon: 'text-yellow-600 dark:text-yellow-400',
gradient: 'from-yellow-500/20 to-yellow-600/20',
},
red: {
bg: 'bg-red-50 dark:bg-red-900/20 group-hover:bg-red-100 dark:group-hover:bg-red-900/40',
icon: 'text-red-600 dark:text-red-400',
gradient: 'from-red-500/20 to-red-600/20',
},
slate: {
bg: 'bg-slate-50 dark:bg-slate-800 group-hover:bg-slate-100 dark:group-hover:bg-slate-700',
icon: 'text-slate-600 dark:text-slate-400',
gradient: 'from-slate-500/20 to-slate-600/20',
},
}
const trendStyles = {
up: {
icon: TrendingUp,
color: 'text-green-600 dark:text-green-400',
},
down: {
icon: TrendingDown,
color: 'text-red-600 dark:text-red-400',
},
stable: {
icon: Minus,
color: 'text-slate-500 dark:text-slate-400',
},
}
const TrendIcon = trend ? trendStyles[trend].icon : null
return (
<Card hoverable className="stat-card group transition-all duration-300">
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{title}</p>
{loading ? (
<div className="mt-2 h-8 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
) : (
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100 tracking-tight">
{typeof value === 'number' ? value.toLocaleString() : value}
</span>
</div>
)}
{trend && trendValue && TrendIcon && (
<div className={`mt-2 flex items-center gap-1 text-sm ${trendStyles[trend].color}`}>
<TrendIcon className="h-4 w-4" />
<span>{trendValue}</span>
</div>
)}
</div>
<div className={`p-3 rounded-xl transition-colors duration-300 ${colorStyles[color].bg} bg-gradient-to-br ${colorStyles[color].gradient}`}>
<Icon className={`h-6 w-6 ${colorStyles[color].icon}`} />
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,3 @@
export { default as StatsCard } from './StatsCard'
export { default as PoolStatus } from './PoolStatus'
export { default as RecentRecords } from './RecentRecords'

View File

@@ -0,0 +1,97 @@
import { Menu, Moon, Sun } from 'lucide-react'
import { useState, useEffect } from 'react'
interface HeaderProps {
onMenuClick: () => void
isConnected?: boolean
}
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
const [isDark, setIsDark] = useState(false)
useEffect(() => {
// Check for saved theme preference or system preference
const savedTheme = localStorage.getItem('theme')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
setIsDark(true)
document.documentElement.classList.add('dark')
}
}, [])
const toggleTheme = () => {
setIsDark(!isDark)
if (isDark) {
document.documentElement.classList.remove('dark')
localStorage.setItem('theme', 'light')
} else {
document.documentElement.classList.add('dark')
localStorage.setItem('theme', 'dark')
}
}
return (
<header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl px-4 lg:px-6 transition-all duration-300">
{/* Mobile menu button */}
<button
onClick={onMenuClick}
className="lg:hidden p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="打开菜单"
>
<Menu className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
{/* Logo - Mobile Only */}
<div className="flex lg:hidden items-center gap-2">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-white font-bold text-sm">CP</span>
</div>
<span className="font-bold text-slate-900 dark:text-slate-100 hidden sm:inline">
Codex Pool
</span>
</div>
{/* Spacer */}
<div className="flex-1" />
{/* Connection status */}
<div
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50'
: 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50'
}`}
>
{isConnected ? (
<>
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
</span>
<span className="hidden sm:inline"></span>
</>
) : (
<>
<span className="relative flex h-2 w-2">
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
</span>
<span className="hidden sm:inline"></span>
</>
)}
</div>
{/* Theme toggle */}
<button
onClick={toggleTheme}
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
>
{isDark ? (
<Sun className="h-5 w-5 text-slate-600 dark:text-slate-400" />
) : (
<Moon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
)}
</button>
</header>
)
}

View File

@@ -0,0 +1,30 @@
import { useState } from 'react'
import { Outlet } from 'react-router-dom'
import Header from './Header'
import Sidebar from './Sidebar'
import { useConfig } from '../../hooks/useConfig'
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
const { isConnected } = useConfig()
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<div className="flex">
{/* Sidebar */}
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
{/* Main content */}
<div className="flex-1 flex flex-col min-h-screen lg:ml-0">
{/* Header */}
<Header onMenuClick={() => setSidebarOpen(true)} isConnected={isConnected} />
{/* Page content */}
<main className="flex-1 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,174 @@
import { NavLink, useLocation } from 'react-router-dom'
import { useState } from 'react'
import {
LayoutDashboard,
Upload,
History,
Users,
Settings,
X,
Activity,
ChevronDown,
Server,
Mail,
Cog,
UsersRound
} from 'lucide-react'
interface SidebarProps {
isOpen: boolean
onClose: () => void
}
interface NavItem {
to: string
icon: React.ComponentType<{ className?: string }>
label: string
children?: NavItem[]
}
const navItems: NavItem[] = [
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
{ to: '/upload', icon: Upload, label: '上传入库' },
{ to: '/team', icon: UsersRound, label: 'Team 批量处理' },
{ to: '/records', icon: History, label: '加号记录' },
{ to: '/accounts', icon: Users, label: '号池账号' },
{ to: '/monitor', icon: Activity, label: '号池监控' },
{
to: '/config',
icon: Settings,
label: '系统配置',
children: [
{ to: '/config', icon: Cog, label: '配置概览' },
{ to: '/config/s2a', icon: Server, label: 'S2A 配置' },
{ to: '/config/email', icon: Mail, label: '邮箱配置' },
]
},
]
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const location = useLocation()
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
const toggleExpand = (path: string) => {
setExpandedItems(prev =>
prev.includes(path)
? prev.filter(p => p !== path)
: [...prev, path]
)
}
const isItemActive = (item: NavItem): boolean => {
if (item.children) {
return item.children.some(child => location.pathname === child.to)
}
return location.pathname === item.to
}
const renderNavItem = (item: NavItem, isChild = false) => {
const hasChildren = item.children && item.children.length > 0
const isExpanded = expandedItems.includes(item.to)
const isActive = isItemActive(item)
if (hasChildren) {
return (
<div key={item.to} className="space-y-1">
<button
onClick={() => toggleExpand(item.to)}
className={`w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isActive
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
}`}
>
<div className="flex items-center gap-3">
<item.icon className="h-5 w-5" />
{item.label}
</div>
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
/>
</button>
{/* 子菜单 */}
<div className={`ml-4 pl-3 border-l-2 border-slate-200 dark:border-slate-700 space-y-1 overflow-hidden transition-all duration-200 ${isExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
}`}>
{item.children?.map(child => renderNavItem(child, true))}
</div>
</div>
)
}
return (
<NavLink
key={item.to}
to={item.to}
onClick={onClose}
end={item.to === '/config'}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isChild ? 'py-2' : ''
} ${isActive
? isChild
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400'
: 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
}`
}
>
<item.icon className={`${isChild ? 'h-4 w-4' : 'h-5 w-5'}`} />
{item.label}
</NavLink>
)
}
return (
<>
{/* Mobile overlay */}
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden" onClick={onClose} />}
{/* Sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{/* Mobile close button */}
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-white font-bold text-sm">CP</span>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
aria-label="关闭菜单"
>
<X className="h-5 w-5 text-slate-600 dark:text-slate-400" />
</button>
</div>
{/* Desktop Header (Logo) */}
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
<span className="text-white font-bold text-sm">CP</span>
</div>
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
</div>
</div>
{/* Navigation */}
<nav className="p-4 space-y-1.5">
{navItems.map(item => renderNavItem(item))}
</nav>
{/* Footer */}
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200/50 dark:border-slate-800/50 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
<div className="text-xs text-slate-500 dark:text-slate-400 text-center font-medium">
Codex Pool v1.0.0
</div>
</div>
</aside>
</>
)
}

View File

@@ -0,0 +1,3 @@
export { default as Layout } from './Layout'
export { default as Header } from './Header'
export { default as Sidebar } from './Sidebar'

View File

@@ -0,0 +1,103 @@
import { CheckCircle, Trash2 } from 'lucide-react'
import type { AddRecord } from '../../types'
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Button } from '../common'
import { formatDateTime } from '../../utils/format'
interface RecordListProps {
records: AddRecord[]
onDelete?: (id: string) => void
loading?: boolean
}
export default function RecordList({ records, onDelete, loading = false }: RecordListProps) {
if (loading) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
))}
</div>
)
}
if (records.length === 0) {
return (
<div className="text-center py-12">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 mb-4">
<CheckCircle className="h-8 w-8 text-slate-400 dark:text-slate-500" />
</div>
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
)
}
return (
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
{onDelete && <TableHead className="w-16"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow key={record.id}>
<TableCell>
<span className="text-sm">{formatDateTime(record.timestamp)}</span>
</TableCell>
<TableCell>
<span
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
record.source === 'manual'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
}`}
>
{record.source === 'manual' ? '手动上传' : '自动补号'}
</span>
</TableCell>
<TableCell className="text-right font-medium">{record.total}</TableCell>
<TableCell className="text-right">
<span className="text-green-600 dark:text-green-400 font-medium">
{record.success}
</span>
</TableCell>
<TableCell className="text-right">
{record.failed > 0 ? (
<span className="text-red-600 dark:text-red-400 font-medium">
{record.failed}
</span>
) : (
<span className="text-slate-400">0</span>
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400 truncate max-w-[200px] block">
{record.details || '-'}
</span>
</TableCell>
{onDelete && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => onDelete(record.id)}
className="text-slate-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}

View File

@@ -0,0 +1,76 @@
import { TrendingUp, Calendar, CheckCircle, XCircle } from 'lucide-react'
import { Card } from '../common'
interface RecordStatsProps {
stats: {
totalRecords: number
totalAdded: number
totalSuccess: number
totalFailed: number
todayAdded: number
weekAdded: number
}
}
export default function RecordStats({ stats }: RecordStatsProps) {
const successRate =
stats.totalAdded > 0 ? ((stats.totalSuccess / stats.totalAdded) * 100).toFixed(1) : '0'
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.totalSuccess}
</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{successRate}%</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.todayAdded}
</p>
</div>
</div>
</Card>
<Card padding="sm">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<p className="text-xs text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.weekAdded}
</p>
</div>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { default as RecordList } from './RecordList'
export { default as RecordStats } from './RecordStats'

View File

@@ -0,0 +1,182 @@
import { useState, useMemo } from 'react'
import { CheckSquare, Square, MinusSquare } from 'lucide-react'
import type { CheckedAccount } from '../../types'
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
StatusBadge,
Button,
} from '../common'
import { maskEmail, maskToken } from '../../utils/format'
interface AccountTableProps {
accounts: CheckedAccount[]
selectedIds: number[]
onSelectionChange: (ids: number[]) => void
disabled?: boolean
}
export default function AccountTable({
accounts,
selectedIds,
onSelectionChange,
disabled = false,
}: AccountTableProps) {
const [showTokens, setShowTokens] = useState(false)
const activeAccounts = useMemo(
() => accounts.filter((acc) => acc.status === 'active'),
[accounts]
)
const allSelected = selectedIds.length === accounts.length && accounts.length > 0
const someSelected = selectedIds.length > 0 && selectedIds.length < accounts.length
const activeSelected = activeAccounts.every((acc) => selectedIds.includes(acc.id))
const handleSelectAll = () => {
if (allSelected) {
onSelectionChange([])
} else {
onSelectionChange(accounts.map((acc) => acc.id))
}
}
const handleSelectActive = () => {
onSelectionChange(activeAccounts.map((acc) => acc.id))
}
const handleSelectNone = () => {
onSelectionChange([])
}
const handleToggle = (id: number) => {
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter((i) => i !== id))
} else {
onSelectionChange([...selectedIds, id])
}
}
if (accounts.length === 0) {
return null
}
return (
<div className="space-y-3">
{/* Selection controls */}
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleSelectActive}
disabled={disabled || activeAccounts.length === 0}
>
({activeAccounts.length})
</Button>
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
({accounts.length})
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSelectNone}
disabled={disabled || selectedIds.length === 0}
>
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setShowTokens(!showTokens)}>
{showTokens ? '隐藏 Token' : '显示 Token'}
</Button>
</div>
{/* Selection summary */}
<div className="text-sm text-slate-600 dark:text-slate-400">
{' '}
<span className="font-medium text-slate-900 dark:text-slate-100">{selectedIds.length}</span>{' '}
{activeSelected && activeAccounts.length > 0 && (
<span className="ml-2 text-green-600 dark:text-green-400">
( {activeAccounts.length} )
</span>
)}
</div>
{/* Table */}
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead className="w-12">
<button
onClick={handleSelectAll}
disabled={disabled}
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded disabled:opacity-50"
>
{allSelected ? (
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : someSelected ? (
<MinusSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="h-4 w-4 text-slate-400" />
)}
</button>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Account ID</TableHead>
<TableHead>Plan Type</TableHead>
{showTokens && <TableHead>Token</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<button
onClick={() => handleToggle(account.id)}
disabled={disabled}
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-50"
>
{selectedIds.includes(account.id) ? (
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
) : (
<Square className="h-4 w-4 text-slate-400" />
)}
</button>
</TableCell>
<TableCell>
<span className="font-mono text-sm">{maskEmail(account.account)}</span>
</TableCell>
<TableCell>
<StatusBadge status={account.status} />
</TableCell>
<TableCell>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{account.accountId || '-'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-600 dark:text-slate-400">
{account.planType || '-'}
</span>
</TableCell>
{showTokens && (
<TableCell>
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{maskToken(account.token, 12)}
</span>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
)
}

View File

@@ -0,0 +1,72 @@
import { CheckCircle, XCircle, AlertTriangle, Clock } from 'lucide-react'
import { Progress } from '../common'
interface CheckProgressProps {
total: number
checked: number
results: {
active: number
banned: number
token_expired: number
error: number
}
checking: boolean
}
export default function CheckProgress({ total, checked, results, checking }: CheckProgressProps) {
return (
<div className="space-y-4">
{/* Progress bar */}
<div>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
{checking ? '检查中...' : checked === total && total > 0 ? '检查完成' : '待检查'}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{checked} / {total}
</span>
</div>
<Progress value={checked} max={total} color={checking ? 'blue' : 'green'} size="md" />
</div>
{/* Results summary */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
<div>
<p className="text-xs text-green-600 dark:text-green-400"></p>
<p className="text-lg font-bold text-green-700 dark:text-green-300">{results.active}</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
<div>
<p className="text-xs text-red-600 dark:text-red-400"></p>
<p className="text-lg font-bold text-red-700 dark:text-red-300">{results.banned}</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<Clock className="h-5 w-5 text-orange-600 dark:text-orange-400" />
<div>
<p className="text-xs text-orange-600 dark:text-orange-400"></p>
<p className="text-lg font-bold text-orange-700 dark:text-orange-300">
{results.token_expired}
</p>
</div>
</div>
<div className="flex items-center gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
<div>
<p className="text-xs text-yellow-600 dark:text-yellow-400"></p>
<p className="text-lg font-bold text-yellow-700 dark:text-yellow-300">
{results.error}
</p>
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,114 @@
import { useCallback, useState } from 'react'
import { Upload, FileJson, AlertCircle } from 'lucide-react'
interface FileDropzoneProps {
onFileSelect: (file: File) => void
disabled?: boolean
error?: string | null
}
export default function FileDropzone({ onFileSelect, disabled = false, error }: FileDropzoneProps) {
const [isDragging, setIsDragging] = useState(false)
const handleDragOver = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
if (!disabled) {
setIsDragging(true)
}
},
[disabled]
)
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (disabled) return
const files = e.dataTransfer.files
if (files.length > 0) {
const file = files[0]
if (file.type === 'application/json' || file.name.endsWith('.json')) {
onFileSelect(file)
}
}
},
[disabled, onFileSelect]
)
const handleFileInput = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
onFileSelect(files[0])
}
// Reset input value to allow selecting the same file again
e.target.value = ''
},
[onFileSelect]
)
return (
<div className="space-y-2">
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
disabled
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 cursor-not-allowed'
: isDragging
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 cursor-pointer'
}`}
>
<input
type="file"
accept=".json,application/json"
onChange={handleFileInput}
disabled={disabled}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
/>
<div className="flex flex-col items-center gap-3">
<div
className={`p-4 rounded-full ${
isDragging ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-slate-100 dark:bg-slate-700'
}`}
>
{isDragging ? (
<Upload className="h-8 w-8 text-blue-600 dark:text-blue-400" />
) : (
<FileJson className="h-8 w-8 text-slate-400 dark:text-slate-500" />
)}
</div>
<div>
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{isDragging ? '释放文件以上传' : '拖拽 JSON 文件到此处'}
</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400"></p>
</div>
<div className="text-xs text-slate-400 dark:text-slate-500">
: [&#123;"account": "email", "password": "pwd", "token": "..."&#125;]
</div>
</div>
</div>
{error && (
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
<AlertCircle className="h-4 w-4" />
<span>{error}</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,173 @@
import { useState, useEffect, useRef } from 'react'
import { Terminal, Trash2, Play, Pause } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
interface LogEntry {
timestamp: string
level: string
message: string
email?: string
step?: string
}
interface LogStreamProps {
apiBase?: string
}
const levelColors: Record<string, string> = {
info: 'text-blue-400',
success: 'text-green-400',
warning: 'text-yellow-400',
error: 'text-red-400',
}
const stepColors: Record<string, string> = {
validate: 'bg-purple-500/20 text-purple-400',
register: 'bg-blue-500/20 text-blue-400',
authorize: 'bg-orange-500/20 text-orange-400',
pool: 'bg-green-500/20 text-green-400',
database: 'bg-slate-500/20 text-slate-400',
}
const stepLabels: Record<string, string> = {
validate: '验证',
register: '注册',
authorize: '授权',
pool: '入库',
database: '数据库',
}
export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) {
const [logs, setLogs] = useState<LogEntry[]>([])
const [connected, setConnected] = useState(false)
const [paused, setPaused] = useState(false)
const logContainerRef = useRef<HTMLDivElement>(null)
const eventSourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (paused) return
const eventSource = new EventSource(`${apiBase}/api/logs/stream`)
eventSourceRef.current = eventSource
eventSource.onopen = () => {
setConnected(true)
}
eventSource.onmessage = (event) => {
try {
const log = JSON.parse(event.data) as LogEntry
setLogs((prev) => [...prev.slice(-199), log])
} catch (e) {
console.error('Failed to parse log:', e)
}
}
eventSource.onerror = () => {
setConnected(false)
eventSource.close()
}
return () => {
eventSource.close()
}
}, [apiBase, paused])
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
}
}, [logs])
const handleClear = async () => {
try {
await fetch(`${apiBase}/api/logs/clear`, { method: 'POST' })
setLogs([])
} catch (e) {
console.error('Failed to clear logs:', e)
}
}
const togglePause = () => {
if (!paused && eventSourceRef.current) {
eventSourceRef.current.close()
}
setPaused(!paused)
}
const formatTime = (timestamp: string) => {
try {
return new Date(timestamp).toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
} catch {
return ''
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
<span
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
/>
</CardTitle>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={togglePause}
icon={paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
>
{paused ? '继续' : '暂停'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClear}
icon={<Trash2 className="h-4 w-4" />}
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div
ref={logContainerRef}
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs"
>
{logs.length === 0 ? (
<div className="text-slate-500 text-center py-8">...</div>
) : (
logs.map((log, i) => (
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50">
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span>
{log.step && (
<span
className={`px-1.5 rounded text-[10px] uppercase flex-shrink-0 ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
>
{stepLabels[log.step] || log.step}
</span>
)}
<span className={`flex-shrink-0 ${levelColors[log.level] || 'text-slate-300'}`}>
{log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
</span>
<span className="text-slate-300 break-all">{log.message}</span>
{log.email && (
<span className="text-slate-500 flex-shrink-0 ml-auto">[{log.email}]</span>
)}
</div>
))
)}
</div>
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,218 @@
import { useState, useEffect } from 'react'
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
interface TeamOwner {
id: number
email: string
account_id: string
status: string
created_at: string
}
interface OwnerListProps {
apiBase?: string
}
const statusColors: Record<string, string> = {
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
}
const statusLabels: Record<string, string> = {
valid: '有效',
registered: '已注册',
pooled: '已入库',
}
export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) {
const [owners, setOwners] = useState<TeamOwner[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [page, setPage] = useState(0)
const [filter, setFilter] = useState<string>('')
const limit = 20
const loadOwners = async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(limit),
offset: String(page * limit),
})
if (filter) {
params.set('status', filter)
}
const res = await fetch(`${apiBase}/api/db/owners?${params}`)
const data = await res.json()
if (data.code === 0) {
setOwners(data.data.owners || [])
setTotal(data.data.total || 0)
}
} catch (e) {
console.error('Failed to load owners:', e)
} finally {
setLoading(false)
}
}
useEffect(() => {
loadOwners()
}, [page, filter])
const handleDelete = async (id: number) => {
if (!confirm('确认删除此账号?')) return
try {
await fetch(`${apiBase}/api/db/owners/${id}`, { method: 'DELETE' })
loadOwners()
} catch (e) {
console.error('Failed to delete:', e)
}
}
const handleClearAll = async () => {
if (!confirm('确认清空所有账号?此操作不可恢复!')) return
try {
await fetch(`${apiBase}/api/db/owners/clear`, { method: 'POST' })
loadOwners()
} catch (e) {
console.error('Failed to clear:', e)
}
}
const totalPages = Math.ceil(total / limit)
const formatTime = (ts: string) => {
try {
return new Date(ts).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
}
return (
<Card className="h-full flex flex-col">
<CardHeader className="flex-shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
({total})
</CardTitle>
<div className="flex gap-2">
<select
className="px-2 py-1 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
value={filter}
onChange={(e) => {
setFilter(e.target.value)
setPage(0)
}}
>
<option value=""></option>
<option value="valid"></option>
<option value="registered"></option>
<option value="pooled"></option>
</select>
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-500 hover:text-red-600"
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0">
<div className="h-full overflow-auto">
<table className="w-full text-sm">
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
<tr>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400"></th>
<th className="text-center p-3 font-medium text-slate-600 dark:text-slate-400"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
...
</td>
</tr>
) : owners.length === 0 ? (
<tr>
<td colSpan={5} className="text-center py-8 text-slate-500">
</td>
</tr>
) : (
owners.map((owner) => (
<tr key={owner.id} className="border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
<td className="p-3 font-mono text-xs text-slate-500">{owner.account_id?.slice(0, 20)}...</td>
<td className="p-3">
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[owner.status] || 'bg-slate-100 text-slate-700'}`}>
{statusLabels[owner.status] || owner.status}
</span>
</td>
<td className="p-3 text-slate-500 text-xs">{formatTime(owner.created_at)}</td>
<td className="p-3 text-center">
<button
onClick={() => handleDelete(owner.id)}
className="text-red-400 hover:text-red-500"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex-shrink-0 p-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between">
<span className="text-sm text-slate-500">
{page + 1} / {totalPages}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
icon={<ChevronLeft className="h-4 w-4" />}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
icon={<ChevronRight className="h-4 w-4" />}
>
</Button>
</div>
</div>
)}
</Card>
)
}

View File

@@ -0,0 +1,141 @@
import { useState } from 'react'
import { Upload, CheckCircle, AlertCircle } from 'lucide-react'
import type { CheckedAccount } from '../../types'
import { Button, Progress } from '../common'
interface PoolActionsProps {
selectedAccounts: CheckedAccount[]
onPool: (accounts: CheckedAccount[]) => Promise<{ success: number; failed: number }>
disabled?: boolean
}
export default function PoolActions({
selectedAccounts,
onPool,
disabled = false,
}: PoolActionsProps) {
const [pooling, setPooling] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const [result, setResult] = useState<{ success: number; failed: number } | null>(null)
const activeSelected = selectedAccounts.filter((acc) => acc.status === 'active')
const hasNonActive = selectedAccounts.some((acc) => acc.status !== 'active')
const handlePool = async () => {
if (selectedAccounts.length === 0) return
setPooling(true)
setProgress({ current: 0, total: selectedAccounts.length })
setResult(null)
try {
const poolResult = await onPool(selectedAccounts)
setResult(poolResult)
} catch (error) {
console.error('Pool error:', error)
setResult({ success: 0, failed: selectedAccounts.length })
} finally {
setPooling(false)
}
}
if (selectedAccounts.length === 0) {
return (
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
</div>
)
}
return (
<div className="space-y-4">
{/* Warning for non-active accounts */}
{hasNonActive && (
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div className="text-sm">
<p className="font-medium text-yellow-800 dark:text-yellow-200">
</p>
<p className="text-yellow-700 dark:text-yellow-300">
"正常"使
</p>
</div>
</div>
)}
{/* Summary */}
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
<div>
<p className="text-sm text-slate-600 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{selectedAccounts.length}
</p>
{activeSelected.length !== selectedAccounts.length && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{activeSelected.length}
</p>
)}
</div>
<Button
onClick={handlePool}
disabled={disabled || pooling}
loading={pooling}
icon={pooling ? undefined : <Upload className="h-4 w-4" />}
>
{pooling ? '入库中...' : '入库选中'}
</Button>
</div>
{/* Progress */}
{pooling && (
<div>
<Progress
value={progress.current}
max={progress.total}
color="blue"
showLabel
label="入库进度"
/>
</div>
)}
{/* Result */}
{result && (
<div
className={`flex items-center gap-3 p-4 rounded-lg ${
result.failed === 0
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
: 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
}`}
>
{result.failed === 0 ? (
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
)}
<div>
<p
className={`font-medium ${
result.failed === 0
? 'text-green-800 dark:text-green-200'
: 'text-yellow-800 dark:text-yellow-200'
}`}
>
</p>
<p
className={`text-sm ${
result.failed === 0
? 'text-green-700 dark:text-green-300'
: 'text-yellow-700 dark:text-yellow-300'
}`}
>
{result.success} {result.failed}
</p>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { default as FileDropzone } from './FileDropzone'
export { default as AccountTable } from './AccountTable'
export { default as CheckProgress } from './CheckProgress'
export { default as PoolActions } from './PoolActions'

View File

@@ -0,0 +1,148 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { AppConfig } from '../types'
import { defaultConfig } from '../types'
import { loadConfig, saveConfig } from '../utils/storage'
import { S2AClient } from '../api/s2a'
interface ConfigContextValue {
config: AppConfig
updateConfig: (updates: Partial<AppConfig>) => void
updateS2AConfig: (updates: Partial<AppConfig['s2a']>) => void
updatePoolingConfig: (updates: Partial<AppConfig['pooling']>) => void
updateCheckConfig: (updates: Partial<AppConfig['check']>) => void
updateEmailConfig: (updates: Partial<AppConfig['email']>) => void
isConnected: boolean
testConnection: () => Promise<boolean>
s2aClient: S2AClient | null
}
const ConfigContext = createContext<ConfigContextValue | null>(null)
export function ConfigProvider({ children }: { children: ReactNode }) {
const [config, setConfig] = useState<AppConfig>(defaultConfig)
const [isConnected, setIsConnected] = useState(false)
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
// Load config from localStorage on mount
useEffect(() => {
const savedConfig = loadConfig()
setConfig(savedConfig)
// Create S2A client if config is available
if (savedConfig.s2a.apiBase && savedConfig.s2a.adminKey) {
const client = new S2AClient({
baseUrl: savedConfig.s2a.apiBase,
apiKey: savedConfig.s2a.adminKey,
})
setS2aClient(client)
// Test connection on load
client.testConnection().then(setIsConnected)
}
}, [])
// Update S2A client when config changes
useEffect(() => {
if (config.s2a.apiBase && config.s2a.adminKey) {
const client = new S2AClient({
baseUrl: config.s2a.apiBase,
apiKey: config.s2a.adminKey,
})
setS2aClient(client)
} else {
setS2aClient(null)
setIsConnected(false)
}
}, [config.s2a.apiBase, config.s2a.adminKey])
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
setConfig((prev) => {
const newConfig = { ...prev, ...updates }
saveConfig(newConfig)
return newConfig
})
}, [])
const updateS2AConfig = useCallback((updates: Partial<AppConfig['s2a']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
s2a: { ...prev.s2a, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updatePoolingConfig = useCallback((updates: Partial<AppConfig['pooling']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
pooling: { ...prev.pooling, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updateCheckConfig = useCallback((updates: Partial<AppConfig['check']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
check: { ...prev.check, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const updateEmailConfig = useCallback((updates: Partial<AppConfig['email']>) => {
setConfig((prev) => {
const newConfig = {
...prev,
email: { ...prev.email, ...updates },
}
saveConfig(newConfig)
return newConfig
})
}, [])
const testConnection = useCallback(async (): Promise<boolean> => {
try {
// 使用后端代理 API 来测试 S2A 连接(避免 CORS 问题)
const res = await fetch('http://localhost:8088/api/s2a/test')
const connected = res.ok
setIsConnected(connected)
return connected
} catch {
setIsConnected(false)
return false
}
}, [])
return (
<ConfigContext.Provider
value={{
config,
updateConfig,
updateS2AConfig,
updatePoolingConfig,
updateCheckConfig,
updateEmailConfig,
isConnected,
testConnection,
s2aClient,
}}
>
{children}
</ConfigContext.Provider>
)
}
export function useConfigContext(): ConfigContextValue {
const context = useContext(ConfigContext)
if (!context) {
throw new Error('useConfigContext must be used within a ConfigProvider')
}
return context
}

View File

@@ -0,0 +1,110 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
import type { AddRecord } from '../types'
import { loadRecords, saveRecords, generateId } from '../utils/storage'
interface RecordsContextValue {
records: AddRecord[]
addRecord: (record: Omit<AddRecord, 'id' | 'timestamp'>) => void
deleteRecord: (id: string) => void
clearRecords: () => void
getRecordsByDateRange: (startDate: Date, endDate: Date) => AddRecord[]
getStats: () => {
totalRecords: number
totalAdded: number
totalSuccess: number
totalFailed: number
todayAdded: number
weekAdded: number
}
}
const RecordsContext = createContext<RecordsContextValue | null>(null)
export function RecordsProvider({ children }: { children: ReactNode }) {
const [records, setRecords] = useState<AddRecord[]>([])
// Load records from localStorage on mount
useEffect(() => {
const savedRecords = loadRecords()
setRecords(savedRecords)
}, [])
const addRecord = useCallback((record: Omit<AddRecord, 'id' | 'timestamp'>) => {
const newRecord: AddRecord = {
...record,
id: generateId(),
timestamp: new Date().toISOString(),
}
setRecords((prev) => {
const updated = [newRecord, ...prev]
saveRecords(updated)
return updated
})
}, [])
const deleteRecord = useCallback((id: string) => {
setRecords((prev) => {
const updated = prev.filter((r) => r.id !== id)
saveRecords(updated)
return updated
})
}, [])
const clearRecords = useCallback(() => {
setRecords([])
saveRecords([])
}, [])
const getRecordsByDateRange = useCallback(
(startDate: Date, endDate: Date): AddRecord[] => {
return records.filter((record) => {
const recordDate = new Date(record.timestamp)
return recordDate >= startDate && recordDate <= endDate
})
},
[records]
)
const getStats = useCallback(() => {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekStart = new Date(todayStart)
weekStart.setDate(weekStart.getDate() - 7)
const todayRecords = records.filter((r) => new Date(r.timestamp) >= todayStart)
const weekRecords = records.filter((r) => new Date(r.timestamp) >= weekStart)
return {
totalRecords: records.length,
totalAdded: records.reduce((sum, r) => sum + r.total, 0),
totalSuccess: records.reduce((sum, r) => sum + r.success, 0),
totalFailed: records.reduce((sum, r) => sum + r.failed, 0),
todayAdded: todayRecords.reduce((sum, r) => sum + r.success, 0),
weekAdded: weekRecords.reduce((sum, r) => sum + r.success, 0),
}
}, [records])
return (
<RecordsContext.Provider
value={{
records,
addRecord,
deleteRecord,
clearRecords,
getRecordsByDateRange,
getStats,
}}
>
{children}
</RecordsContext.Provider>
)
}
export function useRecordsContext(): RecordsContextValue {
const context = useContext(RecordsContext)
if (!context) {
throw new Error('useRecordsContext must be used within a RecordsProvider')
}
return context
}

View File

@@ -0,0 +1,2 @@
export { ConfigProvider, useConfigContext } from './ConfigContext'
export { RecordsProvider, useRecordsContext } from './RecordsContext'

View File

@@ -0,0 +1,5 @@
export { useConfig } from './useConfig'
export { useRecords } from './useRecords'
export { useS2AApi } from './useS2AApi'
export { useAccountCheck } from './useAccountCheck'
export { useBackendApi } from './useBackendApi'

View File

@@ -0,0 +1,160 @@
import { useState, useCallback } from 'react'
import type { AccountInput, CheckedAccount, AccountStatus } from '../types'
import { ChatGPTClient } from '../api/chatgpt'
interface CheckProgress {
total: number
checked: number
results: {
active: number
banned: number
token_expired: number
error: number
}
}
export function useAccountCheck() {
const [accounts, setAccounts] = useState<CheckedAccount[]>([])
const [checking, setChecking] = useState(false)
const [progress, setProgress] = useState<CheckProgress>({
total: 0,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
const loadFromJson = useCallback((jsonData: AccountInput[]) => {
const checkedAccounts: CheckedAccount[] = jsonData.map((account, index) => ({
...account,
id: index,
status: 'pending' as AccountStatus,
}))
setAccounts(checkedAccounts)
setProgress({
total: checkedAccounts.length,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
}, [])
const parseJsonFile = useCallback(
async (file: File): Promise<{ success: boolean; error?: string }> => {
try {
const text = await file.text()
const data = JSON.parse(text)
if (!Array.isArray(data)) {
return { success: false, error: 'JSON 文件必须是数组格式' }
}
// Validate each account
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (!item.account || !item.password || !item.token) {
return {
success: false,
error: `${i + 1} 条记录缺少必要字段 (account, password, token)`,
}
}
}
loadFromJson(data)
return { success: true }
} catch {
return { success: false, error: 'JSON 解析失败,请检查文件格式' }
}
},
[loadFromJson]
)
const startCheck = useCallback(
async (concurrency: number = 20) => {
if (accounts.length === 0 || checking) return
setChecking(true)
const client = new ChatGPTClient()
const results = { active: 0, banned: 0, token_expired: 0, error: 0 }
// Mark all as checking
setAccounts((prev) => prev.map((acc) => ({ ...acc, status: 'checking' as AccountStatus })))
await client.batchCheck(
accounts.map((acc) => ({
account: acc.account,
password: acc.password,
token: acc.token,
})),
{
concurrency,
onProgress: (checkedAccount, index) => {
// Update results count
const status = checkedAccount.status
if (status === 'active') results.active++
else if (status === 'banned') results.banned++
else if (status === 'token_expired') results.token_expired++
else results.error++
// Update account in list
setAccounts((prev) =>
prev.map((acc, i) =>
i === index
? {
...acc,
status: checkedAccount.status,
accountId: checkedAccount.accountId,
planType: checkedAccount.planType,
error: checkedAccount.error,
}
: acc
)
)
// Update progress
setProgress({
total: accounts.length,
checked: index + 1,
results: { ...results },
})
},
}
)
setChecking(false)
},
[accounts, checking]
)
const selectAll = useCallback(
(status?: AccountStatus) => {
return accounts.filter((acc) => !status || acc.status === status).map((acc) => acc.id)
},
[accounts]
)
const getSelectedAccounts = useCallback(
(ids: number[]) => {
return accounts.filter((acc) => ids.includes(acc.id))
},
[accounts]
)
const clearAccounts = useCallback(() => {
setAccounts([])
setProgress({
total: 0,
checked: 0,
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
})
}, [])
return {
accounts,
checking,
progress,
loadFromJson,
parseJsonFile,
startCheck,
selectAll,
getSelectedAccounts,
clearAccounts,
}
}

View File

@@ -0,0 +1,181 @@
import { useState, useCallback } from 'react'
import { useConfig } from './useConfig'
import { useToast } from '../components/common'
interface PoolStatus {
target: number
current: number
deficit: number
last_check: string
auto_add: boolean
min_interval: number
last_auto_add: string
polling_enabled: boolean
polling_interval: number
}
interface HealthCheckResult {
account_id: number
email: string
status: string
checked_at: string
error?: string
}
export function useBackendApi() {
const { config } = useConfig()
const toast = useToast()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// 推断后端 API 地址 (S2A 在 8080, 后端 API 在 8088)
const getBackendUrl = useCallback(() => {
const s2aBase = config.s2a.apiBase
return s2aBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
}, [config.s2a.apiBase])
// 通用请求方法
const request = useCallback(
async <T>(endpoint: string, options: RequestInit = {}): Promise<T | null> => {
setLoading(true)
setError(null)
try {
const backendUrl = getBackendUrl()
const response = await fetch(`${backendUrl}${endpoint}`, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.code !== 0) {
throw new Error(data.message || '请求失败')
}
return data.data as T
} catch (e) {
const msg = e instanceof Error ? e.message : '请求失败'
setError(msg)
toast.error(msg)
return null
} finally {
setLoading(false)
}
},
[getBackendUrl, toast]
)
// 健康检查
const checkHealth = useCallback(async () => {
return request<{ status: string; time: string }>('/api/health')
}, [request])
// 获取号池状态
const getPoolStatus = useCallback(async () => {
return request<PoolStatus>('/api/pool/status')
}, [request])
// 设置号池目标
const setPoolTarget = useCallback(
async (target: number, autoAdd: boolean, minInterval: number) => {
return request('/api/pool/target', {
method: 'POST',
body: JSON.stringify({
target,
auto_add: autoAdd,
min_interval: minInterval,
}),
})
},
[request]
)
// 控制轮询
const setPolling = useCallback(
async (enabled: boolean, interval: number) => {
return request('/api/pool/polling', {
method: 'POST',
body: JSON.stringify({ enabled, interval }),
})
},
[request]
)
// 刷新号池状态
const refreshPool = useCallback(async () => {
return request('/api/pool/refresh', { method: 'POST' })
}, [request])
// 启动健康检查
const startHealthCheck = useCallback(async () => {
return request('/api/health-check/start', { method: 'POST' })
}, [request])
// 获取健康检查结果
const getHealthCheckResults = useCallback(async () => {
return request<HealthCheckResult[]>('/api/health-check/results')
}, [request])
// 获取本地账号
const getLocalAccounts = useCallback(async () => {
return request<Array<{
email: string
pooled: boolean
pooled_at: string
pool_id: number
used: boolean
used_at: string
}>>('/api/accounts')
}, [request])
// 批量入库
const poolAccounts = useCallback(
async (emails: string[]) => {
const result = await request<{ success: number; failed: number }>('/api/accounts/pool', {
method: 'POST',
body: JSON.stringify({ emails }),
})
if (result) {
toast.success(`入库完成: 成功 ${result.success}, 失败 ${result.failed}`)
}
return result
},
[request, toast]
)
// Codex 授权
const startCodexAuth = useCallback(
async (email: string, password: string, proxy?: string) => {
const result = await request('/api/codex/auth', {
method: 'POST',
body: JSON.stringify({ email, password, proxy }),
})
if (result) {
toast.success('授权任务已启动')
}
return result
},
[request, toast]
)
return {
loading,
error,
checkHealth,
getPoolStatus,
setPoolTarget,
setPolling,
refreshPool,
startHealthCheck,
getHealthCheckResults,
getLocalAccounts,
poolAccounts,
startCodexAuth,
}
}

View File

@@ -0,0 +1,5 @@
import { useConfigContext } from '../context/ConfigContext'
export function useConfig() {
return useConfigContext()
}

View File

@@ -0,0 +1,5 @@
import { useRecordsContext } from '../context/RecordsContext'
export function useRecords() {
return useRecordsContext()
}

View File

@@ -0,0 +1,166 @@
import { useState, useCallback } from 'react'
import { useConfig } from './useConfig'
import type { DashboardStats, S2AAccount, AccountListParams, PaginatedResponse } from '../types'
import type { DashboardStatsResponse, AccountListResponse } from '../api/types'
export function useS2AApi() {
const { s2aClient, isConnected } = useConfig()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const getDashboardStats = useCallback(async (): Promise<DashboardStats | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response: DashboardStatsResponse = await s2aClient.getDashboardStats()
return response
} catch (err) {
const message = err instanceof Error ? err.message : '获取统计数据失败'
setError(message)
return null
} finally {
setLoading(false)
}
}, [s2aClient])
const getAccounts = useCallback(
async (params: AccountListParams = {}): Promise<PaginatedResponse<S2AAccount> | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response: AccountListResponse = await s2aClient.getAccounts(params)
return {
data: response.data,
total: response.total,
page: response.page,
page_size: response.page_size,
total_pages: Math.ceil(response.total / response.page_size),
}
} catch (err) {
const message = err instanceof Error ? err.message : '获取账号列表失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
const createAccount = useCallback(
async (data: {
name: string
token: string
email?: string
concurrency?: number
priority?: number
groupIds?: number[]
proxyId?: number | null
}): Promise<S2AAccount | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response = await s2aClient.createAccount({
name: data.name,
platform: 'openai',
type: 'access_token',
credentials: {
access_token: data.token,
email: data.email,
},
concurrency: data.concurrency ?? 1,
priority: data.priority ?? 0,
group_ids: data.groupIds ?? [],
proxy_id: data.proxyId ?? null,
auto_pause_on_expired: true,
})
return response as S2AAccount
} catch (err) {
const message = err instanceof Error ? err.message : '创建账号失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
const deleteAccount = useCallback(
async (id: number): Promise<boolean> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return false
}
setLoading(true)
setError(null)
try {
await s2aClient.deleteAccount(id)
return true
} catch (err) {
const message = err instanceof Error ? err.message : '删除账号失败'
setError(message)
return false
} finally {
setLoading(false)
}
},
[s2aClient]
)
const testAccount = useCallback(
async (id: number): Promise<{ success: boolean; message?: string } | null> => {
if (!s2aClient) {
setError('S2A 客户端未配置')
return null
}
setLoading(true)
setError(null)
try {
const response = await s2aClient.testAccount(id)
return response
} catch (err) {
const message = err instanceof Error ? err.message : '测试账号失败'
setError(message)
return null
} finally {
setLoading(false)
}
},
[s2aClient]
)
return {
loading,
error,
isConnected,
getDashboardStats,
getAccounts,
createAccount,
deleteAccount,
testAccount,
clearError: () => setError(null),
}
}

416
frontend/src/index.css Normal file
View File

@@ -0,0 +1,416 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
@import 'tailwindcss';
/* TailwindCSS 4 Theme Configuration */
@theme {
/* Design System Colors */
--color-primary: #2563eb;
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-primary-950: #172554;
/* Success Color: Green-500 (#22C55E) */
--color-success: #22c55e;
--color-success-50: #f0fdf4;
--color-success-100: #dcfce7;
--color-success-200: #bbf7d0;
--color-success-300: #86efac;
--color-success-400: #4ade80;
--color-success-500: #22c55e;
--color-success-600: #16a34a;
--color-success-700: #15803d;
--color-success-800: #166534;
--color-success-900: #14532d;
--color-success-950: #052e16;
/* Warning Color: Yellow-500 (#EAB308) */
--color-warning: #eab308;
--color-warning-50: #fefce8;
--color-warning-100: #fef9c3;
--color-warning-200: #fef08a;
--color-warning-300: #fde047;
--color-warning-400: #facc15;
--color-warning-500: #eab308;
--color-warning-600: #ca8a04;
--color-warning-700: #a16207;
--color-warning-800: #854d0e;
--color-warning-900: #713f12;
--color-warning-950: #422006;
/* Error Color: Red-500 (#EF4444) */
--color-error: #ef4444;
--color-error-50: #fef2f2;
--color-error-100: #fee2e2;
--color-error-200: #fecaca;
--color-error-300: #fca5a5;
--color-error-400: #f87171;
--color-error-500: #ef4444;
--color-error-600: #dc2626;
--color-error-700: #b91c1c;
--color-error-800: #991b1b;
--color-error-900: #7f1d1d;
--color-error-950: #450a0a;
/* Background Colors: Slate */
--color-background-light: #f8fafc;
--color-background-dark: #0f172a;
--color-surface-light: #ffffff;
--color-surface-dark: #1e293b;
}
/* CSS Variables for runtime theming */
:root {
--color-bg: var(--color-background-light);
--color-surface: var(--color-surface-light);
--color-text: #1e293b;
--color-text-secondary: #64748b;
--color-border: #e2e8f0;
/* Glassmorphism */
--glass-bg: rgba(255, 255, 255, 0.8);
--glass-border: rgba(255, 255, 255, 0.5);
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
/* Gradients */
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
--gradient-success: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
--gradient-warning: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
--gradient-blue: linear-gradient(135deg, #667eea 0%, #5e94ff 100%);
}
/* Dark mode variables */
.dark {
--color-bg: var(--color-background-dark);
--color-surface: var(--color-surface-dark);
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-border: #334155;
/* Dark Glassmorphism */
--glass-bg: rgba(30, 41, 59, 0.8);
--glass-border: rgba(51, 65, 85, 0.5);
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
/* Base styles */
body {
font-family:
'Inter',
system-ui,
-apple-system,
sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
transition:
background-color 0.3s ease,
color 0.3s ease;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb {
background: #475569;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #64748b;
}
/* Utility classes for design system colors */
.bg-app {
background-color: var(--color-bg);
}
.bg-surface {
background-color: var(--color-surface);
}
.text-primary-color {
color: var(--color-text);
}
.text-secondary-color {
color: var(--color-text-secondary);
}
.border-app {
border-color: var(--color-border);
}
/* Glassmorphism Effect */
.glass {
background: var(--glass-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
}
.glass-card {
background: var(--glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
box-shadow: var(--glass-shadow);
border-radius: 16px;
}
/* Gradient backgrounds */
.gradient-primary {
background: var(--gradient-primary);
}
.gradient-success {
background: var(--gradient-success);
}
.gradient-blue {
background: var(--gradient-blue);
}
/* Animated gradient */
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.animated-gradient {
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #5e94ff);
background-size: 300% 300%;
animation: gradient-shift 8s ease infinite;
}
/* Pulse animation */
@keyframes pulse-glow {
0%,
100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4);
}
50% {
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
}
}
.pulse-glow {
animation: pulse-glow 2s infinite;
}
/* Fade in animation */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fadeIn {
animation: fadeIn 0.3s ease-out forwards;
}
/* Scale animation */
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
.animate-scaleIn {
animation: scaleIn 0.2s ease-out forwards;
}
/* Number counter animation */
@keyframes countUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-countUp {
animation: countUp 0.5s ease-out forwards;
}
/* Loading skeleton */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.skeleton {
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.dark .skeleton {
background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%);
background-size: 200% 100%;
}
/* Status indicator */
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background-color: #22c55e;
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
}
.status-dot.offline {
background-color: #ef4444;
}
.status-dot.warning {
background-color: #eab308;
}
/* Progress bar gradient */
.progress-gradient {
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
background-size: 200% 100%;
animation: gradient-shift 3s ease infinite;
}
/* Card hover effect */
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
}
.dark .card-hover:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
/* Button hover effects */
.btn-glow:hover {
box-shadow: 0 0 20px rgba(37, 99, 235, 0.4);
}
/* Responsive utilities */
@media (max-width: 768px) {
.hide-mobile {
display: none !important;
}
}
@media (min-width: 769px) {
.hide-desktop {
display: none !important;
}
}
/* Toast notifications */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 9999;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
/* Focus ring */
.focus-ring:focus {
outline: none;
box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-primary-500);
}
/* Stat card special effects */
.stat-card {
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 4px;
background: var(--gradient-primary);
opacity: 0;
transition: opacity 0.3s ease;
}
.stat-card:hover::before {
opacity: 1;
}

18
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ToastProvider, ErrorBoundary } from './components/common'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter>
<ErrorBoundary>
<ToastProvider>
<App />
</ToastProvider>
</ErrorBoundary>
</BrowserRouter>
</StrictMode>
)

View File

@@ -0,0 +1,263 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { RefreshCw, Search, Settings, ChevronLeft, ChevronRight } from 'lucide-react'
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Input,
Select,
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
StatusBadge,
} from '../components/common'
import { useS2AApi } from '../hooks/useS2AApi'
import { useConfig } from '../hooks/useConfig'
import type { S2AAccount, AccountListParams } from '../types'
import { formatDateTime } from '../utils/format'
export default function Accounts() {
const { config } = useConfig()
const { getAccounts, loading, error } = useS2AApi()
const [accounts, setAccounts] = useState<S2AAccount[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('')
const [refreshing, setRefreshing] = useState(false)
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
const fetchAccounts = useCallback(async () => {
if (!hasConfig) return
setRefreshing(true)
const params: AccountListParams = {
page,
page_size: pageSize,
platform: 'openai',
}
if (search) params.search = search
if (statusFilter) params.status = statusFilter as 'active' | 'inactive' | 'error'
const result = await getAccounts(params)
if (result) {
setAccounts(result.data)
setTotal(result.total)
}
setRefreshing(false)
}, [hasConfig, page, pageSize, search, statusFilter, getAccounts])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
setPage(1)
fetchAccounts()
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchAccounts}
disabled={!hasConfig || refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200"> S2A </p>
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
S2A API
</p>
<Link to="/config" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Filters */}
{hasConfig && (
<Card>
<CardContent>
<form onSubmit={handleSearch} className="flex flex-wrap items-end gap-4">
<div className="flex-1 min-w-[200px]">
<Input
placeholder="搜索账号名称..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full"
/>
</div>
<div className="w-40">
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setPage(1)
}}
options={[
{ value: '', label: '全部状态' },
{ value: 'active', label: '正常' },
{ value: 'inactive', label: '停用' },
{ value: 'error', label: '错误' },
]}
/>
</div>
<Button type="submit" icon={<Search className="h-4 w-4" />}>
</Button>
</form>
</CardContent>
</Card>
)}
{/* Error */}
{error && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
<p className="text-red-700 dark:text-red-400">{error}</p>
</div>
)}
{/* Account List */}
{hasConfig && (
<Card>
<CardHeader>
<CardTitle></CardTitle>
<span className="text-sm text-slate-500 dark:text-slate-400"> {total} </span>
</CardHeader>
<CardContent>
{loading && accounts.length === 0 ? (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div
key={i}
className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse"
/>
))}
</div>
) : accounts.length === 0 ? (
<div className="text-center py-12">
<p className="text-slate-500 dark:text-slate-400"></p>
</div>
) : (
<>
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow hoverable={false}>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell>
<span className="font-mono text-sm">{account.id}</span>
</TableCell>
<TableCell>
<span className="font-medium">{account.name}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400">
{account.type}
</span>
</TableCell>
<TableCell>
<StatusBadge status={account.status} />
</TableCell>
<TableCell className="text-right">
{account.current_concurrency !== undefined ? (
<span>
{account.current_concurrency}/{account.concurrency}
</span>
) : (
account.concurrency
)}
</TableCell>
<TableCell className="text-right">{account.priority}</TableCell>
<TableCell>
<span className="text-sm text-slate-500 dark:text-slate-400">
{formatDateTime(account.created_at)}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4">
<p className="text-sm text-slate-500 dark:text-slate-400">
{page} {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
icon={<ChevronLeft className="h-4 w-4" />}
>
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
icon={<ChevronRight className="h-4 w-4" />}
>
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,301 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import {
Server,
Mail,
ChevronRight,
Settings,
Save,
RefreshCw,
Globe,
ToggleLeft,
ToggleRight
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
export default function Config() {
const { config, isConnected } = useConfig()
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
// 编辑状态
const [editS2ABase, setEditS2ABase] = useState('')
const [editS2AKey, setEditS2AKey] = useState('')
const [editConcurrency, setEditConcurrency] = useState(2)
const [editPriority, setEditPriority] = useState(0)
const [editGroupIds, setEditGroupIds] = useState('')
const [proxyEnabled, setProxyEnabled] = useState(false)
const [proxyAddress, setProxyAddress] = useState('')
// 获取服务器配置
const fetchServerConfig = async () => {
setLoading(true)
try {
const res = await fetch('/api/config')
const data = await res.json()
if (data.success) {
setEditS2ABase(data.data.s2a_api_base || '')
setEditS2AKey(data.data.s2a_admin_key || '')
setEditConcurrency(data.data.concurrency || 2)
setEditPriority(data.data.priority || 0)
setEditGroupIds(data.data.group_ids?.join(', ') || '')
setProxyEnabled(data.data.proxy_enabled || false)
setProxyAddress(data.data.default_proxy || '')
}
} catch (error) {
console.error('Failed to fetch config:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchServerConfig()
}, [])
// 保存配置
const handleSave = async () => {
setSaving(true)
setMessage(null)
try {
// 解析 group_ids
const groupIds = editGroupIds
.split(',')
.map(s => parseInt(s.trim()))
.filter(n => !isNaN(n))
const res = await fetch('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
s2a_api_base: editS2ABase,
s2a_admin_key: editS2AKey,
concurrency: editConcurrency,
priority: editPriority,
group_ids: groupIds,
proxy_enabled: proxyEnabled,
default_proxy: proxyAddress,
}),
})
const data = await res.json()
if (data.success) {
setMessage({ type: 'success', text: '配置已保存' })
fetchServerConfig()
} else {
setMessage({ type: 'error', text: data.error || '保存失败' })
}
} catch (error) {
setMessage({ type: 'error', text: '网络错误' })
} finally {
setSaving(false)
}
}
const configItems = [
{
to: '/config/s2a',
icon: Server,
title: 'S2A 高级配置',
description: 'S2A 号池详细设置和测试',
status: isConnected ? '已连接' : '未连接',
statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500',
},
{
to: '/config/email',
icon: Mail,
title: '邮箱服务配置',
description: '配置邮箱服务用于自动注册',
status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置',
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
},
]
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Settings className="h-7 w-7 text-slate-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<Button
variant="outline"
size="sm"
onClick={fetchServerConfig}
disabled={loading}
icon={<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
{/* Message */}
{message && (
<div className={`p-3 rounded-lg text-sm ${message.type === 'success'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}>
{message.text}
</div>
)}
{/* Quick Config Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* S2A Config */}
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A API
</label>
<Input
value={editS2ABase}
onChange={(e) => setEditS2ABase(e.target.value)}
placeholder="https://your-s2a-server.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
S2A Admin Key
</label>
<Input
type="password"
value={editS2AKey}
onChange={(e) => setEditS2AKey(e.target.value)}
placeholder="admin-xxxxxx"
/>
</div>
</div>
{/* Pooling Config */}
<div className="grid gap-4 md:grid-cols-3">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editConcurrency}
onChange={(e) => setEditConcurrency(parseInt(e.target.value) || 1)}
min={1}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
</label>
<Input
type="number"
value={editPriority}
onChange={(e) => setEditPriority(parseInt(e.target.value) || 0)}
min={0}
max={100}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
ID ()
</label>
<Input
value={editGroupIds}
onChange={(e) => setEditGroupIds(e.target.value)}
placeholder="1, 2, 3"
/>
</div>
</div>
{/* Proxy Config */}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Globe className="h-5 w-5 text-orange-500" />
<span className="font-medium text-slate-700 dark:text-slate-300"></span>
</div>
<button
onClick={() => setProxyEnabled(!proxyEnabled)}
className="flex items-center gap-2 text-sm"
>
{proxyEnabled ? (
<>
<ToggleRight className="h-6 w-6 text-green-500" />
<span className="text-green-600 dark:text-green-400"></span>
</>
) : (
<>
<ToggleLeft className="h-6 w-6 text-slate-400" />
<span className="text-slate-500"></span>
</>
)}
</button>
</div>
<Input
value={proxyAddress}
onChange={(e) => setProxyAddress(e.target.value)}
placeholder="http://127.0.0.1:7890"
disabled={!proxyEnabled}
className={!proxyEnabled ? 'opacity-50' : ''}
/>
<p className="text-xs text-slate-500 mt-1">
</p>
</div>
{/* Save Button */}
<div className="flex justify-end pt-2">
<Button
onClick={handleSave}
disabled={saving}
icon={<Save className="h-4 w-4" />}
>
{saving ? '保存中...' : '保存配置'}
</Button>
</div>
</CardContent>
</Card>
{/* Sub Config Cards */}
<div className="grid gap-4 md:grid-cols-2">
{configItems.map((item) => (
<Link key={item.to} to={item.to} className="block group">
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
<CardContent className="flex items-center gap-4 py-4">
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
<div className="flex-1">
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
</div>
<span className={`text-sm font-medium ${item.statusColor}`}>
{item.status}
</span>
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
</CardContent>
</Card>
</Link>
))}
</div>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p></p>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,162 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Upload, RefreshCw, Settings } from 'lucide-react'
import { PoolStatus, RecentRecords } from '../components/dashboard'
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
import { useS2AApi } from '../hooks/useS2AApi'
import { useRecords } from '../hooks/useRecords'
import { useConfig } from '../hooks/useConfig'
import type { DashboardStats } from '../types'
import { formatNumber, formatCurrency } from '../utils/format'
export default function Dashboard() {
const { getDashboardStats, loading, error, isConnected } = useS2AApi()
const { records } = useRecords()
const { config } = useConfig()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [refreshing, setRefreshing] = useState(false)
const fetchStats = async () => {
if (!isConnected) return
setRefreshing(true)
const data = await getDashboardStats()
if (data) {
setStats(data)
}
setRefreshing(false)
}
useEffect(() => {
fetchStats()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isConnected])
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchStats}
disabled={!isConnected || refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
<Link to="/upload">
<Button size="sm" icon={<Upload className="h-4 w-4" />}>
</Button>
</Link>
</div>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200"> S2A </p>
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
S2A API
</p>
<Link to="/config" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Pool Status */}
<PoolStatus stats={stats} loading={loading || refreshing} error={error} />
{/* Stats Summary */}
{stats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.today_requests)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">Token </p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.today_tokens)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(stats.today_cost)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400">TPM</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.tpm)}
</p>
</div>
</div>
</CardContent>
</Card>
<Card hoverable>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.total_requests)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"> Token</p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatNumber(stats.total_tokens)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{formatCurrency(stats.total_cost)}
</p>
</div>
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
{stats.overload_accounts}
</p>
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Recent Records */}
<RecentRecords records={records} loading={loading} />
</div>
)
}

View File

@@ -0,0 +1,251 @@
import { useState, useEffect } from 'react'
import { CheckCircle, Save, Mail, Plus, Trash2, TestTube, Loader2, Settings, Server } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
import type { MailServiceConfig } from '../types'
const API_BASE = 'http://localhost:8088'
export default function EmailConfig() {
const { config, updateEmailConfig } = useConfig()
const [saved, setSaved] = useState(false)
const [services, setServices] = useState<MailServiceConfig[]>(config.email?.services || [])
const [testingIndex, setTestingIndex] = useState<number | null>(null)
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({})
// 同步配置变化
useEffect(() => {
if (config.email?.services) {
setServices(config.email.services)
}
}, [config.email?.services])
const handleSave = async () => {
// 保存到前端 context
updateEmailConfig({ services })
// 保存到后端
try {
const res = await fetch(`${API_BASE}/api/mail/services`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ services }),
})
if (res.ok) {
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
} catch (error) {
console.error('保存失败:', error)
}
}
const handleAddService = () => {
setServices([
...services,
{
name: `邮箱服务 ${services.length + 1}`,
apiBase: '',
apiToken: '',
domain: '',
},
])
}
const handleRemoveService = (index: number) => {
if (services.length <= 1) {
return // 至少保留一个服务
}
const newServices = services.filter((_, i) => i !== index)
setServices(newServices)
}
const handleUpdateService = (index: number, updates: Partial<MailServiceConfig>) => {
const newServices = [...services]
newServices[index] = { ...newServices[index], ...updates }
setServices(newServices)
}
const handleTestService = async (index: number) => {
const service = services[index]
setTestingIndex(index)
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '测试中...' } }))
try {
const res = await fetch(`${API_BASE}/api/mail/services/test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_base: service.apiBase,
api_token: service.apiToken,
domain: service.domain,
email_path: service.emailPath,
}),
})
const data = await res.json()
if (res.ok && data.code === 0) {
setTestResults(prev => ({ ...prev, [index]: { success: true, message: '连接成功' } }))
} else {
setTestResults(prev => ({ ...prev, [index]: { success: false, message: data.message || '连接失败' } }))
}
} catch (error) {
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '网络错误' } }))
} finally {
setTestingIndex(null)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Mail className="h-7 w-7 text-purple-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleAddService}
icon={<Plus className="h-4 w-4" />}
>
</Button>
<Button
onClick={handleSave}
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
>
{saved ? '已保存' : '保存配置'}
</Button>
</div>
</div>
{/* Service Cards */}
<div className="space-y-4">
{services.map((service, index) => (
<Card key={index}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5 text-purple-500" />
<span>{service.name || `服务 ${index + 1}`}</span>
<span className="text-sm font-normal text-slate-500">
(@{service.domain || '未设置域名'})
</span>
</CardTitle>
<div className="flex items-center gap-2">
{testResults[index] && (
<span className={`text-sm ${testResults[index].success ? 'text-green-500' : 'text-red-500'}`}>
{testResults[index].message}
</span>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleTestService(index)}
disabled={testingIndex === index || !service.apiBase}
icon={testingIndex === index ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveService(index)}
disabled={services.length <= 1}
icon={<Trash2 className="h-4 w-4" />}
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
>
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="服务名称"
placeholder="如:主邮箱服务"
value={service.name}
onChange={(e) => handleUpdateService(index, { name: e.target.value })}
hint="用于识别不同的邮箱服务"
/>
<Input
label="邮箱域名"
placeholder="如example.com"
value={service.domain}
onChange={(e) => handleUpdateService(index, { domain: e.target.value })}
hint="生成邮箱地址的域名后缀"
/>
</div>
<Input
label="API 地址"
placeholder="https://mail.example.com"
value={service.apiBase}
onChange={(e) => handleUpdateService(index, { apiBase: e.target.value })}
hint="邮箱服务 API 地址"
/>
<Input
label="API Token"
type="password"
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
value={service.apiToken}
onChange={(e) => handleUpdateService(index, { apiToken: e.target.value })}
hint="邮箱服务的 API 认证令牌"
/>
{/* Advanced Settings (Collapsed by default) */}
<details className="group">
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 flex items-center gap-1">
<Settings className="h-4 w-4" />
</summary>
<div className="mt-4 space-y-4 pl-5 border-l-2 border-slate-200 dark:border-slate-700">
<Input
label="邮件列表 API 路径"
placeholder="/api/public/emailList (默认)"
value={service.emailPath || ''}
onChange={(e) => handleUpdateService(index, { emailPath: e.target.value })}
hint="获取邮件列表的 API 路径"
/>
<Input
label="创建用户 API 路径"
placeholder="/api/public/addUser (默认)"
value={service.addUserApi || ''}
onChange={(e) => handleUpdateService(index, { addUserApi: e.target.value })}
hint="创建邮箱用户的 API 路径"
/>
</div>
</details>
</CardContent>
</Card>
))}
</div>
{/* Help Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p className="font-medium mb-2"></p>
<ul className="list-disc list-inside space-y-1">
<li>使</li>
<li> API Token </li>
<li> xxx@esyteam.edu.kg</li>
<li></li>
<li>使</li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,588 @@
import { useState, useEffect, useCallback } from 'react'
import {
Target,
Activity,
RefreshCw,
Play,
Pause,
Shield,
TrendingUp,
TrendingDown,
Zap,
AlertTriangle,
CheckCircle,
Clock,
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
import type { DashboardStats } from '../types'
interface PoolStatus {
target: number
current: number
deficit: number
last_check: string
auto_add: boolean
min_interval: number
last_auto_add: string
polling_enabled: boolean
polling_interval: number
}
interface HealthCheckResult {
account_id: number
email: string
status: string
checked_at: string
error?: string
auto_paused?: boolean
}
interface AutoAddLog {
timestamp: string
target: number
current: number
deficit: number
action: string
success: number
failed: number
message: string
}
export default function Monitor() {
const { config } = useConfig()
const [stats, setStats] = useState<DashboardStats | null>(null)
const [poolStatus, setPoolStatus] = useState<PoolStatus | null>(null)
const [healthResults, setHealthResults] = useState<HealthCheckResult[]>([])
const [autoAddLogs, setAutoAddLogs] = useState<AutoAddLog[]>([])
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
const [checkingHealth, setCheckingHealth] = useState(false)
const [autoPauseEnabled, setAutoPauseEnabled] = useState(false)
// 配置表单状态
const [targetInput, setTargetInput] = useState(50)
const [autoAdd, setAutoAdd] = useState(false)
const [minInterval, setMinInterval] = useState(300)
const [pollingEnabled, setPollingEnabled] = useState(false)
const [pollingInterval, setPollingInterval] = useState(60)
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
// 获取号池状态
const fetchPoolStatus = useCallback(async () => {
try {
const res = await fetch(`${backendUrl}/api/pool/status`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setPoolStatus(data.data)
setTargetInput(data.data.target)
setAutoAdd(data.data.auto_add)
setMinInterval(data.data.min_interval)
setPollingEnabled(data.data.polling_enabled)
setPollingInterval(data.data.polling_interval)
}
}
} catch (e) {
console.error('获取号池状态失败:', e)
}
}, [backendUrl])
// 刷新 S2A 统计
const refreshStats = useCallback(async () => {
setRefreshing(true)
try {
const res = await fetch(`${backendUrl}/api/pool/refresh`, { method: 'POST' })
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setStats(data.data)
}
}
await fetchPoolStatus()
} catch (e) {
console.error('刷新统计失败:', e)
}
setRefreshing(false)
}, [backendUrl, fetchPoolStatus])
// 设置目标
const handleSetTarget = async () => {
setLoading(true)
try {
await fetch(`${backendUrl}/api/pool/target`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
target: targetInput,
auto_add: autoAdd,
min_interval: minInterval,
}),
})
await fetchPoolStatus()
} catch (e) {
console.error('设置目标失败:', e)
}
setLoading(false)
}
// 控制轮询
const handleTogglePolling = async () => {
setLoading(true)
try {
await fetch(`${backendUrl}/api/pool/polling`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: !pollingEnabled,
interval: pollingInterval,
}),
})
setPollingEnabled(!pollingEnabled)
await fetchPoolStatus()
} catch (e) {
console.error('控制轮询失败:', e)
}
setLoading(false)
}
// 健康检查
const handleHealthCheck = async (autoPause: boolean = false) => {
setCheckingHealth(true)
try {
await fetch(`${backendUrl}/api/health-check/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_pause: autoPause }),
})
// 等待一会儿再获取结果
setTimeout(async () => {
const res = await fetch(`${backendUrl}/api/health-check/results`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setHealthResults(data.data || [])
}
}
setCheckingHealth(false)
}, 5000)
} catch (e) {
console.error('健康检查失败:', e)
setCheckingHealth(false)
}
}
// 获取自动补号日志
const fetchAutoAddLogs = async () => {
try {
const res = await fetch(`${backendUrl}/api/auto-add/logs`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setAutoAddLogs(data.data || [])
}
}
} catch (e) {
console.error('获取日志失败:', e)
}
}
// 初始化
useEffect(() => {
fetchPoolStatus()
refreshStats()
fetchAutoAddLogs()
}, [fetchPoolStatus, refreshStats])
// 计算健康状态
const healthySummary = healthResults.reduce(
(acc, r) => {
if (r.status === 'active' && !r.error) acc.healthy++
else acc.unhealthy++
return acc
},
{ healthy: 0, unhealthy: 0 }
)
const deficit = poolStatus ? Math.max(0, poolStatus.target - poolStatus.current) : 0
const healthPercent = poolStatus && poolStatus.target > 0
? Math.min(100, (poolStatus.current / poolStatus.target) * 100)
: 0
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={refreshStats}
disabled={refreshing}
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
</div>
{/* 状态概览卡片 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* 当前/目标 */}
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"> / </p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 animate-countUp">
{poolStatus?.current ?? '-'} / {poolStatus?.target ?? '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Target className="h-6 w-6 text-blue-600 dark:text-blue-400" />
</div>
</div>
<div className="mt-4">
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full progress-gradient rounded-full transition-all duration-500"
style={{ width: `${healthPercent}%` }}
/>
</div>
</div>
</CardContent>
</Card>
{/* 需补充 */}
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className={`text-2xl font-bold animate-countUp ${deficit > 0 ? 'text-orange-500' : 'text-green-500'
}`}>
{deficit}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${deficit > 0 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-green-100 dark:bg-green-900/30'
}`}>
{deficit > 0 ? (
<TrendingDown className="h-6 w-6 text-orange-500" />
) : (
<TrendingUp className="h-6 w-6 text-green-500" />
)}
</div>
</div>
{deficit > 0 && (
<p className="mt-2 text-xs text-orange-500 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
</p>
)}
</CardContent>
</Card>
{/* 轮询状态 */}
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{pollingEnabled ? '运行中' : '已停止'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${pollingEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
{pollingEnabled ? (
<Activity className="h-6 w-6 text-green-500 animate-pulse" />
) : (
<Pause className="h-6 w-6 text-slate-400" />
)}
</div>
</div>
{pollingEnabled && (
<p className="mt-2 text-xs text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{pollingInterval}
</p>
)}
</CardContent>
</Card>
{/* 自动补号 */}
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{autoAdd ? '已启用' : '已禁用'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${autoAdd ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
<Zap className={`h-6 w-6 ${autoAdd ? 'text-purple-500' : 'text-slate-400'}`} />
</div>
</div>
</CardContent>
</Card>
</div>
{/* 配置面板 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 目标设置 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="目标账号数"
type="number"
min={1}
max={1000}
value={targetInput}
onChange={(e) => setTargetInput(Number(e.target.value))}
hint="期望保持的活跃账号数量"
/>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="autoAdd"
checked={autoAdd}
onChange={(e) => setAutoAdd(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="autoAdd" className="text-sm text-slate-700 dark:text-slate-300">
</label>
</div>
<Input
label="最小间隔 (秒)"
type="number"
min={60}
max={3600}
value={minInterval}
onChange={(e) => setMinInterval(Number(e.target.value))}
hint="两次自动补号的最小间隔"
disabled={!autoAdd}
/>
<Button onClick={handleSetTarget} loading={loading} className="w-full">
</Button>
</CardContent>
</Card>
{/* 轮询控制 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-5 w-5 text-green-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="轮询间隔 (秒)"
type="number"
min={10}
max={300}
value={pollingInterval}
onChange={(e) => setPollingInterval(Number(e.target.value))}
hint="自动刷新号池状态的间隔时间"
/>
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-slate-900 dark:text-slate-100"></p>
<p className="text-sm text-slate-500">
{pollingEnabled ? '正在实时监控号池状态' : '监控已暂停'}
</p>
</div>
<div className={`status-dot ${pollingEnabled ? 'online' : 'offline'}`} />
</div>
</div>
<Button
onClick={handleTogglePolling}
loading={loading}
variant={pollingEnabled ? 'outline' : 'primary'}
className="w-full"
icon={pollingEnabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
>
{pollingEnabled ? '停止监控' : '启动监控'}
</Button>
</CardContent>
</Card>
</div>
{/* 健康检查 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5 text-purple-500" />
</CardTitle>
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
<input
type="checkbox"
checked={autoPauseEnabled}
onChange={(e) => setAutoPauseEnabled(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500"
/>
</label>
<Button
variant="outline"
size="sm"
onClick={() => handleHealthCheck(autoPauseEnabled)}
disabled={checkingHealth}
loading={checkingHealth}
icon={<Shield className="h-4 w-4" />}
>
{checkingHealth ? '检查中...' : '开始检查'}
</Button>
</div>
</CardHeader>
<CardContent>
{healthResults.length > 0 ? (
<>
{/* 统计 */}
<div className="flex gap-4 mb-4">
<div className="flex items-center gap-2 text-green-600">
<CheckCircle className="h-4 w-4" />
<span>: {healthySummary.healthy}</span>
</div>
<div className="flex items-center gap-2 text-red-500">
<AlertTriangle className="h-4 w-4" />
<span>: {healthySummary.unhealthy}</span>
</div>
</div>
{/* 结果列表 */}
<div className="max-h-64 overflow-y-auto space-y-2">
{healthResults.map((result) => (
<div
key={result.account_id}
className={`flex items-center justify-between p-3 rounded-lg ${result.error
? 'bg-red-50 dark:bg-red-900/20'
: 'bg-green-50 dark:bg-green-900/20'
}`}
>
<div>
<p className="font-medium text-slate-900 dark:text-slate-100">
{result.email}
</p>
<p className="text-xs text-slate-500">ID: {result.account_id}</p>
</div>
<div className="text-right">
<p className={`text-sm font-medium ${result.error ? 'text-red-500' : 'text-green-600'
}`}>
{result.status}
</p>
{result.error && (
<p className="text-xs text-red-400">{result.error}</p>
)}
</div>
</div>
))}
</div>
</>
) : (
<div className="text-center py-8 text-slate-500">
<Shield className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>"开始检查"</p>
</div>
)}
</CardContent>
</Card>
{/* S2A 实时统计 */}
{stats && (
<Card>
<CardHeader>
<CardTitle>S2A </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
<p className="text-2xl font-bold text-blue-600">{stats.total_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
<p className="text-2xl font-bold text-green-600">{stats.normal_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
<p className="text-2xl font-bold text-red-500">{stats.error_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
<div className="text-center p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20">
<p className="text-2xl font-bold text-orange-500">{stats.ratelimit_accounts}</p>
<p className="text-sm text-slate-500"></p>
</div>
</div>
</CardContent>
</Card>
)}
{/* 自动补号日志 */}
{autoAddLogs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5 text-slate-500" />
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={fetchAutoAddLogs}
icon={<RefreshCw className="h-4 w-4" />}
>
</Button>
</CardHeader>
<CardContent>
<div className="max-h-64 overflow-y-auto space-y-2">
{[...autoAddLogs].reverse().slice(0, 20).map((log, idx) => (
<div
key={idx}
className={`flex items-center justify-between p-3 rounded-lg text-sm ${log.action.includes('trigger') || log.action.includes('decrease')
? 'bg-orange-50 dark:bg-orange-900/20'
: log.action.includes('increase')
? 'bg-green-50 dark:bg-green-900/20'
: 'bg-slate-50 dark:bg-slate-800/50'
}`}
>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-400">
{new Date(log.timestamp).toLocaleTimeString()}
</span>
<span className="font-medium text-slate-900 dark:text-slate-100">
{log.message}
</span>
</div>
<div className="text-xs text-slate-500">
{log.current} / {log.target}
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
)
}

View File

@@ -0,0 +1,112 @@
import { useState, useMemo } from 'react'
import { Trash2, Calendar } from 'lucide-react'
import { RecordList, RecordStats } from '../components/records'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useRecords } from '../hooks/useRecords'
export default function Records() {
const { records, deleteRecord, clearRecords, getStats } = useRecords()
const [startDate, setStartDate] = useState('')
const [endDate, setEndDate] = useState('')
const stats = useMemo(() => getStats(), [getStats])
const filteredRecords = useMemo(() => {
if (!startDate && !endDate) return records
return records.filter((record) => {
const recordDate = new Date(record.timestamp)
const start = startDate ? new Date(startDate) : null
const end = endDate ? new Date(endDate + 'T23:59:59') : null
if (start && recordDate < start) return false
if (end && recordDate > end) return false
return true
})
}, [records, startDate, endDate])
const handleClearFilter = () => {
setStartDate('')
setEndDate('')
}
const handleClearAll = () => {
if (window.confirm('确定要清空所有记录吗?此操作不可恢复。')) {
clearRecords()
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100"></h1>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
</div>
{records.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleClearAll}
icon={<Trash2 className="h-4 w-4" />}
>
</Button>
)}
</div>
{/* Stats */}
<RecordStats stats={stats} />
{/* Filter */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
</CardTitle>
{(startDate || endDate) && (
<Button variant="ghost" size="sm" onClick={handleClearFilter}>
</Button>
)}
</CardHeader>
<CardContent>
<div className="flex flex-wrap items-end gap-4">
<div className="w-40">
<Input
label="开始日期"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
</div>
<div className="w-40">
<Input
label="结束日期"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</div>
<div className="text-sm text-slate-500 dark:text-slate-400">
{filteredRecords.length}
{filteredRecords.length !== records.length && <span className="ml-1">()</span>}
</div>
</div>
</CardContent>
</Card>
{/* Record List */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<RecordList records={filteredRecords} onDelete={deleteRecord} />
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,241 @@
import { useState } from 'react'
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X } from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
export default function S2AConfig() {
const {
config,
updateS2AConfig,
updatePoolingConfig,
testConnection,
isConnected,
} = useConfig()
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<boolean | null>(null)
const [saved, setSaved] = useState(false)
// Local form state - S2A 连接
const [s2aApiBase, setS2aApiBase] = useState(config.s2a.apiBase)
const [s2aAdminKey, setS2aAdminKey] = useState(config.s2a.adminKey)
// Local form state - 入库设置
const [poolingConcurrency, setPoolingConcurrency] = useState(config.pooling.concurrency)
const [poolingPriority, setPoolingPriority] = useState(config.pooling.priority)
const [groupIds, setGroupIds] = useState<number[]>(config.pooling.groupIds || [])
const [newGroupId, setNewGroupId] = useState('')
const handleTestConnection = async () => {
// Save first
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
setTesting(true)
setTestResult(null)
// Wait a bit for the client to be recreated
await new Promise((resolve) => setTimeout(resolve, 100))
const result = await testConnection()
setTestResult(result)
setTesting(false)
}
const handleSave = () => {
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
updatePoolingConfig({
concurrency: poolingConcurrency,
priority: poolingPriority,
groupIds: groupIds,
})
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const handleAddGroupId = () => {
const id = parseInt(newGroupId, 10)
if (!isNaN(id) && !groupIds.includes(id)) {
setGroupIds([...groupIds, id])
setNewGroupId('')
}
}
const handleRemoveGroupId = (id: number) => {
setGroupIds(groupIds.filter(g => g !== id))
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<Server className="h-7 w-7 text-blue-500" />
S2A
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400"> S2A </p>
</div>
<Button
onClick={handleSave}
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
>
{saved ? '已保存' : '保存配置'}
</Button>
</div>
{/* S2A Connection */}
<Card>
<CardHeader>
<CardTitle>S2A </CardTitle>
<div className="flex items-center gap-2">
{isConnected ? (
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
<CheckCircle className="h-4 w-4" />
</span>
) : (
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
<XCircle className="h-4 w-4" />
</span>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<Input
label="S2A API 地址"
placeholder="http://localhost:8080"
value={s2aApiBase}
onChange={(e) => setS2aApiBase(e.target.value)}
hint="S2A 服务的 API 地址,例如 http://localhost:8080"
/>
<Input
label="Admin API Key"
type="password"
placeholder="admin-xxxxxxxxxxxxxxxx"
value={s2aAdminKey}
onChange={(e) => setS2aAdminKey(e.target.value)}
hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取"
/>
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !s2aApiBase || !s2aAdminKey}
icon={
testing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)
}
>
{testing ? '测试中...' : '测试连接'}
</Button>
{testResult !== null && (
<span
className={`text-sm ${testResult
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
}`}
>
{testResult ? '连接成功' : '连接失败'}
</span>
)}
</div>
</CardContent>
</Card>
{/* Pooling Settings */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="默认并发数"
type="number"
min={1}
max={10}
value={poolingConcurrency}
onChange={(e) => setPoolingConcurrency(Number(e.target.value))}
hint="账号的默认并发请求数"
/>
<Input
label="默认优先级"
type="number"
min={0}
max={100}
value={poolingPriority}
onChange={(e) => setPoolingPriority(Number(e.target.value))}
hint="账号的默认优先级,数值越大优先级越高"
/>
</div>
{/* Group IDs */}
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
ID
</label>
<div className="flex flex-wrap gap-2">
{groupIds.map(id => (
<span
key={id}
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
>
{id}
<button
onClick={() => handleRemoveGroupId(id)}
className="hover:text-red-500 transition-colors"
>
<X className="h-3 w-3" />
</button>
</span>
))}
{groupIds.length === 0 && (
<span className="text-sm text-slate-400"></span>
)}
</div>
<div className="flex gap-2 mt-2">
<Input
placeholder="输入分组 ID"
type="number"
min={1}
value={newGroupId}
onChange={(e) => setNewGroupId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()}
/>
<Button
variant="outline"
onClick={handleAddGroupId}
disabled={!newGroupId}
icon={<Plus className="h-4 w-4" />}
>
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
</p>
</div>
</CardContent>
</Card>
{/* Info */}
<Card>
<CardContent>
<div className="text-sm text-slate-500 dark:text-slate-400">
<p className="font-medium mb-2"></p>
<ul className="list-disc list-inside space-y-1">
<li>S2A API S2A URL</li>
<li>Admin API Key </li>
<li></li>
<li> ID </li>
</ul>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,483 @@
import { useState, useEffect, useCallback } from 'react'
import {
Users,
Play,
Square,
RefreshCw,
Settings,
CheckCircle,
Clock,
Upload,
Loader2,
AlertTriangle,
} from 'lucide-react'
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
import { useConfig } from '../hooks/useConfig'
interface Owner {
email: string
password: string
token: string
}
interface TeamResult {
team_index: number
owner_email: string
team_id: string
registered: number
added_to_s2a: number
member_emails: string[]
errors: string[]
duration_ms: number
}
interface ProcessStatus {
running: boolean
started_at: string
total_teams: number
completed: number
results: TeamResult[]
elapsed_ms: number
}
export default function TeamProcess() {
const { config } = useConfig()
const [owners, setOwners] = useState<Owner[]>([])
const [status, setStatus] = useState<ProcessStatus | null>(null)
const [loading, setLoading] = useState(false)
const [polling, setPolling] = useState(false)
// 配置
const [membersPerTeam, setMembersPerTeam] = useState(4)
const [concurrentTeams, setConcurrentTeams] = useState(2)
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
const [headless, setHeadless] = useState(true)
const [proxy, setProxy] = useState('')
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
// 获取状态
const fetchStatus = useCallback(async () => {
try {
const res = await fetch(`${backendUrl}/api/team/status`)
if (res.ok) {
const data = await res.json()
if (data.code === 0) {
setStatus(data.data)
if (!data.data.running) {
setPolling(false)
}
}
}
} catch (e) {
console.error('获取状态失败:', e)
}
}, [backendUrl])
// 轮询状态
useEffect(() => {
if (polling) {
const interval = setInterval(fetchStatus, 2000)
return () => clearInterval(interval)
}
}, [polling, fetchStatus])
// 初始化
useEffect(() => {
fetchStatus()
}, [fetchStatus])
// 上传账号文件
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
try {
const text = await file.text()
const data = JSON.parse(text)
const parsed = Array.isArray(data) ? data : [data]
const validOwners = parsed.filter((a: Record<string, unknown>) =>
(a.email || a.account) && a.password && (a.token || a.access_token)
).map((a: Record<string, unknown>) => ({
email: (a.email || a.account) as string,
password: a.password as string,
token: (a.token || a.access_token) as string,
}))
setOwners(validOwners)
setConcurrentTeams(Math.min(validOwners.length, 2))
} catch (err) {
alert('文件解析失败,请确保是有效的 JSON 格式')
}
}
// 启动处理
const handleStart = async () => {
if (owners.length === 0) {
alert('请先上传账号文件')
return
}
setLoading(true)
try {
const res = await fetch(`${backendUrl}/api/team/process`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
owners: owners.slice(0, concurrentTeams),
members_per_team: membersPerTeam,
concurrent_teams: concurrentTeams,
browser_type: browserType,
headless,
proxy,
}),
})
if (res.ok) {
setPolling(true)
fetchStatus()
} else {
const data = await res.json()
alert(data.message || '启动失败')
}
} catch (e) {
console.error('启动失败:', e)
alert('启动失败')
}
setLoading(false)
}
// 停止处理
const handleStop = async () => {
try {
await fetch(`${backendUrl}/api/team/stop`, { method: 'POST' })
setPolling(false)
fetchStatus()
} catch (e) {
console.error('停止失败:', e)
}
}
const isRunning = status?.running
// 计算统计
const totalRegistered = status?.results.reduce((sum, r) => sum + r.registered, 0) || 0
const totalS2A = status?.results.reduce((sum, r) => sum + r.added_to_s2a, 0) || 0
const expectedTotal = (status?.total_teams || 0) * membersPerTeam
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
Team
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Team S2A
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={fetchStatus}
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
>
</Button>
</div>
</div>
{/* 状态概览 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className={`text-lg font-bold ${isRunning ? 'text-green-500' : 'text-slate-500'}`}>
{isRunning ? '运行中' : '空闲'}
</p>
</div>
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${isRunning ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
}`}>
{isRunning ? (
<Loader2 className="h-6 w-6 text-green-500 animate-spin" />
) : (
<Clock className="h-6 w-6 text-slate-400" />
)}
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-lg font-bold text-blue-500">
{status?.completed || 0} / {status?.total_teams || '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<Users className="h-6 w-6 text-blue-500" />
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-lg font-bold text-green-500">
{totalRegistered} / {expectedTotal || '-'}
</p>
</div>
<div className="h-12 w-12 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle className="h-6 w-6 text-green-500" />
</div>
</div>
</CardContent>
</Card>
<Card className="stat-card card-hover">
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500 dark:text-slate-400"></p>
<p className="text-lg font-bold text-purple-500">{totalS2A}</p>
</div>
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<Settings className="h-6 w-6 text-purple-500" />
</div>
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* 配置面板 */}
<Card className="glass-card">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-500" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 账号文件上传 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
Owner
</label>
<div className="flex items-center gap-2">
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-blue-500 transition-colors">
<Upload className="h-5 w-5 text-slate-400" />
<span className="text-sm text-slate-500">
{owners.length > 0 ? `已加载 ${owners.length} 个账号` : '选择 JSON 文件'}
</span>
<input
type="file"
accept=".json"
onChange={handleFileUpload}
className="hidden"
disabled={isRunning}
/>
</label>
</div>
</div>
<Input
label="每个 Team 成员数"
type="number"
min={1}
max={10}
value={membersPerTeam}
onChange={(e) => setMembersPerTeam(Number(e.target.value))}
disabled={isRunning}
/>
<Input
label="并发 Team 数"
type="number"
min={1}
max={Math.max(1, owners.length)}
value={concurrentTeams}
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
disabled={isRunning}
hint={`最多 ${owners.length}`}
/>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
</label>
<div className="flex gap-2">
<button
onClick={() => setBrowserType('chromedp')}
disabled={isRunning}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
? 'bg-blue-500 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
}`}
>
Chromedp ()
</button>
<button
onClick={() => setBrowserType('rod')}
disabled={isRunning}
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
? 'bg-blue-500 text-white'
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
}`}
>
Rod
</button>
</div>
</div>
<div className="flex items-center gap-3">
<input
type="checkbox"
id="headless"
checked={headless}
onChange={(e) => setHeadless(e.target.checked)}
disabled={isRunning}
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="headless" className="text-sm text-slate-700 dark:text-slate-300">
()
</label>
</div>
<Input
label="代理地址"
placeholder="http://127.0.0.1:7890"
value={proxy}
onChange={(e) => setProxy(e.target.value)}
disabled={isRunning}
/>
<div className="flex gap-2 pt-4">
{isRunning ? (
<Button
variant="outline"
onClick={handleStop}
className="flex-1"
icon={<Square className="h-4 w-4" />}
>
</Button>
) : (
<Button
onClick={handleStart}
loading={loading}
disabled={owners.length === 0}
className="flex-1"
icon={<Play className="h-4 w-4" />}
>
</Button>
)}
</div>
</CardContent>
</Card>
{/* 结果列表 */}
<Card className="glass-card lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5 text-green-500" />
</CardTitle>
{status && status.elapsed_ms > 0 && (
<span className="text-sm text-slate-500">
: {(status.elapsed_ms / 1000).toFixed(1)}s
</span>
)}
</CardHeader>
<CardContent>
{status?.results && status.results.length > 0 ? (
<div className="space-y-4 max-h-[500px] overflow-y-auto">
{status.results.map((result) => (
<div
key={result.team_index}
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600">
Team {result.team_index}
</span>
<span className="text-sm text-slate-500 truncate max-w-[200px]">
{result.owner_email}
</span>
</div>
<div className="flex items-center gap-4 text-sm">
<span className="flex items-center gap-1 text-green-600">
<CheckCircle className="h-4 w-4" />
: {result.registered}
</span>
<span className="flex items-center gap-1 text-purple-600">
<Settings className="h-4 w-4" />
: {result.added_to_s2a}
</span>
</div>
</div>
{result.member_emails.length > 0 && (
<div className="mb-3">
<p className="text-xs text-slate-500 mb-1">:</p>
<div className="flex flex-wrap gap-1">
{result.member_emails.map((email, idx) => (
<span
key={idx}
className="px-2 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
>
{email}
</span>
))}
</div>
</div>
)}
{result.errors.length > 0 && (
<div>
<p className="text-xs text-red-500 mb-1 flex items-center gap-1">
<AlertTriangle className="h-3 w-3" />
:
</p>
<div className="space-y-1">
{result.errors.map((err, idx) => (
<p key={idx} className="text-xs text-red-400 pl-4">
{err}
</p>
))}
</div>
</div>
)}
<div className="mt-2 text-xs text-slate-400">
: {(result.duration_ms / 1000).toFixed(1)}s
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12 text-slate-500">
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p></p>
<p className="text-sm mt-1"></p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -0,0 +1,340 @@
import { useState, useCallback, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { Upload as UploadIcon, Settings, Play, Loader2, List, Activity } from 'lucide-react'
import { FileDropzone } from '../components/upload'
import LogStream from '../components/upload/LogStream'
import OwnerList from '../components/upload/OwnerList'
import { Card, CardContent, Button, Tabs } from '../components/common'
import { useConfig } from '../hooks/useConfig'
interface PoolingConfig {
owner_concurrency: number // 母号并发数
include_owner: boolean // 是否入库母号
serial_authorize: boolean
browser_type: 'rod' | 'cdp'
proxy: string
}
interface OwnerStats {
total: number
valid: number
registered: number
pooled: number
}
type TabType = 'upload' | 'owners' | 'logs'
export default function Upload() {
const { config, isConnected } = useConfig()
const apiBase = 'http://localhost:8088'
const [activeTab, setActiveTab] = useState<TabType>('upload')
const [fileError, setFileError] = useState<string | null>(null)
const [validating, setValidating] = useState(false)
const [pooling, setPooling] = useState(false)
const [stats, setStats] = useState<OwnerStats | null>(null)
const [poolingConfig, setPoolingConfig] = useState<PoolingConfig>({
owner_concurrency: 1,
include_owner: true,
serial_authorize: true,
browser_type: 'rod',
proxy: '',
})
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
// Load stats
const loadStats = useCallback(async () => {
try {
const res = await fetch(`${apiBase}/api/db/owners/stats`)
const data = await res.json()
if (data.code === 0) {
setStats(data.data)
}
} catch (e) {
console.error('Failed to load stats:', e)
}
}, [apiBase])
useEffect(() => {
loadStats()
}, [loadStats])
// Upload and validate
const handleFileSelect = useCallback(
async (file: File) => {
setFileError(null)
setValidating(true)
try {
const text = await file.text()
const json = JSON.parse(text)
// Support both array and single account
const accounts = Array.isArray(json) ? json : [json]
const res = await fetch(`${apiBase}/api/upload/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ accounts }),
})
const data = await res.json()
if (data.code === 0) {
loadStats()
} else {
setFileError(data.message || '验证失败')
}
} catch (e) {
setFileError(e instanceof Error ? e.message : 'JSON 解析失败')
} finally {
setValidating(false)
}
},
[apiBase, loadStats]
)
// Start pooling
const handleStartPooling = useCallback(async () => {
setPooling(true)
setActiveTab('logs') // Switch to logs tab
try {
const res = await fetch(`${apiBase}/api/pooling/start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(poolingConfig),
})
const data = await res.json()
if (data.code !== 0) {
alert(data.message || '启动失败')
}
} catch (e) {
console.error('Failed to start pooling:', e)
} finally {
// Check status periodically
const checkStatus = async () => {
try {
const res = await fetch(`${apiBase}/api/pooling/status`)
const data = await res.json()
if (data.code === 0 && !data.data.running) {
setPooling(false)
loadStats()
} else {
setTimeout(checkStatus, 2000)
}
} catch {
setPooling(false)
}
}
setTimeout(checkStatus, 2000)
}
}, [apiBase, poolingConfig, loadStats])
const tabs = [
{ id: 'upload', label: '上传', icon: UploadIcon },
{ id: 'owners', label: '母号列表', icon: List, count: stats?.total },
{ id: 'logs', label: '日志', icon: Activity },
]
return (
<div className="h-[calc(100vh-6rem)] flex flex-col gap-6">
{/* Header */}
<div className="flex items-center justify-between shrink-0">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
<UploadIcon className="h-7 w-7 text-blue-500" />
</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">
Team Owner JSON S2A
</p>
</div>
</div>
{/* Connection warning */}
{!hasConfig && (
<div className="shrink-0 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
<div className="flex items-start gap-3">
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
<div>
<p className="font-medium text-yellow-800 dark:text-yellow-200">
S2A
</p>
<Link to="/config/s2a" className="mt-3 inline-block">
<Button size="sm" variant="outline">
</Button>
</Link>
</div>
</div>
</div>
)}
{/* Tabs */}
<Tabs
tabs={tabs}
activeTab={activeTab}
onChange={(id) => setActiveTab(id as TabType)}
className="shrink-0"
/>
{/* Tab Content */}
<div className="flex-1 min-h-0 overflow-hidden">
{activeTab === 'upload' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full overflow-hidden">
{/* Left: Upload & Config */}
<div className="flex flex-col gap-4 overflow-y-auto">
{/* Upload */}
<Card hoverable className="shrink-0">
<CardContent className="p-4">
<FileDropzone
onFileSelect={handleFileSelect}
disabled={validating}
error={fileError}
/>
{validating && (
<div className="mt-3 flex items-center gap-2 text-blue-500 bg-blue-50 dark:bg-blue-900/20 p-2 rounded-lg text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
<span>...</span>
</div>
)}
</CardContent>
</Card>
{/* Stats - Compact inline */}
{stats && (
<div className="shrink-0 grid grid-cols-4 gap-2">
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700">
<div className="text-lg font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
<div className="text-xs text-slate-500"></div>
</div>
<div className="text-center p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-100 dark:border-blue-800/50">
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.valid}</div>
<div className="text-xs text-blue-600/70 dark:text-blue-400/70"></div>
</div>
<div className="text-center p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-100 dark:border-orange-800/50">
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{stats.registered}</div>
<div className="text-xs text-orange-600/70 dark:text-orange-400/70"></div>
</div>
<div className="text-center p-2 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-100 dark:border-green-800/50">
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.pooled}</div>
<div className="text-xs text-green-600/70 dark:text-green-400/70"></div>
</div>
</div>
)}
{/* Pooling Config - Compact */}
<Card hoverable className="shrink-0">
<CardContent className="p-4 space-y-4">
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
<Play className="h-4 w-4 text-green-500" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<input
type="number"
min={1}
max={10}
value={poolingConfig.owner_concurrency}
onChange={(e) => setPoolingConfig({ ...poolingConfig, owner_concurrency: Number(e.target.value) })}
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<select
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
value={poolingConfig.browser_type}
onChange={(e) => setPoolingConfig({ ...poolingConfig, browser_type: e.target.value as 'rod' | 'cdp' })}
>
<option value="rod">Rod ()</option>
<option value="cdp">CDP</option>
</select>
</div>
</div>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={poolingConfig.include_owner}
onChange={(e) => setPoolingConfig({ ...poolingConfig, include_owner: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-slate-700 dark:text-slate-300"></span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-sm">
<input
type="checkbox"
checked={poolingConfig.serial_authorize}
onChange={(e) => setPoolingConfig({ ...poolingConfig, serial_authorize: e.target.checked })}
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
/>
<span className="text-slate-700 dark:text-slate-300"></span>
</label>
</div>
<div>
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
</label>
<input
type="text"
placeholder="http://127.0.0.1:7890"
value={poolingConfig.proxy}
onChange={(e) => setPoolingConfig({ ...poolingConfig, proxy: e.target.value })}
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
/>
</div>
<Button
onClick={handleStartPooling}
disabled={!isConnected || pooling || !stats?.valid}
loading={pooling}
icon={pooling ? undefined : <Play className="h-4 w-4" />}
className="w-full"
>
{pooling ? '正在入库...' : '开始入库'}
</Button>
</CardContent>
</Card>
</div>
{/* Right: Quick Log View */}
<div className="hidden lg:block h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-900 shadow-inner">
<div className="h-full flex flex-col">
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-800 bg-slate-900/50 backdrop-blur">
<Activity className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-300"></span>
</div>
<div className="flex-1 overflow-hidden">
<LogStream apiBase={apiBase} />
</div>
</div>
</div>
</div>
)}
{activeTab === 'owners' && (
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm">
<OwnerList apiBase={apiBase} />
</div>
)}
{activeTab === 'logs' && (
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm">
<LogStream apiBase={apiBase} />
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,9 @@
export { default as Dashboard } from './Dashboard'
export { default as Upload } from './Upload'
export { default as Records } from './Records'
export { default as Accounts } from './Accounts'
export { default as Config } from './Config'
export { default as S2AConfig } from './S2AConfig'
export { default as EmailConfig } from './EmailConfig'
export { default as Monitor } from './Monitor'
export { default as TeamProcess } from './TeamProcess'

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

225
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,225 @@
// 输入账号JSON 文件格式)
export interface AccountInput {
account: string // 邮箱
password: string // 密码
token: string // access_token
}
// 账号状态
export type AccountStatus = 'pending' | 'checking' | 'active' | 'banned' | 'token_expired' | 'error'
// 检查后的账号
export interface CheckedAccount extends AccountInput {
id: number // 本地序号
status: AccountStatus
accountId?: string // ChatGPT workspace_id
planType?: string
error?: string
}
// 加号记录
export interface AddRecord {
id: string
timestamp: string
total: number
success: number
failed: number
source: 'manual' | 'auto'
details?: string
}
// S2A Dashboard 统计
export interface DashboardStats {
total_accounts: number
normal_accounts: number
error_accounts: number
ratelimit_accounts: number
overload_accounts: number
today_requests: number
today_tokens: number
today_cost: number
total_requests: number
total_tokens: number
total_cost: number
rpm: number
tpm: number
}
// S2A 账号
export interface S2AAccount {
id: number
name: string
notes?: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
credentials: Record<string, unknown>
extra?: Record<string, unknown>
proxy_id?: number
concurrency: number
priority: number
rate_multiplier?: number
status: 'active' | 'inactive' | 'error'
error_message?: string
schedulable: boolean
last_used_at?: string
expires_at?: string
auto_pause_on_expired: boolean
created_at: string
updated_at: string
// 实时字段
current_concurrency?: number
current_window_cost?: number
active_sessions?: number
}
// 分页响应
export interface PaginatedResponse<T> {
data: T[]
total: number
page: number
page_size: number
total_pages: number
}
// 账号列表查询参数
export interface AccountListParams {
page?: number
page_size?: number
platform?: 'openai' | 'anthropic' | 'gemini'
type?: 'oauth' | 'access_token' | 'apikey'
status?: 'active' | 'inactive' | 'error'
search?: string
}
// 创建账号请求
export interface CreateAccountRequest {
name: string
platform: 'openai' | 'anthropic' | 'gemini'
type: 'access_token'
credentials: {
access_token: string
refresh_token?: string
email?: string
}
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
auto_pause_on_expired?: boolean
}
// OAuth 创建账号请求
export interface OAuthCreateRequest {
session_id: string
code: string
name?: string
concurrency?: number
priority?: number
group_ids?: number[]
proxy_id?: number | null
}
// 分组
export interface Group {
id: number
name: string
description?: string
created_at: string
updated_at: string
}
// 代理
export interface Proxy {
id: number
name: string
url: string
status: 'active' | 'inactive' | 'error'
created_at: string
updated_at: string
}
// 趋势数据
export interface TrendData {
date: string
requests: number
tokens: number
cost: number
}
// 邮箱服务配置
export interface MailServiceConfig {
name: string // 服务名称
apiBase: string // API 地址
apiToken: string // API Token
domain: string // 邮箱域名
emailPath?: string // 获取邮件列表的 API 路径
addUserApi?: string // 创建用户的 API 路径
}
// 应用配置
export interface AppConfig {
s2a: {
apiBase: string
adminKey: string
}
pooling: {
concurrency: number
priority: number
groupIds: number[]
proxyId: number | null
}
check: {
concurrency: number
timeout: number
}
email: {
services: MailServiceConfig[] // 多个邮箱服务配置
}
}
// 默认配置
export const defaultConfig: AppConfig = {
s2a: {
apiBase: '',
adminKey: '',
},
pooling: {
concurrency: 1,
priority: 0,
groupIds: [],
proxyId: null,
},
check: {
concurrency: 20,
timeout: 30000,
},
email: {
services: [
{
name: 'esyteam',
apiBase: 'https://mail.esyteam.edu.kg',
apiToken: '',
domain: 'esyteam.edu.kg',
},
],
},
}
// 检查结果
export interface CheckResult {
status: AccountStatus
accountId?: string
planType?: string
error?: string
}
// 测试结果
export interface TestResult {
success: boolean
message?: string
latency?: number
}
// Account 类型别名 (对应 requirements.md A5)
// S2AAccount 已包含完整的 Account 数据结构
export type Account = S2AAccount

View File

@@ -0,0 +1,122 @@
/**
* 格式化日期时间
*/
export function formatDateTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/**
* 格式化日期
*/
export function formatDate(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
/**
* 格式化时间
*/
export function formatTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
return d.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
/**
* 格式化相对时间
*/
export function formatRelativeTime(date: string | Date): string {
const d = typeof date === 'string' ? new Date(date) : date
const now = new Date()
const diff = now.getTime() - d.getTime()
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (days > 0) return `${days}天前`
if (hours > 0) return `${hours}小时前`
if (minutes > 0) return `${minutes}分钟前`
return '刚刚'
}
/**
* 格式化数字(添加千分位)
*/
export function formatNumber(num: number | undefined | null): string {
if (num == null) return '0'
return num.toLocaleString('zh-CN')
}
/**
* 格式化金额
*/
export function formatCurrency(amount: number | undefined | null, currency: string = 'USD'): string {
if (amount == null) amount = 0
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
/**
* 格式化百分比
*/
export function formatPercent(value: number, decimals: number = 1): string {
return `${(value * 100).toFixed(decimals)}%`
}
/**
* 格式化文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
/**
* 截断文本
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text
return `${text.substring(0, maxLength)}...`
}
/**
* 隐藏邮箱中间部分
*/
export function maskEmail(email: string): string {
const [local, domain] = email.split('@')
if (!domain) return email
if (local.length <= 2) return `${local}***@${domain}`
return `${local.substring(0, 2)}***@${domain}`
}
/**
* 隐藏 Token
*/
export function maskToken(token: string, visibleChars: number = 8): string {
if (token.length <= visibleChars * 2) return '***'
return `${token.substring(0, visibleChars)}...${token.substring(token.length - visibleChars)}`
}

View File

@@ -0,0 +1,5 @@
// Utils barrel export
export * from './storage'
export * from './format'
export * from './json-parser'
export * from './status-check'

View File

@@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest'
import { parseAccountJson, isValidEmail, isValidToken } from './json-parser'
describe('parseAccountJson', () => {
it('should parse valid JSON array', () => {
const json = JSON.stringify([
{ account: 'test@example.com', password: 'pass123', token: 'token123456' },
])
const result = parseAccountJson(json)
expect(result.success).toBe(true)
expect(result.data).toHaveLength(1)
expect(result.data?.[0].account).toBe('test@example.com')
})
it('should parse multiple accounts', () => {
const json = JSON.stringify([
{ account: 'user1@example.com', password: 'pass1', token: 'token1234567890' },
{ account: 'user2@example.com', password: 'pass2', token: 'token0987654321' },
])
const result = parseAccountJson(json)
expect(result.success).toBe(true)
expect(result.data).toHaveLength(2)
})
it('should reject non-array JSON', () => {
const json = JSON.stringify({ account: 'test@example.com' })
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('数组格式')
})
it('should reject empty array', () => {
const json = JSON.stringify([])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('不能为空')
})
it('should reject invalid JSON', () => {
const result = parseAccountJson('not valid json')
expect(result.success).toBe(false)
expect(result.error).toContain('解析失败')
})
it('should reject missing account field', () => {
const json = JSON.stringify([{ password: 'pass', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('account')
})
it('should reject missing password field', () => {
const json = JSON.stringify([{ account: 'test@example.com', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('password')
})
it('should reject missing token field', () => {
const json = JSON.stringify([{ account: 'test@example.com', password: 'pass' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('token')
})
it('should reject non-string account', () => {
const json = JSON.stringify([{ account: 123, password: 'pass', token: 'token123456' }])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('account')
})
it('should reject null items in array', () => {
const json = JSON.stringify([null])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('有效的对象')
})
it('should indicate which record has error', () => {
const json = JSON.stringify([
{ account: 'valid@example.com', password: 'pass', token: 'token123456' },
{ account: 'invalid', password: 'pass' }, // missing token
])
const result = parseAccountJson(json)
expect(result.success).toBe(false)
expect(result.error).toContain('第 2 条')
})
})
describe('isValidEmail', () => {
it('should accept valid email', () => {
expect(isValidEmail('test@example.com')).toBe(true)
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
})
it('should reject invalid email', () => {
expect(isValidEmail('invalid')).toBe(false)
expect(isValidEmail('invalid@')).toBe(false)
expect(isValidEmail('@domain.com')).toBe(false)
expect(isValidEmail('')).toBe(false)
})
})
describe('isValidToken', () => {
it('should accept valid token', () => {
expect(isValidToken('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9')).toBe(true)
expect(isValidToken('1234567890')).toBe(true)
})
it('should reject short token', () => {
expect(isValidToken('short')).toBe(false)
expect(isValidToken('')).toBe(false)
})
})

View File

@@ -0,0 +1,96 @@
import type { AccountInput } from '../types'
export interface ParseResult {
success: boolean
data?: AccountInput[]
error?: string
}
/**
* Parse and validate JSON account data
*/
export function parseAccountJson(jsonString: string): ParseResult {
try {
const data = JSON.parse(jsonString)
if (!Array.isArray(data)) {
return {
success: false,
error: 'JSON 文件必须是数组格式',
}
}
if (data.length === 0) {
return {
success: false,
error: 'JSON 数组不能为空',
}
}
// Validate each account
const accounts: AccountInput[] = []
for (let i = 0; i < data.length; i++) {
const item = data[i]
if (typeof item !== 'object' || item === null) {
return {
success: false,
error: `${i + 1} 条记录不是有效的对象`,
}
}
if (!item.account || typeof item.account !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 account 字段`,
}
}
if (!item.password || typeof item.password !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 password 字段`,
}
}
if (!item.token || typeof item.token !== 'string') {
return {
success: false,
error: `${i + 1} 条记录缺少有效的 token 字段`,
}
}
accounts.push({
account: item.account,
password: item.password,
token: item.token,
})
}
return {
success: true,
data: accounts,
}
} catch {
return {
success: false,
error: 'JSON 解析失败,请检查文件格式',
}
}
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return emailRegex.test(email)
}
/**
* Validate token format (basic check)
*/
export function isValidToken(token: string): boolean {
// Token should be a non-empty string with reasonable length
return typeof token === 'string' && token.length >= 10
}

View File

@@ -0,0 +1,54 @@
import type { AccountStatus } from '../types'
/**
* Map HTTP status code to account status
*/
export function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
switch (httpStatus) {
case 200:
return 'active'
case 401:
return 'token_expired'
case 403:
return 'banned'
default:
return 'error'
}
}
/**
* Check if account status allows pooling
*/
export function canPoolAccount(status: AccountStatus): boolean {
return status === 'active'
}
/**
* Get status display text
*/
export function getStatusDisplayText(status: AccountStatus): string {
const statusMap: Record<AccountStatus, string> = {
pending: '待检查',
checking: '检查中',
active: '正常',
banned: '封禁',
token_expired: '过期',
error: '错误',
}
return statusMap[status] || '未知'
}
/**
* Get status color class
*/
export function getStatusColorClass(status: AccountStatus): string {
const colorMap: Record<AccountStatus, string> = {
pending: 'text-slate-500',
checking: 'text-blue-500',
active: 'text-green-500',
banned: 'text-red-500',
token_expired: 'text-orange-500',
error: 'text-yellow-500',
}
return colorMap[status] || 'text-slate-500'
}

View File

View File

@@ -0,0 +1,107 @@
import type { AppConfig, AddRecord } from '../types'
import { defaultConfig } from '../types'
const STORAGE_KEYS = {
CONFIG: 'codex-pool-config',
RECORDS: 'codex-pool-records',
} as const
/**
* 保存配置到 localStorage
*/
export function saveConfig(config: AppConfig): void {
try {
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
} catch (error) {
console.error('Failed to save config:', error)
}
}
/**
* 从 localStorage 加载配置
*/
export function loadConfig(): AppConfig {
try {
const stored = localStorage.getItem(STORAGE_KEYS.CONFIG)
if (stored) {
const parsed = JSON.parse(stored)
// Migration: handle old email config format -> new services array format
let emailConfig = { ...defaultConfig.email }
if (parsed.email) {
if (parsed.email.services && Array.isArray(parsed.email.services)) {
// New format - use directly
emailConfig.services = parsed.email.services
} else if (parsed.email.apiBase || parsed.email.domains) {
// Old format - migrate to new services array
const domains = parsed.email.domains || (parsed.email.domain ? [parsed.email.domain] : ['esyteam.edu.kg'])
emailConfig.services = [{
name: 'default',
apiBase: parsed.email.apiBase || 'https://mail.esyteam.edu.kg',
apiToken: parsed.email.apiToken || '',
domain: domains[0] || 'esyteam.edu.kg',
}]
}
}
// 合并默认配置,确保新增字段有默认值
return {
...defaultConfig,
...parsed,
s2a: { ...defaultConfig.s2a, ...parsed.s2a },
pooling: { ...defaultConfig.pooling, ...parsed.pooling },
check: { ...defaultConfig.check, ...parsed.check },
email: emailConfig,
}
}
} catch (error) {
console.error('Failed to load config:', error)
}
return defaultConfig
}
/**
* 保存记录到 localStorage
*/
export function saveRecords(records: AddRecord[]): void {
try {
localStorage.setItem(STORAGE_KEYS.RECORDS, JSON.stringify(records))
} catch (error) {
console.error('Failed to save records:', error)
}
}
/**
* 从 localStorage 加载记录
*/
export function loadRecords(): AddRecord[] {
try {
const stored = localStorage.getItem(STORAGE_KEYS.RECORDS)
if (stored) {
return JSON.parse(stored)
}
} catch (error) {
console.error('Failed to load records:', error)
}
return []
}
/**
* 生成唯一 ID
*/
export function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
}
/**
* 清除所有存储数据
*/
export function clearStorage(): void {
try {
localStorage.removeItem(STORAGE_KEYS.CONFIG)
localStorage.removeItem(STORAGE_KEYS.RECORDS)
} catch (error) {
console.error('Failed to clear storage:', error)
}
}