feat: 报告生成模块(LLM 生成 + PDF 导出 + 前端页面)
- 新增报告生成 API(/api/report/generate, /api/report/export/pdf, /api/report/preview) - 新增 PDF 导出模块(gofpdf,支持系统中文字体自动检测) - 新增前端报告生成页面(report/ReportGenerationPage.vue) - 新增前端 API(report.js) - 路由注册(后端 router.go + 前端 router/index.js) - Studio 页面新增「报告生成」应用入口 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,7 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.17.3 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"eaisalestrain/backend/internal/ai"
|
||||||
|
"eaisalestrain/backend/internal/config"
|
||||||
|
"eaisalestrain/backend/internal/middleware"
|
||||||
|
"eaisalestrain/backend/internal/model"
|
||||||
|
"eaisalestrain/backend/internal/store"
|
||||||
|
"eaisalestrain/backend/internal/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportGenRequest POST /api/report/generate 报告生成请求
|
||||||
|
type ReportGenRequest struct {
|
||||||
|
Topic string `json:"topic"` // 报告主题,必填
|
||||||
|
Summary string `json:"summary"` // 报告摘要/背景
|
||||||
|
Context map[string]any `json:"context"` // 附加上下文
|
||||||
|
Sections []string `json:"sections"` // 期望包含的章节(可选)
|
||||||
|
Knowledge string `json:"knowledge_key"` // 关联知识库 key
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportChapter 报告章节结构
|
||||||
|
type reportChapter struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// reportContent 完整报告结构
|
||||||
|
type reportContent struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Chapters []reportChapter `json:"chapters"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportGenerate POST /api/report/generate —— 调用 LLM 生成报告正文
|
||||||
|
func ReportGenerate(c *gin.Context) {
|
||||||
|
user := middleware.CurrentUser(c)
|
||||||
|
if user == nil {
|
||||||
|
web.Fail(c, web.NewAuthError("未登录"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ReportGenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
|
||||||
|
web.Fail(c, web.NewBadRequest("topic 必填"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 检索知识库片段
|
||||||
|
spaceKey := ""
|
||||||
|
if req.Knowledge != "" {
|
||||||
|
spaceKey = req.Knowledge
|
||||||
|
}
|
||||||
|
knowledgeContent := retrieveReportKnowledge(req.Topic, spaceKey)
|
||||||
|
|
||||||
|
// 2. 构建 LLM 提示词
|
||||||
|
chapters := make([]reportChapter, 0)
|
||||||
|
if len(req.Sections) > 0 {
|
||||||
|
// 使用用户指定的章节
|
||||||
|
chapters = promptReportWithSections(req, knowledgeContent)
|
||||||
|
} else {
|
||||||
|
// LLM 自动规划章节
|
||||||
|
chapters = promptReportAutoChapters(req, knowledgeContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 组装报告
|
||||||
|
report := reportContent{
|
||||||
|
Topic: req.Topic,
|
||||||
|
Summary: strings.TrimSpace(req.Summary),
|
||||||
|
Chapters: chapters,
|
||||||
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
|
||||||
|
web.OK(c, gin.H{
|
||||||
|
"report": report,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// retrieveReportKnowledge 为报告生成检索知识库片段
|
||||||
|
func retrieveReportKnowledge(topic string, spaceKey string) string {
|
||||||
|
if spaceKey != "" {
|
||||||
|
// 按空间检索
|
||||||
|
var space model.KnowledgeSpace
|
||||||
|
if err := store.DB.Where("key = ?", spaceKey).First(&space).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var chunks []model.KnowledgeChunk
|
||||||
|
store.DB.Where("space_id = ? AND approved = ?", space.ID, true).
|
||||||
|
Order("importance DESC").
|
||||||
|
Limit(10).
|
||||||
|
Find(&chunks)
|
||||||
|
parts := make([]string, 0, len(chunks))
|
||||||
|
for _, ch := range chunks {
|
||||||
|
parts = append(parts, ch.Content)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局检索(按 title 匹配)
|
||||||
|
var chunks []model.KnowledgeChunk
|
||||||
|
store.DB.Where("approved = ?", true).
|
||||||
|
Order("importance DESC").
|
||||||
|
Limit(15).
|
||||||
|
Find(&chunks)
|
||||||
|
parts := make([]string, 0, len(chunks))
|
||||||
|
for _, ch := range chunks {
|
||||||
|
parts = append(parts, fmt.Sprintf("【%s】%s", ch.Content))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptReportWithSections 使用用户指定章节生成报告
|
||||||
|
func promptReportWithSections(req ReportGenRequest, knowledge string) []reportChapter {
|
||||||
|
sectionsStr := strings.Join(req.Sections, "、")
|
||||||
|
systemPrompt := `你是博昇内部培训平台的报告生成引擎。你的任务是输出一份结构化的正式报告,语言为中文。`
|
||||||
|
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
||||||
|
|
||||||
|
主题:%s
|
||||||
|
|
||||||
|
报告要求:
|
||||||
|
1. 内容完整、逻辑清晰、避免空话
|
||||||
|
2. 使用正式的书面语言,适合内部汇报或培训
|
||||||
|
3. 报告必须包含以下章节(按顺序):%s
|
||||||
|
4. 每个章节下至少 3-5 个要点
|
||||||
|
5. 如有知识库资料可引用,请融入内容中
|
||||||
|
|
||||||
|
当前报告摘要/背景:%s
|
||||||
|
|
||||||
|
知识库相关资料(如有):
|
||||||
|
%s`,
|
||||||
|
req.Topic,
|
||||||
|
sectionsStr,
|
||||||
|
strings.TrimSpace(req.Summary),
|
||||||
|
knowledge,
|
||||||
|
)
|
||||||
|
|
||||||
|
route, _ := config.GetRoute("title_gen")
|
||||||
|
content, err := ai.GenerateWithFallback(route, []ai.Message{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fallbackReport(req.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseReportMarkdown(content, req.Sections)
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptReportAutoChapters 让 LLM 自动规划报告章节
|
||||||
|
func promptReportAutoChapters(req ReportGenRequest, knowledge string) []reportChapter {
|
||||||
|
systemPrompt := `你是博昇内部培训平台的报告生成引擎。先规划报告章节,再撰写内容。输出必须严格为 JSON 数组,不要输出其他内容。`
|
||||||
|
userPrompt := fmt.Sprintf(`请围绕以下主题撰写一份正式报告。
|
||||||
|
|
||||||
|
主题:%s
|
||||||
|
|
||||||
|
报告要求:
|
||||||
|
1. 先规划 5-8 个合适的章节
|
||||||
|
2. 每个章节包含:标题(title)和正文(body)
|
||||||
|
3. 正文不少于 300 字
|
||||||
|
4. 使用正式的书面语言
|
||||||
|
|
||||||
|
当前报告摘要/背景:%s
|
||||||
|
|
||||||
|
知识库相关资料(如有):
|
||||||
|
%s
|
||||||
|
|
||||||
|
请输出 JSON 格式,每个条目包含 "title" 和 "body" 两个字段。`,
|
||||||
|
req.Topic,
|
||||||
|
strings.TrimSpace(req.Summary),
|
||||||
|
knowledge,
|
||||||
|
)
|
||||||
|
|
||||||
|
route, _ := config.GetRoute("title_gen")
|
||||||
|
content, err := ai.GenerateWithFallback(route, []ai.Message{
|
||||||
|
{Role: "system", Content: systemPrompt},
|
||||||
|
{Role: "user", Content: userPrompt},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fallbackReport(req.Topic)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseReportJSON(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReportMarkdown 从 Markdown 格式解析报告为章节数组
|
||||||
|
func parseReportMarkdown(content string, defaultSections []string) []reportChapter {
|
||||||
|
if len(defaultSections) == 0 {
|
||||||
|
defaultSections = []string{"背景概述", "核心内容", "关键数据", "实施方案", "总结建议"}
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(content, "\n")
|
||||||
|
chapters := make([]reportChapter, 0, len(defaultSections))
|
||||||
|
currentTitle := ""
|
||||||
|
currentBody := ""
|
||||||
|
|
||||||
|
isChapterTitle := func(line string) bool {
|
||||||
|
if strings.HasPrefix(line, "# ") || strings.HasPrefix(line, "## ") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
runes := []rune(line)
|
||||||
|
if len(runes) >= 2 {
|
||||||
|
// 数字序号:1、 1. 一、
|
||||||
|
if (runes[0] >= '0' && runes[0] <= '9') && (runes[1] == '、' || runes[1] == '.') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if runes[0] == '一' && runes[1] == '、' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 短标题带冒号(排除 URL)
|
||||||
|
if len(runes) < 30 && len(runes) > 3 && strings.Contains(line, ":") && !strings.Contains(line, "https") {
|
||||||
|
// 排除 markdown 行内代码
|
||||||
|
if !strings.Contains(line, "```") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "---") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chapters) < 10 && isChapterTitle(line) {
|
||||||
|
if currentTitle != "" && currentBody != "" {
|
||||||
|
chapters = append(chapters, reportChapter{
|
||||||
|
Title: strings.TrimSpace(currentTitle),
|
||||||
|
Body: strings.TrimSpace(currentBody),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
currentTitle = strings.TrimLeft(line, "# \t")
|
||||||
|
currentTitle = strings.TrimRight(currentTitle, " ")
|
||||||
|
currentBody = ""
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentTitle != "" {
|
||||||
|
currentBody += line + "\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentTitle != "" && currentBody != "" {
|
||||||
|
chapters = append(chapters, reportChapter{
|
||||||
|
Title: strings.TrimSpace(currentTitle),
|
||||||
|
Body: strings.TrimSpace(currentBody),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chapters) == 0 {
|
||||||
|
for _, title := range defaultSections {
|
||||||
|
chapters = append(chapters, reportChapter{
|
||||||
|
Title: title,
|
||||||
|
Body: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(chapters) > 15 {
|
||||||
|
chapters = chapters[:15]
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseReportJSON 从 LLM 返回的 JSON 解析报告
|
||||||
|
func parseReportJSON(content string) []reportChapter {
|
||||||
|
// 提取 JSON 块
|
||||||
|
jsonStr := content
|
||||||
|
if idx := strings.Index(content, "["); idx >= 0 {
|
||||||
|
jsonStr = content[idx:]
|
||||||
|
}
|
||||||
|
if idx := strings.LastIndex(jsonStr, "]"); idx >= 0 {
|
||||||
|
jsonStr = jsonStr[:idx+1]
|
||||||
|
}
|
||||||
|
|
||||||
|
var chapters []reportChapter
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr), &chapters); err == nil && len(chapters) > 0 {
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取数组对象块
|
||||||
|
start := strings.Index(content, "{")
|
||||||
|
end := strings.LastIndex(content, "}")
|
||||||
|
if start >= 0 && end > start {
|
||||||
|
jsonStr2 := content[start : end+1]
|
||||||
|
if err := json.Unmarshal([]byte(jsonStr2), &chapters); err == nil && len(chapters) > 0 {
|
||||||
|
return chapters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回退:生成默认章节
|
||||||
|
return fallbackReport("")
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackReport 无 LLM 时的默认报告模板
|
||||||
|
func fallbackReport(topic string) []reportChapter {
|
||||||
|
title := topic
|
||||||
|
if title == "" {
|
||||||
|
title = "分析报告"
|
||||||
|
}
|
||||||
|
return []reportChapter{
|
||||||
|
{
|
||||||
|
Title: "一、背景概述",
|
||||||
|
Body: fmt.Sprintf("本报告围绕「%s」主题展开,旨在梳理相关背景信息与核心要点,为内部学习与汇报提供参考。", title),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "二、核心内容",
|
||||||
|
Body: fmt.Sprintf("在「%s」领域,当前主流实践聚焦于以下几个维度:知识体系构建、流程标准化、工具赋能与效果评估。通过系统化的培训与考核机制,帮助团队提升专业能力。", title),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "三、关键数据",
|
||||||
|
Body: "关键指标包括:产品覆盖率、培训完成率、考试通过率、知识入库率、AI 使用频次等。具体数据需根据实际运营情况定期更新。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "四、实施方案",
|
||||||
|
Body: "建议分阶段推进:第一阶段建立知识体系与培训框架;第二阶段完善考核与评估机制;第三阶段引入 AI 辅助工具提升效率;第四阶段持续优化与迭代。",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "五、总结建议",
|
||||||
|
Body: "整体而言,「%s」是当前业务发展的核心方向之一。建议持续投入资源,建立系统化培训体系,结合 AI 工具提升效率,确保团队能力与业务发展同步。" + title,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportExportPDF POST /api/report/export/pdf —— 导出报告为 PDF 文件
|
||||||
|
func ReportExportPDF(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 := ExportReportPDF(req.Report)
|
||||||
|
if err != nil {
|
||||||
|
web.Fail(c, web.NewBadRequest("PDF 生成失败: "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/pdf")
|
||||||
|
c.Header("Content-Disposition",
|
||||||
|
"attachment; filename=\""+strings.ReplaceAll(req.Report.Topic, " ", "_")+".pdf\"")
|
||||||
|
c.Data(200, "application/pdf", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReportPreview GET /api/report/preview —— 预览报告(JSON,用于前端预览)
|
||||||
|
func ReportPreview(c *gin.Context) {
|
||||||
|
user := middleware.CurrentUser(c)
|
||||||
|
if user == nil {
|
||||||
|
web.Fail(c, web.NewAuthError("未登录"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req ReportGenRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Topic) == "" {
|
||||||
|
web.Fail(c, web.NewBadRequest("topic 必填"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 LLM 生成报告内容
|
||||||
|
report := promptReportPreview(req)
|
||||||
|
|
||||||
|
web.OK(c, gin.H{
|
||||||
|
"report": report,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// promptReportPreview 预览模式:返回精简报告
|
||||||
|
func promptReportPreview(req ReportGenRequest) reportContent {
|
||||||
|
chapters := []reportChapter{}
|
||||||
|
if len(req.Sections) > 0 {
|
||||||
|
chapters = promptReportWithSections(req, retrieveReportKnowledge(req.Topic, req.Knowledge))
|
||||||
|
} else {
|
||||||
|
chapters = promptReportAutoChapters(req, retrieveReportKnowledge(req.Topic, req.Knowledge))
|
||||||
|
}
|
||||||
|
return reportContent{
|
||||||
|
Topic: req.Topic,
|
||||||
|
Summary: strings.TrimSpace(req.Summary),
|
||||||
|
Chapters: chapters,
|
||||||
|
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jung-kurt/gofpdf/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExportReportPDF 导出报告为 PDF 字节
|
||||||
|
func ExportReportPDF(report reportContent) ([]byte, error) {
|
||||||
|
pdf := gofpdf.New("P", "mm", "A4", "")
|
||||||
|
|
||||||
|
fontLoaded := false
|
||||||
|
for _, candidate := range []string{
|
||||||
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
|
||||||
|
"/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc",
|
||||||
|
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
||||||
|
} {
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
pdf.AddUTF8Font("czk", "", candidate)
|
||||||
|
fontLoaded = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "B", 20)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "B", 20)
|
||||||
|
}
|
||||||
|
pdf.CellFormat(170, 12, report.Topic, "", 1, "C", false, 0, "")
|
||||||
|
pdf.Ln(8)
|
||||||
|
|
||||||
|
// 摘要
|
||||||
|
if strings.TrimSpace(report.Summary) != "" {
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "", 10)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
}
|
||||||
|
pdf.MultiCell(170, 5, report.Summary, "", "L", false)
|
||||||
|
pdf.Ln(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目录
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "B", 14)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
}
|
||||||
|
pdf.Cell(0, 8, "目录")
|
||||||
|
pdf.Ln(4)
|
||||||
|
for _, ch := range report.Chapters {
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "", 10)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
}
|
||||||
|
pdf.Cell(0, 6, " " + ch.Title)
|
||||||
|
pdf.Ln(5)
|
||||||
|
}
|
||||||
|
pdf.Ln(6)
|
||||||
|
|
||||||
|
// 章节
|
||||||
|
for _, ch := range report.Chapters {
|
||||||
|
pdf.AddPage()
|
||||||
|
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "B", 14)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "B", 14)
|
||||||
|
}
|
||||||
|
pdf.Cell(0, 8, ch.Title)
|
||||||
|
pdf.Ln(2)
|
||||||
|
|
||||||
|
if fontLoaded {
|
||||||
|
pdf.SetFont("czk", "", 10)
|
||||||
|
} else {
|
||||||
|
pdf.SetFont("Helvetica", "", 10)
|
||||||
|
}
|
||||||
|
pdf.MultiCell(170, 5, ch.Body, "", "L", false)
|
||||||
|
pdf.Ln(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := pdf.Output(&buf); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
@@ -118,6 +118,11 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
|
|||||||
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
|
r.PUT("/api/notes/:id", middleware.Auth(cfg), UpdateNote)
|
||||||
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
|
r.DELETE("/api/notes/:id", middleware.Auth(cfg), DeleteNote)
|
||||||
|
|
||||||
|
// ── 报告生成(普通员工可访问)──
|
||||||
|
r.POST("/api/report/generate", middleware.Auth(cfg), ReportGenerate)
|
||||||
|
r.POST("/api/report/export/pdf", middleware.Auth(cfg), ReportExportPDF)
|
||||||
|
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview)
|
||||||
|
|
||||||
// 管理员维护
|
// 管理员维护
|
||||||
admin := r.Group("/api")
|
admin := r.Group("/api")
|
||||||
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import http from './http'
|
||||||
|
|
||||||
|
export function generateReport(data) {
|
||||||
|
return http.post('/report/generate', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportReportPDF(data) {
|
||||||
|
return http.post('/report/export/pdf', data, { responseType: 'blob' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewReport(data) {
|
||||||
|
return http.post('/report/preview', data)
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@ const routes = [
|
|||||||
{ path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
{ path: 'apps/knowledge-operations', name: 'KnowledgeOperationsApp', meta: { workspaceKey: 'knowledge-operations' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||||
{ path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
{ path: 'apps/process-coordination', name: 'ProcessCoordinationApp', meta: { workspaceKey: 'process-coordination' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||||
{ path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
{ path: 'apps/report-generation', name: 'ReportGenerationApp', meta: { workspaceKey: 'report-generation' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||||
|
{ path: 'report-gen', name: 'ReportGeneration', meta: { requiresAdmin: true }, component: () => import('@/views/report/ReportGenerationPage.vue') },
|
||||||
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', meta: { workspaceKey: 'wechat-official-account' }, component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
|
{ path: 'apps/wechat-official-account', name: 'WechatOfficialAccountApp', meta: { workspaceKey: 'wechat-official-account' }, component: () => import('@/views/workbench/OfficialAccountWorkflowPage.vue') },
|
||||||
{ path: 'apps/logistics-fulfillment', name: 'LogisticsFulfillmentApp', meta: { workspaceKey: 'logistics-fulfillment' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
{ path: 'apps/logistics-fulfillment', name: 'LogisticsFulfillmentApp', meta: { workspaceKey: 'logistics-fulfillment' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||||
{ path: 'apps/hr-email-sorter', name: 'HrEmailSorter', meta: { workspaceKey: 'hr-email-sorter' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
{ path: 'apps/hr-email-sorter', name: 'HrEmailSorter', meta: { workspaceKey: 'hr-email-sorter' }, component: () => import('@/views/workbench/BusinessAppPage.vue') },
|
||||||
|
|||||||
@@ -0,0 +1,472 @@
|
|||||||
|
<template>
|
||||||
|
<div class="report-gen-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>报告生成</h1>
|
||||||
|
<p>输入报告主题,AI 自动规划章节并生成正式报告,支持导出 PDF</p>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<el-button type="primary" :loading="generating" @click="generateReport">
|
||||||
|
<el-icon v-if="!generating"><Edit /></el-icon>
|
||||||
|
{{ generating ? '生成中...' : '生成报告' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="main-layout">
|
||||||
|
<!-- 左侧:输入面板 -->
|
||||||
|
<section class="input-panel">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-title">报告信息</div>
|
||||||
|
|
||||||
|
<el-form :model="form" label-position="top" class="report-form">
|
||||||
|
<el-form-item label="报告主题">
|
||||||
|
<el-input
|
||||||
|
v-model="form.topic"
|
||||||
|
placeholder="例如:2024年企业AI应用实践报告"
|
||||||
|
maxlength="128"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="摘要/背景(可选)">
|
||||||
|
<el-input
|
||||||
|
v-model="form.summary"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="简要描述报告的背景与目的"
|
||||||
|
maxlength="500"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="期望章节(可选,留空由 AI 自动规划)">
|
||||||
|
<el-select
|
||||||
|
v-model="form.sections"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
allow-create
|
||||||
|
default-first-option
|
||||||
|
placeholder="选择或输入章节名称"
|
||||||
|
>
|
||||||
|
<el-option label="背景概述" value="背景概述" />
|
||||||
|
<el-option label="核心内容" value="核心内容" />
|
||||||
|
<el-option label="关键数据" value="关键数据" />
|
||||||
|
<el-option label="实施方案" value="实施方案" />
|
||||||
|
<el-option label="风险与对策" value="风险与对策" />
|
||||||
|
<el-option label="总结建议" value="总结建议" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="关联知识库(可选)">
|
||||||
|
<el-select v-model="form.knowledge_key" placeholder="选择知识库,留空则全局检索">
|
||||||
|
<el-option label="全局知识库" value="" />
|
||||||
|
<el-option
|
||||||
|
v-for="space in knowledgeSpaces"
|
||||||
|
:key="space.id"
|
||||||
|
:label="space.name"
|
||||||
|
:value="space.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 操作指引 -->
|
||||||
|
<div class="card guide-card">
|
||||||
|
<div class="card-title">操作指引</div>
|
||||||
|
<ol class="guide-steps">
|
||||||
|
<li>填写报告主题(必填)</li>
|
||||||
|
<li>输入摘要/背景(可选)</li>
|
||||||
|
<li>选择期望章节或留空由 AI 自动规划</li>
|
||||||
|
<li>选择关联知识库(可选)</li>
|
||||||
|
<li>点击「生成报告」预览内容</li>
|
||||||
|
<li>确认无误后导出 PDF</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 右侧:报告预览 -->
|
||||||
|
<section class="preview-panel">
|
||||||
|
<div class="card" :class="{ 'has-report': reportContent }">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="card-title-row">
|
||||||
|
<div class="card-title">报告预览</div>
|
||||||
|
<span v-if="reportContent" class="report-meta">
|
||||||
|
生成于 {{ reportContent?.created_at || '—' }}
|
||||||
|
</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-button size="small" @click="resetReport">
|
||||||
|
清除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 空状态 -->
|
||||||
|
<div v-if="!reportContent" class="empty-preview">
|
||||||
|
<div class="eyebrow">Report Generator</div>
|
||||||
|
<h3>报告预览区</h3>
|
||||||
|
<p>填写左侧报告信息后点击「生成报告」,AI 将自动创建报告并在右侧预览</p>
|
||||||
|
<div class="preview-icons">
|
||||||
|
<span class="icon-item">📄</span>
|
||||||
|
<span class="icon-item">📊</span>
|
||||||
|
<span class="icon-item">📑</span>
|
||||||
|
<span class="icon-item">📋</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 报告内容 -->
|
||||||
|
<div v-else class="report-preview">
|
||||||
|
<!-- 章节列表 -->
|
||||||
|
<div class="chapters-list">
|
||||||
|
<div
|
||||||
|
v-for="(chapter, idx) in reportContent.chapters"
|
||||||
|
:key="idx"
|
||||||
|
class="chapter-card"
|
||||||
|
>
|
||||||
|
<div class="chapter-header">
|
||||||
|
<span class="chapter-num">{{ idx + 1 }}</span>
|
||||||
|
<strong>{{ chapter.title }}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="chapter-body">
|
||||||
|
{{ chapter.body }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 { listKnowledgeSpaces } from '@/api/knowledge'
|
||||||
|
|
||||||
|
const generating = ref(false)
|
||||||
|
const exporting = ref(false)
|
||||||
|
const reportContent = ref(null)
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
topic: '',
|
||||||
|
summary: '',
|
||||||
|
sections: [],
|
||||||
|
knowledge_key: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const knowledgeSpaces = ref([])
|
||||||
|
|
||||||
|
async function generateReport() {
|
||||||
|
if (!form.value.topic.trim()) {
|
||||||
|
ElMessage.warning('请输入报告主题')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
generating.value = true
|
||||||
|
try {
|
||||||
|
const res = await generateReport({
|
||||||
|
topic: form.value.topic.trim(),
|
||||||
|
summary: form.value.summary,
|
||||||
|
sections: form.value.sections,
|
||||||
|
knowledge_key: form.value.knowledge_key,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res?.data) {
|
||||||
|
reportContent.value = res.data.report || res.data
|
||||||
|
ElMessage.success('报告生成成功')
|
||||||
|
} else {
|
||||||
|
reportContent.value = res?.data?.report || null
|
||||||
|
if (reportContent.value) {
|
||||||
|
ElMessage.success('报告生成成功')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error.message || '报告生成失败,请稍后重试')
|
||||||
|
reportContent.value = null
|
||||||
|
} finally {
|
||||||
|
generating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportToPDF() {
|
||||||
|
if (!reportContent.value) return
|
||||||
|
|
||||||
|
exporting.value = true
|
||||||
|
try {
|
||||||
|
const blob = await exportReportPDF({
|
||||||
|
report: reportContent.value,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 下载 PDF
|
||||||
|
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, '_')
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
document.body.removeChild(a)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
ElMessage.success('PDF 已下载')
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error('PDF 导出失败')
|
||||||
|
} finally {
|
||||||
|
exporting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetReport() {
|
||||||
|
reportContent.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res = await listKnowledgeSpaces()
|
||||||
|
const items = res?.data?.items || []
|
||||||
|
if (items.length) {
|
||||||
|
knowledgeSpaces.value = items.map((item) => ({
|
||||||
|
id: item.key,
|
||||||
|
name: item.name,
|
||||||
|
desc: item.description,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 静默失败
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.report-gen-page {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
border-bottom: 1px solid #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #1f2d3d;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7785;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 380px minmax(0, 1fr);
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04);
|
||||||
|
padding: 20px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f2d3d;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8a96a3;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-form .el-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-form .el-form-item :deep(.el-form-item__label) {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4b5968;
|
||||||
|
padding-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card {
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5f6b7a;
|
||||||
|
line-height: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-steps li {
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview .eyebrow {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #8a96a3;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview h3 {
|
||||||
|
margin: 12px 0 10px;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #1f2d3d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-preview p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6b7785;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-icons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-item {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f5f8ff;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-item:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview::-webkit-scrollbar-thumb {
|
||||||
|
background: #dcdfe6;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapters-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-card {
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f8f9fb;
|
||||||
|
border: 1px solid #eef1f6;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: linear-gradient(135deg, #f5f8ff 0%, #eef6ff 100%);
|
||||||
|
border-bottom: 1px solid #eef1f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-num {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #2b63d9;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-header strong {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1f2d3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-body {
|
||||||
|
padding: 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #3d4854;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.main-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -547,6 +547,14 @@ const templateCards = [
|
|||||||
actions: ['归并进展', '提炼风险', '生成周报'],
|
actions: ['归并进展', '提炼风险', '生成周报'],
|
||||||
results: ['周报文档'],
|
results: ['周报文档'],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'report-generation',
|
||||||
|
label: '报告生成',
|
||||||
|
summary: 'AI 驱动正式报告生成,自动规划章节,支持 PDF 导出。',
|
||||||
|
inputs: ['报告主题', '背景资料'],
|
||||||
|
actions: ['章节规划', 'LLM 撰写', 'PDF 导出'],
|
||||||
|
results: ['PDF 报告'],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const toolboxItems = [
|
const toolboxItems = [
|
||||||
|
|||||||
Reference in New Issue
Block a user