feat(crm): align product export with ListExportDropdown

Add ids/columns support and unify toolbar export UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-21 17:06:01 +08:00
1 parent 09827fdbf3
commit 5c7210facc
5 files changed
+77 -34

No files matched your search

+23 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
() => {