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:
36
ai.go
36
ai.go
@@ -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 兼容 API(Deepseek / OpenAI / 本地模型 等)
|
// callAIAPI 调用 OpenAI 兼容 API(Deepseek / 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
51
ai_test.go
Normal 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
56
app.go
@@ -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]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
export.go
19
export.go
@@ -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
57
export_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
10
frontend/wailsjs/go/main/App.d.ts
vendored
Normal file → Executable 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
20
frontend/wailsjs/go/main/App.js
Normal file → Executable 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
8
frontend/wailsjs/go/models.ts
Normal file → Executable 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"];
|
||||||
|
|||||||
28
matcher.go
28
matcher.go
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user