feat: 客户导入支持 Excel 模板下载和文件上传

新增 GET /api/customers/import-template 下载带表头和示例行的 xlsx 模板;
POST /api/customers/import 从文件路径输入改为 UploadFile 上传;
前端导入弹窗增加文件选择器和下载模板按钮,移除文本框手动填路径方式。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
taiyi 2026-05-30 11:26:28 +08:00
parent 46d56cb6d3
commit 2aeb9b5cfa
2 changed files with 127 additions and 20 deletions

View File

@ -5,7 +5,12 @@
处理客户信息的增删改查和批量导入接口URL 前缀为 /api/customers
包括客户列表查询创建客户查看客户详情更新客户批量导入客户
"""
from fastapi import APIRouter, Depends, Query
import os
import tempfile
from io import BytesIO
from fastapi import APIRouter, Depends, File, Query, UploadFile
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from backend.app.api.deps import require_roles
@ -13,7 +18,7 @@ from backend.app.core.error_codes import ErrorCode
from backend.app.core.exceptions import AppException
from backend.app.db import get_db_session
from backend.app.schemas.common import success_payload
from backend.app.schemas.customers import CreateCustomerRequest, ImportCustomerRequest, UpdateCustomerRequest
from backend.app.schemas.customers import CreateCustomerRequest, UpdateCustomerRequest
from backend.app.services.customer_service import customer_service
router = APIRouter(prefix="/api/customers", tags=["customers"])
@ -119,17 +124,75 @@ def update_customer(
return success_payload(result)
@router.get("/import-template")
def download_import_template(
_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
) -> StreamingResponse:
"""下载客户导入模板
用途生成包含表头和示例行的 Excel 模板供用户下载填写
返回值xlsx 文件流浏览器自动触发下载
权限要求经理manager管理员admin
"""
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "客户导入模板"
headers = ["客户姓名", "手机号", "客户地址", "客户类型", "结算方式", "账期天数", "业务员ID", "信用额度", "备注"]
ws.append(headers)
ws.append(["张三", "13800000000", "广州市天河区xxx", "渠道客户", "月结", 30, "", 0, "示例数据,请删除后填写"])
for cell in ws[1]:
cell.font = cell.font.copy(bold=True)
ws.column_dimensions["A"].width = 14
ws.column_dimensions["B"].width = 16
ws.column_dimensions["C"].width = 28
ws.column_dimensions["D"].width = 14
ws.column_dimensions["E"].width = 14
ws.column_dimensions["F"].width = 12
ws.column_dimensions["G"].width = 12
ws.column_dimensions["H"].width = 12
ws.column_dimensions["I"].width = 30
buf = BytesIO()
wb.save(buf)
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=customer_import_template.xlsx"},
)
@router.post("/import")
def import_customers(
payload: ImportCustomerRequest, # 批量导入请求体
file: UploadFile = File(...), # 上传的 Excel/CSV 文件
import_mode: str = Query(default="skip_duplicate"), # 导入模式skip_duplicate/cover_duplicate
session: Session = Depends(get_db_session), # 注入数据库会话
current_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
_user: dict = Depends(require_roles("manager", "admin")), # 角色鉴权:仅经理/管理员
) -> dict:
"""批量导入客户
"""批量导入客户(文件上传)
用途通过文件批量导入客户数据 Excel
请求参数ImportCustomerRequest包含导入文件内容或解析后的数据
用途通过上传 Excel/CSV 文件批量导入客户数据
请求参数file - 上传的文件import_mode - 导入模式Query 参数
返回值导入结果成功数量失败详情等
权限要求经理manager管理员admin
"""
return success_payload(customer_service.import_customers_from_file(session, payload.model_dump()))
if import_mode not in {"skip_duplicate", "cover_duplicate"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="导入模式不正确", status_code=400)
suffix = ""
if file.filename:
suffix = os.path.splitext(file.filename)[1].lower()
if suffix not in {".csv", ".xlsx", ".xls"}:
raise AppException(code=ErrorCode.PARAM_ERROR, message="仅支持 csv、xlsx、xls 文件", status_code=400)
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(file.file.read())
tmp_path = tmp.name
return success_payload(
customer_service.import_customers_from_file(
session, {"file_url": tmp_path, "import_mode": import_mode}
)
)

View File

@ -135,11 +135,14 @@
<div v-if="showImportForm" class="modal-mask" @click.self="cancelImport">
<section class="modal-panel">
<div class="modal-header">
<div><h4>客户导入</h4><span>调用真实 `/api/customers/import`</span></div>
<div><h4>客户导入</h4><span>上传 Excel CSV 文件</span></div>
<button class="modal-close" type="button" @click="cancelImport">×</button>
</div>
<div class="form-grid">
<label class="full-width"><span>文件路径</span><input v-model.trim="importForm.file_url" type="text" placeholder="请输入本地文件路径或 OSS 公网 URL" /></label>
<label class="full-width">
<span>选择文件</span>
<input type="file" accept=".csv,.xlsx,.xls" @change="onImportFileChange" />
</label>
<label><span>导入模式</span>
<select v-model="importForm.import_mode">
<option value="skip_duplicate">跳过重复</option>
@ -148,8 +151,11 @@
</label>
</div>
<div class="action-row split">
<button class="ghost-btn" @click="handleDownloadTemplate">下载模板</button>
<div>
<button class="ghost-btn" @click="cancelImport">取消</button>
<button class="primary-btn" :disabled="importing" @click="handleImport">{{ importing ? "导入中..." : "开始导入" }}</button>
<button class="primary-btn" :disabled="importing" @click="handleImport" style="margin-left:8px">{{ importing ? "导入中..." : "开始导入" }}</button>
</div>
</div>
</section>
</div>
@ -197,15 +203,16 @@
*/
import { computed, onMounted, reactive, ref } from 'vue';
import { apiBaseUrl } from '../config';
import {
createCustomer,
fetchCustomerDetail,
fetchCustomerList,
importCustomers,
} from '../mockApi';
import { useToast } from '../composables/useToast';
const toast = useToast();
const ADMIN_TOKEN_KEY = 'admin_token';
/** 列表加载中标识 */
const loading = ref(true);
/** 客户列表数据 */
@ -233,7 +240,9 @@ const createForm = reactive({
settlement_days: 30, customer_type: 'channel', salesman_id: 1, credit_limit: 0, remark: '',
});
/** 导入客户表单数据(文件地址和导入模式) */
const importForm = reactive({ file_url: '', import_mode: 'skip_duplicate' });
const importForm = reactive({ import_mode: 'skip_duplicate' });
/** 导入文件对象 */
const importFile = ref(null);
/** 启用状态客户数量 */
const enabledCount = computed(() => rows.value.filter((r) => r.status === '启用').length);
@ -249,7 +258,7 @@ function formatAmount(v) { return Number(v || 0).toFixed(2); }
/** 重置新增客户表单 */
function resetCreate() { Object.assign(createForm, { customer_name: '', mobile: '', address: '', settlement_type: 'monthly', settlement_days: 30, customer_type: 'channel', salesman_id: 1, credit_limit: 0, remark: '' }); }
/** 重置导入客户表单 */
function resetImport() { Object.assign(importForm, { file_url: '', import_mode: 'skip_duplicate' }); }
function resetImport() { Object.assign(importForm, { import_mode: 'skip_duplicate' }); importFile.value = null; }
/** 打开新增客户弹窗 */
function openCreateModal() { showImportForm.value = false; showCreateForm.value = true; resetCreate(); }
@ -327,16 +336,51 @@ async function handleCreate() {
}
/**
* 提交客户导入校验文件地址后调用导入接口
* 文件选择事件处理
* @param {Event} e - input change 事件
*/
function onImportFileChange(e) {
importFile.value = e.target.files[0] || null;
}
/**
* 下载客户导入模板
*/
function handleDownloadTemplate() {
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
const url = `${apiBaseUrl}/api/customers/import-template`;
const a = document.createElement('a');
a.href = url;
a.download = 'customer_import_template.xlsx';
// token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => { a.href = URL.createObjectURL(blob); a.click(); URL.revokeObjectURL(a.href); })
.catch(() => toast.error('模板下载失败'));
}
/**
* 提交客户导入通过 FormData 上传文件
*/
async function handleImport() {
if (!importForm.file_url.trim()) { toast.error('请输入导入文件地址'); return; }
if (!importFile.value) { toast.error('请选择要导入的文件'); return; }
importing.value = true;
try {
const result = await importCustomers({ ...importForm });
const token = localStorage.getItem(ADMIN_TOKEN_KEY) || '';
const fd = new FormData();
fd.append('file', importFile.value);
const res = await fetch(`${apiBaseUrl}/api/customers/import?import_mode=${importForm.import_mode}`, {
method: 'POST',
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
body: fd,
});
const result = await res.json().catch(() => ({}));
if (!res.ok || result.code !== 0) throw new Error(result.message || '导入失败');
const data = result.data;
showImportForm.value = false;
resetImport();
toast.success(`客户导入完成:总计 ${result.total_count} 条,成功 ${result.success_count} 条,重复 ${result.duplicate_count} 条,失败 ${result.fail_count} 条。`);
await handleSearch();
toast.success(`客户导入完成:总计 ${data.total_count} 条,成功 ${data.success_count} 条,重复 ${data.duplicate_count} 条,失败 ${data.fail_count} 条。`);
} catch (error) {
toast.error(error.message || '导入客户失败');
} finally {