diff --git a/backend/app/api/crm.py b/backend/app/api/crm.py index cd54ab29b..a4cc92b76 100644 --- a/backend/app/api/crm.py +++ b/backend/app/api/crm.py @@ -31,7 +31,12 @@ from app.utils.crm_export_columns import ( PRODUCT_EXPORT_COLUMNS, SUPPLIER_INFO_EXPORT_COLUMNS, ) -from app.utils.excel_export import export_rows_to_xlsx_response +from app.utils.excel_export import ( + EXPORT_MAX_ROWS, + ExportValidationError, + export_rows_to_xlsx_response, + filter_export_columns, +) from app.crud.crm.product import ( _category_row_to_dict, create_category, @@ -871,6 +876,12 @@ def product_import( return error_response(500, str(e)) +def _map_product_export_column_keys(requested: List[str]) -> List[str]: + """前端列表 prop → 导出列 field(如 cost → costPrice)。""" + alias = {"cost": "costPrice"} + return [alias.get(str(key), str(key)) for key in requested] + + @router.post("/products/export") def product_export( body: ProductListBody, @@ -885,23 +896,32 @@ def product_export( db, tenant_no, page=1, - page_size=10000, + page_size=EXPORT_MAX_ROWS, + max_page_size=EXPORT_MAX_ROWS, keyword=body.keyword, category_id=body.category_id, status=body.status, supplier_id=body.supplier_id, + ids=body.ids, + user_info=user_info, match_product_no=True if body.match_product_no is None else bool(body.match_product_no), match_model=bool(body.match_model) if body.match_model is not None else False, ) + export_cols = filter_export_columns( + PRODUCT_EXPORT_COLUMNS, + _map_product_export_column_keys(body.columns) if body.columns else None, + ) date_str = datetime.now().strftime("%Y%m%d") return export_rows_to_xlsx_response( db, tenant_no=tenant_no, sheet_title="产品列表", - columns=PRODUCT_EXPORT_COLUMNS, + columns=export_cols, rows=rows, filename_stem=f"产品列表_{date_str}", ) + except ExportValidationError as e: + return error_response(400, str(e)) except ValueError as e: return error_response(400, str(e)) except Exception as e: diff --git a/backend/app/crud/crm/product.py b/backend/app/crud/crm/product.py index e019d7623..e965db174 100644 --- a/backend/app/crud/crm/product.py +++ b/backend/app/crud/crm/product.py @@ -570,9 +570,11 @@ def list_products( category_id: str = None, status: str = None, supplier_id: str = None, + ids: Optional[List[str]] = None, user_info=None, match_product_no: bool = True, match_model: bool = False, + max_page_size: Optional[int] = None, ) -> Tuple[List[Dict[str, Any]], int]: """分页查询产品列表 @@ -586,6 +588,9 @@ def list_products( if user_info: q = apply_crm_create_by_scope(q, CrmProduct, user_info, db, tenant_no) + if ids: + q = q.filter(CrmProduct.id.in_(ids)) + kw = (keyword or "").strip() if kw: like = f"%{kw}%" @@ -628,7 +633,8 @@ def list_products( total = q.order_by(None).with_entities(func.count(CrmProduct.id)).scalar() or 0 page = max(1, page) - page_size = min(max(1, page_size), 500) + cap = max_page_size if max_page_size is not None else 500 + page_size = min(max(1, page_size), cap) rows = ( q.order_by(CrmProduct.product_no.desc()) diff --git a/backend/app/schemas/crm/product.py b/backend/app/schemas/crm/product.py index 8a93919a8..c2f698958 100644 --- a/backend/app/schemas/crm/product.py +++ b/backend/app/schemas/crm/product.py @@ -88,6 +88,8 @@ class ProductListBody(BaseModel): alias="matchModel", description="keyword 是否匹配规格型号(选择器建议 true)", ) + ids: Optional[List[str]] = Field(default=None, description="导出时指定产品ID列表") + columns: Optional[List[str]] = Field(default=None, description="导出列(前端 visibleColumns prop)") class ProductBrandItem(BaseModel): diff --git a/backend/app/utils/crm_export_columns.py b/backend/app/utils/crm_export_columns.py index 863f34b1e..34d35f753 100644 --- a/backend/app/utils/crm_export_columns.py +++ b/backend/app/utils/crm_export_columns.py @@ -12,6 +12,24 @@ def _yes_no(value) -> str: return "否" +def _join_brand_names(value) -> str: + if not value: + return "" + if isinstance(value, list): + names = [str(x.get("brandName", "")) for x in value if isinstance(x, dict)] + return "、".join(n for n in names if n) + return str(value) + + +def _join_alias_names(value) -> str: + if not value: + return "" + if isinstance(value, list): + names = [str(x.get("aliasName", "")) for x in value if isinstance(x, dict)] + return "、".join(n for n in names if n) + return str(value) + + CLUE_EXPORT_COLUMNS = ( ExportColumn("线索姓名", "clueName"), ExportColumn("手机号", "mobile"), @@ -52,6 +70,8 @@ PRODUCT_EXPORT_COLUMNS = ( ExportColumn("产品编号", "productNo"), ExportColumn("产品名称", "productName"), ExportColumn("分类名称", "categoryName"), + ExportColumn("品牌", "brands", formatter=_join_brand_names), + ExportColumn("别名", "aliases", formatter=_join_alias_names), ExportColumn("规格型号", "model"), ExportColumn("单位", "unit"), ExportColumn("标准单价", "price"), diff --git a/web-admin/src/views/crm/product/index.vue b/web-admin/src/views/crm/product/index.vue index 80187882b..a8caccbb4 100644 --- a/web-admin/src/views/crm/product/index.vue +++ b/web-admin/src/views/crm/product/index.vue @@ -101,10 +101,14 @@ 导入 - - - 导出 - + 产品分类 @@ -421,6 +425,7 @@ allRowsDeletableByCreator, CREATOR_DELETE_TIP } from '@/utils/creator-delete' + import ListExportDropdown from '@/components/business/list-export-dropdown/index.vue' import ListToolbarEndActions from '@/components/business/list-toolbar-end-actions/index.vue' import ListQuickSearchInput from '@/components/business/list-quick-search-input/index.vue' import { usePersistedListColumns } from '@/composables/usePersistedListColumns' @@ -482,6 +487,22 @@ const { visibleColumns, checkAll, isIndeterminate, isColumnVisible, resetToDefault } = usePersistedListColumns('crm-product-list-columns-v2', listColumnDefs) + function getProductExportFilterParams() { + const quickKeyword = quickSearchKeyword.value?.trim() + return { + keyword: quickKeyword || searchForm.keyword || undefined, + categoryId: searchForm.categoryId || undefined, + status: searchForm.status || undefined + } + } + + /** 产品名称列始终展示,需并入导出 columns;cost 映射后端 costPrice */ + const productExportColumns = computed(() => { + const cols = [...visibleColumns.value] + if (!cols.includes('productName')) cols.unshift('productName') + return cols + }) + const selectedRows = ref([]) const pagination = reactive({ page: 1, pageSize: 20, total: 0 }) @@ -781,32 +802,6 @@ } } - async function handleExport() { - try { - const params: Record = {} - const quickKeyword = quickSearchKeyword.value?.trim() - if (quickKeyword) { - params.keyword = quickKeyword - } else if (searchForm.keyword) { - params.keyword = searchForm.keyword - } - if (searchForm.categoryId) params.categoryId = searchForm.categoryId - if (searchForm.status) params.status = searchForm.status - const res = await fetchExportProducts(params) - const blob = new Blob([res as any], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) - const url = URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `产品列表_${new Date().getTime()}.xlsx` - link.click() - URL.revokeObjectURL(url) - } catch { - ElMessage.error('导出失败') - } - } - watchDebounced( searchForm, () => {