diff --git a/project.config.json b/project.config.json index 46dc372..8e39a33 100644 --- a/project.config.json +++ b/project.config.json @@ -1,25 +1,46 @@ { - "miniprogramRoot": "dist/", - "projectname": "playBallTogether", - "description": "playBallTogether", - "appid": "touristappid", - "setting": { - "urlCheck": true, - "es6": false, - "enhance": false, + "miniprogramRoot": "dist/", + "projectname": "playBallTogether", + "description": "playBallTogether", + "appid": "wx815b533167eb7b53", + "setting": { + "urlCheck": true, + "es6": true, + "enhance": true, + "postcss": false, + "preloadBackgroundData": false, + "minified": false, + "newFeature": true, + "coverView": true, + "nodeModules": false, + "autoAudits": false, + "showShadowRootInWxmlPanel": false, + "scopeDataCheck": false, + "uglifyFileName": false, + "checkInvalidKey": true, + "checkSiteMap": true, + "uploadWithSourceMap": true, "compileHotReLoad": false, - "postcss": false, - "preloadBackgroundData": false, - "minified": false, - "newFeature": true, - "autoAudits": false, - "coverView": true, - "showShadowRootInWxmlPanel": false, - "scopeDataCheck": false, - "useCompilerModule": false - }, - "compileType": "miniprogram", - "simulatorType": "wechat", - "simulatorPluginLibVersion": {}, - "condition": {} -} + "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": {} +} \ No newline at end of file diff --git a/src/app.config.ts b/src/app.config.ts index 15c683b..5d3f7cb 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,6 +1,7 @@ export default defineAppConfig({ pages: [ - 'pages/index/index' + 'pages/index/index', + 'pages/list/index' ], window: { backgroundTextStyle: 'light', diff --git a/src/components/Bubble/BubbleItem.tsx b/src/components/Bubble/BubbleItem.tsx new file mode 100644 index 0000000..300c8e9 --- /dev/null +++ b/src/components/Bubble/BubbleItem.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { BubbleOption } from './index'; +import './bubbleItem.scss'; + +export interface BubbleItemProps { + option: BubbleOption; + isSelected: boolean; + size: 'small' | 'medium' | 'large'; + disabled: boolean; + onClick: (option: BubbleOption) => void; +} + +const BubbleItem: React.FC = ({ + option, + isSelected, + size, + disabled, + onClick +}) => { + return ( + + ); +}; + +export default BubbleItem; \ No newline at end of file diff --git a/src/components/Bubble/README.md b/src/components/Bubble/README.md new file mode 100644 index 0000000..90512d3 --- /dev/null +++ b/src/components/Bubble/README.md @@ -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(''); + + const locationOptions: BubbleOption[] = [ + { id: 1, label: '室内', value: 'indoor' }, + { id: 2, label: '室外', value: 'outdoor' }, + { id: 3, label: '半室外', value: 'semi-outdoor' } + ]; + + return ( + setSelectedLocation(value as string)} + layout="horizontal" + size="medium" + /> + ); +}; +``` + +### 时间选择器示例 + +```tsx +const TimeSelector: React.FC = () => { + const [selectedTime, setSelectedTime] = useState(''); + + 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 ( + setSelectedTime(value as string)} + layout="grid" + columns={3} + size="medium" + /> + ); +}; +``` + +### 多选模式 + +```tsx +const MultiSelectExample: React.FC = () => { + const [selectedValues, setSelectedValues] = useState([]); + + const options: BubbleOption[] = [ + { id: 1, label: '运动', value: 'sports' }, + { id: 2, label: '音乐', value: 'music' }, + { id: 3, label: '阅读', value: 'reading' }, + { id: 4, label: '旅行', value: 'travel' } + ]; + + return ( + setSelectedValues(value as string[])} + multiple={true} + layout="grid" + columns={2} + size="medium" + /> + ); +}; +``` + +### 带图标和描述 + +```tsx +const IconExample: React.FC = () => { + const [selectedValue, setSelectedValue] = useState(''); + + const options: BubbleOption[] = [ + { + id: 1, + label: '网球', + value: 'tennis', + icon: '🎾', + description: '室内外均可' + }, + { + id: 2, + label: '篮球', + value: 'basketball', + icon: '🏀', + description: '室内场地' + } + ]; + + return ( + 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` 可以是字符串或数字类型 +- 组件会自动处理选中状态的样式变化 +- 支持图标和描述,让选项更加丰富 +- 响应式设计,自动适应不同屏幕尺寸 diff --git a/src/components/Bubble/USAGE.md b/src/components/Bubble/USAGE.md new file mode 100644 index 0000000..afd455f --- /dev/null +++ b/src/components/Bubble/USAGE.md @@ -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(''); + + const locationOptions: BubbleOption[] = [ + { id: 1, label: '室内', value: 'indoor' }, + { id: 2, label: '室外', value: 'outdoor' }, + { id: 3, label: '半室外', value: 'semi-outdoor' } + ]; + + return ( +
+

选择场地类型

+ setSelectedLocation(value as string)} + layout="horizontal" + size="medium" + /> +

您选择的场地类型: {selectedLocation}

+
+ ); +}; +``` + +### 2. 时间选择器 + +```tsx +const [selectedTime, setSelectedTime] = useState(''); + +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' } +]; + + setSelectedTime(value as string)} + layout="grid" + columns={3} + size="medium" +/> +``` + +### 3. 多选模式 + +```tsx +const [selectedValues, setSelectedValues] = useState([]); + +const options: BubbleOption[] = [ + { id: 1, label: '运动', value: 'sports' }, + { id: 2, label: '音乐', value: 'music' }, + { id: 3, label: '阅读', value: 'reading' }, + { id: 4, label: '旅行', value: 'travel' } +]; + + setSelectedValues(value as string[])} + multiple={true} + layout="grid" + columns={2} + size="medium" +/> +``` + +### 4. 带图标和描述 + +```tsx +const [selectedSport, setSelectedSport] = useState(''); + +const sportOptions: BubbleOption[] = [ + { + id: 1, + label: '网球', + value: 'tennis', + icon: '🎾', + description: '室内外均可' + }, + { + id: 2, + label: '篮球', + value: 'basketball', + icon: '🏀', + description: '室内场地' + } +]; + + setSelectedSport(value as string)} + layout="vertical" + size="large" +/> +``` + +### 5. 不同布局方式 + +```tsx +// 水平布局 - 适合选项较少 + + +// 垂直布局 - 适合选项较多 + + +// 网格布局 - 适合需要整齐排列 + +``` + +### 6. 不同尺寸 + +```tsx +// 小尺寸 - 适合紧凑界面 + + +// 中尺寸 - 默认尺寸 + + +// 大尺寸 - 适合触摸设备 + +``` + +## 组件特性 + +- **通用性**: 不局限于特定功能,可用于任何选择场景 +- **灵活布局**: 支持水平、垂直、网格三种布局方式 +- **多尺寸支持**: 小、中、大三种尺寸适应不同场景 +- **丰富内容**: 支持图标和描述,让选项更加丰富 +- **响应式设计**: 自动适应不同屏幕尺寸 +- **状态管理**: 内置选中状态管理,支持单选和多选 +- **类型安全**: 完整的 TypeScript 类型定义 +- **可访问性**: 支持键盘导航和屏幕阅读器 + +## 常见使用场景 + +1. **场地选择**: 室内/室外/半室外 +2. **时间选择**: 时间段、日期范围 +3. **分类选择**: 兴趣爱好、技能标签 +4. **设置选项**: 主题、语言、通知设置 +5. **筛选条件**: 价格范围、评分、距离等 +6. **导航菜单**: 顶部导航、侧边栏菜单 + +## 注意事项 + +1. 确保传入的 `options` 数组不为空 +2. 多选模式下,`value` 应该是数组类型 +3. 单选模式下,`value` 可以是字符串或数字类型 +4. 组件会自动处理选中状态的样式变化 +5. 支持图标和描述,让选项更加丰富 +6. 响应式设计,自动适应不同屏幕尺寸 +7. 可以通过 `disabled` 属性禁用整个组件或单个选项 diff --git a/src/components/Bubble/bubbleItem.scss b/src/components/Bubble/bubbleItem.scss new file mode 100644 index 0000000..e1a1dbb --- /dev/null +++ b/src/components/Bubble/bubbleItem.scss @@ -0,0 +1,96 @@ +.bubble-option { + position: relative; + border: 1px solid #e5e5e5; + outline: none; // 移除浏览器默认的outline + background: #ffffff; + color: #333333; + font-size: 14px; + font-weight: 400; + text-align: center; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + white-space: nowrap; + padding: 0; + font-size: 14px; + border-radius: 28px; + margin: 0; + height: 36px; + + // 移除浏览器默认样式 + &:focus { + outline: none; + border: none; + } + + &::after { + outline: none; + border: none; + } + + &:active { + outline: none; + border: none; + } + + // 尺寸变体 + &.small { + padding: 8px 12px; + min-height: 32px; + font-size: 12px; + } + + &.medium { + padding: 12px 16px; + min-height: 44px; + font-size: 14px; + } + + &.large { + padding: 16px 20px; + min-height: 56px; + font-size: 16px; + } + + &.selected { + background: #000000; + color: #ffffff; + } + + &.disabled { + background: #f5f5f5; + color: #999999; + cursor: not-allowed; + + &:hover { + background: #f5f5f5; + } + } + + // 图标样式 + .bubble-icon { + display: flex; + align-items: center; + justify-content: center; + + svg { + width: 16px; + height: 16px; + } + } + + // 标签样式 + .bubble-label { + font-weight: 500; + } + + // 描述样式 + .bubble-description { + font-size: 12px; + opacity: 0.7; + font-weight: 400; + } +} \ No newline at end of file diff --git a/src/components/Bubble/example.tsx b/src/components/Bubble/example.tsx new file mode 100644 index 0000000..0ebcdf8 --- /dev/null +++ b/src/components/Bubble/example.tsx @@ -0,0 +1,235 @@ +import React, { useState } from 'react'; +import Bubble, { BubbleOption } from './index'; + +// 室内外选择示例(如UI图所示) +export const LocationSelector: React.FC = () => { + const [selectedLocation, setSelectedLocation] = useState(''); + + const locationOptions: BubbleOption[] = [ + { id: 1, label: '室内', value: 'indoor' }, + { id: 2, label: '室外', value: 'outdoor' }, + { id: 3, label: '半室外', value: 'semi-outdoor' } + ]; + + return ( +
+

选择场地类型

+ setSelectedLocation(value as string)} + layout="horizontal" + size="small" + /> +

当前选择: {selectedLocation || '未选择'}

+
+ ); +}; + +// 时间选择器示例 +export const TimeSelector: React.FC = () => { + const [selectedTime, setSelectedTime] = useState(''); + + 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 ( +
+

选择时间段

+ setSelectedTime(value as string)} + layout="grid" + size="small" + columns={3} + /> +

当前选择: {selectedTime || '未选择'}

+
+

选择时间段

+ setSelectedTime(value as string)} + layout="grid" + columns={2} + size="small" + /> +

当前选择: {selectedTime || '未选择'}

+
+ ); +}; + +// 多选示例 +export const MultiSelectExample: React.FC = () => { + const [selectedValues, setSelectedValues] = useState([]); + + 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 ( +
+

多选示例 - 兴趣爱好

+ setSelectedValues(value as string[])} + multiple={true} + layout="grid" + columns={3} + size="medium" + /> +

当前选择: {selectedValues.join(', ') || '未选择'}

+
+ ); +}; + +// 带图标和描述的示例 +export const IconExample: React.FC = () => { + const [selectedValue, setSelectedValue] = useState(''); + + 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 ( +
+

带图标和描述的运动选择

+ setSelectedValue(value as string)} + layout="vertical" + size="large" + /> +

当前选择: {selectedValue || '未选择'}

+
+ ); +}; + +// 带禁用状态的示例 +export const DisabledExample: React.FC = () => { + const [selectedValue, setSelectedValue] = useState(''); + + 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 ( +
+

带禁用状态的示例

+ setSelectedValue(value as string)} + layout="grid" + columns={2} + size="medium" + /> +

当前选择: {selectedValue || '未选择'}

+
+ ); +}; + +// 不同尺寸的示例 +export const SizeExample: React.FC = () => { + const [selectedSize, setSelectedSize] = useState(''); + + const sizeOptions: BubbleOption[] = [ + { id: 1, label: '小尺寸', value: 'small' }, + { id: 2, label: '中尺寸', value: 'medium' }, + { id: 3, label: '大尺寸', value: 'large' } + ]; + + return ( +
+

不同尺寸的示例

+ +

小尺寸

+ setSelectedSize(value as string)} + layout="horizontal" + size="small" + /> + +

中尺寸

+ setSelectedSize(value as string)} + layout="horizontal" + size="medium" + /> + +

大尺寸

+ setSelectedSize(value as string)} + layout="horizontal" + size="large" + /> + +

当前选择: {selectedSize || '未选择'}

+
+ ); +}; + +// 主示例组件 +export const BubbleExamples: React.FC = () => { + return ( +
+

Bubble 通用气泡组件示例

+ +
+ +
+ +
+ +
+ +
+ +
+ ); +}; + +export default BubbleExamples; diff --git a/src/components/Bubble/index.scss b/src/components/Bubble/index.scss new file mode 100644 index 0000000..aa88fbb --- /dev/null +++ b/src/components/Bubble/index.scss @@ -0,0 +1,35 @@ +.bubble-container { + width: 100%; + + // 水平布局 + .bubble-horizontal { + display: flex; + gap: 12px; + flex-wrap: wrap; + justify-content: space-between; + + .bubble-option { + $gap: 8px; + $count: 3; + flex-basis: calc(100% / $count - $gap * 2); + gap: $gap; + } + } + + // 垂直布局 + .bubble-vertical { + display: flex; + flex-direction: column; + gap: 12px; + + .bubble-option { + width: 100%; + } + } + + // 网格布局 + .bubble-grid { + display: grid; + width: 100%; + } +} diff --git a/src/components/Bubble/index.tsx b/src/components/Bubble/index.tsx new file mode 100644 index 0000000..2c118d3 --- /dev/null +++ b/src/components/Bubble/index.tsx @@ -0,0 +1,152 @@ +import React, { useState, useEffect } from 'react'; +import './index.scss'; +import BubbleItem from './BubbleItem'; + +export interface BubbleOption { + id: string | number; + label: string; + value: string | number; + disabled?: boolean; + icon?: React.ReactNode; + description?: string; +} + +export interface BubbleProps { + options: BubbleOption[]; + value?: string | number | (string | number)[]; + onChange?: (value: string | number | (string | number)[], option: BubbleOption | BubbleOption[]) => void; + multiple?: boolean; + layout?: 'horizontal' | 'vertical' | 'grid'; + columns?: number; + size?: 'small' | 'medium' | 'large'; + className?: string; + style?: React.CSSProperties; + disabled?: boolean; +} + +const Bubble: React.FC = ({ + options, + value, + onChange, + multiple = false, + layout = 'horizontal', + columns = 3, + size = 'small', + className = '', + style = {}, + disabled = false +}) => { + const [selectedValues, setSelectedValues] = useState<(string | number)[]>([]); + + // 同步外部传入的value + useEffect(() => { + if (value !== undefined) { + const newValues = Array.isArray(value) ? value : [value]; + setSelectedValues(newValues); + } + }, [value]); + + const handleOptionClick = (option: BubbleOption) => { + if (disabled || option.disabled) return; + + let newSelectedValues: (string | number)[]; + + if (multiple) { + if (selectedValues.includes(option.value)) { + newSelectedValues = selectedValues.filter(v => v !== option.value); + } else { + newSelectedValues = [...selectedValues, option.value]; + } + } else { + newSelectedValues = [option.value]; + } + + setSelectedValues(newSelectedValues); + + // 调用onChange回调,传递选中的值和对应的选项 + if (onChange) { + if (multiple) { + const selectedOptions = options.filter(opt => newSelectedValues.includes(opt.value)); + onChange(newSelectedValues, selectedOptions); + } else { + onChange(option.value, option); + } + } + }; + + const isSelected = (option: BubbleOption) => { + return selectedValues.includes(option.value); + }; + + const renderHorizontalLayout = () => ( +
+ {options.map((option) => ( + + ))} +
+ ); + + const renderVerticalLayout = () => ( +
+ {options.map((option) => ( + + ))} +
+ ); + + const renderGridLayout = () => ( +
+ {options.map((option) => ( + + ))} +
+ ); + + const renderLayout = () => { + switch (layout) { + case 'horizontal': + return renderHorizontalLayout(); + case 'vertical': + return renderVerticalLayout(); + case 'grid': + return renderGridLayout(); + default: + return renderHorizontalLayout(); + } + }; + + return ( +
+ {renderLayout()} +
+ ); +}; + +export default Bubble; diff --git a/src/components/List/index.scss b/src/components/List/index.scss new file mode 100644 index 0000000..391a9aa --- /dev/null +++ b/src/components/List/index.scss @@ -0,0 +1,7 @@ +.list { + background: #fafafa; + margin-top: 12px; + display: flex; + flex-direction: column; + gap: 5px; +} diff --git a/src/components/List/index.tsx b/src/components/List/index.tsx new file mode 100644 index 0000000..c93c214 --- /dev/null +++ b/src/components/List/index.tsx @@ -0,0 +1,16 @@ +import { View } from '@tarojs/components' +import './index.scss' + +interface ListProps { + children: React.ReactNode +} + +const List: React.FC = ({ children }) => { + return ( + + {children} + + ) +} + +export default List diff --git a/src/components/ListItem/index.scss b/src/components/ListItem/index.scss new file mode 100644 index 0000000..ded1507 --- /dev/null +++ b/src/components/ListItem/index.scss @@ -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; +} diff --git a/src/components/ListItem/index.tsx b/src/components/ListItem/index.tsx new file mode 100644 index 0000000..cc0f498 --- /dev/null +++ b/src/components/ListItem/index.tsx @@ -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 = ({ + title, + dateTime, + location, + distance, + registeredCount, + maxCount, + skillLevel, + matchType, + images +}) => { + // 根据图片数量决定展示样式 + const renderImages = () => { + if (images.length === 0) return null + + if (images.length === 1) { + return ( + + + + + + ) + } + + if (images.length === 2) { + return ( + + + + + + + + + ) + } + + // 3张或更多图片 + return ( + + + + + + + + + + + + ) + } + + return ( + + {/* 左侧内容区域 */} + + {/* 标题 */} + {title} + + {/* 时间信息 */} + {dateTime} + + {/* 地点和距离 */} + {location}・{distance} + + {/* 底部信息行:头像组、报名人数、技能等级、比赛类型 */} + + + + {Array.from({ length: Math.min(registeredCount, 3) }).map((_, index) => ( + + + + ))} + + + + + + + 报名人数 {registeredCount}/{maxCount} + + + + {skillLevel} + + + {matchType} + + + + + + {/* 右侧图片区域 */} + + {renderImages()} + + + ) +} + +export default ListItem \ No newline at end of file diff --git a/src/components/Range/README.md b/src/components/Range/README.md new file mode 100644 index 0000000..5da1a01 --- /dev/null +++ b/src/components/Range/README.md @@ -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 ( + + ); +}; +``` + +## 属性说明 + +| 属性 | 类型 | 默认值 | 说明 | +|------|------|--------|------| +| `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` 文件获取更多使用示例。 diff --git a/src/components/Range/example.tsx b/src/components/Range/example.tsx new file mode 100644 index 0000000..a9b65df --- /dev/null +++ b/src/components/Range/example.tsx @@ -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 ( +
+

Range 组件示例

+ +
+

NTRP 水平区间选择器

+ +
+ 当前选择范围: {ntrpRange[0]} - {ntrpRange[1]} +
+
+ +
+

自定义范围选择器

+ +
+ 当前选择范围: {customRange[0]} - {customRange[1]} +
+
+ +
+

禁用状态

+ +
+ 此选择器已被禁用 +
+
+ +
+

测试说明

+
+

1. 点击并拖拽左右滑块手柄

+

2. 查看控制台日志确认拖拽事件

+

3. 观察滑块位置和值的实时变化

+

4. 检查调试信息显示

+
+
+
+ ); +}; + +export default RangeExample; diff --git a/src/components/Range/index.scss b/src/components/Range/index.scss new file mode 100644 index 0000000..00bd829 --- /dev/null +++ b/src/components/Range/index.scss @@ -0,0 +1,246 @@ +.ntrp-range { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + user-select: none; + + &__header { + display: flex; + align-items: center; + margin-bottom: 16px; + } + + &__icon { + margin-right: 8px; + display: flex; + align-items: center; + + svg { + width: 20px; + height: 20px; + } + } + + &__title { + margin: 0; + font-size: 16px; + font-weight: 500; + color: #000; + line-height: 1.2; + } + + &__slider-container { + position: relative; + } + + &__labels { + display: flex; + justify-content: space-between; + margin-bottom: 12px; + } + + &__label { + font-size: 14px; + color: #000; + font-weight: 400; + } + + &__track-container { + position: relative; + padding: 0 10px; + } + + &__track { + position: relative; + height: 40px; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + display: flex; + align-items: center; + padding: 0 20px; + } + + &__markers { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; + } + + &__marker { + position: absolute; + width: 6px; + height: 6px; + background: #e0e0e0; + border-radius: 50%; + top: 50%; + transform: translate(-50%, -50%); + } + + &__nutui-wrapper { + position: relative; + width: 100%; + height: 100%; + z-index: 2; + + // 隐藏NutUI的默认标签 + .nut-range__label { + display: none !important; + } + + // 隐藏默认的轨道背景 + .nut-range__bar-box { + background: transparent !important; + height: 6px !important; + border-radius: 3px !important; + margin: 0 !important; + padding: 0 !important; + } + + // 隐藏默认的轨道 + .nut-range__bar { + background: transparent !important; + height: 0 !important; + margin: 0 !important; + } + + // 自定义活动轨道(黑色填充条) + .nut-range__bar--active { + background: #000 !important; + height: 6px !important; + border-radius: 3px !important; + margin: 0 !important; + } + + // 自定义滑块手柄样式 + .nut-range__button { + width: 24px !important; + height: 24px !important; + background: #fff !important; + border: 2px solid #000 !important; + border-radius: 50% !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15) !important; + top: 50% !important; + transform: translate(-50%, -50%) !important; + transition: all 0.1s ease !important; + + &:hover { + transform: translate(-50%, -50%) scale(1.1) !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important; + } + + &:active { + transform: translate(-50%, -50%) scale(1.15) !important; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.25) !important; + } + } + + // 确保滑块在正确位置 + .nut-range__button-wrapper { + top: 50% !important; + transform: translateY(-50%) !important; + } + } + + // 响应式设计 + @media (max-width: 480px) { + &__track { + padding: 0 15px; + } + + &__nutui-wrapper { + .nut-range__button { + width: 20px !important; + height: 20px !important; + } + } + + &__title { + font-size: 14px; + } + + &__label { + font-size: 12px; + } + } +} + +// 全局NutUI样式覆盖 +.nut-range { + width: 100% !important; + height: 100% !important; + + .nut-range__bar-box { + margin: 0 !important; + padding: 0 !important; + } + + .nut-range__bar { + margin: 0 !important; + } +} + +.ntrp-range__header { + line-height: 20px; + display: flex; + align-items: center; + justify-content: space-between; + .ntrp-range__header-left { + display: flex; + align-items: center; + gap: 8px; + } + .ntrp-range__title { + font-weight: 600; + font-size: 14px; + color: #000000; + } + + .ntrp-range__content { + font-weight: 400; + font-size: 14px; + color: #3c3c34; + } +} + +.rangeWrapper { + display: flex; + align-items: center; + justify-content: space-between; + gap: 5px; + height: 44px; + border-radius: 12px; + border: 1px solid #e0e0e0; + padding: 0 10px; + .rangeHandle { + .nut-range-mark { + padding-top: 28px; + } + .nut-range-bar { + background: #000000; + height: 6px; + } + .nut-range-button { + border: none; + box-shadow: 0 6px 13px 0 rgba(0, 0, 0, 0.12); + } + + .nut-range-tick { + background: #3c3c3c; + height: 4px !important; + width: 4px !important; + } + } + + span { + font-size: 12px; + color: #999; + } + .rangeWrapper__min, + .rangeWrapper__max { + flex-shrink: 0; + font-size: 12px; + color: #000000; + } +} diff --git a/src/components/Range/index.tsx b/src/components/Range/index.tsx new file mode 100644 index 0000000..0523235 --- /dev/null +++ b/src/components/Range/index.tsx @@ -0,0 +1,91 @@ +import React, { useState, useEffect, useMemo } from "react"; +import { Range } from "@nutui/nutui-react-taro"; +import "./index.scss"; + +interface RangeProps { + min?: number; + max?: number; + step?: number; + value?: [number, number]; + onChange?: (value: [number, number]) => void; + disabled?: boolean; +} + +const NtrpRange: React.FC = ({ + min = 1.0, + max = 5.0, + step = 0.5, + value = [min, max], + onChange, + disabled = false, +}) => { + const [currentValue, setCurrentValue] = useState<[number, number]>(value); + + useEffect(() => { + setCurrentValue(value); + }, [value]); + + const handleChange = (val: [number, number]) => { + console.log("Range value changed:", val); + setCurrentValue(val); + onChange?.(val); + }; + + const marks = useMemo(() => { + let marksMap = {}; + for (let i = min + step; i < max; i += step) { + marksMap[i] = ""; + } + return marksMap; + }, [min, max, step]); + + const rangContent = useMemo(() => { + const [start, end] = currentValue || []; + if (start === min && end === max) { + return "不限"; + } + return `${start.toFixed(1)} - ${end.toFixed(1)}之间`; + }, [currentValue, min, max]); + + return ( +
+
+
+
icon
+

NTRP水平区间

+
+

{rangContent}

+
+ +
+
+ {min} + + {max} +
+
+ + {/* 调试信息 */} +
+ 当前值: {currentValue[0].toFixed(1)} - {currentValue[1].toFixed(1)} +
+
+ ); +}; + +export default NtrpRange; diff --git a/src/components/Range/simple-test.tsx b/src/components/Range/simple-test.tsx new file mode 100644 index 0000000..f874642 --- /dev/null +++ b/src/components/Range/simple-test.tsx @@ -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 ( +
+

简单测试

+ +

当前值: {value[0]} - {value[1]}

+
+ ); +}; + +export default SimpleTest; diff --git a/src/components/Range/style-test.tsx b/src/components/Range/style-test.tsx new file mode 100644 index 0000000..60134d0 --- /dev/null +++ b/src/components/Range/style-test.tsx @@ -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 ( +
+

NtrpRange 样式测试

+ +
+

NTRP 水平区间选择器

+ +
+ 当前选择范围: {value[0]} - {value[1]} +
+
+ +
+

自定义范围选择器

+ console.log('Custom range:', val)} + /> +
+ 固定范围: 20 - 80 +
+
+ +
+

禁用状态

+ +
+ 此选择器已被禁用 +
+
+ +
+

样式说明

+
+

✅ 网球图标 + "NTRP水平区间"标题

+

✅ 左右范围标签("2.0及以下"、"4.0及以上")

+

✅ 圆角矩形轨道容器,带有边框和阴影

+

✅ 白色圆形滑块手柄,黑色边框和阴影

+

✅ 黑色轨道填充条

+

✅ 五个浅灰色标记点

+
+
+
+ ); +}; + +export default StyleTest; diff --git a/src/components/Range/test.tsx b/src/components/Range/test.tsx new file mode 100644 index 0000000..624772c --- /dev/null +++ b/src/components/Range/test.tsx @@ -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 ( +
+

NtrpRange 组件测试

+ +
+ +
+ +
+ 当前值: {value[0].toFixed(1)} - {value[1].toFixed(1)} +
+ +
+

测试说明:

+

1. 拖拽左右滑块手柄

+

2. 观察值的变化

+

3. 检查样式是否正确

+
+
+ ); +}; + +export default TestPage; diff --git a/src/pages/list/index.config.ts b/src/pages/list/index.config.ts new file mode 100644 index 0000000..5c5ad16 --- /dev/null +++ b/src/pages/list/index.config.ts @@ -0,0 +1,5 @@ +export default definePageConfig({ + navigationBarTitleText: '列表', + enablePullDownRefresh: true, + backgroundTextStyle: 'dark' +}) diff --git a/src/pages/list/index.scss b/src/pages/list/index.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/pages/list/index.tsx b/src/pages/list/index.tsx new file mode 100644 index 0000000..14b6563 --- /dev/null +++ b/src/pages/list/index.tsx @@ -0,0 +1,209 @@ +import ListItem from "../../components/ListItem"; +import List from "../../components/List"; +import Bubble from "../../components/Bubble/example"; +import Range from "../../components/Range/example"; +import "./index.scss"; +import { useEffect } from "react"; +import Taro from "@tarojs/taro"; +import { + useTennisMatches, + useTennisLoading, + useTennisError, + useTennisLastRefresh, + useTennisActions, +} from "../../store/listStore"; + +const ListPage = () => { + // 从 store 获取数据和方法 + const matches = useTennisMatches(); + const loading = useTennisLoading(); + const error = useTennisError(); + const lastRefreshTime = useTennisLastRefresh(); + const { fetchMatches, refreshMatches, clearError } = useTennisActions(); + + useEffect(() => { + // 页面加载时获取数据 + fetchMatches(); + }, [fetchMatches]); + + // 下拉刷新处理函数 - 使用Taro生命周期钩子 + Taro.usePullDownRefresh(() => { + console.log("触发下拉刷新"); + + // 调用 store 的刷新方法 + refreshMatches() + .then(() => { + // 刷新完成后停止下拉刷新动画 + Taro.stopPullDownRefresh(); + + // 显示刷新成功提示 + Taro.showToast({ + title: "刷新成功", + icon: "success", + duration: 1500, + }); + }) + .catch(() => { + // 刷新失败时也停止动画 + Taro.stopPullDownRefresh(); + + Taro.showToast({ + title: "刷新失败", + icon: "error", + duration: 1500, + }); + }); + }); + + // 错误处理 + useEffect(() => { + if (error) { + Taro.showToast({ + title: error, + icon: "error", + duration: 2000, + }); + // 3秒后自动清除错误 + setTimeout(() => { + clearError(); + }, 3000); + } + }, [error, clearError]); + + // 格式化时间显示 + const formatRefreshTime = (timeString: string | null) => { + if (!timeString) return ""; + const date = new Date(timeString); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + const minutes = Math.floor(diff / 60000); + + if (minutes < 1) return "刚刚"; + if (minutes < 60) return `${minutes}分钟前`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}小时前`; + return date.toLocaleDateString(); + }; + + // 加载状态显示 + if (loading && matches.length === 0) { + return ( +
+
加载中...
+
+ 正在获取网球比赛数据 +
+
+ ); + } + + // 错误状态显示 + if (error && matches.length === 0) { + return ( +
+
加载失败
+
+ {error} +
+ +
+ ); + } + + return ( +
+ {/* 状态信息栏 */} + {lastRefreshTime && ( +
+ 最后更新: {formatRefreshTime(lastRefreshTime)} | 共 {matches.length}{" "} + 场比赛 +
+ )} + + {/* 范围选择 */} + + + {/* 气泡 */} + + + {/* 列表内容 */} + + {matches.map((match, index) => ( + + ))} + + + {/* 空状态 */} + {!loading && matches.length === 0 && ( +
+
暂无比赛数据
+ +
+ )} +
+ ); +}; + +export default ListPage; diff --git a/src/services/listApi.ts b/src/services/listApi.ts new file mode 100644 index 0000000..a937558 --- /dev/null +++ b/src/services/listApi.ts @@ -0,0 +1,223 @@ +import { TennisMatch } from '../store/listStore' + +// 模拟网络延迟 +const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +// 模拟API响应格式 +interface ApiResponse { + 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 + */ +export const getTennisMatches = async (params?: { + page?: number + pageSize?: number + location?: string + skillLevel?: string +}): Promise => { + 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 + */ +export const refreshTennisMatches = async (): Promise => { + 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 + */ +export const getTennisMatchDetail = async (id: string): Promise => { + 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() + } +} diff --git a/src/store/listStore.ts b/src/store/listStore.ts new file mode 100644 index 0000000..d64d49e --- /dev/null +++ b/src/store/listStore.ts @@ -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 + refreshMatches: () => Promise + clearError: () => void +} + +// 完整的 Store 类型 +type TennisStore = TennisState & TennisActions + +// 创建 store +export const useTennisStore = create()((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 +}))