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

89
ai.go
View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"time"
)
@@ -37,7 +38,19 @@ type deepseekResponse struct {
} `json:"error,omitempty"`
}
// ---------- Deepseek AI 增强匹配 ----------
// maxPromptBChars 限制 B 表数据在 prompt 中的最大字符数
const maxPromptBChars = 80000
// ---------- AI API 调用 ----------
// buildRowCacheKey 为单行匹配构建缓存键
func (a *App) buildRowCacheKey(matchValue, timeStr string, config MatchConfig) string {
parts := fmt.Sprintf("%s|%s|%s|%.1f|%s",
matchValue, timeStr, config.RegexPattern, config.TimeWindow,
filepath.Base(config.FileBPath))
h := sha256.Sum256([]byte(parts))
return hex.EncodeToString(h[:])
}
// hashPrompt 对 prompt 消息计算 SHA256用于缓存键
func hashPrompt(messages []deepseekMessage) string {
@@ -51,10 +64,23 @@ func hashPrompt(messages []deepseekMessage) string {
return hex.EncodeToString(h.Sum(nil))
}
// callDeepseekAPI 调用 Deepseek Chat API带缓存
func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) {
if a.deepseekKey == "" {
return "", fmt.Errorf("请先设置 Deepseek API 密钥")
// callAIAPI 调用 OpenAI 兼容 APIDeepseek / OpenAI / 本地模型 等
func (a *App) callAIAPI(messages []deepseekMessage) (string, error) {
if a.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
if model == "" {
model = deepseekModel
}
hash := hashPrompt(messages)
@@ -64,43 +90,45 @@ func (a *App) callDeepseekAPI(messages []deepseekMessage) (string, error) {
fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12])
return cached, nil
}
fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12])
fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 %s...\n", hash[:12], endpoint)
reqBody := deepseekRequest{
Model: deepseekModel,
Model: model,
Messages: messages,
Temperature: deepseekTemperature,
MaxTokens: deepseekMaxTokens,
}
bodyBytes, _ := json.Marshal(reqBody)
httpReq, err := http.NewRequest("POST", "https://api.deepseek.com/v1/chat/completions",
bytes.NewReader(bodyBytes))
httpReq, err := http.NewRequest("POST", endpoint, bytes.NewReader(bodyBytes))
if err != nil {
return "", fmt.Errorf("创建请求失败: %v", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+a.deepseekKey)
httpReq.Header.Set("Authorization", "Bearer "+a.apiKey)
client := &http.Client{Timeout: 60 * time.Second}
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return "", fmt.Errorf("调用 Deepseek API 失败: %v", err)
return "", fmt.Errorf("调用 AI API 失败: %v", err)
}
defer resp.Body.Close()
respBytes, _ := io.ReadAll(resp.Body)
respBytes, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取 AI 响应失败: %v", err)
}
var dr deepseekResponse
if err := json.Unmarshal(respBytes, &dr); err != nil {
return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err)
return "", fmt.Errorf("解析 AI 响应失败: %v", err)
}
if dr.Error != nil {
return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message)
return "", fmt.Errorf("AI API 错误: %s", dr.Error.Message)
}
if len(dr.Choices) == 0 {
return "", fmt.Errorf("Deepseek 未返回有效结果")
return "", fmt.Errorf("AI 未返回有效结果")
}
result := strings.TrimSpace(dr.Choices[0].Message.Content)
@@ -138,7 +166,8 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf
}
sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows)))
for _, row := range bRows {
truncated := false
for i, row := range bRows {
matchVal := getCell(row, config.ColBMatchIndex)
extractVal := getCell(row, config.ColBExtractIndex)
sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal))
@@ -146,6 +175,15 @@ func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConf
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex)))
}
sb.WriteString("\n")
// 限制 B 表部分总字符数,防止 prompt 超出 token 限制
if sb.Len() > maxPromptBChars {
fmt.Printf("[AI-WARN] Prompt B 表数据超长 (%d 条,%d 字符),截断于第 %d 条\n", len(bRows), sb.Len(), i)
truncated = true
}
if truncated {
sb.WriteString(fmt.Sprintf(" ... 已截断,省略 %d 条\n", len(bRows)-i-1))
break
}
}
sb.WriteString("\n请返回 JSON 格式的匹配结果。")
@@ -177,3 +215,20 @@ func formatTimeDiff(d time.Duration) string {
}
return fmt.Sprintf("%s%ds", sign, secs)
}
// parseTimeDiffDuration 将 TimeDiff 字符串(如 "1h30m")解析为 time.Duration用于排序
func parseTimeDiffDuration(s string) time.Duration {
if s == "" {
return 0
}
sign := time.Duration(1)
if s[0] == '-' {
sign = -1
s = s[1:]
}
d, err := time.ParseDuration(s)
if err != nil {
return 0
}
return sign * d
}

322
app.go
View File

@@ -6,9 +6,9 @@ import (
"fmt"
"math"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/wailsapp/wails/v2/pkg/runtime"
@@ -97,12 +97,26 @@ type MatchConfig struct {
IncludeHeader bool `json:"includeHeader"` // 导出时是否包含表头行
}
// AICacheInfo 缓存状态信息
type AICacheInfo struct {
Count int `json:"count"`
FilePath string `json:"filePath"`
}
// ---------- App 结构体 ----------
type App struct {
ctx context.Context
deepseekKey string
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
lastConfig MatchConfig
headersA []string
headersB []string
}
// NewApp 创建 App 实例
@@ -132,18 +146,52 @@ func (a *App) emitProgress(current, total int, message, phase string) {
})
}
// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中)
// ---------- AI 配置 ----------
// SetDeepseekAPIKey 设置 Deepseek API 密钥(仅保存在内存中,向后兼容)
func (a *App) SetDeepseekAPIKey(key string) string {
a.deepseekKey = strings.TrimSpace(key)
if a.deepseekKey == "" {
a.apiKey = strings.TrimSpace(key)
if a.apiKey == "" {
return "已清除 Deepseek API 密钥"
}
return "Deepseek API 密钥已设置"
}
// SetAIConfig 统一设置 AI API 配置(端点、模型、密钥)
func (a *App) SetAIConfig(endpoint, model, key string) string {
if endpoint != "" {
a.apiEndpoint = strings.TrimSpace(endpoint)
}
if model != "" {
a.apiModel = strings.TrimSpace(model)
}
if key != "" {
a.apiKey = strings.TrimSpace(key)
}
return fmt.Sprintf("AI 配置已更新 (端点=%s, 模型=%s)", a.apiEndpoint, a.apiModel)
}
// SetAPIKey 设置 AI API 密钥(仅保存在内存中)
func (a *App) SetAPIKey(key string) string {
a.apiKey = strings.TrimSpace(key)
if a.apiKey == "" {
return "已清除 AI API 密钥"
}
return "AI API 密钥已设置"
}
// GetDeepseekStatus 返回是否已配置 Deepseek API 密钥
func (a *App) GetDeepseekStatus() bool {
return a.deepseekKey != ""
return a.apiKey != ""
}
// GetAIStatus 返回 AI API 配置状态
func (a *App) GetAIStatus() map[string]string {
return map[string]string{
"ready": fmt.Sprintf("%v", a.apiKey != ""),
"endpoint": a.apiEndpoint,
"model": a.apiModel,
}
}
// ClearAICache 清除所有 AI 缓存
@@ -154,12 +202,9 @@ func (a *App) ClearAICache() string {
}
// GetAICacheInfo 返回 AI 缓存信息(条目数、文件路径)
func (a *App) GetAICacheInfo() map[string]interface{} {
func (a *App) GetAICacheInfo() AICacheInfo {
count, path := a.aiCache.stat()
return map[string]interface{}{
"count": count,
"filePath": path,
}
return AICacheInfo{Count: count, FilePath: path}
}
// ---------- 文件选择对话框 ----------
@@ -212,54 +257,17 @@ func (a *App) ParseHeaders(filePath string) ([]string, error) {
// RunMatch 接收完整 MatchConfig按列索引执行通用匹配
func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
// 1. 编译正则
var reg *regexp.Regexp
if config.RegexPattern != "" {
var err error
reg, err = regexp.Compile(config.RegexPattern)
if err != nil {
return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err)
}
fmt.Printf("[DEBUG] RunMatch 使用正则: '%s'\n", config.RegexPattern)
} else {
fmt.Printf("[DEBUG] RunMatch 跳过清洗(正则为空)\n")
prep, err := a.prepareMatch(config)
if err != nil {
return nil, err
}
return a.runMatchOnData(prep, config)
}
// 2. 默认值兜底
timeWindow := config.TimeWindow
if timeWindow <= 0 {
timeWindow = DefaultTimeWindowHours
}
threshold := config.Threshold
if threshold <= 0 {
threshold = DefaultThreshold
}
// runMatchOnData 在已读取的数据上执行匹配
func (a *App) runMatchOnData(prep *matchPrep, config MatchConfig) ([]MatchResult, error) {
useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0
// 3. 读取原始数据
a.emitProgress(0, 100, "正在读取 A 表...", "reading")
rowsA, err := a.readRawRows(config.FileAPath)
if err != nil {
return nil, fmt.Errorf("读取 A 表失败: %v", err)
}
a.emitProgress(0, 100, "正在读取 B 表...", "reading")
rowsB, err := a.readRawRows(config.FileBPath)
if err != nil {
return nil, fmt.Errorf("读取 B 表失败: %v", err)
}
if len(rowsA) < 2 {
return nil, fmt.Errorf("A 表无有效数据行")
}
if len(rowsB) < 2 {
return nil, fmt.Errorf("B 表无有效数据行")
}
aHeaders := rowsA[0]
_ = aHeaders // 保留表头引用(将来导出时可能用到)
dataA := rowsA[1:]
dataB := rowsB[1:]
windowDuration := time.Duration(timeWindow * float64(time.Hour))
totalA := len(dataA)
totalA := len(prep.dataA)
var results []MatchResult
useAllMatches := config.AllMatches
@@ -268,7 +276,32 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
maxPreview = DefaultMaxPreview
}
for i, rowA := range dataA {
// 预计算 B 表清洗后的匹配值,避免内层循环中重复 regex 替换
totalB := len(prep.dataB)
cleanedBMatch := make([]string, totalB)
origBMatch := make([]string, totalB)
parsedBTime := make([]time.Time, totalB)
hasBTime := make([]bool, totalB)
bExtractVal := make([]string, totalB)
for bIdx, rowB := range prep.dataB {
matchStrB := getCell(rowB, config.ColBMatchIndex)
origBMatch[bIdx] = matchStrB
if matchStrB == "" {
cleanedBMatch[bIdx] = ""
} else {
cleanedBMatch[bIdx] = cleanWithRegex(matchStrB, prep.reg)
}
if useTime {
t, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex))
if err == nil {
parsedBTime[bIdx] = t
hasBTime[bIdx] = true
}
}
bExtractVal[bIdx] = getCell(rowB, config.ColBExtractIndex)
}
for i, rowA := range prep.dataA {
if i%10 == 0 || i == totalA-1 {
pct := (i + 1) * 100 / totalA
a.emitProgress(i+1, totalA,
@@ -287,38 +320,35 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
if err == nil { timeA = t; hasTimeA = true }
}
cleanA := cleanWithRegex(matchStrA, reg)
cleanA := cleanWithRegex(matchStrA, prep.reg)
// 收集该 A 行的所有候选匹配
var candidates []MatchResult
for _, rowB := range dataB {
matchStrB := getCell(rowB, config.ColBMatchIndex)
if matchStrB == "" { continue }
for bIdx := range prep.dataB {
if cleanedBMatch[bIdx] == "" { continue }
var timeDiff time.Duration
if hasTimeA && useTime {
tB, err := parseTimeFlexible(getCell(rowB, config.ColBTimeIndex))
if err != nil { continue }
td := timeA.Sub(tB)
if td < -windowDuration || td > windowDuration { continue }
timeDiff = td
if hasTimeA && useTime && hasBTime[bIdx] {
td := timeA.Sub(parsedBTime[bIdx])
if td < -prep.windowDuration || td > prep.windowDuration { continue }
}
cleanB := cleanWithRegex(matchStrB, reg)
if cleanA == "" || cleanB == "" { continue }
similarity := calcSimilarity(matchStrA, matchStrB, reg, config.CaseSensitive)
similarity := similarityFromCleaned(cleanA, cleanedBMatch[bIdx], config.CaseSensitive)
if i < maxPreview {
fmt.Printf("[DEBUG] | A[%d]='%s'→'%s' | B='%s'→'%s' | 相似度=%.4f\n",
i, matchStrA, cleanA, matchStrB, cleanB, similarity)
i, matchStrA, cleanA, origBMatch[bIdx], cleanedBMatch[bIdx], similarity)
}
if similarity >= threshold {
if similarity >= prep.threshold {
var timeDiff time.Duration
if hasTimeA && useTime && hasBTime[bIdx] {
timeDiff = timeA.Sub(parsedBTime[bIdx])
}
mr := MatchResult{
RowAData: rowA,
RowBKey: matchStrB,
ExtractValue: getCell(rowB, config.ColBExtractIndex),
RowBKey: origBMatch[bIdx],
ExtractValue: bExtractVal[bIdx],
TimeDiff: formatTimeDiff(timeDiff),
SimilarityScore: math.Round(similarity*10000) / 10000,
AIMatched: false,
@@ -343,13 +373,14 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
}
// 结果排序
if config.SortBy == "similarity" {
switch config.SortBy {
case "similarity":
sort.Slice(results, func(i, j int) bool {
return results[i].SimilarityScore > results[j].SimilarityScore
})
} else if config.SortBy == "timeDiff" {
case "timeDiff":
sort.Slice(results, func(i, j int) bool {
return results[i].TimeDiff < results[j].TimeDiff
return parseTimeDiffDuration(results[i].TimeDiff) < parseTimeDiffDuration(results[j].TimeDiff)
})
}
@@ -359,42 +390,31 @@ func (a *App) RunMatch(config MatchConfig) ([]MatchResult, error) {
return results, nil
}
// RunMatchWithAI 执行基础匹配 + Deepseek AI 增强匹配(配置驱动)
// RunMatchWithAI 执行基础匹配 + AI 增强匹配(配置驱动)
func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
if a.deepseekKey == "" {
return nil, fmt.Errorf("请先设置 Deepseek API 密钥")
if a.apiKey == "" {
return nil, fmt.Errorf("请先设置 AI API 密钥")
}
// 1. 先执行基础匹配
results, err := a.RunMatch(config)
prep, err := a.prepareMatch(config)
if err != nil {
return nil, err
}
// 2. 重新读取数据,找出未被基础匹配覆盖的 A 表行
rowsA, err := a.readRawRows(config.FileAPath)
// 1. 先执行基础匹配
results, err := a.runMatchOnData(prep, config)
if err != nil {
return nil, fmt.Errorf("读取 A 表失败: %v", err)
}
rowsB, err := a.readRawRows(config.FileBPath)
if err != nil {
return nil, fmt.Errorf("读取 B 表失败: %v", err)
}
if len(rowsA) < 2 || len(rowsB) < 2 {
return results, nil
return nil, err
}
dataA := rowsA[1:]
dataB := rowsB[1:]
// 用 RowAData 快速判断哪些 A 行已经被匹配
// 2. 找出未被基础匹配覆盖的 A 表行
matchedSet := make(map[string]bool)
for _, r := range results {
matchedSet[strings.Join(r.RowAData, "\x00")] = true
}
var unmatchedA [][]string
for _, row := range dataA {
for _, row := range prep.dataA {
if !matchedSet[strings.Join(row, "\x00")] {
unmatchedA = append(unmatchedA, row)
}
@@ -405,33 +425,61 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
return results, nil
}
// 3. AI 增强匹配
timeWindow := config.TimeWindow
if timeWindow <= 0 {
timeWindow = DefaultTimeWindowHours
}
windowDuration := time.Duration(timeWindow * float64(time.Hour))
// 3. AI 增强匹配(先查行级缓存,减少 API 调用)
useTime := config.ColATimeIndex >= 0 && config.ColBTimeIndex >= 0
totalUnmatched := len(unmatchedA)
aiMatched := 0
var failedBatches []int
// 3a. 检查行级缓存,命中则直接加入结果
var uncachedA [][]string
cacheHits := 0
for _, row := range unmatchedA {
matchVal := getCell(row, config.ColAMatchIndex)
timeStr := ""
if useTime {
timeStr = getCell(row, config.ColATimeIndex)
}
cacheKey := a.buildRowCacheKey(matchVal, timeStr, config)
if cachedVal, ok := a.aiCache.getRow(cacheKey); ok {
results = append(results, MatchResult{
RowAData: row,
RowBKey: "",
ExtractValue: cachedVal,
SimilarityScore: 0,
AIMatched: true,
})
aiMatched++
cacheHits++
} else {
uncachedA = append(uncachedA, row)
}
}
if cacheHits > 0 {
fmt.Printf("[CACHE] ✓ 行级缓存命中 %d 条,剩余 %d 条需 AI 处理\n", cacheHits, len(uncachedA))
}
if len(uncachedA) == 0 {
a.emitProgress(1, 1,
fmt.Sprintf("AI 增强完成!全部 %d 条命中缓存", cacheHits), "done")
return results, nil
}
totalUnmatched := len(uncachedA)
a.emitProgress(0, totalUnmatched,
fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", totalUnmatched),
fmt.Sprintf("AI 增强匹配:%d 条命中缓存,%d 条需调用 AI...", cacheHits, totalUnmatched),
"ai-enhancing")
batchSize := DefaultBatchSize
aiMatched := 0
for batchStart := 0; batchStart < totalUnmatched; batchStart += batchSize {
end := batchStart + batchSize
if end > totalUnmatched {
end = totalUnmatched
}
for batchStart := 0; batchStart < totalUnmatched; batchStart += DefaultBatchSize {
end := min(batchStart+DefaultBatchSize, totalUnmatched)
batchNum := (batchStart / DefaultBatchSize) + 1
a.emitProgress(batchStart+1, totalUnmatched,
fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/batchSize)+1),
fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, batchNum),
"ai-enhancing")
batch := unmatchedA[batchStart:end]
batch := uncachedA[batchStart:end]
// 计算本批 A 表的时间范围
var minTime, maxTime time.Time
@@ -459,10 +507,10 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
// 过滤 B 表在时间窗口内的行(用户配置时间窗口 + 额外余量覆盖批次跨度)
var relevantB [][]string
if hasBatchTime && useTime {
padding := windowDuration + 3*time.Hour
padding := prep.windowDuration + time.Duration(defaultAIWindowPadH)*time.Hour
ws := minTime.Add(-padding)
we := maxTime.Add(padding)
for _, row := range dataB {
for _, row := range prep.dataB {
t, err := parseTimeFlexible(getCell(row, config.ColBTimeIndex))
if err != nil || t.Before(ws) || t.After(we) {
continue
@@ -471,17 +519,16 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
}
} else {
// 无时间列时限制 B 表条数以控制 token 消耗
maxB := 200
if len(dataB) < maxB {
maxB = len(dataB)
}
relevantB = dataB[:maxB]
maxB := min(defaultMaxBNoTime, len(prep.dataB))
relevantB = prep.dataB[:maxB]
}
// 构建 AI 提示
prompt := a.buildGenericAIPrompt(batch, relevantB, config, windowDuration, hasBatchTime)
aiResp, err := a.callDeepseekAPI(prompt)
prompt := a.buildGenericAIPrompt(batch, relevantB, config, prep.windowDuration, hasBatchTime)
aiResp, err := a.callAIAPI(prompt)
if err != nil {
fmt.Printf("[AI-WARN] 第 %d 批 API 调用失败: %v\n", batchNum, err)
failedBatches = append(failedBatches, batchNum)
continue
}
@@ -502,7 +549,8 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
}
if parseErr != nil {
fmt.Printf("[AI-WARN] 响应解析失败 (第 %d 批): %s\n 原始响应: %.200s\n",
(batchStart/batchSize)+1, parseErr.Error(), aiResp)
batchNum, parseErr.Error(), aiResp)
failedBatches = append(failedBatches, batchNum)
continue
}
@@ -522,12 +570,26 @@ func (a *App) RunMatchWithAI(config MatchConfig) ([]MatchResult, error) {
}
results = append(results, mr)
aiMatched++
// 写入行级缓存
matchVal := getCell(rowA, config.ColAMatchIndex)
timeStr := ""
if useTime {
timeStr = getCell(rowA, config.ColATimeIndex)
}
cacheKey := a.buildRowCacheKey(matchVal, timeStr, config)
a.aiCache.putRow(cacheKey, val)
}
}
a.aiCache.saveToFile()
a.emitProgress(totalUnmatched, totalUnmatched,
fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条",
len(results)-aiMatched, aiMatched, len(results)), "done")
// 构建完成消息
msg := fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条",
len(results)-aiMatched, aiMatched, len(results))
if len(failedBatches) > 0 {
msg += fmt.Sprintf("(警告:第 %v 批失败)", failedBatches)
}
a.emitProgress(totalUnmatched, totalUnmatched, msg, "done")
return results, nil
}

157
cache.go
View File

@@ -2,8 +2,10 @@ package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
@@ -17,12 +19,23 @@ type AICacheEntry struct {
CreatedAt int64 `json:"createdAt"`
}
// AIRowCacheEntry 单行 AI 匹配缓存(跨批次复用)
type AIRowCacheEntry struct {
Key string `json:"key"`
Value string `json:"value"`
CreatedAt int64 `json:"createdAt"`
}
// AICache AI 响应缓存(持久化到临时文件)
type AICache struct {
Entries []AICacheEntry `json:"entries"`
mu sync.RWMutex // 小写,必须保持非导出以兼容 JSON 序列化
filePath string
maxSize int // 最大缓存条目数
Entries []AICacheEntry `json:"entries"`
RowEntries []AIRowCacheEntry `json:"rowEntries"`
entriesIdx map[string]int // promptHash → index in Entries (O(1) lookup)
rowEntriesIdx map[string]int // key → index in RowEntries (O(1) lookup)
mu sync.RWMutex
filePath string
maxSize int // 批量缓存最大条目数
maxRowSize int // 行级缓存最大条目数
}
// cacheFileName 缓存文件名
@@ -31,8 +44,9 @@ const cacheFileName = "data-matcher-ai-cache.json"
// newAICache 创建缓存实例并加载已有数据
func newAICache() *AICache {
c := &AICache{
filePath: filepath.Join(os.TempDir(), cacheFileName),
maxSize: cacheMaxSize,
filePath: filepath.Join(os.TempDir(), cacheFileName),
maxSize: cacheMaxSize,
maxRowSize: 5000,
}
c.loadFromFile()
return c
@@ -42,12 +56,18 @@ func newAICache() *AICache {
func (c *AICache) loadFromFile() {
data, err := os.ReadFile(c.filePath)
if err != nil {
return // 文件不存在或无法读取,从空缓存开始
if !os.IsNotExist(err) {
fmt.Printf("[CACHE] 读取缓存文件失败: %v\n", err)
}
return
}
c.mu.Lock()
defer c.mu.Unlock()
var loaded AICache
if err := json.Unmarshal(data, &loaded); err != nil || len(loaded.Entries) == 0 {
var loaded struct {
Entries []AICacheEntry `json:"entries"`
RowEntries []AIRowCacheEntry `json:"rowEntries"`
}
if err := json.Unmarshal(data, &loaded); err != nil || (len(loaded.Entries) == 0 && len(loaded.RowEntries) == 0) {
return // 解析失败或无数据,保留当前缓存
}
// 验证每个条目字段完整性
@@ -56,32 +76,56 @@ func (c *AICache) loadFromFile() {
return
}
}
for _, r := range loaded.RowEntries {
if r.Key == "" {
return
}
}
c.Entries = loaded.Entries
c.RowEntries = loaded.RowEntries
c.rebuildIndexes()
}
// rebuildIndexes 从切片重建索引 map反序列化或裁剪后调用
func (c *AICache) rebuildIndexes() {
c.entriesIdx = make(map[string]int, len(c.Entries))
for i := range c.Entries {
c.entriesIdx[c.Entries[i].PromptHash] = i
}
c.rowEntriesIdx = make(map[string]int, len(c.RowEntries))
for i := range c.RowEntries {
c.rowEntriesIdx[c.RowEntries[i].Key] = i
}
}
// saveToFile 将缓存写入磁盘(线程安全)
func (c *AICache) saveToFile() {
c.mu.RLock()
// 仅序列化 Entries避免泄露 filePath 等内部字段
entries := make([]AICacheEntry, len(c.Entries))
copy(entries, c.Entries)
rowEntries := make([]AIRowCacheEntry, len(c.RowEntries))
copy(rowEntries, c.RowEntries)
c.mu.RUnlock()
data, err := json.Marshal(entries)
data, err := json.Marshal(map[string]interface{}{
"entries": entries,
"rowEntries": rowEntries,
})
if err != nil {
fmt.Printf("[CACHE] 序列化缓存失败: %v\n", err)
return
}
_ = os.WriteFile(c.filePath, data, 0644)
if err := os.WriteFile(c.filePath, data, 0600); err != nil {
fmt.Printf("[CACHE] 写入缓存文件失败: %v\n", err)
}
}
// get 根据 hash 查找缓存,命中返回响应,否则返回空
func (c *AICache) get(hash string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
for i := range c.Entries {
if c.Entries[i].PromptHash == hash {
return c.Entries[i].Response, true
}
if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash {
return c.Entries[idx].Response, true
}
return "", false
}
@@ -92,32 +136,70 @@ func (c *AICache) put(hash, response string) {
defer c.mu.Unlock()
// 去重:如果已存在则覆盖
for i := range c.Entries {
if c.Entries[i].PromptHash == hash {
c.Entries[i].Response = response
c.Entries[i].CreatedAt = time.Now().Unix()
return
}
}
// 超过上限则移除最旧条目,避免全部排序
if len(c.Entries) >= c.maxSize {
oldestIdx := 0
oldestTime := c.Entries[0].CreatedAt
for i := 1; i < len(c.Entries); i++ {
if c.Entries[i].CreatedAt < oldestTime {
oldestIdx = i
oldestTime = c.Entries[i].CreatedAt
}
}
c.Entries = append(c.Entries[:oldestIdx], c.Entries[oldestIdx+1:]...)
if idx, ok := c.entriesIdx[hash]; ok && idx < len(c.Entries) && c.Entries[idx].PromptHash == hash {
c.Entries[idx].Response = response
c.Entries[idx].CreatedAt = time.Now().Unix()
return
}
// 新增条目
idx := len(c.Entries)
c.Entries = append(c.Entries, AICacheEntry{
PromptHash: hash,
Response: response,
CreatedAt: time.Now().Unix(),
})
c.entriesIdx[hash] = idx
// 超过上限则删除最旧的条目
if len(c.Entries) > c.maxSize {
sort.Slice(c.Entries, func(i, j int) bool {
return c.Entries[i].CreatedAt > c.Entries[j].CreatedAt
})
c.Entries = c.Entries[:c.maxSize]
c.rebuildIndexes()
}
}
// getRow 查找行级缓存
func (c *AICache) getRow(key string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key {
return c.RowEntries[idx].Value, true
}
return "", false
}
// putRow 存入行级缓存(线程安全 + 自动裁剪)
func (c *AICache) putRow(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
// 去重:更新已存在的条目
if idx, ok := c.rowEntriesIdx[key]; ok && idx < len(c.RowEntries) && c.RowEntries[idx].Key == key {
c.RowEntries[idx].Value = value
c.RowEntries[idx].CreatedAt = time.Now().Unix()
return
}
// 新增条目
idx := len(c.RowEntries)
c.RowEntries = append(c.RowEntries, AIRowCacheEntry{
Key: key,
Value: value,
CreatedAt: time.Now().Unix(),
})
c.rowEntriesIdx[key] = idx
// 超过上限则删除最旧的条目
if len(c.RowEntries) > c.maxRowSize {
sort.Slice(c.RowEntries, func(i, j int) bool {
return c.RowEntries[i].CreatedAt > c.RowEntries[j].CreatedAt
})
c.RowEntries = c.RowEntries[:c.maxRowSize]
c.rebuildIndexes()
}
}
// clear 清空所有缓存
@@ -125,6 +207,9 @@ func (c *AICache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.Entries = nil
c.RowEntries = nil
c.entriesIdx = nil
c.rowEntriesIdx = nil
_ = os.Remove(c.filePath)
}
@@ -132,5 +217,5 @@ func (c *AICache) clear() {
func (c *AICache) stat() (count int, path string) {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.Entries), c.filePath
return len(c.Entries) + len(c.RowEntries), c.filePath
}

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
}

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
// Wails 自动生成的绑定
import { OpenFileA, OpenFileB, ParseHeaders, RunMatch, RunMatchWithAI, ExportResults, SetDeepseekAPIKey, GetDeepseekStatus, ClearAICache, GetAICacheInfo } from '../wailsjs/go/main/App'
import { OpenFileA, OpenFileB, ParseHeaders, RunMatch, RunMatchWithAI, ExportResults, SetAIConfig, SetAPIKey, GetAIStatus, ClearAICache, GetAICacheInfo } from '../wailsjs/go/main/App'
import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'
// ----------- 文件与列映射 -----------
@@ -67,9 +67,11 @@ function hideProgressNow() {
showProgress.value = false
}
// ----------- Deepseek 状态 -----------
const deepseekKey = ref('')
const deepseekReady = ref(false)
// ----------- AI API 配置 -----------
const apiKey = ref('')
const apiEndpoint = ref('')
const apiModel = ref('')
const aiReady = ref(false)
const showApiInput = ref(false)
const aiEnhancing = ref(false)
@@ -114,6 +116,25 @@ async function selectFileB() {
} catch (e) { errorMsg.value = '读取 B 表头失败: ' + (e.message || e) }
}
// ----------- 构建匹配配置 -----------
function buildMatchConfig() {
return {
fileAPath: fileAPath.value, fileBPath: fileBPath.value,
colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value,
colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value,
colBExtractIndex: colBExtractIdx.value,
regexPattern: matchConfig.value.regexPattern || '',
timeWindow: Number(matchConfig.value.timeWindow) || 12,
threshold: Number(matchConfig.value.threshold) || 0.65,
allMatches: matchConfig.value.allMatches || false,
caseSensitive: matchConfig.value.caseSensitive || false,
sortBy: matchConfig.value.sortBy || '',
maxPreview: Number(matchConfig.value.maxPreview) || 0,
exportFormat: matchConfig.value.exportFormat || 'xlsx',
includeHeader: matchConfig.value.includeHeader !== false
}
}
// ----------- 智能匹配 -----------
async function startMatching() {
if (isRunning.value) return
@@ -129,22 +150,7 @@ async function startMatching() {
progress.value = { current: 0, total: 100, message: '准备中...', phase: 'reading' }
try {
const config = {
fileAPath: fileAPath.value, fileBPath: fileBPath.value,
colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value,
colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value,
colBExtractIndex: colBExtractIdx.value,
regexPattern: matchConfig.value.regexPattern || '',
timeWindow: Number(matchConfig.value.timeWindow) || 12,
threshold: Number(matchConfig.value.threshold) || 0.65,
allMatches: matchConfig.value.allMatches || false,
caseSensitive: matchConfig.value.caseSensitive || false,
sortBy: matchConfig.value.sortBy || '',
maxPreview: Number(matchConfig.value.maxPreview) || 0,
exportFormat: matchConfig.value.exportFormat || 'xlsx',
includeHeader: matchConfig.value.includeHeader !== false
}
const data = await RunMatch(config)
const data = await RunMatch(buildMatchConfig())
results.value = data; stats.value.matched = data.length
} catch (err) { errorMsg.value = typeof err === 'string' ? err : (err.message || '匹配失败')
hideProgressNow()
@@ -161,16 +167,16 @@ async function startAIEnhance() {
errorMsg.value = '请完成列映射配置A表匹配列 / B表匹配列 / B表提取列'
return
}
if (!deepseekReady.value && !deepseekKey.value) {
errorMsg.value = '请先配置 Deepseek API 密钥'
if (!aiReady.value && !apiKey.value) {
errorMsg.value = '请先配置 AI API 密钥'
return
}
cancelProgressTimer()
if (!deepseekReady.value && deepseekKey.value) {
await SetDeepseekAPIKey(deepseekKey.value)
deepseekReady.value = await GetDeepseekStatus()
if (!aiReady.value && apiKey.value) {
await SetAIConfig(apiEndpoint.value, apiModel.value, apiKey.value)
aiReady.value = true
}
isRunning.value = true
@@ -183,22 +189,7 @@ async function startAIEnhance() {
progress.value = { current: 0, total: 100, message: '正在启动 AI 增强匹配...', phase: 'reading' }
try {
const config = {
fileAPath: fileAPath.value, fileBPath: fileBPath.value,
colAMatchIndex: colAMatchIdx.value, colATimeIndex: colATimeIdx.value,
colBMatchIndex: colBMatchIdx.value, colBTimeIndex: colBTimeIdx.value,
colBExtractIndex: colBExtractIdx.value,
regexPattern: matchConfig.value.regexPattern || '',
timeWindow: Number(matchConfig.value.timeWindow) || 12,
threshold: Number(matchConfig.value.threshold) || 0.65,
allMatches: matchConfig.value.allMatches || false,
caseSensitive: matchConfig.value.caseSensitive || false,
sortBy: matchConfig.value.sortBy || '',
maxPreview: Number(matchConfig.value.maxPreview) || 0,
exportFormat: matchConfig.value.exportFormat || 'xlsx',
includeHeader: matchConfig.value.includeHeader !== false
}
const data = await RunMatchWithAI(config)
const data = await RunMatchWithAI(buildMatchConfig())
results.value = data
stats.value.matched = data.length
} catch (err) {
@@ -227,12 +218,13 @@ async function exportResult() {
}
}
// ----------- Deepseek 密钥管理 -----------
async function saveApiKey() {
if (!deepseekKey.value) return
const result = await SetDeepseekAPIKey(deepseekKey.value)
deepseekReady.value = await GetDeepseekStatus()
if (deepseekReady.value) {
// ----------- AI API 密钥管理 -----------
async function saveApiConfig() {
if (!apiKey.value) return
await SetAIConfig(apiEndpoint.value, apiModel.value, apiKey.value)
const status = await GetAIStatus()
aiReady.value = status.ready === 'true'
if (aiReady.value) {
setTimeout(() => { showApiInput.value = false }, 1000)
}
}
@@ -240,11 +232,15 @@ async function saveApiKey() {
// ----------- 进度监听 -----------
onMounted(async () => {
// 检查 Deepseek 状态
deepseekReady.value = await GetDeepseekStatus()
// 恢复 AI API 配置
try {
const status = await GetAIStatus()
aiReady.value = status.ready === 'true'
apiEndpoint.value = status.endpoint || ''
apiModel.value = status.model || ''
} catch { /* ignore */ }
// 检查 AI 缓存状态
await refreshCacheInfo()
// 监听匹配进度事件
EventsOn('match-progress', (data) => {
progress.value = {
current: data.current,
@@ -348,7 +344,7 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
</button>
<button
class="btn btn-ai"
:disabled="!canMatch || !deepseekReady"
:disabled="!canMatch || !aiReady || loading"
@click="startAIEnhance"
>
<template v-if="aiEnhancing">
@@ -460,33 +456,47 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
</transition>
</div>
<!-- Deepseek API 配置 -->
<div class="deepseek-config">
<!-- AI API 配置 -->
<div class="ai-config">
<button class="btn btn-text" @click="showApiInput = !showApiInput">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1.08-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1.08 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1.08 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1.08z"/>
</svg>
{{ showApiInput ? '收起 API 配置' : '配置 Deepseek API' }}
<span class="status-dot" :class="{ active: deepseekReady }"></span>
{{ showApiInput ? '收起 API 配置' : '配置 AI API' }}
<span class="status-dot" :class="{ active: aiReady }"></span>
</button>
<transition name="slide">
<div v-if="showApiInput" class="api-input-row">
<input
type="text"
v-model="apiEndpoint"
placeholder="API 端点 (如 http://localhost:8080)"
class="api-input api-endpoint"
:disabled="loading"
/>
<input
type="text"
v-model="apiModel"
placeholder="模型 (默认 deepseek-chat)"
class="api-input api-model"
:disabled="loading"
/>
<input
type="password"
v-model="deepseekKey"
placeholder="输入 Deepseek API 密钥 (sk-...)"
v-model="apiKey"
placeholder="API 密钥 (sk-...)"
class="api-input"
:disabled="loading"
/>
<button
class="btn btn-sm btn-outline"
@click="saveApiKey"
:disabled="!deepseekKey || loading"
@click="saveApiConfig"
:disabled="!apiKey || loading"
>
保存
</button>
<span v-if="deepseekReady" class="api-status ok">已配置</span>
<span v-if="aiReady" class="api-status ok">已配置</span>
<span v-else class="api-status na">未配置</span>
</div>
</transition>
@@ -915,8 +925,8 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
.fill-done { background: linear-gradient(90deg, #00b894, #00cec9); }
.progress-sub { margin-top: 10px; font-size: 12px; color: rgba(255,255,255,0.3); min-height: 20px; }
/* ===== Deepseek 配置 ===== */
.deepseek-config { margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.06); }
/* ===== AI API 配置 ===== */
.ai-config { margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.06); }
.cache-config {
margin-top: 12px; display: flex; align-items: center; gap: 6px;
}
@@ -943,6 +953,11 @@ const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.v
transition: border-color 0.2s;
}
.api-input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.12); }
.api-endpoint,
.api-model {
max-width: 320px;
font-size: 13px;
}
.api-status { font-size: 12px; font-weight: 700; padding: 3px 10px; border-radius: 6px; }
.api-status.ok { color: #6ee7b7; background: rgba(16,185,129,0.12); }
.api-status.na { color: #fcd34d; background: rgba(251,191,36,0.1); }

View File

@@ -8,13 +8,11 @@ export function CleanString(arg1:string):Promise<string>;
export function ClearAICache():Promise<string>;
export function DeepseekEnhanceMatching(arg1:string,arg2:string):Promise<Array<main.MatchResult>>;
export function ExportResults(arg1:Array<main.MatchResult>):Promise<string>;
export function GetAICacheInfo():Promise<Record<string, any>>;
export function GetAICacheInfo():Promise<main.AICacheInfo>;
export function GetDeepseekStatus():Promise<boolean>;
export function GetAIStatus():Promise<Record<string, string>>;
export function OpenDailyReport():Promise<string>;
@@ -30,6 +28,6 @@ export function RunMatch(arg1:main.MatchConfig):Promise<Array<main.MatchResult>>
export function RunMatchWithAI(arg1:main.MatchConfig):Promise<Array<main.MatchResult>>;
export function SetDeepseekAPIKey(arg1:string):Promise<string>;
export function SetAIConfig(arg1:string,arg2:string,arg3:string):Promise<string>;
export function StartMatching(arg1:string,arg2:string):Promise<Array<main.MatchResult>>;
export function SetAPIKey(arg1:string):Promise<string>;

View File

@@ -14,10 +14,6 @@ export function ClearAICache() {
return window['go']['main']['App']['ClearAICache']();
}
export function DeepseekEnhanceMatching(arg1, arg2) {
return window['go']['main']['App']['DeepseekEnhanceMatching'](arg1, arg2);
}
export function ExportResults(arg1) {
return window['go']['main']['App']['ExportResults'](arg1);
}
@@ -26,8 +22,8 @@ export function GetAICacheInfo() {
return window['go']['main']['App']['GetAICacheInfo']();
}
export function GetDeepseekStatus() {
return window['go']['main']['App']['GetDeepseekStatus']();
export function GetAIStatus() {
return window['go']['main']['App']['GetAIStatus']();
}
export function OpenDailyReport() {
@@ -58,10 +54,10 @@ export function RunMatchWithAI(arg1) {
return window['go']['main']['App']['RunMatchWithAI'](arg1);
}
export function SetDeepseekAPIKey(arg1) {
return window['go']['main']['App']['SetDeepseekAPIKey'](arg1);
export function SetAIConfig(arg1, arg2, arg3) {
return window['go']['main']['App']['SetAIConfig'](arg1, arg2, arg3);
}
export function StartMatching(arg1, arg2) {
return window['go']['main']['App']['StartMatching'](arg1, arg2);
export function SetAPIKey(arg1) {
return window['go']['main']['App']['SetAPIKey'](arg1);
}

View File

@@ -1,5 +1,19 @@
export namespace main {
export class AICacheInfo {
count: number;
filePath: string;
static createFrom(source: any = {}) {
return new AICacheInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.count = source["count"];
this.filePath = source["filePath"];
}
}
export class MatchConfig {
fileAPath: string;
fileBPath: string;
@@ -46,9 +60,6 @@ export namespace main {
rowAData: string[];
rowBKey: string;
extractValue: string;
monthlyCellName: string;
dailyCellId: string;
interruptReason: string;
timeDiff: string;
similarityScore: number;
aiMatched: boolean;
@@ -62,9 +73,6 @@ 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"];

View File

@@ -2,6 +2,7 @@ package main
import (
"embed"
"log"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
@@ -41,6 +42,6 @@ func main() {
})
if err != nil {
println("Error:", err.Error())
log.Fatalf("应用启动失败: %v", err)
}
}

View File

@@ -1,6 +1,7 @@
package main
import (
"encoding/csv"
"fmt"
"math"
"os"
@@ -45,9 +46,7 @@ func parseTimeFlexible(timeStr string) (time.Time, error) {
// ---------- Levenshtein 距离算法 ----------
func levenshteinDistance(s1, s2 string) int {
runes1 := []rune(s1)
runes2 := []rune(s2)
func levenshteinDistance(runes1, runes2 []rune) int {
m, n := len(runes1), len(runes2)
// 使用一维数组优化空间复杂度
@@ -72,7 +71,7 @@ func levenshteinDistance(s1, s2 string) int {
return dp[n]
}
func min(a, b int) int {
func minInt(a, b int) int {
if a < b {
return a
}
@@ -102,7 +101,26 @@ func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float
return 0.0
}
dist := levenshteinDistance(clean1, clean2)
dist := levenshteinDistance(r1, r2)
maxLen := math.Max(float64(len(r1)), float64(len(r2)))
return 1.0 - float64(dist)/maxLen
}
// similarityFromCleaned 基于已清洗字符串计算相似度(跳过 regex 步骤,避免重复清洗)
func similarityFromCleaned(clean1, clean2 string, caseSensitive bool) float64 {
if !caseSensitive {
clean1 = strings.ToLower(clean1)
clean2 = strings.ToLower(clean2)
}
r1 := []rune(clean1)
r2 := []rune(clean2)
if len(r1) == 0 && len(r2) == 0 {
return 1.0
}
if len(r1) == 0 || len(r2) == 0 {
return 0.0
}
dist := levenshteinDistance(r1, r2)
maxLen := math.Max(float64(len(r1)), float64(len(r2)))
return 1.0 - float64(dist)/maxLen
}
@@ -129,29 +147,28 @@ func (a *App) readRawRows(path string) ([][]string, error) {
}
func (a *App) readCSVRaw(path string) ([][]string, error) {
content, err := os.ReadFile(path)
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("打开 CSV 文件失败: %v", err)
}
defer f.Close()
reader := csv.NewReader(f)
reader.LazyQuotes = true
reader.TrimLeadingSpace = true
allRows, err := reader.ReadAll()
if err != nil {
return nil, fmt.Errorf("读取 CSV 文件失败: %v", err)
}
lines := strings.Split(string(content), "\n")
if len(lines) < 2 {
if len(allRows) < 2 {
return nil, fmt.Errorf("CSV 文件至少需要标题行和一条数据")
}
var rows [][]string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
for i := range allRows {
for j := range allRows[i] {
allRows[i][j] = strings.TrimSpace(allRows[i][j])
}
fields := parseCSVLine(line)
rows = append(rows, fields)
}
if len(rows) < 2 {
return nil, fmt.Errorf("CSV 文件无有效数据行")
}
return rows, nil
return allRows, nil
}
func (a *App) readExcelRaw(path string) ([][]string, error) {
@@ -180,30 +197,81 @@ func getCell(row []string, idx int) string {
return strings.TrimSpace(row[idx])
}
// parseCSVLine 简单 CSV 行解析(支持双引号包裹字段和转义双引号 "" → "
func parseCSVLine(line string) []string {
var fields []string
var current strings.Builder
inQuotes := false
runes := []rune(line)
// ---------- 匹配准备 ----------
for i := 0; i < len(runes); i++ {
ch := runes[i]
switch {
case ch == '"':
if inQuotes && i+1 < len(runes) && runes[i+1] == '"' {
current.WriteRune('"')
i++
} else {
inQuotes = !inQuotes
}
case ch == ',' && !inQuotes:
fields = append(fields, strings.TrimSpace(current.String()))
current.Reset()
default:
current.WriteRune(ch)
}
}
fields = append(fields, strings.TrimSpace(current.String()))
return fields
// matchPrep 匹配准备的中间结果
type matchPrep struct {
dataA, dataB [][]string
reg *regexp.Regexp
timeWindow float64
threshold float64
windowDuration time.Duration
}
// compileRegex 编译正则nil 表示跳过清洗
func compileRegex(pattern string) (*regexp.Regexp, error) {
if pattern == "" {
return nil, nil
}
reg, err := regexp.Compile(pattern)
if err != nil {
return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err)
}
fmt.Printf("[DEBUG] 使用正则: '%s'\n", pattern)
return reg, nil
}
// prepareMatch 编译正则、读取文件、初始化默认值RunMatch / RunMatchWithAI 共用)
func (a *App) prepareMatch(config MatchConfig) (*matchPrep, error) {
reg, err := compileRegex(config.RegexPattern)
if err != nil {
return nil, err
}
tw := config.TimeWindow
if tw <= 0 {
tw = DefaultTimeWindowHours
}
th := config.Threshold
if th <= 0 {
th = DefaultThreshold
}
a.emitProgress(0, 100, "正在读取 A 表...", "reading")
rowsA, err := a.readRawRows(config.FileAPath)
if err != nil {
return nil, fmt.Errorf("读取 A 表失败: %v", err)
}
a.emitProgress(0, 100, "正在读取 B 表...", "reading")
rowsB, err := a.readRawRows(config.FileBPath)
if err != nil {
return nil, fmt.Errorf("读取 B 表失败: %v", err)
}
if len(rowsA) < 2 {
return nil, fmt.Errorf("A 表无有效数据行")
}
if len(rowsB) < 2 {
return nil, fmt.Errorf("B 表无有效数据行")
}
a.dataMu.Lock()
a.headersA = rowsA[0]
a.headersB = rowsB[0]
a.lastConfig = config
a.dataMu.Unlock()
return &matchPrep{
dataA: rowsA[1:],
dataB: rowsB[1:],
reg: reg,
timeWindow: tw,
threshold: th,
windowDuration: time.Duration(tw * float64(time.Hour)),
}, nil
}
// defaultMaxBNoTime 无时间列时 B 表最大行数
const defaultMaxBNoTime = 200
// defaultAIWindowPadH AI 时间窗口额外余量(小时)
const defaultAIWindowPadH = 3.0