feat: Excel导入支持自定义列映射+智能列名检测
- 上传后自动读取Excel列名,下拉框选择映射 - 智能匹配: 科目/编码→kpi_code, 期间→period, 金额→actual_value - 支持统一期间(文件无期间列时) - 后端接受 kpi_col/period_col/value_col/default_period 参数
This commit is contained in:
+18
-8
@@ -15,14 +15,24 @@ router = APIRouter(prefix="/api/cma/data", tags=["数据对接"],
|
|||||||
)
|
)
|
||||||
|
|
||||||
@router.post("/import-excel")
|
@router.post("/import-excel")
|
||||||
async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_db)):
|
async def import_excel(file: UploadFile = File(...),
|
||||||
|
kpi_col: str = Query("kpi_code", description="Excel中KPI编码列名"),
|
||||||
|
period_col: str = Query("period", description="Excel中期间列名"),
|
||||||
|
value_col: str = Query("actual_value", description="Excel中实际值列名"),
|
||||||
|
default_period: str = Query(None, description="如文件无期间列,统一使用此值"),
|
||||||
|
db: Session = Depends(get_db)):
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
df = pd.read_excel(io.BytesIO(content))
|
df = pd.read_excel(io.BytesIO(content))
|
||||||
|
|
||||||
required = ["kpi_code", "period", "actual_value"]
|
required = [kpi_col, value_col]
|
||||||
if not all(c in df.columns for c in required):
|
if not default_period:
|
||||||
raise HTTPException(400, f"Excel必须包含列: {required}")
|
required.append(period_col)
|
||||||
|
|
||||||
|
missing = [c for c in required if c not in df.columns]
|
||||||
|
if missing:
|
||||||
|
raise HTTPException(400,
|
||||||
|
f"Excel缺少列: {missing}。当前文件列: {list(df.columns)}")
|
||||||
|
|
||||||
if len(df) == 0:
|
if len(df) == 0:
|
||||||
raise HTTPException(400, "Excel文件为空,没有数据行")
|
raise HTTPException(400, "Excel文件为空,没有数据行")
|
||||||
|
|
||||||
@@ -33,9 +43,9 @@ async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_d
|
|||||||
count = 0
|
count = 0
|
||||||
skipped = []
|
skipped = []
|
||||||
for idx, row in df.iterrows():
|
for idx, row in df.iterrows():
|
||||||
kpi_code = str(row.get("kpi_code", "")).strip()
|
kpi_code = str(row.get(kpi_col, "")).strip()
|
||||||
period = str(row.get("period", "")).strip()
|
period = str(row.get(period_col, default_period or "")).strip() if period_col in df.columns else (default_period or "").strip()
|
||||||
value = row.get("actual_value")
|
value = row.get(value_col)
|
||||||
|
|
||||||
if not kpi_code or not period or pd.isna(value):
|
if not kpi_code or not period or pd.isna(value):
|
||||||
skipped.append(f"第{idx+2}行: 缺少必填字段")
|
skipped.append(f"第{idx+2}行: 缺少必填字段")
|
||||||
|
|||||||
@@ -62,10 +62,10 @@ export const templateApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const dataApi = {
|
export const dataApi = {
|
||||||
importExcel: (file: File) => {
|
importExcel: (file: File, qs?: string) => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('file', file)
|
form.append('file', file)
|
||||||
return api.post('/data/import-excel', form)
|
return api.post(`/data/import-excel${qs ? '?' + qs : ''}`, form)
|
||||||
},
|
},
|
||||||
listSources: () => api.get('/data/sources'),
|
listSources: () => api.get('/data/sources'),
|
||||||
createSource: (data: any) => api.post('/data/sources', data),
|
createSource: (data: any) => api.post('/data/sources', data),
|
||||||
|
|||||||
@@ -14,6 +14,41 @@
|
|||||||
<div v-if="files.length > 0" style="margin-top:8px;">
|
<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>
|
<el-tag v-for="(f, i) in files" :key="i" closable @close="removeFile(i)" style="margin:2px;">{{ f.name }}</el-tag>
|
||||||
</div>
|
</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>
|
<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>
|
<div v-if="result" style="margin-top:12px;"><el-alert :title="result" type="success" show-icon /></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -100,11 +135,14 @@ const activeTab = ref('import')
|
|||||||
const files = ref<any[]>([])
|
const files = ref<any[]>([])
|
||||||
const uploading = ref(false)
|
const uploading = ref(false)
|
||||||
const result = ref('')
|
const result = ref('')
|
||||||
|
const detectedCols = ref<string[]>([])
|
||||||
|
const colMap = ref({ kpi: 'kpi_code', period: 'period', value: 'actual_value', defaultPeriod: '' })
|
||||||
|
|
||||||
function handleFile(f: any) {
|
function handleFile(f: any) {
|
||||||
// el-upload multiple 模式下每次 onChange 传单个文件
|
|
||||||
if (f.raw && !files.value.some(ex => ex.name === f.raw.name && ex.size === f.raw.size)) {
|
if (f.raw && !files.value.some(ex => ex.name === f.raw.name && ex.size === f.raw.size)) {
|
||||||
files.value.push(f.raw)
|
files.value.push(f.raw)
|
||||||
|
// 从第一个文件检测列名
|
||||||
|
if (files.value.length === 1) detectColumns(f.raw)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function handleRemove(f: any) {
|
function handleRemove(f: any) {
|
||||||
@@ -113,14 +151,42 @@ function handleRemove(f: any) {
|
|||||||
function removeFile(i: number) {
|
function removeFile(i: number) {
|
||||||
files.value.splice(i, 1)
|
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() {
|
async function doImport() {
|
||||||
if (files.value.length === 0) return
|
if (files.value.length === 0) return
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
result.value = ''
|
result.value = ''
|
||||||
const msgs: string[] = []
|
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) {
|
for (const f of files.value) {
|
||||||
try {
|
try {
|
||||||
const r: any = await dataApi.importExcel(f)
|
const r: any = await dataApi.importExcel(f, qs)
|
||||||
const skipped = r.skipped || 0
|
const skipped = r.skipped || 0
|
||||||
const total = r.total || 0
|
const total = r.total || 0
|
||||||
msgs.push(`📄 ${f.name}: ${total}条导入${skipped > 0 ? `, ${skipped}条跳过` : ''}`)
|
msgs.push(`📄 ${f.name}: ${total}条导入${skipped > 0 ? `, ${skipped}条跳过` : ''}`)
|
||||||
@@ -129,6 +195,7 @@ async function doImport() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
files.value = []
|
files.value = []
|
||||||
|
detectedCols.value = []
|
||||||
result.value = msgs.join('\n')
|
result.value = msgs.join('\n')
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user