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/export/xlsx", middleware.Auth(cfg), ReportExportXLSX)
r.POST("/api/report/preview", middleware.Auth(cfg), ReportPreview) 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 := r.Group("/api")
admin.Use(middleware.Auth(cfg), middleware.RequireAdmin()) 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",
})
}
+4
View File
@@ -0,0 +1,4 @@
import http from './http'
export function chatWithAssistant(data) {
return http.post('/assistant/chat', data)
}
+7
View File
@@ -0,0 +1,7 @@
import http from './http'
export function extractBatch(data) {
return http.post('/batch/extract', data)
}
export function extractFromFiles(data) {
return http.post('/batch/files', data)
}
+7
View File
@@ -0,0 +1,7 @@
import http from './http'
export function proofreadCopy(data) {
return http.post('/copy/proofread', data)
}
export function quickProofread(q) {
return http.get('/copy/quick', { params: { q } })
}
+4
View File
@@ -0,0 +1,4 @@
import http from './http'
export function translateDocument(data) {
return http.post('/document/translate', data)
}
+5
View File
@@ -24,6 +24,11 @@ const routes = [
{ 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: 'report-gen', name: 'ReportGeneration', meta: { requiresAdmin: true }, component: () => import('@/views/report/ReportGenerationPage.vue') },
{ path: 'tools/document-translate', name: 'DocumentTranslate', component: () => import('@/views/tools/DocumentTranslatePage.vue) },
{ path: 'tools/copy-proofreading', name: 'CopyProofreading', component: () => import('@/views/tools/CopyProofreadingPage.vue) },
{ path: 'tools/smart-assistant', name: 'SmartAssistant', component: () => import('@/views/tools/SmartAssistantPage.vue) },
{ path: 'tools/batch-extract', name: 'BatchExtract', component: () => import('@/views/tools/BatchExtractPage.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,156 @@
<template>
<div class="batch-extract-page">
<header class="page-header">
<div class="header-left">
<h1>批量字段提取</h1>
<p>从多份文档中自动提取结构化字段,导出为表格</p>
</div>
</header>
<div class="main-layout">
<section class="input-panel">
<div class="card">
<div class="card-title">字段配置</div>
<div class="field-config" v-for="(field, idx) in form.fields" :key="idx">
<el-input v-model="field.name" placeholder="字段名" />
<el-select v-model="field.type" style="width: 120px">
<el-option label="文本" value="text" />
<el-option label="数字" value="number" />
<el-option label="日期" value="date" />
<el-option label="百分比" value="percent" />
</el-select>
<el-checkbox v-model="field.required">必填</el-checkbox>
<el-button type="danger" text @click="removeField(idx)">✕</el-button>
</div>
<el-button @click="addField" style="margin-top: 12px">+ 添加字段</el-button>
<div class="card-title" style="margin-top: 24px">文档内容</div>
<el-input v-model="form.content" type="textarea" :rows="8" placeholder="粘贴文档内容,AI 将自动提取字段" />
<el-button type="primary" :loading="extracting" @click="extract" style="width:100%; margin-top: 12px">
<el-icon v-if="!extracting"><DataAnalysis /></el-icon>
{{ extracting ? '提取中...' : '开始提取' }}
</el-button>
</div>
</section>
<section class="preview-panel">
<div class="card">
<div class="card-header">
<div class="card-title">提取结果</div>
<el-button size="small" @click="exportCSV" v-if="result">导出 CSV</el-button>
</div>
<div v-if="!result" class="empty-preview">
<span class="icon-item">📊</span>
<h3>提取结果预览区</h3>
<p>在左侧配置字段并粘贴文档内容</p>
</div>
<div v-else class="result-table">
<table>
<thead>
<tr>
<th>#</th>
<th v-for="f in form.fields" :key="f.name">{{ f.name }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in result.rows" :key="idx">
<td>{{ idx + 1 }}</td>
<td v-for="f in form.fields" :key="f.name">{{ row[f.name] || '-' }}</td>
</tr>
</tbody>
</table>
<div class="result-summary">共提取 {{ result.total }} 条记录</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { DataAnalysis } from '@element-plus/icons-vue'
import { extractBatch } from '@/api/batch'
const extracting = ref(false)
const result = ref(null)
const form = ref({
content: '',
fields: [
{ name: '字段1', type: 'text', required: true },
],
})
function addField() {
form.value.fields.push({ name: '新字段', type: 'text', required: false })
}
function removeField(idx) {
form.value.fields.splice(idx, 1)
}
async function extract() {
if (!form.value.content.trim()) {
ElMessage.warning('请输入文档内容')
return
}
if (form.value.fields.length === 0) {
ElMessage.warning('请至少添加一个字段')
return
}
extracting.value = true
try {
const res = await extractBatch(form.value)
result.value = res.data
ElMessage.success('提取完成')
} catch (error) {
ElMessage.error(error.message || '提取失败')
} finally {
extracting.value = false
}
}
function exportCSV() {
if (!result.value || result.value.rows.length === 0) return
const fields = form.value.fields.map(f => f.name).join(',')
const rows = result.value.rows.map(r =>
form.value.fields.map(f => '"' + (r[f.name] || '') + '"').join(',')
).join('\n')
const csv = fields + '\n' + rows
const blob = new Blob([csv], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = '提取结果.csv'
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
</script>
<style scoped>
.batch-extract-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
.page-header { 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: 420px 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; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; margin-bottom: 12px; }
.field-config { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.field-config .el-input { flex: 1; }
.empty-preview { text-align: center; padding: 60px 20px; }
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
.empty-preview p { font-size: 13px; color: #6b7785; }
.icon-item { font-size: 48px; }
.result-table { overflow-x: auto; }
.result-table table { width: 100%; border-collapse: collapse; font-size: 13px; }
.result-table th, .result-table td { padding: 8px 12px; border: 1px solid #eef1f6; text-align: left; }
.result-table th { background: #f8f9fb; font-weight: 600; color: #1f2d3d; }
.result-summary { margin-top: 12px; font-size: 13px; color: #6b7785; text-align: right; }
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,154 @@
<template>
<div class="copy-proofreading-page">
<header class="page-header">
<div class="header-left">
<h1>文案校对</h1>
<p>智能检查错别字、语病、风格问题,给出修改建议</p>
</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">
<el-form-item label="校对模式">
<el-select v-model="form.mode" placeholder="选择校对模式">
<el-option label="基础 - 错别字" value="basic" />
<el-option label="高级 - 语病 + 风格" value="advanced" />
<el-option label="严格 - 全部检查" value="strict" />
</el-select>
</el-form-item>
<el-form-item label="文案内容">
<el-input v-model="form.text" type="textarea" :rows="12" placeholder="粘贴需要校对的文案" />
</el-form-item>
<el-button type="primary" :loading="proofreading" @click="proofread" style="width:100%">
<el-icon v-if="!proofreading"><Search /></el-icon>
{{ proofreading ? '校对中...' : '开始校对' }}
</el-button>
</el-form>
</div>
</section>
<section class="preview-panel">
<div class="card">
<div class="card-header">
<div class="card-title">校对结果</div>
<div class="card-actions" v-if="result">
<el-button size="small" @click="applyFixes"><el-icon><EditPen /></el-icon> 一键修复</el-button>
</div>
</div>
<div v-if="!result" class="empty-preview">
<span class="icon-item">🔍</span>
<h3>校对结果预览区</h3>
<p>在左侧粘贴文案后点击「开始校对」</p>
</div>
<div v-else>
<div class="score-bar">
<div class="score-label">质量评分</div>
<div class="score-value" :class="scoreClass">{{ result.score }} / 100</div>
<div class="score-stats">
<el-tag size="small" type="danger">错误 {{ result.error_count }}</el-tag>
<el-tag size="small" type="warning">警告 {{ result.warning_count }}</el-tag>
<el-tag size="small">建议 {{ result.total_issues }}</el-tag>
</div>
</div>
<div class="items-list">
<div v-for="(item, idx) in result.items" :key="idx" class="item-card" :class="item.level">
<div class="item-type">{{ typeLabel(item.type) }}</div>
<div class="item-content">
<span class="bad-text">{{ item.bad_text }}</span>
<span class="arrow">→</span>
<span class="good-text">{{ item.good_text }}</span>
</div>
<div class="item-desc">{{ item.explanation }}</div>
</div>
</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Search, EditPen } from '@element-plus/icons-vue'
import { proofreadCopy } from '@/api/copy'
const proofreading = ref(false)
const result = ref(null)
const form = ref({
text: '',
mode: 'basic',
})
const typeLabel = (t) => {
const map = { typo: '错别字', grammar: '语病', style: '风格', number: '数据' }
return map[t] || t
}
const scoreClass = computed(() => {
if (!result.value) return ''
const s = result.value.score
if (s >= 80) return 'score-good'
if (s >= 60) return 'score-warn'
return 'score-bad'
})
async function proofread() {
if (!form.value.text.trim()) {
ElMessage.warning('请输入文案内容')
return
}
proofreading.value = true
try {
const res = await proofreadCopy(form.value)
result.value = res.data
ElMessage.success('校对完成')
} catch (error) {
ElMessage.error(error.message || '校对失败')
} finally {
proofreading.value = false
}
}
import { computed } from 'vue'
</script>
<style scoped>
.copy-proofreading-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
.page-header { 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: 420px 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; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; }
.card-actions { display: flex; gap: 8px; }
.input-panel .card .el-form-item { margin-bottom: 16px; }
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
.empty-preview { text-align: center; padding: 60px 20px; }
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
.empty-preview p { font-size: 13px; color: #6b7785; }
.icon-item { font-size: 48px; }
.score-bar { display: flex; align-items: center; gap: 16px; margin-bottom: 16px; padding: 12px; background: #f8f9fb; border-radius: 8px; }
.score-label { font-size: 13px; color: #6b7785; }
.score-value { font-size: 28px; font-weight: 700; }
.score-good { color: #67c23a; }
.score-warn { color: #e6a23c; }
.score-bad { color: #f56c6c; }
.score-stats { margin-left: auto; display: flex; gap: 8px; }
.items-list { max-height: 500px; overflow-y: auto; }
.item-card { padding: 12px; border-radius: 8px; margin-bottom: 8px; border: 1px solid #eef1f6; }
.item-card.error { border-left: 3px solid #f56c6c; background: #fef0f0; }
.item-card.warning { border-left: 3px solid #e6a23c; background: #fdf6ec; }
.item-card.info { border-left: 3px solid #909399; background: #f4f4f5; }
.item-type { font-size: 12px; color: #909399; margin-bottom: 4px; }
.item-content { font-size: 14px; margin-bottom: 4px; }
.bad-text { color: #f56c6c; text-decoration: line-through; }
.arrow { color: #909399; margin: 0 8px; }
.good-text { color: #67c23a; font-weight: 600; }
.item-desc { font-size: 12px; color: #909399; }
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,170 @@
<template>
<div class="doc-translate-page">
<header class="page-header">
<div class="header-left">
<h1>文档翻译</h1>
<p>支持多语言翻译,可导出为 Word / PPT / Excel / PDF 文档</p>
</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">
<el-form-item label="源语言">
<el-select v-model="form.source_lang" placeholder="选择源语言">
<el-option label="中文" value="zh" />
<el-option label="English" value="en" />
<el-option label="日本語" value="ja" />
<el-option label="한국어" value="ko" />
<el-option label="Français" value="fr" />
<el-option label="Deutsch" value="de" />
<el-option label="Español" value="es" />
</el-select>
</el-form-item>
<el-form-item label="目标语言">
<el-select v-model="form.target_lang" placeholder="选择目标语言">
<el-option label="中文" value="zh" />
<el-option label="English" value="en" />
<el-option label="日本語" value="ja" />
<el-option label="한국어" value="ko" />
<el-option label="Français" value="fr" />
<el-option label="Deutsch" value="de" />
<el-option label="Español" value="es" />
</el-select>
</el-form-item>
<el-form-item label="翻译内容">
<el-input v-model="form.content" type="textarea" :rows="10" placeholder="粘贴需要翻译的文本内容" />
</el-form-item>
<el-form-item label="导出格式">
<el-select v-model="form.format" placeholder="选择导出格式">
<el-option label="TXT 文本" value="txt" />
<el-option label="Word 文档" value="docx" />
<el-option label="PPT 演示文稿" value="pptx" />
<el-option label="Excel 表格" value="xlsx" />
<el-option label="PDF 文档" value="pdf" />
</el-select>
</el-form-item>
<el-button type="primary" :loading="translating" @click="translateDocument" style="width:100%">
<el-icon v-if="!translating"><Translate /></el-icon>
{{ translating ? '翻译中...' : '开始翻译' }}
</el-button>
</el-form>
</div>
</section>
<section class="preview-panel">
<div class="card">
<div class="card-header">
<div class="card-title">翻译结果</div>
<div class="card-actions" v-if="translatedContent">
<el-button size="small" @click="downloadResult"><el-icon><Download /></el-icon> 下载</el-button>
<el-button size="small" @click="copyResult"><el-icon><DocumentCopy /></el-icon> 复制</el-button>
</div>
</div>
<div v-if="!translatedContent" class="empty-preview">
<span class="icon-item">📄</span>
<h3>翻译结果预览区</h3>
<p>在左侧输入文本并选择语言后点击「开始翻译」</p>
</div>
<div v-else class="result-preview">
<div class="result-info">
<el-tag size="small">{{ form.source_lang }} → {{ form.target_lang }}</el-tag>
<el-tag size="small" type="success">{{ form.format }}</el-tag>
</div>
<div class="result-text">{{ translatedContent }}</div>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { Translate, Download, DocumentCopy } from '@element-plus/icons-vue'
import { translateDocument as apiTranslate } from '@/api/document'
const translating = ref(false)
const translatedContent = ref('')
const resultBlob = ref(null)
const resultFormat = ref('txt')
const form = ref({
content: '',
source_lang: 'zh',
target_lang: 'en',
format: 'txt',
})
async function translateDocument() {
if (!form.value.content.trim()) {
ElMessage.warning('请输入翻译内容')
return
}
translating.value = true
try {
const res = await apiTranslate(form.value)
if (res?.data?.result) {
translatedContent.value = res.data.result.translated_content
resultFormat.value = res.data.format
if (res.data.data) {
resultBlob.value = res.data.data
}
ElMessage.success('翻译完成')
}
} catch (error) {
ElMessage.error(error.message || '翻译失败')
} finally {
translating.value = false
}
}
async function downloadResult() {
if (!resultBlob.value) {
ElMessage.warning('暂无可下载的文件')
return
}
const exts = { txt: '.txt', docx: '.docx', pptx: '.pptx', xlsx: '.xlsx', pdf: '.pdf' }
const ext = exts[resultFormat.value] || '.txt'
const url = URL.createObjectURL(resultBlob.value)
const a = document.createElement('a')
a.href = url
a.download = '翻译文档' + ext
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
async function copyResult() {
try {
await navigator.clipboard.writeText(translatedContent.value)
ElMessage.success('已复制到剪贴板')
} catch {
ElMessage.error('复制失败')
}
}
</script>
<style scoped>
.doc-translate-page { max-width: 1400px; margin: 0 auto; padding: 24px; }
.page-header { 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: 420px 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; }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 700; color: #1f2d3d; }
.card-actions { display: flex; gap: 8px; }
.input-panel .card .el-form-item { margin-bottom: 16px; }
.input-panel .el-input__inner, .input-panel .el-textarea__inner { font-size: 13px; }
.empty-preview { text-align: center; padding: 60px 20px; }
.empty-preview h3 { margin: 16px 0 8px; font-size: 18px; color: #1f2d3d; font-weight: 600; }
.empty-preview p { font-size: 13px; color: #6b7785; }
.icon-item { font-size: 48px; }
.result-preview { max-height: 600px; overflow-y: auto; }
.result-info { display: flex; gap: 8px; margin-bottom: 12px; }
.result-text { white-space: pre-wrap; word-break: break-all; font-size: 14px; line-height: 1.8; color: #3d4854; background: #f8f9fb; border-radius: 8px; padding: 16px; }
@media (max-width: 1200px) { .main-layout { grid-template-columns: 1fr; } }
</style>
@@ -0,0 +1,108 @@
<template>
<div class="smart-assistant-page">
<header class="page-header">
<div class="header-left">
<h1>智能助手</h1>
<p>AI 智能助手 — 对话 · 任务拆解 · 文案生成 · 批量提取</p>
</div>
</header>
<div class="mode-tabs">
<el-radio-group v-model="mode" size="large">
<el-radio-button value="chat">💬 对话</el-radio-button>
<el-radio-button value="task">📋 任务拆解</el-radio-button>
<el-radio-button value="copy">✏️ 文案生成</el-radio-button>
<el-radio-button value="extract">📊 批量提取</el-radio-button>
</el-radio-group>
</div>
<div class="chat-layout">
<div class="chat-panel">
<div class="messages" ref="messagesEl">
<div v-for="(msg, idx) in messages" :key="idx" class="message" :class="msg.role">
<div class="msg-avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</div>
<div class="msg-body">
<div class="msg-content">{{ msg.content }}</div>
<div v-if="msg.task_plan" class="msg-task-plan">
<div class="plan-title">{{ msg.task_plan.title }}</div>
<div v-for="step in msg.task_plan.steps" :key="step.id" class="plan-step">
<span class="step-num">{{ step.order }}</span>
<span class="step-title">{{ step.title }}</span>
<span class="step-desc">{{ step.desc }}</span>
</div>
</div>
<div class="msg-time">{{ msg.timestamp }}</div>
</div>
</div>
</div>
<div class="input-area">
<el-input v-model="inputText" :rows="3" type="textarea" :placeholder="placeholder" @keydown.enter.ctrl="send" />
<el-button type="primary" @click="send" :loading="sending">发送</el-button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
import { ElMessage } from 'element-plus'
import { chatWithAssistant } from '@/api/assistant'
const mode = ref('chat')
const inputText = ref('')
const messages = ref([
{ role: 'assistant', content: '你好!我是你的AI助手,可以帮你文档翻译、文案校对、任务拆解和批量字段提取。', timestamp: new Date().toLocaleTimeString(), type: 'text' }
])
const sending = ref(false)
const messagesEl = ref(null)
const placeholder = computed(() => {
const map = { chat: '输入你的问题...', task: '输入需要拆解的任务...', copy: '输入需要生成的文案类型...', extract: '输入包含字段的文档内容...' }
return map[mode.value] || '输入你的问题...'
})
async function send() {
if (!inputText.value.trim()) return
const userMsg = { role: 'user', content: inputText.value, timestamp: new Date().toLocaleTimeString() }
messages.value.push(userMsg)
inputText.value = ''
sending.value = true
try {
const res = await chatWithAssistant({ message: userMsg.content, mode: mode.value })
const reply = res.data.message
reply.timestamp = new Date().toLocaleTimeString()
messages.value.push(reply)
} catch (error) {
ElMessage.error(error.message || '发送失败')
} finally {
sending.value = false
}
}
</script>
<style scoped>
.smart-assistant-page { max-width: 1200px; margin: 0 auto; padding: 24px; }
.page-header { margin-bottom: 16px; padding-bottom: 16px; border-bottom: 1px solid #eef1f6; }
.page-header h1 { margin: 0 0 4px; font-size: 24px; color: #1f2d3d; }
.page-header p { margin: 0; font-size: 14px; color: #6b7785; }
.mode-tabs { margin-bottom: 16px; }
.chat-layout { display: flex; gap: 24px; }
.chat-panel { flex: 1; background: #fff; border-radius: 16px; border: 1px solid #eef1f6; box-shadow: 0 4px 12px rgba(31, 35, 41, 0.04); overflow: hidden; }
.messages { padding: 16px; max-height: 500px; overflow-y: auto; }
.message { display: flex; gap: 12px; margin-bottom: 16px; }
.message.user { flex-direction: row-reverse; }
.msg-avatar { width: 36px; height: 36px; border-radius: 50%; background: #f0f0f0; display: flex; align-items: center; justify-content: center; font-size: 18px; flex-shrink: 0; }
.msg-body { max-width: 70%; }
.msg-content { background: #f5f5f5; padding: 10px 14px; border-radius: 12px; font-size: 14px; line-height: 1.6; }
.message.user .msg-content { background: #2b63d9; color: #fff; }
.msg-time { font-size: 11px; color: #909399; margin-top: 4px; }
.msg-task-plan { margin-top: 8px; background: #f0f7ff; padding: 10px; border-radius: 8px; border: 1px solid #d6e8ff; }
.plan-title { font-weight: 600; font-size: 14px; color: #2b63d9; margin-bottom: 8px; }
.plan-step { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; font-size: 13px; }
.step-num { width: 22px; height: 22px; border-radius: 50%; background: #2b63d9; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 11px; flex-shrink: 0; }
.step-title { font-weight: 600; color: #1f2d3d; }
.step-desc { color: #6b7785; }
.input-area { padding: 16px; border-top: 1px solid #eef1f6; display: flex; gap: 8px; }
.input-area .el-input { flex: 1; }
</style>
@@ -555,6 +555,23 @@ const templateCards = [
actions: ['章节规划', 'LLM 撰写', 'PDF/Word/PPT/Excel 导出'], actions: ['章节规划', 'LLM 撰写', 'PDF/Word/PPT/Excel 导出'],
results: ['PDF 报告', 'Word 文档', 'PPT 演示文稿', 'Excel 表格'], results: ['PDF 报告', 'Word 文档', 'PPT 演示文稿', 'Excel 表格'],
}, },
{
key: 'document-translate',
label: '文档翻译 DW',
summary: 多语言文档翻译,支持 Word/PPT/Excel/PDF 格式导出。',
inputs: ['原文内容', 源语言', 目标语言'],
actions: ['AI 翻译', 格式保留', 文档生成'],
results: ['翻译文档', 翻译文本'],
},
{
key: 'copy-proofreading',
label: '文案校对 DW',
summary: AI 文案校对,检查错别字、语病、风格问题,一键修复。',
inputs: ['文案内容', 校对模式'],
actions: ['错别字检查', 语病检查', 风格建议'],
results: ['校对报告', 修改建议'],
}
},
] ]
const toolboxItems = [ const toolboxItems = [