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 单元测试
This commit is contained in:
2026-06-23 20:39:53 +00:00
parent 2b17760fbd
commit 31a21d5364
11 changed files with 229 additions and 67 deletions

36
ai.go
View File

@@ -64,21 +64,35 @@ func hashPrompt(messages []deepseekMessage) string {
return hex.EncodeToString(h.Sum(nil)) return hex.EncodeToString(h.Sum(nil))
} }
// resolveAIEndpoint 根据用户输入补全为完整的 OpenAI 兼容 chat completions URL
func resolveAIEndpoint(apiEndpoint string) string {
endpoint := strings.TrimRight(apiEndpoint, "/")
switch {
case endpoint == "":
return "https://api.deepseek.com/v1/chat/completions"
case strings.HasSuffix(endpoint, "/chat/completions"):
return endpoint
case strings.HasSuffix(endpoint, "/v1"):
return endpoint + "/chat/completions"
default:
return endpoint + "/v1/chat/completions"
}
}
// callAIAPI 调用 OpenAI 兼容 APIDeepseek / OpenAI / 本地模型 等) // callAIAPI 调用 OpenAI 兼容 APIDeepseek / OpenAI / 本地模型 等)
func (a *App) callAIAPI(messages []deepseekMessage) (string, error) { func (a *App) callAIAPI(messages []deepseekMessage) (string, error) {
if a.apiKey == "" { a.aiMu.RLock()
apiKey := a.apiKey
apiEndpoint := a.apiEndpoint
apiModel := a.apiModel
a.aiMu.RUnlock()
if apiKey == "" {
return "", fmt.Errorf("请先设置 AI API 密钥") return "", fmt.Errorf("请先设置 AI API 密钥")
} }
// 默认值 endpoint := resolveAIEndpoint(apiEndpoint)
endpoint := strings.TrimRight(a.apiEndpoint, "/") model := apiModel
if endpoint == "" {
endpoint = "https://api.deepseek.com/v1/chat/completions"
} else if !strings.HasSuffix(endpoint, "/chat/completions") {
// 自动补齐 OpenAI 兼容路径(用户只需填 base URL
endpoint += "/v1/chat/completions"
}
model := a.apiModel
if model == "" { if model == "" {
model = deepseekModel model = deepseekModel
} }
@@ -105,7 +119,7 @@ func (a *App) callAIAPI(messages []deepseekMessage) (string, error) {
return "", fmt.Errorf("创建请求失败: %v", err) return "", fmt.Errorf("创建请求失败: %v", err)
} }
httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey) httpReq.Header.Set("Authorization", "Bearer "+apiKey)
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq) resp, err := client.Do(httpReq)

51
ai_test.go Normal file
View File

@@ -0,0 +1,51 @@
package main
import "testing"
func TestResolveAIEndpoint(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "empty uses Deepseek default",
input: "",
expected: "https://api.deepseek.com/v1/chat/completions",
},
{
name: "base URL without trailing slash",
input: "https://api.openai.com",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "base URL with trailing slash",
input: "https://api.openai.com/",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "v1 base URL",
input: "https://api.openai.com/v1",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "full completions URL kept as is",
input: "https://api.openai.com/v1/chat/completions",
expected: "https://api.openai.com/v1/chat/completions",
},
{
name: "local base URL",
input: "http://localhost:8080",
expected: "http://localhost:8080/v1/chat/completions",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveAIEndpoint(tt.input)
if got != tt.expected {
t.Errorf("resolveAIEndpoint(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}

56
app.go
View File

@@ -92,9 +92,10 @@ type MatchConfig struct {
AllMatches bool `json:"allMatches"` // true=返回该A行所有匹配(>=阈值)而非仅最佳 AllMatches bool `json:"allMatches"` // true=返回该A行所有匹配(>=阈值)而非仅最佳
CaseSensitive bool `json:"caseSensitive"` // true=大小写敏感匹配 CaseSensitive bool `json:"caseSensitive"` // true=大小写敏感匹配
SortBy string `json:"sortBy"` // "similarity" / "timeDiff" / ""=不排序 SortBy string `json:"sortBy"` // "similarity" / "timeDiff" / ""=不排序
MaxPreview int `json:"maxPreview"` // 调试日志中打印的前 N 条比对详情0=不打印 MaxPreview int `json:"maxPreview"` // 调试日志中打印的前 N 条比对详情0=不打印
ExportFormat string `json:"exportFormat"` // "xlsx"(默认) / "csv" MaxBRowsNoTime int `json:"maxBRowsNoTime"` // 无时间列时 AI 匹配最多取 B 表多少行0=使用默认值 200
IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行 ExportFormat string `json:"exportFormat"` // "xlsx"(默认) / "csv"
IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行
} }
// AICacheInfo 缓存状态信息 // AICacheInfo 缓存状态信息
@@ -107,10 +108,13 @@ type AICacheInfo struct {
type App struct { type App struct {
ctx context.Context ctx context.Context
aiCache *AICache
// AI API 配置(并发访问需要加锁)
aiMu sync.RWMutex
apiKey string // AI API 密钥(兼容 OpenAI/Deepseek/本地模型) apiKey string // AI API 密钥(兼容 OpenAI/Deepseek/本地模型)
apiEndpoint string // API 端点(默认 https://api.deepseek.com/v1/chat/completions apiEndpoint string // API 端点(默认 https://api.deepseek.com/v1/chat/completions
apiModel string // 模型名称(默认 deepseek-chat apiModel string // 模型名称(默认 deepseek-chat
aiCache *AICache
// 最近一次匹配的配置和表头(供导出使用) // 最近一次匹配的配置和表头(供导出使用)
dataMu sync.RWMutex dataMu sync.RWMutex
@@ -150,6 +154,8 @@ func (a *App) emitProgress(current, total int, message, phase string) {
// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中,向后兼容) // SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中,向后兼容)
func (a *App) SetDeepseekAPIKey(key string) string { func (a *App) SetDeepseekAPIKey(key string) string {
a.aiMu.Lock()
defer a.aiMu.Unlock()
a.apiKey = strings.TrimSpace(key) a.apiKey = strings.TrimSpace(key)
if a.apiKey == "" { if a.apiKey == "" {
return "已清除 Deepseek API 密钥" return "已清除 Deepseek API 密钥"
@@ -159,6 +165,8 @@ func (a *App) SetDeepseekAPIKey(key string) string {
// SetAIConfig 统一设置 AI API 配置(端点、模型、密钥) // SetAIConfig 统一设置 AI API 配置(端点、模型、密钥)
func (a *App) SetAIConfig(endpoint, model, key string) string { func (a *App) SetAIConfig(endpoint, model, key string) string {
a.aiMu.Lock()
defer a.aiMu.Unlock()
if endpoint != "" { if endpoint != "" {
a.apiEndpoint = strings.TrimSpace(endpoint) a.apiEndpoint = strings.TrimSpace(endpoint)
} }
@@ -173,6 +181,8 @@ func (a *App) SetAIConfig(endpoint, model, key string) string {
// SetAPIKey 设置 AI API 密钥(仅保存在内存中) // SetAPIKey 设置 AI API 密钥(仅保存在内存中)
func (a *App) SetAPIKey(key string) string { func (a *App) SetAPIKey(key string) string {
a.aiMu.Lock()
defer a.aiMu.Unlock()
a.apiKey = strings.TrimSpace(key) a.apiKey = strings.TrimSpace(key)
if a.apiKey == "" { if a.apiKey == "" {
return "已清除 AI API 密钥" return "已清除 AI API 密钥"
@@ -182,11 +192,15 @@ func (a *App) SetAPIKey(key string) string {
// GetDeepseekStatus 返回是否已配置 Deepseek API 密钥 // GetDeepseekStatus 返回是否已配置 Deepseek API 密钥
func (a *App) GetDeepseekStatus() bool { func (a *App) GetDeepseekStatus() bool {
a.aiMu.RLock()
defer a.aiMu.RUnlock()
return a.apiKey != "" return a.apiKey != ""
} }
// GetAIStatus 返回 AI API 配置状态 // GetAIStatus 返回 AI API 配置状态
func (a *App) GetAIStatus() map[string]string { func (a *App) GetAIStatus() map[string]string {
a.aiMu.RLock()
defer a.aiMu.RUnlock()
return map[string]string{ return map[string]string{
"ready": fmt.Sprintf("%v", a.apiKey != ""), "ready": fmt.Sprintf("%v", a.apiKey != ""),
"endpoint": a.apiEndpoint, "endpoint": a.apiEndpoint,
@@ -261,18 +275,21 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return a.runMatchOnData(prep, config) results, _, err := a.runMatchOnData(prep, config)
return results, err
} }
// runMatchOnData 在已读取的数据上执行匹配 // runMatchOnData 在已读取的数据上执行匹配
func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult, error) { // 返回匹配结果,以及被匹配到的 A 表行索引集合(供 AI 增强阶段判断未匹配行)
func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult, map[int]bool, error) {
useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0 useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0
totalA := len(prep.dataA) totalA := len(prep.dataA)
var results []MatchResult var results []MatchResult
matchedAIndices := make(map[int]bool)
useAllMatches := config.AllMatches useAllMatches := config.AllMatches
maxPreview := config.MaxPreview maxPreview := config.MaxPreview
if maxPreview <= 0 { if maxPreview < 0 {
maxPreview = DefaultMaxPreview maxPreview = DefaultMaxPreview
} }
@@ -369,6 +386,7 @@ func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult
} }
} }
results = append(results, candidates...) results = append(results, candidates...)
matchedAIndices[i] = true
} }
} }
@@ -387,14 +405,17 @@ func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult
a.emitProgress(totalA, totalA, a.emitProgress(totalA, totalA,
fmt.Sprintf("匹配完成!共匹配成功 %d 条记录", len(results)), "done") fmt.Sprintf("匹配完成!共匹配成功 %d 条记录", len(results)), "done")
return results, nil return results, matchedAIndices, nil
} }
// RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动) // RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动)
func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) { func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
a.aiMu.RLock()
if a.apiKey == "" { if a.apiKey == "" {
a.aiMu.RUnlock()
return nil, fmt.Errorf("请先设置 AI API 密钥") return nil, fmt.Errorf("请先设置 AI API 密钥")
} }
a.aiMu.RUnlock()
prep, err := a.prepareMatch(config) prep, err := a.prepareMatch(config)
if err != nil { if err != nil {
@@ -402,20 +423,15 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
} }
// 1. 先执行基础匹配 // 1. 先执行基础匹配
results, err := a.runMatchOnData(prep, config) results, matchedAIndices, err := a.runMatchOnData(prep, config)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// 2. 找出未被基础匹配覆盖的 A 表行 // 2. 找出未被基础匹配覆盖的 A 表行(按索引,避免重复行内容相同导致误判)
matchedSet := make(map[string]bool)
for _, r := range results {
matchedSet[strings.Join(r.RowAData, "\x00")] = true
}
var unmatchedA [][]string var unmatchedA [][]string
for _, row := range prep.dataA { for i, row := range prep.dataA {
if !matchedSet[strings.Join(row, "\x00")] { if !matchedAIndices[i] {
unmatchedA = append(unmatchedA, row) unmatchedA = append(unmatchedA, row)
} }
} }
@@ -519,7 +535,11 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
} }
} else { } else {
// 无时间列时限制 B 表条数以控制 token 消耗 // 无时间列时限制 B 表条数以控制 token 消耗
maxB := min(defaultMaxBNoTime, len(prep.dataB)) if len(prep.dataB) > prep.maxBRowsNoTime {
fmt.Printf("[AI-WARN] 无时间列B 表共 %d 行AI 匹配仅取前 %d 行(可在高级设置调整)\n",
len(prep.dataB), prep.maxBRowsNoTime)
}
maxB := min(prep.maxBRowsNoTime, len(prep.dataB))
relevantB = prep.dataB[:maxB] relevantB = prep.dataB[:maxB]
} }

View File

@@ -57,6 +57,17 @@ func (a *App) ExportResults(results []MatchResult) (string, error) {
return a.exportResultsXLSX(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 构建导出表头行(使用真实表头或回退默认) // exportHeaders 构建导出表头行(使用真实表头或回退默认)
func (a *App) exportHeaders(numACols int) []string { func (a *App) exportHeaders(numACols int) []string {
a.dataMu.RLock() a.dataMu.RLock()
@@ -88,7 +99,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeH
sheetName := "匹配结果" sheetName := "匹配结果"
f.SetSheetName("Sheet1", sheetName) f.SetSheetName("Sheet1", sheetName)
numACols := len(results[0].RowAData) numACols := maxACols(results)
colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c } colLetter := func(n int) string { c, _ := excelize.ColumnNumberToName(n + 1); return c }
headers := a.exportHeaders(numACols) headers := a.exportHeaders(numACols)
@@ -142,7 +153,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeH
rowNum = i + 1 rowNum = i + 1
} }
for ci := 0; ci < numACols; ci++ { 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(ci), rowNum), getCell(r.RowAData, ci))
} }
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(extractCol), rowNum), r.ExtractValue) f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(extractCol), rowNum), r.ExtractValue)
} }
@@ -163,7 +174,7 @@ func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHe
// 使用 UTF-8 BOM 帮助 Excel 正确识别编码 // 使用 UTF-8 BOM 帮助 Excel 正确识别编码
buf.Write([]byte{0xEF, 0xBB, 0xBF}) buf.Write([]byte{0xEF, 0xBB, 0xBF})
numACols := len(results[0].RowAData) numACols := maxACols(results)
headers := a.exportHeaders(numACols) headers := a.exportHeaders(numACols)
// 表头行 // 表头行
@@ -183,7 +194,7 @@ func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHe
if ci > 0 { if ci > 0 {
buf.WriteByte(',') buf.WriteByte(',')
} }
buf.WriteString(csvEscape(r.RowAData[ci])) buf.WriteString(csvEscape(getCell(r.RowAData, ci)))
} }
buf.WriteByte(',') buf.WriteByte(',')
buf.WriteString(csvEscape(r.ExtractValue)) buf.WriteString(csvEscape(r.ExtractValue))

57
export_test.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import "testing"
func TestMaxACols(t *testing.T) {
tests := []struct {
name string
results []MatchResult
expected int
}{
{
name: "uniform rows",
results: []MatchResult{{RowAData: []string{"a", "b", "c"}}, {RowAData: []string{"d", "e", "f"}}},
expected: 3,
},
{
name: "ragged rows",
results: []MatchResult{{RowAData: []string{"a"}}, {RowAData: []string{"b", "c", "d", "e"}}, {RowAData: []string{"f", "g"}}},
expected: 4,
},
{
name: "empty rows",
results: []MatchResult{{RowAData: []string{}}, {RowAData: []string{"a"}}},
expected: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := maxACols(tt.results)
if got != tt.expected {
t.Errorf("maxACols() = %d, want %d", got, tt.expected)
}
})
}
}
func TestCSVEscape(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"hello", "hello"},
{"has,comma", `"has,comma"`},
{"has\"quote", `"has""quote"`},
{"line\nbreak", "\"line\nbreak\""},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := csvEscape(tt.input)
if got != tt.expected {
t.Errorf("csvEscape(%q) = %q, want %q", tt.input, got, tt.expected)
}
})
}
}

View File

@@ -28,6 +28,7 @@ const matchConfig = ref({
caseSensitive: false, caseSensitive: false,
sortBy: '', sortBy: '',
maxPreview: 3, maxPreview: 3,
maxBRowsNoTime: 200,
exportFormat: 'xlsx', exportFormat: 'xlsx',
includeHeader: true includeHeader: true
}) })
@@ -130,6 +131,7 @@ function buildMatchConfig() {
caseSensitive: matchConfig.value.caseSensitive || false, caseSensitive: matchConfig.value.caseSensitive || false,
sortBy: matchConfig.value.sortBy || '', sortBy: matchConfig.value.sortBy || '',
maxPreview: Number(matchConfig.value.maxPreview) || 0, maxPreview: Number(matchConfig.value.maxPreview) || 0,
maxBRowsNoTime: Number(matchConfig.value.maxBRowsNoTime) || 0,
exportFormat: matchConfig.value.exportFormat || 'xlsx', exportFormat: matchConfig.value.exportFormat || 'xlsx',
includeHeader: matchConfig.value.includeHeader !== false includeHeader: matchConfig.value.includeHeader !== false
} }
@@ -420,6 +422,13 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
<span>1</span> <span>1</span>
</div> </div>
</div> </div>
<div class="form-row form-row--cols">
<label class="form-label">
AI 取样行数
<span class="form-hint">无时间列时最多取 B 表前 N 0=默认 200</span>
</label>
<input type="number" v-model.number="matchConfig.maxBRowsNoTime" class="form-input narrow" min="0" max="10000" step="10" :disabled="loading" />
</div>
<div class="form-row form-row--cols"> <div class="form-row form-row--cols">
<label class="form-label"> <label class="form-label">
匹配策略 匹配策略

10
frontend/wailsjs/go/main/App.d.ts vendored Normal file → Executable file
View File

@@ -2,10 +2,6 @@
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {main} from '../models'; import {main} from '../models';
export function CalculateSimilarity(arg1:string,arg2:string):Promise<number>;
export function CleanString(arg1:string):Promise<string>;
export function ClearAICache():Promise<string>; export function ClearAICache():Promise<string>;
export function ExportResults(arg1:Array<main.MatchResult>):Promise<string>; export function ExportResults(arg1:Array<main.MatchResult>):Promise<string>;
@@ -14,14 +10,12 @@ export function GetAICacheInfo():Promise<main.AICacheInfo>;
export function GetAIStatus():Promise<Record<string, string>>; export function GetAIStatus():Promise<Record<string, string>>;
export function OpenDailyReport():Promise<string>; export function GetDeepseekStatus():Promise<boolean>;
export function OpenFileA():Promise<string>; export function OpenFileA():Promise<string>;
export function OpenFileB():Promise<string>; export function OpenFileB():Promise<string>;
export function OpenMonthlyReport():Promise<string>;
export function ParseHeaders(arg1:string):Promise<Array<string>>; export function ParseHeaders(arg1:string):Promise<Array<string>>;
export function RunMatch(arg1:main.MatchConfig):Promise<Array<main.MatchResult>>; export function RunMatch(arg1:main.MatchConfig):Promise<Array<main.MatchResult>>;
@@ -31,3 +25,5 @@ export function RunMatchWithAI(arg1:main.MatchConfig):Promise<Array<main.MatchRe
export function SetAIConfig(arg1:string,arg2:string,arg3:string):Promise<string>; export function SetAIConfig(arg1:string,arg2:string,arg3:string):Promise<string>;
export function SetAPIKey(arg1:string):Promise<string>; export function SetAPIKey(arg1:string):Promise<string>;
export function SetDeepseekAPIKey(arg1:string):Promise<string>;

20
frontend/wailsjs/go/main/App.js Normal file → Executable file
View File

@@ -2,14 +2,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function CalculateSimilarity(arg1, arg2) {
return window['go']['main']['App']['CalculateSimilarity'](arg1, arg2);
}
export function CleanString(arg1) {
return window['go']['main']['App']['CleanString'](arg1);
}
export function ClearAICache() { export function ClearAICache() {
return window['go']['main']['App']['ClearAICache'](); return window['go']['main']['App']['ClearAICache']();
} }
@@ -26,8 +18,8 @@ export function GetAIStatus() {
return window['go']['main']['App']['GetAIStatus'](); return window['go']['main']['App']['GetAIStatus']();
} }
export function OpenDailyReport() { export function GetDeepseekStatus() {
return window['go']['main']['App']['OpenDailyReport'](); return window['go']['main']['App']['GetDeepseekStatus']();
} }
export function OpenFileA() { export function OpenFileA() {
@@ -38,10 +30,6 @@ export function OpenFileB() {
return window['go']['main']['App']['OpenFileB'](); return window['go']['main']['App']['OpenFileB']();
} }
export function OpenMonthlyReport() {
return window['go']['main']['App']['OpenMonthlyReport']();
}
export function ParseHeaders(arg1) { export function ParseHeaders(arg1) {
return window['go']['main']['App']['ParseHeaders'](arg1); return window['go']['main']['App']['ParseHeaders'](arg1);
} }
@@ -61,3 +49,7 @@ export function SetAIConfig(arg1, arg2, arg3) {
export function SetAPIKey(arg1) { export function SetAPIKey(arg1) {
return window['go']['main']['App']['SetAPIKey'](arg1); return window['go']['main']['App']['SetAPIKey'](arg1);
} }
export function SetDeepseekAPIKey(arg1) {
return window['go']['main']['App']['SetDeepseekAPIKey'](arg1);
}

8
frontend/wailsjs/go/models.ts Normal file → Executable file
View File

@@ -29,6 +29,7 @@ export namespace main {
caseSensitive: boolean; caseSensitive: boolean;
sortBy: string; sortBy: string;
maxPreview: number; maxPreview: number;
maxBRowsNoTime: number;
exportFormat: string; exportFormat: string;
includeHeader: boolean; includeHeader: boolean;
@@ -52,6 +53,7 @@ export namespace main {
this.caseSensitive = source["caseSensitive"]; this.caseSensitive = source["caseSensitive"];
this.sortBy = source["sortBy"]; this.sortBy = source["sortBy"];
this.maxPreview = source["maxPreview"]; this.maxPreview = source["maxPreview"];
this.maxBRowsNoTime = source["maxBRowsNoTime"];
this.exportFormat = source["exportFormat"]; this.exportFormat = source["exportFormat"];
this.includeHeader = source["includeHeader"]; this.includeHeader = source["includeHeader"];
} }
@@ -60,6 +62,9 @@ export namespace main {
rowAData: string[]; rowAData: string[];
rowBKey: string; rowBKey: string;
extractValue: string; extractValue: string;
monthlyCellName: string;
dailyCellId: string;
interruptReason: string;
timeDiff: string; timeDiff: string;
similarityScore: number; similarityScore: number;
aiMatched: boolean; aiMatched: boolean;
@@ -73,6 +78,9 @@ export namespace main {
this.rowAData = source["rowAData"]; this.rowAData = source["rowAData"];
this.rowBKey = source["rowBKey"]; this.rowBKey = source["rowBKey"];
this.extractValue = source["extractValue"]; this.extractValue = source["extractValue"];
this.monthlyCellName = source["monthlyCellName"];
this.dailyCellId = source["dailyCellId"];
this.interruptReason = source["interruptReason"];
this.timeDiff = source["timeDiff"]; this.timeDiff = source["timeDiff"];
this.similarityScore = source["similarityScore"]; this.similarityScore = source["similarityScore"];
this.aiMatched = source["aiMatched"]; this.aiMatched = source["aiMatched"];

View File

@@ -71,13 +71,6 @@ func levenshteinDistance(runes1, runes2 []rune) int {
return dp[n] return dp[n]
} }
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
// calcSimilarity 带自定义正则的相似度计算reg 为 nil 时不做清洗直接比对 // calcSimilarity 带自定义正则的相似度计算reg 为 nil 时不做清洗直接比对
func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float64 { func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float64 {
clean1 := s1 clean1 := s1
@@ -186,6 +179,11 @@ func (a *App) readExcelRaw(path string) ([][]string, error) {
if len(allRows) < 2 { if len(allRows) < 2 {
return nil, fmt.Errorf("Excel 文件至少需要标题行和一条数据") return nil, fmt.Errorf("Excel 文件至少需要标题行和一条数据")
} }
for i := range allRows {
for j := range allRows[i] {
allRows[i][j] = strings.TrimSpace(allRows[i][j])
}
}
return allRows, nil return allRows, nil
} }
@@ -201,11 +199,12 @@ func getCell(row []string, idx int) string {
// matchPrep 匹配准备的中间结果 // matchPrep 匹配准备的中间结果
type matchPrep struct { type matchPrep struct {
dataA, dataB [][]string dataA, dataB [][]string
reg *regexp.Regexp reg *regexp.Regexp
timeWindow float64 timeWindow float64
threshold float64 threshold float64
windowDuration time.Duration windowDuration time.Duration
maxBRowsNoTime int // 无时间列时 AI 匹配最多取 B 表多少行
} }
// compileRegex 编译正则nil 表示跳过清洗 // compileRegex 编译正则nil 表示跳过清洗
@@ -236,6 +235,10 @@ func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) {
if th <= 0 { if th <= 0 {
th = DefaultThreshold th = DefaultThreshold
} }
maxBRows := config.MaxBRowsNoTime
if maxBRows <= 0 {
maxBRows = defaultMaxBNoTime
}
a.emitProgress(0, 100, "正在读取 A 表...", "reading") a.emitProgress(0, 100, "正在读取 A 表...", "reading")
rowsA, err := a.readRawRows(config.FileAPath) rowsA, err := a.readRawRows(config.FileAPath)
@@ -267,6 +270,7 @@ func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) {
timeWindow: tw, timeWindow: tw,
threshold: th, threshold: th,
windowDuration: time.Duration(tw * float64(time.Hour)), windowDuration: time.Duration(tw * float64(time.Hour)),
maxBRowsNoTime: maxBRows,
}, nil }, nil
} }

View File

@@ -5,7 +5,7 @@
"frontend:install": "npm install", "frontend:install": "npm install",
"frontend:build": "npm run build", "frontend:build": "npm run build",
"frontend:dev:watcher": "npm run dev", "frontend:dev:watcher": "npm run dev",
"frontend:dev:serverUrl": "http://localhost:34115", "frontend:dev:serverUrl": "http://localhost:5173",
"author": { "author": {
"name": "RainySY", "name": "RainySY",
"email": "chendairong@outlook.com" "email": "chendairong@outlook.com"