📄 progress.ts  •  6849 bytes
/**
 * UI 组件 - 进度条
 * Phase 5: 交互增强
 */

/** 进度条样式 */
export type ProgressStyle = 'bar' | 'dots' | 'spinner' | 'percentage'

/** 进度条配置 */
export interface ProgressOptions {
  total: number
  current?: number
  width?: number
  style?: ProgressStyle
  showPercentage?: boolean
  showLabel?: boolean
  label?: string
  color?: string
  completedColor?: string
}

/** 进度条状态 */
export interface ProgressState {
  total: number
  current: number
  startTime: number
  estimatedEndTime?: number
  lastUpdate: number
}

/** 默认配置 */
const DEFAULT_OPTIONS: Required<Omit<ProgressOptions, 'total' | 'current'>> = {
  width: 30,
  style: 'bar',
  showPercentage: true,
  showLabel: true,
  label: '进度',
  color: '\x1b[38;5;240m',
  completedColor: '\x1b[38;5;208m',
}

/** ANSI 转义序列 */
const ESC = '\x1b'
const RESET = `${ESC}[0m`
const CLEAR_LINE = `${ESC}[2K\r`

/**
 * 创建进度条
 */
export class ProgressBar {
  private state: ProgressState
  private options: Required<ProgressOptions>
  private template: string
  private animationFrame?: ReturnType<typeof setTimeout>
  
  constructor(options: ProgressOptions) {
    this.options = { ...DEFAULT_OPTIONS, ...options } as Required<ProgressOptions>
    this.state = {
      total: options.total,
      current: options.current || 0,
      startTime: Date.now(),
      lastUpdate: Date.now(),
    }
    this.template = ''
  }
  
  /** 开始显示 */
  start(): void {
    this.render()
  }
  
  /** 更新进度 */
  update(current: number): void {
    this.state.current = Math.min(current, this.state.total)
    this.state.lastUpdate = Date.now()
    this.render()
  }
  
  /** 增加进度 */
  increment(delta: number = 1): void {
    this.update(this.state.current + delta)
  }
  
  /** 完成进度条 */
  complete(): void {
    this.state.current = this.state.total
    this.render()
    this.stop()
    console.log('')
  }
  
  /** 停止动画 */
  stop(): void {
    if (this.animationFrame) {
      clearTimeout(this.animationFrame)
      this.animationFrame = undefined
    }
  }
  
  /** 获取百分比 */
  getPercentage(): number {
    return Math.round((this.state.current / this.state.total) * 100)
  }
  
  /** 获取 ETA */
  getETA(): number | null {
    if (this.state.current === 0) return null
    const elapsed = Date.now() - this.state.startTime
    const rate = this.state.current / elapsed
    const remaining = this.state.total - this.state.current
    return Math.round(remaining / rate)
  }
  
  /** 渲染进度条 */
  private render(): void {
    const { width, style, showPercentage, showLabel, label, color, completedColor } = this.options
    const percentage = this.getPercentage()
    const filled = Math.round((percentage / 100) * width)
    const empty = width - filled
    
    let output = CLEAR_LINE
    
    switch (style) {
      case 'bar':
        output += `  ${color}[${completedColor}${'█'.repeat(filled)}${'░'.repeat(empty)}${color}]${RESET}`
        if (showPercentage) {
          output += ` ${percentage}%`
        }
        break
        
      case 'dots':
        const dot = '●'
        const dotEmpty = '○'
        output += `  ${completedColor}${dot.repeat(filled)}${color}${dotEmpty.repeat(empty)}${RESET}`
        if (showPercentage) {
          output += ` ${percentage}%`
        }
        break
        
      case 'spinner':
        const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
        const frameIndex = Math.floor(Date.now() / 100) % frames.length
        output += `  ${completedColor}${frames[frameIndex]}${RESET} ${percentage}%`
        break
        
      case 'percentage':
        output += `  ${percentage}%`
        break
    }
    
    if (showLabel && label) {
      output += ` ${label}`
    }
    
    // ETA
    const eta = this.getETA()
    if (eta !== null && this.state.current < this.state.total) {
      const etaStr = this.formatTime(eta)
      output += ` | ETA: ${etaStr}`
    }
    
    process.stdout.write(output)
  }
  
  /** 格式化时间 */
  private formatTime(ms: number): string {
    const seconds = Math.floor(ms / 1000)
    const minutes = Math.floor(seconds / 60)
    
    if (minutes > 0) {
      return `${minutes}m ${seconds % 60}s`
    }
    return `${seconds}s`
  }
}

/**
 * 创建简单进度条(单次使用)
 */
export function createProgress(options: ProgressOptions): ProgressBar {
  const bar = new ProgressBar(options)
  bar.start()
  return bar
}

/**
 * 带进度的循环
 */
export async function withProgress<T>(
  items: T[],
  callback: (item: T, index: number, progress: ProgressBar) => Promise<void>,
  options: Partial<ProgressOptions> = {}
): Promise<void> {
  const bar = new ProgressBar({
    total: items.length,
    ...options,
    label: options.label || '处理中',
  })
  
  bar.start()
  
  for (let i = 0; i < items.length; i++) {
    await callback(items[i], i, bar)
    bar.increment()
  }
  
  bar.complete()
}

/**
 * 多步进度
 */
export class MultiStepProgress {
  private steps: { name: string; status: 'pending' | 'running' | 'done' | 'error' }[]
  private currentStep: number = 0
  
  constructor(steps: string[]) {
    this.steps = steps.map(name => ({ name, status: 'pending' }))
  }
  
  start(): void {
    this.render()
  }
  
  nextStep(): void {
    if (this.currentStep < this.steps.length) {
      this.steps[this.currentStep].status = 'done'
      this.currentStep++
      if (this.currentStep < this.steps.length) {
        this.steps[this.currentStep].status = 'running'
      }
      this.render()
    }
  }
  
  error(message?: string): void {
    if (this.currentStep < this.steps.length) {
      this.steps[this.currentStep].status = 'error'
    }
    this.render()
    if (message) {
      console.log(`  \x1b[38;5;196m❌ ${message}\x1b[0m`)
    }
  }
  
  complete(): void {
    if (this.currentStep < this.steps.length) {
      this.steps[this.currentStep].status = 'done'
    }
    this.render()
    console.log('')
  }
  
  private render(): void {
    process.stdout.write(CLEAR_LINE)
    console.log('')
    for (let i = 0; i < this.steps.length; i++) {
      const step = this.steps[i]
      let icon = '  '
      let color = '\x1b[38;5;240m'
      
      switch (step.status) {
        case 'done':
          icon = '✅'
          color = '\x1b[38;5;208m'
          break
        case 'running':
          icon = '🔄'
          color = '\x1b[38;5;220m'
          break
        case 'error':
          icon = '❌'
          color = '\x1b[38;5;196m'
          break
        case 'pending':
          icon = '⏳'
          break
      }
      
      const indent = i === this.currentStep ? '' : '  '
      console.log(`${indent}${color}${icon} ${step.name}${RESET}`)
    }
    console.log('')
  }
}