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))
|
||||
}
|
||||
|
||||
// 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 / 本地模型 等)
|
||||
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 密钥")
|
||||
}
|
||||
|
||||
// 默认值
|
||||
endpoint := strings.TrimRight(a.apiEndpoint, "/")
|
||||
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
|
||||
endpoint := resolveAIEndpoint(apiEndpoint)
|
||||
model := apiModel
|
||||
if model == "" {
|
||||
model = deepseekModel
|
||||
}
|
||||
@@ -105,7 +119,7 @@ func (a *App) callAIAPI(messages []deepseekMessage) (string, error) {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
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}
|
||||
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行所有匹配(>=阈值)而非仅最佳
|
||||
CaseSensitive bool `json:"caseSensitive"` // true=大小写敏感匹配
|
||||
SortBy string `json:"sortBy"` // "similarity" / "timeDiff" / ""=不排序
|
||||
MaxPreview int `json:"maxPreview"` // 调试日志中打印的前 N 条比对详情,0=不打印
|
||||
ExportFormat string `json:"exportFormat"` // "xlsx"(默认) / "csv"
|
||||
IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行
|
||||
MaxPreview int `json:"maxPreview"` // 调试日志中打印的前 N 条比对详情,0=不打印
|
||||
MaxBRowsNoTime int `json:"maxBRowsNoTime"` // 无时间列时 AI 匹配最多取 B 表多少行(0=使用默认值 200)
|
||||
ExportFormat string `json:"exportFormat"` // "xlsx"(默认) / "csv"
|
||||
IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行
|
||||
}
|
||||
|
||||
// AICacheInfo 缓存状态信息
|
||||
@@ -107,10 +108,13 @@ type AICacheInfo struct {
|
||||
|
||||
type App struct {
|
||||
ctx context.Context
|
||||
aiCache *AICache
|
||||
|
||||
// AI API 配置(并发访问需要加锁)
|
||||
aiMu sync.RWMutex
|
||||
apiKey string // AI API 密钥(兼容 OpenAI/Deepseek/本地模型)
|
||||
apiEndpoint string // API 端点(默认 https://api.deepseek.com/v1/chat/completions)
|
||||
apiModel string // 模型名称(默认 deepseek-chat)
|
||||
aiCache *AICache
|
||||
|
||||
// 最近一次匹配的配置和表头(供导出使用)
|
||||
dataMu sync.RWMutex
|
||||
@@ -150,6 +154,8 @@ func (a *App) emitProgress(current, total int, message, phase string) {
|
||||
|
||||
// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中,向后兼容)
|
||||
func (a *App) SetDeepseekAPIKey(key string) string {
|
||||
a.aiMu.Lock()
|
||||
defer a.aiMu.Unlock()
|
||||
a.apiKey = strings.TrimSpace(key)
|
||||
if a.apiKey == "" {
|
||||
return "已清除 Deepseek API 密钥"
|
||||
@@ -159,6 +165,8 @@ func (a *App) SetDeepseekAPIKey(key string) string {
|
||||
|
||||
// SetAIConfig 统一设置 AI API 配置(端点、模型、密钥)
|
||||
func (a *App) SetAIConfig(endpoint, model, key string) string {
|
||||
a.aiMu.Lock()
|
||||
defer a.aiMu.Unlock()
|
||||
if endpoint != "" {
|
||||
a.apiEndpoint = strings.TrimSpace(endpoint)
|
||||
}
|
||||
@@ -173,6 +181,8 @@ func (a *App) SetAIConfig(endpoint, model, key string) string {
|
||||
|
||||
// SetAPIKey 设置 AI API 密钥(仅保存在内存中)
|
||||
func (a *App) SetAPIKey(key string) string {
|
||||
a.aiMu.Lock()
|
||||
defer a.aiMu.Unlock()
|
||||
a.apiKey = strings.TrimSpace(key)
|
||||
if a.apiKey == "" {
|
||||
return "已清除 AI API 密钥"
|
||||
@@ -182,11 +192,15 @@ func (a *App) SetAPIKey(key string) string {
|
||||
|
||||
// GetDeepseekStatus 返回是否已配置 Deepseek API 密钥
|
||||
func (a *App) GetDeepseekStatus() bool {
|
||||
a.aiMu.RLock()
|
||||
defer a.aiMu.RUnlock()
|
||||
return a.apiKey != ""
|
||||
}
|
||||
|
||||
// GetAIStatus 返回 AI API 配置状态
|
||||
func (a *App) GetAIStatus() map[string]string {
|
||||
a.aiMu.RLock()
|
||||
defer a.aiMu.RUnlock()
|
||||
return map[string]string{
|
||||
"ready": fmt.Sprintf("%v", a.apiKey != ""),
|
||||
"endpoint": a.apiEndpoint,
|
||||
@@ -261,18 +275,21 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return a.runMatchOnData(prep, config)
|
||||
results, _, err := a.runMatchOnData(prep, config)
|
||||
return results, err
|
||||
}
|
||||
|
||||
// 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
|
||||
totalA := len(prep.dataA)
|
||||
var results []MatchResult
|
||||
matchedAIndices := make(map[int]bool)
|
||||
|
||||
useAllMatches := config.AllMatches
|
||||
maxPreview := config.MaxPreview
|
||||
if maxPreview <= 0 {
|
||||
if maxPreview < 0 {
|
||||
maxPreview = DefaultMaxPreview
|
||||
}
|
||||
|
||||
@@ -369,6 +386,7 @@ func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult
|
||||
}
|
||||
}
|
||||
results = append(results, candidates...)
|
||||
matchedAIndices[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,14 +405,17 @@ func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult
|
||||
a.emitProgress(totalA, totalA,
|
||||
fmt.Sprintf("匹配完成!共匹配成功 %d 条记录", len(results)), "done")
|
||||
|
||||
return results, nil
|
||||
return results, matchedAIndices, nil
|
||||
}
|
||||
|
||||
// RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动)
|
||||
func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
|
||||
a.aiMu.RLock()
|
||||
if a.apiKey == "" {
|
||||
a.aiMu.RUnlock()
|
||||
return nil, fmt.Errorf("请先设置 AI API 密钥")
|
||||
}
|
||||
a.aiMu.RUnlock()
|
||||
|
||||
prep, err := a.prepareMatch(config)
|
||||
if err != nil {
|
||||
@@ -402,20 +423,15 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
|
||||
}
|
||||
|
||||
// 1. 先执行基础匹配
|
||||
results, err := a.runMatchOnData(prep, config)
|
||||
results, matchedAIndices, err := a.runMatchOnData(prep, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. 找出未被基础匹配覆盖的 A 表行
|
||||
matchedSet := make(map[string]bool)
|
||||
for _, r := range results {
|
||||
matchedSet[strings.Join(r.RowAData, "\x00")] = true
|
||||
}
|
||||
|
||||
// 2. 找出未被基础匹配覆盖的 A 表行(按索引,避免重复行内容相同导致误判)
|
||||
var unmatchedA [][]string
|
||||
for _, row := range prep.dataA {
|
||||
if !matchedSet[strings.Join(row, "\x00")] {
|
||||
for i, row := range prep.dataA {
|
||||
if !matchedAIndices[i] {
|
||||
unmatchedA = append(unmatchedA, row)
|
||||
}
|
||||
}
|
||||
@@ -519,7 +535,11 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
|
||||
}
|
||||
} else {
|
||||
// 无时间列时限制 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]
|
||||
}
|
||||
|
||||
|
||||
19
export.go
19
export.go
@@ -57,6 +57,17 @@ func (a *App) ExportResults(results []MatchResult) (string, error) {
|
||||
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()
|
||||
@@ -88,7 +99,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeH
|
||||
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 }
|
||||
|
||||
headers := a.exportHeaders(numACols)
|
||||
@@ -142,7 +153,7 @@ func (a *App) exportResultsXLSX(results []MatchResult, savePath string, includeH
|
||||
rowNum = i + 1
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -163,7 +174,7 @@ func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHe
|
||||
// 使用 UTF-8 BOM 帮助 Excel 正确识别编码
|
||||
buf.Write([]byte{0xEF, 0xBB, 0xBF})
|
||||
|
||||
numACols := len(results[0].RowAData)
|
||||
numACols := maxACols(results)
|
||||
headers := a.exportHeaders(numACols)
|
||||
|
||||
// 表头行
|
||||
@@ -183,7 +194,7 @@ func (a *App) exportResultsCSV(results []MatchResult, savePath string, includeHe
|
||||
if ci > 0 {
|
||||
buf.WriteByte(',')
|
||||
}
|
||||
buf.WriteString(csvEscape(r.RowAData[ci]))
|
||||
buf.WriteString(csvEscape(getCell(r.RowAData, ci)))
|
||||
}
|
||||
buf.WriteByte(',')
|
||||
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,
|
||||
sortBy: '',
|
||||
maxPreview: 3,
|
||||
maxBRowsNoTime: 200,
|
||||
exportFormat: 'xlsx',
|
||||
includeHeader: true
|
||||
})
|
||||
@@ -130,6 +131,7 @@ function buildMatchConfig() {
|
||||
caseSensitive: matchConfig.value.caseSensitive || false,
|
||||
sortBy: matchConfig.value.sortBy || '',
|
||||
maxPreview: Number(matchConfig.value.maxPreview) || 0,
|
||||
maxBRowsNoTime: Number(matchConfig.value.maxBRowsNoTime) || 0,
|
||||
exportFormat: matchConfig.value.exportFormat || 'xlsx',
|
||||
includeHeader: matchConfig.value.includeHeader !== false
|
||||
}
|
||||
@@ -420,6 +422,13 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
|
||||
<span>1</span>
|
||||
</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">
|
||||
<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
|
||||
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 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 OpenDailyReport():Promise<string>;
|
||||
export function GetDeepseekStatus():Promise<boolean>;
|
||||
|
||||
export function OpenFileA():Promise<string>;
|
||||
|
||||
export function OpenFileB():Promise<string>;
|
||||
|
||||
export function OpenMonthlyReport():Promise<string>;
|
||||
|
||||
export function ParseHeaders(arg1:string):Promise<Array<string>>;
|
||||
|
||||
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 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
|
||||
// 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() {
|
||||
return window['go']['main']['App']['ClearAICache']();
|
||||
}
|
||||
@@ -26,8 +18,8 @@ export function GetAIStatus() {
|
||||
return window['go']['main']['App']['GetAIStatus']();
|
||||
}
|
||||
|
||||
export function OpenDailyReport() {
|
||||
return window['go']['main']['App']['OpenDailyReport']();
|
||||
export function GetDeepseekStatus() {
|
||||
return window['go']['main']['App']['GetDeepseekStatus']();
|
||||
}
|
||||
|
||||
export function OpenFileA() {
|
||||
@@ -38,10 +30,6 @@ export function OpenFileB() {
|
||||
return window['go']['main']['App']['OpenFileB']();
|
||||
}
|
||||
|
||||
export function OpenMonthlyReport() {
|
||||
return window['go']['main']['App']['OpenMonthlyReport']();
|
||||
}
|
||||
|
||||
export function ParseHeaders(arg1) {
|
||||
return window['go']['main']['App']['ParseHeaders'](arg1);
|
||||
}
|
||||
@@ -61,3 +49,7 @@ export function SetAIConfig(arg1, arg2, arg3) {
|
||||
export function 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;
|
||||
sortBy: string;
|
||||
maxPreview: number;
|
||||
maxBRowsNoTime: number;
|
||||
exportFormat: string;
|
||||
includeHeader: boolean;
|
||||
|
||||
@@ -52,6 +53,7 @@ export namespace main {
|
||||
this.caseSensitive = source["caseSensitive"];
|
||||
this.sortBy = source["sortBy"];
|
||||
this.maxPreview = source["maxPreview"];
|
||||
this.maxBRowsNoTime = source["maxBRowsNoTime"];
|
||||
this.exportFormat = source["exportFormat"];
|
||||
this.includeHeader = source["includeHeader"];
|
||||
}
|
||||
@@ -60,6 +62,9 @@ export namespace main {
|
||||
rowAData: string[];
|
||||
rowBKey: string;
|
||||
extractValue: string;
|
||||
monthlyCellName: string;
|
||||
dailyCellId: string;
|
||||
interruptReason: string;
|
||||
timeDiff: string;
|
||||
similarityScore: number;
|
||||
aiMatched: boolean;
|
||||
@@ -73,6 +78,9 @@ export namespace main {
|
||||
this.rowAData = source["rowAData"];
|
||||
this.rowBKey = source["rowBKey"];
|
||||
this.extractValue = source["extractValue"];
|
||||
this.monthlyCellName = source["monthlyCellName"];
|
||||
this.dailyCellId = source["dailyCellId"];
|
||||
this.interruptReason = source["interruptReason"];
|
||||
this.timeDiff = source["timeDiff"];
|
||||
this.similarityScore = source["similarityScore"];
|
||||
this.aiMatched = source["aiMatched"];
|
||||
|
||||
28
matcher.go
28
matcher.go
@@ -71,13 +71,6 @@ func levenshteinDistance(runes1, runes2 []rune) int {
|
||||
return dp[n]
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// calcSimilarity 带自定义正则的相似度计算;reg 为 nil 时不做清洗直接比对
|
||||
func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float64 {
|
||||
clean1 := s1
|
||||
@@ -186,6 +179,11 @@ func (a *App) readExcelRaw(path string) ([][]string, error) {
|
||||
if len(allRows) < 2 {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -201,11 +199,12 @@ func getCell(row []string, idx int) string {
|
||||
|
||||
// matchPrep 匹配准备的中间结果
|
||||
type matchPrep struct {
|
||||
dataA, dataB [][]string
|
||||
reg *regexp.Regexp
|
||||
timeWindow float64
|
||||
threshold float64
|
||||
windowDuration time.Duration
|
||||
dataA, dataB [][]string
|
||||
reg *regexp.Regexp
|
||||
timeWindow float64
|
||||
threshold float64
|
||||
windowDuration time.Duration
|
||||
maxBRowsNoTime int // 无时间列时 AI 匹配最多取 B 表多少行
|
||||
}
|
||||
|
||||
// compileRegex 编译正则,nil 表示跳过清洗
|
||||
@@ -236,6 +235,10 @@ func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) {
|
||||
if th <= 0 {
|
||||
th = DefaultThreshold
|
||||
}
|
||||
maxBRows := config.MaxBRowsNoTime
|
||||
if maxBRows <= 0 {
|
||||
maxBRows = defaultMaxBNoTime
|
||||
}
|
||||
|
||||
a.emitProgress(0, 100, "正在读取 A 表...", "reading")
|
||||
rowsA, err := a.readRawRows(config.FileAPath)
|
||||
@@ -267,6 +270,7 @@ func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) {
|
||||
timeWindow: tw,
|
||||
threshold: th,
|
||||
windowDuration: time.Duration(tw * float64(time.Hour)),
|
||||
maxBRowsNoTime: maxBRows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"frontend:install": "npm install",
|
||||
"frontend:build": "npm run build",
|
||||
"frontend:dev:watcher": "npm run dev",
|
||||
"frontend:dev:serverUrl": "http://localhost:34115",
|
||||
"frontend:dev:serverUrl": "http://localhost:5173",
|
||||
"author": {
|
||||
"name": "RainySY",
|
||||
"email": "chendairong@outlook.com"
|
||||
|
||||
Reference in New Issue
Block a user