mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-02 01:10:23 +08:00
Initial commit
This commit is contained in:
148
frontend/src/utils/__tests__/sanitize.spec.ts
Normal file
148
frontend/src/utils/__tests__/sanitize.spec.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { sanitizeHtml, sanitizeText, sanitizeMarkdown } from '../sanitize'
|
||||
|
||||
describe('sanitize utils', () => {
|
||||
describe('sanitizeHtml', () => {
|
||||
it('should allow safe HTML tags', () => {
|
||||
const input = '<p>Hello <strong>World</strong></p>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).toContain('<p>')
|
||||
expect(result).toContain('<strong>')
|
||||
})
|
||||
|
||||
it('should remove script tags', () => {
|
||||
const input = '<p>Hello</p><script>alert("xss")</script>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('<script>')
|
||||
expect(result).not.toContain('alert')
|
||||
expect(result).toContain('<p>')
|
||||
})
|
||||
|
||||
it('should remove onclick handlers', () => {
|
||||
const input = '<p onclick="alert(1)">Click me</p>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('onclick')
|
||||
expect(result).not.toContain('alert')
|
||||
})
|
||||
|
||||
it('should remove javascript: URLs', () => {
|
||||
const input = '<a href="javascript:alert(1)">Link</a>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('should allow safe links', () => {
|
||||
const input = '<a href="https://example.com">Link</a>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).toContain('href')
|
||||
expect(result).toContain('https://example.com')
|
||||
})
|
||||
|
||||
it('should allow code blocks', () => {
|
||||
const input = '<pre><code>const x = 1;</code></pre>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).toContain('<pre>')
|
||||
expect(result).toContain('<code>')
|
||||
})
|
||||
|
||||
it('should remove dangerous attributes', () => {
|
||||
const input = '<div onerror="alert(1)" style="background:url(javascript:alert(1))">Text</div>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('onerror')
|
||||
expect(result).not.toContain('style')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeText', () => {
|
||||
it('should remove all HTML tags', () => {
|
||||
const input = '<p>Hello <strong>World</strong></p>'
|
||||
const result = sanitizeText(input)
|
||||
expect(result).not.toContain('<')
|
||||
expect(result).not.toContain('>')
|
||||
expect(result).toBe('Hello World')
|
||||
})
|
||||
|
||||
it('should remove script tags and content', () => {
|
||||
const input = 'Safe text<script>alert("xss")</script>More text'
|
||||
const result = sanitizeText(input)
|
||||
expect(result).not.toContain('<script>')
|
||||
expect(result).not.toContain('alert')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sanitizeMarkdown', () => {
|
||||
it('should allow markdown-rendered HTML', () => {
|
||||
const input = '<h1>Title</h1><p>Paragraph with <em>emphasis</em></p>'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).toContain('<h1>')
|
||||
expect(result).toContain('<em>')
|
||||
})
|
||||
|
||||
it('should allow images', () => {
|
||||
const input = '<img src="https://example.com/image.png" alt="Test">'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).toContain('<img')
|
||||
expect(result).toContain('src')
|
||||
expect(result).toContain('alt')
|
||||
})
|
||||
|
||||
it('should remove malicious image sources', () => {
|
||||
const input = '<img src="javascript:alert(1)" onerror="alert(1)">'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).not.toContain('javascript:')
|
||||
expect(result).not.toContain('onerror')
|
||||
})
|
||||
|
||||
it('should allow tables', () => {
|
||||
const input = '<table><thead><tr><th>Header</th></tr></thead><tbody><tr><td>Data</td></tr></tbody></table>'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).toContain('<table>')
|
||||
expect(result).toContain('<thead>')
|
||||
expect(result).toContain('<th>')
|
||||
})
|
||||
|
||||
it('should remove script tags in markdown', () => {
|
||||
const input = '<p>Safe content</p><script>alert("xss")</script>'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).not.toContain('<script>')
|
||||
expect(result).toContain('<p>')
|
||||
})
|
||||
|
||||
it('should preserve code blocks with syntax highlighting classes', () => {
|
||||
const input = '<pre><code class="language-javascript">const x = 1;</code></pre>'
|
||||
const result = sanitizeMarkdown(input)
|
||||
expect(result).toContain('<pre>')
|
||||
expect(result).toContain('<code')
|
||||
expect(result).toContain('class')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty strings', () => {
|
||||
expect(sanitizeHtml('')).toBe('')
|
||||
expect(sanitizeText('')).toBe('')
|
||||
expect(sanitizeMarkdown('')).toBe('')
|
||||
})
|
||||
|
||||
it('should handle strings without HTML', () => {
|
||||
const plain = 'Just plain text'
|
||||
expect(sanitizeHtml(plain)).toBe(plain)
|
||||
expect(sanitizeText(plain)).toBe(plain)
|
||||
expect(sanitizeMarkdown(plain)).toBe(plain)
|
||||
})
|
||||
|
||||
it('should handle nested XSS attempts', () => {
|
||||
const input = '<div><p onclick="alert(1)"><script>alert(2)</script></p></div>'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('onclick')
|
||||
expect(result).not.toContain('<script>')
|
||||
expect(result).not.toContain('alert')
|
||||
})
|
||||
|
||||
it('should handle encoded script attempts', () => {
|
||||
const input = '<img src=x onerror="alert('XSS')">'
|
||||
const result = sanitizeHtml(input)
|
||||
expect(result).not.toContain('onerror')
|
||||
})
|
||||
})
|
||||
})
|
||||
120
frontend/src/utils/announcement.ts
Normal file
120
frontend/src/utils/announcement.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 公告类型相关工具函数
|
||||
*/
|
||||
import { AlertCircle, AlertTriangle, Wrench, Info, type LucideIcon } from 'lucide-vue-next'
|
||||
|
||||
export type AnnouncementType = 'important' | 'warning' | 'maintenance' | 'info'
|
||||
|
||||
interface AnnouncementTypeConfig {
|
||||
icon: LucideIcon
|
||||
iconColor: string
|
||||
label: string
|
||||
bgColor: string
|
||||
borderColor: string
|
||||
textColor: string
|
||||
}
|
||||
|
||||
const announcementTypeConfigs: Record<AnnouncementType, AnnouncementTypeConfig> = {
|
||||
important: {
|
||||
icon: AlertCircle,
|
||||
iconColor: 'text-rose-600 dark:text-rose-400',
|
||||
label: '重要公告',
|
||||
bgColor: 'bg-rose-50 dark:bg-rose-950/30',
|
||||
borderColor: 'border-rose-200 dark:border-rose-800',
|
||||
textColor: 'text-rose-800 dark:text-rose-200'
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-amber-600 dark:text-amber-400',
|
||||
label: '警告通知',
|
||||
bgColor: 'bg-amber-50 dark:bg-amber-950/30',
|
||||
borderColor: 'border-amber-200 dark:border-amber-800',
|
||||
textColor: 'text-amber-800 dark:text-amber-200'
|
||||
},
|
||||
maintenance: {
|
||||
icon: Wrench,
|
||||
iconColor: 'text-orange-600 dark:text-orange-400',
|
||||
label: '维护通知',
|
||||
bgColor: 'bg-orange-50 dark:bg-orange-950/30',
|
||||
borderColor: 'border-orange-200 dark:border-orange-800',
|
||||
textColor: 'text-orange-800 dark:text-orange-200'
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconColor: 'text-primary dark:text-primary',
|
||||
label: '系统公告',
|
||||
bgColor: 'bg-primary/5',
|
||||
borderColor: 'border-primary/20',
|
||||
textColor: 'text-foreground'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告类型配置
|
||||
*/
|
||||
export function getAnnouncementConfig(type: string): AnnouncementTypeConfig {
|
||||
return announcementTypeConfigs[type as AnnouncementType] || announcementTypeConfigs.info
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告图标组件
|
||||
*/
|
||||
export function getAnnouncementIcon(type: string): LucideIcon {
|
||||
return getAnnouncementConfig(type).icon
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告图标颜色
|
||||
*/
|
||||
export function getAnnouncementIconColor(type: string): string {
|
||||
return getAnnouncementConfig(type).iconColor
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告类型标签
|
||||
*/
|
||||
export function getAnnouncementTypeLabel(type: string): string {
|
||||
return getAnnouncementConfig(type).label
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告背景颜色
|
||||
*/
|
||||
export function getAnnouncementBgColor(type: string): string {
|
||||
return getAnnouncementConfig(type).bgColor
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告边框颜色
|
||||
*/
|
||||
export function getAnnouncementBorderColor(type: string): string {
|
||||
return getAnnouncementConfig(type).borderColor
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公告文字颜色
|
||||
*/
|
||||
export function getAnnouncementTextColor(type: string): string {
|
||||
return getAnnouncementConfig(type).textColor
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Markdown 内容转换为纯文本摘要
|
||||
*/
|
||||
export function getPlainTextSummary(content: string, maxLength = 120): string {
|
||||
const cleaned = content
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/`[^`]*`/g, ' ')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||
.replace(/\[[^\]]*]\(([^)]*)\)/g, '$1')
|
||||
.replace(/[#>*_~]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (cleaned.length <= maxLength) {
|
||||
return cleaned
|
||||
}
|
||||
|
||||
return `${cleaned.slice(0, maxLength).trim()}...`
|
||||
}
|
||||
120
frontend/src/utils/cache.ts
Normal file
120
frontend/src/utils/cache.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 简单的内存缓存实现
|
||||
* 用于缓存API响应,减少重复请求
|
||||
*/
|
||||
|
||||
interface CacheItem<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
ttl: number // 生存时间(毫秒)
|
||||
}
|
||||
|
||||
class MemoryCache {
|
||||
private cache: Map<string, CacheItem<any>> = new Map()
|
||||
private defaultTTL = 60000 // 默认缓存60秒
|
||||
|
||||
/**
|
||||
* 设置缓存
|
||||
* @param key 缓存键
|
||||
* @param data 缓存数据
|
||||
* @param ttl 生存时间(毫秒)
|
||||
*/
|
||||
set<T>(key: string, data: T, ttl: number = this.defaultTTL): void {
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存
|
||||
* @param key 缓存键
|
||||
* @returns 缓存数据或null
|
||||
*/
|
||||
get<T>(key: string): T | null {
|
||||
const item = this.cache.get(key)
|
||||
|
||||
if (!item) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() - item.timestamp > item.ttl) {
|
||||
this.cache.delete(key)
|
||||
return null
|
||||
}
|
||||
|
||||
return item.data as T
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除缓存
|
||||
* @param key 缓存键
|
||||
*/
|
||||
delete(key: string): void {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存
|
||||
*/
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
cleanup(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, item] of this.cache.entries()) {
|
||||
if (now - item.timestamp > item.ttl) {
|
||||
this.cache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存大小
|
||||
*/
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局缓存实例
|
||||
export const cache = new MemoryCache()
|
||||
|
||||
// 每5分钟清理一次过期缓存
|
||||
setInterval(() => {
|
||||
cache.cleanup()
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
/**
|
||||
* 带缓存的请求包装器
|
||||
* @param key 缓存键
|
||||
* @param fetcher 数据获取函数
|
||||
* @param ttl 缓存时间(毫秒)
|
||||
*/
|
||||
export async function cachedRequest<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl?: number
|
||||
): Promise<T> {
|
||||
// 尝试从缓存获取
|
||||
const cached = cache.get<T>(key)
|
||||
if (cached !== null) {
|
||||
return cached
|
||||
}
|
||||
|
||||
// 缓存未命中,执行请求
|
||||
const data = await fetcher()
|
||||
|
||||
// 存入缓存
|
||||
cache.set(key, data, ttl)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export default cache
|
||||
53
frontend/src/utils/error.ts
Normal file
53
frontend/src/utils/error.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 从后端响应中提取错误消息
|
||||
* 后端统一返回格式: {"error": {"type": "...", "message": "..."}}
|
||||
*/
|
||||
export function extractErrorMessage(error: any, defaultMessage = '操作失败'): string {
|
||||
// 优先从响应中提取错误消息
|
||||
if (error.response?.data?.error?.message) {
|
||||
return error.response.data.error.message
|
||||
}
|
||||
|
||||
// 如果是网络错误或其他异常
|
||||
if (error.message) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
// 返回默认消息
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误类型枚举
|
||||
*/
|
||||
export const ErrorType = {
|
||||
NETWORK_ERROR: 'network_error',
|
||||
AUTH_ERROR: 'auth_error',
|
||||
VALIDATION_ERROR: 'validation_error',
|
||||
NOT_FOUND: 'not_found',
|
||||
PROVIDER_ERROR: 'provider_error',
|
||||
QUOTA_EXCEEDED: 'quota_exceeded',
|
||||
RATE_LIMIT: 'rate_limit',
|
||||
MODEL_NOT_SUPPORTED: 'model_not_supported',
|
||||
INTERNAL_ERROR: 'internal_error',
|
||||
HTTP_ERROR: 'http_error'
|
||||
} as const
|
||||
|
||||
export type ErrorType = typeof ErrorType[keyof typeof ErrorType]
|
||||
|
||||
/**
|
||||
* 从后端响应中提取错误类型
|
||||
*/
|
||||
export function extractErrorType(error: any): ErrorType | null {
|
||||
if (error.response?.data?.error?.type) {
|
||||
return error.response.data.error.type as ErrorType
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否为特定类型的错误
|
||||
*/
|
||||
export function isErrorType(error: any, type: ErrorType): boolean {
|
||||
return extractErrorType(error) === type
|
||||
}
|
||||
195
frontend/src/utils/errorParser.ts
Normal file
195
frontend/src/utils/errorParser.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 解析 API 错误响应,提取友好的错误信息
|
||||
*/
|
||||
|
||||
/**
|
||||
* Pydantic 验证错误项
|
||||
*/
|
||||
interface ValidationError {
|
||||
loc: (string | number)[]
|
||||
msg: string
|
||||
type: string
|
||||
ctx?: Record<string, any>
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段名称映射(中文化)
|
||||
*/
|
||||
const fieldNameMap: Record<string, string> = {
|
||||
'api_key': 'API 密钥',
|
||||
'priority': '优先级',
|
||||
'max_concurrent': '最大并发',
|
||||
'rate_limit': '速率限制',
|
||||
'daily_limit': '每日限制',
|
||||
'monthly_limit': '每月限制',
|
||||
'allowed_models': '允许的模型',
|
||||
'note': '备注',
|
||||
'is_active': '启用状态',
|
||||
'endpoint_id': 'Endpoint ID',
|
||||
'base_url': 'API 基础 URL',
|
||||
'timeout': '超时时间',
|
||||
'max_retries': '最大重试次数',
|
||||
'weight': '权重',
|
||||
'email': '邮箱',
|
||||
'username': '用户名',
|
||||
'password': '密码',
|
||||
'name': '名称',
|
||||
'display_name': '显示名称',
|
||||
'description': '描述',
|
||||
'website': '网站',
|
||||
'provider_priority': '提供商优先级',
|
||||
'billing_type': '计费类型',
|
||||
'monthly_quota_usd': '月度配额',
|
||||
'quota_reset_day': '配额重置日',
|
||||
'quota_expires_at': '配额过期时间',
|
||||
'rpm_limit': 'RPM 限制',
|
||||
'cache_ttl_minutes': '缓存 TTL',
|
||||
'max_probe_interval_minutes': '最大探测间隔',
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误类型映射(中文化)
|
||||
*/
|
||||
const errorTypeMap: Record<string, (error: ValidationError) => string> = {
|
||||
'string_too_short': (error) => {
|
||||
const minLength = error.ctx?.min_length || 10
|
||||
return `长度不能少于 ${minLength} 个字符`
|
||||
},
|
||||
'string_too_long': (error) => {
|
||||
const maxLength = error.ctx?.max_length
|
||||
return `长度不能超过 ${maxLength} 个字符`
|
||||
},
|
||||
'value_error.missing': () => '此字段为必填项',
|
||||
'missing': () => '此字段为必填项',
|
||||
'type_error.none.not_allowed': () => '此字段不能为空',
|
||||
'value_error': (error) => error.msg,
|
||||
'type_error.integer': () => '必须为整数',
|
||||
'type_error.float': () => '必须为数字',
|
||||
'value_error.number.not_ge': (error) => {
|
||||
const limit = error.ctx?.limit_value
|
||||
return limit !== undefined ? `不能小于 ${limit}` : '数值过小'
|
||||
},
|
||||
'value_error.number.not_le': (error) => {
|
||||
const limit = error.ctx?.limit_value
|
||||
return limit !== undefined ? `不能大于 ${limit}` : '数值过大'
|
||||
},
|
||||
'value_error.number.not_gt': (error) => {
|
||||
const limit = error.ctx?.limit_value
|
||||
return limit !== undefined ? `必须大于 ${limit}` : '数值过小'
|
||||
},
|
||||
'value_error.number.not_lt': (error) => {
|
||||
const limit = error.ctx?.limit_value
|
||||
return limit !== undefined ? `必须小于 ${limit}` : '数值过大'
|
||||
},
|
||||
'less_than_equal': (error) => {
|
||||
const limit = error.ctx?.le
|
||||
return limit !== undefined ? `不能大于 ${limit}` : '数值过大'
|
||||
},
|
||||
'greater_than_equal': (error) => {
|
||||
const limit = error.ctx?.ge
|
||||
return limit !== undefined ? `不能小于 ${limit}` : '数值过小'
|
||||
},
|
||||
'less_than': (error) => {
|
||||
const limit = error.ctx?.lt
|
||||
return limit !== undefined ? `必须小于 ${limit}` : '数值过大'
|
||||
},
|
||||
'greater_than': (error) => {
|
||||
const limit = error.ctx?.gt
|
||||
return limit !== undefined ? `必须大于 ${limit}` : '数值过小'
|
||||
},
|
||||
'value_error.email': () => '邮箱格式不正确',
|
||||
'value_error.url': () => 'URL 格式不正确',
|
||||
'type_error.bool': () => '必须为布尔值(true/false)',
|
||||
'type_error.list': () => '必须为数组',
|
||||
'type_error.dict': () => '必须为对象',
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字段的中文名称
|
||||
*/
|
||||
function getFieldName(loc: (string | number)[]): string {
|
||||
if (!loc || loc.length === 0) return '字段'
|
||||
|
||||
const fieldPath = loc.filter(item => item !== 'body').join('.')
|
||||
const fieldKey = String(loc[loc.length - 1])
|
||||
|
||||
return fieldNameMap[fieldKey] || fieldPath || '字段'
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化单个验证错误
|
||||
*/
|
||||
function formatValidationError(error: ValidationError): string {
|
||||
const fieldName = getFieldName(error.loc)
|
||||
const errorFormatter = errorTypeMap[error.type]
|
||||
|
||||
if (errorFormatter) {
|
||||
const errorMsg = errorFormatter(error)
|
||||
return `${fieldName}: ${errorMsg}`
|
||||
}
|
||||
|
||||
// 默认格式
|
||||
return `${fieldName}: ${error.msg}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 API 错误响应
|
||||
* @param err 错误对象
|
||||
* @param defaultMessage 默认错误信息
|
||||
* @returns 格式化的错误信息
|
||||
*/
|
||||
export function parseApiError(err: any, defaultMessage: string = '操作失败'): string {
|
||||
if (!err) return defaultMessage
|
||||
|
||||
// 处理网络错误
|
||||
if (!err.response) {
|
||||
return '无法连接到服务器,请检查网络连接'
|
||||
}
|
||||
|
||||
const detail = err.response?.data?.detail
|
||||
|
||||
// 如果没有 detail 字段
|
||||
if (!detail) {
|
||||
return err.response?.data?.message || err.message || defaultMessage
|
||||
}
|
||||
|
||||
// 1. 处理 Pydantic 验证错误(数组格式)
|
||||
if (Array.isArray(detail)) {
|
||||
const errors = detail
|
||||
.map((error: ValidationError) => formatValidationError(error))
|
||||
.join('\n')
|
||||
return errors || defaultMessage
|
||||
}
|
||||
|
||||
// 2. 处理字符串错误
|
||||
if (typeof detail === 'string') {
|
||||
return detail
|
||||
}
|
||||
|
||||
// 3. 处理对象错误
|
||||
if (typeof detail === 'object') {
|
||||
// 可能是自定义错误对象
|
||||
if (detail.message) {
|
||||
return detail.message
|
||||
}
|
||||
// 尝试 JSON 序列化
|
||||
try {
|
||||
return JSON.stringify(detail, null, 2)
|
||||
} catch {
|
||||
return defaultMessage
|
||||
}
|
||||
}
|
||||
|
||||
return defaultMessage
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析并提取第一个错误信息(用于简短提示)
|
||||
*/
|
||||
export function parseApiErrorShort(err: any, defaultMessage: string = '操作失败'): string {
|
||||
const fullError = parseApiError(err, defaultMessage)
|
||||
|
||||
// 如果有多行错误,只取第一行
|
||||
const lines = fullError.split('\n')
|
||||
return lines[0] || defaultMessage
|
||||
}
|
||||
81
frontend/src/utils/form.ts
Normal file
81
frontend/src/utils/form.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Form utility functions
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse number input value, handling empty strings and NaN
|
||||
* Use this for optional number fields that should be `undefined` when empty
|
||||
*
|
||||
* @param value - Input value (string or number)
|
||||
* @param options - Parse options
|
||||
* @returns Parsed number or undefined
|
||||
*
|
||||
* @example
|
||||
* // In template:
|
||||
* <Input
|
||||
* :model-value="form.rate_limit ?? ''"
|
||||
* @update:model-value="(v) => form.rate_limit = parseNumberInput(v)"
|
||||
* />
|
||||
*/
|
||||
export function parseNumberInput(
|
||||
value: string | number | null | undefined,
|
||||
options: {
|
||||
allowFloat?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
} = {}
|
||||
): number | undefined {
|
||||
const { allowFloat = false, min, max } = options
|
||||
|
||||
// Handle empty/null/undefined
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Parse the value
|
||||
const num = typeof value === 'string'
|
||||
? (allowFloat ? parseFloat(value) : parseInt(value, 10))
|
||||
: value
|
||||
|
||||
// Handle NaN
|
||||
if (isNaN(num)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Apply min/max constraints
|
||||
let result = num
|
||||
if (min !== undefined && result < min) {
|
||||
result = min
|
||||
}
|
||||
if (max !== undefined && result > max) {
|
||||
result = max
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a handler function for number input with specific field
|
||||
* Useful for creating inline handlers in templates
|
||||
*
|
||||
* @param obj - Reactive object containing the field
|
||||
* @param field - Field name to update
|
||||
* @param options - Parse options
|
||||
* @returns Handler function
|
||||
*
|
||||
* @example
|
||||
* // In script:
|
||||
* const handleRateLimit = createNumberInputHandler(form, 'rate_limit')
|
||||
*
|
||||
* // In template:
|
||||
* <Input @update:model-value="handleRateLimit" />
|
||||
*/
|
||||
export function createNumberInputHandler<T extends Record<string, any>>(
|
||||
obj: T,
|
||||
field: keyof T,
|
||||
options: Parameters<typeof parseNumberInput>[1] = {}
|
||||
) {
|
||||
return (value: string | number | null | undefined) => {
|
||||
(obj as any)[field] = parseNumberInput(value, options)
|
||||
}
|
||||
}
|
||||
128
frontend/src/utils/format.ts
Normal file
128
frontend/src/utils/format.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Token formatting - intelligent display based on value size
|
||||
export function formatTokens(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null || num === 0) {
|
||||
return '0'
|
||||
}
|
||||
|
||||
// For very small values (< 1000), show as is without unit
|
||||
if (num < 1000) {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
// For values 1K-999K, show in thousands
|
||||
if (num < 1000000) {
|
||||
const thousands = num / 1000
|
||||
if (thousands >= 100) {
|
||||
return Math.round(thousands) + 'K'
|
||||
} else if (thousands >= 10) {
|
||||
return thousands.toFixed(1) + 'K'
|
||||
} else {
|
||||
return thousands.toFixed(2) + 'K'
|
||||
}
|
||||
}
|
||||
|
||||
// For values >= 1M, show in millions
|
||||
const millions = num / 1000000
|
||||
if (millions >= 100) {
|
||||
return Math.round(millions) + 'M'
|
||||
} else if (millions >= 10) {
|
||||
return millions.toFixed(1) + 'M'
|
||||
} else {
|
||||
return millions.toFixed(2) + 'M'
|
||||
}
|
||||
}
|
||||
|
||||
// Currency formatting with high precision for small values
|
||||
export function formatCurrency(amount: number | undefined | null): string {
|
||||
if (amount === undefined || amount === null || amount === 0) {
|
||||
return '$0.00'
|
||||
}
|
||||
|
||||
// For very small amounts (< $0.00001), show up to 8 decimal places
|
||||
if (amount > 0 && amount < 0.00001) {
|
||||
const formatted = amount.toFixed(8)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return '$' + trimmed
|
||||
}
|
||||
|
||||
// For small amounts (< $0.0001), show up to 6 decimal places
|
||||
if (amount < 0.0001) {
|
||||
const formatted = amount.toFixed(6)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return '$' + trimmed
|
||||
}
|
||||
|
||||
// For small amounts (< $0.01), show up to 5 decimal places
|
||||
if (amount < 0.01) {
|
||||
const formatted = amount.toFixed(5)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return '$' + trimmed
|
||||
}
|
||||
|
||||
// For amounts less than $1, show 4 decimal places
|
||||
if (amount < 1) {
|
||||
const formatted = amount.toFixed(4)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return '$' + trimmed
|
||||
}
|
||||
|
||||
// For amounts $1-$100, show 2-3 decimal places
|
||||
if (amount < 100) {
|
||||
const formatted = amount.toFixed(3)
|
||||
// Remove trailing zeros but keep at least 2 decimal places
|
||||
const trimmed = formatted.replace(/(\.\d\d)0+$/, '$1')
|
||||
return '$' + trimmed
|
||||
}
|
||||
|
||||
// For larger amounts, show 2 decimal places
|
||||
return '$' + amount.toFixed(2)
|
||||
}
|
||||
|
||||
// Number formatting with locale support
|
||||
export function formatNumber(num: number | undefined | null): string {
|
||||
if (num === undefined || num === null) {
|
||||
return '0'
|
||||
}
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
export function formatDate(dateString: string | undefined | null): string {
|
||||
if (!dateString) return '未知'
|
||||
|
||||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// Model price formatting (already in per 1M tokens)
|
||||
export function formatModelPrice(price: number | undefined | null): string {
|
||||
if (price === undefined || price === null) {
|
||||
return '$0.00'
|
||||
}
|
||||
|
||||
// Price is already per 1M tokens, no conversion needed
|
||||
if (price < 1) {
|
||||
return '$' + price.toFixed(4).replace(/\.?0+$/, '').padEnd(price.toFixed(4).indexOf('.') + 3, '0')
|
||||
} else {
|
||||
return '$' + price.toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
// Billing type formatting
|
||||
export function formatBillingType(type: string | undefined | null): string {
|
||||
const typeMap: Record<string, string> = {
|
||||
'pay_as_you_go': '按量付费',
|
||||
'monthly_quota': '月卡配额',
|
||||
'free_tier': '免费套餐'
|
||||
}
|
||||
return typeMap[type || ''] || type || '按量付费'
|
||||
}
|
||||
115
frontend/src/utils/importRetry.ts
Normal file
115
frontend/src/utils/importRetry.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 带重试机制和缓存处理的动态导入工具
|
||||
*/
|
||||
|
||||
const MAX_RETRIES = 3
|
||||
const RETRY_DELAY = 1000 // 1秒
|
||||
const CACHE_BUSTER_DELAY = 2000 // 2秒后尝试缓存清除
|
||||
|
||||
// 模块缓存
|
||||
const moduleCache = new Map<string, Promise<any>>()
|
||||
|
||||
/**
|
||||
* 清除浏览器缓存的工具函数
|
||||
*/
|
||||
function clearBrowserCache() {
|
||||
if (typeof window !== 'undefined') {
|
||||
// 清除一些可能的缓存
|
||||
if ('caches' in window) {
|
||||
caches.keys().then(names => {
|
||||
names.forEach(name => {
|
||||
caches.delete(name)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查错误是否是网络/缓存相关
|
||||
*/
|
||||
function isNetworkOrCacheError(error: any): boolean {
|
||||
const errorMessage = error?.message || ''
|
||||
return (
|
||||
errorMessage.includes('Failed to fetch') ||
|
||||
errorMessage.includes('Loading chunk') ||
|
||||
errorMessage.includes('dynamically imported module') ||
|
||||
errorMessage.includes('NetworkError') ||
|
||||
error?.name === 'ChunkLoadError'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试动态导入
|
||||
* @param importFn 动态导入函数
|
||||
* @param retries 剩余重试次数
|
||||
* @param cacheKey 缓存键
|
||||
* @returns Promise
|
||||
*/
|
||||
export async function importWithRetry<T = any>(
|
||||
importFn: () => Promise<T>,
|
||||
retries: number = MAX_RETRIES,
|
||||
cacheKey?: string
|
||||
): Promise<T> {
|
||||
try {
|
||||
// 如果有缓存键且缓存中存在,直接返回
|
||||
if (cacheKey && moduleCache.has(cacheKey)) {
|
||||
return await moduleCache.get(cacheKey)!
|
||||
}
|
||||
|
||||
const importPromise = importFn()
|
||||
|
||||
// 缓存 Promise
|
||||
if (cacheKey) {
|
||||
moduleCache.set(cacheKey, importPromise)
|
||||
}
|
||||
|
||||
const result = await importPromise
|
||||
return result
|
||||
} catch (error) {
|
||||
// 如果是缓存相关错误,清除对应缓存
|
||||
if (cacheKey && moduleCache.has(cacheKey)) {
|
||||
moduleCache.delete(cacheKey)
|
||||
}
|
||||
|
||||
if (retries > 0 && isNetworkOrCacheError(error)) {
|
||||
// 如果是第二次重试,尝试清除浏览器缓存
|
||||
if (MAX_RETRIES - retries + 1 === 2) {
|
||||
clearBrowserCache()
|
||||
await new Promise(resolve => setTimeout(resolve, CACHE_BUSTER_DELAY))
|
||||
} else {
|
||||
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY))
|
||||
}
|
||||
|
||||
return importWithRetry(importFn, retries - 1, cacheKey)
|
||||
} else {
|
||||
// 最后的fallback:如果是网络/缓存错误,刷新页面
|
||||
if (isNetworkOrCacheError(error) && typeof window !== 'undefined') {
|
||||
// 添加一个时间戳参数来强制刷新
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('_t', Date.now().toString())
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建带重试的组件导入函数
|
||||
* @param importPath 组件路径
|
||||
* @returns 组件导入函数
|
||||
*/
|
||||
export function createRetryableImport(importPath: string) {
|
||||
const cacheKey = importPath
|
||||
return () => importWithRetry(() => import(/* @vite-ignore */ importPath), MAX_RETRIES, cacheKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 预加载关键模块
|
||||
*/
|
||||
export function preloadCriticalModules() {
|
||||
// 在开发环境中预加载已被禁用,因为路径别名在运行时动态导入中不可用
|
||||
// 模块会在需要时按需加载,这在开发环境中是可接受的
|
||||
// 生产环境中模块已经被构建和优化,不需要预加载
|
||||
}
|
||||
101
frontend/src/utils/logger.ts
Normal file
101
frontend/src/utils/logger.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 统一日志工具
|
||||
* 生产环境不输出日志,开发环境按级别输出
|
||||
*/
|
||||
|
||||
import { LogLevel, isDev } from '@/config/constants'
|
||||
|
||||
class Logger {
|
||||
private isDevelopment: boolean
|
||||
|
||||
constructor() {
|
||||
this.isDevelopment = isDev
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日志消息
|
||||
*/
|
||||
private formatMessage(level: LogLevel, message: string, context?: unknown): string {
|
||||
const timestamp = new Date().toISOString()
|
||||
const contextStr = context ? ` | ${JSON.stringify(context)}` : ''
|
||||
return `[${timestamp}] [${level}] ${message}${contextStr}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 调试日志 - 仅开发环境
|
||||
*/
|
||||
debug(message: string, context?: unknown): void {
|
||||
if (this.isDevelopment) {
|
||||
console.debug(this.formatMessage(LogLevel.DEBUG, message, context))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 信息日志 - 仅开发环境
|
||||
*/
|
||||
info(message: string, context?: unknown): void {
|
||||
if (this.isDevelopment) {
|
||||
console.info(this.formatMessage(LogLevel.INFO, message, context))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 警告日志 - 仅开发环境
|
||||
*/
|
||||
warn(message: string, context?: unknown): void {
|
||||
if (this.isDevelopment) {
|
||||
console.warn(this.formatMessage(LogLevel.WARN, message, context))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误日志 - 始终输出(但在生产环境可以发送到监控服务)
|
||||
*/
|
||||
error(message: string, error?: unknown): void {
|
||||
const errorContext = error instanceof Error
|
||||
? { message: error.message, stack: error.stack }
|
||||
: error
|
||||
|
||||
if (this.isDevelopment) {
|
||||
console.error(this.formatMessage(LogLevel.ERROR, message, errorContext))
|
||||
} else {
|
||||
// 生产环境:可以在这里发送到错误监控服务(如 Sentry)
|
||||
// 目前只记录到 console.error,不暴露详细信息
|
||||
console.error(`[ERROR] ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络请求日志
|
||||
*/
|
||||
http(method: string, url: string, status?: number, duration?: number): void {
|
||||
if (this.isDevelopment) {
|
||||
const statusText = status ? `[${status}]` : ''
|
||||
const durationText = duration ? `(${duration}ms)` : ''
|
||||
this.info(`HTTP ${method} ${url} ${statusText} ${durationText}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能日志
|
||||
*/
|
||||
performance(label: string, duration: number): void {
|
||||
if (this.isDevelopment) {
|
||||
this.info(`Performance: ${label} took ${duration}ms`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const logger = new Logger()
|
||||
|
||||
// 便捷方法
|
||||
export const log = {
|
||||
debug: (message: string, context?: unknown) => logger.debug(message, context),
|
||||
info: (message: string, context?: unknown) => logger.info(message, context),
|
||||
warn: (message: string, context?: unknown) => logger.warn(message, context),
|
||||
error: (message: string, error?: unknown) => logger.error(message, error),
|
||||
http: (method: string, url: string, status?: number, duration?: number) =>
|
||||
logger.http(method, url, status, duration),
|
||||
performance: (label: string, duration: number) => logger.performance(label, duration),
|
||||
}
|
||||
63
frontend/src/utils/sanitize.ts
Normal file
63
frontend/src/utils/sanitize.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* 配置 DOMPurify 允许的标签和属性
|
||||
*/
|
||||
const DOMPURIFY_CONFIG = {
|
||||
// 允许的HTML标签
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'ul', 'ol', 'li',
|
||||
'a', 'blockquote',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
||||
'span', 'div'
|
||||
],
|
||||
// 允许的属性
|
||||
ALLOWED_ATTR: [
|
||||
'href', 'title', 'target', 'rel',
|
||||
'class', 'id'
|
||||
],
|
||||
// 允许的URI协议
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理HTML内容,移除潜在的XSS攻击向量
|
||||
* @param dirty - 原始HTML字符串
|
||||
* @returns 清理后的安全HTML字符串
|
||||
*/
|
||||
export function sanitizeHtml(dirty: string): string {
|
||||
return DOMPurify.sanitize(dirty, DOMPURIFY_CONFIG)
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格模式:只允许纯文本
|
||||
* @param dirty - 原始字符串
|
||||
* @returns 纯文本(所有HTML标签被移除)
|
||||
*/
|
||||
export function sanitizeText(dirty: string): string {
|
||||
return DOMPurify.sanitize(dirty, { ALLOWED_TAGS: [] })
|
||||
}
|
||||
|
||||
/**
|
||||
* 为 Markdown 内容提供特殊配置
|
||||
* @param dirty - Markdown 渲染后的 HTML
|
||||
* @returns 清理后的安全 HTML
|
||||
*/
|
||||
export function sanitizeMarkdown(dirty: string): string {
|
||||
// Markdown 可能需要更多的HTML标签支持
|
||||
const markdownConfig = {
|
||||
...DOMPURIFY_CONFIG,
|
||||
ALLOWED_TAGS: [
|
||||
...DOMPURIFY_CONFIG.ALLOWED_TAGS,
|
||||
'img' // Markdown 支持图片
|
||||
],
|
||||
ALLOWED_ATTR: [
|
||||
...DOMPURIFY_CONFIG.ALLOWED_ATTR,
|
||||
'src', 'alt', 'width', 'height' // 图片属性
|
||||
]
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(dirty, markdownConfig)
|
||||
}
|
||||
Reference in New Issue
Block a user