diff --git a/backend/app/api/data.py b/backend/app/api/data.py index 74211df1..85b22abe 100644 --- a/backend/app/api/data.py +++ b/backend/app/api/data.py @@ -15,14 +15,24 @@ router = APIRouter(prefix="/api/cma/data", tags=["数据对接"], ) @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() df = pd.read_excel(io.BytesIO(content)) - required = ["kpi_code", "period", "actual_value"] - if not all(c in df.columns for c in required): - raise HTTPException(400, f"Excel必须包含列: {required}") - + required = [kpi_col, value_col] + if not default_period: + 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: raise HTTPException(400, "Excel文件为空,没有数据行") @@ -33,9 +43,9 @@ async def import_excel(file: UploadFile = File(...), db: Session = Depends(get_d count = 0 skipped = [] for idx, row in df.iterrows(): - kpi_code = str(row.get("kpi_code", "")).strip() - period = str(row.get("period", "")).strip() - value = row.get("actual_value") + kpi_code = str(row.get(kpi_col, "")).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(value_col) if not kpi_code or not period or pd.isna(value): skipped.append(f"第{idx+2}行: 缺少必填字段") diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 09699ba6..b830d5de 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -62,10 +62,10 @@ export const templateApi = { } export const dataApi = { - importExcel: (file: File) => { + importExcel: (file: File, qs?: string) => { const form = new FormData() 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'), createSource: (data: any) => api.post('/data/sources', data), diff --git a/frontend/src/views/DataManage.vue b/frontend/src/views/DataManage.vue index 5e194908..bad10f3f 100644 --- a/frontend/src/views/DataManage.vue +++ b/frontend/src/views/DataManage.vue @@ -14,6 +14,41 @@
{{ f.name }}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 导入 {{ files.length }} 个文件
@@ -100,11 +135,14 @@ const activeTab = ref('import') const files = ref([]) const uploading = ref(false) const result = ref('') +const detectedCols = ref([]) +const colMap = ref({ kpi: 'kpi_code', period: 'period', value: 'actual_value', defaultPeriod: '' }) 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)) { files.value.push(f.raw) + // 从第一个文件检测列名 + if (files.value.length === 1) detectColumns(f.raw) } } function handleRemove(f: any) { @@ -113,14 +151,42 @@ 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 = { + 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) + 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}条跳过` : ''}`) @@ -129,6 +195,7 @@ async function doImport() { } } files.value = [] + detectedCols.value = [] result.value = msgs.join('\n') uploading.value = false }