📄 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('')
}
}