Просмотр исходного кода

feat(crm): align product export with ListExportDropdown

Add ids/columns support and unify toolbar export UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 месяцев назад
Родитель
Сommit
5c7210facc

+ 23 - 3
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:

+ 7 - 1
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())

+ 2 - 0
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):

+ 20 - 0
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"),

+ 25 - 30
web-admin/src/views/crm/product/index.vue

@@ -101,10 +101,14 @@
             <el-icon><Upload /></el-icon>
             导入
           </el-button>
-          <el-button class="btn-ghost" @click="handleExport">
-            <el-icon><Download /></el-icon>
-            导出
-          </el-button>
+          <ListExportDropdown
+            :export-fn="fetchExportProducts"
+            :get-filter-params="getProductExportFilterParams"
+            :has-selection="true"
+            :selected-ids="selectedRows.map((r) => r.id)"
+            :visible-columns="productExportColumns"
+            filename="产品列表.xlsx"
+          />
           <el-button class="btn-ghost" @click="categoryMaintainVisible = true">
             <el-icon><Folder /></el-icon>
             产品分类
@@ -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<any[]>([])
   const pagination = reactive({ page: 1, pageSize: 20, total: 0 })
 
@@ -781,32 +802,6 @@
     }
   }
 
-  async function handleExport() {
-    try {
-      const params: Record<string, any> = {}
-      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,
     () => {