diff --git a/internal/command/album.go b/internal/command/album.go index 5c0089a..2eb71f4 100644 --- a/internal/command/album.go +++ b/internal/command/album.go @@ -25,7 +25,6 @@ import ( "github.com/tickstep/aliyunpan/internal/functions/pandownload" "github.com/tickstep/aliyunpan/internal/global" "github.com/tickstep/aliyunpan/internal/taskframework" - "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -281,6 +280,8 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { } } + fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + var ( panClient = activeUser.PanClient() ) @@ -299,29 +300,10 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} - var dashboard *ui.DownloadDashboard - if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { - dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ - Title: "AliyunPan CLI - 下载中心", - }) - } - logf := func(format string, a ...interface{}) { - if dashboard != nil { - dashboard.Logf(format, a...) - return - } - fmt.Printf(format, a...) - } - if dashboard != nil { - dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } else { - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } - // 处理队列 allShareAlbumList, err := activeUser.PanClient().OpenapiPanClient().ShareAlbumListGetAll() if err != nil { - logf("获取相簿列表失败: %s\n", err) + fmt.Printf("获取相簿列表失败: %s\n", err) return } for k := range albumNames { @@ -341,11 +323,11 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { Limit: 100, }) if er != nil { - logf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) + fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) continue } if fileList == nil || len(fileList) == 0 { - logf("相簿里面没有文件: %s\n", albumNames[k]) + fmt.Printf("相簿里面没有文件: %s\n", albumNames[k]) continue } @@ -368,7 +350,7 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { }) if apierr != nil { logger.Verbosef("ERROR: get album file download url error: %s\n", f.FileId) - logf("下载照片失败: %s\n", f.FileName) + fmt.Printf("\n下载照片失败: %s\n", f.FileName) continue } if durl.StreamsUrl != nil { // 实况图片(照片+视频)下载链接 @@ -422,7 +404,6 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { DriveId: f.DriveId, // 一个相簿的文件会来自多个网盘(资源库/备份盘) GlobalSpeedsStat: globalSpeedsStat, FileRecorder: nil, - UI: dashboard, } // 设置相簿文件信息 @@ -438,10 +419,7 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) - if dashboard != nil { - dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile()) - } - logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) + fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } @@ -449,13 +427,7 @@ func RunShareAlbumDownloadFile(albumNames []string, options *DownloadOptions) { statistic.StartTimer() // 开始执行 - if dashboard != nil { - dashboard.Start() - } executor.Execute() - if dashboard != nil { - dashboard.Close() - } fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) diff --git a/internal/command/album_web.go b/internal/command/album_web.go index cf599d7..81c4e15 100644 --- a/internal/command/album_web.go +++ b/internal/command/album_web.go @@ -26,7 +26,6 @@ import ( "github.com/tickstep/aliyunpan/internal/functions/pandownload" "github.com/tickstep/aliyunpan/internal/functions/panlogin" "github.com/tickstep/aliyunpan/internal/taskframework" - "github.com/tickstep/aliyunpan/internal/ui" "github.com/tickstep/aliyunpan/internal/utils" "github.com/tickstep/aliyunpan/library/requester/transfer" "github.com/tickstep/library-go/converter" @@ -689,6 +688,8 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { } } + fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + var ( panClient = activeUser.PanClient() ) @@ -706,25 +707,6 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} - var dashboard *ui.DownloadDashboard - if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { - dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ - Title: "AliyunPan CLI - 下载中心", - }) - } - logf := func(format string, a ...interface{}) { - if dashboard != nil { - dashboard.Logf(format, a...) - return - } - fmt.Printf(format, a...) - } - if dashboard != nil { - dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } else { - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 下载缓存为: %s\n\n", options.Parallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } - // 处理队列 for k := range albumNames { record := getAlbumFromName(activeUser, albumNames[k]) @@ -736,11 +718,11 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { AlbumId: record.AlbumId, }) if er != nil { - logf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) + fmt.Printf("获取相簿文件出错,请稍后重试: %s\n", albumNames[k]) continue } if fileList == nil || len(fileList) == 0 { - logf("相簿里面没有文件: %s\n", albumNames[k]) + fmt.Printf("相簿里面没有文件: %s\n", albumNames[k]) continue } for _, f := range fileList { @@ -764,7 +746,6 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { DriveId: f.DriveId, // 必须使用文件的DriveId,因为一个相簿的文件会来自多个网盘(资源库/备份盘) GlobalSpeedsStat: globalSpeedsStat, FileRecorder: nil, - UI: dashboard, } // TODO: 相册下载需要重构 @@ -781,10 +762,7 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { unit.SavePath = GetActiveUser().GetSavePath(f.Path) } info := executor.Append(&unit, options.MaxRetry) - if dashboard != nil { - dashboard.RegisterTask(info.Id(), f.Path, f.FileSize, f.IsFile()) - } - logf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) + fmt.Printf("[%s] 加入下载队列: %s\n", info.Id(), f.Path) } } @@ -792,13 +770,7 @@ func RunAlbumDownloadFile(albumNames []string, options *DownloadOptions) { statistic.StartTimer() // 开始执行 - if dashboard != nil { - dashboard.Start() - } executor.Execute() - if dashboard != nil { - dashboard.Close() - } fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) diff --git a/internal/command/download.go b/internal/command/download.go index 939a296..5098d9e 100644 --- a/internal/command/download.go +++ b/internal/command/download.go @@ -55,6 +55,7 @@ type ( DriveId string ExcludeNames []string // 排除的文件名,包括文件夹和文件。即这些文件/文件夹不进行下载,支持正则表达式 IsMultiUserDownload bool // 是否启用多用户联合下载 + IsUseUIDashboard bool // 是否使用UI下载面板显示下载进度 } // LocateDownloadOption 获取下载链接可选参数 @@ -149,22 +150,10 @@ func CmdDownload() cli.Command { DriveId: parseDriveId(c), ExcludeNames: c.StringSlice("exn"), IsMultiUserDownload: c.Bool("md"), + IsUseUIDashboard: c.Bool("ui"), } - // 获取下载文件锁,保证下载操作单实例 - //locker := filelocker.NewFileLocker(config.GetLockerDir() + "/aliyunpan-download") - //if e := filelocker.LockFile(locker, 0755, true, 5*time.Second); e != nil { - // logger.Verboseln(e) - // fmt.Println("本应用其他实例正在执行下载,请先停止或者等待其完成") - // return nil - //} - RunDownload(c.Args(), do) - - // 释放文件锁 - //if locker != nil { - // filelocker.UnlockFile(locker) - //} return nil }, Flags: []cli.Flag{ @@ -225,6 +214,10 @@ func CmdDownload() cli.Command { Name: "md", Usage: "(BETA) Multi-User Download,使用多用户联合下载,可以对单一文件叠加所有登录用户的下载速度", }, + cli.BoolFlag{ + Name: "ui", + Usage: "(BETA) 使用UI面板显示下载详情和进度,更加直观和友好", + }, }, } } @@ -327,8 +320,6 @@ func RunDownload(paths []string, options *DownloadOptions) { // 多用户下载的辅助账号列表 var subPanClientList []*config.PanClient - var multiUserNotice string - var multiUserErrorNotice string if options.IsMultiUserDownload { // 多用户下载 c := config.Config for _, u := range config.Config.UserList { @@ -352,19 +343,20 @@ func RunDownload(paths []string, options *DownloadOptions) { } if subPanClientList == nil || len(subPanClientList) == 0 { - multiUserErrorNotice = "当前登录用户只有一个,无法启用多用户联合下载" + fmt.Printf("\n当前登录用户只有一个,无法启用多用户联合下载\n") subPanClientList = nil } } if subPanClientList != nil || len(subPanClientList) > 0 { // 已启用多用户下载 userCount := len(subPanClientList) + 1 - multiUserNotice = fmt.Sprintf("*** 已启用多用户联合下载,用户数: %d ***", userCount) + fmt.Printf("\n*** 已启用多用户联合下载,用户数: %d ***\n", userCount) // 多用户下载,并发数必须为1,以获得最大下载速度 options.Parallel = 1 // 阿里OpenAPI规定:文件分片下载的并发数为3,即某用户使用 App 时,可以同时下载 1 个文件的 3 个分片,或者同时下载 3 个文件的各 1 个分片。 options.SliceParallel = userCount * 3 } + var ( panClient = activeUser.PanClient() ) @@ -383,10 +375,13 @@ func RunDownload(paths []string, options *DownloadOptions) { // 全局速度统计 globalSpeedsStat := &speeds.Speeds{} - var dashboard *ui.DownloadDashboard - if options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { + // 下载统计UI面板 + var dashboard *ui.DownloadDashboard = nil + if options.IsUseUIDashboard && options.ShowProgress && !options.IsPrintStatus && ui.IsTerminal(os.Stdout) { dashboard = ui.NewDownloadDashboard(cfg.MaxParallel, globalSpeedsStat, &ui.DownloadDashboardOptions{ - Title: "AliyunPan CLI - 下载中心", + Title: "下载统计UI面板", + ActiveSlots: 3, // 下载文件进度展示,最大同时展示3个 + MaxHistory: 50, // 下载日志显示,最多同时显示50条,这个会按照窗口大小进行自适应显示 }) } logf := func(format string, a ...interface{}) { @@ -396,29 +391,13 @@ func RunDownload(paths []string, options *DownloadOptions) { } fmt.Printf(format, a...) } - if multiUserErrorNotice != "" { - if dashboard != nil { - dashboard.Logf("%s", multiUserErrorNotice) - } else { - fmt.Printf("\n%s\n", multiUserErrorNotice) - } - } - if multiUserNotice != "" { - if dashboard != nil { - dashboard.Logf("%s", multiUserNotice) - } else { - fmt.Printf("\n%s\n", multiUserNotice) - } - } - if dashboard != nil { - dashboard.Logf("[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } else { - fmt.Printf("\n[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s\n", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) - } // 下载记录器 fileRecorder := log.NewFileRecorder(config.GetLogDir() + "/download_file_records.csv") + // 输出当前下载配置信息 + logf("\n[0] 当前文件下载最大并发量为: %d, 单文件下载分片线程数为: %d, 下载缓存为: %s\n", options.Parallel, options.SliceParallel, converter.ConvertFileSize(int64(cfg.CacheSize), 2)) + // 处理队列 for k := range paths { // 使用通配符匹配 @@ -488,15 +467,20 @@ func RunDownload(paths []string, options *DownloadOptions) { // 开始计时 statistic.StartTimer() - // 开始执行 + // 启动UI面板显示 if dashboard != nil { dashboard.Start() } + + // 开始执行 executor.Execute() + + // 关闭UI面板 if dashboard != nil { dashboard.Close() } + // 完成下载,输出统计结果 fmt.Printf("\n下载结束, 时间: %s, 数据总量: %s\n", utils.ConvertTime(statistic.Elapsed()), converter.ConvertFileSize(statistic.TotalSize(), 2)) // 输出失败的文件列表 diff --git a/internal/functions/pandownload/download_task_unit.go b/internal/functions/pandownload/download_task_unit.go index 4e839b5..ba9138f 100644 --- a/internal/functions/pandownload/download_task_unit.go +++ b/internal/functions/pandownload/download_task_unit.go @@ -76,8 +76,7 @@ type ( // 下载文件记录器 FileRecorder *log.FileRecorder - UI *ui.DownloadDashboard - skipReason string + UI *ui.DownloadDashboard // 下载统计面板UI } ) @@ -114,15 +113,20 @@ func (dtu *DownloadTaskUnit) verboseInfof(format string, a ...interface{}) { } } +// logf 打印文本日志 func (dtu *DownloadTaskUnit) logf(format string, a ...interface{}) { if dtu.UI != nil { + // 使用UI面板显示下载日志 dtu.UI.Logf(format, a...) return } + + // 使用传统单行文本显示下载日志,直接输出到控制台 fmt.Printf(format, a...) } -func (dtu *DownloadTaskUnit) setUIState(state ui.TaskState, message string) { +// updateUITaskState 更新UI下载面板的任务状态 +func (dtu *DownloadTaskUnit) updateUITaskState(state ui.TaskState, message string) { if dtu.UI == nil { return } @@ -208,10 +212,14 @@ func (dtu *DownloadTaskUnit) download() (err error) { // 这里用共享变量的方式 isComplete := false der.OnDownloadStatusEvent(func(status transfer.DownloadStatuser, workersCallback func(downloader.RangeWorkerFunc)) { + // 方式1:使用UI统计面板显示下载进度 if dtu.UI != nil { + // 更新UI面板任务状态 dtu.UI.UpdateTaskProgress(dtu.taskInfo.Id(), status.Downloaded(), status.TotalSize(), status.SpeedsPerSecond(), status.TimeLeft()) return } + + // 方式2:使用传统单行显示下载进度 // 这里可能会下载结束了, 还会输出内容 builder := &strings.Builder{} if dtu.IsPrintStatus { @@ -304,9 +312,7 @@ func (dtu *DownloadTaskUnit) download() (err error) { dtu.logf("[%s] 警告, 加执行权限错误: %s\n", dtu.taskInfo.Id(), err) } } - if dtu.UI == nil { - fmt.Printf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath) - } + dtu.logf("\n[%s] 下载完成, 保存位置: %s\n", dtu.taskInfo.Id(), dtu.SavePath) return nil } @@ -346,9 +352,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes if dtu.fileInfo.FileSize >= 128*converter.MB { // 大文件, 输出一句提示消息 - if dtu.UI == nil { - fmt.Printf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id()) - } + dtu.logf("[%s] 开始检验文件有效性, 请稍候...\n", dtu.taskInfo.Id()) } // 就在这里处理校验出错 @@ -361,9 +365,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes // 文件不支持校验 result.ResultMessage = "检验文件有效性" result.Err = err - if dtu.UI == nil { - fmt.Printf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err) - } + dtu.logf("[%s] 检验文件有效性: %s\n", dtu.taskInfo.Id(), err) return true case ErrDownloadFileBanned: // 违规文件 @@ -381,9 +383,7 @@ func (dtu *DownloadTaskUnit) checkFileValid(result *taskframework.TaskUnitRunRes } } - if dtu.UI == nil { - fmt.Printf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath) - } + dtu.logf("[%s] 检验文件有效性成功: %s\n", dtu.taskInfo.Id(), dtu.SavePath) return true } @@ -400,11 +400,9 @@ func (dtu *DownloadTaskUnit) OnRetry(lastRunResult *taskframework.TaskUnitRunRes func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunResult) { // 执行插件 dtu.pluginCallback("success") - if dtu.skipReason != "" { - dtu.setUIState(ui.TaskSkipped, dtu.skipReason) - } else { - dtu.setUIState(ui.TaskSuccess, "") - } + + // 更新UI面板任务状态 + dtu.updateUITaskState(ui.TaskSuccess, "") // 下载文件数据记录 if config.Config.FileRecordConfig == "1" { @@ -424,11 +422,13 @@ func (dtu *DownloadTaskUnit) OnSuccess(lastRunResult *taskframework.TaskUnitRunR func (dtu *DownloadTaskUnit) OnFailed(lastRunResult *taskframework.TaskUnitRunResult) { // 失败 dtu.pluginCallback("fail") + + // 更新UI面板 failedMessage := lastRunResult.ResultMessage if lastRunResult.Err != nil { failedMessage = fmt.Sprintf("%s, %s", lastRunResult.ResultMessage, lastRunResult.Err) } - dtu.setUIState(ui.TaskFailed, failedMessage) + dtu.updateUITaskState(ui.TaskFailed, failedMessage) // 失败 if lastRunResult.Err == nil { @@ -469,7 +469,7 @@ func (dtu *DownloadTaskUnit) OnComplete(lastRunResult *taskframework.TaskUnitRun } func (dtu *DownloadTaskUnit) OnCancel(lastRunResult *taskframework.TaskUnitRunResult) { - dtu.setUIState(ui.TaskCanceled, "取消: "+dtu.FilePanPath) + dtu.updateUITaskState(ui.TaskCanceled, "取消: "+dtu.FilePanPath) } func (dtu *DownloadTaskUnit) RetryWait() time.Duration { @@ -478,7 +478,6 @@ func (dtu *DownloadTaskUnit) RetryWait() time.Duration { func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { result = &taskframework.TaskUnitRunResult{} - dtu.setUIState(ui.TaskRunning, "") // 获取文件信息 var apierr *apierror.ApiError if dtu.fileInfo == nil || dtu.taskInfo.Retry() > 0 { @@ -495,15 +494,10 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { } time.Sleep(1 * time.Second) } - if dtu.UI != nil && dtu.fileInfo != nil { - dtu.UI.UpdateTaskInfo(dtu.taskInfo.Id(), dtu.fileInfo.FileName, dtu.fileInfo.FileSize, dtu.fileInfo.IsFile()) - } // 输出文件信息 - if dtu.UI == nil { - fmt.Print("\n") - fmt.Printf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String()) - } + dtu.logf("\n") + dtu.logf("[%s] ----\n%s\n", dtu.taskInfo.Id(), dtu.fileInfo.String()) // 调用插件 ft := "file" @@ -529,7 +523,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { if strings.Compare("yes", downloadFilePrepareResult.DownloadApproved) != 0 { // skip download this file dtu.logf("插件取消了该文件下载: %s\n", dtu.fileInfo.Path) - dtu.setUIState(ui.TaskCanceled, "取消: "+dtu.fileInfo.Path) + dtu.updateUITaskState(ui.TaskCanceled, "插件取消了该文件下载: "+dtu.fileInfo.Path) result.Succeed = false result.Cancel = true return @@ -628,14 +622,14 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { subUnit.FilePanSource = dtu.FilePanSource subUnit.FilePanPath = fileList[k].Path subUnit.SavePath = filepath.Join(dtu.OriginSaveRootPath, fileList[k].Path) // 保存位置 - subUnit.skipReason = "" // 加入父队列,按照队列调度进行下载 info := dtu.ParentTaskExecutor.Append(&subUnit, dtu.taskInfo.MaxRetry()) + dtu.logf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path) + // UI面板注册任务 if dtu.UI != nil { dtu.UI.RegisterTask(info.Id(), fileList[k].Path, fileList[k].FileSize, fileList[k].IsFile()) } - dtu.logf("[%s] 加入下载队列: %s\n", info.Id(), fileList[k].Path) } // 本下载任务执行成功 @@ -652,8 +646,7 @@ func (dtu *DownloadTaskUnit) Run() (result *taskframework.TaskUnitRunResult) { //} // 支持符号文件,逻辑和注释代码一致 if !dtu.IsOverwrite && SymlinkFileExist(dtu.SavePath, dtu.OriginSaveRootPath) { - dtu.skipReason = "文件已存在,跳过: " + dtu.SavePath - dtu.setUIState(ui.TaskSkipped, dtu.skipReason) + dtu.updateUITaskState(ui.TaskSkipped, "文件已存在,跳过: "+dtu.SavePath) dtu.logf("[%s] 文件已经存在: %s, 跳过...\n", dtu.taskInfo.Id(), dtu.SavePath) result.Succeed = true // 执行成功 return diff --git a/internal/ui/download_dashboard.go b/internal/ui/download_dashboard.go index 9cfca37..017bb76 100644 --- a/internal/ui/download_dashboard.go +++ b/internal/ui/download_dashboard.go @@ -1,4 +1,4 @@ -// Copyright (c) 2026. +// Copyright (c) 2020 tickstep. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -44,6 +44,7 @@ type DownloadDashboardOptions struct { Output io.Writer } +// DownloadDashboard 下载任务统计面板使用了很多ANSI转义序列。并不是所有的终端都支持ANSI转义序列,所以需要做兼容性处理。 type DownloadDashboard struct { mu sync.Mutex tasks map[string]*downloadTask @@ -74,9 +75,9 @@ type downloadTask struct { state TaskState message string isFile bool - hasType bool } +// NewDownloadDashboard 创建下载任务统计面板 func NewDownloadDashboard(parallel int, globalSpeeds *speeds.Speeds, opts *DownloadDashboardOptions) *DownloadDashboard { if parallel < 1 { parallel = 1 @@ -89,7 +90,7 @@ func NewDownloadDashboard(parallel int, globalSpeeds *speeds.Speeds, opts *Downl parallel: parallel, activeSlots: 5, globalSpeeds: globalSpeeds, - title: "AliyunPan Download", + title: "aliyunpan下载统计", renderEvery: 500 * time.Millisecond, stopCh: make(chan struct{}), doneCh: make(chan struct{}), @@ -115,6 +116,7 @@ func NewDownloadDashboard(parallel int, globalSpeeds *speeds.Speeds, opts *Downl return db } +// Start 启动面板 func (db *DownloadDashboard) Start() { if db == nil { return @@ -122,7 +124,7 @@ func (db *DownloadDashboard) Start() { db.startOnce.Do(func() { db.startTime = time.Now() enableVirtualTerminalProcessing() - fmt.Fprint(db.out, "\x1b[?25l") + fmt.Fprint(db.out, "\x1b[?25l") // 隐藏光标 db.render() ticker := time.NewTicker(db.renderEvery) go func() { @@ -131,6 +133,9 @@ func (db *DownloadDashboard) Start() { for { select { case <-db.stopCh: + // 延迟1秒 + time.Sleep(1 * time.Second) + // 渲染 db.render() return case <-ticker.C: @@ -141,6 +146,7 @@ func (db *DownloadDashboard) Start() { }) } +// Close 关闭面板 func (db *DownloadDashboard) Close() { if db == nil { return @@ -148,10 +154,11 @@ func (db *DownloadDashboard) Close() { db.closeOnce.Do(func() { close(db.stopCh) <-db.doneCh - fmt.Fprint(db.out, "\x1b[?25h") + fmt.Fprint(db.out, "\x1b[?25h") // 显示光标 }) } +// RegisterTask 注册任务信息 func (db *DownloadDashboard) RegisterTask(id, path string, total int64, isFile bool) { if db == nil || id == "" { return @@ -168,10 +175,10 @@ func (db *DownloadDashboard) RegisterTask(id, path string, total int64, isFile b if total > 0 { task.total = total } - task.hasType = true task.isFile = isFile } +// UpdateTaskInfo 更新任务信息 func (db *DownloadDashboard) UpdateTaskInfo(id, name string, total int64, isFile bool) { if db == nil || id == "" { return @@ -185,10 +192,10 @@ func (db *DownloadDashboard) UpdateTaskInfo(id, name string, total int64, isFile if total > 0 { task.total = total } - task.hasType = true task.isFile = isFile } +// UpdateTaskProgress 更新任务进度 func (db *DownloadDashboard) UpdateTaskProgress(id string, downloaded, total, speed int64, eta time.Duration) { if db == nil || id == "" { return @@ -204,6 +211,7 @@ func (db *DownloadDashboard) UpdateTaskProgress(id string, downloaded, total, sp task.eta = eta } +// MarkTaskState 标记任务状态 func (db *DownloadDashboard) MarkTaskState(id string, state TaskState, message string) { if db == nil || id == "" { return @@ -221,6 +229,7 @@ func (db *DownloadDashboard) MarkTaskState(id string, state TaskState, message s } } +// Logf 增加日志 func (db *DownloadDashboard) Logf(format string, a ...interface{}) { if db == nil { return @@ -230,6 +239,10 @@ func (db *DownloadDashboard) Logf(format string, a ...interface{}) { if msg == "" { return } + // 去掉开头的换行符 + msg = strings.TrimPrefix(msg, "\n") + // 去掉回车换行符,UI面板的日志只能单行显示 + msg = strings.ReplaceAll(msg, "\n", " ") db.mu.Lock() defer db.mu.Unlock() db.appendHistoryLocked(msg) @@ -281,6 +294,7 @@ func (db *DownloadDashboard) defaultHistoryMessage(task *downloadTask, message s } } +// render 渲染面板 func (db *DownloadDashboard) render() { if db == nil { return @@ -292,13 +306,17 @@ func (db *DownloadDashboard) render() { innerWidth := width - 2 snapshot := db.snapshot() + // 统计总览 headerLines := db.headerLines(snapshot, innerWidth) - activeLines := db.activeLines(snapshot, innerWidth) - activeTitle := db.activeTitle(snapshot) + // 下载任务队列详情 + //activeTitle := db.activeTitle(snapshot) + activeLines := db.activeLines(snapshot, innerWidth) + + // 下载日志 headerHeight := len(headerLines) + 2 - activeHeight := len(activeLines) + 4 - historyBase := 4 + activeHeight := len(activeLines) + 1 + historyBase := 3 minHistory := 1 minHeight := headerHeight + activeHeight + historyBase + minHistory if height < minHeight { @@ -310,12 +328,11 @@ func (db *DownloadDashboard) render() { } historyBody := db.historyLines(snapshot, innerWidth, historyLines) + // 拼接并格式化输出 builder := &strings.Builder{} - builder.WriteString("\x1b[H\x1b[2J") + builder.WriteString("\x1b[H\x1b[2J") // 清屏操作 builder.WriteString(renderBox(headerLines, width)) - builder.WriteString("\n") - builder.WriteString(renderTitledBox(activeTitle, activeLines, width)) - builder.WriteString("\n") + builder.WriteString(renderTitledBox("ActiveDownloads", activeLines, width)) builder.WriteString(renderTitledBox("History", historyBody, width)) fmt.Fprint(db.out, builder.String()) @@ -351,14 +368,16 @@ func renderBox(lines []string, width int) string { builder.WriteString("\u2510") for _, line := range lines { builder.WriteString("\n") - builder.WriteString("\u2502") + builder.WriteString("\u2502") // 左边框 builder.WriteString(fitLine(line, innerWidth)) - builder.WriteString("\u2502") + builder.WriteString("\u2502") // 右边框 } builder.WriteString("\n") - builder.WriteString("\u2514") + builder.WriteString("\u2502") // 左边框 + //builder.WriteString("\u2514") // 左下角 builder.WriteString(strings.Repeat("\u2500", innerWidth)) - builder.WriteString("\u2518") + builder.WriteString("\u2502") // 右边框 + //builder.WriteString("\u2518") // 右下角 return builder.String() } @@ -368,25 +387,26 @@ func renderTitledBox(title string, body []string, width int) string { } innerWidth := width - 2 builder := &strings.Builder{} - builder.WriteString("\u250c") - builder.WriteString(strings.Repeat("\u2500", innerWidth)) - builder.WriteString("\u2510\n") - builder.WriteString("\u2502") - builder.WriteString(fitLine(title, innerWidth)) - builder.WriteString("\u2502\n") - builder.WriteString("\u251c") - builder.WriteString(strings.Repeat("\u2500", innerWidth)) - builder.WriteString("\u2524") + for _, line := range body { builder.WriteString("\n") - builder.WriteString("\u2502") - builder.WriteString(fitLine(line, innerWidth)) - builder.WriteString("\u2502") + builder.WriteString("\u2502") // 左边框 + builder.WriteString(fitLine(line, innerWidth)) // 内容,使用空格补足宽度 + builder.WriteString("\u2502") // 右边框 } + builder.WriteString("\n") - builder.WriteString("\u2514") - builder.WriteString(strings.Repeat("\u2500", innerWidth)) - builder.WriteString("\u2518") + if strings.HasPrefix(title, "History") { + builder.WriteString("\u2514") // 左下角 + } else { + builder.WriteString("\u2502") // 左边框 + } + builder.WriteString(strings.Repeat("\u2500", innerWidth)) // 表格横线 + if strings.HasPrefix(title, "History") { + builder.WriteString("\u2518") // 右下角 + } else { + builder.WriteString("\u2502") // 右边框 + } return builder.String() } @@ -400,46 +420,43 @@ func (db *DownloadDashboard) headerLines(snapshot dashboardSnapshot, innerWidth left = formatDurationShort(time.Duration((total-downloaded)/speed) * time.Second) } - status := "Running" + status := "下载中" if totalFiles > 0 && doneFiles >= totalFiles { if failedFiles > 0 { - status = "Completed (errors)" + status = "已完成 (有错误)" } else { - status = "Completed" + status = "已完成" } } - headerLine := fmt.Sprintf("%s [%s]", db.title, status) - line1 := fitLine(headerLine, innerWidth) - speedStr := converter.ConvertFileSize(speed, 2) + "/s" - line2 := fmt.Sprintf("Total Speed: %s | Files: %d/%d | Failed: %d | Elapsed: %s | Left: %s", - speedStr, doneFiles, totalFiles, failedFiles, formatDurationShort(elapsed), left) - line2 = fitLine(line2, innerWidth) + line1 := fmt.Sprintf("总速度: %s | 文件: %d/%d | 失败: %d | 已用时间: %s | 剩余时间: %s | 状态: %s", + speedStr, doneFiles, totalFiles, failedFiles, formatDurationShort(elapsed), left, status) + line1 = fitLine(line1, innerWidth) progressPct := 0.0 if total > 0 { progressPct = float64(downloaded) / float64(total) } progress := fmt.Sprintf("%s/%s", converter.ConvertFileSize(downloaded, 2), converter.ConvertFileSize(total, 2)) - label := "Total Progress: " + label := "总进度: " suffix := fmt.Sprintf(" %5.1f%% (%s)", progressPct*100, progress) barWidth := innerWidth - displayWidth(label) - displayWidth(suffix) - 2 if barWidth < 10 { barWidth = 10 } bar := renderBar(progressPct, barWidth) - line3 := fmt.Sprintf("%s[%s]%s", label, bar, suffix) - line3 = fitLine(line3, innerWidth) + line2 := fmt.Sprintf("%s[%s]%s", label, bar, suffix) + line2 = fitLine(line2, innerWidth) - return []string{line1, line2, line3} + return []string{line1, line2} } func (db *DownloadDashboard) activeTitle(snapshot dashboardSnapshot) string { running := 0 queued := 0 for _, task := range snapshot.tasks { - if task.hasType && !task.isFile { + if !task.isFile { continue } switch task.state { @@ -460,7 +477,7 @@ func (db *DownloadDashboard) activeLines(snapshot dashboardSnapshot, innerWidth lines = append(lines, renderTaskLine(active[i], i, innerWidth)) continue } - placeholder := &downloadTask{name: "waiting...", state: TaskQueued} + placeholder := &downloadTask{name: "[空闲...]", state: TaskQueued} lines = append(lines, renderTaskLine(placeholder, i, innerWidth)) } return lines @@ -488,6 +505,15 @@ func (db *DownloadDashboard) historyLines(snapshot dashboardSnapshot, innerWidth if len(result) == 0 { result = append(result, truncateText("No recent events.", innerWidth)) } + + // 倒序数组 + reversedResult := make([]string, 0, len(result)) + for i := len(result) - 1; i >= 0; i-- { + reversedResult = append(reversedResult, result[i]) + } + result = reversedResult + + // 补全空行 for len(result) < lines { result = append(result, "") } @@ -496,7 +522,7 @@ func (db *DownloadDashboard) historyLines(snapshot dashboardSnapshot, innerWidth func (db *DownloadDashboard) fileCounts(tasks []*downloadTask) (total int, done int, failed int) { for _, task := range tasks { - if task.hasType && !task.isFile { + if !task.isFile { continue } total++ @@ -513,7 +539,7 @@ func (db *DownloadDashboard) fileCounts(tasks []*downloadTask) (total int, done func (db *DownloadDashboard) totalProgress(tasks []*downloadTask) (downloaded int64, total int64) { for _, task := range tasks { - if task.hasType && !task.isFile { + if !task.isFile { continue } if task.total > 0 { @@ -547,7 +573,7 @@ func (db *DownloadDashboard) pickActiveTasks(tasks []*downloadTask) []*downloadT active := make([]*downloadTask, 0, slotCap) queued := make([]*downloadTask, 0, slotCap) for _, task := range tasks { - if task.hasType && !task.isFile { + if !task.isFile { continue } switch task.state { @@ -574,6 +600,7 @@ func renderTaskLine(task *downloadTask, index int, width int) string { name = baseName(task.path) } + // 百分比 progressPct := 0.0 percent := "--.-%" if task.total > 0 { @@ -581,11 +608,13 @@ func renderTaskLine(task *downloadTask, index int, width int) string { percent = fmt.Sprintf("%5.1f%%", math.Min(100, progressPct*100)) } + // 速度 rate := "-" + // 剩余时间 eta := "-" switch task.state { case TaskQueued: - rate = "waiting" + rate = "等待中" case TaskRunning: if task.speed > 0 { rate = converter.ConvertFileSize(task.speed, 2) + "/s" @@ -643,6 +672,7 @@ func renderTaskLine(task *downloadTask, index int, width int) string { return fitLine(line, width) } +// renderBar 绘制进度条 func renderBar(pct float64, width int) string { if width < 5 { width = 5 @@ -654,7 +684,11 @@ func renderBar(pct float64, width int) string { pct = 1 } filled := int(math.Floor(pct * float64(width))) - return strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", width-filled) + // 已经下载的部分使用 => 表示,没有下载的部分使用 空格 表示 + if filled >= 1 { + return strings.Repeat("=", filled-1) + ">" + strings.Repeat(" ", width-filled) + } + return strings.Repeat(" ", width-filled) } func formatDurationShort(d time.Duration) string { diff --git a/internal/ui/terminal.go b/internal/ui/terminal.go index e85b1ff..4b0ce7e 100644 --- a/internal/ui/terminal.go +++ b/internal/ui/terminal.go @@ -1,4 +1,4 @@ -// Copyright (c) 2026. +// Copyright (c) 2020 tickstep. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ package ui import "os" +// IsTerminal 判断输出环境,是否是输出到终端界面,如果重定向到文件则不用进行下载统计打印输出了 func IsTerminal(file *os.File) bool { if file == nil { return false diff --git a/internal/ui/terminal_unix.go b/internal/ui/terminal_unix.go index 2d05855..45c2744 100644 --- a/internal/ui/terminal_unix.go +++ b/internal/ui/terminal_unix.go @@ -1,6 +1,6 @@ //go:build !windows -// Copyright (c) 2026. +// Copyright (c) 2020 tickstep. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. diff --git a/internal/ui/terminal_windows.go b/internal/ui/terminal_windows.go index e8fd03f..f68551f 100644 --- a/internal/ui/terminal_windows.go +++ b/internal/ui/terminal_windows.go @@ -1,6 +1,6 @@ //go:build windows -// Copyright (c) 2026. +// Copyright (c) 2020 tickstep. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License.