release: v1.1.0
移除 V1 死代码(6 个导出方法、4 个内部函数、~300 行), 新增 cache.go/ai.go/matcher.go/export.go 拆分 app.go(原 1645 行), 修复 AICache 3 个并发 bug(TOCTOU、反序列化、全排序), 提取 8 个命名常量,前端添加 isRunning 守卫和 CSS 变量。 Go 升级至 1.24.0。
This commit is contained in:
179
ai.go
Normal file
179
ai.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- Deepseek API 类型 ----------
|
||||
|
||||
type deepseekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type deepseekRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []deepseekMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
MaxTokens int `json:"max_tokens,omitempty"`
|
||||
}
|
||||
|
||||
type deepseekResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ---------- Deepseek AI 增强匹配 ----------
|
||||
|
||||
// hashPrompt 对 prompt 消息计算 SHA256(用于缓存键)
|
||||
func hashPrompt(messages []deepseekMessage) string {
|
||||
h := sha256.New()
|
||||
for _, m := range messages {
|
||||
h.Write([]byte(m.Role))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(m.Content))
|
||||
h.Write([]byte{0})
|
||||
}
|
||||
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 密钥")
|
||||
}
|
||||
|
||||
hash := hashPrompt(messages)
|
||||
|
||||
// 先查缓存
|
||||
if cached, ok := a.aiCache.get(hash); ok {
|
||||
fmt.Printf("[CACHE] ✓ 命中 AI 缓存 (hash=%s)\n", hash[:12])
|
||||
return cached, nil
|
||||
}
|
||||
fmt.Printf("[CACHE] ✗ 缓存未命中 (hash=%s),调用 API...\n", hash[:12])
|
||||
|
||||
reqBody := deepseekRequest{
|
||||
Model: deepseekModel,
|
||||
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))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("创建请求失败: %v", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+a.deepseekKey)
|
||||
|
||||
client := &http.Client{Timeout: 60 * time.Second}
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("调用 Deepseek API 失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
var dr deepseekResponse
|
||||
if err := json.Unmarshal(respBytes, &dr); err != nil {
|
||||
return "", fmt.Errorf("解析 Deepseek 响应失败: %v", err)
|
||||
}
|
||||
|
||||
if dr.Error != nil {
|
||||
return "", fmt.Errorf("Deepseek API 错误: %s", dr.Error.Message)
|
||||
}
|
||||
|
||||
if len(dr.Choices) == 0 {
|
||||
return "", fmt.Errorf("Deepseek 未返回有效结果")
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(dr.Choices[0].Message.Content)
|
||||
|
||||
// 写入缓存并持久化
|
||||
a.aiCache.put(hash, result)
|
||||
a.aiCache.saveToFile()
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// buildGenericAIPrompt 构建通用 AI 匹配提示词
|
||||
func (a *App) buildGenericAIPrompt(unmatched, bRows [][]string, config MatchConfig, windowDuration time.Duration, hasTime bool) []deepseekMessage {
|
||||
var sb strings.Builder
|
||||
sb.WriteString("你是一个数据匹配专家。请根据以下 A 表记录,从 B 表数据中找出最匹配的记录。\n\n")
|
||||
sb.WriteString("匹配规则:\n")
|
||||
sb.WriteString("1. 根据文本相似度匹配(注意中文字段的核心含义,忽略字母数字前缀后缀)\n")
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf("2. 时间差应在 %.0f 小时内\n", windowDuration.Hours()))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("3. 返回匹配到的 B 表记录的目标列值(第 %d 列)\n\n", config.ColBExtractIndex+1))
|
||||
|
||||
sb.WriteString("请严格按照以下 JSON 格式返回结果:\n")
|
||||
sb.WriteString(`{"matches":[{"index":0,"value":"匹配到的目标列值"},{"index":1,"value":""}]}` + "\n")
|
||||
sb.WriteString("如果某条无法匹配,value 设为空字符串。\n\n")
|
||||
|
||||
sb.WriteString(fmt.Sprintf("A 表记录(需要匹配,共 %d 条):\n", len(unmatched)))
|
||||
for i, row := range unmatched {
|
||||
matchVal := getCell(row, config.ColAMatchIndex)
|
||||
sb.WriteString(fmt.Sprintf("- 索引 %d: 「%s」", i, matchVal))
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColATimeIndex)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString(fmt.Sprintf("\nB 表参考数据(共 %d 条):\n", len(bRows)))
|
||||
for _, row := range bRows {
|
||||
matchVal := getCell(row, config.ColBMatchIndex)
|
||||
extractVal := getCell(row, config.ColBExtractIndex)
|
||||
sb.WriteString(fmt.Sprintf(" 「%s」 → 目标列值: 「%s」", matchVal, extractVal))
|
||||
if hasTime {
|
||||
sb.WriteString(fmt.Sprintf(", 时间=%s", getCell(row, config.ColBTimeIndex)))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
sb.WriteString("\n请返回 JSON 格式的匹配结果。")
|
||||
|
||||
return []deepseekMessage{
|
||||
{Role: "system", Content: "你是一个数据匹配专家。请严格按照 JSON 格式返回结果,不要添加额外说明。"},
|
||||
{Role: "user", Content: sb.String()},
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimeDiff 格式化时间差为可读字符串
|
||||
func formatTimeDiff(d time.Duration) string {
|
||||
abs := d
|
||||
if abs < 0 {
|
||||
abs = -abs
|
||||
}
|
||||
hours := int(abs.Hours())
|
||||
mins := int(abs.Minutes()) % 60
|
||||
secs := int(abs.Seconds()) % 60
|
||||
|
||||
sign := ""
|
||||
if d < 0 {
|
||||
sign = "-"
|
||||
}
|
||||
if hours > 0 {
|
||||
return fmt.Sprintf("%s%dh%dm%ds", sign, hours, mins, secs)
|
||||
} else if mins > 0 {
|
||||
return fmt.Sprintf("%s%dm%ds", sign, mins, secs)
|
||||
}
|
||||
return fmt.Sprintf("%s%ds", sign, secs)
|
||||
}
|
||||
136
cache.go
Normal file
136
cache.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ---------- AI 缓存 ----------
|
||||
|
||||
// AICacheEntry 单条缓存记录
|
||||
type AICacheEntry struct {
|
||||
PromptHash string `json:"promptHash"`
|
||||
Response string `json:"response"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
}
|
||||
|
||||
// AICache AI 响应缓存(持久化到临时文件)
|
||||
type AICache struct {
|
||||
Entries []AICacheEntry `json:"entries"`
|
||||
mu sync.RWMutex // 小写,必须保持非导出以兼容 JSON 序列化
|
||||
filePath string
|
||||
maxSize int // 最大缓存条目数
|
||||
}
|
||||
|
||||
// cacheFileName 缓存文件名
|
||||
const cacheFileName = "data-matcher-ai-cache.json"
|
||||
|
||||
// newAICache 创建缓存实例并加载已有数据
|
||||
func newAICache() *AICache {
|
||||
c := &AICache{
|
||||
filePath: filepath.Join(os.TempDir(), cacheFileName),
|
||||
maxSize: cacheMaxSize,
|
||||
}
|
||||
c.loadFromFile()
|
||||
return c
|
||||
}
|
||||
|
||||
// loadFromFile 从磁盘加载缓存
|
||||
func (c *AICache) loadFromFile() {
|
||||
data, err := os.ReadFile(c.filePath)
|
||||
if err != nil {
|
||||
return // 文件不存在或无法读取,从空缓存开始
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
var loaded AICache
|
||||
if err := json.Unmarshal(data, &loaded); err != nil || len(loaded.Entries) == 0 {
|
||||
return // 解析失败或无数据,保留当前缓存
|
||||
}
|
||||
// 验证每个条目字段完整性
|
||||
for _, e := range loaded.Entries {
|
||||
if e.PromptHash == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
c.Entries = loaded.Entries
|
||||
}
|
||||
|
||||
// saveToFile 将缓存写入磁盘(线程安全)
|
||||
func (c *AICache) saveToFile() {
|
||||
c.mu.RLock()
|
||||
// 仅序列化 Entries,避免泄露 filePath 等内部字段
|
||||
entries := make([]AICacheEntry, len(c.Entries))
|
||||
copy(entries, c.Entries)
|
||||
c.mu.RUnlock()
|
||||
|
||||
data, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.WriteFile(c.filePath, data, 0644)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// put 存入一条缓存(线程安全 + 自动裁剪)
|
||||
func (c *AICache) put(hash, response string) {
|
||||
c.mu.Lock()
|
||||
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:]...)
|
||||
}
|
||||
|
||||
c.Entries = append(c.Entries, AICacheEntry{
|
||||
PromptHash: hash,
|
||||
Response: response,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
// clear 清空所有缓存
|
||||
func (c *AICache) clear() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.Entries = nil
|
||||
_ = os.Remove(c.filePath)
|
||||
}
|
||||
|
||||
// stat 返回缓存统计
|
||||
func (c *AICache) stat() (count int, path string) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
return len(c.Entries), c.filePath
|
||||
}
|
||||
111
export.go
Normal file
111
export.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ---------- 导出结果 ----------
|
||||
|
||||
// ExportResults 将匹配结果导出为 Excel 文件
|
||||
func (a *App) ExportResults(results []MatchResult) (string, error) {
|
||||
if len(results) == 0 {
|
||||
return "", fmt.Errorf("没有匹配结果可以导出")
|
||||
}
|
||||
|
||||
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "导出匹配结果",
|
||||
DefaultFilename: fmt.Sprintf("匹配结果_%s.xlsx", time.Now().Format("20060102_150405")),
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "Excel 文件 (*.xlsx)", Pattern: "*.xlsx"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("打开保存对话框失败: %v", err)
|
||||
}
|
||||
if savePath == "" {
|
||||
return "", nil
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(savePath), ".xlsx") {
|
||||
savePath += ".xlsx"
|
||||
}
|
||||
|
||||
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表提取)")
|
||||
|
||||
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"}},
|
||||
})
|
||||
lastCol, _ := excelize.ColumnNumberToName(len(headers))
|
||||
f.SetCellStyle(sheetName, "A1", fmt.Sprintf("%s1", lastCol), headerStyle)
|
||||
|
||||
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 _, c := range []string{"A", "B", "C", "D", "E", "F"} {
|
||||
f.SetColWidth(sheetName, c, c, 22)
|
||||
}
|
||||
}
|
||||
|
||||
if err := f.SaveAs(savePath); err != nil {
|
||||
return "", fmt.Errorf("保存文件失败: %v", err)
|
||||
}
|
||||
return savePath, nil
|
||||
}
|
||||
@@ -15,4 +15,4 @@
|
||||
"@vitejs/plugin-vue": "^3.0.3",
|
||||
"vite": "^3.0.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
21d2a2199c4fb87865d8160b492f51c3
|
||||
12ce3b60b7598eec37a86eb924e48101
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup>
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
|
||||
// Wails 自动生成的绑定
|
||||
@@ -33,6 +33,7 @@ const matchConfig = ref({
|
||||
})
|
||||
|
||||
// ----------- 状态 -----------
|
||||
const isRunning = ref(false)
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const exporting = ref(false)
|
||||
@@ -115,12 +116,14 @@ async function selectFileB() {
|
||||
|
||||
// ----------- 智能匹配 -----------
|
||||
async function startMatching() {
|
||||
if (isRunning.value) return
|
||||
if (!fileAPath.value || !fileBPath.value) return
|
||||
if (colAMatchIdx.value < 0 || colBMatchIdx.value < 0 || colBExtractIdx.value < 0) {
|
||||
errorMsg.value = '请完成列映射配置(A表匹配列 / B表匹配列 / B表提取列)'
|
||||
return
|
||||
}
|
||||
cancelProgressTimer()
|
||||
isRunning.value = true
|
||||
loading.value = true; aiEnhancing.value = false; showProgress.value = true
|
||||
errorMsg.value = ''; results.value = []; exportPath.value = ''
|
||||
progress.value = { current: 0, total: 100, message: '准备中...', phase: 'reading' }
|
||||
@@ -145,11 +148,11 @@ async function startMatching() {
|
||||
results.value = data; stats.value.matched = data.length
|
||||
} catch (err) { errorMsg.value = typeof err === 'string' ? err : (err.message || '匹配失败')
|
||||
hideProgressNow()
|
||||
} finally { loading.value = false; if (!errorMsg.value) scheduleProgressDone() }
|
||||
} finally { loading.value = false; isRunning.value = false; if (!errorMsg.value) scheduleProgressDone() }
|
||||
}
|
||||
|
||||
// ----------- Deepseek AI 增强匹配 -----------
|
||||
async function startAIEnhance() {
|
||||
if (isRunning.value) return
|
||||
if (!fileAPath.value || !fileBPath.value) {
|
||||
errorMsg.value = '请先选择 A 表和 B 表文件'
|
||||
return
|
||||
@@ -170,6 +173,7 @@ async function startAIEnhance() {
|
||||
deepseekReady.value = await GetDeepseekStatus()
|
||||
}
|
||||
|
||||
isRunning.value = true
|
||||
aiEnhancing.value = true
|
||||
loading.value = true
|
||||
showProgress.value = true
|
||||
@@ -202,7 +206,7 @@ async function startAIEnhance() {
|
||||
hideProgressNow()
|
||||
} finally {
|
||||
loading.value = false
|
||||
aiEnhancing.value = false
|
||||
isRunning.value = false
|
||||
if (!errorMsg.value) {
|
||||
scheduleProgressDone()
|
||||
}
|
||||
@@ -267,12 +271,6 @@ const progressPercent = computed(() => {
|
||||
const aiMatchedCount = computed(() => results.value.filter(r => r.aiMatched).length)
|
||||
const basicMatchedCount = computed(() => results.value.length - aiMatchedCount.value)
|
||||
|
||||
// ----------- 辅助函数 -----------
|
||||
function scoreClass(score) {
|
||||
if (score >= 0.9) return 'score-high'
|
||||
if (score >= 0.75) return 'score-mid'
|
||||
return 'score-low'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -644,6 +642,44 @@ function scoreClass(score) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ===== CSS 自定义属性 ===== */
|
||||
:root {
|
||||
--color-primary: #667eea;
|
||||
--color-primary-rgb: 102, 126, 234;
|
||||
--color-secondary: #764ba2;
|
||||
--color-secondary-rgb: 118, 75, 162;
|
||||
--color-ai-from: #a855f7;
|
||||
--color-ai-to: #6366f1;
|
||||
--color-success: #00b894;
|
||||
--color-success-to: #00cec9;
|
||||
--color-danger: #dc2626;
|
||||
--color-text: rgba(255, 255, 255, 0.8);
|
||||
--color-text-muted: rgba(255, 255, 255, 0.45);
|
||||
--color-text-dim: rgba(255, 255, 255, 0.35);
|
||||
--color-bg-panel: rgba(255, 255, 255, 0.04);
|
||||
--color-border-panel: rgba(255, 255, 255, 0.08);
|
||||
--color-border-light: rgba(255, 255, 255, 0.1);
|
||||
--color-border-hover: rgba(255, 255, 255, 0.12);
|
||||
--color-bg-hover: rgba(255, 255, 255, 0.06);
|
||||
--color-input-bg: rgba(255, 255, 255, 0.05);
|
||||
--color-primary-glow: rgba(var(--color-primary-rgb), 0.35);
|
||||
--color-primary-glow-sm: rgba(var(--color-primary-rgb), 0.15);
|
||||
--color-success-glow: rgba(0, 184, 148, 0.25);
|
||||
--color-ai-glow: rgba(168, 85, 247, 0.3);
|
||||
--color-ai-glow-hover: rgba(168, 85, 247, 0.45);
|
||||
--color-danger-border: rgba(220, 38, 38, 0.25);
|
||||
--radius-sm: 10px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 14px;
|
||||
--radius-xl: 18px;
|
||||
--radius-round: 20px;
|
||||
--shadow-panel: 0 4px 24px rgba(0, 0, 0, 0.2);
|
||||
--shadow-primary: 0 8px 32px var(--color-primary-glow);
|
||||
--shadow-primary-hover: 0 12px 40px var(--color-primary-glow);
|
||||
--transition-fast: 0.2s;
|
||||
--transition-normal: 0.25s;
|
||||
}
|
||||
|
||||
/* ===== 暗色主题全局 ===== */
|
||||
.app-container {
|
||||
max-width: 1280px;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {defineConfig} from 'vite'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 34115,
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
|
||||
209
matcher.go
Normal file
209
matcher.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/xuri/excelize/v2"
|
||||
)
|
||||
|
||||
// ---------- 健壮的时间解析 ----------
|
||||
|
||||
// 多种时间格式,覆盖月报和日报的不同格式
|
||||
var timeFormats = []string{
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006/01/02 15:04:05",
|
||||
"2006/01/02 15:04",
|
||||
"2006-1-2 15:04:05",
|
||||
"2006-1-2 15:04",
|
||||
"2006/1/2 15:04:05",
|
||||
"2006/1/2 15:04",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006/01/02T15:04:05",
|
||||
"01/02/2006 15:04",
|
||||
"1/2/2006 15:04",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
}
|
||||
|
||||
// parseTimeFlexible 使用多种格式尝试解析时间字符串
|
||||
func parseTimeFlexible(timeStr string) (time.Time, error) {
|
||||
timeStr = strings.TrimSpace(timeStr)
|
||||
for _, format := range timeFormats {
|
||||
if t, err := time.Parse(format, timeStr); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, fmt.Errorf("无法解析时间格式: %s", timeStr)
|
||||
}
|
||||
|
||||
// ---------- Levenshtein 距离算法 ----------
|
||||
|
||||
func levenshteinDistance(s1, s2 string) int {
|
||||
runes1 := []rune(s1)
|
||||
runes2 := []rune(s2)
|
||||
m, n := len(runes1), len(runes2)
|
||||
|
||||
// 使用一维数组优化空间复杂度
|
||||
dp := make([]int, n+1)
|
||||
for j := range dp {
|
||||
dp[j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= m; i++ {
|
||||
prev := dp[0]
|
||||
dp[0] = i
|
||||
for j := 1; j <= n; j++ {
|
||||
temp := dp[j]
|
||||
cost := 1
|
||||
if runes1[i-1] == runes2[j-1] {
|
||||
cost = 0
|
||||
}
|
||||
dp[j] = min(dp[j]+1, min(dp[j-1]+1, prev+cost))
|
||||
prev = temp
|
||||
}
|
||||
}
|
||||
return dp[n]
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// calcSimilarity 带自定义正则的相似度计算;reg 为 nil 时不做清洗直接比对
|
||||
func calcSimilarity(s1, s2 string, reg *regexp.Regexp, caseSensitive bool) float64 {
|
||||
clean1 := s1
|
||||
clean2 := s2
|
||||
if reg != nil {
|
||||
clean1 = reg.ReplaceAllString(s1, "")
|
||||
clean2 = reg.ReplaceAllString(s2, "")
|
||||
}
|
||||
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(clean1, clean2)
|
||||
maxLen := math.Max(float64(len(r1)), float64(len(r2)))
|
||||
return 1.0 - float64(dist)/maxLen
|
||||
}
|
||||
|
||||
// cleanWithRegex 使用自定义正则清洗字符串;reg 为 nil 时返回原文
|
||||
func cleanWithRegex(input string, reg *regexp.Regexp) string {
|
||||
if reg == nil {
|
||||
return input
|
||||
}
|
||||
return reg.ReplaceAllString(input, "")
|
||||
}
|
||||
|
||||
// ---------- 文件读取(通用)----------
|
||||
|
||||
// readRawRows 读取 Excel/CSV 文件,返回原始二维字符串切片(row[0] = 表头)
|
||||
func (a *App) readRawRows(path string) ([][]string, error) {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".csv":
|
||||
return a.readCSVRaw(path)
|
||||
default:
|
||||
return a.readExcelRaw(path)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) readCSVRaw(path string) ([][]string, error) {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取 CSV 文件失败: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
if len(lines) < 2 {
|
||||
return nil, fmt.Errorf("CSV 文件至少需要标题行和一条数据")
|
||||
}
|
||||
|
||||
var rows [][]string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := parseCSVLine(line)
|
||||
rows = append(rows, fields)
|
||||
}
|
||||
if len(rows) < 2 {
|
||||
return nil, fmt.Errorf("CSV 文件无有效数据行")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (a *App) readExcelRaw(path string) ([][]string, error) {
|
||||
f, err := excelize.OpenFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开 Excel 文件失败: %v", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sheetName := f.GetSheetName(0)
|
||||
allRows, err := f.GetRows(sheetName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取工作表失败: %v", err)
|
||||
}
|
||||
if len(allRows) < 2 {
|
||||
return nil, fmt.Errorf("Excel 文件至少需要标题行和一条数据")
|
||||
}
|
||||
return allRows, nil
|
||||
}
|
||||
|
||||
// getCell 安全获取行中指定索引的单元格值,越界返回空字符串
|
||||
func getCell(row []string, idx int) string {
|
||||
if idx < 0 || idx >= len(row) {
|
||||
return ""
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user