Files
office-data-matcher/export.go
sakuradairong 31a21d5364 fix: 修复审查发现的多个问题并补全开发环境
- 修复 MaxPreview=0 仍被覆盖为默认值的 bug
- 修复 API Endpoint 自动补全逻辑(避免 /v1/v1/chat/completions)
- 为 AI 配置与匹配状态字段增加并发锁
- AI 增强未匹配行改为按索引跟踪,避免重复行误判
- 无时间列时 AI 匹配 B 表行数可配置并增加截断警告
- 导出时防御参差不齐行导致的数组越界 panic
- Excel 读取时对单元格统一 TrimSpace
- 删除未使用的 minInt 函数
- 修复 wails.json 开发服务器地址为 http://localhost:5173
- 重新生成 Wails 前端绑定
- 新增 ai_test.go / export_test.go 单元测试
2026-06-23 20:55:32 +00:00

217 lines
5.5 KiB
Go

package main
import (
"bytes"
"fmt"
"os"
"strings"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
"github.com/xuri/excelize/v2"
)
// ---------- 导出结果 ----------
// ExportResults 将匹配结果导出为 Excel 或 CSV 文件
func (a *App) ExportResults(results []MatchResult) (string, error) {
if len(results) == 0 {
return "", fmt.Errorf("没有匹配结果可以导出")
}
a.dataMu.RLock()
useCSV := a.lastConfig.ExportFormat == "csv"
includeHdr := a.lastConfig.IncludeHeader
a.dataMu.RUnlock()
isCSV := useCSV
ext := ".xlsx"
filterDisplay := "Excel 文件 (*.xlsx)"
filterPattern := "*.xlsx"
if isCSV {
ext = ".csv"
filterDisplay = "CSV 文件 (*.csv)"
filterPattern = "*.csv"
}
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "导出匹配结果",
DefaultFilename: fmt.Sprintf("匹配结果_%s%s", time.Now().Format("20060102_150405"), ext),
Filters: []runtime.FileFilter{
{DisplayName: filterDisplay, Pattern: filterPattern},
},
})
if err != nil {
return "", fmt.Errorf("打开保存对话框失败: %v", err)
}
if savePath == "" {
return "", nil
}
if !strings.HasSuffix(strings.ToLower(savePath), ext) {
savePath += ext
}
if isCSV {
return a.exportResultsCSV(results, savePath, includeHdr)
}
return a.exportResultsXLSX(results, savePath, includeHdr)
}
// maxACols 计算结果中 A 表行的最大列数(处理参差不齐的行)
func maxACols(results []MatchResult) int {
max := 0
for _, r := range results {
if len(r.RowAData) > max {
max = len(r.RowAData)
}
}
return max
}
// exportHeaders 构建导出表头行(使用真实表头或回退默认)
func (a *App) exportHeaders(numACols int) []string {
a.dataMu.RLock()
hdrA := make([]string, len(a.headersA))
copy(hdrA, a.headersA)
a.dataMu.RUnlock()
headers := make([]string, 0, numACols+1)
if len(hdrA) >= numACols {
for _, h := range hdrA[:numACols] {
n := h
if n == "" {
n = fmt.Sprintf("Col%d", len(headers)+1)
}
headers = append(headers, n)
}
} else {
for i := 0; i < numACols; i++ {
headers = append(headers, fmt.Sprintf("A-Col%d", i+1))
}
}
headers = append(headers, "匹配结果(由B表提取)")
return headers
}
func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeHeader bool) (string, error) {
f := excelize.NewFile()
defer f.Close()
sheetName := "匹配结果"
f.SetSheetName("Sheet1", sheetName)
numACols := maxACols(results)
colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c }
headers := a.exportHeaders(numACols)
extractCol := numACols
// 表头
if includeHeader {
for i, h := range headers {
f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), h)
}
headerStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Bold: true, Size: 12, Color: "FFFFFF"},
Fill: excelize.Fill{Type: "pattern", Pattern: 1, Color: []string{"4472C4"}},
})
f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", colLetter(extractCol)), headerStyle)
// 数据行样式(带边框和行号字体)
dataStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 11},
Border: []excelize.Border{
{Type: "bottom", Color: "D9D9D9", Style: 1},
},
})
firstDataRow := 2
lastDataRow := len(results) + 1
for ci := 0; ci <= numACols; ci++ {
f.SetCellStyle(sheetName,
fmt.Sprintf("%s%d", colLetter(ci), firstDataRow),
fmt.Sprintf("%s%d", colLetter(ci), lastDataRow),
dataStyle)
}
} else {
dataStyle, _ := f.NewStyle(&excelize.Style{
Font: &excelize.Font{Size: 11},
Border: []excelize.Border{
{Type: "bottom", Color: "D9D9D9", Style: 1},
},
})
lastDataRow := len(results)
for ci := 0; ci <= numACols; ci++ {
f.SetCellStyle(sheetName,
fmt.Sprintf("%s%d", colLetter(ci), 1),
fmt.Sprintf("%s%d", colLetter(ci), lastDataRow),
dataStyle)
}
}
// 数据行
for i, r := range results {
rowNum := i + 2
if !includeHeader {
rowNum = i + 1
}
for ci := 0; ci < numACols; ci++ {
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), getCell(r.RowAData, ci))
}
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(extractCol), rowNum), r.ExtractValue)
}
// 列宽
for ci := 0; ci <= numACols; ci++ {
f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22)
}
if err := f.SaveAs(savePath); err != nil {
return "", fmt.Errorf("保存文件失败: %v", err)
}
return savePath, nil
}
func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHeader bool) (string, error) {
var buf bytes.Buffer
// 使用 UTF-8 BOM 帮助 Excel 正确识别编码
buf.Write([]byte{0xEF, 0xBB, 0xBF})
numACols := maxACols(results)
headers := a.exportHeaders(numACols)
// 表头行
if includeHeader {
for i, h := range headers {
if i > 0 {
buf.WriteByte(',')
}
buf.WriteString(csvEscape(h))
}
buf.WriteByte('\n')
}
// 数据行
for _, r := range results {
for ci := 0; ci < numACols; ci++ {
if ci > 0 {
buf.WriteByte(',')
}
buf.WriteString(csvEscape(getCell(r.RowAData, ci)))
}
buf.WriteByte(',')
buf.WriteString(csvEscape(r.ExtractValue))
buf.WriteByte('\n')
}
if err := os.WriteFile(savePath, buf.Bytes(), 0600); err != nil {
return "", fmt.Errorf("保存 CSV 文件失败: %v", err)
}
return savePath, nil
}
// csvEscape 对 CSV 字段进行转义(含逗号或引号时包裹双引号)
func csvEscape(s string) string {
if strings.ContainsAny(s, "\",\n\r") {
return `"` + strings.ReplaceAll(s, `"`, `""`) + `"`
}
return s
}