Initial commit

This commit is contained in:
fawney19
2025-12-10 20:52:44 +08:00
commit f784106826
485 changed files with 110993 additions and 0 deletions

View 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="&#97;&#108;&#101;&#114;&#116;&#40;&#39;&#88;&#83;&#83;&#39;&#41;">'
const result = sanitizeHtml(input)
expect(result).not.toContain('onerror')
})
})
})

View 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
View 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

View 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
}

View 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
}

View 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)
}
}

View 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 || '按量付费'
}

View 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() {
// 在开发环境中预加载已被禁用,因为路径别名在运行时动态导入中不可用
// 模块会在需要时按需加载,这在开发环境中是可接受的
// 生产环境中模块已经被构建和优化,不需要预加载
}

View 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),
}

View 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)
}