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
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ require (
|
|||||||
github.com/fumiama/imgsz v0.0.2 // indirect
|
github.com/fumiama/imgsz v0.0.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
|
github.com/go-pdf/fpdf v0.9.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||||
|
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ func SetupRouter() *gin.Engine {
|
|||||||
admin.POST("/system-debug/mqtt/stop", systemDebugController.StopMqtt)
|
admin.POST("/system-debug/mqtt/stop", systemDebugController.StopMqtt)
|
||||||
|
|
||||||
admin.GET("/statistics/ai-analysis-records", statisticsController.ListAIAnalysisRecords)
|
admin.GET("/statistics/ai-analysis-records", statisticsController.ListAIAnalysisRecords)
|
||||||
|
admin.GET("/statistics/ai-analysis-records/:id/pdf", statisticsController.DownloadAIAnalysisRecordPDF)
|
||||||
admin.DELETE("/statistics/ai-analysis-records/:id", statisticsController.DeleteAIAnalysisRecord)
|
admin.DELETE("/statistics/ai-analysis-records/:id", statisticsController.DeleteAIAnalysisRecord)
|
||||||
admin.GET("/statistics/ai-analysis", statisticsController.StatisticsByRegion)
|
admin.GET("/statistics/ai-analysis", statisticsController.StatisticsByRegion)
|
||||||
admin.GET("/statistics/ai-analysis-timeline", statisticsController.TimelineStatistics)
|
admin.GET("/statistics/ai-analysis-timeline", statisticsController.TimelineStatistics)
|
||||||
|
|||||||
Reference in New Issue
Block a user