feat: pdf.
This commit is contained in:
@@ -0,0 +1,657 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"hr_receiver/models"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-pdf/fpdf"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var orderedListPattern = regexp.MustCompile(`^\d+\.\s+`)
|
||||
|
||||
func (sc *StatisticsController) DownloadAIAnalysisRecordPDF(c *gin.Context) {
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
if id == "" {
|
||||
writeError(c, http.StatusBadRequest, "id is required")
|
||||
return
|
||||
}
|
||||
|
||||
var record models.AIAnalysisRecord
|
||||
if err := sc.DB.First(&record, id).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
writeError(c, http.StatusNotFound, "record not found")
|
||||
return
|
||||
}
|
||||
writeError(c, http.StatusInternalServerError, "failed to query record")
|
||||
return
|
||||
}
|
||||
|
||||
kindergartenName := ""
|
||||
if record.RegionID != nil && *record.RegionID > 0 {
|
||||
var kindergarten models.Kindergarten
|
||||
if err := sc.DB.Where("region_id = ?", *record.RegionID).First(&kindergarten).Error; err == nil {
|
||||
kindergartenName = kindergarten.Name
|
||||
}
|
||||
}
|
||||
|
||||
fileBytes, err := buildAIAnalysisRecordPDF(record, kindergartenName)
|
||||
if err != nil {
|
||||
writeError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("analysis-result-%d.pdf", record.ID)
|
||||
c.Header("Content-Type", "application/pdf")
|
||||
c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, fileName))
|
||||
c.Data(http.StatusOK, "application/pdf", fileBytes)
|
||||
}
|
||||
|
||||
func buildAIAnalysisRecordPDF(record models.AIAnalysisRecord, kindergartenName string) ([]byte, error) {
|
||||
normalFontPath, boldFontPath, err := resolveAnalysisPDFFontPaths()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pdf := fpdf.New("P", "mm", "A4", "")
|
||||
pdf.SetMargins(12, 12, 12)
|
||||
pdf.SetAutoPageBreak(true, 12)
|
||||
pdf.AddUTF8Font("SimHei", "", normalFontPath)
|
||||
pdf.AddUTF8Font("SimHei", "B", boldFontPath)
|
||||
pdf.SetFont("SimHei", "", 14)
|
||||
pdf.AddPage()
|
||||
|
||||
renderer := &analysisPDFRenderer{pdf: pdf}
|
||||
renderer.addTitle(fmt.Sprintf("AI 分析结果 #%d", record.ID))
|
||||
renderer.addMetaTable([][2]string{
|
||||
{"幼儿园", valueOrDash(kindergartenName)},
|
||||
{"Region ID", formatNullableRegionID(record.RegionID)},
|
||||
{"来源", translateSourceType(record.SourceType)},
|
||||
{"分析类型", translateAnalysisType(record.AnalysisType)},
|
||||
{"输入 Token", strconv.Itoa(record.InputTokens)},
|
||||
{"输出 Token", strconv.Itoa(record.OutputTokens)},
|
||||
{"输入大小", formatBytesForPDF(int64(record.InputSizeBytes))},
|
||||
{"输出大小", formatBytesForPDF(int64(record.OutputSizeBytes))},
|
||||
{"耗时", formatDurationForPDF(record.DurationMs)},
|
||||
{"原始文件", formatBytesForPDF(record.OriginalFileSize)},
|
||||
{"压缩后", formatBytesForPDF(record.CompressedContentSize)},
|
||||
{"上传时间", formatDateTime(record.UploadTime)},
|
||||
{"总花费", formatCostForPDF(record.TotalCost)},
|
||||
})
|
||||
renderer.addSectionTitle("分析内容")
|
||||
renderer.renderMarkdown(record.AnalysisResult)
|
||||
|
||||
var buffer bytes.Buffer
|
||||
if err := pdf.Output(&buffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
func resolveAnalysisPDFFontPaths() (string, string, error) {
|
||||
normalCandidates := []string{
|
||||
filepath.Join("..", "hr-receiver-web", "apps", "web-ele", "src", "assets", "fonts", "simhei.ttf"),
|
||||
filepath.Join("assets", "fonts", "simhei.ttf"),
|
||||
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simhei.ttf"),
|
||||
`C:\Windows\Fonts\simhei.ttf`,
|
||||
}
|
||||
boldCandidates := []string{
|
||||
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simsunb.ttf"),
|
||||
`C:\Windows\Fonts\simsunb.ttf`,
|
||||
filepath.Join(os.Getenv("WINDIR"), "Fonts", "simhei.ttf"),
|
||||
`C:\Windows\Fonts\simhei.ttf`,
|
||||
}
|
||||
|
||||
normalPath := firstExistingPath(normalCandidates)
|
||||
if normalPath == "" {
|
||||
return "", "", fmt.Errorf("failed to locate chinese font for pdf export")
|
||||
}
|
||||
boldPath := firstExistingPath(boldCandidates)
|
||||
if boldPath == "" {
|
||||
boldPath = normalPath
|
||||
}
|
||||
return normalPath, boldPath, nil
|
||||
}
|
||||
|
||||
func firstExistingPath(candidates []string) string {
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type analysisPDFRenderer struct {
|
||||
pdf *fpdf.Fpdf
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addTitle(title string) {
|
||||
r.pdf.SetFont("SimHei", "", 18)
|
||||
r.pdf.CellFormat(0, 10, title, "", 1, "L", false, 0, "")
|
||||
r.pdf.Ln(2)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addSectionTitle(title string) {
|
||||
r.ensureSpace(8)
|
||||
r.pdf.SetFont("SimHei", "", 14)
|
||||
r.pdf.CellFormat(0, 8, title, "", 1, "L", false, 0, "")
|
||||
r.pdf.Ln(1)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addParagraph(text string) {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
if trimmed == "" {
|
||||
return
|
||||
}
|
||||
r.writeStyledText(trimmed, 11, 0, "")
|
||||
r.pdf.Ln(1)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addBullet(text string) {
|
||||
r.writeStyledText(strings.TrimSpace(text), 11, 5, "• ")
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addMetaTable(rows [][2]string) {
|
||||
leftWidth := 34.0
|
||||
rightWidth := 152.0
|
||||
r.pdf.SetFont("SimHei", "", 11)
|
||||
for _, row := range rows {
|
||||
r.ensureSpace(8)
|
||||
r.pdf.SetFillColor(241, 245, 249)
|
||||
r.pdf.CellFormat(leftWidth, 8, row[0], "1", 0, "L", true, 0, "")
|
||||
r.pdf.CellFormat(rightWidth, 8, row[1], "1", 1, "L", false, 0, "")
|
||||
}
|
||||
r.pdf.Ln(4)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) addTable(headers []string, rows [][]string) {
|
||||
if len(headers) == 0 {
|
||||
return
|
||||
}
|
||||
pageWidth, _ := r.pdf.GetPageSize()
|
||||
left, _, right, _ := r.pdf.GetMargins()
|
||||
tableWidth := pageWidth - left - right
|
||||
colWidths := r.calculateTableColumnWidths(headers, rows, tableWidth)
|
||||
lineHeight := 5.0
|
||||
|
||||
r.pdf.SetFont("SimHei", "", 10)
|
||||
r.pdf.SetFillColor(241, 245, 249)
|
||||
for index, header := range headers {
|
||||
r.pdf.CellFormat(colWidths[index], 8, stripMarkdownMarkers(header), "1", 0, "L", true, 0, "")
|
||||
}
|
||||
r.pdf.Ln(-1)
|
||||
|
||||
for _, row := range rows {
|
||||
normalizedRow := make([]string, len(headers))
|
||||
copy(normalizedRow, row)
|
||||
for len(normalizedRow) < len(headers) {
|
||||
normalizedRow = append(normalizedRow, "")
|
||||
}
|
||||
|
||||
splitCells := make([][]string, len(headers))
|
||||
maxLines := 1
|
||||
for index := range headers {
|
||||
cellText := stripMarkdownMarkers(normalizedRow[index])
|
||||
lines := r.pdf.SplitText(cellText, colWidths[index]-2)
|
||||
if len(lines) == 0 {
|
||||
lines = []string{""}
|
||||
}
|
||||
splitCells[index] = lines
|
||||
if len(lines) > maxLines {
|
||||
maxLines = len(lines)
|
||||
}
|
||||
}
|
||||
|
||||
rowHeight := float64(maxLines)*lineHeight + 2
|
||||
r.ensureSpace(rowHeight)
|
||||
x := left
|
||||
y := r.pdf.GetY()
|
||||
for index := range headers {
|
||||
width := colWidths[index]
|
||||
r.pdf.Rect(x, y, width, rowHeight, "")
|
||||
r.pdf.SetXY(x+1, y+1)
|
||||
r.pdf.MultiCell(width-2, lineHeight, strings.Join(splitCells[index], "\n"), "", "L", false)
|
||||
x += width
|
||||
if index < len(headers)-1 {
|
||||
r.pdf.SetXY(x, y)
|
||||
}
|
||||
}
|
||||
r.pdf.SetXY(left, y+rowHeight)
|
||||
}
|
||||
r.pdf.Ln(2)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) calculateTableColumnWidths(headers []string, rows [][]string, totalWidth float64) []float64 {
|
||||
widths := make([]float64, len(headers))
|
||||
minWidth := 22.0
|
||||
maxWidth := totalWidth * 0.42
|
||||
totalMeasured := 0.0
|
||||
|
||||
r.pdf.SetFont("SimHei", "", 10)
|
||||
for index, header := range headers {
|
||||
measuredWidth := r.pdf.GetStringWidth(stripMarkdownMarkers(header)) + 8
|
||||
for _, row := range rows {
|
||||
if index >= len(row) {
|
||||
continue
|
||||
}
|
||||
cellWidth := r.pdf.GetStringWidth(stripMarkdownMarkers(row[index])) + 8
|
||||
if cellWidth > measuredWidth {
|
||||
measuredWidth = cellWidth
|
||||
}
|
||||
}
|
||||
measuredWidth = clampFloat(measuredWidth, minWidth, maxWidth)
|
||||
if measuredWidth < minWidth {
|
||||
measuredWidth = minWidth
|
||||
}
|
||||
widths[index] = measuredWidth
|
||||
totalMeasured += measuredWidth
|
||||
}
|
||||
|
||||
if totalMeasured <= totalWidth {
|
||||
extra := (totalWidth - totalMeasured) / float64(len(widths))
|
||||
for index := range widths {
|
||||
widths[index] += extra
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
shrinkCapacity := 0.0
|
||||
for _, width := range widths {
|
||||
shrinkCapacity += width - minWidth
|
||||
}
|
||||
overflow := totalMeasured - totalWidth
|
||||
if shrinkCapacity > 0 {
|
||||
for index := range widths {
|
||||
reducible := widths[index] - minWidth
|
||||
if reducible <= 0 {
|
||||
continue
|
||||
}
|
||||
widths[index] -= overflow * (reducible / shrinkCapacity)
|
||||
if widths[index] < minWidth {
|
||||
widths[index] = minWidth
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentTotal := 0.0
|
||||
for _, width := range widths {
|
||||
currentTotal += width
|
||||
}
|
||||
if currentTotal > totalWidth {
|
||||
widths[len(widths)-1] -= currentTotal - totalWidth
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) renderMarkdown(markdown string) {
|
||||
lines := strings.Split(strings.ReplaceAll(markdown, "\r\n", "\n"), "\n")
|
||||
for index := 0; index < len(lines); {
|
||||
line := strings.TrimSpace(lines[index])
|
||||
if line == "" {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if headers, rows, consumed, ok := parseMarkdownTable(lines[index:]); ok {
|
||||
r.addTable(headers, rows)
|
||||
index += consumed
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "#") {
|
||||
depth := countHeadingDepth(line)
|
||||
title := strings.TrimSpace(line[depth:])
|
||||
r.pdf.SetFont("SimHei", "", headingFontSize(depth))
|
||||
r.pdf.MultiCell(0, 7, stripMarkdownMarkers(title), "", "L", false)
|
||||
r.pdf.Ln(1)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "- ") || strings.HasPrefix(line, "* ") {
|
||||
r.addBullet(strings.TrimSpace(line[2:]))
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if orderedListPattern.MatchString(line) {
|
||||
r.addParagraph(line)
|
||||
index++
|
||||
continue
|
||||
}
|
||||
|
||||
paragraphLines := []string{line}
|
||||
index++
|
||||
for index < len(lines) {
|
||||
nextLine := strings.TrimSpace(lines[index])
|
||||
if nextLine == "" || strings.HasPrefix(nextLine, "#") || strings.HasPrefix(nextLine, "- ") || strings.HasPrefix(nextLine, "* ") || orderedListPattern.MatchString(nextLine) {
|
||||
break
|
||||
}
|
||||
if _, _, _, ok := parseMarkdownTable(lines[index:]); ok {
|
||||
break
|
||||
}
|
||||
paragraphLines = append(paragraphLines, nextLine)
|
||||
index++
|
||||
}
|
||||
r.addParagraph(strings.Join(paragraphLines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) ensureSpace(height float64) {
|
||||
_, pageHeight := r.pdf.GetPageSize()
|
||||
_, _, _, bottom := r.pdf.GetMargins()
|
||||
if r.pdf.GetY()+height <= pageHeight-bottom {
|
||||
return
|
||||
}
|
||||
r.pdf.AddPage()
|
||||
r.pdf.SetFont("SimHei", "", 11)
|
||||
}
|
||||
|
||||
func (r *analysisPDFRenderer) writeStyledText(text string, fontSize float64, indent float64, prefix string) {
|
||||
baseLeft, _, rightMargin, _ := r.pdf.GetMargins()
|
||||
contentLeft := baseLeft + indent
|
||||
lineHeight := 6.0
|
||||
|
||||
r.ensureSpace(lineHeight)
|
||||
r.pdf.SetLeftMargin(contentLeft)
|
||||
r.pdf.SetRightMargin(rightMargin)
|
||||
r.pdf.SetXY(contentLeft, r.pdf.GetY())
|
||||
r.pdf.SetFont("SimHei", "", fontSize)
|
||||
if prefix != "" {
|
||||
r.pdf.Write(lineHeight, prefix)
|
||||
}
|
||||
for _, segment := range parseStyledSegments(text) {
|
||||
style := ""
|
||||
if segment.Bold {
|
||||
style = "B"
|
||||
}
|
||||
r.pdf.SetFont("SimHei", style, fontSize)
|
||||
r.pdf.Write(lineHeight, segment.Text)
|
||||
}
|
||||
r.pdf.Ln(lineHeight)
|
||||
r.pdf.SetLeftMargin(baseLeft)
|
||||
r.pdf.SetRightMargin(rightMargin)
|
||||
}
|
||||
|
||||
type styledTextSegment struct {
|
||||
Text string
|
||||
Bold bool
|
||||
}
|
||||
|
||||
func parseStyledSegments(text string) []styledTextSegment {
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return []styledTextSegment{{Text: "", Bold: false}}
|
||||
}
|
||||
|
||||
segments := make([]styledTextSegment, 0, 4)
|
||||
for len(text) > 0 {
|
||||
marker, start := findNextBoldMarker(text)
|
||||
if start < 0 {
|
||||
segments = append(segments, styledTextSegment{
|
||||
Text: stripMarkdownMarkers(text),
|
||||
Bold: false,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
if start > 0 {
|
||||
segments = append(segments, styledTextSegment{
|
||||
Text: stripMarkdownMarkers(text[:start]),
|
||||
Bold: false,
|
||||
})
|
||||
}
|
||||
|
||||
contentStart := start + len(marker)
|
||||
endOffset := strings.Index(text[contentStart:], marker)
|
||||
if endOffset < 0 {
|
||||
segments = append(segments, styledTextSegment{
|
||||
Text: stripMarkdownMarkers(text[start:]),
|
||||
Bold: false,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
boldText := text[contentStart : contentStart+endOffset]
|
||||
if boldText != "" {
|
||||
segments = append(segments, styledTextSegment{
|
||||
Text: stripMarkdownMarkers(boldText),
|
||||
Bold: true,
|
||||
})
|
||||
}
|
||||
text = text[contentStart+endOffset+len(marker):]
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
return []styledTextSegment{{Text: "", Bold: false}}
|
||||
}
|
||||
return mergeAdjacentStyledSegments(segments)
|
||||
}
|
||||
|
||||
func findNextBoldMarker(text string) (string, int) {
|
||||
doubleStar := strings.Index(text, "**")
|
||||
doubleUnderscore := strings.Index(text, "__")
|
||||
switch {
|
||||
case doubleStar >= 0 && (doubleUnderscore < 0 || doubleStar < doubleUnderscore):
|
||||
return "**", doubleStar
|
||||
case doubleUnderscore >= 0:
|
||||
return "__", doubleUnderscore
|
||||
default:
|
||||
return "", -1
|
||||
}
|
||||
}
|
||||
|
||||
func mergeAdjacentStyledSegments(segments []styledTextSegment) []styledTextSegment {
|
||||
merged := make([]styledTextSegment, 0, len(segments))
|
||||
for _, segment := range segments {
|
||||
if segment.Text == "" {
|
||||
continue
|
||||
}
|
||||
if len(merged) > 0 && merged[len(merged)-1].Bold == segment.Bold {
|
||||
merged[len(merged)-1].Text += segment.Text
|
||||
continue
|
||||
}
|
||||
merged = append(merged, segment)
|
||||
}
|
||||
if len(merged) == 0 {
|
||||
return []styledTextSegment{{Text: "", Bold: false}}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func parseMarkdownTable(lines []string) ([]string, [][]string, int, bool) {
|
||||
if len(lines) < 2 {
|
||||
return nil, nil, 0, false
|
||||
}
|
||||
headerLine := strings.TrimSpace(lines[0])
|
||||
separatorLine := strings.TrimSpace(lines[1])
|
||||
if !strings.Contains(headerLine, "|") || !isMarkdownTableSeparator(separatorLine) {
|
||||
return nil, nil, 0, false
|
||||
}
|
||||
|
||||
headers := splitMarkdownTableRow(headerLine)
|
||||
if len(headers) == 0 {
|
||||
return nil, nil, 0, false
|
||||
}
|
||||
|
||||
rows := make([][]string, 0)
|
||||
consumed := 2
|
||||
for consumed < len(lines) {
|
||||
line := strings.TrimSpace(lines[consumed])
|
||||
if line == "" || !strings.Contains(line, "|") {
|
||||
break
|
||||
}
|
||||
row := splitMarkdownTableRow(line)
|
||||
if len(row) == 0 {
|
||||
break
|
||||
}
|
||||
for len(row) < len(headers) {
|
||||
row = append(row, "")
|
||||
}
|
||||
if len(row) > len(headers) {
|
||||
row = row[:len(headers)]
|
||||
}
|
||||
rows = append(rows, row)
|
||||
consumed++
|
||||
}
|
||||
return headers, rows, consumed, true
|
||||
}
|
||||
|
||||
func splitMarkdownTableRow(line string) []string {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
trimmed = strings.TrimPrefix(trimmed, "|")
|
||||
trimmed = strings.TrimSuffix(trimmed, "|")
|
||||
parts := strings.Split(trimmed, "|")
|
||||
cells := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
cells = append(cells, strings.TrimSpace(part))
|
||||
}
|
||||
return cells
|
||||
}
|
||||
|
||||
func isMarkdownTableSeparator(line string) bool {
|
||||
if !strings.Contains(line, "|") {
|
||||
return false
|
||||
}
|
||||
for _, char := range line {
|
||||
if char == '|' || char == '-' || char == ':' || char == ' ' || char == '\t' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func countHeadingDepth(line string) int {
|
||||
depth := 0
|
||||
for depth < len(line) && line[depth] == '#' {
|
||||
depth++
|
||||
}
|
||||
if depth == 0 {
|
||||
return 1
|
||||
}
|
||||
return depth
|
||||
}
|
||||
|
||||
func headingFontSize(depth int) float64 {
|
||||
switch depth {
|
||||
case 1:
|
||||
return 16
|
||||
case 2:
|
||||
return 14
|
||||
case 3:
|
||||
return 13
|
||||
default:
|
||||
return 12
|
||||
}
|
||||
}
|
||||
|
||||
func stripMarkdownMarkers(value string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
"**", "",
|
||||
"__", "",
|
||||
"`", "",
|
||||
)
|
||||
return replacer.Replace(value)
|
||||
}
|
||||
|
||||
func clampFloat(value float64, min float64, max float64) float64 {
|
||||
if value < min {
|
||||
return min
|
||||
}
|
||||
if value > max {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func translateSourceType(sourceType string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(sourceType)) {
|
||||
case "upload":
|
||||
return "上传文件"
|
||||
case "cloud":
|
||||
return "云端文件"
|
||||
default:
|
||||
if sourceType == "" {
|
||||
return "-"
|
||||
}
|
||||
return sourceType
|
||||
}
|
||||
}
|
||||
|
||||
func translateAnalysisType(analysisType string) string {
|
||||
switch strings.TrimSpace(strings.ToLower(analysisType)) {
|
||||
case "heart_rate_only":
|
||||
return "仅心率"
|
||||
case "heart_rate_with_steps":
|
||||
return "心率+步数"
|
||||
default:
|
||||
if analysisType == "" {
|
||||
return "-"
|
||||
}
|
||||
return analysisType
|
||||
}
|
||||
}
|
||||
|
||||
func formatNullableRegionID(regionID *uint32) string {
|
||||
if regionID == nil || *regionID == 0 {
|
||||
return "-"
|
||||
}
|
||||
return strconv.FormatUint(uint64(*regionID), 10)
|
||||
}
|
||||
|
||||
func formatBytesForPDF(value int64) string {
|
||||
if value <= 0 {
|
||||
return "0 B"
|
||||
}
|
||||
units := []string{"B", "KB", "MB", "GB"}
|
||||
size := float64(value)
|
||||
unitIndex := 0
|
||||
for size >= 1024 && unitIndex < len(units)-1 {
|
||||
size /= 1024
|
||||
unitIndex++
|
||||
}
|
||||
if size >= 10 || unitIndex == 0 {
|
||||
return fmt.Sprintf("%.0f %s", size, units[unitIndex])
|
||||
}
|
||||
return fmt.Sprintf("%.1f %s", size, units[unitIndex])
|
||||
}
|
||||
|
||||
func formatDurationForPDF(value int64) string {
|
||||
if value <= 0 {
|
||||
return "-"
|
||||
}
|
||||
if value < 1000 {
|
||||
return fmt.Sprintf("%d ms", value)
|
||||
}
|
||||
return fmt.Sprintf("%.1f s", float64(value)/1000)
|
||||
}
|
||||
|
||||
func formatCostForPDF(value float64) string {
|
||||
if value == 0 {
|
||||
return "0.00 元"
|
||||
}
|
||||
return fmt.Sprintf("%.6f 元", value)
|
||||
}
|
||||
|
||||
func formatDateTime(value int64) string {
|
||||
if value <= 0 {
|
||||
return "-"
|
||||
}
|
||||
return time.UnixMilli(value).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
func valueOrDash(value string) string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
Reference in New Issue
Block a user