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:
sakuradairong
2026-06-05 14:40:55 +08:00
parent b3ec20fd77
commit 40745f5632
9 changed files with 723 additions and 1162 deletions

179
ai.go Normal file
View 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)
}

1185
app.go

File diff suppressed because it is too large Load Diff

136
cache.go Normal file
View 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
View 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
}

View File

@@ -15,4 +15,4 @@
"@vitejs/plugin-vue": "^3.0.3",
"vite": "^3.0.7"
}
}
}

View File

@@ -1 +1 @@
21d2a2199c4fb87865d8160b492f51c3
12ce3b60b7598eec37a86eb924e48101

View File

@@ -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;

View File

@@ -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
View 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
}