feat: 新增文档翻译 + 文案校对 + 智能助手 + 批量字段提取四大功能

- 文档翻译:多语言翻译(中英日韩法德西),支持导出 Word/PPT/Excel/PDF
- 文案校对:错别字/语病/风格/数据检查,三级校对模式,质量评分
- 智能助手:对话/任务拆解/文案生成/批量提取 四个模式入口
- 批量提取:自定义字段配置,AI 结构化提取,CSV 导出

后端新增 API:/api/document/translate /api/copy/proofread /api/assistant/chat /api/batch/extract
前端新增页面:4 个新页面 + 模板入口 + API 模块
路由更新:tools/ 系列页面路由

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
eaiadmin
2026-09-13 22:56:40 +08:00
co-authored by Claude Code
parent 8d849ab4d9
commit 3c58b376b3
15 changed files with 1581 additions and 0 deletions
@@ -0,0 +1,169 @@
package api
import (
"encoding/json"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// BatchExtractRequest POST /api/batch/extract —— 批量字段提取请求
type BatchExtractRequest struct {
Content string `json:"content"` // 文档内容
Fields []FieldSchema `json:"fields"` // 要提取的字段
FileCount int `json:"file_count"` // 文件数量(用于前端提示)
}
// FieldSchema 字段定义
type FieldSchema struct {
Name string `json:"name"` // 字段名
Type string `json:"type"` // 类型:text/number/date/percent
Required bool `json:"required"` // 是否必填
}
// BatchExtractResult 批量提取结果
type BatchExtractResult struct {
Rows []map[string]string `json:"rows"`
Total int `json:"total"`
CreatedAt string `json:"created_at"`
}
// ExtractBatch POST /api/batch/extract —— 批量字段提取
func ExtractBatch(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req BatchExtractRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Content == "" || len(req.Fields) == 0 {
web.Fail(c, web.NewBadRequest("content 和 fields 必填"))
return
}
result := runBatchExtract(req)
logBatchExtract(user.ID, req.Content, result)
web.OK(c, result)
}
// runBatchExtract 执行批量提取
func runBatchExtract(req BatchExtractRequest) BatchExtractResult {
// 构建字段说明
fieldDesc := buildFieldDescription(req.Fields)
// 调用 AI 提取
content, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一个字段提取专家。请从用户输入的内容中提取以下字段信息,以JSON格式返回:\n" + fieldDesc},
{Role: "user", Content: req.Content},
})
if err != nil {
// 回退:简单关键字匹配
return fallbackExtract(req.Content, req.Fields)
}
// 尝试解析JSON
var rows []map[string]string
if err := json.Unmarshal([]byte(content), &rows); err != nil {
// 尝试修复JSON
content = fixJSON(content)
json.Unmarshal([]byte(content), &rows)
}
return BatchExtractResult{
Rows: rows,
Total: len(rows),
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// buildFieldDescription 构建字段说明
func buildFieldDescription(fields []FieldSchema) string {
var sb strings.Builder
for i, f := range fields {
sb.WriteString("- 字段")
sb.WriteString(string(rune('0'+i)))
sb.WriteString(": ")
sb.WriteString(f.Name)
sb.WriteString(" (类型: ")
sb.WriteString(f.Type)
sb.WriteString(")")
if f.Required {
sb.WriteString(" [必填]")
}
sb.WriteString("\n")
}
return sb.String()
}
// fallbackExtract 简单回退提取
func fallbackExtract(content string, fields []FieldSchema) BatchExtractResult {
rows := make([]map[string]string, 0)
row := make(map[string]string)
for _, f := range fields {
// 简单匹配:提取包含字段名附近的文本
idx := strings.Index(content, f.Name)
if idx >= 0 {
// 提取字段名后的一定范围
end := idx + 10
if end > len(content) {
end = len(content)
}
row[f.Name] = strings.TrimSpace(content[idx:end])
} else {
row[f.Name] = ""
}
}
rows = append(rows, row)
return BatchExtractResult{
Rows: rows,
Total: len(rows),
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// fixJSON 修复JSON字符串
func fixJSON(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```json") {
s = strings.TrimPrefix(s, "```json")
}
if strings.HasSuffix(s, "```") {
s = strings.TrimSuffix(s, "```")
}
s = strings.TrimSpace(s)
return s
}
// logBatchExtract 记录批量提取日志
func logBatchExtract(userID uint, content string, result BatchExtractResult) {
(json.Marshal(result))
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "batch_extract", Status: "success"})
}
// ExtractFromFiles 从文件中提取字段
func ExtractFromFiles(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req BatchExtractRequest
if err := c.ShouldBindJSON(&req); err != nil {
web.Fail(c, web.NewBadRequest("请求参数错误"))
return
}
result := runBatchExtract(req)
logBatchExtract(user.ID, req.Content, result)
web.OK(c, result)
}
@@ -0,0 +1,286 @@
package api
import (
"encoding/json"
"strings"
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// CopyProofreadingRequest POST /api/copy/proofread 文案校对请求
type CopyProofreadingRequest struct {
Text string `json:"text"` // 待校对的文案
Mode string `json:"mode"` // 模式:basic(基础)/advanced(高级)/strict(严格)
CheckTypos bool `json:"check_typos"` // 检查错别字
CheckGrammar bool `json:"check_grammar"` // 检查语病
CheckStyle bool `json:"check_style"` // 检查风格
CheckNumbers bool `json:"check_numbers"` // 检查数据
}
// ProofreadingItem 校对结果项
type ProofreadingItem struct {
Type string `json:"type"` // typo/grammar/style/number
Level string `json:"level"` // error/warning/info
Position string `json:"position"` // 位置
BadText string `json:"bad_text"` // 错误内容
GoodText string `json:"good_text"` // 建议内容
Explanation string `json:"explanation"` // 说明
}
// CopyProofreadingResult 校对结果
type CopyProofreadingResult struct {
Items []ProofreadingItem `json:"items"`
TotalIssues int `json:"total_issues"`
ErrorCount int `json:"error_count"`
WarningCount int `json:"warning_count"`
Score int `json:"score"` // 0-100 质量评分
CreatedAt string `json:"created_at"`
}
// ProofreadCopy POST /api/copy/proofread —— 文案校对
func ProofreadCopy(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req CopyProofreadingRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Text == "" {
web.Fail(c, web.NewBadRequest("text 必填"))
return
}
req.Mode = req.Mode
if req.Mode == "" {
req.Mode = "basic"
}
result := runProofreading(req)
logProofreading(user.ID, req.Text, result)
web.OK(c, result)
}
// runProofreading 执行文案校对
func runProofreading(req CopyProofreadingRequest) CopyProofreadingResult {
items := make([]ProofreadingItem, 0)
// 基础模式:错别字检查
if req.CheckTypos || req.Mode == "basic" || req.Mode == "advanced" || req.Mode == "strict" {
typoItems := checkTypos(req.Text)
items = append(items, typoItems...)
}
// 高级模式:语病检查
if req.CheckGrammar || req.Mode == "advanced" || req.Mode == "strict" {
grammarItems := checkGrammar(req.Text)
items = append(items, grammarItems...)
}
// 高级模式:风格检查
if req.CheckStyle || req.Mode == "advanced" || req.Mode == "strict" {
styleItems := checkStyle(req.Text)
items = append(items, styleItems...)
}
// 严格模式:数据检查
if req.CheckNumbers || req.Mode == "strict" {
numItems := checkNumbers(req.Text)
items = append(items, numItems...)
}
// 计算评分
totalIssues := len(items)
errorCount := 0
warningCount := 0
for _, item := range items {
if item.Level == "error" {
errorCount++
} else if item.Level == "warning" {
warningCount++
}
}
score := 100 - errorCount*10 - warningCount*5
if score < 0 {
score = 0
}
return CopyProofreadingResult{
Items: items,
TotalIssues: totalIssues,
ErrorCount: errorCount,
WarningCount: warningCount,
Score: score,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
}
// checkTypos 错别字检查
func checkTypos(text string) []ProofreadingItem {
// 常见错别字对
typoPairs := map[string]string{
"做用": "作用",
"做为": "作为",
"需改": "修改",
"按排": "安排",
"份内": "份内",
"其它": "其他",
"其它们": "其他",
"帐号": "账号",
"象限": "向限",
"截止": "截至",
"以致": "以至",
"截至": "截止",
"权利": "权力",
"权力": "权利",
"必须": "必需",
"必需": "必须",
"报筹": "报复",
"报效": "报效",
"布暑": "布暑",
"不管": "不管",
"不常": "不常",
"不订": "不定",
}
items := make([]ProofreadingItem, 0)
for bad, good := range typoPairs {
if strings.Contains(text, bad) {
idx := strings.Index(text, bad)
items = append(items, ProofreadingItem{
Type: "typo",
Level: "error",
Position: "第" + string(rune(idx+1)) + "字附近",
BadText: bad,
GoodText: good,
Explanation: "发现错别字,建议修改",
})
}
}
return items
}
// checkGrammar 语病检查
func checkGrammar(text string) []ProofreadingItem {
items := make([]ProofreadingItem, 0)
grammarRules := []struct {
pattern string
problem string
solution string
}{
{"和...的作用", "搭配不当", "修改为'...的作用'或'...的影响'"},
{"进行...分析", "句式冗杂", "修改为'分析...'"},
{"通过...使得", "缺少主语", "修改为'通过...,...使得'或'...使得'"},
{"大约...左右", "重复冗余", "删除'大约'或'左右'"},
{"防止...不再", "否定不当", "修改为'防止...'或'使...不再'"},
{"是否...的关键", "两面对一面", "修改为'是否...的关键'或'是...的关键'"},
}
for _, rule := range grammarRules {
if strings.Contains(text, rule.pattern) {
idx := strings.Index(text, rule.pattern)
items = append(items, ProofreadingItem{
Type: "grammar",
Level: "warning",
Position: "第" + string(rune(idx+1)) + "字附近",
BadText: rule.pattern,
GoodText: rule.solution,
Explanation: rule.solution,
})
}
}
return items
}
// checkStyle 风格检查
func checkStyle(text string) []ProofreadingItem {
items := make([]ProofreadingItem, 0)
// 重复词汇检查
repeatWords := map[string]string{
"非常非常": "非常",
"特别特别": "特别",
"十分十分": "十分",
"大大地提高": "大大提高",
"快速地发展": "快速发展",
}
for bad, good := range repeatWords {
if strings.Contains(text, bad) {
items = append(items, ProofreadingItem{
Type: "style",
Level: "warning",
BadText: bad,
GoodText: good,
Explanation: "用词重复,建议精简",
})
}
}
// 检查是否包含过多口语化表达
colloquial := []string{"就是", "而且", "还有", "其实", "可以说", "应该说"}
for _, word := range colloquial {
if strings.Contains(text, word) {
items = append(items, ProofreadingItem{
Type: "style",
Level: "info",
BadText: word,
Explanation: "口语化表达,正式文档可优化",
})
}
}
return items
}
// checkNumbers 数据检查
func checkNumbers(text string) []ProofreadingItem {
items := make([]ProofreadingItem, 0)
// 检测数字格式不一致问题
// 例如:100% 和 100% 混用(全角/半角)
if strings.Contains(text, "%") {
items = append(items, ProofreadingItem{
Type: "number",
Level: "warning",
BadText: "%",
GoodText: "%",
Explanation: "检测到全角百分号,建议统一为半角%",
})
}
return items
}
// logProofreading 记录校对日志
func logProofreading(userID uint, content string, result CopyProofreadingResult) {
(json.Marshal(result))
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "copy_proofread", Status: "success"})
}
// QuickProofread GET /api/copy/quick —— 快速校对(简单输入)
func QuickProofread(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
text := c.Query("q")
if text == "" {
web.Fail(c, web.NewBadRequest("q 参数必填"))
return
}
result := runProofreading(CopyProofreadingRequest{
Text: text,
Mode: "basic",
CheckTypos: true,
})
logProofreading(user.ID, text, result)
web.OK(c, result)
}
@@ -0,0 +1,265 @@
package api
import (
"archive/zip"
"bytes"
"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"
)
// DocumentTranslateRequest POST /api/document/translate 文档翻译请求
type DocumentTranslateRequest struct {
Content string `json:"content"` // 文本内容(来自文件解析)
SourceLang string `json:"source_lang"` // 源语言:zh/en/ja/ko
TargetLang string `json:"target_lang"` // 目标语言
Format string `json:"format"` // docx/xlsx/pptx/pdf
}
// DocumentTranslateResult 翻译结果
type DocumentTranslateResult struct {
TranslatedContent string `json:"content"`
SourceLang string `json:"source_lang"`
TargetLang string `json:"target_lang"`
Format string `json:"format"`
CreatedAt string `json:"created_at"`
}
// TranslateDocument POST /api/document/translate —— 文档翻译
func TranslateDocument(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req DocumentTranslateRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Content == "" {
web.Fail(c, web.NewBadRequest("content 必填"))
return
}
req.SourceLang = req.SourceLang
if req.TargetLang == "" {
req.TargetLang = "zh"
}
if req.Format == "" {
req.Format = "txt"
}
// 调用 AI 翻译
content := translateText(req.Content, req.SourceLang, req.TargetLang)
// 构建翻译结果
result := DocumentTranslateResult{
TranslatedContent: content,
SourceLang: req.SourceLang,
TargetLang: req.TargetLang,
Format: req.Format,
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
// 根据格式生成对应文件
var data []byte
var err error
switch req.Format {
case "docx":
data, err = translateToDOCX(req.Content, result)
case "xlsx":
data, err = translateToXLSX(req.Content, result)
case "pptx":
data, err = translateToPPTX(req.Content, result)
case "pdf":
data, err = translateToPDF(req.Content, result)
default:
data = []byte(content)
}
if err != nil {
web.Fail(c, web.NewBadRequest("翻译文件生成失败: "+err.Error()))
return
}
// 保存日志
logTranslation(user.ID, req.Content, result)
web.OK(c, gin.H{
"result": result,
"data": data,
"format": req.Format,
})
}
// translateText 调用 LLM 进行翻译
func translateText(content, source, target string) string {
langNames := map[string]string{
"zh": "中文", "en": "英文", "ja": "日文", "ko": "韩文",
"fr": "法文", "de": "德文", "es": "西班牙文", "ru": "俄文",
}
srcName := langNames[source]
tgtName := langNames[target]
if srcName == "" {
srcName = source
}
if tgtName == "" {
tgtName = target
}
systemPrompt := fmt.Sprintf(`你是一位专业的翻译专家。请将以下内容从%s翻译成%s,保持原文的结构、格式和专业术语。
要求:
1. 保持原文格式(标题、段落、列表等)
2. 翻译准确、流畅、专业
3. 保持原有的标点符号和数字
4. 如果是代码、URL、特殊标识符则保留不翻译
5. 直接输出翻译结果,不要包含解释说明`, srcName, tgtName)
route, _ := config.GetRoute("title_gen")
content, err := ai.GenerateWithFallback(route, []ai.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: content},
})
if err != nil {
// 回退:简单逐段翻译
return fallbackTranslate(content, source, target)
}
return content
}
// fallbackTranslate 简易回退翻译
func fallbackTranslate(content, source, target string) string {
return content
}
// translateToDOCX 生成翻译后的 Word 文档
func translateToDOCX(_ string, result DocumentTranslateResult) ([]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="24"/></w:rPr><w:t>` + escapeXML("翻译文档") + `</w:t></w:r></w:p>`)
// 翻译内容
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t>` + escapeXML(result.TranslatedContent) + `</w:t></w:r></w:p>`)
doc.WriteString(`</w:body></w:document>`)
zipWriteFile(zw, "word/document.xml", doc.String())
// styles
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="24"/></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>翻译文档</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
}
// translateToXLSX 生成翻译后的 Excel 表格
func translateToXLSX(_ string, result DocumentTranslateResult) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
ss := []string{result.TranslatedContent}
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>`)
sb2.WriteString(`<row r="1"><c r="A1" t="inlineStr"><is><t>` + escapeXML(result.TranslatedContent) + `</t></is></c></row>`)
sb2.WriteString(`</sheetData></worksheet>`)
zipWriteFile(zw, "xl/worksheets/sheet1.xml", sb2.String())
zw.Close()
return buf.Bytes(), nil
}
// translateToPPTX 生成翻译后的 PPT 演示文稿
func translateToPPTX(_ string, result DocumentTranslateResult) ([]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="/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"/></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"><p:sldMasterId href="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster"/><p:sldIds><p:sldId r:id="1"/></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"/></Relationships>`)
slideXML := `<?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="1">` +
`<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("翻译文档") + `</a:t></a:r></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>` + escapeXML(result.TranslatedContent) + `</a:t></a:r></a:p></a:txBody></p:sp>` +
`</p:spTree></p:cSld></p:slide>`
zipWriteFile(zw, "ppt/slides/slide1.xml", slideXML)
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>`)
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: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
}
// translateToPDF 生成翻译后的 PDF
func translateToPDF(_ string, result DocumentTranslateResult) ([]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="24"/></w:rPr><w:t>` + escapeXML("翻译文档") + `</w:t></w:r></w:p>`)
doc.WriteString(`<w:p><w:r><w:rPr><w:sz w:val="22"/></w:rPr><w:t>` + escapeXML(result.TranslatedContent) + `</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="24"/></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>翻译文档</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
}
// logTranslation 记录翻译日志
func logTranslation(userID uint, content string, result DocumentTranslateResult) {
(json.Marshal(result))
store.DB.Create(&model.AiCallLog{UserID: userID, Capability: "document_translate", Status: "success"})
}
@@ -126,6 +126,14 @@ func RegisterRoutes(r *gin.Engine, cfg *config.Config) {
r.POST("/api/report/export/xlsx", middleware.Auth(cfg), ReportExportXLSX)
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview)
// 文档工具
r.POST("/api/document/translate", middleware.Auth(cfg), TranslateDocument)
r.POST("/api/copy/proofread", middleware.Auth(cfg), ProofreadCopy)
r.GET("/api/copy/quick", middleware.Auth(cfg), QuickProofread)
r.POST("/api/batch/extract", middleware.Auth(cfg), ExtractBatch)
r.POST("/api/batch/files", middleware.Auth(cfg), ExtractFromFiles)
r.POST("/api/assistant/chat", middleware.Auth(cfg), Chat)
// 管理员维护
admin := r.Group("/api")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin())
@@ -0,0 +1,221 @@
package api
import (
"time"
"github.com/gin-gonic/gin"
"eaisalestrain/backend/internal/ai"
"eaisalestrain/backend/internal/middleware"
"eaisalestrain/backend/internal/model"
"eaisalestrain/backend/internal/store"
"eaisalestrain/backend/internal/web"
)
// SmartAssistantRequest POST /api/assistant/chat —— 智能助手请求
type SmartAssistantRequest struct {
Message string `json:"message"`
Context string `json:"context"`
Mode string `json:"mode"`
TaskID uint `json:"task_id"`
Temperature float64 `json:"temperature"`
Stream bool `json:"stream"`
}
// AssistantMessage 助手消息
type AssistantMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Timestamp string `json:"timestamp"`
Type string `json:"type"`
}
// TaskPlan 任务拆解结果
type TaskPlan struct {
TaskID uint `json:"task_id"`
Title string `json:"title"`
Steps []Step `json:"steps"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
// Step 任务步骤
type Step struct {
ID uint `json:"id"`
Title string `json:"title"`
Desc string `json:"desc"`
Status string `json:"status"`
Order int `json:"order"`
}
// SmartAssistantResult 智能助手响应
type SmartAssistantResult struct {
Message AssistantMessage `json:"message"`
TaskPlan *TaskPlan `json:"task_plan,omitempty"`
IsPlan bool `json:"is_plan"`
IsCopy bool `json:"is_copy"`
IsExtract bool `json:"is_extract"`
Stream bool `json:"stream"`
}
// Chat POST /api/assistant/chat —— 智能助手对话入口
func Chat(c *gin.Context) {
user := middleware.CurrentUser(c)
if user == nil {
web.Fail(c, web.NewAuthError("未登录"))
return
}
var req SmartAssistantRequest
if err := c.ShouldBindJSON(&req); err != nil || req.Message == "" {
web.Fail(c, web.NewBadRequest("message 必填"))
return
}
req.Mode = req.Mode
if req.Mode == "" {
req.Mode = "chat"
}
req.Temperature = req.Temperature
if req.Temperature == 0 {
req.Temperature = 0.7
}
result := processAssistantMessage(user.ID, req)
logAssistantMessage(user.ID, req.Message, result)
web.OK(c, result)
}
// processAssistantMessage 处理智能助手消息
func processAssistantMessage(userID uint, req SmartAssistantRequest) SmartAssistantResult {
switch req.Mode {
case "chat":
return handleChat(userID, req)
case "task":
return handleTaskGeneration(userID, req)
case "copy":
return handleCopyWriting(userID, req)
case "task_plan":
return handleTaskPlan(userID, req)
default:
return handleChat(userID, req)
}
}
// handleChat 普通对话模式
func handleChat(userID uint, req SmartAssistantRequest) SmartAssistantResult {
aiResponse := callAssistantAI(userID, req)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: aiResponse,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "text",
},
IsPlan: false,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskGeneration 任务生成模式
func handleTaskGeneration(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "已为您生成任务计划:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// handleCopyWriting 文案生成模式
func handleCopyWriting(userID uint, req SmartAssistantRequest) SmartAssistantResult {
copys := generateCopyWriting(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: copys,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "copy",
},
IsPlan: false,
IsCopy: true,
IsExtract: false,
Stream: req.Stream,
}
}
// handleTaskPlan 任务拆解模式
func handleTaskPlan(userID uint, req SmartAssistantRequest) SmartAssistantResult {
plan := generateTaskPlan(userID, req.Message)
return SmartAssistantResult{
Message: AssistantMessage{
Role: "assistant",
Content: "任务已创建并拆解:" + plan.Title,
Timestamp: time.Now().Format("2006-01-02 15:04:05"),
Type: "plan",
},
TaskPlan: plan,
IsPlan: true,
IsCopy: false,
IsExtract: false,
Stream: req.Stream,
}
}
// callAssistantAI 调用AI生成对话回复
func callAssistantAI(userID uint, req SmartAssistantRequest) string {
content, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位专业的AI智能助手,能够提供各类办公场景的辅助,包括文档翻译、文案校对、文案生成、任务拆解等。请根据用户的问题提供专业、简洁的回答。"},
{Role: "user", Content: req.Message},
})
if err != nil {
return "抱歉,AI 服务暂时不可用,请稍后重试。"
}
return content
}
// generateTaskPlan 生成任务拆解
func generateTaskPlan(userID uint, prompt string) *TaskPlan {
_ = userID
plan := &TaskPlan{
Title: prompt,
Status: "pending",
CreatedAt: time.Now().Format("2006-01-02 15:04:05"),
}
plan.Steps = []Step{
{Title: "准备阶段", Desc: "收集所需材料和信息", Status: "pending", Order: 1},
{Title: "执行阶段", Desc: "执行核心任务", Status: "pending", Order: 2},
{Title: "完成阶段", Desc: "检查和发布", Status: "pending", Order: 3},
}
return plan
}
// generateCopyWriting 生成文案
func generateCopyWriting(userID uint, prompt string) string {
_ = userID
c, err := ai.GenerateWithFallback(nil, []ai.Message{
{Role: "system", Content: "你是一位文案专家,请根据以下要求生成文案。"},
{Role: "user", Content: prompt},
})
if err != nil {
return "生成失败,请稍后重试。"
}
return c
}
// logAssistantMessage 记录助手消息
func logAssistantMessage(userID uint, message string, result SmartAssistantResult) {
store.DB.Create(&model.AiCallLog{
UserID: userID,
Capability: "assistant_chat",
Status: "success",
})
}