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 }}
+
+
+
+ 📐 列映射设置(如Excel列名与标准不同,请在此映射)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
导入 {{ 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
}