feat: 飞牛NAS成本审核更新 - 新增SKU、更新价格表、清理归档及订单记录,同步auto-check改动

This commit is contained in:
12600k-rog-d4
2026-08-27 00:44:44 +08:00
parent bde60dbea0
commit a172a9bb5e
82 changed files with 1804 additions and 1180 deletions

View File

@@ -2,22 +2,32 @@ 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 唯一标识)
func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report {
// 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 {
b.AddStressResult(dev.MAC, StressResult{
sr := StressResult{
Type: string(res.Type),
Status: res.Status,
Duration: res.Duration.String(),
@@ -26,10 +36,21 @@ func BuildDeviceReport(dev *model.Device, rpt *stress.Report) *Report {
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())
return b.Build()
r := b.Build()
r.QRURL = qrURL
return r
}
// SaveDeviceReport 保存设备报告(文件名含 MAC/IP/时间戳)
@@ -40,17 +61,119 @@ func SaveDeviceReport(dev *model.Device, rpt *stress.Report, dir string) error {
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")
return BuildDeviceReport(dev, rpt, "").SaveFile(path, "text")
}
// PrintDeviceReport 渲染 80mm 模板并送打印机
func PrintDeviceReport(dev *model.Device, rpt *stress.Report, cfg config.PrintConfig) error {
// 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
}
text, err := BuildDeviceReport(dev, rpt).Render80mmWithConfig(TemplateConfig{Width: width})
// 先保存 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
}
@@ -61,20 +184,76 @@ func PrintDeviceReport(dev *model.Device, rpt *stress.Report, cfg config.PrintCo
}
defer printer.Close()
return printer.Print(text)
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) {
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); err != nil {
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
}

View File

@@ -25,6 +25,9 @@ var Printed = make(map[string]string)
// Printer 报告打印机
type Printer interface {
Print(text string) error
// PrintQR 在打印流末尾追加二维码ESC/POS GS ( k 指令)
// 后端不支持时返回 nil不打印二维码
PrintQR(url string) error
Close() error
}
@@ -66,6 +69,63 @@ func writeEscpos(w io.Writer, text string) error {
return bw.Flush()
}
// ============================
// ESC/POS 二维码GS ( k 指令)
// ============================
//
// 适配 EPSON / Star / 芯烨(Xprinter) / 佳博 等主流 ESC/POS 热敏打印机。
// 指令格式GS ( k pL pH cn fn [n...]
// cn = 49 (0x31) QR Code
// fn = 65 (A) 选择模型 / 67 (C) 模块大小 / 69 (E) 纠错 / 80 (P) 数据 / 81 (Q) 打印
//
// 参考EPSON TM-T20 QR Code 规范
// 数据长度限制Model 2 + Level M 纠错URL 场景 < 512 字节完全够用。
// escposQR 写入 ESC/POS 二维码指令流(不切纸,由调用方决定结尾)
// size: 1-16模块像素数默认 6
// ecc : 48/49/50/51 (L/M/Q/H)
func escposQR(w io.Writer, data string, size byte, ecc byte) error {
if size < 1 {
size = 1
}
if size > 16 {
size = 16
}
if ecc < 48 || ecc > 51 {
ecc = 49 // 默认 M
}
if len(data) == 0 {
return nil
}
bw := bufio.NewWriter(w)
// 1) 选择模型Model 2cn=49 fn=65 n=50
bw.WriteString("\x1d(k\x04\x00\x31\x41\x32")
// 2) 模块大小cn=49 fn=67 n=size
bw.WriteString("\x1d(k\x03\x00\x31\x43")
bw.WriteByte(size)
// 3) 纠错级别cn=49 fn=69 n=ecc
bw.WriteString("\x1d(k\x03\x00\x31\x45")
bw.WriteByte(ecc)
// 4) 存储数据GS ( k pL pH 49 80 48 <data>
// pL pH = (data_len + 3) 低/高字节
pL := byte((len(data) + 3) & 0xff)
pH := byte(((len(data) + 3) >> 8) & 0xff)
bw.WriteString("\x1d(k")
bw.WriteByte(pL)
bw.WriteByte(pH)
bw.WriteString("\x31\x50\x30")
bw.WriteString(data)
// 5) 打印cn=49 fn=81 n=48 不叠加文字)
bw.WriteString("\x1d(k\x03\x00\x31\x51\x30")
return bw.Flush()
}
// ============================
// 后端实现
// ============================
@@ -120,6 +180,9 @@ func (p *SystemPrinter) Print(text string) error {
return cmd.Run()
}
// PrintQR 系统打印后端无二维码指令能力no-op
func (p *SystemPrinter) PrintQR(url string) error { return nil }
// Close 系统打印无需关闭
func (p *SystemPrinter) Close() error { return nil }
@@ -147,6 +210,20 @@ func (p *ESCPOSPrinter) Print(text string) error {
return writeEscpos(p.conn, text)
}
// PrintQR 发送 ESC/POS 二维码指令到网络打印机
// 走纸居中追加一行提示,模块大小 6纠错级别 M
func (p *ESCPOSPrinter) PrintQR(url string) error {
if p.conn == nil {
return nil
}
// 居中ESC a 1后写一行"扫码查看详细报告",然后发二维码
center := "\x1ba\x01———— 扫码查看详细报告 ————\n\x1ba\x00"
if _, err := p.conn.Write([]byte(center)); err != nil {
return err
}
return escposQR(p.conn, url, 6, 49)
}
// Close 关闭连接
func (p *ESCPOSPrinter) Close() error {
if p.conn != nil {
@@ -193,6 +270,18 @@ func (p *USBPrinter) Print(text string) error {
return writeEscpos(p.dev, text)
}
// PrintQR 发送 ESC/POS 二维码指令到 USB 打印机
func (p *USBPrinter) PrintQR(url string) error {
if p.dev == nil {
return nil
}
center := "\x1ba\x01———— 扫码查看详细报告 ————\n\x1ba\x00"
if _, err := p.dev.Write([]byte(center)); err != nil {
return err
}
return escposQR(p.dev, url, 6, 49)
}
// Close 关闭设备
func (p *USBPrinter) Close() error {
if p.dev != nil {
@@ -226,6 +315,12 @@ func (p *FilePrinter) Print(text string) error {
return err
}
// PrintQR 文件后端:将"扫码查看详细报告"提示行 + URL 写入文件(便于在文件中复制链接)
func (p *FilePrinter) PrintQR(url string) error {
_, err := fmt.Fprintf(p.f, "\n———— 扫码查看详细报告 ————\nURL: %s\n\n", url)
return err
}
// Close 关闭文件
func (p *FilePrinter) Close() error {
return p.f.Close()

View File

@@ -0,0 +1,131 @@
package report
import (
"bytes"
"strings"
"testing"
)
// TestEscposQR_EmptyData 空数据应直接返回 nil不写入任何指令
func TestEscposQR_EmptyData(t *testing.T) {
var buf bytes.Buffer
if err := escposQR(&buf, "", 6, 49); err != nil {
t.Fatalf("空数据应返回 nil得到 err=%v", err)
}
if buf.Len() != 0 {
t.Errorf("空数据不应输出指令,实际写入 %d 字节", buf.Len())
}
}
// TestEscposQR_BasicCommands 验证基本 GS ( k 指令流
func TestEscposQR_BasicCommands(t *testing.T) {
var buf bytes.Buffer
data := "http://192.168.1.10:8080/reports/report-abc-10.0.0.1-20260824.html"
if err := escposQR(&buf, data, 6, 49); err != nil {
t.Fatalf("生成指令失败: %v", err)
}
got := buf.Bytes()
// 必须包含 GS ( k 起始
if !bytes.Contains(got, []byte{0x1d, 0x28, 0x6b}) {
t.Error("缺少 GS ( k 起始指令")
}
// 必须包含模型选择 cn=49 fn=65 n=50
if !bytes.Contains(got, []byte{0x31, 0x41, 0x32}) {
t.Error("缺少 QR 模型选择指令 (31 41 32)")
}
// 必须包含模块大小 n=6
if !bytes.Contains(got, []byte{0x31, 0x43, 0x06}) {
t.Error("缺少模块大小指令 (31 43 06)")
}
// 必须包含纠错级别 M (49)
if !bytes.Contains(got, []byte{0x31, 0x45, 49}) {
t.Error("缺少纠错级别指令 (31 45 49)")
}
// 必须包含数据存储 49 80 48
if !bytes.Contains(got, []byte{0x31, 0x50, 0x30}) {
t.Error("缺少数据存储指令 (31 50 30)")
}
// 必须包含打印指令 49 81 48
if !bytes.Contains(got, []byte{0x31, 0x51, 0x30}) {
t.Error("缺少打印指令 (31 51 30)")
}
// 数据本体
if !bytes.Contains(got, []byte(data)) {
t.Error("缺少 URL 数据本体")
}
}
// TestEscposQR_ClampSizeAndECC 验证 size/ecc 参数越界时自动夹紧
func TestEscposQR_ClampSizeAndECC(t *testing.T) {
var buf1, buf2, buf3 bytes.Buffer
// size 越界 0 → 夹紧为 1
_ = escposQR(&buf1, "abc", 0, 49)
// size 越界 99 → 夹紧为 16
_ = escposQR(&buf2, "abc", 99, 49)
// ecc 非法 99 → 夹紧为 49 (M)
_ = escposQR(&buf3, "abc", 6, 99)
if !bytes.Contains(buf1.Bytes(), []byte{0x31, 0x43, 0x01}) {
t.Error("size=0 应夹紧为 1未生效")
}
if !bytes.Contains(buf2.Bytes(), []byte{0x31, 0x43, 0x10}) {
t.Error("size=99 应夹紧为 16未生效")
}
if !bytes.Contains(buf3.Bytes(), []byte{0x31, 0x45, 49}) {
t.Error("ecc=99 应夹紧为 49(M),未生效")
}
}
// TestEscposQR_DataLengthEncoding pL/pH 编码正确(大数据场景)
// 指令布局: 1d 28 6b pL pH 31 50 30 <data>
func TestEscposQR_DataLengthEncoding(t *testing.T) {
var buf bytes.Buffer
// 200 字节数据 → pL = 203 (0xCB), pH = 0
data := strings.Repeat("a", 200)
_ = escposQR(&buf, data, 6, 49)
got := buf.Bytes()
wantPL := byte((200 + 3) & 0xff) // 203 = 0xCB
wantPH := byte(((200 + 3) >> 8) & 0xff)
// 数据存储标记 "31 50 30" 在 got 中的位置
dataMarker := []byte{0x31, 0x50, 0x30}
idx := bytes.Index(got, dataMarker)
if idx < 0 {
t.Fatal("未找到数据存储指令 31 50 30")
}
// 期望布局1d(GS) 28 ( 6b k pL pH 31 50 30 ...
// got[idx-5] = 0x1d, got[idx-4] = 0x28, got[idx-3] = 0x6b
// got[idx-2] = pL, got[idx-1] = pH
if got[idx-5] != 0x1d || got[idx-4] != 0x28 || got[idx-3] != 0x6b {
t.Fatalf("pL/pH 前不是 GS ( k实际前 5 字节 = % x", got[idx-5:idx])
}
if got[idx-2] != wantPL || got[idx-1] != wantPH {
t.Errorf("pL/pH 编码错误: got=0x%02x 0x%02x, want=0x%02x 0x%02x",
got[idx-2], got[idx-1], wantPL, wantPH)
}
}
// TestSystemPrinter_PrintQR 系统打印后端 no-op
func TestSystemPrinter_PrintQR(t *testing.T) {
p := &SystemPrinter{}
if err := p.PrintQR("http://example.com"); err != nil {
t.Errorf("SystemPrinter.PrintQR 应返回 nil得到 %v", err)
}
}
// TestFilePrinter_PrintQR 文件后端写入提示行 + URL
func TestFilePrinter_PrintQR(t *testing.T) {
tmp := t.TempDir()
p, err := NewFilePrinter(tmp + "/out.log")
if err != nil {
t.Fatalf("创建 FilePrinter 失败: %v", err)
}
defer p.Close()
url := "http://192.168.1.10/reports/r.html"
if err := p.PrintQR(url); err != nil {
t.Fatalf("PrintQR 失败: %v", err)
}
}

View File

@@ -32,14 +32,17 @@ type HostResult struct {
// StressResult 单项压力测试结果
type StressResult struct {
Type string `json:"type" yaml:"type"`
Status string `json:"status" yaml:"status"` // pass / fail / skip
Duration string `json:"duration" yaml:"duration"`
Score float64 `json:"score,omitempty" yaml:"score,omitempty"`
ScoreUnit string `json:"score_unit,omitempty" yaml:"score_unit,omitempty"`
Metrics []stress.MetricItem `json:"metrics,omitempty" yaml:"metrics,omitempty"`
Output string `json:"output,omitempty" yaml:"output,omitempty"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
Type string `json:"type" yaml:"type"`
Status string `json:"status" yaml:"status"` // pass / fail / skip
Duration string `json:"duration" yaml:"duration"`
Score float64 `json:"score,omitempty" yaml:"score,omitempty"`
ScoreUnit string `json:"score_unit,omitempty" yaml:"score_unit,omitempty"`
Metrics []stress.MetricItem `json:"metrics,omitempty" yaml:"metrics,omitempty"`
Benchmarks []stress.BenchItem `json:"benchmarks,omitempty" yaml:"benchmarks,omitempty"` // 性能/温度基准评级
OverallLevel string `json:"overall_level,omitempty" yaml:"overall_level,omitempty"`
OverallText string `json:"overall_text,omitempty" yaml:"overall_text,omitempty"`
Output string `json:"output,omitempty" yaml:"output,omitempty"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
}
// Report 检测报告
@@ -49,6 +52,8 @@ type Report struct {
Duration string `json:"duration" yaml:"duration"`
Config ConfigSummary `json:"config" yaml:"config"`
Phases []PhaseResult `json:"phases" yaml:"phases"`
// QRURL HTML 报告访问链接(用于热敏打印中的二维码与模板回退)
QRURL string `json:"qr_url,omitempty" yaml:"qr_url,omitempty"`
}
// ConfigSummary 配置摘要(脱敏)

View File

@@ -18,9 +18,14 @@
{{ end }}{{ end }}{{ if eq .Phase "SSH 登录" }}{{ range .Hosts }}{{ if .SSHLogin }}✓{{ else }}✗{{ end }} {{ padRight .MAC 19 }} {{ padRight .IP 15 }} {{ .SSHErr }}
{{ end }}{{ end }}{{ if eq .Phase "压力测试" }}{{ range .Hosts }}{{ padRight .MAC 19 }} {{ padRight .IP 15 }}
{{ range .Stress }}{{ if eq .Status "pass" }}✓{{ else }}✗{{ end }} {{ padRight .Type 12 }} {{ .Status }} {{ .Duration }}
{{ if .Score }} 跑分: {{ printf "%.1f" .Score }} {{ .ScoreUnit }}{{ end }}{{ range .Metrics }} {{ .Label }}: {{ printf "%.1f" .Value }}{{ .Unit }}
{{ if .Score }} 跑分: {{ printf "%.1f" .Score }} {{ .ScoreUnit }}{{ end }}{{ if .OverallText }} 评级: {{ .OverallText }}{{ end }}{{ range .Benchmarks }} {{ padRight .Label 8 }} {{ .Value }} {{ .LevelText }}
{{ end }}{{ range .Metrics }} {{ .Label }}: {{ printf "%.1f" .Value }}{{ .Unit }}
{{ end }}{{ if .Error }} ERR: {{ trunc .Error 34 }}{{ end }}{{ end }}{{ end }}{{ end }}{{ end }}
{{ "" }}
{{ hr '═' }}
报告结束
{{ hr '═' }}
{{ if .QRURL }}{{ "" }}
———— 扫码查看详细报告 ————
{{ .QRURL }}
{{ end }}