class ValidationUtils{static SUPPORTED_IMAGE_TYPES=['image/jpeg','image/png','image/gif','image/webp','image/bmp']
static MIN_FILE_SIZE=100
static MAX_FILE_SIZE=10*1024*1024
static MAX_FILENAME_LENGTH=255
static SUSPICIOUS_EXTENSIONS=['.exe','.bat','.cmd','.scr','.com','.pif','.vbs','.js','.jar','.msi','.dll','.ps1']
static FORBIDDEN_FILENAME_CHARS=/[<>:"/\\|?*\x00-\x1f]/g
static XSS_PATTERNS=[/<script\b[^>]*>/gi,/javascript:/gi,/on\w+\s*=/gi,/data:\s*text\/html/gi]
static validateImageFile(file){const errors=[]
if(!file){errors.push('文件对象为空')
return{valid:false,errors}}
if(!file.type){errors.push('无法识别文件类型')
return{valid:false,errors}}
if(!this.SUPPORTED_IMAGE_TYPES.includes(file.type)){errors.push(`不支持的图片格式: ${file.type}`)}
if(file.size<this.MIN_FILE_SIZE){errors.push(`文件太小 (${file.size} bytes),可能是空文件或损坏`)}
if(file.size>this.MAX_FILE_SIZE){const sizeMB=(file.size/1024/1024).toFixed(2)
errors.push(`文件超过大小限制: ${sizeMB}MB > 10MB`)}
const filenameErrors=this.validateFilename(file.name)
errors.push(...filenameErrors)
const securityErrors=this.checkFileSecurity(file)
errors.push(...securityErrors)
return{valid:errors.length===0,errors}}
static validateFilename(filename){const errors=[]
if(!filename){errors.push('文件名为空')
return errors}
if(filename.length>this.MAX_FILENAME_LENGTH){errors.push(`文件名过长 (${filename.length} > ${this.MAX_FILENAME_LENGTH})`)}
if(this.FORBIDDEN_FILENAME_CHARS.test(filename)){errors.push('文件名包含非法字符')}
if(filename.includes('..')||filename.includes('/')||filename.includes('\\')){errors.push('文件名包含路径遍历字符')}
return errors}
static checkFileSecurity(file){const errors=[]
const filename=file.name?.toLowerCase()||''
for(const ext of this.SUSPICIOUS_EXTENSIONS){if(filename.endsWith(ext)){errors.push(`检测到可疑文件类型: ${ext}`)
break}}
const parts=filename.split('.')
if(parts.length>2){const lastExt='.'+parts[parts.length-1]
if(this.SUSPICIOUS_EXTENSIONS.includes(lastExt)){errors.push('检测到伪装的可执行文件')}}
return errors}
static validateTextInput(text,options={}){const{minLength=0,maxLength=10000,allowEmpty=true,sanitize=true}=options
const errors=[]
let sanitized=text||''
if(!text||text.trim()===''){if(!allowEmpty){errors.push('输入不能为空')}
return{valid:allowEmpty,errors,sanitized:''}}
if(text.length<minLength){errors.push(`输入长度不足 (${text.length} < ${minLength})`)}
if(text.length>maxLength){errors.push(`输入长度超限 (${text.length} > ${maxLength})`)
sanitized=text.substring(0,maxLength)}
if(sanitize){sanitized=this.sanitizeText(sanitized)}
return{valid:errors.length===0,errors,sanitized}}
static sanitizeText(text){if(!text)return''
let sanitized=text
for(const pattern of this.XSS_PATTERNS){sanitized=sanitized.replace(pattern,'')}
sanitized=sanitized.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''')
return sanitized}
static sanitizeFilename(filename,maxLength=100){if(!filename)return''
return filename.replace(this.FORBIDDEN_FILENAME_CHARS,'').replace(/\s+/g,'_').replace(/\.{2,}/g,'.').trim().substring(0,maxLength)}
static validateNumberRange(value,min,max,fieldName='值'){const num=Number(value)
if(isNaN(num)){return{valid:false,value:min,error:`${fieldName} 必须是数字`}}
if(num<min){return{valid:false,value:min,error:`${fieldName} 不能小于 ${min}`}}
if(num>max){return{valid:false,value:max,error:`${fieldName} 不能大于 ${max}`}}
return{valid:true,value:num,error:null}}
static clampValue(value,min,max){const num=Number(value)
if(isNaN(num))return min
return Math.max(min,Math.min(max,num))}}
class APICache{constructor(defaultTTL=30000){this.cache=new Map()
this.defaultTTL=defaultTTL}
get(key){const entry=this.cache.get(key)
if(!entry)return null
if(Date.now()>entry.expiry){this.cache.delete(key)
return null}
return entry.value}
set(key,value,ttl=this.defaultTTL){this.cache.set(key,{value,expiry:Date.now()+ttl})}
delete(key){this.cache.delete(key)}
clear(){this.cache.clear()}
cleanup(){const now=Date.now()
for(const[key,entry]of this.cache.entries()){if(now>entry.expiry){this.cache.delete(key)}}}
get size(){return this.cache.size}
async fetchWithCache(url,options={},cacheTTL=this.defaultTTL){const method=options.method?.toUpperCase()||'GET'
if(method!=='GET'){const response=await fetch(url,options)
return response.json()}
const cacheKey=`${method}:${url}`
const cached=this.get(cacheKey)
if(cached!==null){return cached}
const response=await fetch(url,options)
const data=await response.json()
if(response.ok){this.set(cacheKey,data,cacheTTL)}
return data}}
const apiCache=new APICache(30000)
setInterval(()=>{apiCache.cleanup()},5*60*1000)
function debounce(func,wait=300,immediate=false){let timeout=null
let result=null
const debounced=function(...args){const context=this
const callNow=immediate&&!timeout
if(timeout){clearTimeout(timeout)}
timeout=setTimeout(()=>{timeout=null
if(!immediate){result=func.apply(context,args)}},wait)
if(callNow){result=func.apply(context,args)}
return result}
debounced.cancel=function(){if(timeout){clearTimeout(timeout)
timeout=null}}
debounced.flush=function(...args){debounced.cancel()
return func.apply(this,args)}
return debounced}
function throttle(func,wait=200,options={}){let timeout=null
let previous=0
const{leading=true,trailing=true}=options
const throttled=function(...args){const context=this
const now=Date.now()
if(!previous&&!leading){previous=now}
const remaining=wait-(now-previous)
if(remaining<=0||remaining>wait){if(timeout){clearTimeout(timeout)
timeout=null}
previous=now
func.apply(context,args)}else if(!timeout&&trailing){timeout=setTimeout(()=>{previous=leading?Date.now():0
timeout=null
func.apply(context,args)},remaining)}}
throttled.cancel=function(){if(timeout){clearTimeout(timeout)
timeout=null}
previous=0}
return throttled}
class RequestDeduplicator{constructor(){this.pendingRequests=new Map()}
async dedupe(key,asyncFn){if(this.pendingRequests.has(key)){console.debug(`[RequestDeduplicator] 去重请求: ${key}`)
return this.pendingRequests.get(key)}
const promise=asyncFn().finally(()=>{this.pendingRequests.delete(key)})
this.pendingRequests.set(key,promise)
return promise}
isPending(key){return this.pendingRequests.has(key)}
clear(){this.pendingRequests.clear()}}
const requestDeduplicator=new RequestDeduplicator()
class LazyLoader{static defaultOptions={rootMargin:'50px 0px',threshold:0.01,loadingClass:'lazy-loading',loadedClass:'lazy-loaded',errorClass:'lazy-error'}
static init(selector='.lazy-image',options={}){const config={...this.defaultOptions,...options}
if(!('IntersectionObserver'in window)){console.warn('浏览器不支持 IntersectionObserver,使用降级方案')
this.loadAllImages(selector)
return}
const observer=new IntersectionObserver((entries,obs)=>{entries.forEach(entry=>{if(entry.isIntersecting){this.loadImage(entry.target,config)
obs.unobserve(entry.target)}})},{rootMargin:config.rootMargin,threshold:config.threshold})
document.querySelectorAll(selector).forEach(img=>{observer.observe(img)})
this._observer=observer
console.log(`懒加载已初始化,监控 ${document.querySelectorAll(selector).length} 张图片`)}
static loadImage(img,config=this.defaultOptions){const src=img.dataset.src
if(!src)return
img.classList.add(config.loadingClass)
const tempImg=new Image()
tempImg.onload=()=>{img.src=src
img.classList.remove(config.loadingClass)
img.classList.add(config.loadedClass)
img.removeAttribute('data-src')}
tempImg.onerror=()=>{img.classList.remove(config.loadingClass)
img.classList.add(config.errorClass)
console.warn(`图片加载失败: ${src}`)}
tempImg.src=src}
static loadAllImages(selector){document.querySelectorAll(selector).forEach(img=>{this.loadImage(img)})}
static load(target){const img=typeof target==='string'?document.querySelector(target):target
if(img){this.loadImage(img)}}
static observe(img){if(this._observer&&img){this._observer.observe(img)}}
static disconnect(){if(this._observer){this._observer.disconnect()
this._observer=null}}}
class VirtualScroller{constructor(container,options={}){this.container=container
this.itemHeight=options.itemHeight||50
this.buffer=options.buffer||5
this.items=[]
this.renderItem=options.renderItem||(item=>`<div>${item}</div>`)
this.init()}
init(){this.wrapper=document.createElement('div')
this.wrapper.className='virtual-scroll-wrapper'
this.wrapper.style.position='relative'
this.content=document.createElement('div')
this.content.className='virtual-scroll-content'
this.wrapper.appendChild(this.content)
this.container.appendChild(this.wrapper)
this.container.addEventListener('scroll',typeof throttle!=='undefined'?throttle(()=>this.render(),16):()=>this.render())}
setItems(items){this.items=items
this.wrapper.style.height=`${items.length * this.itemHeight}px`
this.render()}
render(){const scrollTop=this.container.scrollTop
const containerHeight=this.container.clientHeight
const startIndex=Math.max(0,Math.floor(scrollTop/this.itemHeight)-this.buffer)
const endIndex=Math.min(this.items.length,Math.ceil((scrollTop+containerHeight)/this.itemHeight)+this.buffer)
const visibleItems=this.items.slice(startIndex,endIndex)
this.content.style.transform=`translateY(${startIndex * this.itemHeight}px)`
this.content.innerHTML=visibleItems.map((item,i)=>this.renderItem(item,startIndex+i)).join('')}
scrollToIndex(index){this.container.scrollTop=index*this.itemHeight}
destroy(){this.container.removeChild(this.wrapper)}}
if(typeof module!=='undefined'&&module.exports){module.exports={ValidationUtils,APICache,apiCache,debounce,throttle,RequestDeduplicator,requestDeduplicator,LazyLoader,VirtualScroller}}