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:
89
ai.go
89
ai.go
@@ -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 兼容 API(Deepseek / 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
|
||||
}
|
||||
|
||||
326
app.go
326
app.go
@@ -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)
|
||||
prep, err := a.prepareMatch(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("正则表达式格式错误,请检查: %v", err)
|
||||
}
|
||||
fmt.Printf("[DEBUG] RunMatch 使用正则: '%s'\n", config.RegexPattern)
|
||||
} else {
|
||||
fmt.Printf("[DEBUG] RunMatch 跳过清洗(正则为空)\n")
|
||||
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)
|
||||
a.emitProgress(0, totalUnmatched,
|
||||
fmt.Sprintf("AI 增强匹配:还有 %d 条未匹配记录,正在调用 Deepseek...", totalUnmatched),
|
||||
"ai-enhancing")
|
||||
|
||||
batchSize := DefaultBatchSize
|
||||
aiMatched := 0
|
||||
var failedBatches []int
|
||||
|
||||
for batchStart := 0; batchStart < totalUnmatched; batchStart += batchSize {
|
||||
end := batchStart + batchSize
|
||||
if end > totalUnmatched {
|
||||
end = totalUnmatched
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
a.emitProgress(batchStart+1, totalUnmatched,
|
||||
fmt.Sprintf("AI 分析中 %d/%d (第 %d 批)...", end, totalUnmatched, (batchStart/batchSize)+1),
|
||||
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 条命中缓存,%d 条需调用 AI...", cacheHits, totalUnmatched),
|
||||
"ai-enhancing")
|
||||
|
||||
batch := unmatchedA[batchStart:end]
|
||||
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, batchNum),
|
||||
"ai-enhancing")
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
a.emitProgress(totalUnmatched, totalUnmatched,
|
||||
fmt.Sprintf("AI 增强完成!基础匹配 %d 条 + AI 补充 %d 条 = 共 %d 条",
|
||||
len(results)-aiMatched, aiMatched, len(results)), "done")
|
||||
// 写入行级缓存
|
||||
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()
|
||||
|
||||
// 构建完成消息
|
||||
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
|
||||
}
|
||||
|
||||
147
cache.go
147
cache.go
@@ -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 序列化
|
||||
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 // 最大缓存条目数
|
||||
maxSize int // 批量缓存最大条目数
|
||||
maxRowSize int // 行级缓存最大条目数
|
||||
}
|
||||
|
||||
// cacheFileName 缓存文件名
|
||||
@@ -33,6 +46,7 @@ func newAICache() *AICache {
|
||||
c := &AICache{
|
||||
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()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 超过上限则移除最旧条目,避免全部排序
|
||||
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:]...)
|
||||
}
|
||||
|
||||
// 新增条目
|
||||
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
|
||||
}
|
||||
|
||||
200
export.go
200
export.go
@@ -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表提取)")
|
||||
|
||||
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)
|
||||
}
|
||||
} 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)
|
||||
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 {
|
||||
dataStyle, _ := f.NewStyle(&excelize.Style{
|
||||
Font: &excelize.Font{Size: 11},
|
||||
Border: []excelize.Border{
|
||||
{Type: "bottom", Color: "D9D9D9", Style: 1},
|
||||
},
|
||||
})
|
||||
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 = "是"
|
||||
if !includeHeader {
|
||||
rowNum = i + 1
|
||||
}
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("F%d", rowNum), aiLabel)
|
||||
for ci := 0; ci < numACols; ci++ {
|
||||
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", colLetter(ci), rowNum), r.RowAData[ci])
|
||||
}
|
||||
for _, c := range []string{"A", "B", "C", "D", "E", "F"} {
|
||||
f.SetColWidth(sheetName, c, c, 22)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
10
frontend/wailsjs/go/main/App.d.ts
vendored
10
frontend/wailsjs/go/main/App.d.ts
vendored
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
3
main.go
3
main.go
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
160
matcher.go
160
matcher.go
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user