feat: 智能导入——BOT自动识别报表类型/列名/期间/匹配KPI,无需手动映射

前端: 简化上传界面,自动模式(importExcelSmart)
后端: 新增/import-excel-smart端点,自动检测:
  - 列名: 科目/编码→kpi_code, 本期金额→value, 期间→period
  - 文件名: 提取期间(2026-06)和报表类型(利润表/现金流量表/资产负债表)
  - KPI匹配: 编码精确→别名→中文名模糊匹配
This commit is contained in:
Hermes CI Fix
2026-07-13 15:36:22 +08:00
parent da9aceb567
commit d3edd71e60
4 changed files with 193 additions and 78 deletions
+5
View File
@@ -67,6 +67,11 @@ export const dataApi = {
form.append('file', file)
return api.post(`/data/import-excel${qs ? '?' + qs : ''}`, form)
},
importExcelSmart: (file: File) => {
const form = new FormData()
form.append('file', file)
return api.post('/data/import-excel-smart', form, { timeout: 60000 })
},
listSources: () => api.get('/data/sources'),
createSource: (data: any) => api.post('/data/sources', data),
updateSource: (id: number, data: any) => api.put(`/data/sources/${id}`, data),
+8 -77
View File
@@ -5,52 +5,17 @@
<el-tabs v-model="activeTab" style="margin-top:16px;">
<el-tab-pane label="Excel导入" name="import">
<el-card>
<template #header>Excel导入</template>
<template #header>Excel导入 <el-tag type="success" size="small">🤖 智能识别</el-tag></template>
<el-upload drag :auto-upload="false" :on-change="handleFile" :on-remove="handleRemove" multiple accept=".xlsx,.xls">
<el-icon :size="32"><UploadFilled /></el-icon>
<div>拖拽或点击上传Excel文件</div>
<div style="color:#999;font-size:12px;">支持 .xlsx .xls可一次选择多个文件按住Ctrl/Shift多选</div>
<div style="color:#999;font-size:12px;">支持 .xlsx .xls自动识别利润表/现金流量表/资产负债表/KPI数据表</div>
</el-upload>
<div v-if="files.length > 0" style="margin-top:8px;">
<el-tag v-for="(f, i) in files" :key="i" closable @close="removeFile(i)" style="margin:2px;">{{ f.name }}</el-tag>
</div>
<!-- 列映射设置 -->
<el-card v-if="files.length > 0" style="margin-top:12px;" size="small">
<template #header>📐 列映射设置如Excel列名与标准不同请在此映射</template>
<el-form :model="colMap" label-width="120px" size="small">
<el-row :gutter="16">
<el-col :span="8">
<el-form-item label="KPI编码列">
<el-select v-model="colMap.kpi" allow-create filterable clearable placeholder="kpi_code">
<el-option v-for="c in detectedCols" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="期间列(可选)">
<el-select v-model="colMap.period" allow-create filterable clearable placeholder="period">
<el-option v-for="c in detectedCols" :key="c" :label="c" :value="c" />
<el-option label="(统一设置)" value="__fixed__" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="数值列">
<el-select v-model="colMap.value" allow-create filterable clearable placeholder="actual_value">
<el-option v-for="c in detectedCols" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item v-if="colMap.period === '__fixed__'" label="统一期间">
<el-input v-model="colMap.defaultPeriod" placeholder="如 2026-06" style="width:200px;" />
</el-form-item>
</el-form>
</el-card>
<el-button v-if="files.length > 0" type="primary" style="margin-top:12px;" :loading="uploading" @click="doImport">导入 {{ files.length }} 个文件</el-button>
<div v-if="result" style="margin-top:12px;"><el-alert :title="result" type="success" show-icon /></div>
<el-button v-if="files.length > 0" type="primary" style="margin-top:12px;" :loading="uploading" @click="doImport">🤖 智能导入 {{ files.length }} 个文件</el-button>
<div v-if="result" style="margin-top:12px;white-space:pre-wrap;"><el-alert :title="result" type="success" show-icon /></div>
</el-card>
</el-tab-pane>
@@ -77,7 +42,7 @@
</el-table-column>
<el-table-column prop="status" label="状态" width="70">
<template #default="{ row }">
<el-tag :type="row.status==='active'?'success':'danger'" size="small">{{ row.status }}</el-tag>
<el-tag :type="row.status==='active'?'success':'danger'">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="last_sync_at" label="上次同步" width="160" />
@@ -135,14 +100,10 @@ const activeTab = ref('import')
const files = ref<any[]>([])
const uploading = ref(false)
const result = ref('')
const detectedCols = ref<string[]>([])
const colMap = ref({ kpi: 'kpi_code', period: 'period', value: 'actual_value', defaultPeriod: '' })
function handleFile(f: any) {
if (f.raw && !files.value.some(ex => ex.name === f.raw.name && ex.size === f.raw.size)) {
files.value.push(f.raw)
// 从第一个文件检测列名
if (files.value.length === 1) detectColumns(f.raw)
}
}
function handleRemove(f: any) {
@@ -151,51 +112,21 @@ function handleRemove(f: any) {
function removeFile(i: number) {
files.value.splice(i, 1)
}
async function detectColumns(file: File) {
// 用 Web 方式读 Excel 列名(仅第一行),供用户映射
try {
const buf = await file.arrayBuffer()
const XLSX = (await import('xlsx')).default
const wb = XLSX.read(buf, { type: 'array' })
const ws = wb.Sheets[wb.SheetNames[0]]
const rows: any[] = XLSX.utils.sheet_to_json(ws, { header: 1 })
if (rows.length > 0) {
detectedCols.value = (rows[0] as string[]).filter(Boolean)
// 尝试智能匹配
const all = detectedCols.value.map(c => c.toLowerCase().replace(/[\\s ]/g, ''))
const kpiIdx = all.findIndex(c => /^(kpi_?code|编码|科目|项目|指标名称?)$/.test(c))
const periodIdx = all.findIndex(c => /^(period|期间|月份?|日期|年月)$/.test(c))
const valIdx = all.findIndex(c => /^(actual_?value|金额|数值|本月数|本期金额?|实际值)$/.test(c))
if (kpiIdx >= 0) colMap.value.kpi = detectedCols.value[kpiIdx]
if (periodIdx >= 0) colMap.value.period = detectedCols.value[periodIdx]
if (valIdx >= 0) colMap.value.value = detectedCols.value[valIdx]
}
} catch (_) { /* 预览列名失败不影响导入 */ }
}
async function doImport() {
if (files.value.length === 0) return
uploading.value = true
result.value = ''
const msgs: string[] = []
const params: Record<string, string> = {
kpi_col: colMap.value.kpi || 'kpi_code',
value_col: colMap.value.value || 'actual_value',
}
if (colMap.value.period && colMap.value.period !== '__fixed__') params['period_col'] = colMap.value.period
if (colMap.value.period === '__fixed__' && colMap.value.defaultPeriod) params['default_period'] = colMap.value.defaultPeriod
const qs = new URLSearchParams(params).toString()
for (const f of files.value) {
try {
const r: any = await dataApi.importExcel(f, qs)
const skipped = r.skipped || 0
const total = r.total || 0
msgs.push(`📄 ${f.name}: ${total}条导入${skipped > 0 ? `, ${skipped}条跳过` : ''}`)
const r: any = await dataApi.importExcelSmart(f)
const msg = r.message || ''
msgs.push(`📄 ${f.name}: ${msg}`)
} catch (e: any) {
msgs.push(`${f.name}: ${e?.response?.data?.detail || '导入失败'}`)
}
}
files.value = []
detectedCols.value = []
result.value = msgs.join('\n')
uploading.value = false
}