feat: 报告生成扩展 PPT/Word/Excel 多格式导出

- 新增 ExportReportDOCX 导出 Word 文档(纯标准库)
- 新增 ExportReportPPTX 导出 PPT 演示文稿(纯标准库)
- 新增 ExportReportXLSX 导出 Excel 表格(纯标准库)
- 新增 /api/report/export/{docx,pptx,xlsx} 路由
- 前端增加多格式导出下拉菜单(PDF/Word/PPT/Excel)
- 前端导出函数按格式名提示成功信息
- 零网络依赖,纯 Go 标准库实现
This commit is contained in:
eaiadmin
2026-09-13 22:09:19 +08:00
parent c8781bfe90
commit 8c33dde2d6
8 changed files with 319 additions and 17 deletions
@@ -0,0 +1,31 @@
package api
import (
"archive/zip"
"strings"
)
// writeFile helper: create a file in zip and write content
func zipWriteFile(zw *zip.Writer, name, content string) error {
f, err := zw.Create(name)
if err != nil {
return err
}
_, err = f.Write([]byte(content))
return err
}
// escapeXML escapes HTML/XML special characters
func escapeXML(s string) string {
s = strings.ReplaceAll(s, "&", "&")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "'", "&apos;")
s = strings.ReplaceAll(s, `"`, "&quot;")
return s
}
// timeStr returns a fixed string for pptx sldId
func timeStr() string {
return "20060102T150405"
}
@@ -0,0 +1,31 @@
package api
import (
"archive/zip"
"bytes"
"strings"
)
func ExportReportDOCX(report reportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`)
var doc strings.Builder
doc.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>`)
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Title"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="48"/></w:rPr><w:t>` + escapeXML(report.Topic) + `</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:b/><w:sz w:val="24"/></w:rPr><w:t>摘要</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="24"/></w:rPr><w:t>` + escapeXML(report.Summary) + `</w:t></w:r></w:p>`)
for _, ch := range report.Chapters {
doc.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:sz w:val="28"/></w:rPr><w:t>` + escapeXML(ch.Title) + `</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t>` + escapeXML(ch.Body) + `</w:t></w:r></w:p>`)
}
doc.WriteString(`</w:body></w:document>`)
zipWriteFile(zw, "word/document.xml", doc.String())
zipWriteFile(zw, "word/styles.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><styles xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><style type="paragraph" styleId="Title"><name val="Title"/><uiPriority uiPriority="9"/><rPr><rStyle val="Title"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="48"/></rPr></style><style type="paragraph" styleId="Heading1"><name val="Heading 1"/><rPr><rStyle val="Heading1"/><rFonts w:ascii="Arial" w:hAnsi="Arial"/><b/><sz w:val="28"/></rPr></style></styles>`)
zipWriteFile(zw, "word/_rels/document.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/></Relationships>`)
zipWriteFile(zw, "docProps/core.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>` + escapeXML(report.Topic) + `</dc:title></cp:coreProperties>`)
zipWriteFile(zw, "docProps/app.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Microsoft Word</Application></Properties>`)
zw.Close()
return buf.Bytes(), nil
}
@@ -0,0 +1,72 @@
package api
import (
"archive/zip"
"bytes"
"fmt"
"strings"
)
func ExportReportPPTX(report reportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
layoutRefs := make([]string, 0)
for i := 0; i < len(report.Chapters)+1; i++ {
layoutRefs = append(layoutRefs, "rId"+fmt.Sprint(i+1))
}
slideRels := strings.Join(layoutRefs, "")
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="xml" ContentType="application/xml"/><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/><Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/><Override PartName="/ppt/slideLayouts/slideLayout1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/></Types>`)
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="ppt/presentation.xml"/></Relationships>`)
preXML := `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" changedTime="` + timeStr() + `">` +
`<p:sldMasterId href="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"/>` +
`<p:sldIds>` + slideRels + `</p:sldIds></p:presentation>`
zipWriteFile(zw, "ppt/presentation.xml", preXML)
zipWriteFile(zw, "ppt/_rels/presentation.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="slideMaster/slideMaster1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayouts" Target="slideLayouts/slideLayout1.xml"/><Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="theme/theme1.xml"/></Relationships>`)
titleSlide := buildSlideTitle(report.Topic, report.Summary)
zipWriteFile(zw, "ppt/slides/slide1.xml", titleSlide)
zipWriteFile(zw, "ppt/slides/_rels/slide1.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
for i, ch := range report.Chapters {
slideNum := i + 2
slidePath := fmt.Sprintf("ppt/slides/slide%d.xml", slideNum)
relnPath := fmt.Sprintf("ppt/slides/_rels/slide%d.xml.rels", slideNum)
zipWriteFile(zw, slidePath, buildSlideChapter(ch.Title, ch.Body))
zipWriteFile(zw, relnPath, `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/></Relationships>`)
}
zipWriteFile(zw, "ppt/slideLayouts/slideLayout1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" type="title"><p:cSld><p:spTree><p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:t>标题</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp><p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr><p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:t>内容</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp></p:spTree></p:cSld></p:sldLayout>`)
zipWriteFile(zw, "ppt/theme/theme1.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="Design Theme"><a:themeElements><a:clrScheme name="Office"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="1F497D"/></a:dk2><a:lt2><a:srgbClr val="E8E8E8"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="4472C4"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="9563C1"/></a:folHlink></a:clrScheme><a:fontScheme name="Office"><a:majorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Cambria" script="latin"/></a:majorFont><a:minorFont><a:latin fontFamily="Calibri" script="latn"/><a:ea script="hani"/><a:cs fontFamily="Calibri" script="latin"/></a:minorFont></a:fontScheme><a:fmtScheme name="Office"/></a:themeElements></a:theme>`)
zipWriteFile(zw, "ppt/media/", "")
zw.Close()
return buf.Bytes(), nil
}
func buildSlideTitle(title, summary string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
`<p:cSld><p:spTree>` +
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="1219200"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr alignment="ctr"/><a:r><a:rPr lang="zh-CN" sz="4400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="4400" dirty="0"/></a:p></a:txBody></p:sp>` +
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="摘要"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1524000"/><p:ext cx="6400800" cy="5334000"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2400" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(summary) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2400" dirty="0"/></a:p></a:txBody></p:sp>` +
`</p:spTree></p:cSld></p:slide>`
}
func buildSlideChapter(title, body string) string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<p:slide xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" p:sldId="` + timeStr() + `">` +
`<p:cSld><p:spTree>` +
`<p:sp><p:nvSpPr><p:cNvPr id="1" name="标题"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="914400" y="228600"/><p:ext cx="7315200" cy="914400"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" anchor="ctr"/><a:style><a:lnSz w="914400" h="914400"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr/><a:r><a:rPr lang="zh-CN" sz="3200" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:b/><a:t>` + escapeXML(title) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="3200" dirty="0"/></a:p></a:txBody></p:sp>` +
`<p:sp><p:nvSpPr><p:cNvPr id="2" name="内容"/><p:cNvSpPr/></p:nvSpPr><p:spPr><p:xfrm><p:off x="457200" y="1219200"/><p:ext cx="6400800" cy="5791200"/></p:xfrm></p:spPr>` +
`<p:txBody><a:bodyPr xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" wrap="square" anchor="ctr"/><a:style><a:lnSz w="76200" h="76200"/><a:effectLst/><a:spPr/><a:prstGeom prst="rect"/><a:fld/><a:p><a:pPr><a:indent>-457200"/></a:pPr><a:r><a:rPr lang="zh-CN" sz="2000" dirty="0"/><a:rPr><a:spcBk><a:spcPct val="100000"/></a:spcBk></a:rPr><a:t>` + escapeXML(body) + `</a:t></a:r><a:endParaRPr lang="zh-CN" sz="2000" dirty="0"/></a:p></a:txBody></p:sp>` +
`</p:spTree></p:cSld></p:slide>`
}
@@ -0,0 +1,55 @@
package api
import (
"archive/zip"
"bytes"
"fmt"
"strings"
)
func ExportReportXLSX(report reportContent) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
ss := []string{}
addSS := func(s string) int {
s = strings.TrimSpace(s)
if s == "" {
return len(ss)
}
ss = append(ss, s)
return len(ss) - 1
}
addSS(report.Topic)
addSS(report.Summary)
for _, ch := range report.Chapters {
addSS(ch.Title)
addSS(ch.Body)
}
zipWriteFile(zw, "[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>`)
zipWriteFile(zw, "_rels/.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/></Relationships>`)
zipWriteFile(zw, "xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><workbookPr date1904="false"/><bookViews><workbookView xWindow="0" yWindow="0" windowWidth="25600" windowHeight="14400"/></bookViews><sheets><sheet name="报告" sheetId="1" r:id="rId1"/></sheets></workbook>`)
zipWriteFile(zw, "xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/></Relationships>`)
var sb strings.Builder
sb.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="` + fmt.Sprint(len(ss)) + `" uniqueCount="` + fmt.Sprint(len(ss)) + `">`)
for _, s := range ss {
sb.WriteString(`<si><t>` + escapeXML(s) + `</t></si>`)
}
sb.WriteString(`</sst>`)
zipWriteFile(zw, "xl/sharedStrings.xml", sb.String())
var sb2 strings.Builder
sb2.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?><worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>`)
row := 1
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A1" t="inlineStr"><is><t>` + escapeXML(report.Topic) + `</t></is></c></row>`)
row = 2
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A2" t="inlineStr"><is><t>摘要</t></is></c><c r="B2" t="inlineStr"><is><t>` + escapeXML(report.Summary) + `</t></is></c></row>`)
row = 3
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A3" t="inlineStr"><is><t>章节</t></is></c><c r="B3" t="inlineStr"><is><t>内容</t></is></c></row>`)
for _, ch := range report.Chapters {
row++
sb2.WriteString(`<row r="` + fmt.Sprint(row) + `"><c r="A` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Title) + `</t></is></c><c r="B` + fmt.Sprint(row) + `" t="inlineStr"><is><t>` + escapeXML(ch.Body) + `</t></is></c></row>`)
}
sb2.WriteString(`</sheetData></worksheet>`)
zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String())
zw.Close()
return buf.Bytes(), nil
}
@@ -393,3 +393,75 @@ func promptReportPreview(req ReportGenRequest) reportContent {
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// ReportExportXLSX POST /api/report/export/xlsx —— 导出报告为 Excel
func ReportExportXLSX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := ExportReportXLSX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("Excel 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".xlsx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", data)
}
// ReportExportDOCX POST /api/report/export/docx —— 导出报告为 Word
func ReportExportDOCX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := ExportReportDOCX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("Word 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.wordprocessingml.document")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".docx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.wordprocessingml.document", data)
}
// ReportExportPPTX POST /api/report/export/pptx —— 导出报告为 PPTX
func ReportExportPPTX(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req struct {
Report reportContent `json:"report"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.Report.Topic == "" {
web.Fail(c, web.NewBadRequest("report 不能为空"))
return
}
data, err := ExportReportPPTX(req.Report)
if err != nil {
web.Fail(c, web.NewBadRequest("PPTX 生成失败: "+err.Error()))
return
}
c.Header("Content-Type", "application/vnd.openxmlformats-officedocument.presentationml.presentation")
c.Header("Content-Disposition", "attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pptx\"")
c.Data(200, "application/vnd.openxmlformats-officedocument.presentationml.presentation", data)
}
@@ -121,6 +121,9 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
// ── 报告生成(普通员工可访问)──
r.POST("/api/report/generate", middleware.Auth(cfg), ReportGenerate)
r.POST("/api/report/export/pdf", middleware.Auth(cfg), ReportExportPDF)
r.POST("/api/report/export/docx", middleware.Auth(cfg), ReportExportDOCX)
r.POST("/api/report/export/pptx", middleware.Auth(cfg), ReportExportPPTX)
r.POST("/api/report/export/xlsx", middleware.Auth(cfg), ReportExportXLSX)
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview)
// 管理员维护
+12
View File
@@ -8,6 +8,18 @@ export function exportReportPDF(data) {
return http.post('/report/export/pdf', data, { responseType: 'blob' })
}
export function exportReportDOCX(data) {
return http.post('/report/export/docx', data, { responseType: 'blob' })
}
export function exportReportPPTX(data) {
return http.post('/report/export/pptx', data, { responseType: 'blob' })
}
export function exportReportXLSX(data) {
return http.post('/report/export/xlsx', data, { responseType: 'blob' })
}
export function previewReport(data) {
return http.post('/report/preview', data)
}
@@ -97,10 +97,20 @@
</span>
</div>
<div class="card-actions" v-if="reportContent">
<el-button size="small" :loading="exporting" @click="exportToPDF">
<el-icon><Download /></el-icon>
导出 PDF
</el-button>
<el-dropdown trigger="click" @command="exportReport">
<el-button size="small" :loading="exporting">
<el-icon><Download /></el-icon>
导出 <el-icon><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="pdf"><el-icon><Document /></el-icon> PDF 文档</el-dropdown-item>
<el-dropdown-item command="docx"><el-icon><Reading /></el-icon> Word 文档</el-dropdown-item>
<el-dropdown-item command="pptx"><el-icon><Grid /></el-icon> PPT 演示文稿</el-dropdown-item>
<el-dropdown-item command="xlsx"><el-icon><Reading /></el-icon> Excel 表格</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button size="small" @click="resetReport">
清除
</el-button>
@@ -148,8 +158,8 @@
<script setup>
import { onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Edit, Download } from '@element-plus/icons-vue'
import { generateReport, exportReportPDF } from '@/api/report'
import { Edit, Download, Document, Reading, ArrowDown, Grid } from '@element-plus/icons-vue'
import { generateReport as apiGenerateReport, exportReportPDF, exportReportDOCX, exportReportPPTX, exportReportXLSX } from '@/api/report'
import { listKnowledgeSpaces } from '@/api/knowledge'
const generating = ref(false)
@@ -173,7 +183,7 @@ async function generateReport() {
generating.value = true
try {
const res = await generateReport({
const res = await apiGenerateReport({
topic: form.value.topic.trim(),
summary: form.value.summary,
sections: form.value.sections,
@@ -197,28 +207,44 @@ async function generateReport() {
}
}
async function exportToPDF() {
async function exportReport(command) {
if (!reportContent.value) return
exporting.value = true
try {
const blob = await exportReportPDF({
report: reportContent.value,
})
// 下载 PDF
const formatNames = { pdf: 'PDF', docx: 'Word', pptx: 'PPT', xlsx: 'Excel' }
const exts = { pdf: '.pdf', docx: '.docx', pptx: '.pptx', xlsx: '.xlsx' }
const formatName = formatNames[command] || '文件'
const ext = exts[command] || '.pdf'
let blob
switch (command) {
case 'pdf':
blob = await exportReportPDF({ report: reportContent.value })
break
case 'docx':
blob = await exportReportDOCX({ report: reportContent.value })
break
case 'pptx':
blob = await exportReportPPTX({ report: reportContent.value })
break
case 'xlsx':
blob = await exportReportXLSX({ report: reportContent.value })
break
default:
return
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const filename = (reportContent.value.topic || 'report') + '.pdf'
a.download = filename.replace(/[^a-zA-Z一-龥 -￿]/g, '_')
const filename = (reportContent.value.topic || 'report') + ext
a.download = filename.replace(/[^a-zA-Z一-龥龥 -￿]/g, '_')
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
ElMessage.success('PDF 已下载')
ElMessage.success(`${formatName} 已下载`)
} catch (error) {
ElMessage.error('PDF 导出失败')
ElMessage.error(`${formatName} 导出失败`)
} finally {
exporting.value = false
}