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:
122
frontend/src/utils/format.ts
Normal file
122
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 格式化日期时间
|
||||
*/
|
||||
export function formatDateTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
*/
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间
|
||||
*/
|
||||
export function formatTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
return d.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
*/
|
||||
export function formatRelativeTime(date: string | Date): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - d.getTime()
|
||||
|
||||
const seconds = Math.floor(diff / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const days = Math.floor(hours / 24)
|
||||
|
||||
if (days > 0) return `${days}天前`
|
||||
if (hours > 0) return `${hours}小时前`
|
||||
if (minutes > 0) return `${minutes}分钟前`
|
||||
return '刚刚'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(添加千分位)
|
||||
*/
|
||||
export function formatNumber(num: number | undefined | null): string {
|
||||
if (num == null) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额
|
||||
*/
|
||||
export function formatCurrency(amount: number | undefined | null, currency: string = 'USD'): string {
|
||||
if (amount == null) amount = 0
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化百分比
|
||||
*/
|
||||
export function formatPercent(value: number, decimals: number = 1): string {
|
||||
return `${(value * 100).toFixed(decimals)}%`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
*/
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
*/
|
||||
export function truncateText(text: string, maxLength: number): string {
|
||||
if (text.length <= maxLength) return text
|
||||
return `${text.substring(0, maxLength)}...`
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏邮箱中间部分
|
||||
*/
|
||||
export function maskEmail(email: string): string {
|
||||
const [local, domain] = email.split('@')
|
||||
if (!domain) return email
|
||||
if (local.length <= 2) return `${local}***@${domain}`
|
||||
return `${local.substring(0, 2)}***@${domain}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 隐藏 Token
|
||||
*/
|
||||
export function maskToken(token: string, visibleChars: number = 8): string {
|
||||
if (token.length <= visibleChars * 2) return '***'
|
||||
return `${token.substring(0, visibleChars)}...${token.substring(token.length - visibleChars)}`
|
||||
}
|
||||
5
frontend/src/utils/index.ts
Normal file
5
frontend/src/utils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Utils barrel export
|
||||
export * from './storage'
|
||||
export * from './format'
|
||||
export * from './json-parser'
|
||||
export * from './status-check'
|
||||
126
frontend/src/utils/json-parser.test.ts
Normal file
126
frontend/src/utils/json-parser.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseAccountJson, isValidEmail, isValidToken } from './json-parser'
|
||||
|
||||
describe('parseAccountJson', () => {
|
||||
it('should parse valid JSON array', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'test@example.com', password: 'pass123', token: 'token123456' },
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data?.[0].account).toBe('test@example.com')
|
||||
})
|
||||
|
||||
it('should parse multiple accounts', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'user1@example.com', password: 'pass1', token: 'token1234567890' },
|
||||
{ account: 'user2@example.com', password: 'pass2', token: 'token0987654321' },
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.data).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should reject non-array JSON', () => {
|
||||
const json = JSON.stringify({ account: 'test@example.com' })
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('数组格式')
|
||||
})
|
||||
|
||||
it('should reject empty array', () => {
|
||||
const json = JSON.stringify([])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('不能为空')
|
||||
})
|
||||
|
||||
it('should reject invalid JSON', () => {
|
||||
const result = parseAccountJson('not valid json')
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('解析失败')
|
||||
})
|
||||
|
||||
it('should reject missing account field', () => {
|
||||
const json = JSON.stringify([{ password: 'pass', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('account')
|
||||
})
|
||||
|
||||
it('should reject missing password field', () => {
|
||||
const json = JSON.stringify([{ account: 'test@example.com', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('password')
|
||||
})
|
||||
|
||||
it('should reject missing token field', () => {
|
||||
const json = JSON.stringify([{ account: 'test@example.com', password: 'pass' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('token')
|
||||
})
|
||||
|
||||
it('should reject non-string account', () => {
|
||||
const json = JSON.stringify([{ account: 123, password: 'pass', token: 'token123456' }])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('account')
|
||||
})
|
||||
|
||||
it('should reject null items in array', () => {
|
||||
const json = JSON.stringify([null])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('有效的对象')
|
||||
})
|
||||
|
||||
it('should indicate which record has error', () => {
|
||||
const json = JSON.stringify([
|
||||
{ account: 'valid@example.com', password: 'pass', token: 'token123456' },
|
||||
{ account: 'invalid', password: 'pass' }, // missing token
|
||||
])
|
||||
const result = parseAccountJson(json)
|
||||
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toContain('第 2 条')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidEmail', () => {
|
||||
it('should accept valid email', () => {
|
||||
expect(isValidEmail('test@example.com')).toBe(true)
|
||||
expect(isValidEmail('user.name@domain.co.uk')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject invalid email', () => {
|
||||
expect(isValidEmail('invalid')).toBe(false)
|
||||
expect(isValidEmail('invalid@')).toBe(false)
|
||||
expect(isValidEmail('@domain.com')).toBe(false)
|
||||
expect(isValidEmail('')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidToken', () => {
|
||||
it('should accept valid token', () => {
|
||||
expect(isValidToken('eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9')).toBe(true)
|
||||
expect(isValidToken('1234567890')).toBe(true)
|
||||
})
|
||||
|
||||
it('should reject short token', () => {
|
||||
expect(isValidToken('short')).toBe(false)
|
||||
expect(isValidToken('')).toBe(false)
|
||||
})
|
||||
})
|
||||
96
frontend/src/utils/json-parser.ts
Normal file
96
frontend/src/utils/json-parser.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { AccountInput } from '../types'
|
||||
|
||||
export interface ParseResult {
|
||||
success: boolean
|
||||
data?: AccountInput[]
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate JSON account data
|
||||
*/
|
||||
export function parseAccountJson(jsonString: string): ParseResult {
|
||||
try {
|
||||
const data = JSON.parse(jsonString)
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 文件必须是数组格式',
|
||||
}
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 数组不能为空',
|
||||
}
|
||||
}
|
||||
|
||||
// Validate each account
|
||||
const accounts: AccountInput[] = []
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const item = data[i]
|
||||
|
||||
if (typeof item !== 'object' || item === null) {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录不是有效的对象`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.account || typeof item.account !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 account 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.password || typeof item.password !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 password 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
if (!item.token || typeof item.token !== 'string') {
|
||||
return {
|
||||
success: false,
|
||||
error: `第 ${i + 1} 条记录缺少有效的 token 字段`,
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
account: item.account,
|
||||
password: item.password,
|
||||
token: item.token,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: accounts,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'JSON 解析失败,请检查文件格式',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate email format
|
||||
*/
|
||||
export function isValidEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token format (basic check)
|
||||
*/
|
||||
export function isValidToken(token: string): boolean {
|
||||
// Token should be a non-empty string with reasonable length
|
||||
return typeof token === 'string' && token.length >= 10
|
||||
}
|
||||
54
frontend/src/utils/status-check.ts
Normal file
54
frontend/src/utils/status-check.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AccountStatus } from '../types'
|
||||
|
||||
/**
|
||||
* Map HTTP status code to account status
|
||||
*/
|
||||
export function mapHttpStatusToAccountStatus(httpStatus: number): AccountStatus {
|
||||
switch (httpStatus) {
|
||||
case 200:
|
||||
return 'active'
|
||||
case 401:
|
||||
return 'token_expired'
|
||||
case 403:
|
||||
return 'banned'
|
||||
default:
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if account status allows pooling
|
||||
*/
|
||||
export function canPoolAccount(status: AccountStatus): boolean {
|
||||
return status === 'active'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status display text
|
||||
*/
|
||||
export function getStatusDisplayText(status: AccountStatus): string {
|
||||
const statusMap: Record<AccountStatus, string> = {
|
||||
pending: '待检查',
|
||||
checking: '检查中',
|
||||
active: '正常',
|
||||
banned: '封禁',
|
||||
token_expired: '过期',
|
||||
error: '错误',
|
||||
}
|
||||
return statusMap[status] || '未知'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status color class
|
||||
*/
|
||||
export function getStatusColorClass(status: AccountStatus): string {
|
||||
const colorMap: Record<AccountStatus, string> = {
|
||||
pending: 'text-slate-500',
|
||||
checking: 'text-blue-500',
|
||||
active: 'text-green-500',
|
||||
banned: 'text-red-500',
|
||||
token_expired: 'text-orange-500',
|
||||
error: 'text-yellow-500',
|
||||
}
|
||||
return colorMap[status] || 'text-slate-500'
|
||||
}
|
||||
0
frontend/src/utils/storage.test.ts
Normal file
0
frontend/src/utils/storage.test.ts
Normal file
107
frontend/src/utils/storage.ts
Normal file
107
frontend/src/utils/storage.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import type { AppConfig, AddRecord } from '../types'
|
||||
import { defaultConfig } from '../types'
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CONFIG: 'codex-pool-config',
|
||||
RECORDS: 'codex-pool-records',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 保存配置到 localStorage
|
||||
*/
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.CONFIG, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save config:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载配置
|
||||
*/
|
||||
export function loadConfig(): AppConfig {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.CONFIG)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
|
||||
// Migration: handle old email config format -> new services array format
|
||||
let emailConfig = { ...defaultConfig.email }
|
||||
|
||||
if (parsed.email) {
|
||||
if (parsed.email.services && Array.isArray(parsed.email.services)) {
|
||||
// New format - use directly
|
||||
emailConfig.services = parsed.email.services
|
||||
} else if (parsed.email.apiBase || parsed.email.domains) {
|
||||
// Old format - migrate to new services array
|
||||
const domains = parsed.email.domains || (parsed.email.domain ? [parsed.email.domain] : ['esyteam.edu.kg'])
|
||||
emailConfig.services = [{
|
||||
name: 'default',
|
||||
apiBase: parsed.email.apiBase || 'https://mail.esyteam.edu.kg',
|
||||
apiToken: parsed.email.apiToken || '',
|
||||
domain: domains[0] || 'esyteam.edu.kg',
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// 合并默认配置,确保新增字段有默认值
|
||||
return {
|
||||
...defaultConfig,
|
||||
...parsed,
|
||||
s2a: { ...defaultConfig.s2a, ...parsed.s2a },
|
||||
pooling: { ...defaultConfig.pooling, ...parsed.pooling },
|
||||
check: { ...defaultConfig.check, ...parsed.check },
|
||||
email: emailConfig,
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load config:', error)
|
||||
}
|
||||
return defaultConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存记录到 localStorage
|
||||
*/
|
||||
export function saveRecords(records: AddRecord[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEYS.RECORDS, JSON.stringify(records))
|
||||
} catch (error) {
|
||||
console.error('Failed to save records:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 加载记录
|
||||
*/
|
||||
export function loadRecords(): AddRecord[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.RECORDS)
|
||||
if (stored) {
|
||||
return JSON.parse(stored)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load records:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 ID
|
||||
*/
|
||||
export function generateId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有存储数据
|
||||
*/
|
||||
export function clearStorage(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEYS.CONFIG)
|
||||
localStorage.removeItem(STORAGE_KEYS.RECORDS)
|
||||
} catch (error) {
|
||||
console.error('Failed to clear storage:', error)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user