通用组件开发

This commit is contained in:
juguohong
2025-08-17 00:00:56 +08:00
parent 86e14cb445
commit 4f6ca73148
25 changed files with 2554 additions and 24 deletions

View File

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

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { BubbleOption } from './index';
import './bubbleItem.scss';
export interface BubbleItemProps {
option: BubbleOption;
isSelected: boolean;
size: 'small' | 'medium' | 'large';
disabled: boolean;
onClick: (option: BubbleOption) => void;
}
const BubbleItem: React.FC<BubbleItemProps> = ({
option,
isSelected,
size,
disabled,
onClick
}) => {
return (
<button
className={`bubble-option ${size} ${isSelected ? 'selected' : ''} ${
option.disabled || disabled ? 'disabled' : ''
}`}
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>}
</button>
);
};
export default BubbleItem;

View File

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

@@ -0,0 +1,211 @@
# 如何使用 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

@@ -0,0 +1,96 @@
.bubble-option {
position: relative;
border: 1px solid #e5e5e5;
outline: none; // 移除浏览器默认的outline
background: #ffffff;
color: #333333;
font-size: 14px;
font-weight: 400;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
white-space: nowrap;
padding: 0;
font-size: 14px;
border-radius: 28px;
margin: 0;
height: 36px;
// 移除浏览器默认样式
&:focus {
outline: none;
border: none;
}
&::after {
outline: none;
border: none;
}
&:active {
outline: none;
border: none;
}
// 尺寸变体
&.small {
padding: 8px 12px;
min-height: 32px;
font-size: 12px;
}
&.medium {
padding: 12px 16px;
min-height: 44px;
font-size: 14px;
}
&.large {
padding: 16px 20px;
min-height: 56px;
font-size: 16px;
}
&.selected {
background: #000000;
color: #ffffff;
}
&.disabled {
background: #f5f5f5;
color: #999999;
cursor: not-allowed;
&:hover {
background: #f5f5f5;
}
}
// 图标样式
.bubble-icon {
display: flex;
align-items: center;
justify-content: center;
svg {
width: 16px;
height: 16px;
}
}
// 标签样式
.bubble-label {
font-weight: 500;
}
// 描述样式
.bubble-description {
font-size: 12px;
opacity: 0.7;
font-weight: 400;
}
}

View File

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

@@ -0,0 +1,35 @@
.bubble-container {
width: 100%;
// 水平布局
.bubble-horizontal {
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 {
display: flex;
flex-direction: column;
gap: 12px;
.bubble-option {
width: 100%;
}
}
// 网格布局
.bubble-grid {
display: grid;
width: 100%;
}
}

View File

@@ -0,0 +1,152 @@
import React, { useState, useEffect } from 'react';
import './index.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?: (value: string | number | (string | number)[], option: BubbleOption | BubbleOption[]) => void;
multiple?: boolean;
layout?: 'horizontal' | 'vertical' | 'grid';
columns?: number;
size?: 'small' | 'medium' | 'large';
className?: string;
style?: React.CSSProperties;
disabled?: boolean;
}
const Bubble: React.FC<BubbleProps> = ({
options,
value,
onChange,
multiple = false,
layout = 'horizontal',
columns = 3,
size = 'small',
className = '',
style = {},
disabled = false
}) => {
const [selectedValues, setSelectedValues] = useState<(string | number)[]>([]);
// 同步外部传入的value
useEffect(() => {
if (value !== undefined) {
const newValues = Array.isArray(value) ? value : [value];
setSelectedValues(newValues);
}
}, [value]);
const handleOptionClick = (option: BubbleOption) => {
if (disabled || option.disabled) return;
let newSelectedValues: (string | number)[];
if (multiple) {
if (selectedValues.includes(option.value)) {
newSelectedValues = selectedValues.filter(v => v !== option.value);
} else {
newSelectedValues = [...selectedValues, option.value];
}
} else {
newSelectedValues = [option.value];
}
setSelectedValues(newSelectedValues);
// 调用onChange回调传递选中的值和对应的选项
if (onChange) {
if (multiple) {
const selectedOptions = options.filter(opt => newSelectedValues.includes(opt.value));
onChange(newSelectedValues, selectedOptions);
} else {
onChange(option.value, option);
}
}
};
const isSelected = (option: BubbleOption) => {
return selectedValues.includes(option.value);
};
const renderHorizontalLayout = () => (
<div className="bubble-horizontal">
{options.map((option) => (
<BubbleItem
key={option.id}
option={option}
isSelected={isSelected(option)}
size={size}
disabled={disabled}
onClick={handleOptionClick}
/>
))}
</div>
);
const renderVerticalLayout = () => (
<div className="bubble-vertical">
{options.map((option) => (
<BubbleItem
key={option.id}
option={option}
isSelected={isSelected(option)}
size={size}
disabled={disabled}
onClick={handleOptionClick}
/>
))}
</div>
);
const renderGridLayout = () => (
<div
className="bubble-grid"
style={{
gridTemplateColumns: `repeat(${columns}, 1fr)`,
gap: size === 'small' ? '8px' : size === 'large' ? '16px' : '12px'
}}
>
{options.map((option) => (
<BubbleItem
key={option.id}
option={option}
isSelected={isSelected(option)}
size={size}
disabled={disabled}
onClick={handleOptionClick}
/>
))}
</div>
);
const renderLayout = () => {
switch (layout) {
case 'horizontal':
return renderHorizontalLayout();
case 'vertical':
return renderVerticalLayout();
case 'grid':
return renderGridLayout();
default:
return renderHorizontalLayout();
}
};
return (
<div className={`bubble-container ${className}`} style={style}>
{renderLayout()}
</div>
);
};
export default Bubble;

View File

@@ -0,0 +1,7 @@
.list {
background: #fafafa;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 5px;
}

View File

@@ -0,0 +1,16 @@
import { View } from '@tarojs/components'
import './index.scss'
interface ListProps {
children: React.ReactNode
}
const List: React.FC<ListProps> = ({ children }) => {
return (
<View className="list">
{children}
</View>
)
}
export default List

View File

@@ -0,0 +1,207 @@
.list-item {
display: flex;
padding: 16px;
background: #ffffff;
border-radius: 20px;
border: 0.5px solid #f0f0f0;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.title {
font-size: 16px;
font-weight: 600;
color: #333333;
line-height: 1.4;
}
.date-time {
font-size: 14px;
color: #666666;
line-height: 1.3;
}
.location {
font-size: 14px;
color: #666666;
line-height: 1.3;
}
.bottom-info {
display: flex;
align-items: center;
margin-top: 4px;
column-gap: 4px;
}
.left-section {
display: flex;
align-items: center;
gap: 8px;
}
.avatar-group {
display: flex;
align-items: center;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 50%;
background: #e0e0e0;
border: 2px solid #ffffff;
margin-left: -8px;
overflow: hidden;
.avatar-image {
width: 100%;
height: 100%;
object-fit: cover;
}
&:first-child {
margin-left: 0;
z-index: 3;
}
&:nth-child(2) {
z-index: 2;
}
&:nth-child(3) {
z-index: 1;
}
}
.registration-text {
font-size: 12px;
color: #999999;
}
.tags {
display: flex;
gap: 4px;
}
.tag {
padding: 3px;
border: 1px solid #f5f5f5;
border-radius: 20px;
min-width: 38px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #000000;
font-size: 12px;
}
.tag-text-max {
color: #666666;
}
.image-section {
width: 100px;
height: 100px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
.image-container {
width: 100%;
height: 100%;
padding: 2px;
background: #ffffff;
border-radius: 10px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
overflow: hidden;
position: absolute;
.image {
border-radius: 10px;
}
}
}
.single-image {
position: relative;
width: 88px;
height: 88px;
.image-container {
transform: rotate(-10deg);
}
}
.double-image {
width: 100%;
height: 100%;
position: relative;
.image-container {
width: 60%;
height: 60%;
position: absolute;
overflow: hidden;
top: 20%;
&:first-child {
z-index: 2;
transform: rotate(-10deg);
}
&:last-child {
right: 0;
z-index: 1;
transform: rotate(10deg);
}
}
}
.triple-image {
width: 100%;
height: 100%;
position: relative;
.image-container {
position: absolute;
overflow: hidden;
&:nth-child(1) {
bottom: 0;
left: 0;
width: 55px;
height: 55px;
z-index: 3;
transform: rotate(-10deg);
}
&:nth-child(2) {
bottom: 10px;
right: 0;
width: 55px;
height: 55px;
z-index: 2;
transform: rotate(3deg);
}
&:nth-child(3) {
top: 5%;
left: 50%;
width: 100rpx;
height: 100rpx;
z-index: 1;
transform: translateX(-50%);
}
}
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -0,0 +1,121 @@
import { View, Text, Image } from '@tarojs/components'
import './index.scss'
interface ListItemProps {
title: string
dateTime: string
location: string
distance: string
registeredCount: number
maxCount: number
skillLevel: string
matchType: string
images: string[]
}
const ListItem: React.FC<ListItemProps> = ({
title,
dateTime,
location,
distance,
registeredCount,
maxCount,
skillLevel,
matchType,
images
}) => {
// 根据图片数量决定展示样式
const renderImages = () => {
if (images.length === 0) return null
if (images.length === 1) {
return (
<View className="single-image">
<View className="image-container">
<Image src={images[0]} className="image" mode="aspectFill" />
</View>
</View>
)
}
if (images.length === 2) {
return (
<View className="double-image">
<View className="image-container">
<Image src={images[0]} className="image" mode="aspectFill" />
</View>
<View className="image-container">
<Image src={images[1]} className="image" mode="aspectFill" />
</View>
</View>
)
}
// 3张或更多图片
return (
<View className="triple-image">
<View className="image-container">
<Image src={images[0]} className="image" mode="aspectFill" />
</View>
<View className="image-container">
<Image src={images[1]} className="image" mode="aspectFill" />
</View>
<View className="image-container">
<Image src={images[2]} className="image" mode="aspectFill" />
</View>
</View>
)
}
return (
<View className="list-item">
{/* 左侧内容区域 */}
<View className="content">
{/* 标题 */}
<Text className="title">{title}</Text>
{/* 时间信息 */}
<Text className="date-time">{dateTime}</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) => (
<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" />
</View>
))}
</View>
</View>
<View className="tags">
<View className="tag">
<Text className="tag-text">
{registeredCount}/<Text className="tag-text-max">{maxCount}</Text>
</Text>
</View>
<View className="tag">
<Text className="tag-text">{skillLevel}</Text>
</View>
<View className="tag">
<Text className="tag-text">{matchType}</Text>
</View>
</View>
</View>
</View>
{/* 右侧图片区域 */}
<View className="image-section">
{renderImages()}
</View>
</View>
)
}
export default ListItem

View File

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

@@ -0,0 +1,85 @@
/*
* @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

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

@@ -0,0 +1,91 @@
import React, { useState, useEffect, useMemo } from "react";
import { Range } from "@nutui/nutui-react-taro";
import "./index.scss";
interface RangeProps {
min?: number;
max?: number;
step?: number;
value?: [number, number];
onChange?: (value: [number, number]) => void;
disabled?: boolean;
}
const NtrpRange: React.FC<RangeProps> = ({
min = 1.0,
max = 5.0,
step = 0.5,
value = [min, max],
onChange,
disabled = false,
}) => {
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = (val: [number, number]) => {
console.log("Range value changed:", val);
setCurrentValue(val);
onChange?.(val);
};
const marks = useMemo(() => {
let marksMap = {};
for (let i = min + step; i < max; i += step) {
marksMap[i] = "";
}
return marksMap;
}, [min, max, step]);
const rangContent = useMemo(() => {
const [start, end] = currentValue || [];
if (start === min && end === max) {
return "不限";
}
return `${start.toFixed(1)} - ${end.toFixed(1)}之间`;
}, [currentValue, min, max]);
return (
<div className="ntrp-range">
<div className="ntrp-range__header">
<div className="ntrp-range__header-left">
<div className="ntrp-range__icon">icon</div>
<h3 className="ntrp-range__title">NTRP水平区间</h3>
</div>
<p className="ntrp-range__content">{rangContent}</p>
</div>
<div>
<div className="rangeWrapper">
<span className="rangeWrapper__min">{min}</span>
<Range
range
min={min}
max={max}
step={step}
value={currentValue}
onChange={handleChange}
disabled={disabled}
defaultValue={[min, max]}
className="rangeHandle"
maxDescription={null}
minDescription={null}
currentDescription={null}
marks={marks}
style={{ color: "gold" }}
/>
<span className="rangeWrapper__max">{max}</span>
</div>
</div>
{/* 调试信息 */}
<div style={{ marginTop: "10px", fontSize: "12px", color: "#666" }}>
: {currentValue[0].toFixed(1)} - {currentValue[1].toFixed(1)}
</div>
</div>
);
};
export default NtrpRange;

View File

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

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

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

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '列表',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark'
})

View File

209
src/pages/list/index.tsx Normal file
View File

@@ -0,0 +1,209 @@
import ListItem from "../../components/ListItem";
import List from "../../components/List";
import Bubble from "../../components/Bubble/example";
import Range from "../../components/Range/example";
import "./index.scss";
import { useEffect } from "react";
import Taro from "@tarojs/taro";
import {
useTennisMatches,
useTennisLoading,
useTennisError,
useTennisLastRefresh,
useTennisActions,
} from "../../store/listStore";
const ListPage = () => {
// 从 store 获取数据和方法
const matches = useTennisMatches();
const loading = useTennisLoading();
const error = useTennisError();
const lastRefreshTime = useTennisLastRefresh();
const { fetchMatches, refreshMatches, clearError } = useTennisActions();
useEffect(() => {
// 页面加载时获取数据
fetchMatches();
}, [fetchMatches]);
// 下拉刷新处理函数 - 使用Taro生命周期钩子
Taro.usePullDownRefresh(() => {
console.log("触发下拉刷新");
// 调用 store 的刷新方法
refreshMatches()
.then(() => {
// 刷新完成后停止下拉刷新动画
Taro.stopPullDownRefresh();
// 显示刷新成功提示
Taro.showToast({
title: "刷新成功",
icon: "success",
duration: 1500,
});
})
.catch(() => {
// 刷新失败时也停止动画
Taro.stopPullDownRefresh();
Taro.showToast({
title: "刷新失败",
icon: "error",
duration: 1500,
});
});
});
// 错误处理
useEffect(() => {
if (error) {
Taro.showToast({
title: error,
icon: "error",
duration: 2000,
});
// 3秒后自动清除错误
setTimeout(() => {
clearError();
}, 3000);
}
}, [error, clearError]);
// 格式化时间显示
const formatRefreshTime = (timeString: string | null) => {
if (!timeString) return "";
const date = new Date(timeString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
return date.toLocaleDateString();
};
// 加载状态显示
if (loading && matches.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
}}
>
<div style={{ marginBottom: "10px" }}>...</div>
<div style={{ fontSize: "12px", color: "#ccc" }}>
</div>
</div>
);
}
// 错误状态显示
if (error && matches.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
}}
>
<div style={{ marginBottom: "10px" }}></div>
<div style={{ marginBottom: "15px", fontSize: "12px", color: "#ccc" }}>
{error}
</div>
<button
onClick={() => fetchMatches()}
style={{
padding: "8px 16px",
fontSize: "12px",
color: "#fff",
backgroundColor: "#007aff",
border: "none",
borderRadius: "4px",
}}
>
</button>
</div>
);
}
return (
<div>
{/* 状态信息栏 */}
{lastRefreshTime && (
<div
style={{
padding: "8px 16px",
fontSize: "12px",
color: "#999",
backgroundColor: "#f8f8f8",
borderBottom: "1px solid #eee",
}}
>
: {formatRefreshTime(lastRefreshTime)} | {matches.length}{" "}
</div>
)}
{/* 范围选择 */}
<Range />
{/* 气泡 */}
<Bubble />
{/* 列表内容 */}
<List>
{matches.map((match, index) => (
<ListItem key={match.id || index} {...match} />
))}
</List>
{/* 空状态 */}
{!loading && matches.length === 0 && (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
}}
>
<div style={{ marginBottom: "10px" }}></div>
<button
onClick={() => fetchMatches()}
style={{
padding: "8px 16px",
fontSize: "12px",
color: "#fff",
backgroundColor: "#007aff",
border: "none",
borderRadius: "4px",
}}
>
</button>
</div>
)}
</div>
);
};
export default ListPage;

223
src/services/listApi.ts Normal file
View File

@@ -0,0 +1,223 @@
import { TennisMatch } from '../store/listStore'
// 模拟网络延迟
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
// 模拟API响应格式
interface ApiResponse<T> {
code: number
message: string
data: T
timestamp: number
}
// 模拟网球比赛数据
const mockTennisMatches: TennisMatch[] = [
{
id: '1',
title: '周一晚场浦东新区单打约球',
dateTime: '明天(周五)下午5点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
registeredCount: 3,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
images: [
'https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center'
]
},
{
id: '2',
title: '浦东新区单打约球',
dateTime: '明天(周五)下午5点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
registeredCount: 2,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
images: [
'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center'
]
},
{
id: '3',
title: '黄浦区双打约球',
dateTime: '7月20日(周日)下午6点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
registeredCount: 3,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
images: [
'https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center'
]
}
]
// 模拟数据变化
const generateDynamicData = (): TennisMatch[] => {
return mockTennisMatches.map(match => ({
...match,
// 随机更新注册人数
registeredCount: Math.min(
match.maxCount,
Math.max(0, match.registeredCount + Math.floor(Math.random() * 3) - 1)
),
// 随机更新距离
distance: `${(Math.random() * 5 + 1).toFixed(1)}km`,
// 随机更新时间
dateTime: Math.random() > 0.5 ? match.dateTime : '今天下午3点 2小时'
}))
}
// 模拟网络错误
const simulateNetworkError = (): boolean => {
// 10% 概率模拟网络错误
return Math.random() < 0.1
}
// 模拟网络超时
const simulateTimeout = (): boolean => {
// 5% 概率模拟超时
return Math.random() < 0.05
}
/**
* 获取网球比赛列表
* @param params 查询参数
* @returns Promise<TennisMatch[]>
*/
export const getTennisMatches = async (params?: {
page?: number
pageSize?: number
location?: string
skillLevel?: string
}): Promise<TennisMatch[]> => {
try {
console.log('API调用: getTennisMatches', params)
// 模拟网络延迟 (800-1500ms)
const delayTime = 800 + Math.random() * 700
await delay(delayTime)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('网络连接失败,请检查网络设置')
}
// 模拟超时
if (simulateTimeout()) {
throw new Error('请求超时,请稍后重试')
}
// 生成动态数据
const matches = generateDynamicData()
// 模拟分页
if (params?.page && params?.pageSize) {
const start = (params.page - 1) * params.pageSize
const end = start + params.pageSize
return matches.slice(start, end)
}
// 模拟筛选
if (params?.location) {
return matches.filter(match =>
match.location.includes(params.location!)
)
}
if (params?.skillLevel) {
return matches.filter(match =>
match.skillLevel.includes(params.skillLevel!)
)
}
console.log('API响应成功:', matches.length, '条数据')
return matches
} catch (error) {
console.error('API调用失败:', error)
throw error
}
}
/**
* 刷新网球比赛数据
* @returns Promise<TennisMatch[]>
*/
export const refreshTennisMatches = async (): Promise<TennisMatch[]> => {
try {
console.log('API调用: refreshTennisMatches')
// 模拟刷新延迟 (500-1000ms)
const delayTime = 500 + Math.random() * 500
await delay(delayTime)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('刷新失败,请稍后重试')
}
// 生成新的动态数据
const matches = generateDynamicData()
console.log('API刷新成功:', matches.length, '条数据')
return matches
} catch (error) {
console.error('API刷新失败:', error)
throw error
}
}
/**
* 获取比赛详情
* @param id 比赛ID
* @returns Promise<TennisMatch | null>
*/
export const getTennisMatchDetail = async (id: string): Promise<TennisMatch | null> => {
try {
console.log('API调用: getTennisMatchDetail', id)
// 模拟网络延迟
await delay(600 + Math.random() * 400)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('获取详情失败,请稍后重试')
}
const match = mockTennisMatches.find(m => m.id === id)
if (!match) {
throw new Error('比赛不存在')
}
console.log('API获取详情成功:', match.title)
return match
} catch (error) {
console.error('API获取详情失败:', error)
throw error
}
}
/**
* 模拟API统计信息
*/
export const getApiStats = () => {
return {
totalCalls: 0,
successRate: 0.95,
averageResponseTime: 800,
lastCallTime: new Date().toISOString()
}
}

108
src/store/listStore.ts Normal file
View File

@@ -0,0 +1,108 @@
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 TennisState {
matches: TennisMatch[]
loading: boolean
error: string | null
lastRefreshTime: string | null
}
// Store Actions 接口
interface TennisActions {
fetchMatches: (params?: {
page?: number
pageSize?: number
location?: string
skillLevel?: string
}) => Promise<void>
refreshMatches: () => Promise<void>
clearError: () => void
}
// 完整的 Store 类型
type TennisStore = TennisState & TennisActions
// 创建 store
export const useTennisStore = create<TennisStore>()((set, get) => ({
// 初始状态
matches: [],
loading: false,
error: null,
lastRefreshTime: null,
// 获取比赛数据
fetchMatches: async (params) => {
set({ loading: true, error: null })
try {
const matches = await getTennisMatches(params)
set({
matches,
loading: false,
lastRefreshTime: new Date().toISOString()
})
console.log('Store: 成功获取网球比赛数据:', matches.length, '条')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
set({
error: errorMessage,
loading: false
})
console.error('Store: 获取网球比赛数据失败:', errorMessage)
}
},
// 刷新比赛数据
refreshMatches: async () => {
set({ loading: true, error: null })
try {
const matches = await getTennisMatches()
set({
matches,
loading: false,
lastRefreshTime: new Date().toISOString()
})
console.log('Store: 成功刷新网球比赛数据:', matches.length, '条')
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '未知错误'
set({
error: errorMessage,
loading: false
})
console.error('Store: 刷新网球比赛数据失败:', errorMessage)
}
},
// 清除错误信息
clearError: () => {
set({ error: null })
}
}))
// 导出便捷的 hooks
export const useTennisMatches = () => useTennisStore((state) => state.matches)
export const useTennisLoading = () => useTennisStore((state) => state.loading)
export const useTennisError = () => useTennisStore((state) => state.error)
export const useTennisLastRefresh = () => useTennisStore((state) => state.lastRefreshTime)
export const useTennisActions = () => useTennisStore((state) => ({
fetchMatches: state.fetchMatches,
refreshMatches: state.refreshMatches,
clearError: state.clearError
}))