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

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

24
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

6
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
node_modules
dist
build
coverage
*.min.js
*.min.css

10
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,10 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

73
frontend/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

76
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,76 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
import eslintPluginPrettier from 'eslint-plugin-prettier'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'node_modules', 'coverage', 'build']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
eslintConfigPrettier,
],
plugins: {
prettier: eslintPluginPrettier,
},
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
// Prettier integration - show formatting issues as ESLint errors
'prettier/prettier': 'error',
// TypeScript specific rules
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// Allow empty interfaces that extend other interfaces (common pattern for component props)
'@typescript-eslint/no-empty-object-type': 'off',
// React specific rules
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
// Allow setState in effects for initialization patterns (common in React for loading from localStorage)
'react-hooks/set-state-in-effect': 'off',
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
// General rules
'no-console': ['warn', { allow: ['warn', 'error'] }],
'prefer-const': 'error',
'no-var': 'error',
},
},
// Configuration for JavaScript files (like config files)
{
files: ['**/*.{js,mjs,cjs}'],
extends: [js.configs.recommended, eslintConfigPrettier],
plugins: {
prettier: eslintPluginPrettier,
},
languageOptions: {
ecmaVersion: 2020,
globals: {
...globals.node,
},
},
rules: {
'prettier/prettier': 'error',
},
},
])

14
frontend/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Codex Pool - 智能账号池管理系统" />
<title>Codex Pool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5522
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

49
frontend/package.json Normal file
View File

@@ -0,0 +1,49 @@
{
"name": "codex-pool-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"check": "npm run lint && npm run format:check",
"fix": "npm run lint:fix && npm run format",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tailwindcss/vite": "^4.1.18",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"recharts": "^3.7.0",
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.9",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"jsdom": "^27.4.0",
"prettier": "^3.8.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4",
"vitest": "^4.0.18"
}
}

View File

@@ -0,0 +1,21 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="180"
height="180"
viewBox="0 0 180 180"
fill="none"
>
<style>
:root {
fill: #000;
}
@media (prefers-color-scheme: dark) {
:root {
fill: #fff;
}
}
</style>
<path
d="M101.228 164.247C96.2776 164.247 91.5751 163.307 87.1201 161.426C82.6651 159.545 78.7051 156.921 75.2401 153.555C71.4781 154.842 67.5676 155.486 63.5086 155.486C56.8756 155.486 50.7376 153.852 45.0946 150.585C39.4516 147.318 34.8976 142.863 31.4326 137.22C28.0666 131.577 26.3836 125.291 26.3836 118.361C26.3836 115.49 26.7796 112.371 27.5716 109.005C23.6116 105.342 20.5426 101.135 18.3646 96.3828C16.1866 91.5318 15.0976 86.4828 15.0976 81.2358C15.0976 75.8898 16.2361 70.7418 18.5131 65.7918C20.7901 60.8418 23.9581 56.5848 28.0171 53.0208C32.1751 49.3578 36.9766 46.8333 42.4216 45.4473C43.5106 39.8043 45.7876 34.7553 49.2526 30.3003C52.8166 25.7463 57.1726 22.1823 62.3206 19.6083C67.4686 17.0343 72.9631 15.7473 78.8041 15.7473C83.7541 15.7473 88.4566 16.6878 92.9116 18.5688C97.3666 20.4498 101.327 23.0733 104.792 26.4393C108.554 25.1523 112.464 24.5088 116.523 24.5088C123.156 24.5088 129.294 26.1423 134.937 29.4093C140.58 32.6763 145.085 37.1313 148.451 42.7743C151.916 48.4173 153.648 54.7038 153.648 61.6338C153.648 64.5048 153.252 67.6233 152.46 70.9893C156.42 74.6523 159.489 78.9093 161.667 83.7603C163.845 88.5123 164.934 93.5118 164.934 98.7588C164.934 104.105 163.796 109.253 161.519 114.203C159.242 119.153 156.024 123.459 151.866 127.122C147.807 130.686 143.055 133.161 137.61 134.547C136.521 140.19 134.195 145.239 130.631 149.694C127.166 154.248 122.859 157.812 117.711 160.386C112.563 162.96 107.069 164.247 101.228 164.247ZM64.5481 145.685C69.4981 145.685 73.8046 144.645 77.4676 142.566L105.386 126.528C106.376 125.835 106.871 124.895 106.871 123.707V110.936L70.9336 131.577C68.7556 132.864 66.5776 132.864 64.3996 131.577L36.3331 115.391C36.3331 115.688 36.2836 116.034 36.1846 116.43C36.1846 116.826 36.1846 117.42 36.1846 118.212C36.1846 123.261 37.3726 127.914 39.7486 132.171C42.2236 136.329 45.6391 139.596 49.9951 141.972C54.3511 144.447 59.2021 145.685 64.5481 145.685ZM66.0331 121.479C66.6271 121.776 67.1716 121.925 67.6666 121.925C68.1616 121.925 68.6566 121.776 69.1516 121.479L80.2891 115.094L44.5006 94.3038C42.3226 93.0168 41.2336 91.0863 41.2336 88.5123V56.2878C36.2836 58.4658 32.3236 61.8318 29.3536 66.3858C26.3836 70.8408 24.8986 75.7908 24.8986 81.2358C24.8986 86.0868 26.1361 90.7398 28.6111 95.1948C31.0861 99.6498 34.3036 103.016 38.2636 105.293L66.0331 121.479ZM101.228 154.446C106.475 154.446 111.227 153.258 115.484 150.882C119.741 148.506 123.107 145.239 125.582 141.081C128.057 136.923 129.294 132.27 129.294 127.122V95.0463C129.294 93.8583 128.799 92.9673 127.809 92.3733L116.523 85.8393V127.271C116.523 129.845 115.434 131.775 113.256 133.062L85.1896 149.249C90.0406 152.714 95.3866 154.446 101.228 154.446ZM106.871 100.095V79.8993L90.09 70.3953L73.1611 79.8993V100.095L90.09 109.599L106.871 100.095ZM63.5086 52.7238C63.5086 50.1498 64.5976 48.2193 66.7756 46.9323L94.8421 30.7458C89.9911 27.2808 84.6451 25.5483 78.8041 25.5483C73.5571 25.5483 68.8051 26.7363 64.5481 29.1123C60.2911 31.4883 56.9251 34.7553 54.4501 38.9133C52.0741 43.0713 50.8861 47.7243 50.8861 52.8723V84.7998C50.8861 85.9878 51.3811 86.9283 52.3711 87.6213L63.5086 94.1553V52.7238ZM138.947 123.707C143.897 121.529 147.807 118.163 150.678 113.609C153.648 109.055 155.133 104.105 155.133 98.7588C155.133 93.9078 153.896 89.2548 151.421 84.7998C148.946 80.3448 145.728 76.9788 141.768 74.7018L113.999 58.6638C113.405 58.2678 112.86 58.1193 112.365 58.2183C111.87 58.2183 111.375 58.3668 110.88 58.6638L99.7426 64.9008L135.68 85.8393C136.769 86.4333 137.561 87.2253 138.056 88.2153C138.65 89.1063 138.947 90.1953 138.947 91.4823V123.707ZM109.098 48.2688C111.276 46.8828 113.454 46.8828 115.632 48.2688L143.847 64.7523C143.847 64.0593 143.847 63.1683 143.847 62.0793C143.847 57.3273 142.659 52.8228 140.283 48.5658C138.006 44.2098 134.69 40.7448 130.334 38.1708C126.077 35.5968 121.127 34.3098 115.484 34.3098C110.534 34.3098 106.227 35.3493 102.564 37.4283L74.6461 53.4663C73.6561 54.1593 73.1611 55.0998 73.1611 56.2878V69.0588L109.098 48.2688Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

1
frontend/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

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

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

View File

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

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

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

11
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 3000,
},
})

16
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
coverage: {
reporter: ['text', 'json', 'html'],
exclude: ['node_modules/', 'src/test/'],
},
},
})