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:
sakuradairong
2026-06-05 14:46:55 +08:00
10 changed files with 752 additions and 370 deletions

206
export.go
View File

@@ -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
}