通用组件开发
This commit is contained in:
@@ -2,21 +2,42 @@
|
|||||||
"miniprogramRoot": "dist/",
|
"miniprogramRoot": "dist/",
|
||||||
"projectname": "playBallTogether",
|
"projectname": "playBallTogether",
|
||||||
"description": "playBallTogether",
|
"description": "playBallTogether",
|
||||||
"appid": "touristappid",
|
"appid": "wx815b533167eb7b53",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": true,
|
||||||
"es6": false,
|
"es6": true,
|
||||||
"enhance": false,
|
"enhance": true,
|
||||||
"compileHotReLoad": false,
|
|
||||||
"postcss": false,
|
"postcss": false,
|
||||||
"preloadBackgroundData": false,
|
"preloadBackgroundData": false,
|
||||||
"minified": false,
|
"minified": false,
|
||||||
"newFeature": true,
|
"newFeature": true,
|
||||||
"autoAudits": false,
|
|
||||||
"coverView": true,
|
"coverView": true,
|
||||||
|
"nodeModules": false,
|
||||||
|
"autoAudits": false,
|
||||||
"showShadowRootInWxmlPanel": false,
|
"showShadowRootInWxmlPanel": false,
|
||||||
"scopeDataCheck": false,
|
"scopeDataCheck": false,
|
||||||
"useCompilerModule": false
|
"uglifyFileName": false,
|
||||||
|
"checkInvalidKey": true,
|
||||||
|
"checkSiteMap": true,
|
||||||
|
"uploadWithSourceMap": true,
|
||||||
|
"compileHotReLoad": false,
|
||||||
|
"useMultiFrameRuntime": true,
|
||||||
|
"useApiHook": true,
|
||||||
|
"useApiHostProcess": false,
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"enableEngineNative": false,
|
||||||
|
"useIsolateContext": true,
|
||||||
|
"useCompilerModule": false,
|
||||||
|
"userConfirmedUseCompilerModuleSwitch": false,
|
||||||
|
"userConfirmedBundleSwitch": false,
|
||||||
|
"packNpmManually": false,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"minifyWXSS": true,
|
||||||
|
"minifyWXML": true
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram",
|
"compileType": "miniprogram",
|
||||||
"simulatorType": "wechat",
|
"simulatorType": "wechat",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
pages: [
|
pages: [
|
||||||
'pages/index/index'
|
'pages/index/index',
|
||||||
|
'pages/list/index'
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: 'light',
|
backgroundTextStyle: 'light',
|
||||||
|
|||||||
35
src/components/Bubble/BubbleItem.tsx
Normal file
35
src/components/Bubble/BubbleItem.tsx
Normal 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;
|
||||||
208
src/components/Bubble/README.md
Normal file
208
src/components/Bubble/README.md
Normal 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` 可以是字符串或数字类型
|
||||||
|
- 组件会自动处理选中状态的样式变化
|
||||||
|
- 支持图标和描述,让选项更加丰富
|
||||||
|
- 响应式设计,自动适应不同屏幕尺寸
|
||||||
211
src/components/Bubble/USAGE.md
Normal file
211
src/components/Bubble/USAGE.md
Normal 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` 属性禁用整个组件或单个选项
|
||||||
96
src/components/Bubble/bubbleItem.scss
Normal file
96
src/components/Bubble/bubbleItem.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
235
src/components/Bubble/example.tsx
Normal file
235
src/components/Bubble/example.tsx
Normal 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;
|
||||||
35
src/components/Bubble/index.scss
Normal file
35
src/components/Bubble/index.scss
Normal 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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/components/Bubble/index.tsx
Normal file
152
src/components/Bubble/index.tsx
Normal 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;
|
||||||
7
src/components/List/index.scss
Normal file
7
src/components/List/index.scss
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.list {
|
||||||
|
background: #fafafa;
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
16
src/components/List/index.tsx
Normal file
16
src/components/List/index.tsx
Normal 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
|
||||||
207
src/components/ListItem/index.scss
Normal file
207
src/components/ListItem/index.scss
Normal 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;
|
||||||
|
}
|
||||||
121
src/components/ListItem/index.tsx
Normal file
121
src/components/ListItem/index.tsx
Normal 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
|
||||||
93
src/components/Range/README.md
Normal file
93
src/components/Range/README.md
Normal 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` 文件获取更多使用示例。
|
||||||
85
src/components/Range/example.tsx
Normal file
85
src/components/Range/example.tsx
Normal 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;
|
||||||
246
src/components/Range/index.scss
Normal file
246
src/components/Range/index.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/components/Range/index.tsx
Normal file
91
src/components/Range/index.tsx
Normal 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;
|
||||||
22
src/components/Range/simple-test.tsx
Normal file
22
src/components/Range/simple-test.tsx
Normal 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;
|
||||||
68
src/components/Range/style-test.tsx
Normal file
68
src/components/Range/style-test.tsx
Normal 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;
|
||||||
35
src/components/Range/test.tsx
Normal file
35
src/components/Range/test.tsx
Normal 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;
|
||||||
5
src/pages/list/index.config.ts
Normal file
5
src/pages/list/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '列表',
|
||||||
|
enablePullDownRefresh: true,
|
||||||
|
backgroundTextStyle: 'dark'
|
||||||
|
})
|
||||||
0
src/pages/list/index.scss
Normal file
0
src/pages/list/index.scss
Normal file
209
src/pages/list/index.tsx
Normal file
209
src/pages/list/index.tsx
Normal 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
223
src/services/listApi.ts
Normal 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
108
src/store/listStore.ts
Normal 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
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user