筛选组件开发

This commit is contained in:
juguohong
2025-08-17 18:36:43 +08:00
parent 4f6ca73148
commit db48e55b05
17 changed files with 406 additions and 314 deletions

View File

@@ -46,7 +46,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
enable: true, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'
@@ -75,7 +75,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
config: {}
},
cssModules: {
enable: false, // 默认为 false如需使用 css modules 功能,则设为 true
enable: true, // 默认为 false如需使用 css modules 功能,则设为 true
config: {
namingPattern: 'module', // 转换模式,取值为 global/module
generateScopedName: '[name]__[local]___[hash:base64:5]'

View File

@@ -1,7 +1,7 @@
export default defineAppConfig({
pages: [
'pages/index/index',
'pages/list/index'
'pages/list/index',
'pages/index/index'
],
window: {
backgroundTextStyle: 'light',

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { BubbleOption } from './index';
import './bubbleItem.scss';
import styles from './bubbleItem.module.scss';
export interface BubbleItemProps {
option: BubbleOption;
@@ -8,6 +8,7 @@ export interface BubbleItemProps {
size: 'small' | 'medium' | 'large';
disabled: boolean;
onClick: (option: BubbleOption) => void;
itemClassName?: string;
}
const BubbleItem: React.FC<BubbleItemProps> = ({
@@ -15,19 +16,20 @@ const BubbleItem: React.FC<BubbleItemProps> = ({
isSelected,
size,
disabled,
onClick
onClick,
itemClassName
}) => {
return (
<button
className={`bubble-option ${size} ${isSelected ? 'selected' : ''} ${
option.disabled || disabled ? 'disabled' : ''
}`}
className={`${styles.bubbleOption} ${size} ${isSelected ? styles.selected : ''} ${
option.disabled || disabled ? styles.disabled : ''
} ${itemClassName ? itemClassName : ''}`}
onClick={() => onClick(option)}
disabled={option.disabled || disabled}
>
{option.icon && <span className="bubble-icon">{option.icon}</span>}
<span className="bubble-label">{option.label}</span>
{option.description && <span className="bubble-description">{option.description}</span>}
{option.icon && <span className={ styles.bubbleIcon}>{option.icon}</span>}
<span className={styles.bubbleLabel}>{option.label}</span>
{option.description && <span className={ styles.bubbleDescription}>{option.description}</span>}
</button>
);
};

View File

@@ -1,4 +1,4 @@
.bubble-option {
.bubbleOption {
position: relative;
border: 1px solid #e5e5e5;
outline: none; // 移除浏览器默认的outline
@@ -14,11 +14,11 @@
justify-content: center;
gap: 8px;
white-space: nowrap;
padding: 0;
font-size: 14px;
border-radius: 28px;
margin: 0;
height: 36px;
width: 116px;
height: 28px;
// 移除浏览器默认样式
&:focus {
@@ -38,8 +38,6 @@
// 尺寸变体
&.small {
padding: 8px 12px;
min-height: 32px;
font-size: 12px;
}
@@ -71,7 +69,7 @@
}
// 图标样式
.bubble-icon {
.bubbleIcon {
display: flex;
align-items: center;
justify-content: center;
@@ -83,12 +81,12 @@
}
// 标签样式
.bubble-label {
.bubbleLabel {
font-weight: 500;
}
// 描述样式
.bubble-description {
.bubbleDescription {
font-size: 12px;
opacity: 0.7;
font-weight: 400;

View File

@@ -1,34 +1,23 @@
.bubble-container {
.bubbleContainer {
width: 100%;
// 水平布局
.bubble-horizontal {
.bubbleHorizontal {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: space-between;
.bubble-option {
$gap: 8px;
$count: 3;
flex-basis: calc(100% / $count - $gap * 2);
gap: $gap;
}
}
// 垂直布局
.bubble-vertical {
.bubbleVertical {
display: flex;
flex-direction: column;
gap: 12px;
.bubble-option {
width: 100%;
}
}
// 网格布局
.bubble-grid {
.bubbleGrid {
display: grid;
width: 100%;
}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import './index.scss';
import BubbleItem from './BubbleItem';
import React, { useState, useEffect } from "react";
import styles from "./index.module.scss";
import BubbleItem from "./BubbleItem";
export interface BubbleOption {
id: string | number;
@@ -14,12 +14,16 @@ export interface BubbleOption {
export interface BubbleProps {
options: BubbleOption[];
value?: string | number | (string | number)[];
onChange?: (value: string | number | (string | number)[], option: BubbleOption | BubbleOption[]) => void;
onChange?: (
value: string | number | (string | number)[],
option: BubbleOption | BubbleOption[]
) => void;
multiple?: boolean;
layout?: 'horizontal' | 'vertical' | 'grid';
layout?: "horizontal" | "vertical" | "grid";
columns?: number;
size?: 'small' | 'medium' | 'large';
size?: "small" | "medium" | "large";
className?: string;
itemClassName?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
@@ -29,12 +33,13 @@ const Bubble: React.FC<BubbleProps> = ({
value,
onChange,
multiple = false,
layout = 'horizontal',
layout = "horizontal",
columns = 3,
size = 'small',
className = '',
size = "small",
className = "",
itemClassName = "",
style = {},
disabled = false
disabled = false,
}) => {
const [selectedValues, setSelectedValues] = useState<(string | number)[]>([]);
@@ -53,7 +58,7 @@ const Bubble: React.FC<BubbleProps> = ({
if (multiple) {
if (selectedValues.includes(option.value)) {
newSelectedValues = selectedValues.filter(v => v !== option.value);
newSelectedValues = selectedValues.filter((v) => v !== option.value);
} else {
newSelectedValues = [...selectedValues, option.value];
}
@@ -66,7 +71,9 @@ const Bubble: React.FC<BubbleProps> = ({
// 调用onChange回调传递选中的值和对应的选项
if (onChange) {
if (multiple) {
const selectedOptions = options.filter(opt => newSelectedValues.includes(opt.value));
const selectedOptions = options.filter((opt) =>
newSelectedValues.includes(opt.value)
);
onChange(newSelectedValues, selectedOptions);
} else {
onChange(option.value, option);
@@ -79,7 +86,7 @@ const Bubble: React.FC<BubbleProps> = ({
};
const renderHorizontalLayout = () => (
<div className="bubble-horizontal">
<div className={styles.bubbleHorizontal}>
{options.map((option) => (
<BubbleItem
key={option.id}
@@ -88,13 +95,14 @@ const Bubble: React.FC<BubbleProps> = ({
size={size}
disabled={disabled}
onClick={handleOptionClick}
itemClassName={itemClassName}
/>
))}
</div>
);
const renderVerticalLayout = () => (
<div className="bubble-vertical">
<div className={styles.bubbleVertical}>
{options.map((option) => (
<BubbleItem
key={option.id}
@@ -103,6 +111,7 @@ const Bubble: React.FC<BubbleProps> = ({
size={size}
disabled={disabled}
onClick={handleOptionClick}
itemClassName={itemClassName}
/>
))}
</div>
@@ -110,10 +119,10 @@ const Bubble: React.FC<BubbleProps> = ({
const renderGridLayout = () => (
<div
className="bubble-grid"
className={styles.bubbleGrid}
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: size === 'small' ? '8px' : size === 'large' ? '16px' : '12px'
gap: size === "small" ? "8px" : size === "large" ? "16px" : "12px",
}}
>
{options.map((option) => (
@@ -124,6 +133,7 @@ const Bubble: React.FC<BubbleProps> = ({
size={size}
disabled={disabled}
onClick={handleOptionClick}
itemClassName={itemClassName}
/>
))}
</div>
@@ -131,11 +141,11 @@ const Bubble: React.FC<BubbleProps> = ({
const renderLayout = () => {
switch (layout) {
case 'horizontal':
case "horizontal":
return renderHorizontalLayout();
case 'vertical':
case "vertical":
return renderVerticalLayout();
case 'grid':
case "grid":
return renderGridLayout();
default:
return renderHorizontalLayout();
@@ -143,7 +153,7 @@ const Bubble: React.FC<BubbleProps> = ({
};
return (
<div className={`bubble-container ${className}`} style={style}>
<div className={`${styles.bubbleContainer} ${className}`} style={style}>
{renderLayout()}
</div>
);

View File

@@ -0,0 +1,18 @@
import { useState } from "react";
import MenuComponent from "./index";
export default function Example() {
const [value, setValue] = useState("a");
const options = [
{ text: "默认排序", value: "a" },
{ text: "好评排序", value: "b" },
{ text: "销量排序", value: "c" },
];
return (
<MenuComponent
options={options}
value={value}
onChange={(val) => setValue(val)}
/>
);
}

View File

@@ -0,0 +1,59 @@
.menuWrap {
padding: 5px 20px 10px;
.menuItem {
left: 0;
border-bottom-left-radius: 30px;
border-bottom-right-radius: 30px;
}
&.active {
.nut-menu-bar {
background-color: #000000;
color: #ffffff;
}
}
:global(.nut-menu-bar) {
color: #000000;
line-height: 1;
box-shadow: unset;
min-height: 28px;
min-width: 80px;
border-radius: 28px;
border: 1px solid #e5e5e5;
line-height: 28px;
font-size: 14px;
width: max-content;
.nut-menu-title {
color: inherit !important;
font-weight: 600;
}
.nut-menu-title-text {
padding-left: 0;
}
}
.positionWrap {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 16px;
}
.title {
font-size: 14px;
font-weight: 600;
}
.cityName {
font-size: 13px;
font-weight: 400;
}
.distanceWrap {
margin-bottom: 16px;
width: 100%;
}
.distanceBubbleItem {
width: auto;
}
}

View File

@@ -0,0 +1,64 @@
import { Menu } from "@nutui/nutui-react-taro";
import styles from "./index.module.scss";
import { useState, useRef } from "react";
import Bubble, { BubbleOption } from "../Bubble";
interface IProps {
options: BubbleOption[];
value: string;
onChange: (value: string) => void;
wrapperClassName?: string;
itemClassName?: string;
}
const MenuComponent = (props: IProps) => {
const { value, onChange, wrapperClassName, itemClassName } = props;
const [isChange, setIsChange] = useState(false);
const itemRef = useRef(null);
const handleChange = (value: string) => {
console.log("===value", value);
setIsChange(true);
onChange && onChange(value);
};
const options: BubbleOption[] = [
{ id: 0, label: "全城", value: "0" },
{ id: 1, label: "3km", value: "3" },
{ id: 2, label: "5km", value: "5" },
{ id: 3, label: "10km", value: "10" },
];
return (
<Menu
className={`${styles.menuWrap} ${wrapperClassName} ${
isChange ? styles.active : ""
}`}
activeColor="#000"
>
<Menu.Item
title="全城"
className={`${styles.menuItem} ${itemClassName}`}
ref={itemRef}
>
<div className={styles.positionWrap}>
<p className={styles.title}></p>
<p className={styles.cityName}></p>
</div>
<div className={styles.distanceWrap}>
<Bubble
options={options}
value={value}
onChange={handleChange}
layout="grid"
size="small"
columns={4}
itemClassName={styles.distanceBubbleItem}
/>
</div>
</Menu.Item>
</Menu>
);
};
export default MenuComponent;

View File

@@ -0,0 +1,18 @@
import { useState } from "react";
import MenuComponent from "./index";
export default function Example() {
const [value, setValue] = useState("a");
const options = [
{ text: "默认排序", value: "a" },
{ text: "好评排序", value: "b" },
{ text: "销量排序", value: "c" },
];
return (
<MenuComponent
options={options}
value={value}
onChange={(val) => setValue(val)}
/>
);
}

View File

@@ -0,0 +1,45 @@
.menuWrap {
padding: 5px 20px 10px;
.menuItem {
left: 0;
border-bottom-left-radius: 30px;
border-bottom-right-radius: 30px;
}
&.active {
:global(.nut-menu-bar) {
background-color: #000000;
color: #ffffff;
}
}
:global(.nut-menu-bar) {
color: #000000;
line-height: 1;
box-shadow: unset;
min-height: 28px;
min-width: 94px;
border-radius: 28px;
border: 1px solid #e5e5e5;
line-height: 28px;
font-size: 14px;
width: max-content;
.nut-menu-title-text {
padding-left: 0;
}
}
:global(.nut-menu-title) {
color: inherit !important;
font-weight: 600;
}
:global(.nut-menu-container-item) {
color: #3c3c43;
font-weight: 600;
font-size: 14px;
}
:global(.nut-menu-container-item.active) {
flex-direction: row-reverse;
justify-content: space-between;
}
}

View File

@@ -0,0 +1,37 @@
import { Menu } from "@nutui/nutui-react-taro";
import styles from "./index.module.scss";
import { useState } from "react";
interface IProps {
options: { text: string; value: string }[];
value: string;
onChange: (value: string) => void;
wrapperClassName?: string;
itemClassName?: string;
}
const MenuComponent = (props: IProps) => {
const { options, value, onChange, wrapperClassName, itemClassName } = props;
const [isChange, setIsChange] = useState(false);
const handleChange = (value: string) => {
setIsChange(true);
onChange && onChange(value);
};
return (
<Menu
className={`${styles.menuWrap} ${wrapperClassName} ${isChange ? styles.active : ""}`}
activeColor="#000"
>
<Menu.Item
className={`${styles.menuItem} ${itemClassName}`}
options={options}
defaultValue={value}
onChange={handleChange}
/>
</Menu>
);
};
export default MenuComponent;

View File

@@ -0,0 +1,81 @@
// 全局NutUI样式覆盖
.nutRange {
width: 100% !important;
height: 100% !important;
// .nut-range__bar-box {
// margin: 0 !important;
// padding: 0 !important;
// }
// .nut-range__bar {
// margin: 0 !important;
// }
}
.nutRangeHeader {
line-height: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.nutRangeHeaderLeft {
display: flex;
align-items: center;
gap: 8px;
}
.nutRangeHeaderTitle {
font-weight: 600;
font-size: 14px;
color: #000000;
}
.nutRangeHeaderContent {
font-weight: 400;
font-size: 14px;
color: #3c3c34;
}
}
.rangeWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
height: 44px;
border-radius: 12px;
border: 1px solid #e0e0e0;
padding: 0 10px;
.rangeHandle {
:global(.nut-range-mark) {
padding-top: 28px;
left: 8px;
}
:global(.nut-range-bar) {
background: #000000;
height: 6px;
}
:global(.nut-range-button) {
border: none;
box-shadow: 0 6px 13px 0 rgba(0, 0, 0, 0.12);
}
:global(.nut-range-tick) {
background: #3c3c3c;
height: 4px !important;
width: 4px !important;
}
}
span {
font-size: 12px;
color: #999;
}
.rangeWrapperMin,
.rangeWrapperMax {
flex-shrink: 0;
font-size: 12px;
color: #000000;
}
}

View File

@@ -1,246 +0,0 @@
.ntrp-range {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
user-select: none;
&__header {
display: flex;
align-items: center;
margin-bottom: 16px;
}
&__icon {
margin-right: 8px;
display: flex;
align-items: center;
svg {
width: 20px;
height: 20px;
}
}
&__title {
margin: 0;
font-size: 16px;
font-weight: 500;
color: #000;
line-height: 1.2;
}
&__slider-container {
position: relative;
}
&__labels {
display: flex;
justify-content: space-between;
margin-bottom: 12px;
}
&__label {
font-size: 14px;
color: #000;
font-weight: 400;
}
&__track-container {
position: relative;
padding: 0 10px;
}
&__track {
position: relative;
height: 40px;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
display: flex;
align-items: center;
padding: 0 20px;
}
&__markers {
position: absolute;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
}
&__marker {
position: absolute;
width: 6px;
height: 6px;
background: #e0e0e0;
border-radius: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
&__nutui-wrapper {
position: relative;
width: 100%;
height: 100%;
z-index: 2;
// 隐藏NutUI的默认标签
.nut-range__label {
display: none !important;
}
// 隐藏默认的轨道背景
.nut-range__bar-box {
background: transparent !important;
height: 6px !important;
border-radius: 3px !important;
margin: 0 !important;
padding: 0 !important;
}
// 隐藏默认的轨道
.nut-range__bar {
background: transparent !important;
height: 0 !important;
margin: 0 !important;
}
// 自定义活动轨道(黑色填充条)
.nut-range__bar--active {
background: #000 !important;
height: 6px !important;
border-radius: 3px !important;
margin: 0 !important;
}
// 自定义滑块手柄样式
.nut-range__button {
width: 24px !important;
height: 24px !important;
background: #fff !important;
border: 2px solid #000 !important;
border-radius: 50% !important;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
transition: all 0.1s ease !important;
&:hover {
transform: translate(-50%, -50%) scale(1.1) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
}
&:active {
transform: translate(-50%, -50%) scale(1.15) !important;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important;
}
}
// 确保滑块在正确位置
.nut-range__button-wrapper {
top: 50% !important;
transform: translateY(-50%) !important;
}
}
// 响应式设计
@media (max-width: 480px) {
&__track {
padding: 0 15px;
}
&__nutui-wrapper {
.nut-range__button {
width: 20px !important;
height: 20px !important;
}
}
&__title {
font-size: 14px;
}
&__label {
font-size: 12px;
}
}
}
// 全局NutUI样式覆盖
.nut-range {
width: 100% !important;
height: 100% !important;
.nut-range__bar-box {
margin: 0 !important;
padding: 0 !important;
}
.nut-range__bar {
margin: 0 !important;
}
}
.ntrp-range__header {
line-height: 20px;
display: flex;
align-items: center;
justify-content: space-between;
.ntrp-range__header-left {
display: flex;
align-items: center;
gap: 8px;
}
.ntrp-range__title {
font-weight: 600;
font-size: 14px;
color: #000000;
}
.ntrp-range__content {
font-weight: 400;
font-size: 14px;
color: #3c3c34;
}
}
.rangeWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 5px;
height: 44px;
border-radius: 12px;
border: 1px solid #e0e0e0;
padding: 0 10px;
.rangeHandle {
.nut-range-mark {
padding-top: 28px;
}
.nut-range-bar {
background: #000000;
height: 6px;
}
.nut-range-button {
border: none;
box-shadow: 0 6px 13px 0 rgba(0, 0, 0, 0.12);
}
.nut-range-tick {
background: #3c3c3c;
height: 4px !important;
width: 4px !important;
}
}
span {
font-size: 12px;
color: #999;
}
.rangeWrapper__min,
.rangeWrapper__max {
flex-shrink: 0;
font-size: 12px;
color: #000000;
}
}

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useMemo } from "react";
import { Range } from "@nutui/nutui-react-taro";
import "./index.scss";
import styles from "./index.module.scss";
interface RangeProps {
min?: number;
@@ -48,18 +48,18 @@ const NtrpRange: React.FC<RangeProps> = ({
}, [currentValue, min, max]);
return (
<div className="ntrp-range">
<div className="ntrp-range__header">
<div className="ntrp-range__header-left">
<div className={styles.nutRange}>
<div className={styles.nutRangeHeader}>
<div className={styles.nutRangeHeaderLeft}>
<div className="ntrp-range__icon">icon</div>
<h3 className="ntrp-range__title">NTRP水平区间</h3>
<h3 className={styles.nutRangeHeaderTitle}>NTRP水平区间</h3>
</div>
<p className="ntrp-range__content">{rangContent}</p>
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
</div>
<div>
<div className="rangeWrapper">
<span className="rangeWrapper__min">{min}</span>
<div className={styles.rangeWrapper}>
<span className={styles.rangeWrapperMin}>{min}</span>
<Range
range
min={min}
@@ -69,14 +69,14 @@ const NtrpRange: React.FC<RangeProps> = ({
onChange={handleChange}
disabled={disabled}
defaultValue={[min, max]}
className="rangeHandle"
className={styles.rangeHandle}
maxDescription={null}
minDescription={null}
currentDescription={null}
marks={marks}
style={{ color: "gold" }}
/>
<span className="rangeWrapper__max">{max}</span>
<span className={styles.rangeWrapperMax}>{max}</span>
</div>
</div>

View File

@@ -2,6 +2,8 @@ import ListItem from "../../components/ListItem";
import List from "../../components/List";
import Bubble from "../../components/Bubble/example";
import Range from "../../components/Range/example";
import Menu from "../../components/Menu/example";
import CityFilter from "../../components/CityFilter/example";
import "./index.scss";
import { useEffect } from "react";
import Taro from "@tarojs/taro";
@@ -160,6 +162,12 @@ const ListPage = () => {
</div>
)}
{/* 全城筛选 */}
<CityFilter />
{/* 菜单 */}
<Menu />
{/* 范围选择 */}
<Range />

9
types/css-modules.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module '*.module.scss' {
const classes: { [key: string]: string };
export default classes;
}
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}