Files
office-data-matcher/frontend/src/App.vue
RainySY 40c3966e9a fix: 修复代码审查全部18项问题,重构导出与匹配引擎
A级(严重):
- ExportResults 支持 CSV 格式导出和 IncludeHeader 配置,使用实际表头名
- RunMatchWithAI 消除重复文件读取,提取 runMatchOnData() 内部函数
- AI 缓存文件权限收紧至 0600

B级(中等):
- 移除废弃代码约400行 (MonthlyReport/DailyReport/StartMatching/DeepseekEnhanceMatching)
- 替换自定义 parseCSVLine 为标准 encoding/csv
- GetAICacheInfo 返回命名结构体 AICacheInfo
- 时间差排序改为数值比较
- App.vue 提取 buildMatchConfig() 工厂函数消除配置重复
- AllMatches=false 时命中 1.0 相似度可提前结束 B 表循环

C级(轻微):
- 魔法数字提取为命名常量
- main.go 替换 println 为 log.Fatalf
- 清理未使用变量

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 14:27:15 +08:00

952 lines
37 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
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 { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'
// ----------- 文件与列映射 -----------
const fileAPath = ref('')
const fileBPath = ref('')
const headersA = ref([])
const headersB = ref([])
// 列映射索引(-1 表示未选/不使用)
const colAMatchIdx = ref(-1)
const colATimeIdx = ref(-1)
const colBMatchIdx = ref(-1)
const colBTimeIdx = ref(-1)
const colBExtractIdx = ref(-1)
// ----------- 高级匹配配置 -----------
const showAdvanced = ref(false)
const matchConfig = ref({
regexPattern: '[^\\p{Han}]+',
timeWindow: 12,
threshold: 0.65,
allMatches: false,
caseSensitive: false,
sortBy: '',
maxPreview: 3,
exportFormat: 'xlsx',
includeHeader: true
})
// ----------- 状态 -----------
const loading = ref(false)
const results = ref([])
const exporting = ref(false)
const exportPath = ref('')
const errorMsg = ref('')
const stats = ref({ monthly: 0, daily: 0, matched: 0 })
// ----------- 进度状态 -----------
const progress = ref({ current: 0, total: 100, message: '', phase: '' })
const showProgress = ref(false)
const progressTimerId = ref(null)
// ----------- 进度辅助函数 -----------
function cancelProgressTimer() {
if (progressTimerId.value !== null) {
clearTimeout(progressTimerId.value)
progressTimerId.value = null
}
}
function scheduleProgressDone() {
cancelProgressTimer()
progressTimerId.value = setTimeout(() => {
showProgress.value = false
progressTimerId.value = null
}, 1500)
}
function hideProgressNow() {
cancelProgressTimer()
showProgress.value = false
}
// ----------- Deepseek 状态 -----------
const deepseekKey = ref('')
const deepseekReady = ref(false)
const showApiInput = ref(false)
const aiEnhancing = ref(false)
// ----------- AI 缓存状态 -----------
const cacheInfo = ref({ count: 0, filePath: '' })
async function refreshCacheInfo() {
try { cacheInfo.value = await GetAICacheInfo() } catch { /* ignore */ }
}
async function clearCache() {
try {
const msg = await ClearAICache()
await refreshCacheInfo()
errorMsg.value = ''
} catch (e) {
errorMsg.value = '清除缓存失败: ' + (e.message || e)
}
}
// ----------- 文件选择 -----------
async function selectFileA() {
errorMsg.value = ''
const path = await OpenFileA()
if (!path) return
fileAPath.value = path
// 读表头用于动态下拉框
try {
headersA.value = await ParseHeaders(path)
colAMatchIdx.value = -1; colATimeIdx.value = -1
} catch (e) { errorMsg.value = '读取 A 表头失败: ' + (e.message || e) }
}
async function selectFileB() {
errorMsg.value = ''
const path = await OpenFileB()
if (!path) return
fileBPath.value = path
try {
headersB.value = await ParseHeaders(path)
colBMatchIdx.value = -1; colBTimeIdx.value = -1; colBExtractIdx.value = -1
} catch (e) { errorMsg.value = '读取 B 表头失败: ' + (e.message || e) }
}
// ----------- 智能匹配 -----------
async function startMatching() {
if (!fileAPath.value || !fileBPath.value) return
if (colAMatchIdx.value < 0 || colBMatchIdx.value < 0 || colBExtractIdx.value < 0) {
errorMsg.value = '请完成列映射配置A表匹配列 / B表匹配列 / B表提取列'
return
}
cancelProgressTimer()
loading.value = true; aiEnhancing.value = false; showProgress.value = true
errorMsg.value = ''; results.value = []; exportPath.value = ''
progress.value = { current: 0, total: 100, message: '准备中...', phase: 'reading' }
try {
const data = await RunMatch(buildMatchConfig())
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() }
}
// buildMatchConfig 从响应式状态构建 MatchConfig 对象(消除重复)
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
}
}
// ----------- Deepseek AI 增强匹配 -----------
async function startAIEnhance() {
if (!fileAPath.value || !fileBPath.value) {
errorMsg.value = '请先选择 A 表和 B 表文件'
return
}
if (colAMatchIdx.value < 0 || colBMatchIdx.value < 0 || colBExtractIdx.value < 0) {
errorMsg.value = '请完成列映射配置A表匹配列 / B表匹配列 / B表提取列'
return
}
if (!deepseekReady.value && !deepseekKey.value) {
errorMsg.value = '请先配置 Deepseek API 密钥'
return
}
cancelProgressTimer()
if (!deepseekReady.value && deepseekKey.value) {
await SetDeepseekAPIKey(deepseekKey.value)
deepseekReady.value = await GetDeepseekStatus()
}
aiEnhancing.value = true
loading.value = true
showProgress.value = true
errorMsg.value = ''
results.value = []
exportPath.value = ''
progress.value = { current: 0, total: 100, message: '正在启动 AI 增强匹配...', phase: 'reading' }
try {
const data = await RunMatchWithAI(buildMatchConfig())
results.value = data
stats.value.matched = data.length
} catch (err) {
errorMsg.value = typeof err === 'string' ? err : (err.message || 'AI 增强匹配失败')
hideProgressNow()
} finally {
loading.value = false
aiEnhancing.value = false
if (!errorMsg.value) {
scheduleProgressDone()
}
}
}
// ----------- 导出 -----------
async function exportResult() {
if (results.value.length === 0) return
exporting.value = true
try {
const path = await ExportResults(results.value)
if (path) exportPath.value = path
} catch (err) {
errorMsg.value = typeof err === 'string' ? err : (err.message || '导出失败')
} finally {
exporting.value = false
}
}
// ----------- Deepseek 密钥管理 -----------
async function saveApiKey() {
if (!deepseekKey.value) return
const result = await SetDeepseekAPIKey(deepseekKey.value)
deepseekReady.value = await GetDeepseekStatus()
if (deepseekReady.value) {
setTimeout(() => { showApiInput.value = false }, 1000)
}
}
// ----------- 进度监听 -----------
onMounted(async () => {
// 检查 Deepseek 状态
deepseekReady.value = await GetDeepseekStatus()
// 检查 AI 缓存状态
await refreshCacheInfo()
// 监听匹配进度事件
EventsOn('match-progress', (data) => {
progress.value = {
current: data.current,
total: data.total,
message: data.message,
phase: data.phase
}
})
})
onUnmounted(() => {
cancelProgressTimer()
EventsOff('match-progress')
})
// ----------- 计算属性 -----------
const canMatch = computed(() => fileAPath.value && fileBPath.value && !loading.value)
const hasResults = computed(() => results.value.length > 0)
const progressPercent = computed(() => {
const p = progress.value
if (p.total === 0) return 0
return Math.round((p.current / p.total) * 100)
})
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>
<div class="app-container">
<!-- 顶部标题 -->
<header class="app-header">
<div class="header-icon">
<svg viewBox="0 0 24 24" width="32" height="32" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
</div>
<div class="header-text">
<h1>数据智能匹配工具</h1>
<p class="subtitle">日报中断原因 月报精准匹配</p>
</div>
</header>
<!-- 操作面板 -->
<section class="panel operation-panel">
<div class="file-selectors">
<!-- A -->
<div class="file-row">
<label class="file-label"><span class="label-icon">📅</span>A 基准表</label>
<div class="file-input-group">
<button class="btn btn-outline" @click="selectFileA" :disabled="loading">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>选择文件
</button>
<span class="file-path" :class="{ selected: fileAPath }">{{ fileAPath || '尚未选择' }}</span>
</div>
</div>
<div v-if="headersA.length" class="mapping-row">
<label class="mapping-label">匹配列</label>
<select v-model.number="colAMatchIdx" class="col-select"><option :value="-1">-- 请选择 --</option><option v-for="(h,i) in headersA" :key="i" :value="i">{{ h }}</option></select>
<label class="mapping-label">时间列</label>
<select v-model.number="colATimeIdx" class="col-select"><option :value="-1">跳过时间</option><option v-for="(h,i) in headersA" :key="i" :value="i">{{ h }}</option></select>
</div>
<!-- B -->
<div class="file-row">
<label class="file-label"><span class="label-icon">📋</span>B 数据源表</label>
<div class="file-input-group">
<button class="btn btn-outline" @click="selectFileB" :disabled="loading">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>选择文件
</button>
<span class="file-path" :class="{ selected: fileBPath }">{{ fileBPath || '尚未选择' }}</span>
</div>
</div>
<div v-if="headersB.length" class="mapping-row">
<label class="mapping-label">匹配列</label>
<select v-model.number="colBMatchIdx" class="col-select"><option :value="-1">-- 请选择 --</option><option v-for="(h,i) in headersB" :key="i" :value="i">{{ h }}</option></select>
<label class="mapping-label">时间列</label>
<select v-model.number="colBTimeIdx" class="col-select"><option :value="-1">跳过时间</option><option v-for="(h,i) in headersB" :key="i" :value="i">{{ h }}</option></select>
<label class="mapping-label">提取列</label>
<select v-model.number="colBExtractIdx" class="col-select"><option :value="-1">-- 请选择 --</option><option v-for="(h,i) in headersB" :key="i" :value="i">{{ h }}</option></select>
</div>
</div>
<div class="action-row action-row--multi">
<button
class="btn btn-primary btn-large"
:disabled="!canMatch"
@click="startMatching"
>
<template v-if="loading && !aiEnhancing">
<span class="spinner"></span>
匹配中...
</template>
<template v-else>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
</svg>
开始智能匹配
</template>
</button>
<button
class="btn btn-ai"
:disabled="!canMatch || !deepseekReady"
@click="startAIEnhance"
>
<template v-if="aiEnhancing">
<span class="spinner spinner-dark"></span>
AI 增强中...
</template>
<template v-else>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2a4 4 0 0 1 4 4c0 2-2 4-4 4s-4-2-4-4a4 4 0 0 1 4-4z"/>
<path d="M2 22c0-4 4-8 10-8s10 4 10 8"/>
</svg>
AI 增强匹配
</template>
</button>
</div>
<!-- 高级设置 -->
<div class="advanced-config">
<button class="btn btn-text" @click="showAdvanced = !showAdvanced">
<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="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/>
</svg>
{{ showAdvanced ? '收起匹配规则' : '匹配规则设置' }}
</button>
<transition name="slide">
<div v-if="showAdvanced" class="advanced-form">
<div class="form-row">
<label class="form-label">
清洗正则
<span class="form-hint">匹配内容将被剔除默认保留纯中文</span>
</label>
<input
type="text"
v-model="matchConfig.regexPattern"
class="form-input mono"
placeholder="[^\p{Han}]+"
:disabled="loading"
/>
</div>
<div class="form-row">
<label class="form-label">
时间容错窗口
<span class="form-hint">单位小时</span>
</label>
<input
type="number"
v-model.number="matchConfig.timeWindow"
class="form-input narrow"
min="0"
max="168"
step="1"
:disabled="loading"
/>
</div>
<div class="form-row">
<label class="form-label">
相似度阈值
<span class="form-hint">当前值{{ matchConfig.threshold.toFixed(2) }}</span>
</label>
<input
type="range"
v-model.number="matchConfig.threshold"
class="form-slider"
min="0"
max="1"
step="0.05"
:disabled="loading"
/>
<div class="slider-labels">
<span>0</span>
<span class="slider-tick" v-for="v in [0.25,0.5,0.65,0.8,0.95]" :key="v" :style="{ left: (v*100)+'%' }">|</span>
<span>1</span>
</div>
</div>
<div class="form-row form-row--cols">
<label class="form-label">
匹配策略
<span class="form-hint">全部返回 vs 仅最佳</span>
</label>
<label class="toggle-label">
<input type="checkbox" v-model="matchConfig.allMatches" class="toggle-input" :disabled="loading" />
<span class="toggle-text">{{ matchConfig.allMatches ? '全部匹配' : '仅最佳匹配' }}</span>
</label>
<label class="form-label" style="margin-left:20px">
调试预览
</label>
<input type="number" v-model.number="matchConfig.maxPreview" class="form-input narrow" min="0" max="50" step="1" :disabled="loading" />
</div>
<div class="form-row form-row--cols">
<label class="form-label">
结果排序
<span class="form-hint">匹配结果排序方式</span>
</label>
<select v-model="matchConfig.sortBy" class="col-select" style="max-width:160px" :disabled="loading">
<option value="">不排序</option>
<option value="similarity">相似度降序</option>
<option value="timeDiff">时间差升序</option>
</select>
<label class="form-label" style="margin-left:20px">
导出格式
</label>
<select v-model="matchConfig.exportFormat" class="col-select" style="max-width:100px" :disabled="loading">
<option value="xlsx">Excel</option>
<option value="csv">CSV</option>
</select>
</div>
</div>
</transition>
</div>
<!-- Deepseek API 配置 -->
<div class="deepseek-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>
</button>
<transition name="slide">
<div v-if="showApiInput" class="api-input-row">
<input
type="password"
v-model="deepseekKey"
placeholder="输入 Deepseek API 密钥 (sk-...)"
class="api-input"
:disabled="loading"
/>
<button
class="btn btn-sm btn-outline"
@click="saveApiKey"
:disabled="!deepseekKey || loading"
>
保存
</button>
<span v-if="deepseekReady" class="api-status ok">已配置</span>
<span v-else class="api-status na">未配置</span>
</div>
</transition>
</div>
<!-- AI 缓存管理 -->
<div class="cache-config" v-if="cacheInfo.count >= 0">
<button class="btn btn-text" @click="refreshCacheInfo">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
<path d="M21 3v5h-5"/>
</svg>
AI 缓存{{ cacheInfo.count }}
</button>
<button
v-if="cacheInfo.count > 0"
class="btn btn-sm btn-outline btn-danger-outline"
@click="clearCache"
:disabled="loading"
style="margin-left: 8px"
>
清除缓存
</button>
</div>
<!-- 错误提示 -->
<transition name="fade">
<div v-if="errorMsg" class="error-banner">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span>{{ errorMsg }}</span>
<button class="error-close" @click="errorMsg = ''">&times;</button>
</div>
</transition>
</section>
<!-- 进度面板放在操作面板与结果之间避免被旧定时器误关 -->
<section class="panel progress-panel" v-if="showProgress">
<div class="progress-state">
<div class="progress-header">
<span class="progress-phase">
<span v-if="progress.phase === 'reading'" class="phase-icon">📂</span>
<span v-else-if="progress.phase === 'matching'" class="phase-icon">🔗</span>
<span v-else-if="progress.phase === 'ai-enhancing'" class="phase-icon">🤖</span>
<span v-else class="phase-icon"></span>
{{ progress.message }}
</span>
<span class="progress-pct">{{ progressPercent }}%</span>
</div>
<div class="progress-bar-track">
<div
class="progress-bar-fill"
:class="{
'fill-matching': progress.phase === 'matching',
'fill-ai': progress.phase === 'ai-enhancing',
'fill-done': progress.phase === 'done'
}"
:style="{ width: progressPercent + '%' }"
></div>
</div>
<div class="progress-sub">
<span v-if="progress.phase === 'matching'">
已处理 {{ progress.current }} / {{ progress.total }}
</span>
<span v-else-if="progress.phase === 'ai-enhancing'">
AI 分析中 {{ progress.current }} / {{ progress.total }}
</span>
<span v-else>&nbsp;</span>
</div>
</div>
</section>
<!-- 结果面板 -->
<section class="panel result-panel" v-if="hasResults">
<div class="result-header">
<div class="result-title">
<h2>
匹配结果
<span class="badge">{{ results.length }} </span>
<span v-if="aiMatchedCount > 0" class="badge badge-ai">AI 辅助 {{ aiMatchedCount }} </span>
<span v-else class="badge badge-basic">基础匹配 {{ basicMatchedCount }} </span>
</h2>
</div>
<div class="result-actions">
<button
class="btn btn-success"
@click="exportResult"
:disabled="exporting"
>
<template v-if="exporting">
<span class="spinner"></span>
导出中...
</template>
<template v-else>
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
导出结果
</template>
</button>
</div>
</div>
<!-- 导出路径提示 -->
<transition name="fade">
<div v-if="exportPath" class="success-banner">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<span>导出成功{{ exportPath }}</span>
</div>
</transition>
<!-- 数据表格新通用格式 -->
<div class="table-wrapper">
<table class="result-table">
<thead><tr>
<th v-for="(h,i) in headersA" :key="'ha'+i">{{ h || ('Col'+(i+1)) }}</th>
<th class="col-extract">匹配结果(由B表提取)</th>
</tr></thead>
<tbody>
<tr v-for="(r, idx) in results" :key="idx">
<td v-for="(h,i) in headersA" :key="'da'+i" :title="r.rowAData?.[i]">{{ r.rowAData?.[i] }}</td>
<td class="col-extract" :title="r.extractValue">{{ r.extractValue }}</td>
</tr>
</tbody>
</table>
</div>
</section>
<!-- 空状态 -->
<section class="panel empty-panel" v-else-if="!loading && !errorMsg">
<div class="empty-state">
<svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1" class="empty-icon">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
<h3>等待匹配</h3>
<p>请先选择月报和日报文件然后点击开始智能匹配</p>
</div>
</section>
</div>
</template>
<style scoped>
/* ===== 暗色主题全局 ===== */
.app-container {
max-width: 1280px;
margin: 0 auto;
padding: 32px 40px 64px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', 'Noto Sans SC', sans-serif;
}
/* ===== 头部 ===== */
.app-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 36px;
}
.header-icon {
display: flex; align-items: center; justify-content: center;
width: 60px; height: 60px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: 18px; color: white; flex-shrink: 0;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.35);
}
.header-text h1 {
font-size: 26px; font-weight: 800; color: #fff; margin: 0 0 4px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 14px; color: rgba(255,255,255,0.45); margin: 0; font-weight: 400;
}
/* ===== 卡片面板 ===== */
.panel {
background: rgba(255,255,255,0.04);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 20px;
margin-bottom: 24px;
padding: 28px;
box-shadow: 0 4px 24px rgba(0,0,0,0.2);
}
/* ===== 文件选择器 ===== */
.file-selectors { display: flex; flex-direction: column; gap: 16px; margin-bottom: 8px; }
.file-row { display: flex; align-items: center; gap: 14px; }
.file-label {
display: flex; align-items: center; gap: 8px;
min-width: 110px; font-weight: 700; font-size: 14px; color: rgba(255,255,255,0.8);
}
.label-icon { font-size: 18px; }
.file-input-group { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
.file-path {
font-size: 13px; color: rgba(255,255,255,0.35);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1;
}
.file-path.selected { color: rgba(255,255,255,0.7); font-weight: 500; }
/* ===== 列映射下拉框 ===== */
.mapping-row {
display: flex; align-items: center; gap: 10px; margin-top: 2px;
margin-left: 124px; flex-wrap: wrap;
}
.mapping-label {
font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.5);
min-width: 52px; text-transform: uppercase; letter-spacing: 0.5px;
}
.col-select {
flex: 1; min-width: 130px; max-width: 220px;
padding: 8px 12px; font-size: 12px;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 10px; outline: none;
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.8);
transition: all 0.2s;
}
.col-select:focus {
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102,126,234,0.15);
}
.col-select option { background: #1a1a2e; color: #fff; }
/* ===== 按钮 ===== */
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 10px 20px; font-size: 14px; font-weight: 600;
border: 1px solid transparent; border-radius: 12px;
cursor: pointer; transition: all 0.25s ease; white-space: nowrap;
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-outline {
background: rgba(255,255,255,0.06);
border-color: rgba(255,255,255,0.12);
color: rgba(255,255,255,0.8);
}
.btn-outline:hover:not(:disabled) {
background: rgba(255,255,255,0.1);
border-color: rgba(255,255,255,0.25);
}
.btn-primary {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white; border: none; padding: 14px 36px; font-size: 16px;
font-weight: 700; border-radius: 14px;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
}
.btn-primary:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 40px rgba(102, 126, 234, 0.45);
}
.btn-primary:active:not(:disabled) { transform: translateY(0); }
.btn-success {
background: linear-gradient(135deg, #00b894, #00cec9);
color: white; border: none; padding: 10px 24px; font-weight: 600;
border-radius: 12px; box-shadow: 0 4px 16px rgba(0,184,148,0.25);
}
.btn-success:hover:not(:disabled) {
background: linear-gradient(135deg, #00a381, #00b5b0);
transform: translateY(-1px);
}
.btn-ai {
background: linear-gradient(135deg, #a855f7, #6366f1);
color: white; border: none; padding: 14px 30px; font-weight: 700;
border-radius: 14px; font-size: 15px;
box-shadow: 0 8px 28px rgba(168,85,247,0.3);
}
.btn-ai:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 12px 36px rgba(168,85,247,0.45);
}
.btn-ai:active:not(:disabled) { transform: translateY(0); }
.btn-ai:disabled { opacity: 0.35; cursor: not-allowed; }
.btn-text {
background: none; border: none; color: rgba(255,255,255,0.45);
font-size: 13px; padding: 6px 10px; border-radius: 8px;
cursor: pointer; display: inline-flex; align-items: center; gap: 6px;
}
.btn-text:hover { color: #667eea; background: rgba(102,126,234,0.08); }
.btn-sm { padding: 7px 16px; font-size: 13px; }
.btn-large { min-width: 200px; justify-content: center; }
.action-row { display: flex; justify-content: center; }
.action-row--multi { gap: 14px; flex-wrap: wrap; }
/* ===== Spinner ===== */
.spinner {
display: inline-block; width: 18px; height: 18px;
border: 2.5px solid rgba(255,255,255,0.25);
border-top-color: white; border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner-dark { border-color: rgba(255,255,255,0.2); border-top-color: white; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ===== 横幅 ===== */
.error-banner, .success-banner {
display: flex; align-items: center; gap: 12px;
padding: 14px 18px; border-radius: 14px; margin-top: 18px; font-size: 14px;
animation: slideIn 0.3s ease;
}
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
.error-banner {
background: rgba(220,38,38,0.12); color: #fca5a5;
border: 1px solid rgba(220,38,38,0.2);
}
.success-banner {
background: rgba(16,185,129,0.12); color: #6ee7b7;
border: 1px solid rgba(16,185,129,0.2); margin-bottom: 16px;
}
.error-close { margin-left: auto; background: none; border: none; font-size: 22px; cursor: pointer; color: inherit; opacity: 0.5; }
.error-close:hover { opacity: 1; }
/* ===== 结果面板 ===== */
.result-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.result-title h2 {
font-size: 20px; font-weight: 700; color: #fff; margin: 0;
display: flex; align-items: center; gap: 12px;
}
.badge {
display: inline-flex; align-items: center; padding: 3px 12px;
font-size: 12px; font-weight: 700; border-radius: 20px;
color: #667eea; background: rgba(102,126,234,0.15);
}
.badge-ai { color: #a855f7; background: rgba(168,85,247,0.15); }
/* ===== 表格 ===== */
.table-wrapper {
overflow-x: auto; border-radius: 14px;
border: 1px solid rgba(255,255,255,0.06);
}
.result-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.result-table thead { background: rgba(255,255,255,0.03); }
.result-table th {
padding: 14px 16px; text-align: left;
font-weight: 700; color: rgba(255,255,255,0.6); font-size: 11px;
text-transform: uppercase; letter-spacing: 0.5px;
border-bottom: 1px solid rgba(255,255,255,0.06); white-space: nowrap;
}
.result-table td {
padding: 12px 16px; color: rgba(255,255,255,0.75);
border-bottom: 1px solid rgba(255,255,255,0.03);
max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-size: 13px;
}
.result-table tbody tr { transition: background 0.15s; }
.result-table tbody tr:hover { background: rgba(102,126,234,0.08); }
.result-table tbody tr:last-child td { border-bottom: none; }
.col-extract {
min-width: 180px; font-weight: 600;
color: #a78bfa; background: rgba(167,139,250,0.06);
}
/* ===== 空状态 ===== */
.empty-state { text-align: center; padding: 60px 20px; }
.empty-icon { color: rgba(255,255,255,0.1); margin-bottom: 20px; }
.empty-state h3 { font-size: 20px; font-weight: 700; color: rgba(255,255,255,0.4); margin: 0 0 10px; }
.empty-state p { font-size: 14px; color: rgba(255,255,255,0.25); margin: 0; }
/* ===== 进度条 ===== */
.progress-panel { background: rgba(255,255,255,0.05); }
.progress-state { padding: 4px 0; }
.progress-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 14px; }
.progress-phase { font-size: 14px; font-weight: 600; color: rgba(255,255,255,0.75); display: flex; align-items: center; gap: 8px; }
.phase-icon { font-size: 18px; }
.progress-pct { font-size: 22px; font-weight: 800; color: #667eea; }
.progress-bar-track {
width: 100%; height: 10px; background: rgba(255,255,255,0.06);
border-radius: 5px; overflow: hidden;
}
.progress-bar-fill {
height: 100%; border-radius: 5px; transition: width 0.4s ease;
background: linear-gradient(90deg, #667eea, #764ba2);
box-shadow: 0 0 12px rgba(102,126,234,0.3);
}
.fill-matching { background: linear-gradient(90deg, #667eea, #764ba2); }
.fill-ai { background: linear-gradient(90deg, #a855f7, #6366f1); }
.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); }
.cache-config {
margin-top: 12px; display: flex; align-items: center; gap: 6px;
}
.btn-danger-outline {
border-color: rgba(220,38,38,0.25); color: #fca5a5;
}
.btn-danger-outline:hover:not(:disabled) {
background: rgba(220,38,38,0.1);
border-color: rgba(220,38,38,0.4);
}
.status-dot {
display: inline-block; width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,0.2);
}
.status-dot.active {
background: #00b894; box-shadow: 0 0 10px rgba(0,184,148,0.5);
}
.api-input-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.api-input {
flex: 1; max-width: 440px; padding: 10px 14px; font-size: 13px;
border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; outline: none;
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.8);
font-family: 'SF Mono', 'Fira Code', monospace;
transition: border-color 0.2s;
}
.api-input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.12); }
.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); }
/* ===== 高级设置 ===== */
.advanced-config { margin-top: 18px; padding-top: 16px; border-top: 1px solid rgba(255,255,255,0.06); }
.advanced-form {
display: flex; flex-direction: column; gap: 14px; margin-top: 14px;
padding: 20px; background: rgba(255,255,255,0.03);
border-radius: 14px; border: 1px solid rgba(255,255,255,0.06);
}
.form-row { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.form-label {
min-width: 110px; font-size: 13px; font-weight: 600;
color: rgba(255,255,255,0.65); display: flex; flex-direction: column; gap: 3px;
}
.form-hint { font-weight: 400; font-size: 11px; color: rgba(255,255,255,0.3); }
.form-input {
flex: 1; padding: 9px 14px; font-size: 13px;
border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; outline: none;
background: rgba(255,255,255,0.05); color: rgba(255,255,255,0.8);
transition: border-color 0.2s;
}
.form-input:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.12); }
.form-input.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
.form-input.narrow { max-width: 130px; }
.form-slider { flex: 1; max-width: 300px; height: 6px; accent-color: #667eea; }
/* ===== 多列表单行 ===== */
.form-row--cols { flex-wrap: nowrap; }
.toggle-label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.toggle-input {
width: 40px; height: 22px; appearance: none;
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.15);
border-radius: 11px; position: relative; cursor: pointer; outline: none;
transition: background 0.25s;
}
.toggle-input::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 16px; height: 16px; border-radius: 50%;
background: rgba(255,255,255,0.5); transition: transform 0.25s;
}
.toggle-input:checked { background: #667eea; border-color: #667eea; }
.toggle-input:checked::after { transform: translateX(18px); background: white; }
.toggle-text { font-size: 12px; color: rgba(255,255,255,0.55); }
/* ===== 过渡动画 ===== */
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s ease; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
.slide-enter-active, .slide-leave-active { transition: all 0.25s ease; overflow: hidden; }
.slide-enter-from, .slide-leave-to { max-height: 0; opacity: 0; }
.slide-enter-to, .slide-leave-from { max-height: 400px; opacity: 1; }
</style>