Merge remote-tracking branch 'origin' into feat/liujie
@@ -3,6 +3,8 @@ import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'
|
||||
import devConfig from './dev'
|
||||
import prodConfig from './prod'
|
||||
import vitePluginImp from 'vite-plugin-imp'
|
||||
import path from 'path'
|
||||
|
||||
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
|
||||
export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
const baseConfig: UserConfigExport<'webpack5'> = {
|
||||
@@ -20,6 +22,16 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
plugins: ['@tarojs/plugin-html'],
|
||||
defineConstants: {
|
||||
},
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '..', 'src'),
|
||||
'@/assets': path.resolve(__dirname, '..', 'src/assets'),
|
||||
'@/components': path.resolve(__dirname, '..', 'src/components'),
|
||||
'@/utils': path.resolve(__dirname, '..', 'src/utils'),
|
||||
'@/services': path.resolve(__dirname, '..', 'src/services'),
|
||||
'@/store': path.resolve(__dirname, '..', 'src/store'),
|
||||
'@/config': path.resolve(__dirname, '..', 'src/config'),
|
||||
'@/static': path.resolve(__dirname, '..', 'src/static'),
|
||||
},
|
||||
copy: {
|
||||
patterns: [
|
||||
],
|
||||
@@ -29,7 +41,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
framework: 'react',
|
||||
compiler: {
|
||||
|
||||
type: 'webpack5',
|
||||
type: 'webpack5',
|
||||
prebundle: {
|
||||
enable: false
|
||||
}
|
||||
@@ -46,7 +58,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
}
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
enable: true, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]'
|
||||
@@ -75,7 +87,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
config: {}
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
enable: true, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]'
|
||||
|
||||
@@ -5,45 +5,42 @@
|
||||
"appid": "wx815b533167eb7b53",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": false,
|
||||
"es6": true,
|
||||
"enhance": true,
|
||||
"postcss": false,
|
||||
"preloadBackgroundData": false,
|
||||
"minified": false,
|
||||
"newFeature": true,
|
||||
"autoAudits": false,
|
||||
"coverView": true,
|
||||
"nodeModules": false,
|
||||
"autoAudits": false,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"scopeDataCheck": false,
|
||||
"useCompilerModule": false,
|
||||
"compileWorklet": false,
|
||||
"uglifyFileName": false,
|
||||
"checkInvalidKey": true,
|
||||
"checkSiteMap": true,
|
||||
"uploadWithSourceMap": true,
|
||||
"packNpmManually": false,
|
||||
"packNpmRelationList": [],
|
||||
"minifyWXSS": true,
|
||||
"minifyWXML": true,
|
||||
"localPlugins": false,
|
||||
"disableUseStrict": false,
|
||||
"useCompilerPlugins": false,
|
||||
"condition": false,
|
||||
"swc": false,
|
||||
"disableSWC": 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",
|
||||
"simulatorType": "wechat",
|
||||
"simulatorPluginLibVersion": {},
|
||||
"condition": {},
|
||||
"packOptions": {
|
||||
"ignore": [],
|
||||
"include": []
|
||||
},
|
||||
"editorSetting": {},
|
||||
"libVersion": "3.9.0"
|
||||
"condition": {}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/detail/index',
|
||||
'pages/list/index',
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
|
||||
37
src/components/Bubble/BubbleItem.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { BubbleOption } from './index';
|
||||
import styles from './bubbleItem.module.scss';
|
||||
|
||||
export interface BubbleItemProps {
|
||||
option: BubbleOption;
|
||||
isSelected: boolean;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
disabled: boolean;
|
||||
onClick: (option: BubbleOption) => void;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
const BubbleItem: React.FC<BubbleItemProps> = ({
|
||||
option,
|
||||
isSelected,
|
||||
size,
|
||||
disabled,
|
||||
onClick,
|
||||
itemClassName
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`${styles.bubbleOption} ${size} ${isSelected ? styles.selected : ''} ${
|
||||
option.disabled || disabled ? styles.disabled : ''
|
||||
} ${itemClassName ? itemClassName : ''}`}
|
||||
onClick={() => onClick(option)}
|
||||
disabled={option.disabled || disabled}
|
||||
>
|
||||
{option.icon && <span className={ styles.bubbleIcon}>{option.icon}</span>}
|
||||
<span className={styles.bubbleLabel}>{option.label}</span>
|
||||
{option.description && <span className={ styles.bubbleDescription}>{option.description}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default BubbleItem;
|
||||
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
@@ -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` 属性禁用整个组件或单个选项
|
||||
92
src/components/Bubble/bubbleItem.module.scss
Normal file
@@ -0,0 +1,92 @@
|
||||
.bubbleOption {
|
||||
position: relative;
|
||||
border: 1px solid #e5e5e5;
|
||||
outline: none; // 移除浏览器默认的outline
|
||||
background: #ffffff;
|
||||
color: #333333;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
border-radius: 28px;
|
||||
margin: 0;
|
||||
width: 116px;
|
||||
height: 28px;
|
||||
|
||||
// 移除浏览器默认样式
|
||||
&:focus {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&::after {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
outline: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
// 尺寸变体
|
||||
&.small {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 图标样式
|
||||
.bubbleIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// 标签样式
|
||||
.bubbleLabel {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
// 描述样式
|
||||
.bubbleDescription {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
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;
|
||||
27
src/components/Bubble/index.module.scss
Normal file
@@ -0,0 +1,27 @@
|
||||
.bubbleContainer {
|
||||
width: 100%;
|
||||
|
||||
// 水平布局
|
||||
.bubbleHorizontal {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
// 垂直布局
|
||||
.bubbleVertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
// 网格布局
|
||||
.bubbleGrid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
.bubbleOption {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/components/Bubble/index.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import styles from "./index.module.scss";
|
||||
import BubbleItem from "./BubbleItem";
|
||||
|
||||
export interface BubbleOption {
|
||||
id: string | number;
|
||||
label: string;
|
||||
value: string | number;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface BubbleProps {
|
||||
options: BubbleOption[];
|
||||
value?: string | number | (string | number)[];
|
||||
onChange?: (
|
||||
value: string | number | (string | number)[],
|
||||
option: BubbleOption | BubbleOption[]
|
||||
) => void;
|
||||
multiple?: boolean;
|
||||
layout?: "horizontal" | "vertical" | "grid";
|
||||
columns?: number;
|
||||
size?: "small" | "medium" | "large";
|
||||
className?: string;
|
||||
itemClassName?: string;
|
||||
style?: React.CSSProperties;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const Bubble: React.FC<BubbleProps> = ({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
multiple = false,
|
||||
layout = "horizontal",
|
||||
columns = 3,
|
||||
size = "small",
|
||||
className = "",
|
||||
itemClassName = "",
|
||||
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={styles.bubbleHorizontal}>
|
||||
{options.map((option) => (
|
||||
<BubbleItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
isSelected={isSelected(option)}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={handleOptionClick}
|
||||
itemClassName={itemClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderVerticalLayout = () => (
|
||||
<div className={styles.bubbleVertical}>
|
||||
{options.map((option) => (
|
||||
<BubbleItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
isSelected={isSelected(option)}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={handleOptionClick}
|
||||
itemClassName={itemClassName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderGridLayout = () => (
|
||||
<div
|
||||
className={styles.bubbleGrid}
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||
gap: size === "small" ? "6px" : size === "large" ? "16px" : "12px",
|
||||
}}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<BubbleItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
isSelected={isSelected(option)}
|
||||
size={size}
|
||||
disabled={disabled}
|
||||
onClick={handleOptionClick}
|
||||
itemClassName={itemClassName || styles.bubbleOption}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLayout = () => {
|
||||
switch (layout) {
|
||||
case "horizontal":
|
||||
return renderHorizontalLayout();
|
||||
case "vertical":
|
||||
return renderVerticalLayout();
|
||||
case "grid":
|
||||
return renderGridLayout();
|
||||
default:
|
||||
return renderHorizontalLayout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${styles.bubbleContainer} ${className}`} style={style}>
|
||||
{renderLayout()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Bubble;
|
||||
18
src/components/CityFilter/example.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import MenuComponent from "./index";
|
||||
|
||||
export default function Example() {
|
||||
const [value, setValue] = useState("a");
|
||||
const options = [
|
||||
{ text: "默认排序", value: "a" },
|
||||
{ text: "好评排序", value: "b" },
|
||||
{ text: "销量排序", value: "c" },
|
||||
];
|
||||
return (
|
||||
<MenuComponent
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
60
src/components/CityFilter/index.module.scss
Normal file
@@ -0,0 +1,60 @@
|
||||
.menuWrap {
|
||||
padding: 5px 20px 10px;
|
||||
.menuItem {
|
||||
left: 0;
|
||||
border-bottom-left-radius: 30px;
|
||||
border-bottom-right-radius: 30px;
|
||||
}
|
||||
&.active {
|
||||
.nut-menu-bar {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
:global(.nut-menu-bar) {
|
||||
color: #000000;
|
||||
line-height: 1;
|
||||
box-shadow: unset;
|
||||
min-height: 28px;
|
||||
min-width: 80px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid #e5e5e5;
|
||||
line-height: 28px;
|
||||
font-size: 14px;
|
||||
width: max-content;
|
||||
.nut-menu-title {
|
||||
color: inherit !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nut-menu-title-text {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.positionWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cityName {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #3C3C43;
|
||||
}
|
||||
.distanceWrap {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
.distanceBubbleItem {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
64
src/components/CityFilter/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Menu } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import { useState, useRef } from "react";
|
||||
import Bubble, { BubbleOption } from "../Bubble";
|
||||
|
||||
interface IProps {
|
||||
options: BubbleOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
wrapperClassName?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
const MenuComponent = (props: IProps) => {
|
||||
const { value, onChange, wrapperClassName, itemClassName } = props;
|
||||
const [isChange, setIsChange] = useState(false);
|
||||
const itemRef = useRef(null);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
console.log("===value", value);
|
||||
setIsChange(true);
|
||||
onChange && onChange(value);
|
||||
};
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{ id: 0, label: "全城", value: "0" },
|
||||
{ id: 1, label: "3km", value: "3" },
|
||||
{ id: 2, label: "5km", value: "5" },
|
||||
{ id: 3, label: "10km", value: "10" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={`${styles.menuWrap} ${wrapperClassName} ${
|
||||
isChange ? styles.active : ""
|
||||
}`}
|
||||
activeColor="#000"
|
||||
>
|
||||
<Menu.Item
|
||||
title="全城"
|
||||
className={`${styles.menuItem} ${itemClassName}`}
|
||||
ref={itemRef}
|
||||
>
|
||||
<div className={styles.positionWrap}>
|
||||
<p className={styles.title}>当前位置</p>
|
||||
<p className={styles.cityName}>上海市</p>
|
||||
</div>
|
||||
<div className={styles.distanceWrap}>
|
||||
<Bubble
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={4}
|
||||
itemClassName={styles.distanceBubbleItem}
|
||||
/>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuComponent;
|
||||
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
@@ -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
@@ -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
@@ -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
|
||||
18
src/components/Menu/example.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import MenuComponent from "./index";
|
||||
|
||||
export default function Example() {
|
||||
const [value, setValue] = useState("a");
|
||||
const options = [
|
||||
{ text: "默认排序", value: "a" },
|
||||
{ text: "好评排序", value: "b" },
|
||||
{ text: "销量排序", value: "c" },
|
||||
];
|
||||
return (
|
||||
<MenuComponent
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/components/Menu/index.module.scss
Normal file
@@ -0,0 +1,45 @@
|
||||
.menuWrap {
|
||||
padding: 5px 20px 10px;
|
||||
.menuItem {
|
||||
left: 0;
|
||||
border-bottom-left-radius: 30px;
|
||||
border-bottom-right-radius: 30px;
|
||||
}
|
||||
&.active {
|
||||
:global(.nut-menu-bar) {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
:global(.nut-menu-bar) {
|
||||
color: #000000;
|
||||
line-height: 1;
|
||||
box-shadow: unset;
|
||||
min-height: 28px;
|
||||
min-width: 94px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid #e5e5e5;
|
||||
line-height: 28px;
|
||||
font-size: 14px;
|
||||
width: max-content;
|
||||
|
||||
.nut-menu-title-text {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.nut-menu-title) {
|
||||
color: inherit !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.nut-menu-container-item) {
|
||||
color: #3c3c43;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
:global(.nut-menu-container-item.active) {
|
||||
flex-direction: row-reverse;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
37
src/components/Menu/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Menu } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import { useState } from "react";
|
||||
|
||||
interface IProps {
|
||||
options: { text: string; value: string }[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
wrapperClassName?: string;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
const MenuComponent = (props: IProps) => {
|
||||
const { options, value, onChange, wrapperClassName, itemClassName } = props;
|
||||
const [isChange, setIsChange] = useState(false);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setIsChange(true);
|
||||
onChange && onChange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={`${styles.menuWrap} ${wrapperClassName} ${isChange ? styles.active : ""}`}
|
||||
activeColor="#000"
|
||||
>
|
||||
<Menu.Item
|
||||
className={`${styles.menuItem} ${itemClassName}`}
|
||||
options={options}
|
||||
defaultValue={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuComponent;
|
||||
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
@@ -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;
|
||||
81
src/components/Range/index.module.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
|
||||
|
||||
// 全局NutUI样式覆盖
|
||||
.nutRange {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
|
||||
// .nut-range__bar-box {
|
||||
// margin: 0 !important;
|
||||
// padding: 0 !important;
|
||||
// }
|
||||
|
||||
// .nut-range__bar {
|
||||
// margin: 0 !important;
|
||||
// }
|
||||
}
|
||||
|
||||
.nutRangeHeader {
|
||||
line-height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.nutRangeHeaderLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.nutRangeHeaderTitle {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.nutRangeHeaderContent {
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
color: #3c3c34;
|
||||
}
|
||||
}
|
||||
|
||||
.rangeWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #e0e0e0;
|
||||
padding: 0 10px;
|
||||
.rangeHandle {
|
||||
:global(.nut-range-mark) {
|
||||
padding-top: 28px;
|
||||
left: 8px;
|
||||
}
|
||||
:global(.nut-range-bar) {
|
||||
background: #000000;
|
||||
height: 6px;
|
||||
}
|
||||
:global(.nut-range-button) {
|
||||
border: none;
|
||||
box-shadow: 0 6px 13px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
:global(.nut-range-tick) {
|
||||
background: #3c3c3c;
|
||||
height: 4px !important;
|
||||
width: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
.rangeWrapperMin,
|
||||
.rangeWrapperMax {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #000000;
|
||||
}
|
||||
}
|
||||
88
src/components/Range/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Range } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import TitleComponent from "../Title";
|
||||
|
||||
interface RangeProps {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
value?: [number, number];
|
||||
onChange?: (value: [number, number]) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NtrpRange: React.FC<RangeProps> = ({
|
||||
min = 1.0,
|
||||
max = 5.0,
|
||||
step = 0.5,
|
||||
value = [min, max],
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
}) => {
|
||||
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
|
||||
|
||||
useEffect(() => {
|
||||
value && setCurrentValue(value);
|
||||
}, [JSON.stringify(value || [])]);
|
||||
|
||||
const handleChange = (val: [number, number]) => {
|
||||
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 (Number(start) === Number(min) && Number(end) === Number(max)) {
|
||||
return "不限";
|
||||
}
|
||||
return `${start.toFixed(1)} - ${end.toFixed(1)}之间`;
|
||||
}, [JSON.stringify(currentValue || []), min, max]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.nutRange} ${className ? className : ''} `}>
|
||||
<div className={styles.nutRangeHeader}>
|
||||
{/* <div className={styles.nutRangeHeaderLeft}>
|
||||
<div className="ntrp-range__icon">icon</div>
|
||||
<h3 className={styles.nutRangeHeaderTitle}>NTRP水平区间</h3>
|
||||
</div> */}
|
||||
<TitleComponent title='NTRP水平区间'/>
|
||||
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className={styles.rangeWrapper}>
|
||||
<span className={styles.rangeWrapperMin}>{min.toFixed(1)}</span>
|
||||
<Range
|
||||
range
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
// value={currentValue}
|
||||
onEnd={handleChange}
|
||||
disabled={disabled}
|
||||
defaultValue={[min, max]}
|
||||
className={styles.rangeHandle}
|
||||
maxDescription={null}
|
||||
minDescription={null}
|
||||
currentDescription={null}
|
||||
marks={marks}
|
||||
/>
|
||||
<span className={styles.rangeWrapperMax}>{max.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NtrpRange;
|
||||
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
@@ -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
@@ -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;
|
||||
23
src/components/SearchBar/index.module.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
.searchBar {
|
||||
--nutui-searchbar-padding: 10px 15px;
|
||||
--nutui-searchbar-font-size: 16px;
|
||||
--nutui-searchbar-input-height: 44px;
|
||||
--nutui-searchbar-content-border-radius: 44px;
|
||||
--nutui-searchbar-input-text-color: #000000;
|
||||
--nutui-searchbar-input-padding: 0 0 0 10px;
|
||||
--nutui-searchbar-padding:0 15px;
|
||||
// --nutui-searchbar-background: #ffffff;
|
||||
:global(.nut-searchbar-content) {
|
||||
box-shadow: 0 4px 48px #00000014;
|
||||
}
|
||||
.searchBarRight {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #0000000F;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
22
src/components/SearchBar/index.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
|
||||
import { SearchBar } from '@nutui/nutui-react-taro'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
const SearchBarComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<SearchBar
|
||||
// leftIn={
|
||||
// <div>123</div>
|
||||
// }
|
||||
right={
|
||||
<div className={styles.searchBarRight}>筛</div>
|
||||
}
|
||||
className={styles.searchBar}
|
||||
placeholder='搜索上海的球局和场地'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default SearchBarComponent
|
||||
11
src/components/Title/index.module.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
19
src/components/Title/index.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import styles from "./index.module.scss";
|
||||
interface IProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
}
|
||||
const TitleComponent = (props: IProps) => {
|
||||
const { title, className } = props;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.titleContainer} ${className ? className : ""} `}
|
||||
>
|
||||
<div>图</div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export default TitleComponent;
|
||||
11
src/config/images.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
ICON_REMOVE: require('@/static/publishBall/icon-remove.svg'),
|
||||
ICON_UPLOAD: require('@/static/publishBall/icon-upload.svg'),
|
||||
ICON_LOCATION: require('@/static/publishBall/icon-location.svg'),
|
||||
ICON_GAMEPLAY: require('@/static/publishBall/icon-gameplay.svg'),
|
||||
ICON_PERSONAL: require('@/static/publishBall/icon-personal.svg'),
|
||||
ICON_CHANGDA: require('@/static/publishBall/icon-changda.svg'),
|
||||
ICON_COST: require('@/static/publishBall/icon-cost.svg'),
|
||||
ICON_TIPS: require('@/static/publishBall/icon-tips.svg'),
|
||||
ICON_ARROW_RIGHT: require('@/static/publishBall/icon-arrow-right.svg'),
|
||||
}
|
||||
72
src/pages/list/FilterPopup.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Popup } from "@nutui/nutui-react-taro";
|
||||
import Range from "../../components/Range";
|
||||
import Bubble, { BubbleOption } from "../../components/Bubble";
|
||||
import styles from "./filterPopup.module.scss";
|
||||
import TitleComponent from "src/components/Title";
|
||||
|
||||
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" },
|
||||
];
|
||||
|
||||
const locationOptions: BubbleOption[] = [
|
||||
{ id: 1, label: "室内", value: "1" },
|
||||
{ id: 2, label: "室外", value: "2" },
|
||||
{ id: 3, label: "半室外", value: "3" },
|
||||
];
|
||||
|
||||
const FilterPopup = () => {
|
||||
return (
|
||||
<>
|
||||
<Popup
|
||||
visible={true}
|
||||
destroyOnClose
|
||||
position="top"
|
||||
round
|
||||
closeOnOverlayClick={false}
|
||||
onClose={() => {
|
||||
// setShowTop(false)
|
||||
}}
|
||||
>
|
||||
<div className={styles.filterPopupWrapper}>
|
||||
{/* 时间气泡选项 */}
|
||||
<Bubble
|
||||
options={timeOptions}
|
||||
value={(value) => {}}
|
||||
onChange={(value) => {}}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* 范围选择 */}
|
||||
<Range
|
||||
min={1.0}
|
||||
max={5.0}
|
||||
step={0.5}
|
||||
className={styles.filterPopupRange}
|
||||
/>
|
||||
|
||||
{/* 场次气泡选项 */}
|
||||
<div>
|
||||
<TitleComponent title="场地类型" />
|
||||
<Bubble
|
||||
options={locationOptions}
|
||||
value={(value) => {}}
|
||||
onChange={(value) => {}}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FilterPopup;
|
||||
8
src/pages/list/filterPopup.module.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
.filterPopupWrapper {
|
||||
$m18: 18px;
|
||||
padding: $m18;
|
||||
.filterPopupRange {
|
||||
margin-top: $m18;
|
||||
margin-bottom: $m18;
|
||||
}
|
||||
}
|
||||
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
206
src/pages/list/index.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import ListItem from "../../components/ListItem";
|
||||
import List from "../../components/List";
|
||||
import Bubble from "../../components/Bubble/example";
|
||||
import Range from "../../components/Range/example";
|
||||
import Menu from "../../components/Menu/example";
|
||||
import CityFilter from "../../components/CityFilter/example";
|
||||
import SearchBar from "../../components/SearchBar";
|
||||
import FilterPopup from "./FilterPopup";
|
||||
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>
|
||||
<SearchBar />
|
||||
{/* 综合筛选 */}
|
||||
<div>
|
||||
<FilterPopup />
|
||||
</div>
|
||||
{/* 筛选 */}
|
||||
<div>
|
||||
{/* 全城筛选 */}
|
||||
<CityFilter />
|
||||
{/* 智能排序 */}
|
||||
<Menu />
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* 列表内容 */}
|
||||
<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;
|
||||
30
src/scss/images.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
// src/scss/images.scss
|
||||
// 暴露公共API (可选)
|
||||
@forward 'sass:map';
|
||||
@forward 'sass:meta';
|
||||
@use 'sass:map';
|
||||
|
||||
// 使用私有变量命名 (前缀加 -)
|
||||
$-static-path: '~@/static/' !default;
|
||||
|
||||
// 图片映射表
|
||||
$-images: (
|
||||
'icon-upload': '/publishBall/icon-upload.svg',
|
||||
'icon-add': '/publishBall/icon-add.svg',
|
||||
'icon-location': '/publishBall/icon-location.svg',
|
||||
'icon-gameplay': '/publishBall/icon-gameplay.svg',
|
||||
'icon-personal': '/publishBall/icon-personal.svg',
|
||||
'icon-changda': '/publishBall/icon-changda.svg',
|
||||
'icon-cost': '/publishBall/icon-cost.svg',
|
||||
'icon-remove': '/publishBall/icon-remove.svg'
|
||||
) !default;
|
||||
|
||||
// 图片获取函数
|
||||
@function taro-image($name) {
|
||||
@if not map.has-key($-images, $name) {
|
||||
@warn "Image `#{$name}` not found in $images map";
|
||||
@return url($-static-path + 'default.png');
|
||||
}
|
||||
@return url($-static-path + map.get($-images, $name));
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
3
src/static/publishBall/icon-add.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 4C13.5 3.44772 13.0523 3 12.5 3C11.9477 3 11.5 3.44772 11.5 4V11H4.5C3.94772 11 3.5 11.4477 3.5 12C3.5 12.5523 3.94772 13 4.5 13H11.5V20C11.5 20.5523 11.9477 21 12.5 21C13.0523 21 13.5 20.5523 13.5 20V13H20.5C21.0523 13 21.5 12.5523 21.5 12C21.5 11.4477 21.0523 11 20.5 11H13.5V4Z" fill="#3C3C43" fill-opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
3
src/static/publishBall/icon-arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.77774 2.98949C5.14223 2.625 5.73318 2.625 6.09767 2.98949L9.28329 6.17511C9.7389 6.63072 9.7389 7.36941 9.28329 7.82502L6.09767 11.0106C5.73318 11.3751 5.14223 11.3751 4.77774 11.0106C4.41325 10.6461 4.41325 10.0552 4.77774 9.6907L7.46838 7.00006L4.77774 4.30942C4.41325 3.94494 4.41325 3.35398 4.77774 2.98949Z" fill="#161823" fill-opacity="0.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
21
src/static/publishBall/icon-changda.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2">
|
||||
<mask id="mask0_586_20634" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="2" width="18" height="16">
|
||||
<path d="M3.1509 4.88544C4.06659 3.22577 5.8122 2.19519 7.70772 2.19519H11.6591C13.5546 2.19519 15.3002 3.22577 16.2159 4.88544L17.6815 7.54183C18.5449 9.10671 18.5449 11.0052 17.6815 12.57L16.2159 15.2264C15.3002 16.8861 13.5546 17.9167 11.6591 17.9167H7.70772C5.8122 17.9167 4.06658 16.8861 3.1509 15.2264L1.68531 12.57C0.821926 11.0052 0.821927 9.10671 1.68531 7.54183L3.1509 4.88544Z" fill="#D9D9D9"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_586_20634)">
|
||||
<g filter="url(#filter0_f_586_20634)">
|
||||
<circle cx="16.5617" cy="9.78481" r="5.8549" fill="#0BBE61" fill-opacity="0.34"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M12.8129 17.7198H12.8036L7.82718 17.7042C7.2053 17.7017 6.59498 17.5359 6.05724 17.2236C5.51949 16.9112 5.07315 16.4632 4.76286 15.9243L2.28558 11.6088C1.97756 11.0697 1.81624 10.4592 1.8177 9.83824C1.81916 9.21729 1.98335 8.60758 2.29391 8.06987L4.792 3.76691C5.10437 3.23028 5.55178 2.78477 6.08975 2.4747C6.62771 2.16464 7.23748 2.00081 7.8584 1.99951H7.86777L12.8442 2.012C13.466 2.01462 14.0763 2.18037 14.614 2.49271C15.1518 2.80505 15.5981 3.25303 15.9085 3.79189L18.3858 8.10734C18.6946 8.64722 18.8563 9.25871 18.8546 9.88067C18.853 10.5026 18.6881 11.1133 18.3764 11.6515L15.8783 15.9555C15.5659 16.4916 15.1185 16.9366 14.5807 17.2461C14.0429 17.5556 13.4335 17.7189 12.8129 17.7198ZM7.8584 3.25168C7.45641 3.25272 7.06168 3.35896 6.71346 3.55982C6.36524 3.76068 6.07564 4.04918 5.87346 4.39664L3.37537 8.70064C3.17561 9.04916 3.0705 9.44388 3.0705 9.84559C3.0705 10.2473 3.17561 10.642 3.37537 10.9906L5.8464 15.3102C6.04817 15.6578 6.33753 15.9464 6.68563 16.1473C7.03372 16.3482 7.42839 16.4543 7.8303 16.4551L12.8067 16.4687C13.2099 16.4694 13.6062 16.364 13.9557 16.163C14.3053 15.9621 14.5957 15.6726 14.7979 15.3237L17.296 11.0207C17.4965 10.6727 17.6025 10.2782 17.6035 9.87647C17.6044 9.47476 17.5002 9.0798 17.3012 8.73082L14.8239 4.41433C14.6229 4.06531 14.3337 3.77518 13.9854 3.57297C13.6371 3.37076 13.2417 3.26356 12.839 3.26209L7.86257 3.2496L7.8584 3.25168Z" fill="#161823"/>
|
||||
<path d="M9.85676 12.836C9.70243 12.8346 9.55411 12.776 9.44041 12.6716L6.83824 10.2859C6.72076 10.1728 6.65216 10.0183 6.64709 9.85535C6.64202 9.69238 6.70089 9.5339 6.81112 9.41375C6.92134 9.2936 7.07418 9.22133 7.23698 9.21236C7.39979 9.2034 7.55964 9.25846 7.68238 9.36578L9.75268 11.2695L12.4308 7.44017C12.4773 7.37155 12.5369 7.31284 12.6062 7.26747C12.6755 7.22211 12.7532 7.19099 12.8347 7.17594C12.9161 7.16088 12.9998 7.16219 13.0808 7.17978C13.1617 7.19738 13.2384 7.2309 13.3062 7.27841C13.3741 7.32592 13.4319 7.38646 13.4761 7.4565C13.5204 7.52655 13.5502 7.6047 13.564 7.6864C13.5778 7.76811 13.5751 7.85173 13.5562 7.9324C13.5373 8.01307 13.5026 8.08918 13.454 8.15629L10.3678 12.5696C10.3168 12.6423 10.2509 12.7034 10.1744 12.7486C10.098 12.7938 10.0127 12.8222 9.92442 12.8319C9.90199 12.8348 9.87938 12.8362 9.85676 12.836Z" fill="#0BBE61"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_586_20634" x="5.50249" y="-1.27444" width="22.1185" height="22.1185" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.60218" result="effect1_foregroundBlur_586_20634"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
7
src/static/publishBall/icon-cost.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00001 15.1666C11.6819 15.1666 14.6667 12.1819 14.6667 8.49998C14.6667 4.81808 11.6819 1.83331 8.00001 1.83331C4.31811 1.83331 1.33334 4.81808 1.33334 8.49998C1.33334 12.1819 4.31811 15.1666 8.00001 15.1666Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M6 7.83331H10" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 9.83331H10" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.00278 7.83331V11.8333" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 5.5L8 7.5L6 5.5" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 848 B |
13
src/static/publishBall/icon-gameplay.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_586_20716)">
|
||||
<path d="M12.8374 3.67871C11.6155 2.53414 9.97294 1.83331 8.16668 1.83331C4.39274 1.83331 1.33334 4.89271 1.33334 8.66665C1.33334 12.4406 4.39274 15.5 8.16668 15.5C10.0383 15.5 11.7341 14.7475 12.9683 13.5287L8.00001 8.49998L12.8374 3.67871Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 9.83335C14.0697 9.83335 14.6667 9.23639 14.6667 8.50002C14.6667 7.76365 14.0697 7.16669 13.3333 7.16669C12.597 7.16669 12 7.76365 12 8.50002C12 9.23639 12.597 9.83335 13.3333 9.83335Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M5.66666 4.83331V7.49998" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.33334 6.16669H7.00001" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_586_20716">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
src/static/publishBall/icon-location.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.66668 4L1.33334 2V12L5.66668 14L10.3333 12L14.6667 14V4L10.3333 2L5.66668 4Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.3333 2V12" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.66666 4V14" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 3L5.66667 4L10.3333 2L12.5 3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 13L5.66667 14L10.3333 12L12.5 13" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 768 B |
19
src/static/publishBall/icon-personal.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_586_20626)">
|
||||
<g filter="url(#filter0_f_586_20626)">
|
||||
<circle cx="15.125" cy="11.4583" r="5.625" fill="#0BBE61" fill-opacity="0.34"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M10.7901 16.8477C10.7901 15.0336 10.0679 13.2938 8.7824 12.0111C7.49688 10.7283 5.75333 10.0077 3.93532 10.0077C3.85764 10.0077 3.74156 10.0113 3.63737 10.0168C3.58619 10.0186 3.5414 10.0223 3.50941 10.0241L3.47102 10.0259H3.45823C3.38196 10.0331 3.30502 10.0249 3.23199 10.0018C3.15897 9.97869 3.09134 9.94116 3.03314 9.89145C2.97494 9.84174 2.92736 9.78086 2.89322 9.71243C2.85909 9.64399 2.83909 9.5694 2.83443 9.4931C2.82977 9.4168 2.84054 9.34034 2.8661 9.26828C2.89165 9.19621 2.93147 9.13001 2.98319 9.07362C3.03491 9.01722 3.09747 8.97178 3.16714 8.94001C3.23682 8.90823 3.31219 8.89076 3.38877 8.88864H3.4043L3.44543 8.8859L3.58253 8.87861C3.68855 8.87314 3.8293 8.86766 3.93532 8.86766C6.05633 8.86766 8.09047 9.70842 9.59025 11.205C11.09 12.7015 11.9326 14.7313 11.9326 16.8477C11.9316 16.9652 11.9276 17.0825 11.9207 17.1998L11.9134 17.332L11.9107 17.3712L11.9097 17.3822V17.3867C11.8991 17.5375 11.8288 17.678 11.7144 17.7771C11.6 17.8762 11.4508 17.9259 11.2997 17.9152C11.1485 17.9046 11.0078 17.8345 10.9085 17.7203C10.8092 17.6062 10.7594 17.4573 10.77 17.3065V17.3037V17.2946L10.7728 17.2591C10.7755 17.229 10.7773 17.1861 10.7801 17.1378C10.7856 17.0393 10.7901 16.928 10.7901 16.8477ZM9.64766 3.16761C9.64766 2.95602 9.6568 2.82196 9.67051 2.62588C9.67419 2.54981 9.6931 2.47525 9.72614 2.4066C9.75917 2.33795 9.80566 2.2766 9.86285 2.22618C9.92005 2.17575 9.98679 2.13728 10.0591 2.11302C10.1315 2.08876 10.208 2.07921 10.2841 2.08494C10.3602 2.09067 10.4343 2.11156 10.5022 2.14637C10.5701 2.18119 10.6303 2.22922 10.6793 2.28763C10.7282 2.34605 10.765 2.41366 10.7873 2.48648C10.8096 2.5593 10.8171 2.63585 10.8093 2.7116C10.7963 2.86326 10.7899 3.0154 10.7901 3.16761C10.7901 4.98171 11.5123 6.7215 12.7979 8.00427C14.0834 9.28703 15.8269 10.0077 17.6449 10.0077C17.8104 10.0077 17.9082 10.0013 18.0836 9.98944C18.2301 9.98665 18.3721 10.04 18.4803 10.1386C18.5885 10.2371 18.6547 10.3733 18.6652 10.5191C18.6758 10.665 18.6299 10.8092 18.537 10.9223C18.4441 11.0353 18.3113 11.1085 18.1659 11.1267C17.9926 11.1408 17.8188 11.1478 17.6449 11.1477C15.5239 11.1477 13.4898 10.3069 11.99 8.81038C10.4902 7.31382 9.64766 5.28406 9.64766 3.16761Z" fill="#0BBE61"/>
|
||||
<path d="M17.625 10C17.625 9.09717 17.4471 8.20317 17.1016 7.36906C16.7561 6.53494 16.2497 5.77705 15.6113 5.13865C14.9729 4.50024 14.215 3.99383 13.3809 3.64833C12.5468 3.30283 11.6528 3.125 10.75 3.125C9.84712 3.125 8.95312 3.30283 8.11901 3.64833C7.2849 3.99383 6.527 4.50024 5.8886 5.13865C5.2502 5.77705 4.74379 6.53494 4.39829 7.36906C4.05279 8.20317 3.87496 9.09717 3.87496 10C3.87496 11.8234 4.59929 13.5721 5.8886 14.8614C7.17791 16.1507 8.9266 16.875 10.75 16.875C12.5733 16.875 14.322 16.1507 15.6113 14.8614C16.9006 13.5721 17.625 11.8234 17.625 10ZM18.7708 10C18.7708 12.1273 17.9257 14.1674 16.4215 15.6716C14.9173 17.1758 12.8772 18.0208 10.75 18.0208C8.6227 18.0208 6.58257 17.1758 5.07837 15.6716C3.57418 14.1674 2.72913 12.1273 2.72913 10C2.72913 7.87275 3.57418 5.83262 5.07837 4.32842C6.58257 2.82422 8.6227 1.97917 10.75 1.97917C12.8772 1.97917 14.9173 2.82422 16.4215 4.32842C17.9257 5.83262 18.7708 7.87275 18.7708 10Z" fill="#161823"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_586_20626" x="4.5" y="0.833328" width="21.25" height="21.25" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.5" result="effect1_foregroundBlur_586_20626"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_586_20626">
|
||||
<rect x="2.625" y="1.97917" width="16.0417" height="16.0417" rx="8.02083" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
3
src/static/publishBall/icon-remove.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667C14.6024 1.66667 18.3333 5.39763 18.3333 10ZM13.5059 6.49408C13.1805 6.16865 12.6529 6.16865 12.3274 6.49408L10 8.82149L7.67259 6.49408C7.34716 6.16865 6.81952 6.16865 6.49408 6.49408C6.16865 6.81952 6.16865 7.34716 6.49408 7.67259L8.82149 10L6.49408 12.3274C6.16865 12.6529 6.16865 13.1805 6.49408 13.5059C6.81952 13.8314 7.34716 13.8314 7.67259 13.5059L10 11.1785L12.3274 13.5059C12.6529 13.8314 13.1805 13.8314 13.5059 13.5059C13.8314 13.1805 13.8314 12.6529 13.5059 12.3274L11.1785 10L13.5059 7.67259C13.8314 7.34716 13.8314 6.81952 13.5059 6.49408Z" fill="#161823" fill-opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
5
src/static/publishBall/icon-tips.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.49988 8.6C6.49988 8.87614 6.27602 9.1 5.99988 9.1C5.72374 9.1 5.49988 8.87614 5.49988 8.6V6.1C5.49988 5.82386 5.72374 5.6 5.99988 5.6C6.27602 5.6 6.49988 5.82386 6.49988 6.1V8.6Z" fill="black" fill-opacity="0.75"/>
|
||||
<path d="M5.99997 4C5.69622 4 5.44997 4.24624 5.44997 4.55C5.44997 4.85375 5.69622 5.1 5.99997 5.1C6.30373 5.1 6.54997 4.85375 6.54997 4.55C6.54997 4.24624 6.30373 4 5.99997 4Z" fill="black" fill-opacity="0.75"/>
|
||||
<path d="M6 11.5C8.76142 11.5 11 9.26142 11 6.5C11 3.73858 8.76142 1.5 6 1.5C3.23858 1.5 1 3.73858 1 6.5C1 9.26142 3.23858 11.5 6 11.5ZM6 10.5C3.79086 10.5 2 8.70914 2 6.5C2 4.29086 3.79086 2.5 6 2.5C8.20914 2.5 10 4.29086 10 6.5C10 8.70914 8.20914 10.5 6 10.5Z" fill="black" fill-opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 841 B |
20
src/static/publishBall/icon-upload.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
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
|
||||
}))
|
||||
9
types/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
declare module '*.module.scss' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||
|
||||
declare module '*.module.css' {
|
||||
const classes: { [key: string]: string };
|
||||
export default classes;
|
||||
}
|
||||