598 lines
17 KiB
Go
598 lines
17 KiB
Go
package utils
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"unicode"
|
||
|
||
"github.com/phpdave11/gofpdf"
|
||
"github.com/signintech/gopdf"
|
||
"go.uber.org/zap"
|
||
)
|
||
|
||
// cleanTextForPDF 清理文本,移除PDF不支持的字符(如emoji)
|
||
// gofpdf库不支持某些特殊字符
|
||
func cleanTextForPDF(text string) string {
|
||
var result []rune
|
||
for _, r := range text {
|
||
// 保留基本多文种平面(BMP)内的字符(码点 <= 0xFFFF)
|
||
// 这样可以保留中文、英文、数字等常用字符,但过滤掉emoji等特殊字符
|
||
if r <= 0xFFFF && (unicode.IsPrint(r) || unicode.IsSpace(r)) {
|
||
result = append(result, r)
|
||
}
|
||
}
|
||
return string(result)
|
||
}
|
||
|
||
// loadChineseFont 加载中文字体
|
||
func loadChineseFont(pdf *gofpdf.Fpdf, fontPath string) error {
|
||
var fontData []byte
|
||
var err error
|
||
|
||
// 如果提供了本地字体路径,优先使用本地字体
|
||
if fontPath == "" {
|
||
return errors.New("字体文件路径不能为空")
|
||
}
|
||
|
||
fontData, err = os.ReadFile(fontPath)
|
||
if err != nil {
|
||
return fmt.Errorf("读取字体文件失败: %v", err)
|
||
}
|
||
// 使用本地字体文件
|
||
pdf.AddUTF8FontFromBytes("Chinese", "", fontData)
|
||
return nil
|
||
}
|
||
|
||
// GeneratePDF 生成PDF文件
|
||
func GeneratePDF(text, imageURL, outputPath, fontPath string) error {
|
||
if text == "" {
|
||
return errors.New("文本不能为空")
|
||
}
|
||
// 创建PDF实例,P=纵向,mm=毫米单位,A4=页面大小
|
||
pdf := gofpdf.New("P", "mm", "A4", "")
|
||
|
||
// 加载中文字体
|
||
err := loadChineseFont(pdf, fontPath)
|
||
if err != nil {
|
||
return fmt.Errorf("加载中文字体失败: %v", err)
|
||
}
|
||
|
||
// 添加新页面
|
||
pdf.AddPage()
|
||
|
||
// 设置字体,使用中文字体,12号字体
|
||
pdf.SetFont("Chinese", "", 12)
|
||
|
||
// 设置页面边距(左、上、右)
|
||
pdf.SetMargins(20, 10, 20)
|
||
|
||
// 设置当前位置(x, y),从左上角开始
|
||
pdf.SetXY(20, 10)
|
||
|
||
// 清理文本,移除PDF不支持的字符(如emoji)
|
||
cleanedText := cleanTextForPDF(text)
|
||
|
||
// 添加文本内容
|
||
// 使用MultiCell方法处理多行文本,支持自动换行
|
||
// 参数:宽度、行高、文本内容、边框、对齐方式、是否填充
|
||
// A4页面宽度210mm,减去左右边距40mm,可用宽度170mm
|
||
textWidth := 170.0
|
||
lineHeight := 7.0
|
||
pdf.MultiCell(textWidth, lineHeight, cleanedText, "", "L", false)
|
||
|
||
// 如果提供了图片URL,则添加图片
|
||
if imageURL != "" {
|
||
// 添加一些间距
|
||
pdf.Ln(5)
|
||
|
||
// 解析URL获取文件扩展名
|
||
u, err := url.Parse(imageURL)
|
||
if err != nil {
|
||
return fmt.Errorf("图片链接解析错误: %v", err)
|
||
}
|
||
fileExt := filepath.Ext(u.Path)
|
||
// 如果没有扩展名,默认使用.jpg
|
||
if fileExt == "" {
|
||
fileExt = ".jpg"
|
||
}
|
||
|
||
// 下载图片
|
||
resp, err := http.Get(imageURL)
|
||
if err != nil {
|
||
return fmt.Errorf("下载图片失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
// 读取图片数据
|
||
imageData, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("读取图片数据失败: %v", err)
|
||
}
|
||
|
||
// 将图片数据保存到临时文件(gofpdf需要文件路径)
|
||
tmpFile, err := os.CreateTemp("", "pdf_image_*"+fileExt)
|
||
if err != nil {
|
||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(tmpFile.Name()) // 使用完后删除临时文件
|
||
defer tmpFile.Close()
|
||
|
||
// 写入图片数据到临时文件
|
||
_, err = tmpFile.Write(imageData)
|
||
if err != nil {
|
||
return fmt.Errorf("写入临时文件失败: %v", err)
|
||
}
|
||
tmpFile.Close()
|
||
|
||
// A4纵向页面宽度210mm,减去左右边距40mm,可用宽度170mm
|
||
// 图片宽度设为可用宽度的70%
|
||
imageWidth := textWidth * 0.7
|
||
// 计算居中位置:页面宽度210mm,图片居中
|
||
imageX := (210.0 - imageWidth) / 2
|
||
currentY := pdf.GetY()
|
||
|
||
// 注册图片并获取原始尺寸,用于计算缩放后的高度
|
||
imgInfo := pdf.RegisterImageOptions(tmpFile.Name(), gofpdf.ImageOptions{})
|
||
if imgInfo == nil {
|
||
return fmt.Errorf("注册图片失败")
|
||
}
|
||
|
||
// 计算缩放后的图片高度(按比例缩放)
|
||
// 原始宽度:原始高度 = 缩放后宽度:缩放后高度
|
||
originalWidth, originalHeight := imgInfo.Extent()
|
||
imageHeight := (imageWidth / originalWidth) * originalHeight
|
||
|
||
// A4页面高度297mm,底部边距10mm,计算可用的最大Y坐标
|
||
pageHeight := 297.0
|
||
bottomMargin := 10.0
|
||
maxY := pageHeight - bottomMargin
|
||
|
||
// 检查当前页面剩余空间是否足够放下图片
|
||
// 如果图片底部会超出页面可用区域,则添加新页面
|
||
if currentY+imageHeight > maxY {
|
||
pdf.AddPage()
|
||
// 新页面从顶部边距开始
|
||
currentY = 10.0
|
||
}
|
||
|
||
// 添加图片
|
||
// ImageOptions参数:图片路径、x坐标、y坐标、宽度、高度、是否流式布局、选项、链接
|
||
// 高度设为0表示按比例自动计算
|
||
pdf.ImageOptions(tmpFile.Name(), imageX, currentY, imageWidth, 0, false, gofpdf.ImageOptions{}, 0, "")
|
||
}
|
||
|
||
// 生成并保存PDF文件
|
||
err = pdf.OutputFileAndClose(outputPath)
|
||
if err != nil {
|
||
return fmt.Errorf("生成PDF失败: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// CompetitorReportData 竞品报告数据
|
||
type CompetitorReportData struct {
|
||
HighlightAnalysis HighlightAnalysisData `json:"highlight_analysis"`
|
||
DataPerformance DataPerformanceData `json:"data_performance_analysis"`
|
||
OverallSummary string `json:"overall_summary_and_optimization"`
|
||
ImageURL string `json:"image_url"` // 图片URL,如果有图片则生成单独一页PDF
|
||
}
|
||
|
||
type HighlightAnalysisData struct {
|
||
Summary string `json:"summary"`
|
||
Points PointsData `json:"points"`
|
||
}
|
||
|
||
type PointsData struct {
|
||
Theme string `json:"theme"`
|
||
Narrative string `json:"narrative"`
|
||
Content string `json:"content"`
|
||
Copywriting string `json:"copywriting"`
|
||
Data string `json:"data"`
|
||
Music string `json:"music,omitempty"`
|
||
}
|
||
|
||
type DataPerformanceData struct {
|
||
Views string `json:"views"`
|
||
Completion string `json:"completion_rate,omitempty"`
|
||
Engagement string `json:"engagement"`
|
||
}
|
||
|
||
// GenerateCompetitorReportPDF 生成竞品报告PDF
|
||
// 参数:
|
||
// - templatePath: 模板文件路径(保留参数以兼容现有调用,传空则不使用模板)
|
||
// - outputPath: 输出PDF路径
|
||
// - data: 竞品报告数据
|
||
//
|
||
// 返回: 错误信息
|
||
func GenerateCompetitorReportPDF(templatePath, outputPath string, data CompetitorReportData) error {
|
||
fmt.Println("================================templatePath:", templatePath)
|
||
fmt.Println("================================outputPath:", outputPath)
|
||
|
||
pdf := gopdf.GoPdf{}
|
||
pdf.Start(gopdf.Config{PageSize: *gopdf.PageSizeA4})
|
||
|
||
// 如果有模板路径,则导入模板
|
||
if templatePath != "" {
|
||
err := pdf.ImportPagesFromSource(templatePath, "/MediaBox")
|
||
if err != nil {
|
||
return fmt.Errorf("无法导入页面: %v", err)
|
||
}
|
||
}
|
||
|
||
// 获取模板文件的总页数(如果有模板)
|
||
totalPages := pdf.GetNumberOfPages()
|
||
fmt.Printf("模板文件的总页数: %d\n", totalPages)
|
||
|
||
// 确定字体路径
|
||
var fontPath string
|
||
if templatePath != "" {
|
||
dir := filepath.Dir(templatePath)
|
||
fontPath = filepath.Join(dir, "simfang.ttf")
|
||
if _, err := os.Stat(fontPath); err != nil {
|
||
fontPath = filepath.Join("data", "simfang.ttf")
|
||
}
|
||
} else {
|
||
fontPath = filepath.Join("data", "simfang.ttf")
|
||
}
|
||
fmt.Printf("字体文件路径: %s\n", fontPath)
|
||
|
||
// 加载中文字体
|
||
ttfErr := pdf.AddTTFFont("simfang", fontPath)
|
||
if ttfErr != nil {
|
||
zap.L().Error("加载字体失败", zap.String("fontPath", fontPath), zap.Error(ttfErr))
|
||
return fmt.Errorf("加载中文字体失败: %v", ttfErr)
|
||
}
|
||
|
||
// 设置字体和字号
|
||
err := pdf.SetFont("simfang", "", 10)
|
||
if err != nil {
|
||
return fmt.Errorf("设置字体失败: %v", err)
|
||
}
|
||
|
||
// 行高15pt
|
||
lineHeight := 15.0
|
||
|
||
// 如果有内容要写入,确保在第一页
|
||
if totalPages > 0 || (data.HighlightAnalysis.Summary != "" || data.OverallSummary != "") {
|
||
pdf.SetPage(1)
|
||
|
||
// 概述 - 使用逐行写入,一行最多35个字
|
||
pdf.SetXY(200, 104)
|
||
summaryLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Summary), 35.0)
|
||
for i, line := range summaryLines {
|
||
pdf.SetXY(200, 104+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 标题亮点 - 一行最多9个字
|
||
pdf.SetXY(200, 184)
|
||
themeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Theme), 9.0)
|
||
for i, line := range themeLines {
|
||
pdf.SetXY(200, 184+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 题材亮点 - 一行最多9个字
|
||
pdf.SetXY(330, 184)
|
||
narrativeLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Narrative), 9.0)
|
||
for i, line := range narrativeLines {
|
||
pdf.SetXY(330, 184+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 内容亮点 - 一行最多9个字
|
||
pdf.SetXY(460, 184)
|
||
contentLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Content), 9.0)
|
||
for i, line := range contentLines {
|
||
pdf.SetXY(460, 184+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 文案亮点 - 一行最多9个字
|
||
pdf.SetXY(200, 323)
|
||
copywritingLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Copywriting), 9.0)
|
||
for i, line := range copywritingLines {
|
||
pdf.SetXY(200, 323+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 数据亮点 - 一行最多9个字
|
||
pdf.SetXY(330, 323)
|
||
dataLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Data), 9.0)
|
||
for i, line := range dataLines {
|
||
pdf.SetXY(330, 323+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 配乐亮点(仅视频) - 一行最多9个字
|
||
if data.HighlightAnalysis.Points.Music != "" {
|
||
pdf.SetXY(460, 323)
|
||
musicLines := splitTextByRune(cleanTextForPDF(data.HighlightAnalysis.Points.Music), 9.0)
|
||
for i, line := range musicLines {
|
||
pdf.SetXY(460, 323+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
}
|
||
|
||
// 浏览量 - 一行最多35个字
|
||
pdf.SetXY(200, 474)
|
||
viewsLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Views), 35.0)
|
||
for i, line := range viewsLines {
|
||
pdf.SetXY(200, 474+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 完播率 - 一行最多35个字
|
||
pdf.SetXY(200, 539)
|
||
if data.DataPerformance.Completion != "" {
|
||
completionLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Completion), 35.0)
|
||
for i, line := range completionLines {
|
||
pdf.SetXY(200, 539+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
}
|
||
|
||
// 点赞/分享/评论 - 一行最多35个字
|
||
pdf.SetXY(200, 600)
|
||
engagementLines := splitTextByRune(cleanTextForPDF(data.DataPerformance.Engagement), 35.0)
|
||
for i, line := range engagementLines {
|
||
pdf.SetXY(200, 600+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
|
||
// 整体总结及可优化建议 - 一行最多35个字
|
||
pdf.SetXY(200, 676)
|
||
overallSummaryLines := splitTextByRune(cleanTextForPDF(data.OverallSummary), 35.0)
|
||
for i, line := range overallSummaryLines {
|
||
pdf.SetXY(200, 676+float64(i)*lineHeight)
|
||
pdf.Cell(nil, line)
|
||
}
|
||
}
|
||
|
||
// 如果有图片URL,添加新页面并居中显示图片
|
||
if data.ImageURL != "" {
|
||
// 添加新页面
|
||
pdf.AddPage()
|
||
|
||
// 下载图片
|
||
resp, err := http.Get(data.ImageURL)
|
||
if err != nil {
|
||
return fmt.Errorf("下载图片失败: %v", err)
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
imageData, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return fmt.Errorf("读取图片数据失败: %v", err)
|
||
}
|
||
|
||
// 解析URL获取文件扩展名
|
||
u, err := url.Parse(data.ImageURL)
|
||
if err != nil {
|
||
return fmt.Errorf("图片链接解析错误: %v", err)
|
||
}
|
||
fileExt := filepath.Ext(u.Path)
|
||
if fileExt == "" {
|
||
fileExt = ".jpg"
|
||
}
|
||
|
||
// 保存到临时文件
|
||
tmpFile, err := os.CreateTemp("", "pdf_image_*"+fileExt)
|
||
if err != nil {
|
||
return fmt.Errorf("创建临时文件失败: %v", err)
|
||
}
|
||
defer os.Remove(tmpFile.Name())
|
||
defer tmpFile.Close()
|
||
|
||
_, err = tmpFile.Write(imageData)
|
||
if err != nil {
|
||
return fmt.Errorf("写入临时文件失败: %v", err)
|
||
}
|
||
tmpFile.Close()
|
||
|
||
// 使用 image 包获取图片原始尺寸
|
||
imgFile, err := os.Open(tmpFile.Name())
|
||
if err != nil {
|
||
return fmt.Errorf("打开图片文件失败: %v", err)
|
||
}
|
||
defer imgFile.Close()
|
||
|
||
config, format, err := image.DecodeConfig(imgFile)
|
||
if err != nil {
|
||
return fmt.Errorf("获取图片尺寸失败: %v", err)
|
||
}
|
||
_ = format // 忽略格式
|
||
|
||
// A4页面宽度595pt(210mm),高度842pt(297mm)
|
||
pageWidth := 595.0
|
||
pageHeight := 842.0
|
||
margin := 20.0
|
||
|
||
// 计算可用宽度
|
||
availableWidth := pageWidth - 2*margin
|
||
|
||
// 计算缩放后的图片尺寸(保持宽高比,宽度为可用宽度的80%)
|
||
imageWidth := availableWidth * 0.8
|
||
originalWidth := float64(config.Width)
|
||
originalHeight := float64(config.Height)
|
||
imageHeight := (imageWidth / originalWidth) * originalHeight
|
||
|
||
// 计算居中位置
|
||
imageX := (pageWidth - imageWidth) / 2
|
||
imageY := (pageHeight - imageHeight) / 2
|
||
|
||
// 使用 ImageHolderByBytes 添加图片
|
||
imgH1, err := gopdf.ImageHolderByBytes(imageData)
|
||
if err != nil {
|
||
return fmt.Errorf("创建图片Holder失败: %v", err)
|
||
}
|
||
|
||
// 绘制图片
|
||
err = pdf.ImageByHolder(imgH1, imageX, imageY, &gopdf.Rect{W: imageWidth, H: imageHeight})
|
||
if err != nil {
|
||
return fmt.Errorf("绘制图片失败: %v", err)
|
||
}
|
||
}
|
||
|
||
// 生成新的 PDF
|
||
if err := pdf.WritePdf(outputPath); err != nil {
|
||
return fmt.Errorf("error writing final PDF: %v", err)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// getCharWidth 获取字符的宽度权重
|
||
// 英文字母、数字、英文符号返回 0.5,其他字符返回 1.0
|
||
func getCharWidth(r rune) float64 {
|
||
// 英文字母 (A-Z, a-z)
|
||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||
return 0.5
|
||
}
|
||
// 数字 (0-9)
|
||
if r >= '0' && r <= '9' {
|
||
return 0.5
|
||
}
|
||
// 英文符号
|
||
if (r >= 0x21 && r <= 0x2F) || // ! " # $ % & ' ( ) * + , - . /
|
||
(r >= 0x3A && r <= 0x40) || // : ; < = > ? @
|
||
(r >= 0x5B && r <= 0x60) || // [ \ ] ^ _ `
|
||
(r >= 0x7B && r <= 0x7E) { // { | } ~
|
||
return 0.5
|
||
}
|
||
return 1.0
|
||
}
|
||
|
||
// splitTextByRune 将文本按指定字符宽度拆分成多行
|
||
// 按每行最大宽度拆分,英文字母/数字/英文符号按0.5计算,其他按1计算
|
||
func splitTextByRune(text string, maxWidth float64) []string {
|
||
if text == "" {
|
||
return []string{}
|
||
}
|
||
runes := []rune(text)
|
||
|
||
var lines []string
|
||
currentLine := ""
|
||
currentWidth := 0.0
|
||
|
||
for _, r := range runes {
|
||
charWidth := getCharWidth(r)
|
||
|
||
// 检查加上这个字符是否会超过最大宽度
|
||
if currentWidth+charWidth > maxWidth {
|
||
// 超过最大宽度,保存当前行并开始新行
|
||
if currentLine != "" {
|
||
lines = append(lines, currentLine)
|
||
}
|
||
currentLine = string(r)
|
||
currentWidth = charWidth
|
||
} else {
|
||
currentLine += string(r)
|
||
currentWidth += charWidth
|
||
}
|
||
}
|
||
|
||
// 添加最后一行
|
||
if currentLine != "" {
|
||
lines = append(lines, currentLine)
|
||
}
|
||
|
||
return lines
|
||
}
|
||
|
||
// TruncateTextByRune 按字符权重截断文本
|
||
// 中文字符算1.0,英文字母/数字/英文符号算0.5
|
||
// 返回截断后的文本,确保总权重不超过maxWidth
|
||
func TruncateTextByRune(text string, maxWidth float64) string {
|
||
if text == "" {
|
||
return ""
|
||
}
|
||
|
||
runes := []rune(text)
|
||
var result []rune
|
||
currentWidth := 0.0
|
||
|
||
for _, r := range runes {
|
||
charWidth := getCharWidth(r)
|
||
if currentWidth+charWidth > maxWidth {
|
||
break
|
||
}
|
||
result = append(result, r)
|
||
currentWidth += charWidth
|
||
}
|
||
|
||
return string(result)
|
||
}
|
||
|
||
// ConvertCompetitorReportToText 将竞品报告数据转换为文本格式
|
||
// 参数:
|
||
// - data: 竞品报告数据
|
||
// - isVideo: 是否为视频(视频需要包含完播率和配乐亮点)
|
||
//
|
||
// 返回: 转换后的文本内容
|
||
func ConvertCompetitorReportToText(data CompetitorReportData, isVideo bool) string {
|
||
var sb strings.Builder
|
||
|
||
// 一、亮点表现分析
|
||
sb.WriteString("一、亮点表现分析\n")
|
||
sb.WriteString(data.HighlightAnalysis.Summary)
|
||
sb.WriteString("\n\n")
|
||
|
||
sb.WriteString("1. 标题亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Theme)
|
||
sb.WriteString("\n")
|
||
|
||
sb.WriteString("2. 题材亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Narrative)
|
||
sb.WriteString("\n")
|
||
|
||
sb.WriteString("3. 内容亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Content)
|
||
sb.WriteString("\n")
|
||
|
||
sb.WriteString("4. 文案亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Copywriting)
|
||
sb.WriteString("\n")
|
||
|
||
sb.WriteString("5. 数据亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Data)
|
||
sb.WriteString("\n")
|
||
|
||
if isVideo && data.HighlightAnalysis.Points.Music != "" {
|
||
sb.WriteString("6. 配乐亮点:")
|
||
sb.WriteString(data.HighlightAnalysis.Points.Music)
|
||
sb.WriteString("\n")
|
||
}
|
||
|
||
// 二、数据表现分析
|
||
sb.WriteString("\n二、数据表现分析\n")
|
||
sb.WriteString("1. 浏览量表现:")
|
||
sb.WriteString(data.DataPerformance.Views)
|
||
sb.WriteString("\n")
|
||
|
||
if isVideo && data.DataPerformance.Completion != "" {
|
||
sb.WriteString("2. 完播率表现:")
|
||
sb.WriteString(data.DataPerformance.Completion)
|
||
sb.WriteString("\n")
|
||
sb.WriteString("3. 点赞/分享/评论表现:")
|
||
} else {
|
||
sb.WriteString("2. 点赞/分享/评论表现:")
|
||
}
|
||
sb.WriteString(data.DataPerformance.Engagement)
|
||
sb.WriteString("\n")
|
||
|
||
// 三、整体总结及可优化建议
|
||
sb.WriteString("\n三、整体总结及可优化建议\n")
|
||
sb.WriteString(data.OverallSummary)
|
||
|
||
return sb.String()
|
||
}
|