Parcourir la source

产品分类页

zhangzf il y a 1 semaine
Parent
commit
f14cfe9ef7

+ 1 - 0
package.json

@@ -26,6 +26,7 @@
     "@heroui/popover": "^2.3.32",
     "@heroui/radio": "^2.3.32",
     "@heroui/select": "^2.4.29",
+    "@heroui/slider": "^2.4.29",
     "@heroui/switch": "^2.2.27",
     "@heroui/system": "^2.4.28",
     "@heroui/theme": "^2.4.26",

Fichier diff supprimé car celui-ci est trop grand
+ 129 - 6
pnpm-lock.yaml


+ 99 - 47
src/app/(public)/category/[slug]/[id]/_components/ProductListing.tsx

@@ -10,8 +10,11 @@ import NewSortOrder from "@components/theme/filters/NewSortOrder";
 import { SortByFields } from "@utils/constants";
 import { isArray } from "@/utils/type-guards";
 import { useRouter, usePathname } from "next/navigation";
+import { useMutation, useApolloClient } from "@apollo/client/react";
+import { CategoryProductsResult } from "@components/catalog/type";
+import {CATEGORY_PRODUCTS} from "@/graphql";
 interface QueryState {
-  search: string;
+  // search: string;
 
   sort: string;
 
@@ -20,6 +23,7 @@ interface QueryState {
   cursor?: string;
 }
 interface ProductListingProps {
+  slug:string;
   categoryId: string;
 
   filterAttributes: any[];
@@ -34,6 +38,7 @@ interface ProductListingProps {
 }
 
 export default function ProductListing({
+  slug,
   categoryId,
   filterAttributes,
   initialProducts,
@@ -52,38 +57,63 @@ export default function ProductListing({
 
   const [_total, setTotal] = useState(initialTotal);
   const firstRender = useRef(true);
+  const apolloClient = useApolloClient();
+
   const fetchProducts = async (currentQuery: QueryState) => {
     try {
-      const res = await fetch("/api/products", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-        },
-        body: JSON.stringify({
-          categoryId,
-
-          search: currentQuery.search,
-
-          sort: currentQuery.sort,
-
-          filters: currentQuery.filters,
-
-          first: 12,
+      const res = await apolloClient.query<CategoryProductsResult>({
+        query: CATEGORY_PRODUCTS,
+        variables: 
+        // {
+        
+        //   filter: JSON.stringify({
+        //   }),
+        //   first: 15,
+        //   after: null,
+        // }
+        {
+          slug: slug ||"ready-to-go-wig",
+        
+          // sort: currentQuery.sort,
+
+          filter:JSON.stringify( currentQuery.filters),
+
+          first: 15,
 
           after: null,
-        }),
+        },
       });
+      console.log("res----------------------------------:",res);
+      
+      // const res = await fetch("/api/products", {
+      //   method: "POST",
+      //   headers: {
+      //     "Content-Type": "application/json",
+      //   },
+      //   body: JSON.stringify({
+      //     categoryId,
+
+      //     sort: currentQuery.sort,
+
+      //     filters: currentQuery.filters,
+
+      //     first: 12,
+
+      //     after: null,
+      //   }),
+      // });
+      //  search: currentQuery.search,
       console.log("筛选排序触发调接口");
-      const result = await res.json();
+      // const result = await res.json();
 
       const newProducts =
-        result.data?.products?.edges?.map((e: any) => e.node) || [];
+        res.data?.categoryProducts?.products.map((e: any) => e.node) || [];
 
       setProducts(newProducts);
 
-      setPageInfo(result.data?.products?.pageInfo);
+      setPageInfo(res.data?.categoryProducts?.pageInfo);
 
-      setTotal(result.data?.products?.totalCount || 0);
+      setTotal(res.data?.categoryProducts?.totalCount || 0);
     } catch (error) {
       console.error("fetch products error", error);
     }
@@ -95,9 +125,11 @@ export default function ProductListing({
 
       return;
     }
+    console.log("query-----------------------------22222:",query);
     fetchProducts(query);
-  }, [query.filters, query.sort, query.search]);
-
+    
+  }, [query.filters, query.sort]);
+  //query.search
   const router = useRouter();
   const pathname = usePathname();
   console.log("query-----:", query);
@@ -126,36 +158,56 @@ export default function ProductListing({
   };
   //   view more 逻辑
   const loadMore = async () => {
-    const res = await fetch("/api/products", {
-      method: "POST",
-
-      headers: {
-        "Content-Type": "application/json",
-      },
-
-      body: JSON.stringify({
-        categoryId,
-
-        search: query.search,
-
-        sort: query.sort,
-
-        filters: query.filters,
-
-        first: 15,
-
-        after: pageInfo.endCursor,
-      }),
-    });
+    // const res = await fetch("/api/products", {
+    //   method: "POST",
+
+    //   headers: {
+    //     "Content-Type": "application/json",
+    //   },
+
+    //   body: JSON.stringify({
+    //     categoryId,
+    //     sort: query.sort,
+
+    //     filters: query.filters,
+
+    //     first: 15,
+
+    //     after: pageInfo.endCursor,
+    //   }),
+    // });
+     const res = await apolloClient.query<CategoryProductsResult>({
+        query: CATEGORY_PRODUCTS,
+        variables: 
+        // {
+        
+        //   filter: JSON.stringify({
+        //   }),
+        //   first: 15,
+        //   after: null,
+        // }
+        {
+          slug: slug ||"ready-to-go-wig",
+        
+          // sort: query.sort,
+
+          filter:JSON.stringify(query.filters),
+
+          first: 15,
+
+          after: pageInfo.endCursor,
+        },
+      });
+    //search: query.search,
     console.log("触发view more调接口");
-    const result = await res.json();
+    // const result = await res.json();
 
     const moreProducts =
-      result.data?.products?.edges?.map((e: any) => e.node) || [];
+       res.data?.categoryProducts?.products.map((e: any) => e.node) || [];
 
     setProducts((prev) => [...prev, ...moreProducts]);
 
-    setPageInfo(result.data.products.pageInfo);
+    setPageInfo(res.data?.categoryProducts?.pageInfo);
   };
   return (
     <>

+ 66 - 13
src/app/(public)/category/[slug]/[id]/page.tsx

@@ -4,11 +4,17 @@ import { notFound } from "next/navigation";
 // import FilterList from "@components/theme/filters/FilterList";
 // import Pagination from "@components/catalog/Pagination";
 import ProductListing from "./_components/ProductListing";
-import { ProductsResponse } from "@components/catalog/type";
+import {
+  ProductsResponse,
+  GetCategoryAttrFiltersResult,
+  CategoryProductsResult,
+} from "@components/catalog/type";
 import {
   GET_FILTER_PRODUCTS,
   // GET_TREE_CATEGORIES,
   GET_CATEHORY_BY_ID,
+  GET_CATEGORY_ATTR_FILTERS,
+  CATEGORY_PRODUCTS,
 } from "@/graphql";
 import {
   // cachedGraphQLRequest,
@@ -26,7 +32,7 @@ import FilterListSkeleton from "@components/common/skeleton/FilterSkeleton";
 import {
   // extractNumericId,
   // findCategoryBySlug,
-  buildProductFilters,
+  newBuildProductFilters,
 } from "@utils/helper";
 import { serverGraphqlFetch } from "@/utils/bagisto/index";
 import {
@@ -98,9 +104,29 @@ export default async function CategoryPage({
   const categories = treeData?.treeCategories || [];
   const categoryItem = findCategoryBySlug(categories, categorySlug);
   */
-  const filterAttributes = await getFilterAttributes(); // 这个不能用
+  //const filterAttributes = await getFilterAttributes(); // 这个不能用
+  const filterAttributesRes = await serverGraphqlFetch<
+    GetCategoryAttrFiltersResult,
+    { slug: string; first: number }
+  >({
+    query: GET_CATEGORY_ATTR_FILTERS,
+    variables: {
+      slug: _categorySlug || "Ready To Go Wig",
+      first: 50,
+    },
+  });
+
+  // 错误判断
+  if (filterAttributesRes.error) {
+    console.error("获取分类筛选属性失败", filterAttributesRes.error);
+    throw new Error(filterAttributesRes.error.message);
+  }
 
-  console.log("filterAttributes---------------:", filterAttributes);
+  console.log(
+    "filterAttributesRes---------------AAA",
+    filterAttributesRes.data?.categoryAttributeFilters,
+  );
+  console.log("filterAttributes---------------:", resolvedParams);
   const { data: categoryData } = await serverGraphqlFetch<
     CategorySingleResponse,
     { id: number }
@@ -125,20 +151,20 @@ export default async function CategoryPage({
   // } = (resolvedParams || {}) as {
   //   [key: string]: string;
   // };
-  const searchValue = getSearchParam(resolvedParams?.q) ?? "";
+  const searchValue = resolvedParams?.q ?? "";
 
   // const page = getSearchParam(resolvedParams?.page);
 
   const cursor = getSearchParam(resolvedParams?.cursor);
 
   const before = getSearchParam(resolvedParams?.before);
-  const itemsPerPage = 12;
+  const itemsPerPage = 15;
   // const currentPage = page ? parseInt(page) - 1 : 0;
   const sortValue = getSearchParam(resolvedParams?.sort) ?? "name-asc";
   const selectedSort =
     SortByFields.find((s) => s.key === sortValue) || SortByFields[0];
 
-  const { filterObject: baseFilterObject } = buildProductFilters(
+  const { filterObject: baseFilterObject } = newBuildProductFilters(
     resolvedParams || {},
   );
 
@@ -151,7 +177,31 @@ export default async function CategoryPage({
   }
 
   const filterInput = JSON.stringify(filterObject);
-
+  // 默认获取产品数据接口:
+  const { data: categoryProductDatas } = await serverGraphqlFetch<
+    CategoryProductsResult,
+    any
+  >({
+    query: CATEGORY_PRODUCTS,
+    variables: {
+      slug: _categorySlug || "Ready To Go Wig",
+      filter: filterInput,
+      id: Number(categoryId),
+      ...(before
+        ? {
+            last: itemsPerPage,
+            before,
+          }
+        : {
+            first: itemsPerPage,
+            after: cursor || null,
+          }),
+      sortKey: selectedSort.sortKey,
+      reverse: selectedSort.reverse,
+    },
+  });
+  console.log("categoryProductDatas-----------:",categoryProductDatas);
+  
   // 这个也改成 serverGraphqlFetch
   const productVariables = {
     query: searchValue || "",
@@ -196,9 +246,9 @@ export default async function CategoryPage({
     selectedSort,
   );
 
-  const products = data?.products?.edges?.map((e) => e.node) || [];
-  const pageInfo = data?.products?.pageInfo;
-  const totalCount = data?.products?.totalCount || 0;
+  const products = categoryProductDatas?.categoryProducts?.products?.map((e) => e.node) || [];
+  const pageInfo = categoryProductDatas?.categoryProducts?.pageInfo;
+  const totalCount = categoryProductDatas?.categoryProducts?.totalCount || 0;
   const translation = categoryItem.translation;
   console.log("initialQuery------------", {
     search: searchValue,
@@ -267,12 +317,15 @@ export default async function CategoryPage({
         </div> */}
         <ProductListing
           categoryId={numericId}
-          filterAttributes={filterAttributes}
+          slug={_categorySlug} 
+          filterAttributes={
+            filterAttributesRes.data?.categoryAttributeFilters.edges
+          }
           initialProducts={products}
           initialPageInfo={pageInfo}
           initialTotal={totalCount}
           initialQuery={{
-            search: searchValue,
+            // search: searchValue,
             sort: sortValue,
             filters: baseFilterObject,
             cursor,

+ 59 - 0
src/components/catalog/type.ts

@@ -160,7 +160,66 @@ export interface ProductsResponse {
     totalCount: number;
   };
 }
+export interface CategoryAttrFilterItem  {
+  _id: string;
+  code: string;
+  adminName: string;
+  type: string;
+  swatchType: string;
+  position: number;
+  minPrice: number | null;
+  maxPrice: number | null;
+  options: {
+    edges: Array<{
+      node: {
+        _id: string;
+        adminName: string;
+        sortOrder: number;
+        swatchValue: string | null;
+      };
+    }>;
+  };
+};
+export interface GetCategoryAttrFiltersResult  {
+  categoryAttributeFilters: {
+    edges: Array<{ node: CategoryAttrFilterItem }>;
+    pageInfo: {
+      hasNextPage: boolean;
+      endCursor: string | null;
+    };
+  };
+};
+export type CategoryProductNode = {
+  _id: number;
+  id: string;
+  sku: string;
+  name: string;
+  type: string;
+  price: string;
+  minimumPrice: string;
+  maximumPrice: string;
+  formattedPrice: string;
+  baseImageUrl: string;
+  status: string;
+  new: string;
+  featured: string;
+  urlKey: string;
+  isInWishlist: string;
+};
 
+export type CategoryProductsResult = {
+  categoryProducts: {
+    totalCount: number;
+    pageInfo:{
+      endCursor:string,
+      hasNextPage: boolean
+    }
+    products: Array<{
+      cursor: string;
+      node: CategoryProductNode;
+    }>;
+  };
+};
 export interface SearchProductsVariables {
     query: string;
     suggest?: boolean;

+ 117 - 0
src/components/theme/filters/FilterPrice.tsx

@@ -0,0 +1,117 @@
+"use client";
+import { useState, useEffect, useRef } from "react";
+import { Slider } from "@heroui/slider";
+import { useConfig } from "@/utils/hooks/useConfig";
+
+type PriceFilterNode = {
+  _id: number;
+  code: "price";
+  adminName: string;
+  minPrice: string;
+  maxPrice: string;
+};
+
+type PriceSelectedRange = {
+  minPrice?: number;
+  maxPrice?: number;
+};
+// 选中价格区间类型
+type PriceRange = {
+  minPrice: number;
+  maxPrice: number;
+};
+type Props = {
+  priceAttr: PriceFilterNode;
+  initialRange?: PriceSelectedRange;
+  onChangeComplete: (range: PriceRange) => void;
+};
+
+export default function PriceRangeSlider({
+  priceAttr,
+  initialRange,
+  onChangeComplete,
+}: Props) {
+  const apiMin = Number(priceAttr.minPrice);
+  const apiMax = Number(priceAttr.maxPrice);
+  // 保存【基准初始值快照】用来拖拽结束对比
+  const baselineRangeRef = useRef<PriceRange>({
+    minPrice: apiMin,
+    maxPrice: apiMax,
+  });
+  const [sliderValue, setSliderValue] = useState<number[]>([apiMin, apiMax]);
+
+  useEffect(() => {
+    let startMin = apiMin;
+    let startMax = apiMax;
+
+    if (initialRange?.minPrice !== undefined) {
+      startMin = Math.max(initialRange.minPrice, apiMin);
+    }
+    if (initialRange?.maxPrice !== undefined) {
+      startMax = Math.min(initialRange.maxPrice, apiMax);
+    }
+    // 强制约束:左滑块不能大于右滑块
+    if (startMin > startMax) startMin = startMax;
+    const newInitial: PriceRange = {
+      minPrice: startMin,
+      maxPrice: startMax,
+    };
+    setSliderValue([startMin, startMax]);
+    // 更新基准快照
+    baselineRangeRef.current = newInitial;
+  }, [initialRange, apiMin, apiMax]);
+
+  const handleChange = (val: number | number[]) => {
+    if (!Array.isArray(val)) return;
+    setSliderValue(val);
+  };
+
+  const handleDragEnd = (finalVal: number | number[]) => {
+    if (!Array.isArray(finalVal)) return;
+    const newRange: PriceRange = {
+      minPrice: finalVal[0],
+      maxPrice: finalVal[1],
+    };
+    if (
+      newRange.minPrice === baselineRangeRef.current.minPrice &&
+      newRange.maxPrice === baselineRangeRef.current.maxPrice
+    ) {
+      return;
+    }
+    onChangeComplete(newRange);
+  };
+  // 货币符号
+  const { getCurrentCurrencyItem } = useConfig();
+  const currentCurrency = getCurrentCurrencyItem();
+  const currencySymbol = currentCurrency.symbol;
+  const currencyCode = currentCurrency.code;
+  return (
+    <div className="py-5">
+      <p className="text-base mb-4">
+        Range: {currencySymbol}
+        {sliderValue[0].toFixed(2)}-${sliderValue[1].toFixed(2)}
+      </p>
+      <Slider
+        minValue={apiMin}
+        maxValue={apiMax}
+        size="sm"
+        classNames={{
+          base: "max-w-md gap-3",
+          filler: "bg-black",
+          thumb:
+            "w-5 h-5 bg-white border-3 border-black rounded-full shadow-lg",
+        }}
+        step={1}
+        value={sliderValue}
+        onChange={handleChange}
+        onChangeEnd={handleDragEnd}
+        showTooltip
+        aria-label="Price range filter"
+        formatOptions={{
+          style: "currency",
+          currency: currencyCode,
+        }}
+      />
+    </div>
+  );
+}

+ 48 - 24
src/components/theme/filters/NewMobileFilter.tsx

@@ -10,12 +10,11 @@ import {
 import { Button } from "@heroui/button";
 
 import { useDisclosure } from "@heroui/use-disclosure";
-
+import FilterPrice from "./FilterPrice";
 import {
   AdjustmentsHorizontalIcon,
   XMarkIcon,
 } from "@heroicons/react/24/outline";
-
 import { useEffect, useState, useRef } from "react";
 
 interface MobileFilterProps {
@@ -25,7 +24,6 @@ interface MobileFilterProps {
 
   onChange: (filters: Record<string, string>) => void;
 }
-
 export default function MobileFilter({
   filterAttributes,
   filters,
@@ -55,6 +53,7 @@ export default function MobileFilter({
       queueMicrotask(() => {
         // 浅拷贝,断开引用
         setTempFilters({ ...filters });
+        console.log("tempFilters---------------------", tempFilters);
 
         const expand: Record<string, boolean> = {};
         filterAttributes.forEach((attr) => {
@@ -67,7 +66,7 @@ export default function MobileFilter({
 
     // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [isOpen]);
-    useEffect(() => {
+  useEffect(() => {
     console.log("【父组件传入筛选filters】", filters);
     console.log("【抽屉是否打开isOpen】", isOpen);
   }, [isOpen, filters]);
@@ -125,7 +124,6 @@ export default function MobileFilter({
   const formatLabel = (str?: string) => {
     return str ? str.charAt(0).toUpperCase() + str.slice(1).toLowerCase() : "";
   };
-
   return (
     <>
       <div className="flex flex-wrap gap-3">
@@ -158,36 +156,62 @@ export default function MobileFilter({
 
             <DrawerBody className="px-6 overflow-y-auto">
               {filterAttributes.map((attr) => {
-                const expand = expandedGroups[attr.code] ?? true;
+                const expand = expandedGroups[attr.node.code] ?? true;
 
                 return (
-                  <div key={attr.id} className="py-4 border-b">
+                  <div key={attr.node._id} className="py-4 border-b">
                     <button
                       className="flex justify-between w-full text-xl"
-                      onClick={() => toggleGroupExpand(attr.code)}
+                      onClick={() => toggleGroupExpand(attr.node.code)}
                     >
-                      <span>{formatLabel(attr.adminName)}</span>
+                      <span>{formatLabel(attr.node.adminName)}</span>
 
                       <span>⌄</span>
                     </button>
 
                     {expand && (
                       <div className="flex flex-col gap-3 mt-4">
-                        {(attr.options || []).map((opt: any) => {
-                          const checked = tempFilters[attr.code] === opt.id;
-
-                          return (
-                            <label key={opt.id} className="flex gap-3 text-lg">
-                              <input
-                                type="checkbox"
-                                checked={checked}
-                                onChange={() => toggleOption(attr.code, opt.id)}
-                              />
-
-                              <span>{opt.adminName}</span>
-                            </label>
-                          );
-                        })}
+                        {attr.node.code === "price" ? (
+                          <FilterPrice
+                            priceAttr={attr.node}
+                            initialRange={{
+                              minPrice: tempFilters.minPrice
+                                ? Number(tempFilters.minPrice)
+                                : undefined,
+                              maxPrice: tempFilters.maxPrice
+                                ? Number(tempFilters.maxPrice)
+                                : undefined,
+                            }}
+                            onChangeComplete={(range) => {
+                              setTempFilters((prev) => ({
+                                ...prev,
+                                minPrice: String(range.minPrice),
+                                maxPrice: String(range.maxPrice),
+                              }));
+                            }}
+                          />
+                        ) : (
+                          (attr.node.options.edges || []).map((opt: any) => {
+                            const checked =
+                              tempFilters[attr.node.code] == opt.node._id;
+                            return (
+                              <label
+                                key={opt.node._id}
+                                className="flex gap-3 text-lg"
+                              >
+                                <input
+                                  type="checkbox"
+                                  checked={checked}
+                                  onChange={() =>
+                                    toggleOption(attr.node.code, opt.node._id)
+                                  }
+                                />
+
+                                <span>{opt.node.adminName}</span>
+                              </label>
+                            );
+                          })
+                        )}
                       </div>
                     )}
                   </div>

+ 86 - 0
src/graphql/catalog/queries/CategoryAttributeFilters.ts

@@ -0,0 +1,86 @@
+import { gql } from "@apollo/client";
+
+/**
+ * 获取分类筛选属性
+ * @param categorySlug - 分类slug标识
+ * @param first - 返回条数
+ */
+export const GET_CATEGORY_ATTR_FILTERS = gql`
+  query getCategoryAttributeFilters($slug: String, $first: Int) {
+    categoryAttributeFilters(slug: $slug, first: $first) {
+      edges {
+        node {
+          _id
+          code
+          adminName
+          type
+          swatchType
+          position
+          minPrice
+          maxPrice
+          options {
+            edges {
+              node {
+                _id
+                adminName
+                sortOrder
+                swatchValue
+              }
+            }
+          }
+        }
+      }
+      pageInfo {
+        hasNextPage
+        endCursor
+      }
+    }
+  }
+`;
+
+// ---------------------- TS类型定义 ----------------------
+export type FilterOptionNode = {
+  _id: string;
+  adminName: string;
+  sortOrder: number;
+  swatchValue: string | null;
+};
+
+export type FilterOptionEdge = {
+  node: FilterOptionNode;
+};
+
+export type CategoryAttrFilterNode = {
+  _id: string;
+  code: string;
+  adminName: string;
+  type: string;
+  swatchType: string;
+  position: number;
+  minPrice: number | null;
+  maxPrice: number | null;
+  options: {
+    edges: FilterOptionEdge[];
+  };
+};
+
+export type CategoryAttrFilterEdge = {
+  node: CategoryAttrFilterNode;
+};
+
+export type FilterPageInfo = {
+  hasNextPage: boolean;
+  endCursor: string | null;
+};
+
+export type GetCategoryAttrFiltersData = {
+  categoryAttributeFilters: {
+    edges: CategoryAttrFilterEdge[];
+    pageInfo: FilterPageInfo;
+  };
+};
+
+export type GetCategoryAttrFiltersVars = {
+  categorySlug: string;
+  first?: number;
+};

+ 33 - 0
src/graphql/catalog/queries/GetCategoryProducts.ts

@@ -0,0 +1,33 @@
+import { gql } from "@apollo/client";
+// graphql 查询文本
+export const CATEGORY_PRODUCTS = gql`
+query CategoryProducts($slug: String!, $filter: String, $first: Int, $after: String) {
+  categoryProducts(slug: $slug, filter: $filter, first: $first, after: $after) {
+    totalCount
+    pageInfo {
+     endCursor     
+     hasNextPage   
+    }
+    products {
+      cursor
+      node {
+        _id
+        id
+        sku
+        name
+        type
+        price
+        minimumPrice
+        maximumPrice
+        formattedPrice
+        baseImageUrl
+        status
+        new
+        featured
+        urlKey
+        isInWishlist
+      }
+    }
+  }
+}
+`;

+ 2 - 0
src/graphql/catalog/queries/index.ts

@@ -11,3 +11,5 @@ export { GET_TREE_CATEGORIES } from "./GetTreeCategories";
 export { SEARCH_PRODUCTS } from "./SearchProducts";
 export { GET_CATEHORY_BY_ID } from "./GetCategoryById";
 export { GET_PRODUCT_BY_ID } from "./GetProductById";
+export { GET_CATEGORY_ATTR_FILTERS } from "./CategoryAttributeFilters";
+export { CATEGORY_PRODUCTS } from "./GetCategoryProducts";

+ 55 - 0
src/utils/helper.ts

@@ -469,6 +469,61 @@ export function buildProductFilters(params: {
     isFilterApplied,
   };
 }
+export function newBuildProductFilters(params: {
+  [key: string]: string | string[] | undefined;
+}) {
+  // ==========黑名单:这些字段不会筛选==========
+  const EXCLUDE_KEYS = new Set([
+    "q",
+    "sort",
+    "cursor",
+    "before",
+    "after",
+    "page",
+  ]);
+
+  const extractId = (value: string) => {
+    if (/^\d+$/.test(value)) return value;
+    const match = value.match(/\/(\d+)$/);
+    return match ? match[1] : null;
+  };
+
+  const parseParamIds = (raw: string | string[] | undefined) => {
+    let list: string[] = [];
+    if (typeof raw === "string") {
+      list = raw.split(",");
+    } else if (Array.isArray(raw)) {
+      list = raw;
+    }
+    return list
+      .map(extractId)
+      .filter((id): id is string => Boolean(id));
+  };
+
+  const filterObject: Record<string, string> = {};
+
+  // 遍历全部传入参数
+  for (const [key, value] of Object.entries(params)) {
+    // 黑名单字段直接跳过
+    if (EXCLUDE_KEYS.has(key)) continue;
+
+    const ids = parseParamIds(value);
+    if (ids.length > 0) {
+      filterObject[key] = ids.join(",");
+    }
+  }
+
+  const isFilterApplied = Object.keys(filterObject).length > 0;
+  const filterInput = isFilterApplied
+    ? JSON.stringify(filterObject)
+    : undefined;
+
+  return {
+    filterObject,
+    filterInput,
+    isFilterApplied,
+  };
+}
 
 export function getAverageRating(reviews: ProductReview[]): number {
   if (!reviews.length) return 0;