列表综合筛选

This commit is contained in:
juguohong
2025-08-24 16:24:49 +08:00
parent e6124131e7
commit 8cfe0ab0b0
34 changed files with 620 additions and 1339 deletions

View File

@@ -1,16 +1,7 @@
import React from 'react';
import { BubbleOption } from './index';
import { BubbleItemProps } from '../../../types/list/types';
import styles from './bubbleItem.module.scss';
export interface BubbleItemProps {
option: BubbleOption;
isSelected: boolean;
size: 'small' | 'medium' | 'large';
disabled: boolean;
onClick: (option: BubbleOption) => void;
itemClassName?: string;
}
const BubbleItem: React.FC<BubbleItemProps> = ({
option,
isSelected,

View File

@@ -1,208 +0,0 @@
# Bubble 通用气泡组件
一个高度可配置的气泡选择器组件,支持任何内容的选择,包括但不限于时间、地点、标签、分类等。
## 特性
- 🎯 支持单选和多选模式
- 📱 三种布局方式:水平、垂直、网格
- 🎨 三种尺寸:小、中、大
- ♿ 支持禁用状态和图标描述
- 🔄 支持受控和非受控模式
- 📱 响应式设计,自动适应不同屏幕
- 🎨 可自定义样式和类名
## 基本用法
### 室内外选择示例如UI图所示
```tsx
import React, { useState } from 'react';
import Bubble, { BubbleOption } from './index';
const LocationSelector: React.FC = () => {
const [selectedLocation, setSelectedLocation] = useState<string>('');
const locationOptions: BubbleOption[] = [
{ id: 1, label: '室内', value: 'indoor' },
{ id: 2, label: '室外', value: 'outdoor' },
{ id: 3, label: '半室外', value: 'semi-outdoor' }
];
return (
<Bubble
options={locationOptions}
value={selectedLocation}
onChange={(value) => setSelectedLocation(value as string)}
layout="horizontal"
size="medium"
/>
);
};
```
### 时间选择器示例
```tsx
const TimeSelector: React.FC = () => {
const [selectedTime, setSelectedTime] = useState<string>('');
const timeOptions: BubbleOption[] = [
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
];
return (
<Bubble
options={timeOptions}
value={selectedTime}
onChange={(value) => setSelectedTime(value as string)}
layout="grid"
columns={3}
size="medium"
/>
);
};
```
### 多选模式
```tsx
const MultiSelectExample: React.FC = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const options: BubbleOption[] = [
{ id: 1, label: '运动', value: 'sports' },
{ id: 2, label: '音乐', value: 'music' },
{ id: 3, label: '阅读', value: 'reading' },
{ id: 4, label: '旅行', value: 'travel' }
];
return (
<Bubble
options={options}
value={selectedValues}
onChange={(value) => setSelectedValues(value as string[])}
multiple={true}
layout="grid"
columns={2}
size="medium"
/>
);
};
```
### 带图标和描述
```tsx
const IconExample: React.FC = () => {
const [selectedValue, setSelectedValue] = useState<string>('');
const options: BubbleOption[] = [
{
id: 1,
label: '网球',
value: 'tennis',
icon: '🎾',
description: '室内外均可'
},
{
id: 2,
label: '篮球',
value: 'basketball',
icon: '🏀',
description: '室内场地'
}
];
return (
<Bubble
options={options}
value={selectedValue}
onChange={(value) => setSelectedValue(value as string)}
layout="vertical"
size="large"
/>
);
};
```
## API
### BubbleProps
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| options | `BubbleOption[]` | - | 选项数组 |
| value | `string \| number \| (string \| number)[]` | - | 当前选中的值 |
| onChange | `(value, option) => void` | - | 选择变化时的回调 |
| multiple | `boolean` | `false` | 是否支持多选 |
| layout | `'horizontal' \| 'vertical' \| 'grid'` | `'horizontal'` | 布局方式 |
| columns | `number` | `3` | 网格布局的列数 |
| size | `'small' \| 'medium' \| 'large'` | `'medium'` | 按钮尺寸 |
| className | `string` | `''` | 自定义类名 |
| style | `React.CSSProperties` | `{}` | 自定义样式 |
| disabled | `boolean` | `false` | 是否禁用整个组件 |
### BubbleOption
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| id | `string \| number` | - | 选项的唯一标识 |
| label | `string` | - | 显示的文本 |
| value | `string \| number` | - | 选项的值 |
| disabled | `boolean` | `false` | 是否禁用 |
| icon | `React.ReactNode` | - | 可选的图标 |
| description | `string` | - | 可选的描述文本 |
## 布局说明
### 水平布局 (horizontal)
- 适合选项较少的情况
- 自动换行,响应式设计
- 适合顶部导航、标签选择等
### 垂直布局 (vertical)
- 适合选项较多的情况
- 每个选项占满一行
- 适合侧边栏、设置页面等
### 网格布局 (grid)
- 适合选项较多且需要整齐排列的情况
- 可自定义列数
- 适合分类选择、时间选择等
## 尺寸说明
### 小尺寸 (small)
- 适合紧凑的界面
- 适合移动端或空间受限的场景
### 中尺寸 (medium)
- 默认尺寸,适合大多数场景
- 平衡了可用性和美观性
### 大尺寸 (large)
- 适合需要突出显示的场景
- 适合触摸设备或重要操作
## 样式定制
组件使用 SCSS 编写,可以通过以下方式自定义样式:
1. 覆盖 CSS 变量
2. 使用 `className``style` 属性
3. 修改 SCSS 源文件
## 注意事项
- 在网格布局中,`columns` 属性控制列数,行数会根据选项数量自动计算
- 多选模式下,`value` 应该是数组类型
- 单选模式下,`value` 可以是字符串或数字类型
- 组件会自动处理选中状态的样式变化
- 支持图标和描述,让选项更加丰富
- 响应式设计,自动适应不同屏幕尺寸

View File

@@ -1,211 +0,0 @@
# 如何使用 Bubble 通用气泡组件
## 在其他组件中导入
```tsx
import Bubble, { BubbleOption } from '@/components/Bubble';
```
## 基本使用示例
### 1. 室内外选择如UI图所示
```tsx
import React, { useState } from 'react';
import Bubble, { BubbleOption } from '@/components/Bubble';
const MyPage: React.FC = () => {
const [selectedLocation, setSelectedLocation] = useState<string>('');
const locationOptions: BubbleOption[] = [
{ id: 1, label: '室内', value: 'indoor' },
{ id: 2, label: '室外', value: 'outdoor' },
{ id: 3, label: '半室外', value: 'semi-outdoor' }
];
return (
<div>
<h2></h2>
<Bubble
options={locationOptions}
value={selectedLocation}
onChange={(value) => setSelectedLocation(value as string)}
layout="horizontal"
size="medium"
/>
<p>: {selectedLocation}</p>
</div>
);
};
```
### 2. 时间选择器
```tsx
const [selectedTime, setSelectedTime] = useState<string>('');
const timeOptions: BubbleOption[] = [
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
];
<Bubble
options={timeOptions}
value={selectedTime}
onChange={(value) => setSelectedTime(value as string)}
layout="grid"
columns={3}
size="medium"
/>
```
### 3. 多选模式
```tsx
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const options: BubbleOption[] = [
{ id: 1, label: '运动', value: 'sports' },
{ id: 2, label: '音乐', value: 'music' },
{ id: 3, label: '阅读', value: 'reading' },
{ id: 4, label: '旅行', value: 'travel' }
];
<Bubble
options={options}
value={selectedValues}
onChange={(value) => setSelectedValues(value as string[])}
multiple={true}
layout="grid"
columns={2}
size="medium"
/>
```
### 4. 带图标和描述
```tsx
const [selectedSport, setSelectedSport] = useState<string>('');
const sportOptions: BubbleOption[] = [
{
id: 1,
label: '网球',
value: 'tennis',
icon: '🎾',
description: '室内外均可'
},
{
id: 2,
label: '篮球',
value: 'basketball',
icon: '🏀',
description: '室内场地'
}
];
<Bubble
options={sportOptions}
value={selectedSport}
onChange={(value) => setSelectedSport(value as string)}
layout="vertical"
size="large"
/>
```
### 5. 不同布局方式
```tsx
// 水平布局 - 适合选项较少
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="horizontal"
size="medium"
/>
// 垂直布局 - 适合选项较多
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="vertical"
size="medium"
/>
// 网格布局 - 适合需要整齐排列
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="grid"
columns={3}
size="medium"
/>
```
### 6. 不同尺寸
```tsx
// 小尺寸 - 适合紧凑界面
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="horizontal"
size="small"
/>
// 中尺寸 - 默认尺寸
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="horizontal"
size="medium"
/>
// 大尺寸 - 适合触摸设备
<Bubble
options={options}
value={selectedValue}
onChange={handleChange}
layout="horizontal"
size="large"
/>
```
## 组件特性
- **通用性**: 不局限于特定功能,可用于任何选择场景
- **灵活布局**: 支持水平、垂直、网格三种布局方式
- **多尺寸支持**: 小、中、大三种尺寸适应不同场景
- **丰富内容**: 支持图标和描述,让选项更加丰富
- **响应式设计**: 自动适应不同屏幕尺寸
- **状态管理**: 内置选中状态管理,支持单选和多选
- **类型安全**: 完整的 TypeScript 类型定义
- **可访问性**: 支持键盘导航和屏幕阅读器
## 常见使用场景
1. **场地选择**: 室内/室外/半室外
2. **时间选择**: 时间段、日期范围
3. **分类选择**: 兴趣爱好、技能标签
4. **设置选项**: 主题、语言、通知设置
5. **筛选条件**: 价格范围、评分、距离等
6. **导航菜单**: 顶部导航、侧边栏菜单
## 注意事项
1. 确保传入的 `options` 数组不为空
2. 多选模式下,`value` 应该是数组类型
3. 单选模式下,`value` 可以是字符串或数字类型
4. 组件会自动处理选中状态的样式变化
5. 支持图标和描述,让选项更加丰富
6. 响应式设计,自动适应不同屏幕尺寸
7. 可以通过 `disabled` 属性禁用整个组件或单个选项

View File

@@ -1,235 +0,0 @@
import React, { useState } from 'react';
import Bubble, { BubbleOption } from './index';
// 室内外选择示例如UI图所示
export const LocationSelector: React.FC = () => {
const [selectedLocation, setSelectedLocation] = useState<string>('');
const locationOptions: BubbleOption[] = [
{ id: 1, label: '室内', value: 'indoor' },
{ id: 2, label: '室外', value: 'outdoor' },
{ id: 3, label: '半室外', value: 'semi-outdoor' }
];
return (
<div>
<h3></h3>
<Bubble
options={locationOptions}
value={selectedLocation}
onChange={(value) => setSelectedLocation(value as string)}
layout="horizontal"
size="small"
/>
<p>: {selectedLocation || '未选择'}</p>
</div>
);
};
// 时间选择器示例
export const TimeSelector: React.FC = () => {
const [selectedTime, setSelectedTime] = useState<string>('');
const timeOptions: BubbleOption[] = [
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
];
return (
<div>
<h3></h3>
<Bubble
options={timeOptions}
value={selectedTime}
onChange={(value) => setSelectedTime(value as string)}
layout="grid"
size="small"
columns={3}
/>
<p>: {selectedTime || '未选择'}</p>
<hr />
<h3></h3>
<Bubble
options={timeOptions}
value={selectedTime}
onChange={(value) => setSelectedTime(value as string)}
layout="grid"
columns={2}
size="small"
/>
<p>: {selectedTime || '未选择'}</p>
</div>
);
};
// 多选示例
export const MultiSelectExample: React.FC = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
const options: BubbleOption[] = [
{ id: 1, label: '运动', value: 'sports' },
{ id: 2, label: '音乐', value: 'music' },
{ id: 3, label: '阅读', value: 'reading' },
{ id: 4, label: '旅行', value: 'travel' },
{ id: 5, label: '美食', value: 'food' },
{ id: 6, label: '摄影', value: 'photography' }
];
return (
<div style={{ padding: '20px' }}>
<h3> - </h3>
<Bubble
options={options}
value={selectedValues}
onChange={(value) => setSelectedValues(value as string[])}
multiple={true}
layout="grid"
columns={3}
size="medium"
/>
<p>: {selectedValues.join(', ') || '未选择'}</p>
</div>
);
};
// 带图标和描述的示例
export const IconExample: React.FC = () => {
const [selectedValue, setSelectedValue] = useState<string>('');
const options: BubbleOption[] = [
{
id: 1,
label: '网球',
value: 'tennis',
icon: '🎾',
description: '室内外均可'
},
{
id: 2,
label: '篮球',
value: 'basketball',
icon: '🏀',
description: '室内场地'
},
{
id: 3,
label: '足球',
value: 'football',
icon: '⚽',
description: '室外场地'
}
];
return (
<div style={{ padding: '20px' }}>
<h3></h3>
<Bubble
options={options}
value={selectedValue}
onChange={(value) => setSelectedValue(value as string)}
layout="vertical"
size="large"
/>
<p>: {selectedValue || '未选择'}</p>
</div>
);
};
// 带禁用状态的示例
export const DisabledExample: React.FC = () => {
const [selectedValue, setSelectedValue] = useState<string>('');
const options: BubbleOption[] = [
{ id: 1, label: '可用选项1', value: 'option1' },
{ id: 2, label: '禁用选项2', value: 'option2', disabled: true },
{ id: 3, label: '可用选项3', value: 'option3' },
{ id: 4, label: '禁用选项4', value: 'option4', disabled: true }
];
return (
<div style={{ padding: '20px' }}>
<h3></h3>
<Bubble
options={options}
value={selectedValue}
onChange={(value) => setSelectedValue(value as string)}
layout="grid"
columns={2}
size="medium"
/>
<p>: {selectedValue || '未选择'}</p>
</div>
);
};
// 不同尺寸的示例
export const SizeExample: React.FC = () => {
const [selectedSize, setSelectedSize] = useState<string>('');
const sizeOptions: BubbleOption[] = [
{ id: 1, label: '小尺寸', value: 'small' },
{ id: 2, label: '中尺寸', value: 'medium' },
{ id: 3, label: '大尺寸', value: 'large' }
];
return (
<div style={{ padding: '20px' }}>
<h3></h3>
<h4></h4>
<Bubble
options={sizeOptions}
value={selectedSize}
onChange={(value) => setSelectedSize(value as string)}
layout="horizontal"
size="small"
/>
<h4></h4>
<Bubble
options={sizeOptions}
value={selectedSize}
onChange={(value) => setSelectedSize(value as string)}
layout="horizontal"
size="medium"
/>
<h4></h4>
<Bubble
options={sizeOptions}
value={selectedSize}
onChange={(value) => setSelectedSize(value as string)}
layout="horizontal"
size="large"
/>
<p>: {selectedSize || '未选择'}</p>
</div>
);
};
// 主示例组件
export const BubbleExamples: React.FC = () => {
return (
<div>
<h1>Bubble </h1>
<LocationSelector />
<hr />
<TimeSelector />
<hr />
<MultiSelectExample />
<hr />
<IconExample />
<hr />
<DisabledExample />
<hr />
<SizeExample />
</div>
);
};
export default BubbleExamples;

View File

@@ -1,34 +1,7 @@
import React, { useState, useEffect } from "react";
import styles from "./index.module.scss";
import BubbleItem from "./BubbleItem";
export interface BubbleOption {
id: string | number;
label: string;
value: string | number;
disabled?: boolean;
icon?: React.ReactNode;
description?: string;
}
export interface BubbleProps {
options: BubbleOption[];
value?: string | number | (string | number)[];
onChange?: (
name: string,
value: string | number | (string | number)[],
option: BubbleOption | BubbleOption[]
) => void;
multiple?: boolean;
layout?: "horizontal" | "vertical" | "grid";
columns?: number;
size?: "small" | "medium" | "large";
className?: string;
itemClassName?: string;
style?: React.CSSProperties;
disabled?: boolean;
name: string;
}
import {BubbleProps} from '../../../types/list/types'
const Bubble: React.FC<BubbleProps> = ({
options,
@@ -79,6 +52,7 @@ const Bubble: React.FC<BubbleProps> = ({
);
onChange(name, newSelectedValues, selectedOptions);
} else {
console.log('===111', name, option.value)
onChange(name, option.value, option);
}
}

View File

@@ -1,18 +0,0 @@
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

@@ -1,5 +1,15 @@
.menuWrap {
padding: 5px 20px 10px;
$height: 26px;
.menuIcon {
width: 16px;
height: 16px;
&.rotate {
transform: rotate(180deg);
}
}
.menuItem {
width: 100vw;
@@ -13,7 +23,7 @@
}
&.active {
.nut-menu-bar {
:global(.nut-menu-bar) {
background-color: #000000;
color: #ffffff;
}
@@ -21,24 +31,23 @@
:global(.nut-menu-bar) {
color: #000000;
line-height: 1;
width: 66px;
height: $height;
border-radius: $height;
line-height: $height;
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;
font-weight: 600;
}
.nut-menu-title {
:global(.nut-menu-title) {
color: inherit !important;
font-weight: 600;
}
.nut-menu-title-text {
padding-left: 0;
}
:global(.nut-menu-title-text) {
--nutui-menu-title-padding: 0 6px 0 0;
}
.positionWrap {

View File

@@ -1,28 +1,33 @@
import { Menu } from "@nutui/nutui-react-taro";
import styles from "./index.module.scss";
import { useState, useRef } from "react";
import Bubble, { BubbleOption } from "../Bubble";
import Bubble from "../Bubble";
import { Image } from "@tarojs/components";
import img from "../../config/images";
import {DistanceFilterProps} from '../../../types/list/types'
interface IProps {
options: BubbleOption[];
value: string;
onChange: (value: string) => void;
wrapperClassName?: string;
itemClassName?: string;
}
const MenuComponent = (props: IProps) => {
const { value, onChange, wrapperClassName, itemClassName, options } = props;
const MenuComponent = (props: DistanceFilterProps) => {
const { value, onChange, wrapperClassName, itemClassName, options, name } =
props;
const [isChange, setIsChange] = useState(false);
const [iOpen, setIsOpen] = useState(false);
const itemRef = useRef(null);
const handleChange = (value: string) => {
console.log("===value", value);
const handleChange = (name: string, value: string) => {
setIsChange(true);
onChange && onChange(value);
onChange && onChange(name, value);
(itemRef.current as any)?.toggle(false);
};
const handleOpen = () => {
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
};
return (
<Menu
@@ -30,11 +35,25 @@ const MenuComponent = (props: IProps) => {
isChange ? styles.active : ""
}`}
activeColor="#000"
onOpen={handleOpen}
onClose={handleClose}
icon={
<Image
className={`${styles.menuIcon} ${iOpen ? styles.rotate : ""}`}
src={isChange ? img.ICON_ARROW_DOWN_WHITE : img.ICON_ARROW_DOWN}
/>
}
>
<Menu.Item
title="全城"
className={`${styles.menuItem} ${itemClassName ? itemClassName : ''}`}
title={value}
className={`${styles.menuItem} ${itemClassName ? itemClassName : ""}`}
ref={itemRef}
icon={
<Image
className={styles.itemIcon}
src={img.ICON_MENU_ITEM_SELECTED}
/>
}
>
<div className={styles.positionWrap}>
<p className={styles.title}></p>
@@ -49,6 +68,7 @@ const MenuComponent = (props: IProps) => {
size="small"
columns={4}
itemClassName={styles.distanceBubbleItem}
name={name}
/>
</div>
</Menu.Item>

View File

@@ -10,26 +10,32 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
gap: 6px;
}
.titleWrapper {
display: flex;
align-items: center;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333333;
line-height: 1.4;
color: #000000;
line-height: 24px;
}
.date-time {
font-size: 14px;
color: #666666;
line-height: 1.3;
.title-right-arrow {
width: 16px;
height: 16px;
}
.date-time,
.location {
font-size: 14px;
color: #666666;
line-height: 1.3;
font-size: 12px;
color: #3C3C4399;
font-weight: 400;
line-height: 18px;
}
.bottom-info {
@@ -58,6 +64,8 @@
border: 2px solid #ffffff;
margin-left: -8px;
overflow: hidden;
box-sizing: border-box;
.avatar-image {
width: 100%;
height: 100%;
@@ -89,8 +97,10 @@
}
.tag {
padding: 3px;
border: 1px solid #f5f5f5;
box-sizing: border-box;
padding: 0 6px;
border: 0.5px solid #00000029;
height: 20px;
border-radius: 20px;
min-width: 38px;
display: flex;
@@ -98,7 +108,7 @@
justify-content: center;
flex-shrink: 0;
color: #000000;
font-size: 12px;
font-size: 11px;
}
.tag-text-max {
@@ -112,16 +122,20 @@
display: flex;
align-items: center;
justify-content: center;
flex-basis: 100px;
flex-grow: 0;
flex-shrink: 0;
.image-container {
width: 100%;
height: 100%;
padding: 2px;
background: #ffffff;
border: 1.5px solid #ffffff;
border-radius: 10px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
position: absolute;
box-sizing: border-box;
.image {
border-radius: 10px;
}
@@ -132,7 +146,10 @@
position: relative;
width: 88px;
height: 88px;
.image-container {
width: 88px;
height: 88px;
transform: rotate(-10deg);
}
}
@@ -151,13 +168,13 @@
&:first-child {
z-index: 2;
transform: rotate(-10deg);
transform: translateX(4px) rotate(-10deg);
}
&:last-child {
right: 0;
z-index: 1;
transform: rotate(10deg);
transform: translateX(-4px) rotate(10deg);
}
}
}
@@ -177,7 +194,7 @@
width: 55px;
height: 55px;
z-index: 3;
transform: rotate(-10deg);
transform: translateX(4px) rotate(-10deg);
}
&:nth-child(2) {

View File

@@ -1,16 +1,17 @@
import { View, Text, Image } from '@tarojs/components'
import './index.scss'
import { View, Text, Image } from "@tarojs/components";
import img from "../../config/images";
import "./index.scss";
interface ListItemProps {
title: string
dateTime: string
location: string
distance: string
registeredCount: number
maxCount: number
skillLevel: string
matchType: string
images: string[]
title: string;
dateTime: string;
location: string;
distance: string;
registeredCount: number;
maxCount: number;
skillLevel: string;
matchType: string;
images: string[];
}
const ListItem: React.FC<ListItemProps> = ({
@@ -22,11 +23,11 @@ const ListItem: React.FC<ListItemProps> = ({
maxCount,
skillLevel,
matchType,
images
images,
}) => {
// 根据图片数量决定展示样式
const renderImages = () => {
if (images.length === 0) return null
if (images.length === 0) return null;
if (images.length === 1) {
return (
@@ -35,7 +36,7 @@ const ListItem: React.FC<ListItemProps> = ({
<Image src={images[0]} className="image" mode="aspectFill" />
</View>
</View>
)
);
}
if (images.length === 2) {
@@ -48,7 +49,7 @@ const ListItem: React.FC<ListItemProps> = ({
<Image src={images[1]} className="image" mode="aspectFill" />
</View>
</View>
)
);
}
// 3张或更多图片
@@ -64,40 +65,54 @@ const ListItem: React.FC<ListItemProps> = ({
<Image src={images[2]} className="image" mode="aspectFill" />
</View>
</View>
)
}
);
};
return (
<View className="list-item">
{/* 左侧内容区域 */}
<View className="content">
{/* 标题 */}
<View className="titleWrapper">
<Text className="title">{title}</Text>
<Image
src={img.ICON_LIST_RIGHT_ARROW}
className="title-right-arrow"
mode="aspectFit"
/>
</View>
{/* 时间信息 */}
<Text className="date-time">{dateTime}</Text>
{/* 地点和距离 */}
<Text className="location">{location}{distance}</Text>
<Text className="location">
{location}{distance}
</Text>
{/* 底部信息行:头像组、报名人数、技能等级、比赛类型 */}
<View className="bottom-info">
<View className="left-section">
<View className="avatar-group">
{Array.from({ length: Math.min(registeredCount, 3) }).map((_, index) => (
{Array.from({ length: Math.min(registeredCount, 3) }).map(
(_, index) => (
<View key={index} className="avatar">
<Image
className="avatar-image"
src="https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center" className="avatar-image" mode="aspectFill" />
src="https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center"
mode="aspectFill"
/>
</View>
))}
)
)}
</View>
</View>
<View className="tags">
<View className="tag">
<Text className="tag-text">
{registeredCount}/<Text className="tag-text-max">{maxCount}</Text>
{registeredCount}/
<Text className="tag-text-max">{maxCount}</Text>
</Text>
</View>
<View className="tag">
@@ -111,11 +126,9 @@ const ListItem: React.FC<ListItemProps> = ({
</View>
{/* 右侧图片区域 */}
<View className="image-section">
{renderImages()}
<View className="image-section">{renderImages()}</View>
</View>
</View>
)
}
);
};
export default ListItem
export default ListItem;

View File

@@ -1,18 +0,0 @@
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

@@ -1,33 +1,46 @@
.menuWrap {
position: static;
padding: 5px 20px 10px;
--nutui-menu-title-padding: 0 6px 0 0;
$height: 26px;
.menuItem {
width: 100vw;
left: 0;
border-bottom-left-radius: 30px;
border-bottom-right-radius: 30px;
}
.menuIcon {
width: 16px;
height: 16px;
&.rotate {
transform: rotate(180deg);
}
}
.itemIcon {
width: 20px;
height: 20px;
}
&.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-radius: $height;
line-height: $height;
height: $height;
border: 1px solid #e5e5e5;
line-height: 28px;
font-size: 14px;
width: max-content;
.nut-menu-title-text {
padding-left: 0;
}
width: 94px;
}
:global(.nut-menu-title) {
@@ -35,11 +48,16 @@
font-weight: 600;
}
:global(.nut-menu-title-text) {
--nutui-menu-title-padding: 0 6px 0 4px;
}
: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

@@ -1,34 +1,55 @@
import { Menu } from "@nutui/nutui-react-taro";
import styles from "./index.module.scss";
import { useState } from "react";
import { Image } from "@tarojs/components";
import img from "../../config/images";
import { MenuFilterProps } from "../../../types/list/types";
import styles from "./index.module.scss";
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 MenuComponent = (props: MenuFilterProps) => {
const { options, value, onChange, wrapperClassName, itemClassName, name } =
props;
const [isChange, setIsChange] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const handleChange = (value: string) => {
const handleChange = (val: Record<string, string>) => {
setIsChange(true);
onChange && onChange(value);
onChange && onChange(name, val.value);
};
const handleOpen = () => {
setIsOpen(true);
};
const handleClose = () => {
setIsOpen(false);
};
return (
<Menu
className={`${styles.menuWrap} ${wrapperClassName} ${isChange ? styles.active : ""}`}
className={`${styles.menuWrap} ${wrapperClassName} ${
isChange ? styles.active : ""
}`}
activeColor="#000"
onOpen={handleOpen}
onClose={handleClose}
icon={
<Image
className={`${styles.menuIcon} ${isOpen ? styles.rotate : ""}`}
src={isChange ? img.ICON_ARROW_DOWN_WHITE : img.ICON_ARROW_DOWN}
/>
}
>
<Menu.Item
className={`${styles.menuItem} ${itemClassName}`}
options={options}
defaultValue={value}
onChange={handleChange}
icon={
<Image
className={styles.itemIcon}
src={img.ICON_MENU_ITEM_SELECTED}
/>
}
/>
</Menu>
);

View File

@@ -1,93 +0,0 @@
# NtrpRange 范围选择器组件
基于NutUI Range组件的双滑块范围选择器通过CSS样式覆盖完全匹配设计稿支持自定义范围、步长和回调函数。
## 功能特性
- 🎯 双滑块设计,支持选择范围区间
- 🎨 精准还原设计稿的视觉效果
- 📱 响应式设计,支持移动端
- 🎮 流畅的拖拽交互体验
- ⚙️ 可配置的最小值、最大值和步长
- 🔒 支持禁用状态
- 📊 实时值变化回调
## 基本用法
```tsx
import NtrpRange from '@/components/Range';
const MyComponent = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
);
};
```
## 属性说明
| 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `min` | `number` | `2.0` | 最小值 |
| `max` | `number` | `4.0` | 最大值 |
| `step` | `number` | `0.5` | 步长 |
| `value` | `[number, number]` | `[min, max]` | 当前选择的范围值 |
| `onChange` | `(value: [number, number]) => void` | - | 值变化时的回调函数 |
| `disabled` | `boolean` | `false` | 是否禁用 |
## 技术实现
- 基于NutUI Range组件确保拖拽功能的可靠性
- 通过CSS样式覆盖完全匹配设计稿视觉效果
- TypeScript + React Hooks
- 响应式设计,支持移动端
## 设计规范
组件严格按照设计稿实现,包含以下视觉元素:
- **网球图标**: 黑色轮廓的网球图标
- **标题**: "NTRP水平区间" 文字
- **标签**: 左右两端的范围标签(如"2.0及以下"、"4.0及以上"
- **滑块轨道**: 圆角矩形容器,带有浅灰色边框和阴影
- **滑块手柄**: 两个白色圆形手柄,带有黑色边框和阴影
- **轨道填充**: 黑色填充条,显示当前选择的范围
- **标记点**: 四个浅灰色圆点,均匀分布在轨道上
## 样式定制
组件使用 BEM 命名规范,可以通过 CSS 变量或覆盖样式来自定义外观:
```scss
.ntrp-range {
// 自定义样式
&__track {
background: #f5f5f5;
border-color: #d0d0d0;
}
&__handle {
background: #007bff;
border-color: #0056b3;
}
}
```
## 注意事项
1. 确保 `min < max`,否则组件可能无法正常工作
2. `step` 值应该能够整除 `max - min` 的差值
3. 组件内部会确保左右滑块不会重叠,最小间距为 `step`
4. 拖拽时会自动吸附到最近的步长值
## 示例
查看 `example.tsx` 文件获取更多使用示例。

View File

@@ -1,85 +0,0 @@
/*
* @Author: juguohong juguohong@flashhold.com
* @Date: 2025-08-16 17:59:28
* @LastEditors: juguohong juguohong@flashhold.com
* @LastEditTime: 2025-08-16 23:48:25
* @FilePath: /mini-programs/src/components/Range/example.tsx
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
*/
import React, { useState } from 'react';
import NtrpRange from './index';
const RangeExample: React.FC = () => {
const [ntrpRange, setNtrpRange] = useState<[number, number]>([2.0, 4.0]);
const [customRange, setCustomRange] = useState<[number, number]>([0, 100]);
const handleNtrpChange = (value: [number, number]) => {
console.log('NTRP range changed:', value);
setNtrpRange(value);
};
const handleCustomChange = (value: [number, number]) => {
console.log('Custom range changed:', value);
setCustomRange(value);
};
return (
<div >
<h1>Range </h1>
<div style={{ marginBottom: '40px' }}>
<h2>NTRP </h2>
<NtrpRange
min={1.0}
max={5.0}
step={0.5}
value={ntrpRange}
onChange={handleNtrpChange}
/>
<div >
: {ntrpRange[0]} - {ntrpRange[1]}
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<NtrpRange
min={0}
max={100}
step={10}
value={customRange}
onChange={handleCustomChange}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
: {customRange[0]} - {customRange[1]}
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<NtrpRange
min={1}
max={10}
step={1}
value={[3, 7]}
disabled={true}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
</div>
</div>
<div style={{ marginBottom: '40px' }}>
<h2></h2>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
<p>1. </p>
<p>2. </p>
<p>3. </p>
<p>4. </p>
</div>
</div>
</div>
);
};
export default RangeExample;

View File

@@ -1,7 +1,9 @@
import React, { useState, useEffect, useMemo } from "react";
import { View, Text, Image } from "@tarojs/components";
import { Range } from "@nutui/nutui-react-taro";
import styles from "./index.module.scss";
import TitleComponent from "../Title";
import img from "../../config/images";
interface RangeProps {
min?: number;
@@ -25,9 +27,8 @@ const NtrpRange: React.FC<RangeProps> = ({
name,
}) => {
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
console.log('===currentValue', currentValue)
useEffect(() => {
console.log('===rrr', value)
value && setCurrentValue(value);
}, [JSON.stringify(value || [])]);
@@ -55,7 +56,10 @@ console.log('===currentValue', currentValue)
return (
<div className={`${styles.nutRange} ${className ? className : ""} `}>
<div className={styles.nutRangeHeader}>
<TitleComponent title="NTRP水平区间" />
<TitleComponent
title="NTRP水平区间"
icon={<Image src={img.ICON_PLAY} />}
/>
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
</div>
@@ -69,6 +73,7 @@ console.log('===currentValue', currentValue)
step={step}
value={currentValue}
onEnd={handleChange}
onChange={handleChange}
disabled={disabled}
defaultValue={[min, max]}
className={styles.rangeHandle}

View File

@@ -1,22 +0,0 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const SimpleTest: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px' }}>
<h2></h2>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
<p>: {value[0]} - {value[1]}</p>
</div>
);
};
export default SimpleTest;

View File

@@ -1,68 +0,0 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const StyleTest: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
<h1>NtrpRange </h1>
<div style={{ marginBottom: '30px' }}>
<h2>NTRP </h2>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
: {value[0]} - {value[1]}
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<NtrpRange
min={0}
max={100}
step={1}
value={[20, 80]}
onChange={(val) => console.log('Custom range:', val)}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
固定范围: 20 - 80
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<NtrpRange
min={1}
max={10}
step={1}
value={[3, 7]}
disabled={true}
/>
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
</div>
</div>
<div style={{ marginBottom: '30px' }}>
<h2></h2>
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
<p> + "NTRP水平区间"</p>
<p> "2.0及以下""4.0及以上"</p>
<p> </p>
<p> </p>
<p> </p>
<p> </p>
</div>
</div>
</div>
);
};
export default StyleTest;

View File

@@ -1,35 +0,0 @@
import React, { useState } from 'react';
import NtrpRange from './index';
const TestPage: React.FC = () => {
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
return (
<div style={{ padding: '20px' }}>
<h1>NtrpRange </h1>
<div style={{ marginBottom: '20px' }}>
<NtrpRange
min={2.0}
max={4.0}
step={0.5}
value={value}
onChange={setValue}
/>
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
: {value[0].toFixed(1)} - {value[1].toFixed(1)}
</div>
<div style={{ marginTop: '20px', fontSize: '12px', color: '#999' }}>
<p>:</p>
<p>1. </p>
<p>2. </p>
<p>3. </p>
</div>
</div>
);
};
export default TestPage;

View File

@@ -7,10 +7,14 @@ interface IProps {
handleFilterIcon: () => void;
isSelect: boolean;
filterCount: number;
onChange: (value: string) => void;
}
const SearchBarComponent = (props: IProps) => {
const { handleFilterIcon, isSelect, filterCount } = props;
const { handleFilterIcon, isSelect, filterCount, onChange } = props;
const handleChange = (value: string) => {
onChange && onChange(value);
};
return (
<>
<SearchBar
@@ -30,11 +34,14 @@ const SearchBarComponent = (props: IProps) => {
src={isSelect ? img.ICON_FILTER_SELECTED : img.ICON_FILTER}
className={styles.filterIcon}
/>
{isSelect && (
<Text className={styles.filterCount}>{filterCount}</Text>
)}
</View>
}
className={styles.searchBar}
placeholder="搜索上海的球局和场地"
onChange={handleChange}
/>
</>
);

View File

@@ -4,6 +4,17 @@
margin-bottom: 10px;
gap: 6px;
}
.icon {
width: 16px;
height: 16px;
image {
width: 100%;
height: 100%;
}
}
.title {
font-weight: 600;
font-size: 16px;

View File

@@ -2,15 +2,16 @@ import styles from "./index.module.scss";
interface IProps {
title: string;
className?: string;
icon?: JSX.Element;
}
const TitleComponent = (props: IProps) => {
const { title, className } = props;
const { title, className, icon } = props;
return (
<>
<div
className={`${styles.titleContainer} ${className ? className : ""} `}
>
<div></div>
<div className={styles.icon}>{icon}</div>
<h1 className={styles.title}>{title}</h1>
</div>
</>

View File

@@ -11,4 +11,10 @@ export default {
ICON_FILTER: require('@/static/list/icon-filter.svg'),
ICON_FILTER_SELECTED: require('@/static/list/icon-filter-selected.svg'),
ICON_SEARCH: require('@/static/list/icon-search.svg'),
ICON_PLAY: require('@/static/list/icon-play.svg'),
ICON_SITE: require('@/static/list/icon-site.svg'),
ICON_ARROW_DOWN: require('@/static/list/icon-arrow-down.svg'),
ICON_MENU_ITEM_SELECTED: require('@/static/list/icon-menu-item-selected.svg'),
ICON_ARROW_DOWN_WHITE: require('@/static/list/icon-arrow-down-white.svg'),
ICON_LIST_RIGHT_ARROW: require('@/static/list/icon-list-right-arrow.svg'),
}

View File

@@ -1,60 +1,52 @@
import { Popup } from "@nutui/nutui-react-taro";
import Range from "../../components/Range";
import Bubble, { BubbleOption } from "../../components/Bubble";
import Bubble from "../../components/Bubble";
import styles from "./filterPopup.module.scss";
import TitleComponent from "src/components/Title";
import { Button } from "@nutui/nutui-react-taro";
import { Image } from "@tarojs/components";
import img from "../../config/images";
import { useListStore } from "src/store/listStore";
import {FilterPopupProps} from '../../../types/list/types'
const timeOptions: BubbleOption[] = [
{ id: 1, label: "晨间 6:00-10:00", value: "1" },
{ id: 2, label: "上午 10:00-12:00", value: "2" },
{ id: 3, label: "中午 12:00-14:00", value: "3" },
{ id: 4, label: "下午 14:00-18:00", value: "4" },
{ id: 5, label: "晚上 18:00-22:00", value: "5" },
{ id: 6, label: "夜间 22:00-24:00", value: "6" },
];
const FilterPopup = (props: FilterPopupProps) => {
const {
loading,
onCancel,
onConfirm,
onChange,
filterOptions,
onClear,
visible,
onClose,
} = props;
const locationOptions: BubbleOption[] = [
{ id: 1, label: "室内", value: "1" },
{ id: 2, label: "室外", value: "2" },
{ id: 3, label: "半室外", value: "3" },
];
interface IProps {
onCancel: () => void;
onConfirm: () => void;
onChange: (params: Record<string, string>) => void;
loading: boolean;
filterOptions: Record<string, any>;
onClear: () => void;
}
const FilterPopup = (props: IProps) => {
const { loading, onCancel, onConfirm, onChange, filterOptions, onClear } = props;
console.log('===filterOptions', filterOptions)
const store = useListStore() || {};
const { timeBubbleData, locationOptions } = store;
const handleFilterChange = (name, value) => {
onChange({ [name]: value });
};
const handleClearFilter = () => {
onClear()
onClear();
onCancel();
}
};
return (
<>
<Popup
visible={true}
destroyOnClose
position="top"
round
closeOnOverlayClick={true}
visible={visible}
onClose={onClose}
// closeOnOverlayClick={true}
>
<div className={styles.filterPopupWrapper}>
{/* 时间气泡选项 */}
<Bubble
options={timeOptions}
options={timeBubbleData}
value={filterOptions?.time}
onChange={handleFilterChange}
layout="grid"
@@ -71,12 +63,15 @@ const FilterPopup = (props: IProps) => {
className={styles.filterPopupRange}
onChange={handleFilterChange}
value={filterOptions?.ntrp}
name='ntrp'
name="ntrp"
/>
{/* 场次气泡选项 */}
<div>
<TitleComponent title="场地类型" />
<TitleComponent
title="场地类型"
icon={<Image src={img.ICON_SITE} />}
/>
<Bubble
options={locationOptions}
value={filterOptions?.site}
@@ -84,12 +79,16 @@ const FilterPopup = (props: IProps) => {
layout="grid"
size="small"
columns={3}
name='site'
name="site"
/>
</div>
{/* 按钮 */}
<div className={styles.filterPopupBtnWrapper}>
<Button className={styles.btn} type="default" onClick={handleClearFilter}>
<Button
className={styles.btn}
type="default"
onClick={handleClearFilter}
>
</Button>
<Button

View File

@@ -1,14 +1,22 @@
.listPage {
background-color: #fafafa;
.listTopSearchWrapper {
padding: 0 15px;
}
.listTopFilterWrapper {
display: flex;
align-items: center;
padding: 5px 0 10px;
margin-top: 5px;
margin-bottom: 10px;
gap: 5px;
}
.listContentWrapper {
padding: 0 5px;
}
.menuFilter {
padding: 0;
}

View File

@@ -8,9 +8,12 @@ import styles from "./index.module.scss";
import { useEffect } from "react";
import Taro from "@tarojs/taro";
import { useListStore } from "../../store/listStore";
import { View } from "@tarojs/components";
const ListPage = () => {
// 从 store 获取数据和方法
const store = useListStore() || {};
console.log("===store===", store);
const {
isShowFilterPopup,
error,
@@ -24,7 +27,10 @@ const ListPage = () => {
updateFilterOptions, // 更新筛选条件
filterOptions,
clearFilterOptions,
} = useListStore() || {};
distanceData,
quickFilterData,
distanceQuickFilter,
} = store;
useEffect(() => {
// 页面加载时获取数据
@@ -155,29 +161,34 @@ const ListPage = () => {
updateState(params);
};
const cityOptions: BubbleOption[] = [
{ id: 0, label: "全城", value: "0" },
{ id: 1, label: "3km", value: "3" },
{ id: 2, label: "5km", value: "5" },
{ id: 3, label: "10km", value: "10" },
];
const options = [
{ text: "默认排序", value: "a" },
{ text: "好评排序", value: "b" },
{ text: "销量排序", value: "c" },
];
/**
* @description 更新筛选条件
* @param {Record<string, any>} params 筛选项
*/
const handleUpdateFilterOptions = (params: Record<string, any>) => {
updateFilterOptions(params);
};
const handleSearchChange = () => {};
// 距离筛选
const handleDistanceOrQuickChange = (name, value) => {
updateState({
distanceQuickFilter: {
...distanceQuickFilter,
[name]: value,
},
});
};
console.log("===visible", isShowFilterPopup);
return (
<div className={styles.listPage}>
<View className={styles.listPage}>
<View className={styles.listTopSearchWrapper}>
<SearchBar
handleFilterIcon={toggleShowPopup}
isSelect={filterCount > 0}
filterCount={filterCount}
onChange={handleSearchChange}
/>
{/* 综合筛选 */}
{isShowFilterPopup && (
@@ -189,6 +200,8 @@ const ListPage = () => {
onChange={handleUpdateFilterOptions}
filterOptions={filterOptions}
onClear={clearFilterOptions}
visible={isShowFilterPopup}
onClose={toggleShowPopup}
/>
</div>
)}
@@ -196,20 +209,24 @@ const ListPage = () => {
<div className={styles.listTopFilterWrapper}>
{/* 全城筛选 */}
<CityFilter
options={cityOptions}
value="1"
onChange={() => {}}
options={distanceData}
value={distanceQuickFilter?.distance}
wrapperClassName={styles.menuFilter}
onChange={handleDistanceOrQuickChange}
name="distance"
/>
{/* 智能排序 */}
<Menu
options={options}
value="a"
onChange={() => {}}
options={quickFilterData}
value={distanceQuickFilter?.quick}
onChange={handleDistanceOrQuickChange}
wrapperClassName={styles.menuFilter}
name="quick"
/>
</div>
</View>
<View className={styles.listContentWrapper}>
{/* 列表内容 */}
<List>
{matches.map((match, index) => (
@@ -246,7 +263,8 @@ const ListPage = () => {
</button>
</div>
)}
</div>
</View>
</View>
);
};

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 6L8 10L4 6" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 215 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 10L8 6L12 10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.33325 4L10.3333 8L6.33325 12" stroke="#3C3C43" stroke-opacity="0.6" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 255 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.16663 9.99999L8.33329 14.1667L16.6666 5.83333" stroke="#333333" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 251 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.00016 14.6666C11.6821 14.6666 14.6668 11.6819 14.6668 7.99998C14.6668 4.31808 11.6821 1.33331 8.00016 1.33331C4.31826 1.33331 1.3335 4.31808 1.3335 7.99998C1.3335 11.6819 4.31826 14.6666 8.00016 14.6666Z" stroke="black" stroke-width="1.33333"/>
<path d="M8.00016 1.33331C7.96653 3.55605 7.4208 5.22318 6.36296 6.33478C5.3051 7.44635 3.6286 8.00235 1.3335 8.00275" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.6561 8.33505C12.4852 8.18615 10.8265 8.65015 9.67983 9.72705C8.53313 10.804 7.97353 12.4505 8.00096 14.6666" stroke="black" stroke-width="1.33333" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 751 B

View File

@@ -0,0 +1,6 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.6668 3.33331H1.3335V13.3333H14.6668V3.33331Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
<path d="M3.3335 5.33331L12.6668 11.3333" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.6667 5.33331L8 11.6666" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8.00016 5.33331L3.3335 11.3333" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@@ -1,53 +1,6 @@
import { create } from 'zustand'
import { getTennisMatches } from '../services/listApi'
// 网球比赛数据接口
export interface TennisMatch {
id: string
title: string
dateTime: string
location: string
distance: string
registeredCount: number
maxCount: number
skillLevel: string
matchType: string
images: string[]
}
// Store 状态接口
interface ListState {
matches: TennisMatch[]
loading: boolean
error: string | null
lastRefreshTime: string | null
isShowFilterPopup: boolean
filterOptions: IFilterOptions
filterCount: number
}
interface IFilterOptions {
location: string
time: string
ntrp: [number, number]
site: string
wanfa: string
}
// Store Actions 接口
interface ListActions {
fetchMatches: (params?: {
page?: number
pageSize?: number
location?: string
skillLevel?: string
}) => Promise<void>
refreshMatches: () => Promise<void>
clearError: () => void
updateState: (payload: Record<string, any>) => void
updateFilterOptions: (payload: Record<string, any>) => void
clearFilterOptions: () => void
}
import {ListActions, IFilterOptions, ListState } from '../../types/list/types'
// 完整的 Store 类型
type TennisStore = ListState & ListActions
@@ -60,6 +13,8 @@ const defaultFilterOptions: IFilterOptions = {
wanfa: '', // 玩法
};
const defaultDistance = 'all'; // 默认距离
// 创建 store
export const useListStore = create<TennisStore>()((set, get) => ({
// 初始状态
@@ -73,6 +28,43 @@ export const useListStore = create<TennisStore>()((set, get) => ({
filterOptions: defaultFilterOptions,
// 综合筛选 选择的筛选数量
filterCount: 0,
// 距离筛选
distance: defaultDistance,
// 快捷筛选
quickFilter: 1, // 1: 默认 2: 好评 3: 销量
// 距离筛选数据
distanceData: [
{ id: 0, label: "全城", value: "全城" },
{ id: 1, label: "3km", value: "3km" },
{ id: 2, label: "5km", value: "5km" },
{ id: 3, label: "10km", value: "10km" },
],
// 快捷筛选数据
quickFilterData:[
{ text: "默认排序", value: "0" },
{ text: "好评排序", value: "1" },
{ text: "销量排序", value: "2" },
],
// 距离筛选和快捷筛选
distanceQuickFilter: {
distance: '全城',
quick: '0',
},
// 时间气泡数据
timeBubbleData: [
{ id: 1, label: "晨间 6:00-10:00", value: "1" },
{ id: 2, label: "上午 10:00-12:00", value: "2" },
{ id: 3, label: "中午 12:00-14:00", value: "3" },
{ id: 4, label: "下午 14:00-18:00", value: "4" },
{ id: 5, label: "晚上 18:00-22:00", value: "5" },
{ id: 6, label: "夜间 22:00-24:00", value: "6" },
],
// 场地类型数据
locationOptions: [
{ id: 1, label: "室内", value: "1" },
{ id: 2, label: "室外", value: "2" },
{ id: 3, label: "半室外", value: "3" },
],
// 获取比赛数据
fetchMatches: async (params) => {
@@ -87,12 +79,12 @@ export const useListStore = create<TennisStore>()((set, get) => ({
})
console.log('Store: 成功获取网球比赛数据:', matches.length, '条')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
set({
error: errorMessage,
loading: false
})
console.error('Store: 获取网球比赛数据失败:', errorMessage)
// const errorMessage = error instanceof Error ? error.message : '未知错误'
// set({
// error: errorMessage,
// loading: false
// })
// console.error('Store: 获取网球比赛数据失败:', errorMessage)
}
},
@@ -109,12 +101,12 @@ export const useListStore = create<TennisStore>()((set, get) => ({
})
console.log('Store: 成功刷新网球比赛数据:', matches.length, '条')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
set({
error: errorMessage,
loading: false
})
console.error('Store: 刷新网球比赛数据失败:', errorMessage)
// const errorMessage = error instanceof Error ? error.message : '未知错误'
// set({
// error: errorMessage,
// loading: false
// })
// console.error('Store: 刷新网球比赛数据失败:', errorMessage)
}
},

141
types/list/types.ts Normal file
View File

@@ -0,0 +1,141 @@
// 网球比赛数据接口
export interface TennisMatch {
id: string
title: string
dateTime: string
location: string
distance: string
registeredCount: number
maxCount: number
skillLevel: string
matchType: string
images: string[]
}
export interface IFilterOptions {
location: string
time: string
ntrp: [number, number]
site: string
wanfa: string
}
export interface ListState {
matches: TennisMatch[]
loading: boolean
error: string | null
lastRefreshTime: string | null
isShowFilterPopup: boolean
filterOptions: IFilterOptions
filterCount: number
distance: string | number
quickFilter: string | number
distanceData: any[]
quickFilterData: any[]
distanceQuickFilter: {
distance: string
quick: string
}
timeBubbleData: BubbleOption[]
locationOptions: BubbleOption[]
}
export interface ListState {
matches: TennisMatch[]
loading: boolean
error: string | null
lastRefreshTime: string | null
isShowFilterPopup: boolean
filterOptions: IFilterOptions
filterCount: number
distance: string | number
quickFilter: string | number
distanceData: any[]
quickFilterData: any[]
distanceQuickFilter: {
distance: string
quick: string
}
}
export interface ListActions {
fetchMatches: (params?: {
page?: number
pageSize?: number
location?: string
skillLevel?: string
}) => Promise<void>
refreshMatches: () => Promise<void>
clearError: () => void
updateState: (payload: Record<string, any>) => void
updateFilterOptions: (payload: Record<string, any>) => void
clearFilterOptions: () => void
}
// 快捷筛选
export interface MenuFilterProps {
options: { text: string; value: string }[];
value: string;
onChange: (name: string, value: string) => void;
wrapperClassName?: string;
itemClassName?: string;
name: string;
}
// 距离筛选
export interface DistanceFilterProps {
options: BubbleOption[];
value: string;
onChange: (name: string, value: string) => void;
wrapperClassName?: string;
itemClassName?: string;
name: string;
}
// bubble 组件
export interface BubbleOption {
id: string | number;
label: string;
value: string | number;
disabled?: boolean;
icon?: React.ReactNode;
description?: string;
}
export interface BubbleProps {
options: BubbleOption[];
value?: string | number | (string | number)[];
onChange?: (
name: string,
value: string | number | (string | number)[],
option: BubbleOption | BubbleOption[]
) => void;
multiple?: boolean;
layout?: "horizontal" | "vertical" | "grid";
columns?: number;
size?: "small" | "medium" | "large";
className?: string;
itemClassName?: string;
style?: React.CSSProperties;
disabled?: boolean;
name: string;
}
export interface BubbleItemProps {
option: BubbleOption;
isSelected: boolean;
size: 'small' | 'medium' | 'large';
disabled: boolean;
onClick: (option: BubbleOption) => void;
itemClassName?: string;
}
// FilterPopup 组件
export interface FilterPopupProps {
onCancel: () => void;
onConfirm: () => void;
onChange: (params: Record<string, string>) => void;
loading: boolean;
filterOptions: Record<string, any>;
onClear: () => void;
visible: boolean;
onClose: () => void;
}