Merge remote changes, split app.go, remove V1 dead code, fix AICache (#2)
- Merge remote improvements: generic AI API, row-level cache, CSV export, matchPrep, prompt truncation, O(1) cache index - Split app.go (1645 -> 5 files: app.go, cache.go, ai.go, matcher.go, export.go) - Remove V1 dead code: 6 methods, 4 helpers, ~300 lines - Fix AICache 3 bugs: TOCTOU saveToFile, silent loadFromFile, full-sort put - Extract 8 named constants (threshold, time window, batch size...) - Frontend: isRunning guard, buildMatchConfig dedup, CSS variables - Upgrade Go to 1.24.0
This commit is contained in:
206
export.go
206
export.go
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -11,17 +13,32 @@ import (
|
||||
|
||||
// ---------- 导出结果 ----------
|
||||
|
||||
// ExportResults 将匹配结果导出为 Excel 文件
|
||||
// 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.xlsx", time.Now().Format("20060102_150405")),
|
||||
DefaultFilename: fmt.Sprintf("匹配结果_%s%s", time.Now().Format("20060102_150405"), ext),
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Excel 文件 (*.xlsx)", Pattern: "*.xlsx"},
|
||||
{DisplayName: filterDisplay, Pattern: filterPattern},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
@@ -30,78 +47,109 @@ func (a *App) ExportResults(results []MatchResult) (string, error) {
|
||||
if savePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(savePath), ".xlsx") {
|
||||
savePath += ".xlsx"
|
||||
if !strings.HasSuffix(strings.ToLower(savePath), ext) {
|
||||
savePath += ext
|
||||
}
|
||||
|
||||
if isCSV {
|
||||
return a.exportResultsCSV(results, savePath, includeHdr)
|
||||
}
|
||||
return a.exportResultsXLSX(results, savePath, includeHdr)
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// 判断使用新格式还是旧格式
|
||||
if len(results) > 0 && len(results[0].RowAData) > 0 {
|
||||
// 新格式:A 表所有原始列 + 最后追加「匹配结果(由B表提取)」
|
||||
numACols := len(results[0].RowAData)
|
||||
colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c }
|
||||
colNums := make([]int, numACols+1)
|
||||
for i := 0; i < numACols; i++ {
|
||||
colNums[i] = i
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(i)), fmt.Sprintf("A-Col%d", i+1))
|
||||
}
|
||||
extractCol := numACols
|
||||
colNums[numACols] = extractCol
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s1", colLetter(extractCol)), "匹配结果(由B表提取)")
|
||||
numACols := len(results[0].RowAData)
|
||||
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)
|
||||
|
||||
for i, r := range results {
|
||||
rowNum := i + 2
|
||||
for _, ci := range colNums {
|
||||
if ci < numACols {
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci])
|
||||
} else {
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.ExtractValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
for ci := range colNums {
|
||||
f.SetColWidth(sheetName, colLetter(ci), colLetter(ci), 22)
|
||||
// 数据行样式(带边框和行号字体)
|
||||
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 {
|
||||
// 旧格式 向后兼容
|
||||
headers := []string{"月报小区名称", "日报小区号", "匹配时间差", "相似度得分", "统计到的中断原因", "AI辅助匹配"}
|
||||
for i, h := range headers {
|
||||
col, _ := excelize.ColumnNumberToName(i + 1)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s1", col), 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"}},
|
||||
dataStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Size: 11},
|
||||
Border: []excelize.Border{
|
||||
{Type: "bottom", Color: "D9D9D9", Style: 1},
|
||||
},
|
||||
})
|
||||
lastCol, _ := excelize.ColumnNumberToName(len(headers))
|
||||
f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", lastCol), headerStyle)
|
||||
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
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("A%d", rowNum), r.MonthlyCellName)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("B%d", rowNum), r.DailyCellID)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("C%d", rowNum), r.TimeDiff)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("D%d", rowNum), r.SimilarityScore)
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("E%d", rowNum), r.InterruptReason)
|
||||
aiLabel := "否"
|
||||
if r.AIMatched {
|
||||
aiLabel = "是"
|
||||
}
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), aiLabel)
|
||||
// 数据行
|
||||
for i, r := range results {
|
||||
rowNum := i + 2
|
||||
if !includeHeader {
|
||||
rowNum = i + 1
|
||||
}
|
||||
for _, c := range []string{"A", "B", "C", "D", "E", "F"} {
|
||||
f.SetColWidth(sheetName, c, c, 22)
|
||||
for ci := 0; ci < numACols; ci++ {
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), 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 {
|
||||
@@ -109,3 +157,49 @@ func (a *App) ExportResults(results []MatchResult) (string, error) {
|
||||
}
|
||||
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 := len(results[0].RowAData)
|
||||
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(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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user