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:
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal 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
6
frontend/.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
dist
|
||||
build
|
||||
coverage
|
||||
*.min.js
|
||||
*.min.css
|
||||
10
frontend/.prettierrc
Normal file
10
frontend/.prettierrc
Normal 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
73
frontend/README.md
Normal 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
76
frontend/eslint.config.js
Normal 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
14
frontend/index.html
Normal 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
5522
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
49
frontend/package.json
Normal file
49
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
21
frontend/public/favicon.svg
Normal file
21
frontend/public/favicon.svg
Normal 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
1
frontend/public/vite.svg
Normal 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
28
frontend/src/App.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { ConfigProvider, RecordsProvider } from './context'
|
||||
import { Layout } from './components/layout'
|
||||
import { Dashboard, Upload, Records, Accounts, Config, S2AConfig, EmailConfig, Monitor, TeamProcess } from './pages'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ConfigProvider>
|
||||
<RecordsProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Layout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="upload" element={<Upload />} />
|
||||
<Route path="records" element={<Records />} />
|
||||
<Route path="accounts" element={<Accounts />} />
|
||||
<Route path="monitor" element={<Monitor />} />
|
||||
<Route path="team" element={<TeamProcess />} />
|
||||
<Route path="config" element={<Config />} />
|
||||
<Route path="config/s2a" element={<S2AConfig />} />
|
||||
<Route path="config/email" element={<EmailConfig />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</RecordsProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
294
frontend/src/api/chatgpt.test.ts
Normal file
294
frontend/src/api/chatgpt.test.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { ChatGPTClient, getStatusText, getStatusColor, createChatGPTClient } from './chatgpt'
|
||||
import type { AccountInput } from '../types'
|
||||
|
||||
describe('ChatGPTClient', () => {
|
||||
let client: ChatGPTClient
|
||||
let fetchMock: ReturnType<typeof vi.fn>
|
||||
|
||||
beforeEach(() => {
|
||||
client = new ChatGPTClient()
|
||||
fetchMock = vi.fn()
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('checkAccount', () => {
|
||||
it('should return active status for HTTP 200 with account info', async () => {
|
||||
const mockResponse = {
|
||||
accounts: [
|
||||
{
|
||||
account_id: 'test-account-id',
|
||||
entitlement: {
|
||||
subscription_plan: 'plus',
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('valid-token')
|
||||
|
||||
expect(result.status).toBe('active')
|
||||
expect(result.accountId).toBe('test-account-id')
|
||||
expect(result.planType).toBe('plus')
|
||||
expect(result.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should return active status for HTTP 200 without account info', async () => {
|
||||
const mockResponse = {
|
||||
accounts: [],
|
||||
}
|
||||
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve(mockResponse),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('valid-token')
|
||||
|
||||
expect(result.status).toBe('active')
|
||||
expect(result.accountId).toBeUndefined()
|
||||
expect(result.planType).toBe('unknown')
|
||||
})
|
||||
|
||||
it('should return token_expired status for HTTP 401', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
json: () => Promise.reject(new Error('No JSON')),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('expired-token')
|
||||
|
||||
expect(result.status).toBe('token_expired')
|
||||
expect(result.error).toBe('Token 已过期')
|
||||
})
|
||||
|
||||
it('should return banned status for HTTP 403', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: () => Promise.reject(new Error('No JSON')),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('banned-token')
|
||||
|
||||
expect(result.status).toBe('banned')
|
||||
expect(result.error).toBe('账号已被封禁')
|
||||
})
|
||||
|
||||
it('should return error status for other HTTP codes', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: () => Promise.reject(new Error('No JSON')),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('some-token')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('HTTP 500: Internal Server Error')
|
||||
})
|
||||
|
||||
it('should return error status for network errors', async () => {
|
||||
fetchMock.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
const result = await client.checkAccount('some-token')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('Network error')
|
||||
})
|
||||
|
||||
it('should return error status for empty token', async () => {
|
||||
const result = await client.checkAccount('')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('缺少 token')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return error status for whitespace-only token', async () => {
|
||||
const result = await client.checkAccount(' ')
|
||||
|
||||
expect(result.status).toBe('error')
|
||||
expect(result.error).toBe('缺少 token')
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use correct API endpoint and headers', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ accounts: [] }),
|
||||
})
|
||||
|
||||
await client.checkAccount('test-token')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/chatgpt/accounts/check/v4-2023-04-27', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle JSON parse errors gracefully for HTTP 200', async () => {
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.reject(new Error('Invalid JSON')),
|
||||
})
|
||||
|
||||
const result = await client.checkAccount('valid-token')
|
||||
|
||||
// Should still return active since HTTP 200
|
||||
expect(result.status).toBe('active')
|
||||
expect(result.planType).toBe('unknown')
|
||||
})
|
||||
})
|
||||
|
||||
describe('batchCheck', () => {
|
||||
it('should return empty array for empty input', async () => {
|
||||
const results = await client.batchCheck([], { concurrency: 5 })
|
||||
|
||||
expect(results).toEqual([])
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should check all accounts and return results in order', async () => {
|
||||
const accounts: AccountInput[] = [
|
||||
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
|
||||
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
|
||||
{ account: 'user3@test.com', password: 'pass3', token: 'token3' },
|
||||
]
|
||||
|
||||
// Mock responses for each account
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
accounts: [{ account_id: 'id1', entitlement: { subscription_plan: 'plus' } }],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 401,
|
||||
statusText: 'Unauthorized',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
})
|
||||
|
||||
const results = await client.batchCheck(accounts, { concurrency: 3 })
|
||||
|
||||
expect(results).toHaveLength(3)
|
||||
expect(results[0].status).toBe('active')
|
||||
expect(results[0].accountId).toBe('id1')
|
||||
expect(results[1].status).toBe('token_expired')
|
||||
expect(results[2].status).toBe('banned')
|
||||
})
|
||||
|
||||
it('should call onProgress callback for each account', async () => {
|
||||
const accounts: AccountInput[] = [
|
||||
{ account: 'user1@test.com', password: 'pass1', token: 'token1' },
|
||||
{ account: 'user2@test.com', password: 'pass2', token: 'token2' },
|
||||
]
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ accounts: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ accounts: [] }),
|
||||
})
|
||||
|
||||
const onProgress = vi.fn()
|
||||
|
||||
await client.batchCheck(accounts, { concurrency: 2, onProgress })
|
||||
|
||||
expect(onProgress).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should respect concurrency limit', async () => {
|
||||
const accounts: AccountInput[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
account: `user${i}@test.com`,
|
||||
password: `pass${i}`,
|
||||
token: `token${i}`,
|
||||
}))
|
||||
|
||||
let maxConcurrent = 0
|
||||
let currentConcurrent = 0
|
||||
|
||||
fetchMock.mockImplementation(async () => {
|
||||
currentConcurrent++
|
||||
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
|
||||
|
||||
// Simulate network delay
|
||||
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||
|
||||
currentConcurrent--
|
||||
return {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
json: () => Promise.resolve({ accounts: [] }),
|
||||
}
|
||||
})
|
||||
|
||||
await client.batchCheck(accounts, { concurrency: 3 })
|
||||
|
||||
// Max concurrent should not exceed the concurrency limit
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(3)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatusText', () => {
|
||||
it('should return correct Chinese text for each status', () => {
|
||||
expect(getStatusText('pending')).toBe('待检查')
|
||||
expect(getStatusText('checking')).toBe('检查中')
|
||||
expect(getStatusText('active')).toBe('正常')
|
||||
expect(getStatusText('banned')).toBe('封禁')
|
||||
expect(getStatusText('token_expired')).toBe('过期')
|
||||
expect(getStatusText('error')).toBe('错误')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getStatusColor', () => {
|
||||
it('should return correct color for each status', () => {
|
||||
expect(getStatusColor('pending')).toBe('gray')
|
||||
expect(getStatusColor('checking')).toBe('blue')
|
||||
expect(getStatusColor('active')).toBe('green')
|
||||
expect(getStatusColor('banned')).toBe('red')
|
||||
expect(getStatusColor('token_expired')).toBe('orange')
|
||||
expect(getStatusColor('error')).toBe('yellow')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createChatGPTClient', () => {
|
||||
it('should create a new ChatGPTClient instance', () => {
|
||||
const client = createChatGPTClient()
|
||||
expect(client).toBeInstanceOf(ChatGPTClient)
|
||||
})
|
||||
|
||||
it('should create a client with custom base URL', () => {
|
||||
const client = createChatGPTClient('https://custom.api.com')
|
||||
expect(client).toBeInstanceOf(ChatGPTClient)
|
||||
})
|
||||
})
|
||||
253
frontend/src/api/chatgpt.ts
Normal file
253
frontend/src/api/chatgpt.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { AccountInput, AccountStatus, CheckedAccount, CheckResult } from '../types'
|
||||
import type { ChatGPTCheckResponse } from './types'
|
||||
|
||||
/**
|
||||
* ChatGPT API 检查端点
|
||||
* 通过 nginx 代理访问,避免 CORS 问题
|
||||
* 原始 API: https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
|
||||
*/
|
||||
const CHATGPT_CHECK_API = '/api/chatgpt/accounts/check/v4-2023-04-27'
|
||||
|
||||
/**
|
||||
* HTTP 状态码到账号状态的映射
|
||||
* 根据 requirements.md A3 定义:
|
||||
* - HTTP 200 → active (账号正常)
|
||||
* - HTTP 401 → token_expired (Token 已过期)
|
||||
* - HTTP 403 → banned (账号被封禁)
|
||||
* - 其他 → error (网络错误等)
|
||||
*/
|
||||
function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
|
||||
switch (httpStatus) {
|
||||
case 200:
|
||||
return 'active'
|
||||
case 401:
|
||||
return 'token_expired'
|
||||
case 403:
|
||||
return 'banned'
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 HTTP 状态码对应的错误消息
|
||||
*/
|
||||
function getErrorMessageForStatus(httpStatus: number, statusText: string): string | undefined {
|
||||
switch (httpStatus) {
|
||||
case 200:
|
||||
return undefined
|
||||
case 401:
|
||||
return 'Token 已过期'
|
||||
case 403:
|
||||
return '账号已被封禁'
|
||||
default:
|
||||
return `HTTP ${httpStatus}: ${statusText}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatGPT API 客户端
|
||||
* 用于检查 ChatGPT 账号状态
|
||||
*/
|
||||
export class ChatGPTClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string = '') {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查单个账号状态
|
||||
* @param token - ChatGPT access_token
|
||||
* @returns CheckResult 包含状态、account_id、plan_type 等信息
|
||||
*
|
||||
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
|
||||
* Headers: Authorization: Bearer {token}
|
||||
*
|
||||
* 状态映射 (requirements.md A3):
|
||||
* - HTTP 200 → active
|
||||
* - HTTP 401 → token_expired
|
||||
* - HTTP 403 → banned
|
||||
* - 其他 → error
|
||||
*/
|
||||
async checkAccount(token: string): Promise<CheckResult> {
|
||||
// 处理空 token 的情况
|
||||
if (!token || token.trim() === '') {
|
||||
return {
|
||||
status: 'error',
|
||||
error: '缺少 token',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${CHATGPT_CHECK_API}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const status = mapHttpStatusToAccountStatus(response.status)
|
||||
const errorMessage = getErrorMessageForStatus(response.status, response.statusText)
|
||||
|
||||
if (response.status === 200) {
|
||||
try {
|
||||
const data: ChatGPTCheckResponse = await response.json()
|
||||
const accountInfo = data.accounts?.[0]
|
||||
|
||||
if (accountInfo) {
|
||||
return {
|
||||
status: 'active',
|
||||
accountId: accountInfo.account_id,
|
||||
planType: accountInfo.entitlement?.subscription_plan || 'free',
|
||||
}
|
||||
}
|
||||
|
||||
// 200 响应但没有账号信息
|
||||
return {
|
||||
status: 'active',
|
||||
accountId: undefined,
|
||||
planType: 'unknown',
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,但 HTTP 200 仍视为 active
|
||||
return {
|
||||
status: 'active',
|
||||
accountId: undefined,
|
||||
planType: 'unknown',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
error: errorMessage,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : '网络错误',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量检查账号(带并发控制)
|
||||
* @param accounts - 待检查的账号列表
|
||||
* @param options.concurrency - 并发数量(默认 20)
|
||||
* @param options.onProgress - 进度回调,每检查完一个账号调用一次
|
||||
* @returns 检查完成的账号列表
|
||||
*
|
||||
* 使用队列 + Promise 实现并发控制,确保任意时刻活跃请求数 ≤ concurrency
|
||||
*/
|
||||
async batchCheck(
|
||||
accounts: AccountInput[],
|
||||
options: {
|
||||
concurrency: number
|
||||
onProgress?: (result: CheckedAccount, index: number) => void
|
||||
}
|
||||
): Promise<CheckedAccount[]> {
|
||||
const { concurrency, onProgress } = options
|
||||
|
||||
// 空数组直接返回
|
||||
if (accounts.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const results: CheckedAccount[] = new Array(accounts.length)
|
||||
const queue: number[] = [...Array(accounts.length).keys()]
|
||||
let activeCount = 0
|
||||
let completedCount = 0
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const processNext = () => {
|
||||
// 所有任务完成
|
||||
if (completedCount === accounts.length) {
|
||||
resolve(results)
|
||||
return
|
||||
}
|
||||
|
||||
// 启动新任务,直到达到并发限制或队列为空
|
||||
while (activeCount < concurrency && queue.length > 0) {
|
||||
const index = queue.shift()!
|
||||
activeCount++
|
||||
|
||||
// 异步处理单个账号
|
||||
this.processAccount(accounts[index], index)
|
||||
.then((checkedAccount) => {
|
||||
results[index] = checkedAccount
|
||||
onProgress?.(checkedAccount, index)
|
||||
})
|
||||
.finally(() => {
|
||||
activeCount--
|
||||
completedCount++
|
||||
// 继续处理下一个
|
||||
processNext()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 开始处理
|
||||
processNext()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理单个账号检查
|
||||
* @private
|
||||
*/
|
||||
private async processAccount(account: AccountInput, index: number): Promise<CheckedAccount> {
|
||||
const checkResult = await this.checkAccount(account.token)
|
||||
|
||||
return {
|
||||
...account,
|
||||
id: index,
|
||||
status: checkResult.status,
|
||||
accountId: checkResult.accountId,
|
||||
planType: checkResult.planType,
|
||||
error: checkResult.error,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析账号状态为中文描述
|
||||
*/
|
||||
export function getStatusText(status: AccountStatus): string {
|
||||
const statusMap: Record<AccountStatus, string> = {
|
||||
pending: '待检查',
|
||||
checking: '检查中',
|
||||
active: '正常',
|
||||
banned: '封禁',
|
||||
token_expired: '过期',
|
||||
error: '错误',
|
||||
}
|
||||
return statusMap[status] || status
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取状态对应的颜色类名
|
||||
*/
|
||||
export function getStatusColor(status: AccountStatus): string {
|
||||
const colorMap: Record<AccountStatus, string> = {
|
||||
pending: 'gray',
|
||||
checking: 'blue',
|
||||
active: 'green',
|
||||
banned: 'red',
|
||||
token_expired: 'orange',
|
||||
error: 'yellow',
|
||||
}
|
||||
return colorMap[status] || 'gray'
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 ChatGPT 客户端实例
|
||||
* @param baseUrl - 可选的基础 URL,默认为空(使用相对路径)
|
||||
*/
|
||||
export function createChatGPTClient(baseUrl: string = ''): ChatGPTClient {
|
||||
return new ChatGPTClient(baseUrl)
|
||||
}
|
||||
|
||||
// 导出默认客户端实例(使用相对路径,通过 nginx 代理)
|
||||
export const chatGPTClient = new ChatGPTClient()
|
||||
4
frontend/src/api/index.ts
Normal file
4
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// API Layer barrel export
|
||||
export * from './types'
|
||||
export * from './s2a'
|
||||
export * from './chatgpt'
|
||||
155
frontend/src/api/s2a.ts
Normal file
155
frontend/src/api/s2a.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
DashboardStatsResponse,
|
||||
DashboardTrendResponse,
|
||||
AccountListResponse,
|
||||
AccountResponse,
|
||||
CreateAccountPayload,
|
||||
OAuthCreatePayload,
|
||||
GroupResponse,
|
||||
ProxyResponse,
|
||||
TestAccountResponse,
|
||||
} from './types'
|
||||
import type { AccountListParams } from '../types'
|
||||
|
||||
// 使用后端代理 API 来避免 CORS 问题
|
||||
const PROXY_BASE = 'http://localhost:8088/api/s2a/proxy'
|
||||
|
||||
export class S2AClient {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
constructor(_config: { baseUrl: string; apiKey: string }) {
|
||||
// 不再使用直接配置,通过后端代理
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
// 将 /api/v1/admin/* 转换为代理路径
|
||||
const proxyEndpoint = endpoint.replace('/api/v1/admin', '')
|
||||
const url = `${PROXY_BASE}${proxyEndpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
errorData.message || errorData.error || `HTTP ${response.status}: ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// Dashboard APIs
|
||||
async getDashboardStats(): Promise<DashboardStatsResponse> {
|
||||
return this.request<DashboardStatsResponse>('/dashboard/stats')
|
||||
}
|
||||
|
||||
async getDashboardTrend(granularity: 'day' | 'hour' = 'day'): Promise<DashboardTrendResponse> {
|
||||
return this.request<DashboardTrendResponse>(
|
||||
`/dashboard/trend?granularity=${granularity}`
|
||||
)
|
||||
}
|
||||
|
||||
// Account APIs
|
||||
async getAccounts(params: AccountListParams = {}): Promise<AccountListResponse> {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (params.page) searchParams.set('page', params.page.toString())
|
||||
if (params.page_size) searchParams.set('page_size', params.page_size.toString())
|
||||
if (params.platform) searchParams.set('platform', params.platform)
|
||||
if (params.type) searchParams.set('type', params.type)
|
||||
if (params.status) searchParams.set('status', params.status)
|
||||
if (params.search) searchParams.set('search', params.search)
|
||||
|
||||
const queryString = searchParams.toString()
|
||||
const endpoint = `/accounts${queryString ? `?${queryString}` : ''}`
|
||||
return this.request<AccountListResponse>(endpoint)
|
||||
}
|
||||
|
||||
async getAccount(id: number): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>(`/accounts/${id}`)
|
||||
}
|
||||
|
||||
async createAccount(data: CreateAccountPayload): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>('/accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async createFromOAuth(data: OAuthCreatePayload): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>('/openai/create-from-oauth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async updateAccount(id: number, data: Partial<CreateAccountPayload>): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>(`/accounts/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
async deleteAccount(id: number): Promise<void> {
|
||||
await this.request<void>(`/accounts/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
async testAccount(id: number): Promise<TestAccountResponse> {
|
||||
return this.request<TestAccountResponse>(`/accounts/${id}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async refreshAccountToken(id: number): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>(`/accounts/${id}/refresh`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async clearAccountError(id: number): Promise<AccountResponse> {
|
||||
return this.request<AccountResponse>(`/accounts/${id}/clear-error`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Group APIs
|
||||
async getGroups(): Promise<GroupResponse[]> {
|
||||
const response = await this.request<{ data: GroupResponse[] }>('/groups/all')
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
// Proxy APIs
|
||||
async getProxies(): Promise<ProxyResponse[]> {
|
||||
const response = await this.request<{ data: ProxyResponse[] }>('/proxies/all')
|
||||
return response.data || []
|
||||
}
|
||||
|
||||
async testProxy(id: number): Promise<TestAccountResponse> {
|
||||
return this.request<TestAccountResponse>(`/proxies/${id}/test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
// Connection test
|
||||
async testConnection(): Promise<boolean> {
|
||||
try {
|
||||
await this.getDashboardStats()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 创建默认客户端实例的工厂函数
|
||||
export function createS2AClient(baseUrl: string, apiKey: string): S2AClient {
|
||||
return new S2AClient({ baseUrl, apiKey })
|
||||
}
|
||||
522
frontend/src/api/types.ts
Normal file
522
frontend/src/api/types.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
// =============================================================================
|
||||
// S2A API 响应类型
|
||||
// 基于 requirements.md Appendix A2 定义
|
||||
// =============================================================================
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Dashboard 相关接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Dashboard Stats 响应
|
||||
* API: GET /api/v1/admin/dashboard/stats
|
||||
* 获取号池统计(账号数、请求数、Token消耗等)
|
||||
*/
|
||||
export interface DashboardStatsResponse {
|
||||
total_accounts: number
|
||||
normal_accounts: number
|
||||
error_accounts: number
|
||||
ratelimit_accounts: number
|
||||
overload_accounts: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
today_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
rpm: number
|
||||
tpm: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Trend 响应
|
||||
* API: GET /api/v1/admin/dashboard/trend
|
||||
* 获取使用趋势(支持 granularity=day/hour)
|
||||
*/
|
||||
export interface DashboardTrendResponse {
|
||||
data: TrendDataPoint[]
|
||||
}
|
||||
|
||||
export interface TrendDataPoint {
|
||||
date: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Models 响应
|
||||
* API: GET /api/v1/admin/dashboard/models
|
||||
* 获取模型使用统计
|
||||
*/
|
||||
export interface DashboardModelsResponse {
|
||||
data: ModelUsageStats[]
|
||||
}
|
||||
|
||||
export interface ModelUsageStats {
|
||||
model: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard Users Trend 响应
|
||||
* API: GET /api/v1/admin/dashboard/users-trend
|
||||
* 获取用户使用趋势
|
||||
*/
|
||||
export interface DashboardUsersTrendResponse {
|
||||
data: UsersTrendDataPoint[]
|
||||
}
|
||||
|
||||
export interface UsersTrendDataPoint {
|
||||
date: string
|
||||
active_users: number
|
||||
new_users: number
|
||||
total_users: number
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 账号管理接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 账号列表响应
|
||||
* API: GET /api/v1/admin/accounts
|
||||
* 获取账号列表(支持分页、筛选)
|
||||
*/
|
||||
export interface AccountListResponse {
|
||||
data: AccountResponse[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个账号响应
|
||||
* API: GET /api/v1/admin/accounts/:id
|
||||
* 对应 requirements.md A5 Account 数据结构
|
||||
*/
|
||||
export interface AccountResponse {
|
||||
id: number
|
||||
name: string
|
||||
notes?: string
|
||||
platform: 'openai' | 'anthropic' | 'gemini'
|
||||
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
|
||||
credentials: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
proxy_id?: number
|
||||
concurrency: number
|
||||
priority: number
|
||||
rate_multiplier?: number
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
error_message?: string
|
||||
schedulable: boolean
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
auto_pause_on_expired: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
current_concurrency?: number
|
||||
current_window_cost?: number
|
||||
active_sessions?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号统计响应
|
||||
* API: GET /api/v1/admin/accounts/:id/stats
|
||||
* 获取账号使用统计
|
||||
*/
|
||||
export interface AccountStatsResponse {
|
||||
account_id: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
today_cost: number
|
||||
last_used_at?: string
|
||||
error_count: number
|
||||
success_rate: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建账号请求
|
||||
* API: POST /api/v1/admin/accounts
|
||||
* 对应 requirements.md A4 access_token 类型账号
|
||||
*/
|
||||
export interface CreateAccountPayload {
|
||||
name: string
|
||||
platform: 'openai' | 'anthropic' | 'gemini'
|
||||
type: 'access_token'
|
||||
credentials: {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
email?: string
|
||||
}
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
proxy_id?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新账号请求
|
||||
* API: POST /api/v1/admin/accounts/bulk-update
|
||||
*/
|
||||
export interface BulkUpdateAccountsPayload {
|
||||
ids: number[]
|
||||
updates: {
|
||||
status?: 'active' | 'inactive'
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
proxy_id?: number | null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新账号响应
|
||||
*/
|
||||
export interface BulkUpdateAccountsResponse {
|
||||
success: boolean
|
||||
updated_count: number
|
||||
failed_count: number
|
||||
errors?: string[]
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// OpenAI OAuth 接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* OAuth 创建账号请求
|
||||
* API: POST /api/v1/admin/openai/create-from-oauth
|
||||
* 对应 requirements.md A4 OAuth 类型账号
|
||||
*/
|
||||
export interface OAuthCreatePayload {
|
||||
session_id: string
|
||||
code: string
|
||||
name?: string
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
proxy_id?: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OAuth 授权 URL 请求
|
||||
* API: POST /api/v1/admin/openai/generate-auth-url
|
||||
*/
|
||||
export interface GenerateAuthUrlPayload {
|
||||
redirect_uri?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 OAuth 授权 URL 响应
|
||||
*/
|
||||
export interface GenerateAuthUrlResponse {
|
||||
auth_url: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换授权码请求
|
||||
* API: POST /api/v1/admin/openai/exchange-code
|
||||
*/
|
||||
export interface ExchangeCodePayload {
|
||||
session_id: string
|
||||
code: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 交换授权码响应
|
||||
*/
|
||||
export interface ExchangeCodeResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token 请求
|
||||
* API: POST /api/v1/admin/openai/refresh-token
|
||||
*/
|
||||
export interface RefreshTokenPayload {
|
||||
refresh_token: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Token 响应
|
||||
*/
|
||||
export interface RefreshTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in?: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 分组管理接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 分组响应
|
||||
* API: GET /api/v1/admin/groups, GET /api/v1/admin/groups/all
|
||||
*/
|
||||
export interface GroupResponse {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 分组统计响应
|
||||
* API: GET /api/v1/admin/groups/:id/stats
|
||||
*/
|
||||
export interface GroupStatsResponse {
|
||||
group_id: number
|
||||
total_accounts: number
|
||||
active_accounts: number
|
||||
error_accounts: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 代理管理接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 代理响应
|
||||
* API: GET /api/v1/admin/proxies, GET /api/v1/admin/proxies/all
|
||||
*/
|
||||
export interface ProxyResponse {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试代理/账号响应
|
||||
* API: POST /api/v1/admin/proxies/:id/test, POST /api/v1/admin/accounts/:id/test
|
||||
*/
|
||||
export interface TestAccountResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
latency?: number
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// 运维监控接口响应
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 并发统计响应
|
||||
* API: GET /api/v1/admin/ops/concurrency
|
||||
*/
|
||||
export interface OpsConcurrencyResponse {
|
||||
total_concurrency: number
|
||||
used_concurrency: number
|
||||
available_concurrency: number
|
||||
accounts: AccountConcurrencyInfo[]
|
||||
}
|
||||
|
||||
export interface AccountConcurrencyInfo {
|
||||
account_id: number
|
||||
account_name: string
|
||||
max_concurrency: number
|
||||
current_concurrency: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 账号可用性响应
|
||||
* API: GET /api/v1/admin/ops/account-availability
|
||||
*/
|
||||
export interface OpsAccountAvailabilityResponse {
|
||||
total_accounts: number
|
||||
available_accounts: number
|
||||
unavailable_accounts: number
|
||||
availability_rate: number
|
||||
accounts: AccountAvailabilityInfo[]
|
||||
}
|
||||
|
||||
export interface AccountAvailabilityInfo {
|
||||
account_id: number
|
||||
account_name: string
|
||||
status: 'available' | 'unavailable' | 'rate_limited' | 'error'
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 实时流量响应
|
||||
* API: GET /api/v1/admin/ops/realtime-traffic
|
||||
*/
|
||||
export interface OpsRealtimeTrafficResponse {
|
||||
current_rpm: number
|
||||
current_tpm: number
|
||||
peak_rpm: number
|
||||
peak_tpm: number
|
||||
requests_last_minute: number
|
||||
tokens_last_minute: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 运维仪表盘概览响应
|
||||
* API: GET /api/v1/admin/ops/dashboard/overview
|
||||
*/
|
||||
export interface OpsDashboardOverviewResponse {
|
||||
health_score: number
|
||||
total_accounts: number
|
||||
healthy_accounts: number
|
||||
warning_accounts: number
|
||||
error_accounts: number
|
||||
current_load: number
|
||||
max_load: number
|
||||
alerts: OpsAlert[]
|
||||
}
|
||||
|
||||
export interface OpsAlert {
|
||||
id: string
|
||||
level: 'info' | 'warning' | 'error' | 'critical'
|
||||
message: string
|
||||
timestamp: string
|
||||
account_id?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误趋势响应
|
||||
* API: GET /api/v1/admin/ops/dashboard/error-trend
|
||||
*/
|
||||
export interface OpsErrorTrendResponse {
|
||||
data: ErrorTrendDataPoint[]
|
||||
}
|
||||
|
||||
export interface ErrorTrendDataPoint {
|
||||
date: string
|
||||
total_errors: number
|
||||
rate_limit_errors: number
|
||||
auth_errors: number
|
||||
network_errors: number
|
||||
other_errors: number
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ChatGPT API 响应类型
|
||||
// 基于 requirements.md Requirement 1.2 定义
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* 账号检查响应
|
||||
* API: GET https://chatgpt.com/backend-api/accounts/check/v4-2023-04-27
|
||||
* Headers: Authorization: Bearer {token}
|
||||
*/
|
||||
export interface ChatGPTCheckResponse {
|
||||
accounts: ChatGPTAccountInfo[]
|
||||
}
|
||||
|
||||
export interface ChatGPTAccountInfo {
|
||||
account_id: string
|
||||
account: ChatGPTAccountDetails
|
||||
features: string[]
|
||||
entitlement: ChatGPTEntitlement
|
||||
last_active_subscription: ChatGPTLastActiveSubscription
|
||||
is_eligible_for_yearly_plus_subscription: boolean
|
||||
}
|
||||
|
||||
export interface ChatGPTAccountDetails {
|
||||
account_user_id: string
|
||||
processor: {
|
||||
a001: {
|
||||
has_customer_object: boolean
|
||||
}
|
||||
}
|
||||
account_user_role: string
|
||||
plan_type: string
|
||||
is_most_recent_expired_subscription_gratis: boolean
|
||||
has_previously_paid_subscription: boolean
|
||||
name: string | null
|
||||
profile_picture_id: string | null
|
||||
profile_picture_url: string | null
|
||||
structure: string
|
||||
is_deactivated: boolean
|
||||
is_disabled: boolean
|
||||
// SAM (Security Account Management) 相关字段
|
||||
is_sam_enforced: boolean
|
||||
is_sam_enabled: boolean
|
||||
is_sam_compliant: boolean
|
||||
is_sam_grace_period: boolean
|
||||
is_sam_grace_period_expired: boolean
|
||||
is_sam_grace_period_expiring_soon: boolean
|
||||
is_sam_grace_period_expiring_today: boolean
|
||||
is_sam_grace_period_expiring_tomorrow: boolean
|
||||
is_sam_grace_period_expiring_in_two_days: boolean
|
||||
is_sam_grace_period_expiring_in_three_days: boolean
|
||||
is_sam_grace_period_expiring_in_four_days: boolean
|
||||
is_sam_grace_period_expiring_in_five_days: boolean
|
||||
is_sam_grace_period_expiring_in_six_days: boolean
|
||||
is_sam_grace_period_expiring_in_seven_days: boolean
|
||||
}
|
||||
|
||||
export interface ChatGPTEntitlement {
|
||||
subscription_id: string | null
|
||||
has_active_subscription: boolean
|
||||
subscription_plan: string
|
||||
expires_at: string | null
|
||||
}
|
||||
|
||||
export interface ChatGPTLastActiveSubscription {
|
||||
subscription_id: string | null
|
||||
purchase_origin_platform: string
|
||||
will_renew: boolean
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 通用 API 类型
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* API 错误响应
|
||||
* 通用错误响应格式
|
||||
*/
|
||||
export interface ApiErrorResponse {
|
||||
error: string
|
||||
message?: string
|
||||
code?: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页请求参数
|
||||
*/
|
||||
export interface PaginationParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应包装
|
||||
*/
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用列表响应包装
|
||||
*/
|
||||
export interface ListResponse<T> {
|
||||
data: T[]
|
||||
}
|
||||
215
frontend/src/components/common/Button.test.tsx
Normal file
215
frontend/src/components/common/Button.test.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import Button from './Button'
|
||||
|
||||
describe('Button', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders children correctly', () => {
|
||||
render(<Button>Click me</Button>)
|
||||
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders with default props', () => {
|
||||
render(<Button>Default Button</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
// Default variant is primary, default size is md
|
||||
expect(button).toHaveClass('bg-primary-600')
|
||||
expect(button).toHaveClass('px-4', 'py-2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('variants', () => {
|
||||
it('renders primary variant correctly', () => {
|
||||
render(<Button variant="primary">Primary</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('bg-primary-600', 'text-white')
|
||||
})
|
||||
|
||||
it('renders secondary variant correctly', () => {
|
||||
render(<Button variant="secondary">Secondary</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('bg-slate-100', 'text-slate-900')
|
||||
})
|
||||
|
||||
it('renders danger variant correctly', () => {
|
||||
render(<Button variant="danger">Danger</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('bg-error-500', 'text-white')
|
||||
})
|
||||
|
||||
it('renders ghost variant correctly', () => {
|
||||
render(<Button variant="ghost">Ghost</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('text-slate-700', 'bg-transparent')
|
||||
})
|
||||
|
||||
it('renders outline variant correctly', () => {
|
||||
render(<Button variant="outline">Outline</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('border', 'border-slate-300', 'text-slate-700')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sizes', () => {
|
||||
it('renders small size correctly', () => {
|
||||
render(<Button size="sm">Small</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('px-3', 'py-1.5', 'text-sm')
|
||||
})
|
||||
|
||||
it('renders medium size correctly', () => {
|
||||
render(<Button size="md">Medium</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('px-4', 'py-2', 'text-sm')
|
||||
})
|
||||
|
||||
it('renders large size correctly', () => {
|
||||
render(<Button size="lg">Large</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('px-6', 'py-3', 'text-base')
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
it('shows spinner when loading', () => {
|
||||
render(<Button loading>Loading</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
// Check for the spinner (Loader2 icon with animate-spin class)
|
||||
const spinner = button.querySelector('.animate-spin')
|
||||
expect(spinner).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables button when loading', () => {
|
||||
render(<Button loading>Loading</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('sets aria-busy when loading', () => {
|
||||
render(<Button loading>Loading</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('aria-busy', 'true')
|
||||
})
|
||||
|
||||
it('hides icon when loading', () => {
|
||||
const icon = <span data-testid="test-icon">Icon</span>
|
||||
render(
|
||||
<Button loading icon={icon}>
|
||||
With Icon
|
||||
</Button>
|
||||
)
|
||||
expect(screen.queryByTestId('test-icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('disabled state', () => {
|
||||
it('disables button when disabled prop is true', () => {
|
||||
render(<Button disabled>Disabled</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toBeDisabled()
|
||||
})
|
||||
|
||||
it('applies disabled styles', () => {
|
||||
render(<Button disabled>Disabled</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('disabled:opacity-50', 'disabled:cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('sets aria-disabled when disabled', () => {
|
||||
render(<Button disabled>Disabled</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
|
||||
it('does not call onClick when disabled', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Button disabled onClick={handleClick}>
|
||||
Disabled
|
||||
</Button>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('icon support', () => {
|
||||
it('renders icon when provided', () => {
|
||||
const icon = <span data-testid="test-icon">★</span>
|
||||
render(<Button icon={icon}>With Icon</Button>)
|
||||
expect(screen.getByTestId('test-icon')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders icon before text', () => {
|
||||
const icon = <span data-testid="test-icon">★</span>
|
||||
render(<Button icon={icon}>With Icon</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
const iconElement = screen.getByTestId('test-icon')
|
||||
// Icon should be a child of the button
|
||||
expect(button).toContainElement(iconElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('click handling', () => {
|
||||
it('calls onClick when clicked', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(<Button onClick={handleClick}>Click me</Button>)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not call onClick when loading', () => {
|
||||
const handleClick = vi.fn()
|
||||
render(
|
||||
<Button loading onClick={handleClick}>
|
||||
Loading
|
||||
</Button>
|
||||
)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(handleClick).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('custom className', () => {
|
||||
it('applies custom className', () => {
|
||||
render(<Button className="custom-class">Custom</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('merges custom className with default classes', () => {
|
||||
render(<Button className="custom-class">Custom</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveClass('custom-class', 'bg-primary-600')
|
||||
})
|
||||
})
|
||||
|
||||
describe('forwarded ref', () => {
|
||||
it('forwards ref to button element', () => {
|
||||
const ref = vi.fn()
|
||||
render(<Button ref={ref}>Ref Button</Button>)
|
||||
expect(ref).toHaveBeenCalled()
|
||||
expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLButtonElement)
|
||||
})
|
||||
})
|
||||
|
||||
describe('HTML button attributes', () => {
|
||||
it('passes through type attribute', () => {
|
||||
render(<Button type="submit">Submit</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('type', 'submit')
|
||||
})
|
||||
|
||||
it('passes through form attribute', () => {
|
||||
render(<Button form="my-form">Submit</Button>)
|
||||
const button = screen.getByRole('button')
|
||||
expect(button).toHaveAttribute('form', 'my-form')
|
||||
})
|
||||
|
||||
it('passes through aria-label', () => {
|
||||
render(<Button aria-label="Close dialog">×</Button>)
|
||||
const button = screen.getByRole('button', { name: 'Close dialog' })
|
||||
expect(button).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
158
frontend/src/components/common/Button.tsx
Normal file
158
frontend/src/components/common/Button.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { type ButtonHTMLAttributes, forwardRef } from 'react'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Button component variants
|
||||
* - primary: Main action button using design system primary color (Blue-600)
|
||||
* - secondary: Secondary action button with subtle background
|
||||
* - outline: Bordered button with transparent background
|
||||
* - danger: Destructive action button using design system error color (Red-500)
|
||||
* - ghost: Minimal button with no background, only hover state
|
||||
*/
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost'
|
||||
|
||||
/**
|
||||
* Button component sizes
|
||||
* - sm: Small button for compact UIs
|
||||
* - md: Medium button (default)
|
||||
* - lg: Large button for prominent actions
|
||||
*/
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual style variant of the button */
|
||||
variant?: ButtonVariant
|
||||
/** Size of the button */
|
||||
size?: ButtonSize
|
||||
/** Shows a loading spinner and disables the button */
|
||||
loading?: boolean
|
||||
/** Optional icon to display before the button text */
|
||||
icon?: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A reusable Button component with support for multiple variants, sizes,
|
||||
* loading state, and disabled state. Uses TailwindCSS with design system colors.
|
||||
*
|
||||
* @example
|
||||
* // Primary button
|
||||
* <Button variant="primary">Submit</Button>
|
||||
*
|
||||
* @example
|
||||
* // Loading state
|
||||
* <Button loading>Processing...</Button>
|
||||
*
|
||||
* @example
|
||||
* // With icon
|
||||
* <Button icon={<PlusIcon />}>Add Item</Button>
|
||||
*/
|
||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{
|
||||
className = '',
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
disabled,
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// Base styles applied to all button variants
|
||||
const baseStyles = [
|
||||
'inline-flex items-center justify-center',
|
||||
'font-medium rounded-lg',
|
||||
'transition-colors duration-200',
|
||||
'focus:outline-none focus:ring-2 focus:ring-offset-2',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
].join(' ')
|
||||
|
||||
// Variant-specific styles using design system colors
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
// Primary: Blue-600 (#2563EB) - Main action button
|
||||
primary: [
|
||||
'bg-primary-600 text-white',
|
||||
'hover:bg-primary-700',
|
||||
'focus:ring-primary-500',
|
||||
'dark:bg-primary-500 dark:hover:bg-primary-600',
|
||||
].join(' '),
|
||||
|
||||
// Secondary: Slate background - Secondary action button
|
||||
secondary: [
|
||||
'bg-slate-100 text-slate-900',
|
||||
'hover:bg-slate-200',
|
||||
'focus:ring-slate-500',
|
||||
'dark:bg-slate-700 dark:text-slate-100 dark:hover:bg-slate-600',
|
||||
].join(' '),
|
||||
|
||||
// Outline: Bordered button with transparent background
|
||||
outline: [
|
||||
'border border-slate-300 text-slate-700 bg-transparent',
|
||||
'hover:bg-slate-50',
|
||||
'focus:ring-slate-500',
|
||||
'dark:border-slate-600 dark:text-slate-300 dark:hover:bg-slate-800',
|
||||
].join(' '),
|
||||
|
||||
// Danger: Red-500 (#EF4444) - Destructive action button
|
||||
danger: [
|
||||
'bg-error-500 text-white',
|
||||
'hover:bg-error-600',
|
||||
'focus:ring-error-500',
|
||||
'dark:bg-error-500 dark:hover:bg-error-600',
|
||||
].join(' '),
|
||||
|
||||
// Ghost: Transparent background - Minimal button
|
||||
ghost: [
|
||||
'text-slate-700 bg-transparent',
|
||||
'hover:bg-slate-100',
|
||||
'focus:ring-slate-500',
|
||||
'dark:text-slate-300 dark:hover:bg-slate-800',
|
||||
].join(' '),
|
||||
}
|
||||
|
||||
// Size-specific styles
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm gap-1.5',
|
||||
md: 'px-4 py-2 text-sm gap-2',
|
||||
lg: 'px-6 py-3 text-base gap-2',
|
||||
}
|
||||
|
||||
// Spinner size based on button size
|
||||
const spinnerSizeStyles: Record<ButtonSize, string> = {
|
||||
sm: 'h-3.5 w-3.5',
|
||||
md: 'h-4 w-4',
|
||||
lg: 'h-5 w-5',
|
||||
}
|
||||
|
||||
const isDisabled = disabled || loading
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${variantStyles[variant]} ${sizeStyles[size]} ${className}`}
|
||||
disabled={isDisabled}
|
||||
aria-busy={loading}
|
||||
aria-disabled={isDisabled}
|
||||
{...props}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2
|
||||
className={`${spinnerSizeStyles[size]} animate-spin`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : icon ? (
|
||||
<span className="flex-shrink-0" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
) : null}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
84
frontend/src/components/common/Card.tsx
Normal file
84
frontend/src/components/common/Card.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { type HTMLAttributes, forwardRef } from 'react'
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
hoverable?: boolean
|
||||
variant?: 'default' | 'glass'
|
||||
}
|
||||
|
||||
const Card = forwardRef<HTMLDivElement, CardProps>(
|
||||
({ className = '', padding = 'md', hoverable = false, variant = 'default', children, ...props }, ref) => {
|
||||
const paddingStyles = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
const baseStyles = variant === 'glass'
|
||||
? 'glass-card'
|
||||
: 'bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 shadow-sm rounded-xl'
|
||||
|
||||
const hoverStyles = hoverable ? 'card-hover cursor-pointer' : ''
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${baseStyles} ${paddingStyles[padding]} ${hoverStyles} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Card.displayName = 'Card'
|
||||
|
||||
export interface CardHeaderProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const CardHeader = forwardRef<HTMLDivElement, CardHeaderProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={`flex items-center justify-between mb-4 ${className}`} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
export interface CardTitleProps extends HTMLAttributes<HTMLHeadingElement> {}
|
||||
|
||||
export const CardTitle = forwardRef<HTMLHeadingElement, CardTitleProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={`text-lg font-semibold text-slate-900 dark:text-slate-100 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h3>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
export interface CardContentProps extends HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export const CardContent = forwardRef<HTMLDivElement, CardContentProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
export default Card
|
||||
61
frontend/src/components/common/ErrorBoundary.tsx
Normal file
61
frontend/src/components/common/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
import Button from './Button'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props)
|
||||
this.state = { hasError: false, error: null }
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error }
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo)
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null })
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-[400px] flex items-center justify-center p-8">
|
||||
<div className="text-center max-w-md">
|
||||
<div className="h-16 w-16 mx-auto mb-4 rounded-2xl bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||
<AlertTriangle className="h-8 w-8 text-red-500" />
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100 mb-2">
|
||||
出错了
|
||||
</h2>
|
||||
<p className="text-slate-500 dark:text-slate-400 mb-4">
|
||||
{this.state.error?.message || '发生了一个意外错误'}
|
||||
</p>
|
||||
<Button onClick={this.handleReset} icon={<RefreshCw className="h-4 w-4" />}>
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
100
frontend/src/components/common/Input.tsx
Normal file
100
frontend/src/components/common/Input.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { type InputHTMLAttributes, type TextareaHTMLAttributes, forwardRef } from 'react'
|
||||
|
||||
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className = '', label, error, hint, id, ...props }, ref) => {
|
||||
const inputId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
id={inputId}
|
||||
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
placeholder-slate-400 dark:placeholder-slate-500
|
||||
${
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className = '', label, error, hint, id, ...props }, ref) => {
|
||||
const textareaId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={textareaId}
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<textarea
|
||||
ref={ref}
|
||||
id={textareaId}
|
||||
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
placeholder-slate-400 dark:placeholder-slate-500
|
||||
${
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
resize-none
|
||||
${className}`}
|
||||
{...props}
|
||||
/>
|
||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export default Input
|
||||
68
frontend/src/components/common/Progress.tsx
Normal file
68
frontend/src/components/common/Progress.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { type HTMLAttributes, forwardRef } from 'react'
|
||||
|
||||
export interface ProgressProps extends HTMLAttributes<HTMLDivElement> {
|
||||
value: number
|
||||
max?: number
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red'
|
||||
showLabel?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
const Progress = forwardRef<HTMLDivElement, ProgressProps>(
|
||||
(
|
||||
{
|
||||
className = '',
|
||||
value,
|
||||
max = 100,
|
||||
size = 'md',
|
||||
color = 'blue',
|
||||
showLabel = false,
|
||||
label,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const percentage = Math.min(Math.max((value / max) * 100, 0), 100)
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'h-1.5',
|
||||
md: 'h-2.5',
|
||||
lg: 'h-4',
|
||||
}
|
||||
|
||||
const colorStyles = {
|
||||
blue: 'bg-blue-600 dark:bg-blue-500',
|
||||
green: 'bg-green-600 dark:bg-green-500',
|
||||
yellow: 'bg-yellow-500 dark:bg-yellow-400',
|
||||
red: 'bg-red-600 dark:bg-red-500',
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={className} {...props}>
|
||||
{(showLabel || label) && (
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{label || '进度'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{percentage.toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`w-full bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden ${sizeStyles[size]}`}
|
||||
>
|
||||
<div
|
||||
className={`${sizeStyles[size]} ${colorStyles[color]} rounded-full transition-all duration-300 ease-out`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Progress.displayName = 'Progress'
|
||||
|
||||
export default Progress
|
||||
74
frontend/src/components/common/Select.tsx
Normal file
74
frontend/src/components/common/Select.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { type SelectHTMLAttributes, forwardRef } from 'react'
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number
|
||||
label: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, 'children'> {
|
||||
label?: string
|
||||
error?: string
|
||||
hint?: string
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ className = '', label, error, hint, options, placeholder, id, ...props }, ref) => {
|
||||
const selectId = id || label?.toLowerCase().replace(/\s+/g, '-')
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={selectId}
|
||||
className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="relative">
|
||||
<select
|
||||
ref={ref}
|
||||
id={selectId}
|
||||
className={`w-full px-3 py-2 text-sm rounded-lg border transition-colors appearance-none
|
||||
bg-white dark:bg-slate-800
|
||||
text-slate-900 dark:text-slate-100
|
||||
${
|
||||
error
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-500'
|
||||
: 'border-slate-300 dark:border-slate-600 focus:border-blue-500 focus:ring-blue-500'
|
||||
}
|
||||
focus:outline-none focus:ring-2 focus:ring-offset-0
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
pr-10
|
||||
${className}`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400 pointer-events-none" />
|
||||
</div>
|
||||
{error && <p className="mt-1 text-sm text-red-500">{error}</p>}
|
||||
{hint && !error && (
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Select.displayName = 'Select'
|
||||
|
||||
export default Select
|
||||
66
frontend/src/components/common/StatusBadge.tsx
Normal file
66
frontend/src/components/common/StatusBadge.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type HTMLAttributes, forwardRef } from 'react'
|
||||
import type { AccountStatus } from '../../types'
|
||||
|
||||
export interface StatusBadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
||||
status: AccountStatus | 'active' | 'inactive' | 'error'
|
||||
size?: 'sm' | 'md'
|
||||
}
|
||||
|
||||
const StatusBadge = forwardRef<HTMLSpanElement, StatusBadgeProps>(
|
||||
({ className = '', status, size = 'md', ...props }, ref) => {
|
||||
const getStatusConfig = (status: string) => {
|
||||
const configs: Record<string, { label: string; color: string }> = {
|
||||
pending: {
|
||||
label: '待检查',
|
||||
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
},
|
||||
checking: {
|
||||
label: '检查中',
|
||||
color: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
},
|
||||
active: {
|
||||
label: '正常',
|
||||
color: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
},
|
||||
banned: {
|
||||
label: '封禁',
|
||||
color: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
},
|
||||
token_expired: {
|
||||
label: '过期',
|
||||
color: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
},
|
||||
error: {
|
||||
label: '错误',
|
||||
color: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
},
|
||||
inactive: {
|
||||
label: '停用',
|
||||
color: 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300',
|
||||
},
|
||||
}
|
||||
return configs[status] || configs.error
|
||||
}
|
||||
|
||||
const config = getStatusConfig(status)
|
||||
|
||||
const sizeStyles = {
|
||||
sm: 'px-2 py-0.5 text-xs',
|
||||
md: 'px-2.5 py-1 text-xs',
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
className={`inline-flex items-center font-medium rounded-full ${config.color} ${sizeStyles[size]} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
StatusBadge.displayName = 'StatusBadge'
|
||||
|
||||
export default StatusBadge
|
||||
110
frontend/src/components/common/Table.tsx
Normal file
110
frontend/src/components/common/Table.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type ThHTMLAttributes,
|
||||
type TdHTMLAttributes,
|
||||
forwardRef,
|
||||
} from 'react'
|
||||
|
||||
export interface TableProps extends HTMLAttributes<HTMLTableElement> {}
|
||||
|
||||
const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table ref={ref} className={`w-full text-sm text-left ${className}`} {...props}>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Table.displayName = 'Table'
|
||||
|
||||
export interface TableHeaderProps extends HTMLAttributes<HTMLTableSectionElement> {}
|
||||
|
||||
export const TableHeader = forwardRef<HTMLTableSectionElement, TableHeaderProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<thead
|
||||
ref={ref}
|
||||
className={`text-xs text-slate-700 uppercase bg-slate-50 dark:bg-slate-700 dark:text-slate-400 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</thead>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
export interface TableBodyProps extends HTMLAttributes<HTMLTableSectionElement> {}
|
||||
|
||||
export const TableBody = forwardRef<HTMLTableSectionElement, TableBodyProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<tbody ref={ref} className={className} {...props}>
|
||||
{children}
|
||||
</tbody>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
export interface TableRowProps extends HTMLAttributes<HTMLTableRowElement> {
|
||||
hoverable?: boolean
|
||||
}
|
||||
|
||||
export const TableRow = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||
({ className = '', hoverable = true, children, ...props }, ref) => {
|
||||
return (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={`border-b border-slate-200 dark:border-slate-700 ${
|
||||
hoverable ? 'hover:bg-slate-50 dark:hover:bg-slate-700/50' : ''
|
||||
} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
export interface TableHeadProps extends ThHTMLAttributes<HTMLTableCellElement> {}
|
||||
|
||||
export const TableHead = forwardRef<HTMLTableCellElement, TableHeadProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<th ref={ref} className={`px-4 py-3 font-medium ${className}`} {...props}>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
export interface TableCellProps extends TdHTMLAttributes<HTMLTableCellElement> {}
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
({ className = '', children, ...props }, ref) => {
|
||||
return (
|
||||
<td
|
||||
ref={ref}
|
||||
className={`px-4 py-3 text-slate-900 dark:text-slate-100 ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
export default Table
|
||||
48
frontend/src/components/common/Tabs.tsx
Normal file
48
frontend/src/components/common/Tabs.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface TabItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: LucideIcon
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface TabsProps {
|
||||
tabs: TabItem[]
|
||||
activeTab: string
|
||||
onChange: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Tabs({ tabs, activeTab, onChange, className = '' }: TabsProps) {
|
||||
return (
|
||||
<div className={`flex gap-2 border-b border-slate-200 dark:border-slate-800 ${className}`}>
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200 ${activeTab === tab.id
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
{tab.icon && <tab.icon className="h-4 w-4" />}
|
||||
{tab.label}
|
||||
{tab.count !== undefined && (
|
||||
<span
|
||||
className={`px-2 py-0.5 text-xs rounded-full ${activeTab === tab.id
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300'
|
||||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600 dark:bg-blue-400 rounded-t-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/common/Toast.tsx
Normal file
141
frontend/src/components/common/Toast.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, createContext, useContext, useCallback, type ReactNode } from 'react'
|
||||
import { CheckCircle, XCircle, AlertTriangle, Info, X } from 'lucide-react'
|
||||
|
||||
type ToastType = 'success' | 'error' | 'warning' | 'info'
|
||||
|
||||
interface Toast {
|
||||
id: string
|
||||
type: ToastType
|
||||
message: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
toasts: Toast[]
|
||||
addToast: (type: ToastType, message: string, duration?: number) => void
|
||||
removeToast: (id: string) => void
|
||||
success: (message: string) => void
|
||||
error: (message: string) => void
|
||||
warning: (message: string) => void
|
||||
info: (message: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: ToastProviderProps) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const addToast = useCallback(
|
||||
(type: ToastType, message: string, duration = 3000) => {
|
||||
const id = Math.random().toString(36).substring(2, 9)
|
||||
const toast: Toast = { id, type, message, duration }
|
||||
setToasts((prev) => [...prev, toast])
|
||||
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration)
|
||||
}
|
||||
},
|
||||
[removeToast]
|
||||
)
|
||||
|
||||
const success = useCallback((message: string) => addToast('success', message), [addToast])
|
||||
const error = useCallback((message: string) => addToast('error', message), [addToast])
|
||||
const warning = useCallback((message: string) => addToast('warning', message), [addToast])
|
||||
const info = useCallback((message: string) => addToast('info', message), [addToast])
|
||||
|
||||
return (
|
||||
<ToastContext.Provider
|
||||
value={{ toasts, addToast, removeToast, success, error, warning, info }}
|
||||
>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemove={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: Toast[]
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
function ToastContainer({ toasts, onRemove }: ToastContainerProps) {
|
||||
if (toasts.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 right-6 z-[9999] space-y-3">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} onRemove={onRemove} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ToastItemProps {
|
||||
toast: Toast
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
const typeStyles: Record<ToastType, { bg: string; icon: typeof CheckCircle }> = {
|
||||
success: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800',
|
||||
icon: CheckCircle,
|
||||
},
|
||||
error: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800',
|
||||
icon: XCircle,
|
||||
},
|
||||
warning: {
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
info: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800',
|
||||
icon: Info,
|
||||
},
|
||||
}
|
||||
|
||||
const iconColors: Record<ToastType, string> = {
|
||||
success: 'text-green-500',
|
||||
error: 'text-red-500',
|
||||
warning: 'text-yellow-500',
|
||||
info: 'text-blue-500',
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onRemove }: ToastItemProps) {
|
||||
const { bg, icon: Icon } = typeStyles[toast.type]
|
||||
const iconColor = iconColors[toast.type]
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-xl border shadow-lg animate-fadeIn min-w-[300px] max-w-md ${bg}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 flex-shrink-0 ${iconColor}`} />
|
||||
<p className="flex-1 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{toast.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => onRemove(toast.id)}
|
||||
className="p-1 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
frontend/src/components/common/index.ts
Normal file
33
frontend/src/components/common/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export { default as Button } from './Button'
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'
|
||||
|
||||
export { default as Card, CardHeader, CardTitle, CardContent } from './Card'
|
||||
export type { CardProps, CardHeaderProps, CardTitleProps, CardContentProps } from './Card'
|
||||
|
||||
export { default as Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table'
|
||||
export type {
|
||||
TableProps,
|
||||
TableHeaderProps,
|
||||
TableBodyProps,
|
||||
TableRowProps,
|
||||
TableHeadProps,
|
||||
TableCellProps,
|
||||
} from './Table'
|
||||
|
||||
export { default as Progress } from './Progress'
|
||||
export type { ProgressProps } from './Progress'
|
||||
|
||||
export { default as StatusBadge } from './StatusBadge'
|
||||
export type { StatusBadgeProps } from './StatusBadge'
|
||||
|
||||
export { default as Input, Textarea } from './Input'
|
||||
export type { InputProps, TextareaProps } from './Input'
|
||||
|
||||
export { default as Select } from './Select'
|
||||
export type { SelectProps, SelectOption } from './Select'
|
||||
|
||||
export { ErrorBoundary } from './ErrorBoundary'
|
||||
export { ToastProvider, useToast } from './Toast'
|
||||
|
||||
export { Tabs } from './Tabs'
|
||||
export type { TabItem } from './Tabs'
|
||||
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
64
frontend/src/components/dashboard/PoolStatus.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Users, CheckCircle, XCircle, AlertTriangle, Zap, Activity } from 'lucide-react'
|
||||
import type { DashboardStats } from '../../types'
|
||||
import StatsCard from './StatsCard'
|
||||
|
||||
interface PoolStatusProps {
|
||||
stats: DashboardStats | null
|
||||
loading: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function PoolStatus({ stats, loading, error }: PoolStatusProps) {
|
||||
if (error) {
|
||||
return (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<div className="flex items-center gap-2 text-red-700 dark:text-red-400">
|
||||
<XCircle className="h-5 w-5" />
|
||||
<span className="font-medium">获取数据失败</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-red-600 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-4">
|
||||
<StatsCard
|
||||
title="总账号数"
|
||||
value={stats?.total_accounts ?? 0}
|
||||
icon={Users}
|
||||
color="blue"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="正常账号"
|
||||
value={stats?.normal_accounts ?? 0}
|
||||
icon={CheckCircle}
|
||||
color="green"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="错误账号"
|
||||
value={stats?.error_accounts ?? 0}
|
||||
icon={XCircle}
|
||||
color="red"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="限流账号"
|
||||
value={stats?.ratelimit_accounts ?? 0}
|
||||
icon={AlertTriangle}
|
||||
color="yellow"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard
|
||||
title="今日请求"
|
||||
value={stats?.today_requests ?? 0}
|
||||
icon={Activity}
|
||||
color="slate"
|
||||
loading={loading}
|
||||
/>
|
||||
<StatsCard title="RPM" value={stats?.rpm ?? 0} icon={Zap} color="blue" loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
82
frontend/src/components/dashboard/RecentRecords.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Clock, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { AddRecord } from '../../types'
|
||||
import { formatRelativeTime } from '../../utils/format'
|
||||
import { Card, CardHeader, CardTitle, Button } from '../common'
|
||||
|
||||
interface RecentRecordsProps {
|
||||
records: AddRecord[]
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function RecentRecords({ records, loading = false }: RecentRecordsProps) {
|
||||
const recentRecords = records.slice(0, 5)
|
||||
|
||||
return (
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>最近加号记录</CardTitle>
|
||||
<Link to="/records">
|
||||
<Button variant="ghost" size="sm">
|
||||
查看全部
|
||||
</Button>
|
||||
</Link>
|
||||
</CardHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : recentRecords.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="h-12 w-12 mx-auto text-slate-300 dark:text-slate-600" />
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">暂无加号记录</p>
|
||||
<Link to="/upload" className="mt-4 inline-block">
|
||||
<Button size="sm">开始上传</Button>
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentRecords.map((record) => (
|
||||
<div
|
||||
key={record.id}
|
||||
className="flex items-center justify-between p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-700"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-lg ${
|
||||
record.failed === 0
|
||||
? 'bg-green-100 dark:bg-green-900/30'
|
||||
: 'bg-yellow-100 dark:bg-yellow-900/30'
|
||||
}`}
|
||||
>
|
||||
{record.failed === 0 ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{record.source === 'manual' ? '手动上传' : '自动补号'}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{formatRelativeTime(record.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
+{record.success}
|
||||
</p>
|
||||
{record.failed > 0 && <p className="text-xs text-red-500">失败 {record.failed}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
96
frontend/src/components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import { TrendingUp, TrendingDown, Minus } from 'lucide-react'
|
||||
import { Card } from '../common'
|
||||
|
||||
interface StatsCardProps {
|
||||
title: string
|
||||
value: number | string
|
||||
icon: LucideIcon
|
||||
trend?: 'up' | 'down' | 'stable'
|
||||
trendValue?: string
|
||||
color?: 'blue' | 'green' | 'yellow' | 'red' | 'slate'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function StatsCard({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
trend,
|
||||
trendValue,
|
||||
color = 'blue',
|
||||
loading = false,
|
||||
}: StatsCardProps) {
|
||||
const colorStyles = {
|
||||
blue: {
|
||||
bg: 'bg-blue-50 dark:bg-blue-900/20 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/40',
|
||||
icon: 'text-blue-600 dark:text-blue-400',
|
||||
gradient: 'from-blue-500/20 to-blue-600/20',
|
||||
},
|
||||
green: {
|
||||
bg: 'bg-green-50 dark:bg-green-900/20 group-hover:bg-green-100 dark:group-hover:bg-green-900/40',
|
||||
icon: 'text-green-600 dark:text-green-400',
|
||||
gradient: 'from-green-500/20 to-green-600/20',
|
||||
},
|
||||
yellow: {
|
||||
bg: 'bg-yellow-50 dark:bg-yellow-900/20 group-hover:bg-yellow-100 dark:group-hover:bg-yellow-900/40',
|
||||
icon: 'text-yellow-600 dark:text-yellow-400',
|
||||
gradient: 'from-yellow-500/20 to-yellow-600/20',
|
||||
},
|
||||
red: {
|
||||
bg: 'bg-red-50 dark:bg-red-900/20 group-hover:bg-red-100 dark:group-hover:bg-red-900/40',
|
||||
icon: 'text-red-600 dark:text-red-400',
|
||||
gradient: 'from-red-500/20 to-red-600/20',
|
||||
},
|
||||
slate: {
|
||||
bg: 'bg-slate-50 dark:bg-slate-800 group-hover:bg-slate-100 dark:group-hover:bg-slate-700',
|
||||
icon: 'text-slate-600 dark:text-slate-400',
|
||||
gradient: 'from-slate-500/20 to-slate-600/20',
|
||||
},
|
||||
}
|
||||
|
||||
const trendStyles = {
|
||||
up: {
|
||||
icon: TrendingUp,
|
||||
color: 'text-green-600 dark:text-green-400',
|
||||
},
|
||||
down: {
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-600 dark:text-red-400',
|
||||
},
|
||||
stable: {
|
||||
icon: Minus,
|
||||
color: 'text-slate-500 dark:text-slate-400',
|
||||
},
|
||||
}
|
||||
|
||||
const TrendIcon = trend ? trendStyles[trend].icon : null
|
||||
|
||||
return (
|
||||
<Card hoverable className="stat-card group transition-all duration-300">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-500 dark:text-slate-400">{title}</p>
|
||||
{loading ? (
|
||||
<div className="mt-2 h-8 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
|
||||
) : (
|
||||
<div className="mt-2 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100 tracking-tight">
|
||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{trend && trendValue && TrendIcon && (
|
||||
<div className={`mt-2 flex items-center gap-1 text-sm ${trendStyles[trend].color}`}>
|
||||
<TrendIcon className="h-4 w-4" />
|
||||
<span>{trendValue}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl transition-colors duration-300 ${colorStyles[color].bg} bg-gradient-to-br ${colorStyles[color].gradient}`}>
|
||||
<Icon className={`h-6 w-6 ${colorStyles[color].icon}`} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/dashboard/index.ts
Normal file
3
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as StatsCard } from './StatsCard'
|
||||
export { default as PoolStatus } from './PoolStatus'
|
||||
export { default as RecentRecords } from './RecentRecords'
|
||||
97
frontend/src/components/layout/Header.tsx
Normal file
97
frontend/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Menu, Moon, Sun } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface HeaderProps {
|
||||
onMenuClick: () => void
|
||||
isConnected?: boolean
|
||||
}
|
||||
|
||||
export default function Header({ onMenuClick, isConnected = false }: HeaderProps) {
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
// Check for saved theme preference or system preference
|
||||
const savedTheme = localStorage.getItem('theme')
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
|
||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
||||
setIsDark(true)
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setIsDark(!isDark)
|
||||
if (isDark) {
|
||||
document.documentElement.classList.remove('dark')
|
||||
localStorage.setItem('theme', 'light')
|
||||
} else {
|
||||
document.documentElement.classList.add('dark')
|
||||
localStorage.setItem('theme', 'dark')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 flex h-16 items-center gap-4 border-b border-slate-200/50 dark:border-slate-800/50 bg-white/80 dark:bg-slate-900/80 backdrop-blur-xl px-4 lg:px-6 transition-all duration-300">
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="lg:hidden p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="打开菜单"
|
||||
>
|
||||
<Menu className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* Logo - Mobile Only */}
|
||||
<div className="flex lg:hidden items-center gap-2">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-slate-900 dark:text-slate-100 hidden sm:inline">
|
||||
Codex Pool
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Connection status */}
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${isConnected
|
||||
? 'bg-green-50 text-green-700 border border-green-200 dark:bg-green-900/20 dark:text-green-400 dark:border-green-900/50'
|
||||
: 'bg-red-50 text-red-700 border border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-900/50'
|
||||
}`}
|
||||
>
|
||||
{isConnected ? (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="hidden sm:inline">已连接</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500"></span>
|
||||
</span>
|
||||
<span className="hidden sm:inline">未连接</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label={isDark ? '切换到浅色模式' : '切换到深色模式'}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
30
frontend/src/components/layout/Layout.tsx
Normal file
30
frontend/src/components/layout/Layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
import { useConfig } from '../../hooks/useConfig'
|
||||
|
||||
export default function Layout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const { isConnected } = useConfig()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<div className="flex">
|
||||
{/* Sidebar */}
|
||||
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col min-h-screen lg:ml-0">
|
||||
{/* Header */}
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} isConnected={isConnected} />
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 p-4 lg:p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
174
frontend/src/components/layout/Sidebar.tsx
Normal file
174
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { NavLink, useLocation } from 'react-router-dom'
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Upload,
|
||||
History,
|
||||
Users,
|
||||
Settings,
|
||||
X,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
Server,
|
||||
Mail,
|
||||
Cog,
|
||||
UsersRound
|
||||
} from 'lucide-react'
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface NavItem {
|
||||
to: string
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
label: string
|
||||
children?: NavItem[]
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/', icon: LayoutDashboard, label: '仪表盘' },
|
||||
{ to: '/upload', icon: Upload, label: '上传入库' },
|
||||
{ to: '/team', icon: UsersRound, label: 'Team 批量处理' },
|
||||
{ to: '/records', icon: History, label: '加号记录' },
|
||||
{ to: '/accounts', icon: Users, label: '号池账号' },
|
||||
{ to: '/monitor', icon: Activity, label: '号池监控' },
|
||||
{
|
||||
to: '/config',
|
||||
icon: Settings,
|
||||
label: '系统配置',
|
||||
children: [
|
||||
{ to: '/config', icon: Cog, label: '配置概览' },
|
||||
{ to: '/config/s2a', icon: Server, label: 'S2A 配置' },
|
||||
{ to: '/config/email', icon: Mail, label: '邮箱配置' },
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const location = useLocation()
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(['/config'])
|
||||
|
||||
const toggleExpand = (path: string) => {
|
||||
setExpandedItems(prev =>
|
||||
prev.includes(path)
|
||||
? prev.filter(p => p !== path)
|
||||
: [...prev, path]
|
||||
)
|
||||
}
|
||||
|
||||
const isItemActive = (item: NavItem): boolean => {
|
||||
if (item.children) {
|
||||
return item.children.some(child => location.pathname === child.to)
|
||||
}
|
||||
return location.pathname === item.to
|
||||
}
|
||||
|
||||
const renderNavItem = (item: NavItem, isChild = false) => {
|
||||
const hasChildren = item.children && item.children.length > 0
|
||||
const isExpanded = expandedItems.includes(item.to)
|
||||
const isActive = isItemActive(item)
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<div key={item.to} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleExpand(item.to)}
|
||||
className={`w-full flex items-center justify-between gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isActive
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.label}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{/* 子菜单 */}
|
||||
<div className={`ml-4 pl-3 border-l-2 border-slate-200 dark:border-slate-700 space-y-1 overflow-hidden transition-all duration-200 ${isExpanded ? 'max-h-96 opacity-100' : 'max-h-0 opacity-0'
|
||||
}`}>
|
||||
{item.children?.map(child => renderNavItem(child, true))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={onClose}
|
||||
end={item.to === '/config'}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 ${isChild ? 'py-2' : ''
|
||||
} ${isActive
|
||||
? isChild
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400'
|
||||
: 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-500/25'
|
||||
: 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800/50'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<item.icon className={`${isChild ? 'h-4 w-4' : 'h-5 w-5'}`} />
|
||||
{item.label}
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile overlay */}
|
||||
{isOpen && <div className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm lg:hidden" onClick={onClose} />}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 w-64 bg-white/80 dark:bg-slate-900/90 backdrop-blur-xl border-r border-slate-200 dark:border-slate-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:z-auto ${isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
}`}
|
||||
>
|
||||
{/* Mobile close button */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50 lg:hidden">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
aria-label="关闭菜单"
|
||||
>
|
||||
<X className="h-5 w-5 text-slate-600 dark:text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop Header (Logo) */}
|
||||
<div className="hidden lg:flex items-center h-16 px-6 border-b border-slate-200/50 dark:border-slate-800/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center shadow-lg shadow-blue-500/30">
|
||||
<span className="text-white font-bold text-sm">CP</span>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-slate-900 dark:text-slate-100 tracking-tight">Codex Pool</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="p-4 space-y-1.5">
|
||||
{navItems.map(item => renderNavItem(item))}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4 border-t border-slate-200/50 dark:border-slate-800/50 bg-white/50 dark:bg-slate-900/50 backdrop-blur-sm">
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 text-center font-medium">
|
||||
Codex Pool v1.0.0
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/layout/index.ts
Normal file
3
frontend/src/components/layout/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as Layout } from './Layout'
|
||||
export { default as Header } from './Header'
|
||||
export { default as Sidebar } from './Sidebar'
|
||||
103
frontend/src/components/records/RecordList.tsx
Normal file
103
frontend/src/components/records/RecordList.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { CheckCircle, Trash2 } from 'lucide-react'
|
||||
import type { AddRecord } from '../../types'
|
||||
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell, Button } from '../common'
|
||||
import { formatDateTime } from '../../utils/format'
|
||||
|
||||
interface RecordListProps {
|
||||
records: AddRecord[]
|
||||
onDelete?: (id: string) => void
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
export default function RecordList({ records, onDelete, loading = false }: RecordListProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 mb-4">
|
||||
<CheckCircle className="h-8 w-8 text-slate-400 dark:text-slate-500" />
|
||||
</div>
|
||||
<p className="text-slate-500 dark:text-slate-400">暂无加号记录</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead className="text-right">总数</TableHead>
|
||||
<TableHead className="text-right">成功</TableHead>
|
||||
<TableHead className="text-right">失败</TableHead>
|
||||
<TableHead>详情</TableHead>
|
||||
{onDelete && <TableHead className="w-16"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{records.map((record) => (
|
||||
<TableRow key={record.id}>
|
||||
<TableCell>
|
||||
<span className="text-sm">{formatDateTime(record.timestamp)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium ${
|
||||
record.source === 'manual'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
|
||||
}`}
|
||||
>
|
||||
{record.source === 'manual' ? '手动上传' : '自动补号'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">{record.total}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">
|
||||
{record.success}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{record.failed > 0 ? (
|
||||
<span className="text-red-600 dark:text-red-400 font-medium">
|
||||
{record.failed}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-400">0</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400 truncate max-w-[200px] block">
|
||||
{record.details || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{onDelete && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onDelete(record.id)}
|
||||
className="text-slate-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/records/RecordStats.tsx
Normal file
76
frontend/src/components/records/RecordStats.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { TrendingUp, Calendar, CheckCircle, XCircle } from 'lucide-react'
|
||||
import { Card } from '../common'
|
||||
|
||||
interface RecordStatsProps {
|
||||
stats: {
|
||||
totalRecords: number
|
||||
totalAdded: number
|
||||
totalSuccess: number
|
||||
totalFailed: number
|
||||
todayAdded: number
|
||||
weekAdded: number
|
||||
}
|
||||
}
|
||||
|
||||
export default function RecordStats({ stats }: RecordStatsProps) {
|
||||
const successRate =
|
||||
stats.totalAdded > 0 ? ((stats.totalSuccess / stats.totalAdded) * 100).toFixed(1) : '0'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">总入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.totalSuccess}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">成功率</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">{successRate}%</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
|
||||
<Calendar className="h-5 w-5 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">今日入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.todayAdded}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">本周入库</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.weekAdded}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
frontend/src/components/records/index.ts
Normal file
2
frontend/src/components/records/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as RecordList } from './RecordList'
|
||||
export { default as RecordStats } from './RecordStats'
|
||||
182
frontend/src/components/upload/AccountTable.tsx
Normal file
182
frontend/src/components/upload/AccountTable.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { CheckSquare, Square, MinusSquare } from 'lucide-react'
|
||||
import type { CheckedAccount } from '../../types'
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
StatusBadge,
|
||||
Button,
|
||||
} from '../common'
|
||||
import { maskEmail, maskToken } from '../../utils/format'
|
||||
|
||||
interface AccountTableProps {
|
||||
accounts: CheckedAccount[]
|
||||
selectedIds: number[]
|
||||
onSelectionChange: (ids: number[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function AccountTable({
|
||||
accounts,
|
||||
selectedIds,
|
||||
onSelectionChange,
|
||||
disabled = false,
|
||||
}: AccountTableProps) {
|
||||
const [showTokens, setShowTokens] = useState(false)
|
||||
|
||||
const activeAccounts = useMemo(
|
||||
() => accounts.filter((acc) => acc.status === 'active'),
|
||||
[accounts]
|
||||
)
|
||||
|
||||
const allSelected = selectedIds.length === accounts.length && accounts.length > 0
|
||||
const someSelected = selectedIds.length > 0 && selectedIds.length < accounts.length
|
||||
const activeSelected = activeAccounts.every((acc) => selectedIds.includes(acc.id))
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (allSelected) {
|
||||
onSelectionChange([])
|
||||
} else {
|
||||
onSelectionChange(accounts.map((acc) => acc.id))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectActive = () => {
|
||||
onSelectionChange(activeAccounts.map((acc) => acc.id))
|
||||
}
|
||||
|
||||
const handleSelectNone = () => {
|
||||
onSelectionChange([])
|
||||
}
|
||||
|
||||
const handleToggle = (id: number) => {
|
||||
if (selectedIds.includes(id)) {
|
||||
onSelectionChange(selectedIds.filter((i) => i !== id))
|
||||
} else {
|
||||
onSelectionChange([...selectedIds, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (accounts.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Selection controls */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectActive}
|
||||
disabled={disabled || activeAccounts.length === 0}
|
||||
>
|
||||
全选正常 ({activeAccounts.length})
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleSelectAll} disabled={disabled}>
|
||||
全选 ({accounts.length})
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSelectNone}
|
||||
disabled={disabled || selectedIds.length === 0}
|
||||
>
|
||||
取消全选
|
||||
</Button>
|
||||
<div className="flex-1" />
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowTokens(!showTokens)}>
|
||||
{showTokens ? '隐藏 Token' : '显示 Token'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Selection summary */}
|
||||
<div className="text-sm text-slate-600 dark:text-slate-400">
|
||||
已选择{' '}
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">{selectedIds.length}</span>{' '}
|
||||
个账号
|
||||
{activeSelected && activeAccounts.length > 0 && (
|
||||
<span className="ml-2 text-green-600 dark:text-green-400">
|
||||
(包含全部 {activeAccounts.length} 个正常账号)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead className="w-12">
|
||||
<button
|
||||
onClick={handleSelectAll}
|
||||
disabled={disabled}
|
||||
className="p-1 hover:bg-slate-200 dark:hover:bg-slate-600 rounded disabled:opacity-50"
|
||||
>
|
||||
{allSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : someSelected ? (
|
||||
<MinusSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>Account ID</TableHead>
|
||||
<TableHead>Plan Type</TableHead>
|
||||
{showTokens && <TableHead>Token</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>
|
||||
<button
|
||||
onClick={() => handleToggle(account.id)}
|
||||
disabled={disabled}
|
||||
className="p-1 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-50"
|
||||
>
|
||||
{selectedIds.includes(account.id) ? (
|
||||
<CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-slate-400" />
|
||||
)}
|
||||
</button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{maskEmail(account.account)}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={account.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{account.accountId || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{account.planType || '-'}
|
||||
</span>
|
||||
</TableCell>
|
||||
{showTokens && (
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
|
||||
{maskToken(account.token, 12)}
|
||||
</span>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
72
frontend/src/components/upload/CheckProgress.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { CheckCircle, XCircle, AlertTriangle, Clock } from 'lucide-react'
|
||||
import { Progress } from '../common'
|
||||
|
||||
interface CheckProgressProps {
|
||||
total: number
|
||||
checked: number
|
||||
results: {
|
||||
active: number
|
||||
banned: number
|
||||
token_expired: number
|
||||
error: number
|
||||
}
|
||||
checking: boolean
|
||||
}
|
||||
|
||||
export default function CheckProgress({ total, checked, results, checking }: CheckProgressProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Progress bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{checking ? '检查中...' : checked === total && total > 0 ? '检查完成' : '待检查'}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{checked} / {total}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={checked} max={total} color={checking ? 'blue' : 'green'} size="md" />
|
||||
</div>
|
||||
|
||||
{/* Results summary */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<div className="flex items-center gap-2 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<CheckCircle className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<p className="text-xs text-green-600 dark:text-green-400">正常</p>
|
||||
<p className="text-lg font-bold text-green-700 dark:text-green-300">{results.active}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||
<XCircle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">封禁</p>
|
||||
<p className="text-lg font-bold text-red-700 dark:text-red-300">{results.banned}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
<div>
|
||||
<p className="text-xs text-orange-600 dark:text-orange-400">过期</p>
|
||||
<p className="text-lg font-bold text-orange-700 dark:text-orange-300">
|
||||
{results.token_expired}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600 dark:text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">错误</p>
|
||||
<p className="text-lg font-bold text-yellow-700 dark:text-yellow-300">
|
||||
{results.error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
114
frontend/src/components/upload/FileDropzone.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Upload, FileJson, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface FileDropzoneProps {
|
||||
onFileSelect: (file: File) => void
|
||||
disabled?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function FileDropzone({ onFileSelect, disabled = false, error }: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
if (!disabled) {
|
||||
setIsDragging(true)
|
||||
}
|
||||
},
|
||||
[disabled]
|
||||
)
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
|
||||
if (disabled) return
|
||||
|
||||
const files = e.dataTransfer.files
|
||||
if (files.length > 0) {
|
||||
const file = files[0]
|
||||
if (file.type === 'application/json' || file.name.endsWith('.json')) {
|
||||
onFileSelect(file)
|
||||
}
|
||||
}
|
||||
},
|
||||
[disabled, onFileSelect]
|
||||
)
|
||||
|
||||
const handleFileInput = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
if (files && files.length > 0) {
|
||||
onFileSelect(files[0])
|
||||
}
|
||||
// Reset input value to allow selecting the same file again
|
||||
e.target.value = ''
|
||||
},
|
||||
[onFileSelect]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={`relative border-2 border-dashed rounded-xl p-8 text-center transition-colors ${
|
||||
disabled
|
||||
? 'border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/50 cursor-not-allowed'
|
||||
: isDragging
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-slate-300 dark:border-slate-600 hover:border-blue-400 dark:hover:border-blue-500 cursor-pointer'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,application/json"
|
||||
onChange={handleFileInput}
|
||||
disabled={disabled}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div
|
||||
className={`p-4 rounded-full ${
|
||||
isDragging ? 'bg-blue-100 dark:bg-blue-900/30' : 'bg-slate-100 dark:bg-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isDragging ? (
|
||||
<Upload className="h-8 w-8 text-blue-600 dark:text-blue-400" />
|
||||
) : (
|
||||
<FileJson className="h-8 w-8 text-slate-400 dark:text-slate-500" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
{isDragging ? '释放文件以上传' : '拖拽 JSON 文件到此处'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">或点击选择文件</p>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 dark:text-slate-500">
|
||||
支持格式: [{"account": "email", "password": "pwd", "token": "..."}]
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
173
frontend/src/components/upload/LogStream.tsx
Normal file
173
frontend/src/components/upload/LogStream.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Terminal, Trash2, Play, Pause } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||
|
||||
interface LogEntry {
|
||||
timestamp: string
|
||||
level: string
|
||||
message: string
|
||||
email?: string
|
||||
step?: string
|
||||
}
|
||||
|
||||
interface LogStreamProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const levelColors: Record<string, string> = {
|
||||
info: 'text-blue-400',
|
||||
success: 'text-green-400',
|
||||
warning: 'text-yellow-400',
|
||||
error: 'text-red-400',
|
||||
}
|
||||
|
||||
const stepColors: Record<string, string> = {
|
||||
validate: 'bg-purple-500/20 text-purple-400',
|
||||
register: 'bg-blue-500/20 text-blue-400',
|
||||
authorize: 'bg-orange-500/20 text-orange-400',
|
||||
pool: 'bg-green-500/20 text-green-400',
|
||||
database: 'bg-slate-500/20 text-slate-400',
|
||||
}
|
||||
|
||||
const stepLabels: Record<string, string> = {
|
||||
validate: '验证',
|
||||
register: '注册',
|
||||
authorize: '授权',
|
||||
pool: '入库',
|
||||
database: '数据库',
|
||||
}
|
||||
|
||||
export default function LogStream({ apiBase = 'http://localhost:8088' }: LogStreamProps) {
|
||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [paused, setPaused] = useState(false)
|
||||
const logContainerRef = useRef<HTMLDivElement>(null)
|
||||
const eventSourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (paused) return
|
||||
|
||||
const eventSource = new EventSource(`${apiBase}/api/logs/stream`)
|
||||
eventSourceRef.current = eventSource
|
||||
|
||||
eventSource.onopen = () => {
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const log = JSON.parse(event.data) as LogEntry
|
||||
setLogs((prev) => [...prev.slice(-199), log])
|
||||
} catch (e) {
|
||||
console.error('Failed to parse log:', e)
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
setConnected(false)
|
||||
eventSource.close()
|
||||
}
|
||||
|
||||
return () => {
|
||||
eventSource.close()
|
||||
}
|
||||
}, [apiBase, paused])
|
||||
|
||||
useEffect(() => {
|
||||
if (logContainerRef.current) {
|
||||
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
const handleClear = async () => {
|
||||
try {
|
||||
await fetch(`${apiBase}/api/logs/clear`, { method: 'POST' })
|
||||
setLogs([])
|
||||
} catch (e) {
|
||||
console.error('Failed to clear logs:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const togglePause = () => {
|
||||
if (!paused && eventSourceRef.current) {
|
||||
eventSourceRef.current.close()
|
||||
}
|
||||
setPaused(!paused)
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
try {
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Terminal className="h-5 w-5" />
|
||||
实时日志
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={togglePause}
|
||||
icon={paused ? <Play className="h-4 w-4" /> : <Pause className="h-4 w-4" />}
|
||||
>
|
||||
{paused ? '继续' : '暂停'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div
|
||||
ref={logContainerRef}
|
||||
className="h-full overflow-y-auto bg-slate-900 dark:bg-slate-950 p-4 font-mono text-xs"
|
||||
>
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500 text-center py-8">等待日志...</div>
|
||||
) : (
|
||||
logs.map((log, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5 hover:bg-slate-800/50">
|
||||
<span className="text-slate-500 flex-shrink-0">{formatTime(log.timestamp)}</span>
|
||||
{log.step && (
|
||||
<span
|
||||
className={`px-1.5 rounded text-[10px] uppercase flex-shrink-0 ${stepColors[log.step] || 'bg-slate-500/20 text-slate-400'}`}
|
||||
>
|
||||
{stepLabels[log.step] || log.step}
|
||||
</span>
|
||||
)}
|
||||
<span className={`flex-shrink-0 ${levelColors[log.level] || 'text-slate-300'}`}>
|
||||
{log.level === 'success' ? '✓' : log.level === 'error' ? '✗' : '•'}
|
||||
</span>
|
||||
<span className="text-slate-300 break-all">{log.message}</span>
|
||||
{log.email && (
|
||||
<span className="text-slate-500 flex-shrink-0 ml-auto">[{log.email}]</span>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
218
frontend/src/components/upload/OwnerList.tsx
Normal file
218
frontend/src/components/upload/OwnerList.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Users, Trash2, RefreshCw, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../common'
|
||||
|
||||
interface TeamOwner {
|
||||
id: number
|
||||
email: string
|
||||
account_id: string
|
||||
status: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
interface OwnerListProps {
|
||||
apiBase?: string
|
||||
}
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
valid: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
registered: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
valid: '有效',
|
||||
registered: '已注册',
|
||||
pooled: '已入库',
|
||||
}
|
||||
|
||||
export default function OwnerList({ apiBase = 'http://localhost:8088' }: OwnerListProps) {
|
||||
const [owners, setOwners] = useState<TeamOwner[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [page, setPage] = useState(0)
|
||||
const [filter, setFilter] = useState<string>('')
|
||||
const limit = 20
|
||||
|
||||
const loadOwners = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(page * limit),
|
||||
})
|
||||
if (filter) {
|
||||
params.set('status', filter)
|
||||
}
|
||||
|
||||
const res = await fetch(`${apiBase}/api/db/owners?${params}`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setOwners(data.data.owners || [])
|
||||
setTotal(data.data.total || 0)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load owners:', e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadOwners()
|
||||
}, [page, filter])
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确认删除此账号?')) return
|
||||
try {
|
||||
await fetch(`${apiBase}/api/db/owners/${id}`, { method: 'DELETE' })
|
||||
loadOwners()
|
||||
} catch (e) {
|
||||
console.error('Failed to delete:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearAll = async () => {
|
||||
if (!confirm('确认清空所有账号?此操作不可恢复!')) return
|
||||
try {
|
||||
await fetch(`${apiBase}/api/db/owners/clear`, { method: 'POST' })
|
||||
loadOwners()
|
||||
} catch (e) {
|
||||
console.error('Failed to clear:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
|
||||
const formatTime = (ts: string) => {
|
||||
try {
|
||||
return new Date(ts).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
母号列表 ({total})
|
||||
</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="px-2 py-1 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
|
||||
value={filter}
|
||||
onChange={(e) => {
|
||||
setFilter(e.target.value)
|
||||
setPage(0)
|
||||
}}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="valid">有效</option>
|
||||
<option value="registered">已注册</option>
|
||||
<option value="pooled">已入库</option>
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="text-red-500 hover:text-red-600"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 overflow-hidden p-0">
|
||||
<div className="h-full overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-50 dark:bg-slate-800 sticky top-0">
|
||||
<tr>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">邮箱</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">Account ID</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">状态</th>
|
||||
<th className="text-left p-3 font-medium text-slate-600 dark:text-slate-400">创建时间</th>
|
||||
<th className="text-center p-3 font-medium text-slate-600 dark:text-slate-400">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
加载中...
|
||||
</td>
|
||||
</tr>
|
||||
) : owners.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-8 text-slate-500">
|
||||
暂无数据
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
owners.map((owner) => (
|
||||
<tr key={owner.id} className="border-t border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/50">
|
||||
<td className="p-3 text-slate-900 dark:text-slate-100">{owner.email}</td>
|
||||
<td className="p-3 font-mono text-xs text-slate-500">{owner.account_id?.slice(0, 20)}...</td>
|
||||
<td className="p-3">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${statusColors[owner.status] || 'bg-slate-100 text-slate-700'}`}>
|
||||
{statusLabels[owner.status] || owner.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-slate-500 text-xs">{formatTime(owner.created_at)}</td>
|
||||
<td className="p-3 text-center">
|
||||
<button
|
||||
onClick={() => handleDelete(owner.id)}
|
||||
className="text-red-400 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex-shrink-0 p-3 border-t border-slate-100 dark:border-slate-800 flex items-center justify-between">
|
||||
<span className="text-sm text-slate-500">
|
||||
第 {page + 1} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
icon={<ChevronLeft className="h-4 w-4" />}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
icon={<ChevronRight className="h-4 w-4" />}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/upload/PoolActions.tsx
Normal file
141
frontend/src/components/upload/PoolActions.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState } from 'react'
|
||||
import { Upload, CheckCircle, AlertCircle } from 'lucide-react'
|
||||
import type { CheckedAccount } from '../../types'
|
||||
import { Button, Progress } from '../common'
|
||||
|
||||
interface PoolActionsProps {
|
||||
selectedAccounts: CheckedAccount[]
|
||||
onPool: (accounts: CheckedAccount[]) => Promise<{ success: number; failed: number }>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function PoolActions({
|
||||
selectedAccounts,
|
||||
onPool,
|
||||
disabled = false,
|
||||
}: PoolActionsProps) {
|
||||
const [pooling, setPooling] = useState(false)
|
||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||
const [result, setResult] = useState<{ success: number; failed: number } | null>(null)
|
||||
|
||||
const activeSelected = selectedAccounts.filter((acc) => acc.status === 'active')
|
||||
const hasNonActive = selectedAccounts.some((acc) => acc.status !== 'active')
|
||||
|
||||
const handlePool = async () => {
|
||||
if (selectedAccounts.length === 0) return
|
||||
|
||||
setPooling(true)
|
||||
setProgress({ current: 0, total: selectedAccounts.length })
|
||||
setResult(null)
|
||||
|
||||
try {
|
||||
const poolResult = await onPool(selectedAccounts)
|
||||
setResult(poolResult)
|
||||
} catch (error) {
|
||||
console.error('Pool error:', error)
|
||||
setResult({ success: 0, failed: selectedAccounts.length })
|
||||
} finally {
|
||||
setPooling(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAccounts.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-slate-500 dark:text-slate-400">
|
||||
请先选择要入库的账号
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Warning for non-active accounts */}
|
||||
{hasNonActive && (
|
||||
<div className="flex items-start gap-2 p-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||
选中的账号包含非正常状态
|
||||
</p>
|
||||
<p className="text-yellow-700 dark:text-yellow-300">
|
||||
建议只入库状态为"正常"的账号,非正常账号可能无法正常使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 dark:bg-slate-800 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">即将入库</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{selectedAccounts.length} 个账号
|
||||
</p>
|
||||
{activeSelected.length !== selectedAccounts.length && (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
其中 {activeSelected.length} 个正常账号
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
onClick={handlePool}
|
||||
disabled={disabled || pooling}
|
||||
loading={pooling}
|
||||
icon={pooling ? undefined : <Upload className="h-4 w-4" />}
|
||||
>
|
||||
{pooling ? '入库中...' : '入库选中'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
{pooling && (
|
||||
<div>
|
||||
<Progress
|
||||
value={progress.current}
|
||||
max={progress.total}
|
||||
color="blue"
|
||||
showLabel
|
||||
label="入库进度"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{result && (
|
||||
<div
|
||||
className={`flex items-center gap-3 p-4 rounded-lg ${
|
||||
result.failed === 0
|
||||
? 'bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800'
|
||||
: 'bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800'
|
||||
}`}
|
||||
>
|
||||
{result.failed === 0 ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
) : (
|
||||
<AlertCircle className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
|
||||
)}
|
||||
<div>
|
||||
<p
|
||||
className={`font-medium ${
|
||||
result.failed === 0
|
||||
? 'text-green-800 dark:text-green-200'
|
||||
: 'text-yellow-800 dark:text-yellow-200'
|
||||
}`}
|
||||
>
|
||||
入库完成
|
||||
</p>
|
||||
<p
|
||||
className={`text-sm ${
|
||||
result.failed === 0
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-yellow-700 dark:text-yellow-300'
|
||||
}`}
|
||||
>
|
||||
成功 {result.success} 个,失败 {result.failed} 个
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
4
frontend/src/components/upload/index.ts
Normal file
4
frontend/src/components/upload/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as FileDropzone } from './FileDropzone'
|
||||
export { default as AccountTable } from './AccountTable'
|
||||
export { default as CheckProgress } from './CheckProgress'
|
||||
export { default as PoolActions } from './PoolActions'
|
||||
148
frontend/src/context/ConfigContext.tsx
Normal file
148
frontend/src/context/ConfigContext.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import type { AppConfig } from '../types'
|
||||
import { defaultConfig } from '../types'
|
||||
import { loadConfig, saveConfig } from '../utils/storage'
|
||||
import { S2AClient } from '../api/s2a'
|
||||
|
||||
interface ConfigContextValue {
|
||||
config: AppConfig
|
||||
updateConfig: (updates: Partial<AppConfig>) => void
|
||||
updateS2AConfig: (updates: Partial<AppConfig['s2a']>) => void
|
||||
updatePoolingConfig: (updates: Partial<AppConfig['pooling']>) => void
|
||||
updateCheckConfig: (updates: Partial<AppConfig['check']>) => void
|
||||
updateEmailConfig: (updates: Partial<AppConfig['email']>) => void
|
||||
isConnected: boolean
|
||||
testConnection: () => Promise<boolean>
|
||||
s2aClient: S2AClient | null
|
||||
}
|
||||
|
||||
const ConfigContext = createContext<ConfigContextValue | null>(null)
|
||||
|
||||
export function ConfigProvider({ children }: { children: ReactNode }) {
|
||||
const [config, setConfig] = useState<AppConfig>(defaultConfig)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [s2aClient, setS2aClient] = useState<S2AClient | null>(null)
|
||||
|
||||
// Load config from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedConfig = loadConfig()
|
||||
setConfig(savedConfig)
|
||||
|
||||
// Create S2A client if config is available
|
||||
if (savedConfig.s2a.apiBase && savedConfig.s2a.adminKey) {
|
||||
const client = new S2AClient({
|
||||
baseUrl: savedConfig.s2a.apiBase,
|
||||
apiKey: savedConfig.s2a.adminKey,
|
||||
})
|
||||
setS2aClient(client)
|
||||
|
||||
// Test connection on load
|
||||
client.testConnection().then(setIsConnected)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update S2A client when config changes
|
||||
useEffect(() => {
|
||||
if (config.s2a.apiBase && config.s2a.adminKey) {
|
||||
const client = new S2AClient({
|
||||
baseUrl: config.s2a.apiBase,
|
||||
apiKey: config.s2a.adminKey,
|
||||
})
|
||||
setS2aClient(client)
|
||||
} else {
|
||||
setS2aClient(null)
|
||||
setIsConnected(false)
|
||||
}
|
||||
}, [config.s2a.apiBase, config.s2a.adminKey])
|
||||
|
||||
const updateConfig = useCallback((updates: Partial<AppConfig>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = { ...prev, ...updates }
|
||||
saveConfig(newConfig)
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateS2AConfig = useCallback((updates: Partial<AppConfig['s2a']>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
s2a: { ...prev.s2a, ...updates },
|
||||
}
|
||||
saveConfig(newConfig)
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updatePoolingConfig = useCallback((updates: Partial<AppConfig['pooling']>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
pooling: { ...prev.pooling, ...updates },
|
||||
}
|
||||
saveConfig(newConfig)
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateCheckConfig = useCallback((updates: Partial<AppConfig['check']>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
check: { ...prev.check, ...updates },
|
||||
}
|
||||
saveConfig(newConfig)
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateEmailConfig = useCallback((updates: Partial<AppConfig['email']>) => {
|
||||
setConfig((prev) => {
|
||||
const newConfig = {
|
||||
...prev,
|
||||
email: { ...prev.email, ...updates },
|
||||
}
|
||||
saveConfig(newConfig)
|
||||
return newConfig
|
||||
})
|
||||
}, [])
|
||||
|
||||
const testConnection = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
// 使用后端代理 API 来测试 S2A 连接(避免 CORS 问题)
|
||||
const res = await fetch('http://localhost:8088/api/s2a/test')
|
||||
const connected = res.ok
|
||||
setIsConnected(connected)
|
||||
return connected
|
||||
} catch {
|
||||
setIsConnected(false)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ConfigContext.Provider
|
||||
value={{
|
||||
config,
|
||||
updateConfig,
|
||||
updateS2AConfig,
|
||||
updatePoolingConfig,
|
||||
updateCheckConfig,
|
||||
updateEmailConfig,
|
||||
isConnected,
|
||||
testConnection,
|
||||
s2aClient,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useConfigContext(): ConfigContextValue {
|
||||
const context = useContext(ConfigContext)
|
||||
if (!context) {
|
||||
throw new Error('useConfigContext must be used within a ConfigProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
110
frontend/src/context/RecordsContext.tsx
Normal file
110
frontend/src/context/RecordsContext.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import type { AddRecord } from '../types'
|
||||
import { loadRecords, saveRecords, generateId } from '../utils/storage'
|
||||
|
||||
interface RecordsContextValue {
|
||||
records: AddRecord[]
|
||||
addRecord: (record: Omit<AddRecord, 'id' | 'timestamp'>) => void
|
||||
deleteRecord: (id: string) => void
|
||||
clearRecords: () => void
|
||||
getRecordsByDateRange: (startDate: Date, endDate: Date) => AddRecord[]
|
||||
getStats: () => {
|
||||
totalRecords: number
|
||||
totalAdded: number
|
||||
totalSuccess: number
|
||||
totalFailed: number
|
||||
todayAdded: number
|
||||
weekAdded: number
|
||||
}
|
||||
}
|
||||
|
||||
const RecordsContext = createContext<RecordsContextValue | null>(null)
|
||||
|
||||
export function RecordsProvider({ children }: { children: ReactNode }) {
|
||||
const [records, setRecords] = useState<AddRecord[]>([])
|
||||
|
||||
// Load records from localStorage on mount
|
||||
useEffect(() => {
|
||||
const savedRecords = loadRecords()
|
||||
setRecords(savedRecords)
|
||||
}, [])
|
||||
|
||||
const addRecord = useCallback((record: Omit<AddRecord, 'id' | 'timestamp'>) => {
|
||||
const newRecord: AddRecord = {
|
||||
...record,
|
||||
id: generateId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
setRecords((prev) => {
|
||||
const updated = [newRecord, ...prev]
|
||||
saveRecords(updated)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
const deleteRecord = useCallback((id: string) => {
|
||||
setRecords((prev) => {
|
||||
const updated = prev.filter((r) => r.id !== id)
|
||||
saveRecords(updated)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearRecords = useCallback(() => {
|
||||
setRecords([])
|
||||
saveRecords([])
|
||||
}, [])
|
||||
|
||||
const getRecordsByDateRange = useCallback(
|
||||
(startDate: Date, endDate: Date): AddRecord[] => {
|
||||
return records.filter((record) => {
|
||||
const recordDate = new Date(record.timestamp)
|
||||
return recordDate >= startDate && recordDate <= endDate
|
||||
})
|
||||
},
|
||||
[records]
|
||||
)
|
||||
|
||||
const getStats = useCallback(() => {
|
||||
const now = new Date()
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const weekStart = new Date(todayStart)
|
||||
weekStart.setDate(weekStart.getDate() - 7)
|
||||
|
||||
const todayRecords = records.filter((r) => new Date(r.timestamp) >= todayStart)
|
||||
const weekRecords = records.filter((r) => new Date(r.timestamp) >= weekStart)
|
||||
|
||||
return {
|
||||
totalRecords: records.length,
|
||||
totalAdded: records.reduce((sum, r) => sum + r.total, 0),
|
||||
totalSuccess: records.reduce((sum, r) => sum + r.success, 0),
|
||||
totalFailed: records.reduce((sum, r) => sum + r.failed, 0),
|
||||
todayAdded: todayRecords.reduce((sum, r) => sum + r.success, 0),
|
||||
weekAdded: weekRecords.reduce((sum, r) => sum + r.success, 0),
|
||||
}
|
||||
}, [records])
|
||||
|
||||
return (
|
||||
<RecordsContext.Provider
|
||||
value={{
|
||||
records,
|
||||
addRecord,
|
||||
deleteRecord,
|
||||
clearRecords,
|
||||
getRecordsByDateRange,
|
||||
getStats,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RecordsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useRecordsContext(): RecordsContextValue {
|
||||
const context = useContext(RecordsContext)
|
||||
if (!context) {
|
||||
throw new Error('useRecordsContext must be used within a RecordsProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
2
frontend/src/context/index.ts
Normal file
2
frontend/src/context/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ConfigProvider, useConfigContext } from './ConfigContext'
|
||||
export { RecordsProvider, useRecordsContext } from './RecordsContext'
|
||||
5
frontend/src/hooks/index.ts
Normal file
5
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { useConfig } from './useConfig'
|
||||
export { useRecords } from './useRecords'
|
||||
export { useS2AApi } from './useS2AApi'
|
||||
export { useAccountCheck } from './useAccountCheck'
|
||||
export { useBackendApi } from './useBackendApi'
|
||||
160
frontend/src/hooks/useAccountCheck.ts
Normal file
160
frontend/src/hooks/useAccountCheck.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import type { AccountInput, CheckedAccount, AccountStatus } from '../types'
|
||||
import { ChatGPTClient } from '../api/chatgpt'
|
||||
|
||||
interface CheckProgress {
|
||||
total: number
|
||||
checked: number
|
||||
results: {
|
||||
active: number
|
||||
banned: number
|
||||
token_expired: number
|
||||
error: number
|
||||
}
|
||||
}
|
||||
|
||||
export function useAccountCheck() {
|
||||
const [accounts, setAccounts] = useState<CheckedAccount[]>([])
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [progress, setProgress] = useState<CheckProgress>({
|
||||
total: 0,
|
||||
checked: 0,
|
||||
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
|
||||
})
|
||||
|
||||
const loadFromJson = useCallback((jsonData: AccountInput[]) => {
|
||||
const checkedAccounts: CheckedAccount[] = jsonData.map((account, index) => ({
|
||||
...account,
|
||||
id: index,
|
||||
status: 'pending' as AccountStatus,
|
||||
}))
|
||||
setAccounts(checkedAccounts)
|
||||
setProgress({
|
||||
total: checkedAccounts.length,
|
||||
checked: 0,
|
||||
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const parseJsonFile = useCallback(
|
||||
async (file: File): Promise<{ success: boolean; error?: string }> => {
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return { success: false, error: 'JSON 文件必须是数组格式' }
|
||||
}
|
||||
|
||||
// Validate each account
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i]
|
||||
if (!item.account || !item.password || !item.token) {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少必要字段 (account, password, token)`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadFromJson(data)
|
||||
return { success: true }
|
||||
} catch {
|
||||
return { success: false, error: 'JSON 解析失败,请检查文件格式' }
|
||||
}
|
||||
},
|
||||
[loadFromJson]
|
||||
)
|
||||
|
||||
const startCheck = useCallback(
|
||||
async (concurrency: number = 20) => {
|
||||
if (accounts.length === 0 || checking) return
|
||||
|
||||
setChecking(true)
|
||||
const client = new ChatGPTClient()
|
||||
const results = { active: 0, banned: 0, token_expired: 0, error: 0 }
|
||||
|
||||
// Mark all as checking
|
||||
setAccounts((prev) => prev.map((acc) => ({ ...acc, status: 'checking' as AccountStatus })))
|
||||
|
||||
await client.batchCheck(
|
||||
accounts.map((acc) => ({
|
||||
account: acc.account,
|
||||
password: acc.password,
|
||||
token: acc.token,
|
||||
})),
|
||||
{
|
||||
concurrency,
|
||||
onProgress: (checkedAccount, index) => {
|
||||
// Update results count
|
||||
const status = checkedAccount.status
|
||||
if (status === 'active') results.active++
|
||||
else if (status === 'banned') results.banned++
|
||||
else if (status === 'token_expired') results.token_expired++
|
||||
else results.error++
|
||||
|
||||
// Update account in list
|
||||
setAccounts((prev) =>
|
||||
prev.map((acc, i) =>
|
||||
i === index
|
||||
? {
|
||||
...acc,
|
||||
status: checkedAccount.status,
|
||||
accountId: checkedAccount.accountId,
|
||||
planType: checkedAccount.planType,
|
||||
error: checkedAccount.error,
|
||||
}
|
||||
: acc
|
||||
)
|
||||
)
|
||||
|
||||
// Update progress
|
||||
setProgress({
|
||||
total: accounts.length,
|
||||
checked: index + 1,
|
||||
results: { ...results },
|
||||
})
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
setChecking(false)
|
||||
},
|
||||
[accounts, checking]
|
||||
)
|
||||
|
||||
const selectAll = useCallback(
|
||||
(status?: AccountStatus) => {
|
||||
return accounts.filter((acc) => !status || acc.status === status).map((acc) => acc.id)
|
||||
},
|
||||
[accounts]
|
||||
)
|
||||
|
||||
const getSelectedAccounts = useCallback(
|
||||
(ids: number[]) => {
|
||||
return accounts.filter((acc) => ids.includes(acc.id))
|
||||
},
|
||||
[accounts]
|
||||
)
|
||||
|
||||
const clearAccounts = useCallback(() => {
|
||||
setAccounts([])
|
||||
setProgress({
|
||||
total: 0,
|
||||
checked: 0,
|
||||
results: { active: 0, banned: 0, token_expired: 0, error: 0 },
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
accounts,
|
||||
checking,
|
||||
progress,
|
||||
loadFromJson,
|
||||
parseJsonFile,
|
||||
startCheck,
|
||||
selectAll,
|
||||
getSelectedAccounts,
|
||||
clearAccounts,
|
||||
}
|
||||
}
|
||||
181
frontend/src/hooks/useBackendApi.ts
Normal file
181
frontend/src/hooks/useBackendApi.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useConfig } from './useConfig'
|
||||
import { useToast } from '../components/common'
|
||||
|
||||
interface PoolStatus {
|
||||
target: number
|
||||
current: number
|
||||
deficit: number
|
||||
last_check: string
|
||||
auto_add: boolean
|
||||
min_interval: number
|
||||
last_auto_add: string
|
||||
polling_enabled: boolean
|
||||
polling_interval: number
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
account_id: number
|
||||
email: string
|
||||
status: string
|
||||
checked_at: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export function useBackendApi() {
|
||||
const { config } = useConfig()
|
||||
const toast = useToast()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// 推断后端 API 地址 (S2A 在 8080, 后端 API 在 8088)
|
||||
const getBackendUrl = useCallback(() => {
|
||||
const s2aBase = config.s2a.apiBase
|
||||
return s2aBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
|
||||
}, [config.s2a.apiBase])
|
||||
|
||||
// 通用请求方法
|
||||
const request = useCallback(
|
||||
async <T>(endpoint: string, options: RequestInit = {}): Promise<T | null> => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const backendUrl = getBackendUrl()
|
||||
const response = await fetch(`${backendUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
if (data.code !== 0) {
|
||||
throw new Error(data.message || '请求失败')
|
||||
}
|
||||
|
||||
return data.data as T
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '请求失败'
|
||||
setError(msg)
|
||||
toast.error(msg)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[getBackendUrl, toast]
|
||||
)
|
||||
|
||||
// 健康检查
|
||||
const checkHealth = useCallback(async () => {
|
||||
return request<{ status: string; time: string }>('/api/health')
|
||||
}, [request])
|
||||
|
||||
// 获取号池状态
|
||||
const getPoolStatus = useCallback(async () => {
|
||||
return request<PoolStatus>('/api/pool/status')
|
||||
}, [request])
|
||||
|
||||
// 设置号池目标
|
||||
const setPoolTarget = useCallback(
|
||||
async (target: number, autoAdd: boolean, minInterval: number) => {
|
||||
return request('/api/pool/target', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
target,
|
||||
auto_add: autoAdd,
|
||||
min_interval: minInterval,
|
||||
}),
|
||||
})
|
||||
},
|
||||
[request]
|
||||
)
|
||||
|
||||
// 控制轮询
|
||||
const setPolling = useCallback(
|
||||
async (enabled: boolean, interval: number) => {
|
||||
return request('/api/pool/polling', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ enabled, interval }),
|
||||
})
|
||||
},
|
||||
[request]
|
||||
)
|
||||
|
||||
// 刷新号池状态
|
||||
const refreshPool = useCallback(async () => {
|
||||
return request('/api/pool/refresh', { method: 'POST' })
|
||||
}, [request])
|
||||
|
||||
// 启动健康检查
|
||||
const startHealthCheck = useCallback(async () => {
|
||||
return request('/api/health-check/start', { method: 'POST' })
|
||||
}, [request])
|
||||
|
||||
// 获取健康检查结果
|
||||
const getHealthCheckResults = useCallback(async () => {
|
||||
return request<HealthCheckResult[]>('/api/health-check/results')
|
||||
}, [request])
|
||||
|
||||
// 获取本地账号
|
||||
const getLocalAccounts = useCallback(async () => {
|
||||
return request<Array<{
|
||||
email: string
|
||||
pooled: boolean
|
||||
pooled_at: string
|
||||
pool_id: number
|
||||
used: boolean
|
||||
used_at: string
|
||||
}>>('/api/accounts')
|
||||
}, [request])
|
||||
|
||||
// 批量入库
|
||||
const poolAccounts = useCallback(
|
||||
async (emails: string[]) => {
|
||||
const result = await request<{ success: number; failed: number }>('/api/accounts/pool', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ emails }),
|
||||
})
|
||||
if (result) {
|
||||
toast.success(`入库完成: 成功 ${result.success}, 失败 ${result.failed}`)
|
||||
}
|
||||
return result
|
||||
},
|
||||
[request, toast]
|
||||
)
|
||||
|
||||
// Codex 授权
|
||||
const startCodexAuth = useCallback(
|
||||
async (email: string, password: string, proxy?: string) => {
|
||||
const result = await request('/api/codex/auth', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email, password, proxy }),
|
||||
})
|
||||
if (result) {
|
||||
toast.success('授权任务已启动')
|
||||
}
|
||||
return result
|
||||
},
|
||||
[request, toast]
|
||||
)
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
checkHealth,
|
||||
getPoolStatus,
|
||||
setPoolTarget,
|
||||
setPolling,
|
||||
refreshPool,
|
||||
startHealthCheck,
|
||||
getHealthCheckResults,
|
||||
getLocalAccounts,
|
||||
poolAccounts,
|
||||
startCodexAuth,
|
||||
}
|
||||
}
|
||||
5
frontend/src/hooks/useConfig.ts
Normal file
5
frontend/src/hooks/useConfig.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useConfigContext } from '../context/ConfigContext'
|
||||
|
||||
export function useConfig() {
|
||||
return useConfigContext()
|
||||
}
|
||||
5
frontend/src/hooks/useRecords.ts
Normal file
5
frontend/src/hooks/useRecords.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useRecordsContext } from '../context/RecordsContext'
|
||||
|
||||
export function useRecords() {
|
||||
return useRecordsContext()
|
||||
}
|
||||
166
frontend/src/hooks/useS2AApi.ts
Normal file
166
frontend/src/hooks/useS2AApi.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useConfig } from './useConfig'
|
||||
import type { DashboardStats, S2AAccount, AccountListParams, PaginatedResponse } from '../types'
|
||||
import type { DashboardStatsResponse, AccountListResponse } from '../api/types'
|
||||
|
||||
export function useS2AApi() {
|
||||
const { s2aClient, isConnected } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const getDashboardStats = useCallback(async (): Promise<DashboardStats | null> => {
|
||||
if (!s2aClient) {
|
||||
setError('S2A 客户端未配置')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response: DashboardStatsResponse = await s2aClient.getDashboardStats()
|
||||
return response
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '获取统计数据失败'
|
||||
setError(message)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [s2aClient])
|
||||
|
||||
const getAccounts = useCallback(
|
||||
async (params: AccountListParams = {}): Promise<PaginatedResponse<S2AAccount> | null> => {
|
||||
if (!s2aClient) {
|
||||
setError('S2A 客户端未配置')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response: AccountListResponse = await s2aClient.getAccounts(params)
|
||||
return {
|
||||
data: response.data,
|
||||
total: response.total,
|
||||
page: response.page,
|
||||
page_size: response.page_size,
|
||||
total_pages: Math.ceil(response.total / response.page_size),
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '获取账号列表失败'
|
||||
setError(message)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[s2aClient]
|
||||
)
|
||||
|
||||
const createAccount = useCallback(
|
||||
async (data: {
|
||||
name: string
|
||||
token: string
|
||||
email?: string
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
groupIds?: number[]
|
||||
proxyId?: number | null
|
||||
}): Promise<S2AAccount | null> => {
|
||||
if (!s2aClient) {
|
||||
setError('S2A 客户端未配置')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await s2aClient.createAccount({
|
||||
name: data.name,
|
||||
platform: 'openai',
|
||||
type: 'access_token',
|
||||
credentials: {
|
||||
access_token: data.token,
|
||||
email: data.email,
|
||||
},
|
||||
concurrency: data.concurrency ?? 1,
|
||||
priority: data.priority ?? 0,
|
||||
group_ids: data.groupIds ?? [],
|
||||
proxy_id: data.proxyId ?? null,
|
||||
auto_pause_on_expired: true,
|
||||
})
|
||||
return response as S2AAccount
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '创建账号失败'
|
||||
setError(message)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[s2aClient]
|
||||
)
|
||||
|
||||
const deleteAccount = useCallback(
|
||||
async (id: number): Promise<boolean> => {
|
||||
if (!s2aClient) {
|
||||
setError('S2A 客户端未配置')
|
||||
return false
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await s2aClient.deleteAccount(id)
|
||||
return true
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '删除账号失败'
|
||||
setError(message)
|
||||
return false
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[s2aClient]
|
||||
)
|
||||
|
||||
const testAccount = useCallback(
|
||||
async (id: number): Promise<{ success: boolean; message?: string } | null> => {
|
||||
if (!s2aClient) {
|
||||
setError('S2A 客户端未配置')
|
||||
return null
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await s2aClient.testAccount(id)
|
||||
return response
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : '测试账号失败'
|
||||
setError(message)
|
||||
return null
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
},
|
||||
[s2aClient]
|
||||
)
|
||||
|
||||
return {
|
||||
loading,
|
||||
error,
|
||||
isConnected,
|
||||
getDashboardStats,
|
||||
getAccounts,
|
||||
createAccount,
|
||||
deleteAccount,
|
||||
testAccount,
|
||||
clearError: () => setError(null),
|
||||
}
|
||||
}
|
||||
416
frontend/src/index.css
Normal file
416
frontend/src/index.css
Normal file
@@ -0,0 +1,416 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* TailwindCSS 4 Theme Configuration */
|
||||
@theme {
|
||||
/* Design System Colors */
|
||||
--color-primary: #2563eb;
|
||||
--color-primary-50: #eff6ff;
|
||||
--color-primary-100: #dbeafe;
|
||||
--color-primary-200: #bfdbfe;
|
||||
--color-primary-300: #93c5fd;
|
||||
--color-primary-400: #60a5fa;
|
||||
--color-primary-500: #3b82f6;
|
||||
--color-primary-600: #2563eb;
|
||||
--color-primary-700: #1d4ed8;
|
||||
--color-primary-800: #1e40af;
|
||||
--color-primary-900: #1e3a8a;
|
||||
--color-primary-950: #172554;
|
||||
|
||||
/* Success Color: Green-500 (#22C55E) */
|
||||
--color-success: #22c55e;
|
||||
--color-success-50: #f0fdf4;
|
||||
--color-success-100: #dcfce7;
|
||||
--color-success-200: #bbf7d0;
|
||||
--color-success-300: #86efac;
|
||||
--color-success-400: #4ade80;
|
||||
--color-success-500: #22c55e;
|
||||
--color-success-600: #16a34a;
|
||||
--color-success-700: #15803d;
|
||||
--color-success-800: #166534;
|
||||
--color-success-900: #14532d;
|
||||
--color-success-950: #052e16;
|
||||
|
||||
/* Warning Color: Yellow-500 (#EAB308) */
|
||||
--color-warning: #eab308;
|
||||
--color-warning-50: #fefce8;
|
||||
--color-warning-100: #fef9c3;
|
||||
--color-warning-200: #fef08a;
|
||||
--color-warning-300: #fde047;
|
||||
--color-warning-400: #facc15;
|
||||
--color-warning-500: #eab308;
|
||||
--color-warning-600: #ca8a04;
|
||||
--color-warning-700: #a16207;
|
||||
--color-warning-800: #854d0e;
|
||||
--color-warning-900: #713f12;
|
||||
--color-warning-950: #422006;
|
||||
|
||||
/* Error Color: Red-500 (#EF4444) */
|
||||
--color-error: #ef4444;
|
||||
--color-error-50: #fef2f2;
|
||||
--color-error-100: #fee2e2;
|
||||
--color-error-200: #fecaca;
|
||||
--color-error-300: #fca5a5;
|
||||
--color-error-400: #f87171;
|
||||
--color-error-500: #ef4444;
|
||||
--color-error-600: #dc2626;
|
||||
--color-error-700: #b91c1c;
|
||||
--color-error-800: #991b1b;
|
||||
--color-error-900: #7f1d1d;
|
||||
--color-error-950: #450a0a;
|
||||
|
||||
/* Background Colors: Slate */
|
||||
--color-background-light: #f8fafc;
|
||||
--color-background-dark: #0f172a;
|
||||
--color-surface-light: #ffffff;
|
||||
--color-surface-dark: #1e293b;
|
||||
}
|
||||
|
||||
/* CSS Variables for runtime theming */
|
||||
:root {
|
||||
--color-bg: var(--color-background-light);
|
||||
--color-surface: var(--color-surface-light);
|
||||
--color-text: #1e293b;
|
||||
--color-text-secondary: #64748b;
|
||||
--color-border: #e2e8f0;
|
||||
|
||||
/* Glassmorphism */
|
||||
--glass-bg: rgba(255, 255, 255, 0.8);
|
||||
--glass-border: rgba(255, 255, 255, 0.5);
|
||||
--glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.1);
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--gradient-success: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
||||
--gradient-warning: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
--gradient-blue: linear-gradient(135deg, #667eea 0%, #5e94ff 100%);
|
||||
}
|
||||
|
||||
/* Dark mode variables */
|
||||
.dark {
|
||||
--color-bg: var(--color-background-dark);
|
||||
--color-surface: var(--color-surface-dark);
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--color-border: #334155;
|
||||
|
||||
/* Dark Glassmorphism */
|
||||
--glass-bg: rgba(30, 41, 59, 0.8);
|
||||
--glass-border: rgba(51, 65, 85, 0.5);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Base styles */
|
||||
body {
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
/* Utility classes for design system colors */
|
||||
.bg-app {
|
||||
background-color: var(--color-bg);
|
||||
}
|
||||
|
||||
.bg-surface {
|
||||
background-color: var(--color-surface);
|
||||
}
|
||||
|
||||
.text-primary-color {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.text-secondary-color {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.border-app {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
/* Glassmorphism Effect */
|
||||
.glass {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--glass-border);
|
||||
box-shadow: var(--glass-shadow);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
/* Gradient backgrounds */
|
||||
.gradient-primary {
|
||||
background: var(--gradient-primary);
|
||||
}
|
||||
|
||||
.gradient-success {
|
||||
background: var(--gradient-success);
|
||||
}
|
||||
|
||||
.gradient-blue {
|
||||
background: var(--gradient-blue);
|
||||
}
|
||||
|
||||
/* Animated gradient */
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animated-gradient {
|
||||
background: linear-gradient(135deg, #667eea, #764ba2, #f093fb, #5e94ff);
|
||||
background-size: 300% 300%;
|
||||
animation: gradient-shift 8s ease infinite;
|
||||
}
|
||||
|
||||
/* Pulse animation */
|
||||
@keyframes pulse-glow {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
50% {
|
||||
box-shadow: 0 0 0 8px rgba(37, 99, 235, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-glow {
|
||||
animation: pulse-glow 2s infinite;
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Scale animation */
|
||||
@keyframes scaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-scaleIn {
|
||||
animation: scaleIn 0.2s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Number counter animation */
|
||||
@keyframes countUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-countUp {
|
||||
animation: countUp 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
/* Loading skeleton */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
.dark .skeleton {
|
||||
background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
/* Status indicator */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.status-dot.online {
|
||||
background-color: #22c55e;
|
||||
box-shadow: 0 0 8px rgba(34, 197, 94, 0.5);
|
||||
}
|
||||
|
||||
.status-dot.offline {
|
||||
background-color: #ef4444;
|
||||
}
|
||||
|
||||
.status-dot.warning {
|
||||
background-color: #eab308;
|
||||
}
|
||||
|
||||
/* Progress bar gradient */
|
||||
.progress-gradient {
|
||||
background: linear-gradient(90deg, #3b82f6, #8b5cf6, #ec4899);
|
||||
background-size: 200% 100%;
|
||||
animation: gradient-shift 3s ease infinite;
|
||||
}
|
||||
|
||||
/* Card hover effect */
|
||||
.card-hover {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.dark .card-hover:hover {
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.btn-glow:hover {
|
||||
box-shadow: 0 0 20px rgba(37, 99, 235, 0.4);
|
||||
}
|
||||
|
||||
/* Responsive utilities */
|
||||
@media (max-width: 768px) {
|
||||
.hide-mobile {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.hide-desktop {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Toast notifications */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9999;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring */
|
||||
.focus-ring:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-primary-500);
|
||||
}
|
||||
|
||||
/* Stat card special effects */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--gradient-primary);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.stat-card:hover::before {
|
||||
opacity: 1;
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ToastProvider, ErrorBoundary } from './components/common'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<ToastProvider>
|
||||
<App />
|
||||
</ToastProvider>
|
||||
</ErrorBoundary>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
)
|
||||
263
frontend/src/pages/Accounts.tsx
Normal file
263
frontend/src/pages/Accounts.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { RefreshCw, Search, Settings, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Button,
|
||||
Input,
|
||||
Select,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
StatusBadge,
|
||||
} from '../components/common'
|
||||
import { useS2AApi } from '../hooks/useS2AApi'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { S2AAccount, AccountListParams } from '../types'
|
||||
import { formatDateTime } from '../utils/format'
|
||||
|
||||
export default function Accounts() {
|
||||
const { config } = useConfig()
|
||||
const { getAccounts, loading, error } = useS2AApi()
|
||||
|
||||
const [accounts, setAccounts] = useState<S2AAccount[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(20)
|
||||
const [search, setSearch] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
if (!hasConfig) return
|
||||
|
||||
setRefreshing(true)
|
||||
const params: AccountListParams = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
platform: 'openai',
|
||||
}
|
||||
|
||||
if (search) params.search = search
|
||||
if (statusFilter) params.status = statusFilter as 'active' | 'inactive' | 'error'
|
||||
|
||||
const result = await getAccounts(params)
|
||||
if (result) {
|
||||
setAccounts(result.data)
|
||||
setTotal(result.total)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}, [hasConfig, page, pageSize, search, statusFilter, getAccounts])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setPage(1)
|
||||
fetchAccounts()
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">号池账号</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看 S2A 号池中的账号列表</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchAccounts}
|
||||
disabled={!hasConfig || refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
{!hasConfig && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">请先配置 S2A 连接</p>
|
||||
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
查看账号列表需要配置 S2A API 连接信息
|
||||
</p>
|
||||
<Link to="/config" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">
|
||||
前往配置
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
{hasConfig && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSearch} className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<Input
|
||||
placeholder="搜索账号名称..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value)
|
||||
setPage(1)
|
||||
}}
|
||||
options={[
|
||||
{ value: '', label: '全部状态' },
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'inactive', label: '停用' },
|
||||
{ value: 'error', label: '错误' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" icon={<Search className="h-4 w-4" />}>
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-4">
|
||||
<p className="text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Account List */}
|
||||
{hasConfig && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>账号列表</CardTitle>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">共 {total} 个账号</span>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && accounts.length === 0 ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-16 bg-slate-100 dark:bg-slate-700 rounded-lg animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-slate-500 dark:text-slate-400">暂无账号</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="border border-slate-200 dark:border-slate-700 rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow hoverable={false}>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>类型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">并发</TableHead>
|
||||
<TableHead className="text-right">优先级</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm">{account.id}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-medium">{account.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{account.type}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={account.status} />
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{account.current_concurrency !== undefined ? (
|
||||
<span>
|
||||
{account.current_concurrency}/{account.concurrency}
|
||||
</span>
|
||||
) : (
|
||||
account.concurrency
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{account.priority}</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatDateTime(account.created_at)}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4">
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
第 {page} 页,共 {totalPages} 页
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
icon={<ChevronLeft className="h-4 w-4" />}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
icon={<ChevronRight className="h-4 w-4" />}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
301
frontend/src/pages/Config.tsx
Normal file
301
frontend/src/pages/Config.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
Server,
|
||||
Mail,
|
||||
ChevronRight,
|
||||
Settings,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Globe,
|
||||
ToggleLeft,
|
||||
ToggleRight
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
export default function Config() {
|
||||
const { config, isConnected } = useConfig()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null)
|
||||
|
||||
// 编辑状态
|
||||
const [editS2ABase, setEditS2ABase] = useState('')
|
||||
const [editS2AKey, setEditS2AKey] = useState('')
|
||||
const [editConcurrency, setEditConcurrency] = useState(2)
|
||||
const [editPriority, setEditPriority] = useState(0)
|
||||
const [editGroupIds, setEditGroupIds] = useState('')
|
||||
const [proxyEnabled, setProxyEnabled] = useState(false)
|
||||
const [proxyAddress, setProxyAddress] = useState('')
|
||||
|
||||
// 获取服务器配置
|
||||
const fetchServerConfig = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/config')
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setEditS2ABase(data.data.s2a_api_base || '')
|
||||
setEditS2AKey(data.data.s2a_admin_key || '')
|
||||
setEditConcurrency(data.data.concurrency || 2)
|
||||
setEditPriority(data.data.priority || 0)
|
||||
setEditGroupIds(data.data.group_ids?.join(', ') || '')
|
||||
setProxyEnabled(data.data.proxy_enabled || false)
|
||||
setProxyAddress(data.data.default_proxy || '')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch config:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchServerConfig()
|
||||
}, [])
|
||||
|
||||
// 保存配置
|
||||
const handleSave = async () => {
|
||||
setSaving(true)
|
||||
setMessage(null)
|
||||
try {
|
||||
// 解析 group_ids
|
||||
const groupIds = editGroupIds
|
||||
.split(',')
|
||||
.map(s => parseInt(s.trim()))
|
||||
.filter(n => !isNaN(n))
|
||||
|
||||
const res = await fetch('/api/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
s2a_api_base: editS2ABase,
|
||||
s2a_admin_key: editS2AKey,
|
||||
concurrency: editConcurrency,
|
||||
priority: editPriority,
|
||||
group_ids: groupIds,
|
||||
proxy_enabled: proxyEnabled,
|
||||
default_proxy: proxyAddress,
|
||||
}),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.success) {
|
||||
setMessage({ type: 'success', text: '配置已保存' })
|
||||
fetchServerConfig()
|
||||
} else {
|
||||
setMessage({ type: 'error', text: data.error || '保存失败' })
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: '网络错误' })
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const configItems = [
|
||||
{
|
||||
to: '/config/s2a',
|
||||
icon: Server,
|
||||
title: 'S2A 高级配置',
|
||||
description: 'S2A 号池详细设置和测试',
|
||||
status: isConnected ? '已连接' : '未连接',
|
||||
statusColor: isConnected ? 'text-green-600 dark:text-green-400' : 'text-red-500',
|
||||
},
|
||||
{
|
||||
to: '/config/email',
|
||||
icon: Mail,
|
||||
title: '邮箱服务配置',
|
||||
description: '配置邮箱服务用于自动注册',
|
||||
status: (config.email?.services?.length ?? 0) > 0 ? '已配置' : '未配置',
|
||||
statusColor: (config.email?.services?.length ?? 0) > 0 ? 'text-green-600 dark:text-green-400' : 'text-orange-500',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Settings className="h-7 w-7 text-slate-500" />
|
||||
系统配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">配置会自动保存到服务器</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchServerConfig}
|
||||
disabled={loading}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
{message && (
|
||||
<div className={`p-3 rounded-lg text-sm ${message.type === 'success'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Config Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-500" />
|
||||
核心配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* S2A Config */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
S2A API 地址
|
||||
</label>
|
||||
<Input
|
||||
value={editS2ABase}
|
||||
onChange={(e) => setEditS2ABase(e.target.value)}
|
||||
placeholder="https://your-s2a-server.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
S2A Admin Key
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={editS2AKey}
|
||||
onChange={(e) => setEditS2AKey(e.target.value)}
|
||||
placeholder="admin-xxxxxx"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pooling Config */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
入库并发
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editConcurrency}
|
||||
onChange={(e) => setEditConcurrency(parseInt(e.target.value) || 1)}
|
||||
min={1}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
优先级
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={editPriority}
|
||||
onChange={(e) => setEditPriority(parseInt(e.target.value) || 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1">
|
||||
分组 ID (逗号分隔)
|
||||
</label>
|
||||
<Input
|
||||
value={editGroupIds}
|
||||
onChange={(e) => setEditGroupIds(e.target.value)}
|
||||
placeholder="1, 2, 3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proxy Config */}
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-5 w-5 text-orange-500" />
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">代理设置</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setProxyEnabled(!proxyEnabled)}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
{proxyEnabled ? (
|
||||
<>
|
||||
<ToggleRight className="h-6 w-6 text-green-500" />
|
||||
<span className="text-green-600 dark:text-green-400">已启用</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ToggleLeft className="h-6 w-6 text-slate-400" />
|
||||
<span className="text-slate-500">已禁用</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
value={proxyAddress}
|
||||
onChange={(e) => setProxyAddress(e.target.value)}
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
disabled={!proxyEnabled}
|
||||
className={!proxyEnabled ? 'opacity-50' : ''}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
服务器部署时通常不需要代理,在本地开发或特殊网络环境下可启用
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
icon={<Save className="h-4 w-4" />}
|
||||
>
|
||||
{saving ? '保存中...' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Sub Config Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{configItems.map((item) => (
|
||||
<Link key={item.to} to={item.to} className="block group">
|
||||
<Card className="h-full transition-all duration-200 hover:shadow-lg hover:border-blue-300 dark:hover:border-blue-600">
|
||||
<CardContent className="flex items-center gap-4 py-4">
|
||||
<div className="p-3 rounded-xl bg-blue-50 dark:bg-blue-900/30 group-hover:bg-blue-100 dark:group-hover:bg-blue-900/50 transition-colors">
|
||||
<item.icon className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-slate-900 dark:text-slate-100">{item.title}</h3>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{item.description}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${item.statusColor}`}>
|
||||
{item.status}
|
||||
</span>
|
||||
<ChevronRight className="h-5 w-5 text-slate-400 group-hover:text-blue-500 transition-colors" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p>配置会保存在服务器端,重启后自动加载。首次启动时会自动创建默认配置。</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
frontend/src/pages/Dashboard.tsx
Normal file
162
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Upload, RefreshCw, Settings } from 'lucide-react'
|
||||
import { PoolStatus, RecentRecords } from '../components/dashboard'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button } from '../components/common'
|
||||
import { useS2AApi } from '../hooks/useS2AApi'
|
||||
import { useRecords } from '../hooks/useRecords'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { DashboardStats } from '../types'
|
||||
import { formatNumber, formatCurrency } from '../utils/format'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { getDashboardStats, loading, error, isConnected } = useS2AApi()
|
||||
const { records } = useRecords()
|
||||
const { config } = useConfig()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const fetchStats = async () => {
|
||||
if (!isConnected) return
|
||||
setRefreshing(true)
|
||||
const data = await getDashboardStats()
|
||||
if (data) {
|
||||
setStats(data)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isConnected])
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">仪表盘</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">号池状态概览</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStats}
|
||||
disabled={!isConnected || refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
<Link to="/upload">
|
||||
<Button size="sm" icon={<Upload className="h-4 w-4" />}>
|
||||
上传入库
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
{!hasConfig && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">请先配置 S2A 连接</p>
|
||||
<p className="mt-1 text-sm text-yellow-700 dark:text-yellow-300">
|
||||
前往系统配置页面设置 S2A API 地址和管理密钥
|
||||
</p>
|
||||
<Link to="/config" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">
|
||||
前往配置
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pool Status */}
|
||||
<PoolStatus stats={stats} loading={loading || refreshing} error={error} />
|
||||
|
||||
{/* Stats Summary */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>今日统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">请求数</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.today_requests)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Token 消耗</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.today_tokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">费用</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(stats.today_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">TPM</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.tpm)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card hoverable>
|
||||
<CardHeader>
|
||||
<CardTitle>累计统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总请求数</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.total_requests)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总 Token</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatNumber(stats.total_tokens)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">总费用</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{formatCurrency(stats.total_cost)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">过载账号</p>
|
||||
<p className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{stats.overload_accounts}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Records */}
|
||||
<RecentRecords records={records} loading={loading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
frontend/src/pages/EmailConfig.tsx
Normal file
251
frontend/src/pages/EmailConfig.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { CheckCircle, Save, Mail, Plus, Trash2, TestTube, Loader2, Settings, Server } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { MailServiceConfig } from '../types'
|
||||
|
||||
const API_BASE = 'http://localhost:8088'
|
||||
|
||||
export default function EmailConfig() {
|
||||
const { config, updateEmailConfig } = useConfig()
|
||||
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [services, setServices] = useState<MailServiceConfig[]>(config.email?.services || [])
|
||||
const [testingIndex, setTestingIndex] = useState<number | null>(null)
|
||||
const [testResults, setTestResults] = useState<Record<number, { success: boolean; message: string }>>({})
|
||||
|
||||
// 同步配置变化
|
||||
useEffect(() => {
|
||||
if (config.email?.services) {
|
||||
setServices(config.email.services)
|
||||
}
|
||||
}, [config.email?.services])
|
||||
|
||||
const handleSave = async () => {
|
||||
// 保存到前端 context
|
||||
updateEmailConfig({ services })
|
||||
|
||||
// 保存到后端
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mail/services`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ services }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddService = () => {
|
||||
setServices([
|
||||
...services,
|
||||
{
|
||||
name: `邮箱服务 ${services.length + 1}`,
|
||||
apiBase: '',
|
||||
apiToken: '',
|
||||
domain: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
const handleRemoveService = (index: number) => {
|
||||
if (services.length <= 1) {
|
||||
return // 至少保留一个服务
|
||||
}
|
||||
const newServices = services.filter((_, i) => i !== index)
|
||||
setServices(newServices)
|
||||
}
|
||||
|
||||
const handleUpdateService = (index: number, updates: Partial<MailServiceConfig>) => {
|
||||
const newServices = [...services]
|
||||
newServices[index] = { ...newServices[index], ...updates }
|
||||
setServices(newServices)
|
||||
}
|
||||
|
||||
const handleTestService = async (index: number) => {
|
||||
const service = services[index]
|
||||
setTestingIndex(index)
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '测试中...' } }))
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/mail/services/test`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_base: service.apiBase,
|
||||
api_token: service.apiToken,
|
||||
domain: service.domain,
|
||||
email_path: service.emailPath,
|
||||
}),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.code === 0) {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: true, message: '连接成功' } }))
|
||||
} else {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: data.message || '连接失败' } }))
|
||||
}
|
||||
} catch (error) {
|
||||
setTestResults(prev => ({ ...prev, [index]: { success: false, message: '网络错误' } }))
|
||||
} finally {
|
||||
setTestingIndex(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Mail className="h-7 w-7 text-purple-500" />
|
||||
邮箱服务配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
配置多个邮箱服务用于自动注册和验证码接收
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddService}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
添加服务
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
>
|
||||
{saved ? '已保存' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Cards */}
|
||||
<div className="space-y-4">
|
||||
{services.map((service, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-purple-500" />
|
||||
<span>{service.name || `服务 ${index + 1}`}</span>
|
||||
<span className="text-sm font-normal text-slate-500">
|
||||
(@{service.domain || '未设置域名'})
|
||||
</span>
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResults[index] && (
|
||||
<span className={`text-sm ${testResults[index].success ? 'text-green-500' : 'text-red-500'}`}>
|
||||
{testResults[index].message}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestService(index)}
|
||||
disabled={testingIndex === index || !service.apiBase}
|
||||
icon={testingIndex === index ? <Loader2 className="h-4 w-4 animate-spin" /> : <TestTube className="h-4 w-4" />}
|
||||
>
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveService(index)}
|
||||
disabled={services.length <= 1}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="服务名称"
|
||||
placeholder="如:主邮箱服务"
|
||||
value={service.name}
|
||||
onChange={(e) => handleUpdateService(index, { name: e.target.value })}
|
||||
hint="用于识别不同的邮箱服务"
|
||||
/>
|
||||
<Input
|
||||
label="邮箱域名"
|
||||
placeholder="如:example.com"
|
||||
value={service.domain}
|
||||
onChange={(e) => handleUpdateService(index, { domain: e.target.value })}
|
||||
hint="生成邮箱地址的域名后缀"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="API 地址"
|
||||
placeholder="https://mail.example.com"
|
||||
value={service.apiBase}
|
||||
onChange={(e) => handleUpdateService(index, { apiBase: e.target.value })}
|
||||
hint="邮箱服务 API 地址"
|
||||
/>
|
||||
<Input
|
||||
label="API Token"
|
||||
type="password"
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
value={service.apiToken}
|
||||
onChange={(e) => handleUpdateService(index, { apiToken: e.target.value })}
|
||||
hint="邮箱服务的 API 认证令牌"
|
||||
/>
|
||||
|
||||
{/* Advanced Settings (Collapsed by default) */}
|
||||
<details className="group">
|
||||
<summary className="cursor-pointer text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 flex items-center gap-1">
|
||||
<Settings className="h-4 w-4" />
|
||||
高级设置
|
||||
</summary>
|
||||
<div className="mt-4 space-y-4 pl-5 border-l-2 border-slate-200 dark:border-slate-700">
|
||||
<Input
|
||||
label="邮件列表 API 路径"
|
||||
placeholder="/api/public/emailList (默认)"
|
||||
value={service.emailPath || ''}
|
||||
onChange={(e) => handleUpdateService(index, { emailPath: e.target.value })}
|
||||
hint="获取邮件列表的 API 路径"
|
||||
/>
|
||||
<Input
|
||||
label="创建用户 API 路径"
|
||||
placeholder="/api/public/addUser (默认)"
|
||||
value={service.addUserApi || ''}
|
||||
onChange={(e) => handleUpdateService(index, { addUserApi: e.target.value })}
|
||||
hint="创建邮箱用户的 API 路径"
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Help Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p className="font-medium mb-2">配置说明:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>可以添加多个邮箱服务,系统会轮询使用各个服务</li>
|
||||
<li>每个服务需要配置独立的 API 地址、Token 和域名</li>
|
||||
<li>邮箱域名决定生成的邮箱地址后缀(如 xxx@esyteam.edu.kg)</li>
|
||||
<li>验证码会自动从配置的邮箱服务获取</li>
|
||||
<li>高级设置通常不需要修改,使用默认值即可</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
588
frontend/src/pages/Monitor.tsx
Normal file
588
frontend/src/pages/Monitor.tsx
Normal file
@@ -0,0 +1,588 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Target,
|
||||
Activity,
|
||||
RefreshCw,
|
||||
Play,
|
||||
Pause,
|
||||
Shield,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
import type { DashboardStats } from '../types'
|
||||
|
||||
interface PoolStatus {
|
||||
target: number
|
||||
current: number
|
||||
deficit: number
|
||||
last_check: string
|
||||
auto_add: boolean
|
||||
min_interval: number
|
||||
last_auto_add: string
|
||||
polling_enabled: boolean
|
||||
polling_interval: number
|
||||
}
|
||||
|
||||
interface HealthCheckResult {
|
||||
account_id: number
|
||||
email: string
|
||||
status: string
|
||||
checked_at: string
|
||||
error?: string
|
||||
auto_paused?: boolean
|
||||
}
|
||||
|
||||
interface AutoAddLog {
|
||||
timestamp: string
|
||||
target: number
|
||||
current: number
|
||||
deficit: number
|
||||
action: string
|
||||
success: number
|
||||
failed: number
|
||||
message: string
|
||||
}
|
||||
|
||||
export default function Monitor() {
|
||||
const { config } = useConfig()
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [poolStatus, setPoolStatus] = useState<PoolStatus | null>(null)
|
||||
const [healthResults, setHealthResults] = useState<HealthCheckResult[]>([])
|
||||
const [autoAddLogs, setAutoAddLogs] = useState<AutoAddLog[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [checkingHealth, setCheckingHealth] = useState(false)
|
||||
const [autoPauseEnabled, setAutoPauseEnabled] = useState(false)
|
||||
|
||||
// 配置表单状态
|
||||
const [targetInput, setTargetInput] = useState(50)
|
||||
const [autoAdd, setAutoAdd] = useState(false)
|
||||
const [minInterval, setMinInterval] = useState(300)
|
||||
const [pollingEnabled, setPollingEnabled] = useState(false)
|
||||
const [pollingInterval, setPollingInterval] = useState(60)
|
||||
|
||||
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
|
||||
|
||||
// 获取号池状态
|
||||
const fetchPoolStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/pool/status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setPoolStatus(data.data)
|
||||
setTargetInput(data.data.target)
|
||||
setAutoAdd(data.data.auto_add)
|
||||
setMinInterval(data.data.min_interval)
|
||||
setPollingEnabled(data.data.polling_enabled)
|
||||
setPollingInterval(data.data.polling_interval)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取号池状态失败:', e)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
// 刷新 S2A 统计
|
||||
const refreshStats = useCallback(async () => {
|
||||
setRefreshing(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/pool/refresh`, { method: 'POST' })
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStats(data.data)
|
||||
}
|
||||
}
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('刷新统计失败:', e)
|
||||
}
|
||||
setRefreshing(false)
|
||||
}, [backendUrl, fetchPoolStatus])
|
||||
|
||||
// 设置目标
|
||||
const handleSetTarget = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/pool/target`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
target: targetInput,
|
||||
auto_add: autoAdd,
|
||||
min_interval: minInterval,
|
||||
}),
|
||||
})
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('设置目标失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 控制轮询
|
||||
const handleTogglePolling = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/pool/polling`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: !pollingEnabled,
|
||||
interval: pollingInterval,
|
||||
}),
|
||||
})
|
||||
setPollingEnabled(!pollingEnabled)
|
||||
await fetchPoolStatus()
|
||||
} catch (e) {
|
||||
console.error('控制轮询失败:', e)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 健康检查
|
||||
const handleHealthCheck = async (autoPause: boolean = false) => {
|
||||
setCheckingHealth(true)
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/health-check/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ auto_pause: autoPause }),
|
||||
})
|
||||
// 等待一会儿再获取结果
|
||||
setTimeout(async () => {
|
||||
const res = await fetch(`${backendUrl}/api/health-check/results`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setHealthResults(data.data || [])
|
||||
}
|
||||
}
|
||||
setCheckingHealth(false)
|
||||
}, 5000)
|
||||
} catch (e) {
|
||||
console.error('健康检查失败:', e)
|
||||
setCheckingHealth(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取自动补号日志
|
||||
const fetchAutoAddLogs = async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/auto-add/logs`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setAutoAddLogs(data.data || [])
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取日志失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchPoolStatus()
|
||||
refreshStats()
|
||||
fetchAutoAddLogs()
|
||||
}, [fetchPoolStatus, refreshStats])
|
||||
|
||||
// 计算健康状态
|
||||
const healthySummary = healthResults.reduce(
|
||||
(acc, r) => {
|
||||
if (r.status === 'active' && !r.error) acc.healthy++
|
||||
else acc.unhealthy++
|
||||
return acc
|
||||
},
|
||||
{ healthy: 0, unhealthy: 0 }
|
||||
)
|
||||
|
||||
const deficit = poolStatus ? Math.max(0, poolStatus.target - poolStatus.current) : 0
|
||||
const healthPercent = poolStatus && poolStatus.target > 0
|
||||
? Math.min(100, (poolStatus.current / poolStatus.target) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">号池监控</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
实时监控号池状态,自动补号管理
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refreshStats}
|
||||
disabled={refreshing}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 当前/目标 */}
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">当前 / 目标</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100 animate-countUp">
|
||||
{poolStatus?.current ?? '-'} / {poolStatus?.target ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Target className="h-6 w-6 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full progress-gradient rounded-full transition-all duration-500"
|
||||
style={{ width: `${healthPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 需补充 */}
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">需补充</p>
|
||||
<p className={`text-2xl font-bold animate-countUp ${deficit > 0 ? 'text-orange-500' : 'text-green-500'
|
||||
}`}>
|
||||
{deficit}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${deficit > 0 ? 'bg-orange-100 dark:bg-orange-900/30' : 'bg-green-100 dark:bg-green-900/30'
|
||||
}`}>
|
||||
{deficit > 0 ? (
|
||||
<TrendingDown className="h-6 w-6 text-orange-500" />
|
||||
) : (
|
||||
<TrendingUp className="h-6 w-6 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{deficit > 0 && (
|
||||
<p className="mt-2 text-xs text-orange-500 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
低于目标
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 轮询状态 */}
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">实时监控</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{pollingEnabled ? '运行中' : '已停止'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${pollingEnabled ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
{pollingEnabled ? (
|
||||
<Activity className="h-6 w-6 text-green-500 animate-pulse" />
|
||||
) : (
|
||||
<Pause className="h-6 w-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{pollingEnabled && (
|
||||
<p className="mt-2 text-xs text-slate-500 flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
每 {pollingInterval} 秒刷新
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 自动补号 */}
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">自动补号</p>
|
||||
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{autoAdd ? '已启用' : '已禁用'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${autoAdd ? 'bg-purple-100 dark:bg-purple-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
<Zap className={`h-6 w-6 ${autoAdd ? 'text-purple-500' : 'text-slate-400'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 配置面板 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* 目标设置 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-blue-500" />
|
||||
号池目标设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="目标账号数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={targetInput}
|
||||
onChange={(e) => setTargetInput(Number(e.target.value))}
|
||||
hint="期望保持的活跃账号数量"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoAdd"
|
||||
checked={autoAdd}
|
||||
onChange={(e) => setAutoAdd(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="autoAdd" className="text-sm text-slate-700 dark:text-slate-300">
|
||||
启用自动补号
|
||||
</label>
|
||||
</div>
|
||||
<Input
|
||||
label="最小间隔 (秒)"
|
||||
type="number"
|
||||
min={60}
|
||||
max={3600}
|
||||
value={minInterval}
|
||||
onChange={(e) => setMinInterval(Number(e.target.value))}
|
||||
hint="两次自动补号的最小间隔"
|
||||
disabled={!autoAdd}
|
||||
/>
|
||||
<Button onClick={handleSetTarget} loading={loading} className="w-full">
|
||||
保存设置
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 轮询控制 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-green-500" />
|
||||
实时监控设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="轮询间隔 (秒)"
|
||||
type="number"
|
||||
min={10}
|
||||
max={300}
|
||||
value={pollingInterval}
|
||||
onChange={(e) => setPollingInterval(Number(e.target.value))}
|
||||
hint="自动刷新号池状态的间隔时间"
|
||||
/>
|
||||
<div className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">监控状态</p>
|
||||
<p className="text-sm text-slate-500">
|
||||
{pollingEnabled ? '正在实时监控号池状态' : '监控已暂停'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`status-dot ${pollingEnabled ? 'online' : 'offline'}`} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleTogglePolling}
|
||||
loading={loading}
|
||||
variant={pollingEnabled ? 'outline' : 'primary'}
|
||||
className="w-full"
|
||||
icon={pollingEnabled ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
>
|
||||
{pollingEnabled ? '停止监控' : '启动监控'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 健康检查 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-purple-500" />
|
||||
账号健康检查
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoPauseEnabled}
|
||||
onChange={(e) => setAutoPauseEnabled(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-slate-300 text-purple-600 focus:ring-purple-500"
|
||||
/>
|
||||
自动暂停问题账号
|
||||
</label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleHealthCheck(autoPauseEnabled)}
|
||||
disabled={checkingHealth}
|
||||
loading={checkingHealth}
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
>
|
||||
{checkingHealth ? '检查中...' : '开始检查'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{healthResults.length > 0 ? (
|
||||
<>
|
||||
{/* 统计 */}
|
||||
<div className="flex gap-4 mb-4">
|
||||
<div className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>健康: {healthySummary.healthy}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-red-500">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>异常: {healthySummary.unhealthy}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{healthResults.map((result) => (
|
||||
<div
|
||||
key={result.account_id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg ${result.error
|
||||
? 'bg-red-50 dark:bg-red-900/20'
|
||||
: 'bg-green-50 dark:bg-green-900/20'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{result.email}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">ID: {result.account_id}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-sm font-medium ${result.error ? 'text-red-500' : 'text-green-600'
|
||||
}`}>
|
||||
{result.status}
|
||||
</p>
|
||||
{result.error && (
|
||||
<p className="text-xs text-red-400">{result.error}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
<Shield className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>点击"开始检查"验证所有账号状态</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* S2A 实时统计 */}
|
||||
{stats && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>S2A 实时统计</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="text-center p-4 rounded-lg bg-blue-50 dark:bg-blue-900/20">
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.total_accounts}</p>
|
||||
<p className="text-sm text-slate-500">总账号</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-green-50 dark:bg-green-900/20">
|
||||
<p className="text-2xl font-bold text-green-600">{stats.normal_accounts}</p>
|
||||
<p className="text-sm text-slate-500">正常</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-red-50 dark:bg-red-900/20">
|
||||
<p className="text-2xl font-bold text-red-500">{stats.error_accounts}</p>
|
||||
<p className="text-sm text-slate-500">错误</p>
|
||||
</div>
|
||||
<div className="text-center p-4 rounded-lg bg-orange-50 dark:bg-orange-900/20">
|
||||
<p className="text-2xl font-bold text-orange-500">{stats.ratelimit_accounts}</p>
|
||||
<p className="text-sm text-slate-500">限流</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 自动补号日志 */}
|
||||
{autoAddLogs.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5 text-slate-500" />
|
||||
操作日志
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchAutoAddLogs}
|
||||
icon={<RefreshCw className="h-4 w-4" />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{[...autoAddLogs].reverse().slice(0, 20).map((log, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex items-center justify-between p-3 rounded-lg text-sm ${log.action.includes('trigger') || log.action.includes('decrease')
|
||||
? 'bg-orange-50 dark:bg-orange-900/20'
|
||||
: log.action.includes('increase')
|
||||
? 'bg-green-50 dark:bg-green-900/20'
|
||||
: 'bg-slate-50 dark:bg-slate-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-slate-400">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="font-medium text-slate-900 dark:text-slate-100">
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
{log.current} / {log.target}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
112
frontend/src/pages/Records.tsx
Normal file
112
frontend/src/pages/Records.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Trash2, Calendar } from 'lucide-react'
|
||||
import { RecordList, RecordStats } from '../components/records'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useRecords } from '../hooks/useRecords'
|
||||
|
||||
export default function Records() {
|
||||
const { records, deleteRecord, clearRecords, getStats } = useRecords()
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
const stats = useMemo(() => getStats(), [getStats])
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!startDate && !endDate) return records
|
||||
|
||||
return records.filter((record) => {
|
||||
const recordDate = new Date(record.timestamp)
|
||||
const start = startDate ? new Date(startDate) : null
|
||||
const end = endDate ? new Date(endDate + 'T23:59:59') : null
|
||||
|
||||
if (start && recordDate < start) return false
|
||||
if (end && recordDate > end) return false
|
||||
return true
|
||||
})
|
||||
}, [records, startDate, endDate])
|
||||
|
||||
const handleClearFilter = () => {
|
||||
setStartDate('')
|
||||
setEndDate('')
|
||||
}
|
||||
|
||||
const handleClearAll = () => {
|
||||
if (window.confirm('确定要清空所有记录吗?此操作不可恢复。')) {
|
||||
clearRecords()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">加号记录</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">查看历史入库记录</p>
|
||||
</div>
|
||||
{records.length > 0 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearAll}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
>
|
||||
清空记录
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<RecordStats stats={stats} />
|
||||
|
||||
{/* Filter */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="h-5 w-5" />
|
||||
日期筛选
|
||||
</CardTitle>
|
||||
{(startDate || endDate) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleClearFilter}>
|
||||
清除筛选
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="w-40">
|
||||
<Input
|
||||
label="开始日期"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-40">
|
||||
<Input
|
||||
label="结束日期"
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
共 {filteredRecords.length} 条记录
|
||||
{filteredRecords.length !== records.length && <span className="ml-1">(已筛选)</span>}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Record List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>记录列表</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RecordList records={filteredRecords} onDelete={deleteRecord} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
241
frontend/src/pages/S2AConfig.tsx
Normal file
241
frontend/src/pages/S2AConfig.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react'
|
||||
import { TestTube, CheckCircle, XCircle, Loader2, Save, Server, Plus, X } from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
export default function S2AConfig() {
|
||||
const {
|
||||
config,
|
||||
updateS2AConfig,
|
||||
updatePoolingConfig,
|
||||
testConnection,
|
||||
isConnected,
|
||||
} = useConfig()
|
||||
|
||||
const [testing, setTesting] = useState(false)
|
||||
const [testResult, setTestResult] = useState<boolean | null>(null)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
// Local form state - S2A 连接
|
||||
const [s2aApiBase, setS2aApiBase] = useState(config.s2a.apiBase)
|
||||
const [s2aAdminKey, setS2aAdminKey] = useState(config.s2a.adminKey)
|
||||
|
||||
// Local form state - 入库设置
|
||||
const [poolingConcurrency, setPoolingConcurrency] = useState(config.pooling.concurrency)
|
||||
const [poolingPriority, setPoolingPriority] = useState(config.pooling.priority)
|
||||
const [groupIds, setGroupIds] = useState<number[]>(config.pooling.groupIds || [])
|
||||
const [newGroupId, setNewGroupId] = useState('')
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
// Save first
|
||||
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
|
||||
|
||||
setTesting(true)
|
||||
setTestResult(null)
|
||||
|
||||
// Wait a bit for the client to be recreated
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const result = await testConnection()
|
||||
setTestResult(result)
|
||||
setTesting(false)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updateS2AConfig({ apiBase: s2aApiBase, adminKey: s2aAdminKey })
|
||||
updatePoolingConfig({
|
||||
concurrency: poolingConcurrency,
|
||||
priority: poolingPriority,
|
||||
groupIds: groupIds,
|
||||
})
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const handleAddGroupId = () => {
|
||||
const id = parseInt(newGroupId, 10)
|
||||
if (!isNaN(id) && !groupIds.includes(id)) {
|
||||
setGroupIds([...groupIds, id])
|
||||
setNewGroupId('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveGroupId = (id: number) => {
|
||||
setGroupIds(groupIds.filter(g => g !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<Server className="h-7 w-7 text-blue-500" />
|
||||
S2A 配置
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">配置 S2A 号池连接和入库参数</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
icon={saved ? <CheckCircle className="h-4 w-4" /> : <Save className="h-4 w-4" />}
|
||||
>
|
||||
{saved ? '已保存' : '保存配置'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* S2A Connection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>S2A 连接配置</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<span className="flex items-center gap-1 text-sm text-green-600 dark:text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
已连接
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
未连接
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Input
|
||||
label="S2A API 地址"
|
||||
placeholder="http://localhost:8080"
|
||||
value={s2aApiBase}
|
||||
onChange={(e) => setS2aApiBase(e.target.value)}
|
||||
hint="S2A 服务的 API 地址,例如 http://localhost:8080"
|
||||
/>
|
||||
<Input
|
||||
label="Admin API Key"
|
||||
type="password"
|
||||
placeholder="admin-xxxxxxxxxxxxxxxx"
|
||||
value={s2aAdminKey}
|
||||
onChange={(e) => setS2aAdminKey(e.target.value)}
|
||||
hint="S2A 管理密钥,可在 S2A 后台 Settings 页面获取"
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !s2aApiBase || !s2aAdminKey}
|
||||
icon={
|
||||
testing ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{testing ? '测试中...' : '测试连接'}
|
||||
</Button>
|
||||
{testResult !== null && (
|
||||
<span
|
||||
className={`text-sm ${testResult
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{testResult ? '连接成功' : '连接失败'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Pooling Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>入库默认设置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="默认并发数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={poolingConcurrency}
|
||||
onChange={(e) => setPoolingConcurrency(Number(e.target.value))}
|
||||
hint="账号的默认并发请求数"
|
||||
/>
|
||||
<Input
|
||||
label="默认优先级"
|
||||
type="number"
|
||||
min={0}
|
||||
max={100}
|
||||
value={poolingPriority}
|
||||
onChange={(e) => setPoolingPriority(Number(e.target.value))}
|
||||
hint="账号的默认优先级,数值越大优先级越高"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group IDs */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||
分组 ID
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupIds.map(id => (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 px-3 py-1 rounded-full text-sm bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"
|
||||
>
|
||||
{id}
|
||||
<button
|
||||
onClick={() => handleRemoveGroupId(id)}
|
||||
className="hover:text-red-500 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{groupIds.length === 0 && (
|
||||
<span className="text-sm text-slate-400">未设置分组</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Input
|
||||
placeholder="输入分组 ID"
|
||||
type="number"
|
||||
min={1}
|
||||
value={newGroupId}
|
||||
onChange={(e) => setNewGroupId(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddGroupId()}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddGroupId}
|
||||
disabled={!newGroupId}
|
||||
icon={<Plus className="h-4 w-4" />}
|
||||
>
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
入库时账号将被分配到这些分组
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info */}
|
||||
<Card>
|
||||
<CardContent>
|
||||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||||
<p className="font-medium mb-2">配置说明:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>S2A API 地址是您部署的 S2A 服务的完整 URL</li>
|
||||
<li>Admin API Key 用于管理账号池,具有完全权限</li>
|
||||
<li>入库默认设置会应用到新入库的账号</li>
|
||||
<li>分组 ID 用于将账号归类到指定分组</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
483
frontend/src/pages/TeamProcess.tsx
Normal file
483
frontend/src/pages/TeamProcess.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Play,
|
||||
Square,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Upload,
|
||||
Loader2,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Card, CardHeader, CardTitle, CardContent, Button, Input } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
interface Owner {
|
||||
email: string
|
||||
password: string
|
||||
token: string
|
||||
}
|
||||
|
||||
interface TeamResult {
|
||||
team_index: number
|
||||
owner_email: string
|
||||
team_id: string
|
||||
registered: number
|
||||
added_to_s2a: number
|
||||
member_emails: string[]
|
||||
errors: string[]
|
||||
duration_ms: number
|
||||
}
|
||||
|
||||
interface ProcessStatus {
|
||||
running: boolean
|
||||
started_at: string
|
||||
total_teams: number
|
||||
completed: number
|
||||
results: TeamResult[]
|
||||
elapsed_ms: number
|
||||
}
|
||||
|
||||
export default function TeamProcess() {
|
||||
const { config } = useConfig()
|
||||
const [owners, setOwners] = useState<Owner[]>([])
|
||||
const [status, setStatus] = useState<ProcessStatus | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [polling, setPolling] = useState(false)
|
||||
|
||||
// 配置
|
||||
const [membersPerTeam, setMembersPerTeam] = useState(4)
|
||||
const [concurrentTeams, setConcurrentTeams] = useState(2)
|
||||
const [browserType, setBrowserType] = useState<'chromedp' | 'rod'>('chromedp')
|
||||
const [headless, setHeadless] = useState(true)
|
||||
const [proxy, setProxy] = useState('')
|
||||
|
||||
const backendUrl = config.s2a.apiBase.replace(/\/api.*$/, '').replace(/:8080/, ':8088')
|
||||
|
||||
// 获取状态
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/team/status`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStatus(data.data)
|
||||
if (!data.data.running) {
|
||||
setPolling(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取状态失败:', e)
|
||||
}
|
||||
}, [backendUrl])
|
||||
|
||||
// 轮询状态
|
||||
useEffect(() => {
|
||||
if (polling) {
|
||||
const interval = setInterval(fetchStatus, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [polling, fetchStatus])
|
||||
|
||||
// 初始化
|
||||
useEffect(() => {
|
||||
fetchStatus()
|
||||
}, [fetchStatus])
|
||||
|
||||
// 上传账号文件
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = JSON.parse(text)
|
||||
const parsed = Array.isArray(data) ? data : [data]
|
||||
|
||||
const validOwners = parsed.filter((a: Record<string, unknown>) =>
|
||||
(a.email || a.account) && a.password && (a.token || a.access_token)
|
||||
).map((a: Record<string, unknown>) => ({
|
||||
email: (a.email || a.account) as string,
|
||||
password: a.password as string,
|
||||
token: (a.token || a.access_token) as string,
|
||||
}))
|
||||
|
||||
setOwners(validOwners)
|
||||
setConcurrentTeams(Math.min(validOwners.length, 2))
|
||||
} catch (err) {
|
||||
alert('文件解析失败,请确保是有效的 JSON 格式')
|
||||
}
|
||||
}
|
||||
|
||||
// 启动处理
|
||||
const handleStart = async () => {
|
||||
if (owners.length === 0) {
|
||||
alert('请先上传账号文件')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/api/team/process`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
owners: owners.slice(0, concurrentTeams),
|
||||
members_per_team: membersPerTeam,
|
||||
concurrent_teams: concurrentTeams,
|
||||
browser_type: browserType,
|
||||
headless,
|
||||
proxy,
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
setPolling(true)
|
||||
fetchStatus()
|
||||
} else {
|
||||
const data = await res.json()
|
||||
alert(data.message || '启动失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('启动失败:', e)
|
||||
alert('启动失败')
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
// 停止处理
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await fetch(`${backendUrl}/api/team/stop`, { method: 'POST' })
|
||||
setPolling(false)
|
||||
fetchStatus()
|
||||
} catch (e) {
|
||||
console.error('停止失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const isRunning = status?.running
|
||||
|
||||
// 计算统计
|
||||
const totalRegistered = status?.results.reduce((sum, r) => sum + r.registered, 0) || 0
|
||||
const totalS2A = status?.results.reduce((sum, r) => sum + r.added_to_s2a, 0) || 0
|
||||
const expectedTotal = (status?.total_teams || 0) * membersPerTeam
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
Team 批量处理
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
多 Team 并发注册成员并入库 S2A
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchStatus}
|
||||
icon={<RefreshCw className={`h-4 w-4 ${polling ? 'animate-spin' : ''}`} />}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 状态概览 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">运行状态</p>
|
||||
<p className={`text-lg font-bold ${isRunning ? 'text-green-500' : 'text-slate-500'}`}>
|
||||
{isRunning ? '运行中' : '空闲'}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`h-12 w-12 rounded-xl flex items-center justify-center ${isRunning ? 'bg-green-100 dark:bg-green-900/30' : 'bg-slate-100 dark:bg-slate-800'
|
||||
}`}>
|
||||
{isRunning ? (
|
||||
<Loader2 className="h-6 w-6 text-green-500 animate-spin" />
|
||||
) : (
|
||||
<Clock className="h-6 w-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">进度</p>
|
||||
<p className="text-lg font-bold text-blue-500">
|
||||
{status?.completed || 0} / {status?.total_teams || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">已注册</p>
|
||||
<p className="text-lg font-bold text-green-500">
|
||||
{totalRegistered} / {expectedTotal || '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||
<CheckCircle className="h-6 w-6 text-green-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="stat-card card-hover">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">已入库</p>
|
||||
<p className="text-lg font-bold text-purple-500">{totalS2A}</p>
|
||||
</div>
|
||||
<div className="h-12 w-12 rounded-xl bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
|
||||
<Settings className="h-6 w-6 text-purple-500" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* 配置面板 */}
|
||||
<Card className="glass-card">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-blue-500" />
|
||||
处理配置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 账号文件上传 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
Owner 账号文件
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex-1 flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg cursor-pointer hover:border-blue-500 transition-colors">
|
||||
<Upload className="h-5 w-5 text-slate-400" />
|
||||
<span className="text-sm text-slate-500">
|
||||
{owners.length > 0 ? `已加载 ${owners.length} 个账号` : '选择 JSON 文件'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".json"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
disabled={isRunning}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="每个 Team 成员数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={membersPerTeam}
|
||||
onChange={(e) => setMembersPerTeam(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="并发 Team 数"
|
||||
type="number"
|
||||
min={1}
|
||||
max={Math.max(1, owners.length)}
|
||||
value={concurrentTeams}
|
||||
onChange={(e) => setConcurrentTeams(Number(e.target.value))}
|
||||
disabled={isRunning}
|
||||
hint={`最多 ${owners.length} 个`}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2">
|
||||
浏览器自动化
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setBrowserType('chromedp')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'chromedp'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Chromedp (推荐)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setBrowserType('rod')}
|
||||
disabled={isRunning}
|
||||
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${browserType === 'rod'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Rod
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="headless"
|
||||
checked={headless}
|
||||
onChange={(e) => setHeadless(e.target.checked)}
|
||||
disabled={isRunning}
|
||||
className="h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<label htmlFor="headless" className="text-sm text-slate-700 dark:text-slate-300">
|
||||
无头模式 (推荐)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="代理地址"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={proxy}
|
||||
onChange={(e) => setProxy(e.target.value)}
|
||||
disabled={isRunning}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
{isRunning ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleStop}
|
||||
className="flex-1"
|
||||
icon={<Square className="h-4 w-4" />}
|
||||
>
|
||||
停止
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleStart}
|
||||
loading={loading}
|
||||
disabled={owners.length === 0}
|
||||
className="flex-1"
|
||||
icon={<Play className="h-4 w-4" />}
|
||||
>
|
||||
开始处理
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<Card className="glass-card lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-green-500" />
|
||||
处理结果
|
||||
</CardTitle>
|
||||
{status && status.elapsed_ms > 0 && (
|
||||
<span className="text-sm text-slate-500">
|
||||
耗时: {(status.elapsed_ms / 1000).toFixed(1)}s
|
||||
</span>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{status?.results && status.results.length > 0 ? (
|
||||
<div className="space-y-4 max-h-[500px] overflow-y-auto">
|
||||
{status.results.map((result) => (
|
||||
<div
|
||||
key={result.team_index}
|
||||
className="p-4 rounded-lg bg-slate-50 dark:bg-slate-800/50 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-1 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-600">
|
||||
Team {result.team_index}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 truncate max-w-[200px]">
|
||||
{result.owner_email}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1 text-green-600">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
注册: {result.registered}
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-purple-600">
|
||||
<Settings className="h-4 w-4" />
|
||||
入库: {result.added_to_s2a}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.member_emails.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<p className="text-xs text-slate-500 mb-1">成员邮箱:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{result.member_emails.map((email, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 rounded text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"
|
||||
>
|
||||
{email}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.errors.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-red-500 mb-1 flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
错误:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{result.errors.map((err, idx) => (
|
||||
<p key={idx} className="text-xs text-red-400 pl-4">
|
||||
• {err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-xs text-slate-400">
|
||||
耗时: {(result.duration_ms / 1000).toFixed(1)}s
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-slate-500">
|
||||
<Users className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无处理结果</p>
|
||||
<p className="text-sm mt-1">上传账号文件并点击开始处理</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
340
frontend/src/pages/Upload.tsx
Normal file
340
frontend/src/pages/Upload.tsx
Normal file
@@ -0,0 +1,340 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Upload as UploadIcon, Settings, Play, Loader2, List, Activity } from 'lucide-react'
|
||||
import { FileDropzone } from '../components/upload'
|
||||
import LogStream from '../components/upload/LogStream'
|
||||
import OwnerList from '../components/upload/OwnerList'
|
||||
import { Card, CardContent, Button, Tabs } from '../components/common'
|
||||
import { useConfig } from '../hooks/useConfig'
|
||||
|
||||
interface PoolingConfig {
|
||||
owner_concurrency: number // 母号并发数
|
||||
include_owner: boolean // 是否入库母号
|
||||
serial_authorize: boolean
|
||||
browser_type: 'rod' | 'cdp'
|
||||
proxy: string
|
||||
}
|
||||
|
||||
interface OwnerStats {
|
||||
total: number
|
||||
valid: number
|
||||
registered: number
|
||||
pooled: number
|
||||
}
|
||||
|
||||
type TabType = 'upload' | 'owners' | 'logs'
|
||||
|
||||
export default function Upload() {
|
||||
const { config, isConnected } = useConfig()
|
||||
const apiBase = 'http://localhost:8088'
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('upload')
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
const [validating, setValidating] = useState(false)
|
||||
const [pooling, setPooling] = useState(false)
|
||||
const [stats, setStats] = useState<OwnerStats | null>(null)
|
||||
const [poolingConfig, setPoolingConfig] = useState<PoolingConfig>({
|
||||
owner_concurrency: 1,
|
||||
include_owner: true,
|
||||
serial_authorize: true,
|
||||
browser_type: 'rod',
|
||||
proxy: '',
|
||||
})
|
||||
|
||||
const hasConfig = config.s2a.apiBase && config.s2a.adminKey
|
||||
|
||||
// Load stats
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/db/owners/stats`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
setStats(data.data)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load stats:', e)
|
||||
}
|
||||
}, [apiBase])
|
||||
|
||||
useEffect(() => {
|
||||
loadStats()
|
||||
}, [loadStats])
|
||||
|
||||
// Upload and validate
|
||||
const handleFileSelect = useCallback(
|
||||
async (file: File) => {
|
||||
setFileError(null)
|
||||
setValidating(true)
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const json = JSON.parse(text)
|
||||
|
||||
// Support both array and single account
|
||||
const accounts = Array.isArray(json) ? json : [json]
|
||||
|
||||
const res = await fetch(`${apiBase}/api/upload/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accounts }),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
loadStats()
|
||||
} else {
|
||||
setFileError(data.message || '验证失败')
|
||||
}
|
||||
} catch (e) {
|
||||
setFileError(e instanceof Error ? e.message : 'JSON 解析失败')
|
||||
} finally {
|
||||
setValidating(false)
|
||||
}
|
||||
},
|
||||
[apiBase, loadStats]
|
||||
)
|
||||
|
||||
// Start pooling
|
||||
const handleStartPooling = useCallback(async () => {
|
||||
setPooling(true)
|
||||
setActiveTab('logs') // Switch to logs tab
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/pooling/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(poolingConfig),
|
||||
})
|
||||
|
||||
const data = await res.json()
|
||||
if (data.code !== 0) {
|
||||
alert(data.message || '启动失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to start pooling:', e)
|
||||
} finally {
|
||||
// Check status periodically
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/pooling/status`)
|
||||
const data = await res.json()
|
||||
if (data.code === 0 && !data.data.running) {
|
||||
setPooling(false)
|
||||
loadStats()
|
||||
} else {
|
||||
setTimeout(checkStatus, 2000)
|
||||
}
|
||||
} catch {
|
||||
setPooling(false)
|
||||
}
|
||||
}
|
||||
setTimeout(checkStatus, 2000)
|
||||
}
|
||||
}, [apiBase, poolingConfig, loadStats])
|
||||
|
||||
const tabs = [
|
||||
{ id: 'upload', label: '上传', icon: UploadIcon },
|
||||
{ id: 'owners', label: '母号列表', icon: List, count: stats?.total },
|
||||
{ id: 'logs', label: '日志', icon: Activity },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-6rem)] flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between shrink-0">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100 flex items-center gap-2">
|
||||
<UploadIcon className="h-7 w-7 text-blue-500" />
|
||||
上传与入库
|
||||
</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
上传 Team Owner JSON,验证并入库到 S2A
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection warning */}
|
||||
{!hasConfig && (
|
||||
<div className="shrink-0 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Settings className="h-5 w-5 text-yellow-600 dark:text-yellow-400 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-yellow-800 dark:text-yellow-200">
|
||||
请先配置 S2A 连接
|
||||
</p>
|
||||
<Link to="/config/s2a" className="mt-3 inline-block">
|
||||
<Button size="sm" variant="outline">
|
||||
前往设置
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={(id) => setActiveTab(id as TabType)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{activeTab === 'upload' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 h-full overflow-hidden">
|
||||
{/* Left: Upload & Config */}
|
||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
||||
{/* Upload */}
|
||||
<Card hoverable className="shrink-0">
|
||||
<CardContent className="p-4">
|
||||
<FileDropzone
|
||||
onFileSelect={handleFileSelect}
|
||||
disabled={validating}
|
||||
error={fileError}
|
||||
/>
|
||||
{validating && (
|
||||
<div className="mt-3 flex items-center gap-2 text-blue-500 bg-blue-50 dark:bg-blue-900/20 p-2 rounded-lg text-sm">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>正在验证账号...</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Stats - Compact inline */}
|
||||
{stats && (
|
||||
<div className="shrink-0 grid grid-cols-4 gap-2">
|
||||
<div className="text-center p-2 bg-slate-50 dark:bg-slate-800/50 rounded-lg border border-slate-100 dark:border-slate-700">
|
||||
<div className="text-lg font-bold text-slate-700 dark:text-slate-200">{stats.total}</div>
|
||||
<div className="text-xs text-slate-500">总数</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-100 dark:border-blue-800/50">
|
||||
<div className="text-lg font-bold text-blue-600 dark:text-blue-400">{stats.valid}</div>
|
||||
<div className="text-xs text-blue-600/70 dark:text-blue-400/70">有效</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-orange-50 dark:bg-orange-900/20 rounded-lg border border-orange-100 dark:border-orange-800/50">
|
||||
<div className="text-lg font-bold text-orange-600 dark:text-orange-400">{stats.registered}</div>
|
||||
<div className="text-xs text-orange-600/70 dark:text-orange-400/70">已注册</div>
|
||||
</div>
|
||||
<div className="text-center p-2 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-100 dark:border-green-800/50">
|
||||
<div className="text-lg font-bold text-green-600 dark:text-green-400">{stats.pooled}</div>
|
||||
<div className="text-xs text-green-600/70 dark:text-green-400/70">已入库</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pooling Config - Compact */}
|
||||
<Card hoverable className="shrink-0">
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
<Play className="h-4 w-4 text-green-500" />
|
||||
入库设置
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
母号并发
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={poolingConfig.owner_concurrency}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, owner_concurrency: Number(e.target.value) })}
|
||||
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
浏览器
|
||||
</label>
|
||||
<select
|
||||
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
value={poolingConfig.browser_type}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, browser_type: e.target.value as 'rod' | 'cdp' })}
|
||||
>
|
||||
<option value="rod">Rod (反检测)</option>
|
||||
<option value="cdp">CDP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={poolingConfig.include_owner}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, include_owner: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">入库母号</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={poolingConfig.serial_authorize}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, serial_authorize: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600 text-blue-600"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">串行授权</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-slate-600 dark:text-slate-400 mb-1">
|
||||
代理(可选)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
value={poolingConfig.proxy}
|
||||
onChange={(e) => setPoolingConfig({ ...poolingConfig, proxy: e.target.value })}
|
||||
className="w-full h-9 px-3 text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleStartPooling}
|
||||
disabled={!isConnected || pooling || !stats?.valid}
|
||||
loading={pooling}
|
||||
icon={pooling ? undefined : <Play className="h-4 w-4" />}
|
||||
className="w-full"
|
||||
>
|
||||
{pooling ? '正在入库...' : '开始入库'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right: Quick Log View */}
|
||||
<div className="hidden lg:block h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-900 shadow-inner">
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-800 bg-slate-900/50 backdrop-blur">
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-medium text-slate-300">实时日志</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<LogStream apiBase={apiBase} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'owners' && (
|
||||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 shadow-sm">
|
||||
<OwnerList apiBase={apiBase} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div className="h-full overflow-hidden rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-900 shadow-sm">
|
||||
<LogStream apiBase={apiBase} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
9
frontend/src/pages/index.ts
Normal file
9
frontend/src/pages/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { default as Dashboard } from './Dashboard'
|
||||
export { default as Upload } from './Upload'
|
||||
export { default as Records } from './Records'
|
||||
export { default as Accounts } from './Accounts'
|
||||
export { default as Config } from './Config'
|
||||
export { default as S2AConfig } from './S2AConfig'
|
||||
export { default as EmailConfig } from './EmailConfig'
|
||||
export { default as Monitor } from './Monitor'
|
||||
export { default as TeamProcess } from './TeamProcess'
|
||||
1
frontend/src/test/setup.ts
Normal file
1
frontend/src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
225
frontend/src/types/index.ts
Normal file
225
frontend/src/types/index.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// 输入账号(JSON 文件格式)
|
||||
export interface AccountInput {
|
||||
account: string // 邮箱
|
||||
password: string // 密码
|
||||
token: string // access_token
|
||||
}
|
||||
|
||||
// 账号状态
|
||||
export type AccountStatus = 'pending' | 'checking' | 'active' | 'banned' | 'token_expired' | 'error'
|
||||
|
||||
// 检查后的账号
|
||||
export interface CheckedAccount extends AccountInput {
|
||||
id: number // 本地序号
|
||||
status: AccountStatus
|
||||
accountId?: string // ChatGPT workspace_id
|
||||
planType?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 加号记录
|
||||
export interface AddRecord {
|
||||
id: string
|
||||
timestamp: string
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
source: 'manual' | 'auto'
|
||||
details?: string
|
||||
}
|
||||
|
||||
// S2A Dashboard 统计
|
||||
export interface DashboardStats {
|
||||
total_accounts: number
|
||||
normal_accounts: number
|
||||
error_accounts: number
|
||||
ratelimit_accounts: number
|
||||
overload_accounts: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
today_cost: number
|
||||
total_requests: number
|
||||
total_tokens: number
|
||||
total_cost: number
|
||||
rpm: number
|
||||
tpm: number
|
||||
}
|
||||
|
||||
// S2A 账号
|
||||
export interface S2AAccount {
|
||||
id: number
|
||||
name: string
|
||||
notes?: string
|
||||
platform: 'openai' | 'anthropic' | 'gemini'
|
||||
type: 'oauth' | 'access_token' | 'apikey' | 'setup-token'
|
||||
credentials: Record<string, unknown>
|
||||
extra?: Record<string, unknown>
|
||||
proxy_id?: number
|
||||
concurrency: number
|
||||
priority: number
|
||||
rate_multiplier?: number
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
error_message?: string
|
||||
schedulable: boolean
|
||||
last_used_at?: string
|
||||
expires_at?: string
|
||||
auto_pause_on_expired: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
// 实时字段
|
||||
current_concurrency?: number
|
||||
current_window_cost?: number
|
||||
active_sessions?: number
|
||||
}
|
||||
|
||||
// 分页响应
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
// 账号列表查询参数
|
||||
export interface AccountListParams {
|
||||
page?: number
|
||||
page_size?: number
|
||||
platform?: 'openai' | 'anthropic' | 'gemini'
|
||||
type?: 'oauth' | 'access_token' | 'apikey'
|
||||
status?: 'active' | 'inactive' | 'error'
|
||||
search?: string
|
||||
}
|
||||
|
||||
// 创建账号请求
|
||||
export interface CreateAccountRequest {
|
||||
name: string
|
||||
platform: 'openai' | 'anthropic' | 'gemini'
|
||||
type: 'access_token'
|
||||
credentials: {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
email?: string
|
||||
}
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
proxy_id?: number | null
|
||||
auto_pause_on_expired?: boolean
|
||||
}
|
||||
|
||||
// OAuth 创建账号请求
|
||||
export interface OAuthCreateRequest {
|
||||
session_id: string
|
||||
code: string
|
||||
name?: string
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
group_ids?: number[]
|
||||
proxy_id?: number | null
|
||||
}
|
||||
|
||||
// 分组
|
||||
export interface Group {
|
||||
id: number
|
||||
name: string
|
||||
description?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 代理
|
||||
export interface Proxy {
|
||||
id: number
|
||||
name: string
|
||||
url: string
|
||||
status: 'active' | 'inactive' | 'error'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
// 趋势数据
|
||||
export interface TrendData {
|
||||
date: string
|
||||
requests: number
|
||||
tokens: number
|
||||
cost: number
|
||||
}
|
||||
|
||||
// 邮箱服务配置
|
||||
export interface MailServiceConfig {
|
||||
name: string // 服务名称
|
||||
apiBase: string // API 地址
|
||||
apiToken: string // API Token
|
||||
domain: string // 邮箱域名
|
||||
emailPath?: string // 获取邮件列表的 API 路径
|
||||
addUserApi?: string // 创建用户的 API 路径
|
||||
}
|
||||
|
||||
// 应用配置
|
||||
export interface AppConfig {
|
||||
s2a: {
|
||||
apiBase: string
|
||||
adminKey: string
|
||||
}
|
||||
pooling: {
|
||||
concurrency: number
|
||||
priority: number
|
||||
groupIds: number[]
|
||||
proxyId: number | null
|
||||
}
|
||||
check: {
|
||||
concurrency: number
|
||||
timeout: number
|
||||
}
|
||||
email: {
|
||||
services: MailServiceConfig[] // 多个邮箱服务配置
|
||||
}
|
||||
}
|
||||
|
||||
// 默认配置
|
||||
export const defaultConfig: AppConfig = {
|
||||
s2a: {
|
||||
apiBase: '',
|
||||
adminKey: '',
|
||||
},
|
||||
pooling: {
|
||||
concurrency: 1,
|
||||
priority: 0,
|
||||
groupIds: [],
|
||||
proxyId: null,
|
||||
},
|
||||
check: {
|
||||
concurrency: 20,
|
||||
timeout: 30000,
|
||||
},
|
||||
email: {
|
||||
services: [
|
||||
{
|
||||
name: 'esyteam',
|
||||
apiBase: 'https://mail.esyteam.edu.kg',
|
||||
apiToken: '',
|
||||
domain: 'esyteam.edu.kg',
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
// 检查结果
|
||||
export interface CheckResult {
|
||||
status: AccountStatus
|
||||
accountId?: string
|
||||
planType?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// 测试结果
|
||||
export interface TestResult {
|
||||
success: boolean
|
||||
message?: string
|
||||
latency?: number
|
||||
}
|
||||
|
||||
// Account 类型别名 (对应 requirements.md A5)
|
||||
// S2AAccount 已包含完整的 Account 数据结构
|
||||
export type Account = S2AAccount
|
||||
122
frontend/src/utils/format.ts
Normal file
122
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function formatTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(添加千分位)
|
||||
*/
|
||||
export function formatNumber(num: number | undefined | null): string {
|
||||
if (num == null) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
*/
|
||||
export function formatCurrency(amount: number | undefined | null, currency: string = 'USD'): string {
|
||||
if (amount == null) amount = 0
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
*/
|
||||
export function formatPercent(value: number, decimals: number = 1): string {
|
||||
return `${(value * 100).toFixed(decimals)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏邮箱中间部分
|
||||
*/
|
||||
export function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
if (local.length <= 2) return `${local}***@${domain}`
|
||||
return `${local.substring(0, 2)}***@${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏 Token
|
||||
*/
|
||||
export function maskToken(token: string, visibleChars: number = 8): string {
|
||||
if (token.length <= visibleChars * 2) return '***'
|
||||
return `${token.substring(0, visibleChars)}...${token.substring(token.length - visibleChars)}`
|
||||
}
|
||||
5
frontend/src/utils/index.ts
Normal file
5
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Utils barrel export
|
||||
export * from './storage'
|
||||
export * from './format'
|
||||
export * from './json-parser'
|
||||
export * from './status-check'
|
||||
126
frontend/src/utils/json-parser.test.ts
Normal file
126
frontend/src/utils/json-parser.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseAccountJson, isValidEmail, isValidToken } from './json-parser'
|
||||
|
||||
describe('parseAccountJson', () => {
|
||||
it('should parse valid JSON array', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'test@example.com', password: 'pass123', token: 'token123456' },
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data?.[0].account).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('should parse multiple accounts', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'user1@example.com', password: 'pass1', token: 'token1234567890' },
|
||||
{ account: 'user2@example.com', password: 'pass2', token: 'token0987654321' },
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should reject non-array JSON', () => {
|
||||
const json = JSON.stringify({ account: 'test@example.com' })
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('数组格式')
|
||||
})
|
||||
|
||||
it('should reject empty array', () => {
|
||||
const json = JSON.stringify([])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不能为空')
|
||||
})
|
||||
|
||||
it('should reject invalid JSON', () => {
|
||||
const result = parseAccountJson('not valid json')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('解析失败')
|
||||
})
|
||||
|
||||
it('should reject missing account field', () => {
|
||||
const json = JSON.stringify([{ password: 'pass', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('account')
|
||||
})
|
||||
|
||||
it('should reject missing password field', () => {
|
||||
const json = JSON.stringify([{ account: 'test@example.com', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('password')
|
||||
})
|
||||
|
||||
it('should reject missing token field', () => {
|
||||
const json = JSON.stringify([{ account: 'test@example.com', password: 'pass' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('token')
|
||||
})
|
||||
|
||||
it('should reject non-string account', () => {
|
||||
const json = JSON.stringify([{ account: 123, password: 'pass', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('account')
|
||||
})
|
||||
|
||||
it('should reject null items in array', () => {
|
||||
const json = JSON.stringify([null])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('有效的对象')
|
||||
})
|
||||
|
||||
it('should indicate which record has error', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'valid@example.com', password: 'pass', token: 'token123456' },
|
||||
{ account: 'invalid', password: 'pass' }, // missing token
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('第 2 条')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it('should accept valid email', () => {
|
||||
expect(isValidEmail('test@example.com')).toBe(true)
|
||||
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email', () => {
|
||||
expect(isValidEmail('invalid')).toBe(false)
|
||||
expect(isValidEmail('invalid@')).toBe(false)
|
||||
expect(isValidEmail('@domain.com')).toBe(false)
|
||||
expect(isValidEmail('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidToken', () => {
|
||||
it('should accept valid token', () => {
|
||||
expect(isValidToken('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9')).toBe(true)
|
||||
expect(isValidToken('1234567890')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject short token', () => {
|
||||
expect(isValidToken('short')).toBe(false)
|
||||
expect(isValidToken('')).toBe(false)
|
||||
})
|
||||
})
|
||||
96
frontend/src/utils/json-parser.ts
Normal file
96
frontend/src/utils/json-parser.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { AccountInput } from '../types'
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean
|
||||
data?: AccountInput[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate JSON account data
|
||||
*/
|
||||
export function parseAccountJson(jsonString: string): ParseResult {
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 文件必须是数组格式',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 数组不能为空',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each account
|
||||
const accounts: AccountInput[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i]
|
||||
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录不是有效的对象`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.account || typeof item.account !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 account 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.password || typeof item.password !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 password 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.token || typeof item.token !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 token 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
account: item.account,
|
||||
password: item.password,
|
||||
token: item.token,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: accounts,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 解析失败,请检查文件格式',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token format (basic check)
|
||||
*/
|
||||
export function isValidToken(token: string): boolean {
|
||||
// Token should be a non-empty string with reasonable length
|
||||
return typeof token === 'string' && token.length >= 10
|
||||
}
|
||||
54
frontend/src/utils/status-check.ts
Normal file
54
frontend/src/utils/status-check.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AccountStatus } from '../types'
|
||||
|
||||
/**
|
||||
* Map HTTP status code to account status
|
||||
*/
|
||||
export function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
|
||||
switch (httpStatus) {
|
||||
case 200:
|
||||
return 'active'
|
||||
case 401:
|
||||
return 'token_expired'
|
||||
case 403:
|
||||
return 'banned'
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account status allows pooling
|
||||
*/
|
||||
export function canPoolAccount(status: AccountStatus): boolean {
|
||||
return status === 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status display text
|
||||
*/
|
||||
export function getStatusDisplayText(status: AccountStatus): string {
|
||||
const statusMap: Record<AccountStatus, string> = {
|
||||
pending: '待检查',
|
||||
checking: '检查中',
|
||||
active: '正常',
|
||||
banned: '封禁',
|
||||
token_expired: '过期',
|
||||
error: '错误',
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color class
|
||||
*/
|
||||
export function getStatusColorClass(status: AccountStatus): string {
|
||||
const colorMap: Record<AccountStatus, string> = {
|
||||
pending: 'text-slate-500',
|
||||
checking: 'text-blue-500',
|
||||
active: 'text-green-500',
|
||||
banned: 'text-red-500',
|
||||
token_expired: 'text-orange-500',
|
||||
error: 'text-yellow-500',
|
||||
}
|
||||
return colorMap[status] || 'text-slate-500'
|
||||
}
|
||||
0
frontend/src/utils/storage.test.ts
Normal file
0
frontend/src/utils/storage.test.ts
Normal file
107
frontend/src/utils/storage.ts
Normal file
107
frontend/src/utils/storage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { AppConfig, AddRecord } from '../types'
|
||||
import { defaultConfig } from '../types'
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CONFIG: 'codex-pool-config',
|
||||
RECORDS: 'codex-pool-records',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 保存配置到 localStorage
|
||||
*/
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载配置
|
||||
*/
|
||||
export function loadConfig(): AppConfig {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.CONFIG)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
|
||||
// Migration: handle old email config format -> new services array format
|
||||
let emailConfig = { ...defaultConfig.email }
|
||||
|
||||
if (parsed.email) {
|
||||
if (parsed.email.services && Array.isArray(parsed.email.services)) {
|
||||
// New format - use directly
|
||||
emailConfig.services = parsed.email.services
|
||||
} else if (parsed.email.apiBase || parsed.email.domains) {
|
||||
// Old format - migrate to new services array
|
||||
const domains = parsed.email.domains || (parsed.email.domain ? [parsed.email.domain] : ['esyteam.edu.kg'])
|
||||
emailConfig.services = [{
|
||||
name: 'default',
|
||||
apiBase: parsed.email.apiBase || 'https://mail.esyteam.edu.kg',
|
||||
apiToken: parsed.email.apiToken || '',
|
||||
domain: domains[0] || 'esyteam.edu.kg',
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// 合并默认配置,确保新增字段有默认值
|
||||
return {
|
||||
...defaultConfig,
|
||||
...parsed,
|
||||
s2a: { ...defaultConfig.s2a, ...parsed.s2a },
|
||||
pooling: { ...defaultConfig.pooling, ...parsed.pooling },
|
||||
check: { ...defaultConfig.check, ...parsed.check },
|
||||
email: emailConfig,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
}
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存记录到 localStorage
|
||||
*/
|
||||
export function saveRecords(records: AddRecord[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.RECORDS, JSON.stringify(records))
|
||||
} catch (error) {
|
||||
console.error('Failed to save records:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载记录
|
||||
*/
|
||||
export function loadRecords(): AddRecord[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.RECORDS)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load records:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有存储数据
|
||||
*/
|
||||
export function clearStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.CONFIG)
|
||||
localStorage.removeItem(STORAGE_KEYS.RECORDS)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error)
|
||||
}
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal 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
11
frontend/vite.config.ts
Normal 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
16
frontend/vitest.config.ts
Normal 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/'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user