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:
parent
46d56cb6d3
commit
2aeb9b5cfa
@ -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}
|
||||
)
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user