package report import ( "fmt" "net/url" "os" "path/filepath" "strings" "time" "github.com/skip2/go-qrcode" "auto-check/pkg/config" "auto-check/pkg/model" "auto-check/pkg/stress" ) // BuildDeviceReport 由压测结果构建设备报告(MAC 唯一标识) // qrURL 非空时会附加到报告,供 80mm 模板在末尾显示"扫码查看详细报告"提示与 URL 文本兜底 func BuildDeviceReport(dev *model.Device, rpt *stress.Report, qrURL string) *Report { // 从 sysInfo 提取 CPU 型号用于按硬件库匹配基准 cpuModel := "" if rpt.SysInfo != nil { cpuModel = rpt.SysInfo["cpu"] } b := NewBuilder(ConfigSummary{}) b.StartPhase("压力测试") b.AddHost(HostResult{MAC: dev.MAC, IP: dev.IP}) for _, res := range rpt.Results { sr := StressResult{ Type: string(res.Type), Status: res.Status, Duration: res.Duration.String(), Score: res.Score, ScoreUnit: stress.ScoreUnit(res.Type), Metrics: res.OrderedMetrics(), Error: res.Error, Output: res.Output, } // 基准评级(让用户对结果有感性认知;按硬件库匹配专属阈值,未匹配全部"达到正常") if items := stress.EvaluateResult(res, cpuModel); len(items) > 0 { sr.Benchmarks = items if lvl := stress.OverallLevel(items); lvl != "" { sr.OverallLevel = string(lvl) sr.OverallText = stress.LevelMeta(lvl).Text } } b.AddStressResult(dev.MAC, sr) } b.EndPhase(rpt.Summary()) r := b.Build() r.QRURL = qrURL return r } // SaveDeviceReport 保存设备报告(文件名含 MAC/IP/时间戳) func SaveDeviceReport(dev *model.Device, rpt *stress.Report, dir string) error { if dir == "" { dir = "reports" } ts := time.Now().Format("20060102-150405") name := strings.ReplaceAll(dev.MAC, ":", "") path := filepath.Join(dir, fmt.Sprintf("report-%s-%s-%s.txt", name, dev.IP, ts)) return BuildDeviceReport(dev, rpt, "").SaveFile(path, "text") } // SaveDeviceQR 保存设备报告对应的二维码 PNG(与 txt/html 同目录) // url 为空时不生成;返回 PNG 完整路径,失败时返回错误 func SaveDeviceQR(dev *model.Device, url string, dir string) (string, error) { if url == "" { return "", nil } if dir == "" { dir = "reports" } if err := os.MkdirAll(dir, 0755); err != nil { return "", err } ts := time.Now().Format("20060102-150405") mac := strings.ReplaceAll(dev.MAC, ":", "") filename := fmt.Sprintf("report-%s-%s-%s.qr.png", mac, dev.IP, ts) path := filepath.Join(dir, filename) // 512 字节足够;高纠错级别 H 提升扫码容错 png, err := qrcode.New(url, qrcode.High) if err != nil { return "", err } // 512x512 PNG;二维码扫描距离更友好 if err := png.WriteFile(512, path); err != nil { return "", err } return path, nil } // SaveDeviceHTMLReport 保存设备 HTML 报告(文件名按 config.HTML.Filename 模板生成) // 返回: (filename, fullPath, error) func SaveDeviceHTMLReport(dev *model.Device, rpt *stress.Report, dir string, htmlCfg config.HTMLConfig) (string, string, error) { dir = htmlDir(dir, htmlCfg) ts := time.Now().Format("20060102-150405") mac := strings.ReplaceAll(dev.MAC, ":", "") filename := renderHTMLFilename(htmlCfg.Filename, mac, dev.IP, ts) if filename == "" { filename = fmt.Sprintf("report-%s-%s-%s.html", mac, dev.IP, ts) } path, err := stress.WriteReportHTMLTo(dev.IP, rpt, dir, filename) if err != nil { return "", "", err } return filename, path, nil } // BuildDeviceReportURL 构建设备 HTML 报告的访问 URL(用于二维码) // 优先级:qr_url 模板 > base_url + filename > 空字符串 // 占位符:{filename} {mac} {ip} {ts} func BuildDeviceReportURL(dev *model.Device, filename string, htmlCfg config.HTMLConfig) string { mac := strings.ReplaceAll(dev.MAC, ":", "") ts := time.Now().Format("20060102-150405") if tpl := strings.TrimSpace(htmlCfg.QRURL); tpl != "" { return fillPlaceholders(tpl, map[string]string{ "filename": filename, "mac": mac, "ip": dev.IP, "ts": ts, }) } base := strings.TrimRight(strings.TrimSpace(htmlCfg.BaseURL), "/") if base == "" || filename == "" { return "" } return base + "/" + filename } // saveDeviceHTMLAndQR 保存 HTML 报告 + 二维码 PNG(一体化),返回 (filename, qrPath, error) // - HTML 失败:仅返回错误,不生成 QR // - QR 失败:只打日志,不影响 HTML(用户至少能用 URL 文本兜底) func saveDeviceHTMLAndQR(dev *model.Device, rpt *stress.Report, dir string, htmlCfg config.HTMLConfig) (string, string, error) { filename, _, err := SaveDeviceHTMLReport(dev, rpt, dir, htmlCfg) if err != nil { return "", "", err } url := BuildDeviceReportURL(dev, filename, htmlCfg) if url == "" && filename != "" { // 兜底:base_url 未配置时,至少打印本地路径(便于手工打开) url = filename } qrPath, qerr := SaveDeviceQR(dev, url, dir) if qerr != nil { fmt.Printf(" [报告] %s 二维码 PNG 生成失败: %v\n", dev.IP, qerr) } return filename, qrPath, nil } // PrintDeviceReport 渲染 80mm 模板并送打印机,启用 HTML 时自动追加二维码 func PrintDeviceReport(dev *model.Device, rpt *stress.Report, cfg config.PrintConfig, htmlCfg config.HTMLConfig) error { width := cfg.Width if width <= 0 { width = 42 } // 先保存 HTML + 二维码 PNG,并计算二维码 URL filename, qrPath, herr := saveDeviceHTMLAndQR(dev, rpt, "", htmlCfg) if herr != nil { fmt.Printf(" [报告] %s HTML 保存失败: %v\n", dev.IP, herr) } url := BuildDeviceReportURL(dev, filename, htmlCfg) if url == "" && filename != "" { // 兜底:base_url 未配置时,至少打印本地路径(便于手工打开) url = filename } if qrPath != "" { fmt.Printf(" [报告] %s 二维码 PNG: %s\n", dev.IP, qrPath) } text, err := BuildDeviceReport(dev, rpt, url).Render80mmWithConfig(TemplateConfig{Width: width}) if err != nil { return err } printer, err := NewPrinter(config.PrintConfig{Enabled: true, Backend: cfg.Backend, Device: cfg.Device, Width: width}) if err != nil { return err } defer printer.Close() if err := printer.Print(text); err != nil { return err } // 追加二维码(HTML 报告访问链接);后端不支持或无 URL 时 PrintQR 内部静默跳过 if printer != nil && url != "" { if qerr := printer.PrintQR(url); qerr != nil { fmt.Printf(" [打印] %s 二维码失败: %v\n", dev.IP, qerr) } } return nil } // SaveAndPrintDeviceReport 保存报告并(启用时)打印,含错误输出(一站式业务入口) func SaveAndPrintDeviceReport(dev *model.Device, rpt *stress.Report, dir string, printCfg config.PrintConfig, htmlCfg config.HTMLConfig) { if err := SaveDeviceReport(dev, rpt, dir); err != nil { fmt.Printf(" [报告] %s 保存失败: %v\n", dev.IP, err) } // 总是保存 HTML + 二维码 PNG(便于不打印时也能扫码查看详细报告) _, qrPath, herr := saveDeviceHTMLAndQR(dev, rpt, dir, htmlCfg) if herr != nil { fmt.Printf(" [报告] %s HTML 保存失败: %v\n", dev.IP, herr) } else if qrPath != "" { fmt.Printf(" [报告] %s 二维码 PNG: %s\n", dev.IP, qrPath) } if printCfg.Enabled { if err := PrintDeviceReport(dev, rpt, printCfg, htmlCfg); err != nil { fmt.Printf(" [打印] %s 失败: %v\n", dev.IP, err) } else { fmt.Printf(" [打印] %s 已送出 (%s)\n", dev.IP, printCfg.Backend) } } } // ============================ // 辅助 // ============================ // htmlDir 解析 HTML 报告目录 func htmlDir(defaultDir string, cfg config.HTMLConfig) string { if p := strings.TrimSpace(cfg.Path); p != "" { return p } if defaultDir == "" { return "reports" } return defaultDir } // renderHTMLFilename 渲染文件名模板,缺省时返回默认 func renderHTMLFilename(tpl, mac, ip, ts string) string { tpl = strings.TrimSpace(tpl) if tpl == "" { return "" } return fillPlaceholders(tpl, map[string]string{ "filename": "", // 文件名模板内不应再嵌 filename "mac": mac, "ip": ip, "ts": ts, }) } // fillPlaceholders 简单占位符替换 {key} func fillPlaceholders(s string, kv map[string]string) string { for k, v := range kv { s = strings.ReplaceAll(s, "{"+k+"}", url.PathEscape(v)) } return s }