diff --git a/config/index.ts b/config/index.ts index 267a4ac..e36b8e5 100644 --- a/config/index.ts +++ b/config/index.ts @@ -58,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]' @@ -87,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]' 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..ea3393a 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,5 +1,6 @@ export default defineAppConfig({ pages: [ + 'pages/list/index', 'pages/index/index' ], window: { diff --git a/src/components/Bubble/BubbleItem.tsx b/src/components/Bubble/BubbleItem.tsx new file mode 100644 index 0000000..0ede586 --- /dev/null +++ b/src/components/Bubble/BubbleItem.tsx @@ -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 = ({ + option, + isSelected, + size, + disabled, + onClick, + itemClassName +}) => { + 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.module.scss b/src/components/Bubble/bubbleItem.module.scss new file mode 100644 index 0000000..a56297d --- /dev/null +++ b/src/components/Bubble/bubbleItem.module.scss @@ -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; + } +} \ 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.module.scss b/src/components/Bubble/index.module.scss new file mode 100644 index 0000000..8a46b7b --- /dev/null +++ b/src/components/Bubble/index.module.scss @@ -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%; + } + } +} diff --git a/src/components/Bubble/index.tsx b/src/components/Bubble/index.tsx new file mode 100644 index 0000000..264de4e --- /dev/null +++ b/src/components/Bubble/index.tsx @@ -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 = ({ + 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 = () => ( +
+ {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/CityFilter/example.tsx b/src/components/CityFilter/example.tsx new file mode 100644 index 0000000..022297a --- /dev/null +++ b/src/components/CityFilter/example.tsx @@ -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 ( + setValue(val)} + /> + ); +} diff --git a/src/components/CityFilter/index.module.scss b/src/components/CityFilter/index.module.scss new file mode 100644 index 0000000..aa8442f --- /dev/null +++ b/src/components/CityFilter/index.module.scss @@ -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; + } +} diff --git a/src/components/CityFilter/index.tsx b/src/components/CityFilter/index.tsx new file mode 100644 index 0000000..db40f86 --- /dev/null +++ b/src/components/CityFilter/index.tsx @@ -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 ( + + +
+

当前位置

+

上海市

+
+
+ +
+
+
+ ); +}; + +export default MenuComponent; 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/Menu/example.tsx b/src/components/Menu/example.tsx new file mode 100644 index 0000000..022297a --- /dev/null +++ b/src/components/Menu/example.tsx @@ -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 ( + setValue(val)} + /> + ); +} diff --git a/src/components/Menu/index.module.scss b/src/components/Menu/index.module.scss new file mode 100644 index 0000000..0867087 --- /dev/null +++ b/src/components/Menu/index.module.scss @@ -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; + } +} diff --git a/src/components/Menu/index.tsx b/src/components/Menu/index.tsx new file mode 100644 index 0000000..0f3d435 --- /dev/null +++ b/src/components/Menu/index.tsx @@ -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 ( + + + + ); +}; + +export default MenuComponent; 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.module.scss b/src/components/Range/index.module.scss new file mode 100644 index 0000000..571e63d --- /dev/null +++ b/src/components/Range/index.module.scss @@ -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; + } +} diff --git a/src/components/Range/index.tsx b/src/components/Range/index.tsx new file mode 100644 index 0000000..84fe4b1 --- /dev/null +++ b/src/components/Range/index.tsx @@ -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 = ({ + 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 ( +
+
+ {/*
+
icon
+

NTRP水平区间

+
*/} + +

{rangContent}

+
+ +
+
+ {min.toFixed(1)} + + {max.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/components/SearchBar/index.module.scss b/src/components/SearchBar/index.module.scss new file mode 100644 index 0000000..b376efc --- /dev/null +++ b/src/components/SearchBar/index.module.scss @@ -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; + } +} \ No newline at end of file diff --git a/src/components/SearchBar/index.tsx b/src/components/SearchBar/index.tsx new file mode 100644 index 0000000..60fd06f --- /dev/null +++ b/src/components/SearchBar/index.tsx @@ -0,0 +1,22 @@ + +import { SearchBar } from '@nutui/nutui-react-taro' +import styles from './index.module.scss' + +const SearchBarComponent = () => { + return ( + <> + 123 + // } + right={ +
+ } + className={styles.searchBar} + placeholder='搜索上海的球局和场地' + /> + + ) +} + +export default SearchBarComponent \ No newline at end of file diff --git a/src/components/Title/index.module.scss b/src/components/Title/index.module.scss new file mode 100644 index 0000000..a731606 --- /dev/null +++ b/src/components/Title/index.module.scss @@ -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; +} diff --git a/src/components/Title/index.tsx b/src/components/Title/index.tsx new file mode 100644 index 0000000..2eca985 --- /dev/null +++ b/src/components/Title/index.tsx @@ -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 ( + <> +
+
+

{title}

+
+ + ); +}; +export default TitleComponent; diff --git a/src/pages/list/FilterPopup.tsx b/src/pages/list/FilterPopup.tsx new file mode 100644 index 0000000..1e31276 --- /dev/null +++ b/src/pages/list/FilterPopup.tsx @@ -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 ( + <> + { + // setShowTop(false) + }} + > +
+ {/* 时间气泡选项 */} + {}} + onChange={(value) => {}} + layout="grid" + size="small" + columns={3} + /> + + {/* 范围选择 */} + + + {/* 场次气泡选项 */} +
+ + {}} + onChange={(value) => {}} + layout="grid" + size="small" + columns={3} + /> +
+
+
+ + ); +}; + +export default FilterPopup; diff --git a/src/pages/list/filterPopup.module.scss b/src/pages/list/filterPopup.module.scss new file mode 100644 index 0000000..c1e834a --- /dev/null +++ b/src/pages/list/filterPopup.module.scss @@ -0,0 +1,8 @@ +.filterPopupWrapper { + $m18: 18px; + padding: $m18; + .filterPopupRange { + margin-top: $m18; + margin-bottom: $m18; + } +} \ No newline at end of file diff --git a/src/pages/list/index.config.ts b/src/pages/list/index.config.ts new file mode 100644 index 0000000..17f6db0 --- /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..7587ed3 --- /dev/null +++ b/src/pages/list/index.tsx @@ -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 ( +
+
加载中...
+
+ 正在获取网球比赛数据 +
+
+ ); + } + + // 错误状态显示 + if (error && matches.length === 0) { + return ( +
+
加载失败
+
+ {error} +
+ +
+ ); + } + + return ( +
+ + {/* 综合筛选 */} +
+ +
+ {/* 筛选 */} +
+ {/* 全城筛选 */} + + {/* 智能排序 */} + +
+ + + + {/* 列表内容 */} + + {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 +})) diff --git a/types/css-modules.d.ts b/types/css-modules.d.ts new file mode 100644 index 0000000..01d9fe6 --- /dev/null +++ b/types/css-modules.d.ts @@ -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; +}