diff --git a/public/worker.js b/public/worker.js index 05d44ff..b78ae57 100644 --- a/public/worker.js +++ b/public/worker.js @@ -350,7 +350,8 @@ async function processKeyWithRetry(apiKey, config, slotIndex) { error: result.error, retryCount: attempt, isPaid: finalResult.isPaid, - cacheApiStatus: finalResult.cacheApiStatus + cacheApiStatus: finalResult.cacheApiStatus, + statusCode: extractStatusCode(result.error) } }); @@ -380,7 +381,8 @@ async function processKeyWithRetry(apiKey, config, slotIndex) { key: apiKey, status: 'invalid', error: result.error, - retryCount: attempt + retryCount: attempt, + statusCode: extractStatusCode(result.error) } }); @@ -407,7 +409,8 @@ async function processKeyWithRetry(apiKey, config, slotIndex) { key: apiKey, status: 'invalid', error: result.error, - retryCount: attempt + retryCount: attempt, + statusCode: extractStatusCode(result.error) } }); @@ -453,7 +456,8 @@ async function processKeyWithRetry(apiKey, config, slotIndex) { key: apiKey, status: 'invalid', error: finalError, - retryCount: attempt + retryCount: attempt, + statusCode: extractStatusCode(finalError) } }); @@ -497,11 +501,13 @@ function shouldRetry(error, statusCode) { function extractStatusCode(error) { if (!error || typeof error !== 'string') return null; - const match = error.match(/$(\d{3})$/); + // 匹配括号中的3位数字状态码,如 "认证失败 (401)" 或 "权限不足 (403)" + const match = error.match(/\((\d{3})\)$/); if (match) { return parseInt(match[1]); } + // 匹配 "HTTP" + 空格 + 状态码的格式 if (error.includes('HTTP ')) { const httpMatch = error.match(/HTTP (\d{3})/); if (httpMatch) { diff --git a/src/App.jsx b/src/App.jsx index b345360..c9fb763 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import Controls from './components/features/Controls'; import Results from './components/features/Results'; import AdvancedSettings from './components/features/AdvancedSettings'; import ToastProvider from './components/common/ToastProvider'; +import Card from './components/common/Card'; import { useLanguage } from './hooks/useLanguage'; const AppContent = () => { @@ -17,17 +18,17 @@ const AppContent = () => { const leftPanel = (
-
+ -
+ -
+ -
+ -
+
@@ -37,10 +38,10 @@ const AppContent = () => {
{t('usage1')}
{t('usage2')}
-
+
); - const rightPanel = ( - - ); + const rightPanel = ; return ( <> diff --git a/src/__tests__/hooks/useVirtualization.test.js b/src/__tests__/hooks/useVirtualization.test.js index c51db3f..e532079 100644 --- a/src/__tests__/hooks/useVirtualization.test.js +++ b/src/__tests__/hooks/useVirtualization.test.js @@ -6,6 +6,14 @@ import { renderHook } from '@testing-library/react'; import { useVirtualization } from '../../hooks/useVirtualization'; describe('useVirtualization Hook', () => { + // 保存原始的 window.matchMedia + const originalMatchMedia = window.matchMedia; + + afterEach(() => { + // 恢复原始的 window.matchMedia + window.matchMedia = originalMatchMedia; + }); + test('should initialize with correct functions', () => { const { result } = renderHook(() => useVirtualization()); @@ -21,203 +29,119 @@ describe('useVirtualization Hook', () => { }); describe('getItemHeight', () => { - let getItemHeight; + test('should return desktop height (74px) for screens wider than 768px', () => { + // 模拟桌面屏幕 + window.matchMedia = jest.fn((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); - beforeEach(() => { const { result } = renderHook(() => useVirtualization()); - getItemHeight = result.current.getItemHeight; - }); - - test('should return default height for null keyData', () => { - const height = getItemHeight(null); - expect(height).toBe(60); - }); - - test('should return default height for undefined keyData', () => { - const height = getItemHeight(undefined); - expect(height).toBe(60); - }); + const height = result.current.getItemHeight(); - test('should return base height for minimal keyData', () => { - const keyData = { key: 'sk-test' }; - const height = getItemHeight(keyData); - expect(height).toBe(68); // Math.max(60, 68) + expect(height).toBe(74); // 桌面端:72px + wrapper padding(2px) }); - test('should calculate height for short keys', () => { - const keyData = { key: 'sk-short-key' }; - const height = getItemHeight(keyData); - expect(height).toBe(68); - }); + test('should return mobile height (66px) for screens between 481px and 768px', () => { + // 模拟移动端屏幕 + window.matchMedia = jest.fn((query) => ({ + matches: query === '(max-width: 768px)', + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); - test('should add extra height for long keys', () => { - const longKey = 'sk-' + 'x'.repeat(100); // 103 characters - const keyData = { key: longKey }; - const height = getItemHeight(keyData); - - // Should add extra lines for long key - // (103 - 60) / 60 = 0.71, ceil = 1 extra line - // 60 + (1 * 18) = 78 - expect(height).toBe(78); - }); + const { result } = renderHook(() => useVirtualization()); + const height = result.current.getItemHeight(); - test('should add height for model information', () => { - const keyData = { - key: 'sk-test', - model: 'gpt-4' - }; - const height = getItemHeight(keyData); - expect(height).toBe(76); // 60 + 16 for model + expect(height).toBe(66); // 移动端:64px + wrapper padding(2px) }); - test('should add height for short error messages', () => { - const keyData = { - key: 'sk-test', - error: 'Invalid key' - }; - const height = getItemHeight(keyData); - expect(height).toBe(76); // 60 + 16 for error - }); + test('should return extra-small height (62px) for screens 480px or smaller', () => { + // 模拟超小屏幕 + window.matchMedia = jest.fn((query) => ({ + matches: true, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); - test('should add double height for long error messages', () => { - const keyData = { - key: 'sk-test', - error: 'This is a very long error message that exceeds fifty characters' - }; - const height = getItemHeight(keyData); - expect(height).toBe(92); // 60 + (16 * 2) for long error - }); + const { result } = renderHook(() => useVirtualization()); + const height = result.current.getItemHeight(); - test('should add height for retry information', () => { - const keyData = { - key: 'sk-test', - retryCount: 2 - }; - const height = getItemHeight(keyData); - expect(height).toBe(76); // 60 + 16 for retry info + expect(height).toBe(62); // 超小屏幕:60px + wrapper padding(2px) }); - test('should add height for valid status', () => { - const keyData = { - key: 'sk-test', - status: 'valid' - }; - const height = getItemHeight(keyData); - expect(height).toBe(76); // 60 + 16 for status - }); + test('should return consistent height across multiple calls', () => { + // 模拟桌面屏幕 + window.matchMedia = jest.fn((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); - test('should add height for paid status', () => { - const keyData = { - key: 'sk-test', - status: 'paid' - }; - const height = getItemHeight(keyData); - expect(height).toBe(76); // 60 + 16 for status - }); - - test('should add extra height for siliconcloud with valid status', () => { - const keyData = { - key: 'sk-test', - status: 'valid' - }; - const height = getItemHeight(keyData, 'siliconcloud'); - expect(height).toBe(92); // 60 + 16 (status) + 16 (balance) - }); + const { result } = renderHook(() => useVirtualization()); - test('should add extra height for siliconcloud with paid status', () => { - const keyData = { - key: 'sk-test', - status: 'paid' - }; - const height = getItemHeight(keyData, 'siliconcloud'); - expect(height).toBe(92); // 60 + 16 (status) + 16 (balance) - }); + const height1 = result.current.getItemHeight(); + const height2 = result.current.getItemHeight(); + const height3 = result.current.getItemHeight(); - test('should not add extra height for siliconcloud with invalid status', () => { - const keyData = { - key: 'sk-test', - status: 'invalid' - }; - const height = getItemHeight(keyData, 'siliconcloud'); - expect(height).toBe(68); // base height only + expect(height1).toBe(height2); + expect(height2).toBe(height3); + expect(height1).toBe(74); }); - test('should calculate height for complex keyData', () => { - const keyData = { - key: 'sk-' + 'x'.repeat(70), // 73 characters, should add 1 extra line - model: 'gpt-4', - error: 'This is a long error message that should take two lines', - retryCount: 3, - status: 'valid' - }; - const height = getItemHeight(keyData, 'openai'); - - // 60 (base) + 18 (long key) + 16 (model) + 32 (long error) + 16 (retry) + 16 (status) = 158 - expect(height).toBe(158); - }); + test('should handle missing window.matchMedia gracefully', () => { + // 移除 window.matchMedia + window.matchMedia = undefined; - test('should calculate height for siliconcloud with all features', () => { - const keyData = { - key: 'sk-test', - model: 'Qwen2-72B', - error: 'Short error', - retryCount: 1, - status: 'valid' - }; - const height = getItemHeight(keyData, 'siliconcloud'); - - // 60 (base) + 16 (model) + 16 (error) + 16 (retry) + 16 (status) + 16 (balance) = 140 - expect(height).toBe(140); - }); + const { result } = renderHook(() => useVirtualization()); + const height = result.current.getItemHeight(); - test('should enforce minimum height', () => { - const keyData = { key: '' }; // Very minimal data - const height = getItemHeight(keyData); - expect(height).toBeGreaterThanOrEqual(68); + // 应该返回桌面端高度(默认值) + expect(height).toBe(74); }); - test('should handle missing key property', () => { - const keyData = { - model: 'gpt-4', - status: 'valid' - }; - const height = getItemHeight(keyData); - expect(height).toBe(92); // 60 + 16 (model) + 16 (status) - }); + test('should not accept any parameters', () => { + // 模拟桌面屏幕 + window.matchMedia = jest.fn((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); - test('should handle zero retry count', () => { - const keyData = { - key: 'sk-test', - retryCount: 0 - }; - const height = getItemHeight(keyData); - expect(height).toBe(68); // Should not add height for 0 retries - }); + const { result } = renderHook(() => useVirtualization()); - test('should handle empty strings', () => { - const keyData = { - key: '', - model: '', - error: '' - }; - const height = getItemHeight(keyData); - expect(height).toBe(68); // Should not add height for empty strings - }); + // 传递参数应该被忽略,高度只取决于屏幕宽度 + const heightWithoutParams = result.current.getItemHeight(); + const heightWithParams = result.current.getItemHeight({ key: 'sk-test' }, 'openai'); - test('should be stable across multiple calls', () => { - const keyData = { - key: 'sk-test', - model: 'gpt-4', - status: 'valid' - }; - - const height1 = getItemHeight(keyData, 'openai'); - const height2 = getItemHeight(keyData, 'openai'); - const height3 = getItemHeight(keyData, 'openai'); - - expect(height1).toBe(height2); - expect(height2).toBe(height3); - expect(height1).toBe(92); // 60 + 16 (model) + 16 (status) + expect(heightWithoutParams).toBe(heightWithParams); + expect(heightWithoutParams).toBe(74); }); }); -}); \ No newline at end of file +}); diff --git a/src/components/common/Button/index.jsx b/src/components/common/Button/index.jsx index 7103bcd..e5651ff 100644 --- a/src/components/common/Button/index.jsx +++ b/src/components/common/Button/index.jsx @@ -7,6 +7,7 @@ const Button = ({ onClick, disabled = false, loading = false, + icon, className = '', ...props }) => { @@ -28,7 +29,11 @@ const Button = ({ disabled={disabled || loading} {...props} > - {loading && } + {loading ? ( + + ) : icon ? ( + {icon} + ) : null} {children} ); diff --git a/src/components/common/Card/index.jsx b/src/components/common/Card/index.jsx new file mode 100644 index 0000000..d5a83e7 --- /dev/null +++ b/src/components/common/Card/index.jsx @@ -0,0 +1,72 @@ +import React from 'react'; + +const Card = ({ + children, + title, + action, + variant = 'default', + padding = 'md', + hover = false, + className = '', + onClick, + ...props +}) => { + const getVariantClass = () => { + switch (variant) { + case 'function': + return 'card--primary'; + case 'base': + return 'card-base'; + case 'usage': + return 'card--primary card--info'; + default: + return 'card-base'; + } + }; + + const getPaddingClass = () => { + switch (padding) { + case 'none': + return ''; + case 'sm': + return 'card-padding-sm'; + case 'md': + return 'card-padding'; + case 'lg': + return 'p-lg'; + default: + return 'card-padding'; + } + }; + + const baseClass = getVariantClass(); + const paddingClass = getPaddingClass(); + const hoverClass = hover ? 'card-hover' : ''; + const clickableClass = onClick ? 'cursor-pointer' : ''; + + const classes = [ + baseClass, + paddingClass, + hoverClass, + clickableClass, + className + ].filter(Boolean).join(' '); + + return ( +
+ {(title || action) && ( +
+ {title &&

{title}

} + {action} +
+ )} + {children} +
+ ); +}; + +export default Card; \ No newline at end of file diff --git a/src/components/common/EmptyState/index.jsx b/src/components/common/EmptyState/index.jsx new file mode 100644 index 0000000..9e22311 --- /dev/null +++ b/src/components/common/EmptyState/index.jsx @@ -0,0 +1,41 @@ +import React from 'react'; + +const EmptyState = ({ + icon = '📭', + title, + message, + children, + className = '', + size = 'default', + ...props +}) => { + const sizeClass = size === 'small' ? 'empty-state--small' : + size === 'large' ? 'empty-state--large' : ''; + + const classes = ['empty-state', sizeClass, className] + .filter(Boolean) + .join(' '); + + return ( +
+ {icon && ( +
+ {typeof icon === 'string' ? icon : icon} +
+ )} + {title && ( +
+ {title} +
+ )} + {message && ( +
+ {message} +
+ )} + {children} +
+ ); +}; + +export default EmptyState; \ No newline at end of file diff --git a/src/components/common/FormField/index.jsx b/src/components/common/FormField/index.jsx new file mode 100644 index 0000000..5327164 --- /dev/null +++ b/src/components/common/FormField/index.jsx @@ -0,0 +1,92 @@ +import React, { useRef } from 'react'; + +const FormField = ({ + label, + children, + error, + help, + required = false, + className = '', + ...props +}) => { + const idRef = useRef(props.id || `field-${Math.random().toString(36).substr(2, 9)}`); + const fieldId = idRef.current; + const describedBy = [ + error ? `${fieldId}-error` : null, + help ? `${fieldId}-help` : null + ].filter(Boolean).join(' ') || undefined; + + return ( +
+ {label && ( + + )} + {React.Children.map(children, child => { + if (!React.isValidElement(child)) return child; + const type = child.type; + const tag = typeof type === 'string' ? type : type?.displayName; + const isFormControl = ['input', 'textarea', 'select', 'Input'].includes(tag); + const childId = child.props.id || fieldId; + const extraProps = isFormControl ? { id: childId, 'aria-invalid': !!error, 'aria-describedby': describedBy } : {}; + return React.cloneElement(child, { ...child.props, ...extraProps }); + })} + {error && ( +
+ {error} +
+ )} + {help && ( +
+ {help} +
+ )} +
+ ); +}; + +const Input = React.forwardRef(({ + type = 'text', + variant = 'default', + size = 'default', + error, + className = '', + ...props +}, ref) => { + const baseClass = 'form-control'; + const variantClass = type === 'textarea' ? 'textarea' : ''; + const errorClass = error ? 'error' : ''; + const sizeClass = size === 'small' ? 'form-control--sm' : + size === 'large' ? 'form-control--lg' : ''; + + const classes = [baseClass, variantClass, errorClass, sizeClass, className] + .filter(Boolean) + .join(' '); + + if (type === 'textarea') { + return ( +