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:
28
frontend/src/App.tsx
Normal file
28
frontend/src/App.tsx
Normal 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
|
||||
294
frontend/src/api/chatgpt.test.ts
Normal file
294
frontend/src/api/chatgpt.test.ts
Normal 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
253
frontend/src/api/chatgpt.ts
Normal 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()
|
||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal 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
155
frontend/src/api/s2a.ts
Normal 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
522
frontend/src/api/types.ts
Normal 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[]
|
||||
}
|
||||
215
frontend/src/components/common/Button.test.tsx
Normal file
215
frontend/src/components/common/Button.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
158
frontend/src/components/common/Button.tsx
Normal file
158
frontend/src/components/common/Button.tsx
Normal 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
|
||||
84
frontend/src/components/common/Card.tsx
Normal file
84
frontend/src/components/common/Card.tsx
Normal 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
|
||||
61
frontend/src/components/common/ErrorBoundary.tsx
Normal file
61
frontend/src/components/common/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
100
frontend/src/components/common/Input.tsx
Normal file
100
frontend/src/components/common/Input.tsx
Normal 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
|
||||
68
frontend/src/components/common/Progress.tsx
Normal file
68
frontend/src/components/common/Progress.tsx
Normal 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
|
||||
74
frontend/src/components/common/Select.tsx
Normal file
74
frontend/src/components/common/Select.tsx
Normal 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
|
||||
66
frontend/src/components/common/StatusBadge.tsx
Normal file
66
frontend/src/components/common/StatusBadge.tsx
Normal 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
|
||||
110
frontend/src/components/common/Table.tsx
Normal file
110
frontend/src/components/common/Table.tsx
Normal 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
|
||||
48
frontend/src/components/common/Tabs.tsx
Normal file
48
frontend/src/components/common/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/common/Toast.tsx
Normal file
141
frontend/src/components/common/Toast.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/common/index.ts
Normal file
33
frontend/src/components/common/index.ts
Normal 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'
|
||||
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
96
frontend/src/components/dashboard/StatsCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/dashboard/index.ts
Normal file
3
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as StatsCard } from './StatsCard'
|
||||
export { default as PoolStatus } from './PoolStatus'
|
||||
export { default as RecentRecords } from './RecentRecords'
|
||||
97
frontend/src/components/layout/Header.tsx
Normal file
97
frontend/src/components/layout/Header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/layout/Layout.tsx
Normal file
30
frontend/src/components/layout/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
174
frontend/src/components/layout/Sidebar.tsx
Normal file
174
frontend/src/components/layout/Sidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Layout } from './Layout'
|
||||
export { default as Header } from './Header'
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
103
frontend/src/components/records/RecordList.tsx
Normal file
103
frontend/src/components/records/RecordList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/records/RecordStats.tsx
Normal file
76
frontend/src/components/records/RecordStats.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/records/index.ts
Normal file
2
frontend/src/components/records/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RecordList } from './RecordList'
|
||||
export { default as RecordStats } from './RecordStats'
|
||||
182
frontend/src/components/upload/AccountTable.tsx
Normal file
182
frontend/src/components/upload/AccountTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
72
frontend/src/components/upload/CheckProgress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
114
frontend/src/components/upload/FileDropzone.tsx
Normal 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">
|
||||
支持格式: [{"account": "email", "password": "pwd", "token": "..."}]
|
||||
</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>
|
||||
)
|
||||
}
|
||||
173
frontend/src/components/upload/LogStream.tsx
Normal file
173
frontend/src/components/upload/LogStream.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/upload/OwnerList.tsx
Normal file
218
frontend/src/components/upload/OwnerList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/upload/PoolActions.tsx
Normal file
141
frontend/src/components/upload/PoolActions.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
frontend/src/components/upload/index.ts
Normal file
4
frontend/src/components/upload/index.ts
Normal 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'
|
||||
148
frontend/src/context/ConfigContext.tsx
Normal file
148
frontend/src/context/ConfigContext.tsx
Normal 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
|
||||
}
|
||||
110
frontend/src/context/RecordsContext.tsx
Normal file
110
frontend/src/context/RecordsContext.tsx
Normal 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
|
||||
}
|
||||
2
frontend/src/context/index.ts
Normal file
2
frontend/src/context/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ConfigProvider, useConfigContext } from './ConfigContext'
|
||||
export { RecordsProvider, useRecordsContext } from './RecordsContext'
|
||||
5
frontend/src/hooks/index.ts
Normal file
5
frontend/src/hooks/index.ts
Normal 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'
|
||||
160
frontend/src/hooks/useAccountCheck.ts
Normal file
160
frontend/src/hooks/useAccountCheck.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
181
frontend/src/hooks/useBackendApi.ts
Normal file
181
frontend/src/hooks/useBackendApi.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
5
frontend/src/hooks/useConfig.ts
Normal file
5
frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useConfigContext } from '../context/ConfigContext'
|
||||
|
||||
export function useConfig() {
|
||||
return useConfigContext()
|
||||
}
|
||||
5
frontend/src/hooks/useRecords.ts
Normal file
5
frontend/src/hooks/useRecords.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useRecordsContext } from '../context/RecordsContext'
|
||||
|
||||
export function useRecords() {
|
||||
return useRecordsContext()
|
||||
}
|
||||
166
frontend/src/hooks/useS2AApi.ts
Normal file
166
frontend/src/hooks/useS2AApi.ts
Normal 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
416
frontend/src/index.css
Normal 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
18
frontend/src/main.tsx
Normal 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>
|
||||
)
|
||||
263
frontend/src/pages/Accounts.tsx
Normal file
263
frontend/src/pages/Accounts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
301
frontend/src/pages/Config.tsx
Normal file
301
frontend/src/pages/Config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
frontend/src/pages/Dashboard.tsx
Normal file
162
frontend/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
251
frontend/src/pages/EmailConfig.tsx
Normal file
251
frontend/src/pages/EmailConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
588
frontend/src/pages/Monitor.tsx
Normal file
588
frontend/src/pages/Monitor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/Records.tsx
Normal file
112
frontend/src/pages/Records.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/S2AConfig.tsx
Normal file
241
frontend/src/pages/S2AConfig.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
483
frontend/src/pages/TeamProcess.tsx
Normal file
483
frontend/src/pages/TeamProcess.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
340
frontend/src/pages/Upload.tsx
Normal file
340
frontend/src/pages/Upload.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal 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'
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
225
frontend/src/types/index.ts
Normal file
225
frontend/src/types/index.ts
Normal 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
|
||||
122
frontend/src/utils/format.ts
Normal file
122
frontend/src/utils/format.ts
Normal 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)}`
|
||||
}
|
||||
5
frontend/src/utils/index.ts
Normal file
5
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Utils barrel export
|
||||
export * from './storage'
|
||||
export * from './format'
|
||||
export * from './json-parser'
|
||||
export * from './status-check'
|
||||
126
frontend/src/utils/json-parser.test.ts
Normal file
126
frontend/src/utils/json-parser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
96
frontend/src/utils/json-parser.ts
Normal file
96
frontend/src/utils/json-parser.ts
Normal 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
|
||||
}
|
||||
54
frontend/src/utils/status-check.ts
Normal file
54
frontend/src/utils/status-check.ts
Normal 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'
|
||||
}
|
||||
0
frontend/src/utils/storage.test.ts
Normal file
0
frontend/src/utils/storage.test.ts
Normal file
107
frontend/src/utils/storage.ts
Normal file
107
frontend/src/utils/storage.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user