合并代码
This commit is contained in:
@@ -1,16 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BubbleOption } from './index';
|
||||
import { BubbleItemProps } from '../../../types/list/types';
|
||||
import styles from './bubbleItem.module.scss';
|
||||
|
||||
export interface BubbleItemProps {
|
||||
option: BubbleOption;
|
||||
isSelected: boolean;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
disabled: boolean;
|
||||
onClick: (option: BubbleOption) => void;
|
||||
itemClassName?: string;
|
||||
}
|
||||
|
||||
const BubbleItem: React.FC<BubbleItemProps> = ({
|
||||
option,
|
||||
isSelected,
|
||||
|
||||
@@ -1,208 +0,0 @@
|
||||
# Bubble 通用气泡组件
|
||||
|
||||
一个高度可配置的气泡选择器组件,支持任何内容的选择,包括但不限于时间、地点、标签、分类等。
|
||||
|
||||
## 特性
|
||||
|
||||
- 🎯 支持单选和多选模式
|
||||
- 📱 三种布局方式:水平、垂直、网格
|
||||
- 🎨 三种尺寸:小、中、大
|
||||
- ♿ 支持禁用状态和图标描述
|
||||
- 🔄 支持受控和非受控模式
|
||||
- 📱 响应式设计,自动适应不同屏幕
|
||||
- 🎨 可自定义样式和类名
|
||||
|
||||
## 基本用法
|
||||
|
||||
### 室内外选择示例(如UI图所示)
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import Bubble, { BubbleOption } from './index';
|
||||
|
||||
const LocationSelector: React.FC = () => {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>('');
|
||||
|
||||
const locationOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '室内', value: 'indoor' },
|
||||
{ id: 2, label: '室外', value: 'outdoor' },
|
||||
{ id: 3, label: '半室外', value: 'semi-outdoor' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Bubble
|
||||
options={locationOptions}
|
||||
value={selectedLocation}
|
||||
onChange={(value) => setSelectedLocation(value as string)}
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 时间选择器示例
|
||||
|
||||
```tsx
|
||||
const TimeSelector: React.FC = () => {
|
||||
const [selectedTime, setSelectedTime] = useState<string>('');
|
||||
|
||||
const timeOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
|
||||
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
|
||||
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
|
||||
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
|
||||
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
|
||||
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Bubble
|
||||
options={timeOptions}
|
||||
value={selectedTime}
|
||||
onChange={(value) => setSelectedTime(value as string)}
|
||||
layout="grid"
|
||||
columns={3}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 多选模式
|
||||
|
||||
```tsx
|
||||
const MultiSelectExample: React.FC = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{ id: 1, label: '运动', value: 'sports' },
|
||||
{ id: 2, label: '音乐', value: 'music' },
|
||||
{ id: 3, label: '阅读', value: 'reading' },
|
||||
{ id: 4, label: '旅行', value: 'travel' }
|
||||
];
|
||||
|
||||
return (
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
onChange={(value) => setSelectedValues(value as string[])}
|
||||
multiple={true}
|
||||
layout="grid"
|
||||
columns={2}
|
||||
size="medium"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 带图标和描述
|
||||
|
||||
```tsx
|
||||
const IconExample: React.FC = () => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>('');
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{
|
||||
id: 1,
|
||||
label: '网球',
|
||||
value: 'tennis',
|
||||
icon: '🎾',
|
||||
description: '室内外均可'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '篮球',
|
||||
value: 'basketball',
|
||||
icon: '🏀',
|
||||
description: '室内场地'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={(value) => setSelectedValue(value as string)}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### BubbleProps
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| options | `BubbleOption[]` | - | 选项数组 |
|
||||
| value | `string \| number \| (string \| number)[]` | - | 当前选中的值 |
|
||||
| onChange | `(value, option) => void` | - | 选择变化时的回调 |
|
||||
| multiple | `boolean` | `false` | 是否支持多选 |
|
||||
| layout | `'horizontal' \| 'vertical' \| 'grid'` | `'horizontal'` | 布局方式 |
|
||||
| columns | `number` | `3` | 网格布局的列数 |
|
||||
| size | `'small' \| 'medium' \| 'large'` | `'medium'` | 按钮尺寸 |
|
||||
| className | `string` | `''` | 自定义类名 |
|
||||
| style | `React.CSSProperties` | `{}` | 自定义样式 |
|
||||
| disabled | `boolean` | `false` | 是否禁用整个组件 |
|
||||
|
||||
### BubbleOption
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| id | `string \| number` | - | 选项的唯一标识 |
|
||||
| label | `string` | - | 显示的文本 |
|
||||
| value | `string \| number` | - | 选项的值 |
|
||||
| disabled | `boolean` | `false` | 是否禁用 |
|
||||
| icon | `React.ReactNode` | - | 可选的图标 |
|
||||
| description | `string` | - | 可选的描述文本 |
|
||||
|
||||
## 布局说明
|
||||
|
||||
### 水平布局 (horizontal)
|
||||
- 适合选项较少的情况
|
||||
- 自动换行,响应式设计
|
||||
- 适合顶部导航、标签选择等
|
||||
|
||||
### 垂直布局 (vertical)
|
||||
- 适合选项较多的情况
|
||||
- 每个选项占满一行
|
||||
- 适合侧边栏、设置页面等
|
||||
|
||||
### 网格布局 (grid)
|
||||
- 适合选项较多且需要整齐排列的情况
|
||||
- 可自定义列数
|
||||
- 适合分类选择、时间选择等
|
||||
|
||||
## 尺寸说明
|
||||
|
||||
### 小尺寸 (small)
|
||||
- 适合紧凑的界面
|
||||
- 适合移动端或空间受限的场景
|
||||
|
||||
### 中尺寸 (medium)
|
||||
- 默认尺寸,适合大多数场景
|
||||
- 平衡了可用性和美观性
|
||||
|
||||
### 大尺寸 (large)
|
||||
- 适合需要突出显示的场景
|
||||
- 适合触摸设备或重要操作
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用 SCSS 编写,可以通过以下方式自定义样式:
|
||||
|
||||
1. 覆盖 CSS 变量
|
||||
2. 使用 `className` 和 `style` 属性
|
||||
3. 修改 SCSS 源文件
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 在网格布局中,`columns` 属性控制列数,行数会根据选项数量自动计算
|
||||
- 多选模式下,`value` 应该是数组类型
|
||||
- 单选模式下,`value` 可以是字符串或数字类型
|
||||
- 组件会自动处理选中状态的样式变化
|
||||
- 支持图标和描述,让选项更加丰富
|
||||
- 响应式设计,自动适应不同屏幕尺寸
|
||||
@@ -1,211 +0,0 @@
|
||||
# 如何使用 Bubble 通用气泡组件
|
||||
|
||||
## 在其他组件中导入
|
||||
|
||||
```tsx
|
||||
import Bubble, { BubbleOption } from '@/components/Bubble';
|
||||
```
|
||||
|
||||
## 基本使用示例
|
||||
|
||||
### 1. 室内外选择(如UI图所示)
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import Bubble, { BubbleOption } from '@/components/Bubble';
|
||||
|
||||
const MyPage: React.FC = () => {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>('');
|
||||
|
||||
const locationOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '室内', value: 'indoor' },
|
||||
{ id: 2, label: '室外', value: 'outdoor' },
|
||||
{ id: 3, label: '半室外', value: 'semi-outdoor' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>选择场地类型</h2>
|
||||
<Bubble
|
||||
options={locationOptions}
|
||||
value={selectedLocation}
|
||||
onChange={(value) => setSelectedLocation(value as string)}
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
/>
|
||||
<p>您选择的场地类型: {selectedLocation}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 时间选择器
|
||||
|
||||
```tsx
|
||||
const [selectedTime, setSelectedTime] = useState<string>('');
|
||||
|
||||
const timeOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
|
||||
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
|
||||
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
|
||||
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
|
||||
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
|
||||
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
|
||||
];
|
||||
|
||||
<Bubble
|
||||
options={timeOptions}
|
||||
value={selectedTime}
|
||||
onChange={(value) => setSelectedTime(value as string)}
|
||||
layout="grid"
|
||||
columns={3}
|
||||
size="medium"
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 多选模式
|
||||
|
||||
```tsx
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{ id: 1, label: '运动', value: 'sports' },
|
||||
{ id: 2, label: '音乐', value: 'music' },
|
||||
{ id: 3, label: '阅读', value: 'reading' },
|
||||
{ id: 4, label: '旅行', value: 'travel' }
|
||||
];
|
||||
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
onChange={(value) => setSelectedValues(value as string[])}
|
||||
multiple={true}
|
||||
layout="grid"
|
||||
columns={2}
|
||||
size="medium"
|
||||
/>
|
||||
```
|
||||
|
||||
### 4. 带图标和描述
|
||||
|
||||
```tsx
|
||||
const [selectedSport, setSelectedSport] = useState<string>('');
|
||||
|
||||
const sportOptions: BubbleOption[] = [
|
||||
{
|
||||
id: 1,
|
||||
label: '网球',
|
||||
value: 'tennis',
|
||||
icon: '🎾',
|
||||
description: '室内外均可'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '篮球',
|
||||
value: 'basketball',
|
||||
icon: '🏀',
|
||||
description: '室内场地'
|
||||
}
|
||||
];
|
||||
|
||||
<Bubble
|
||||
options={sportOptions}
|
||||
value={selectedSport}
|
||||
onChange={(value) => setSelectedSport(value as string)}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
/>
|
||||
```
|
||||
|
||||
### 5. 不同布局方式
|
||||
|
||||
```tsx
|
||||
// 水平布局 - 适合选项较少
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
// 垂直布局 - 适合选项较多
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="vertical"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
// 网格布局 - 适合需要整齐排列
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="grid"
|
||||
columns={3}
|
||||
size="medium"
|
||||
/>
|
||||
```
|
||||
|
||||
### 6. 不同尺寸
|
||||
|
||||
```tsx
|
||||
// 小尺寸 - 适合紧凑界面
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="horizontal"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
// 中尺寸 - 默认尺寸
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
// 大尺寸 - 适合触摸设备
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={handleChange}
|
||||
layout="horizontal"
|
||||
size="large"
|
||||
/>
|
||||
```
|
||||
|
||||
## 组件特性
|
||||
|
||||
- **通用性**: 不局限于特定功能,可用于任何选择场景
|
||||
- **灵活布局**: 支持水平、垂直、网格三种布局方式
|
||||
- **多尺寸支持**: 小、中、大三种尺寸适应不同场景
|
||||
- **丰富内容**: 支持图标和描述,让选项更加丰富
|
||||
- **响应式设计**: 自动适应不同屏幕尺寸
|
||||
- **状态管理**: 内置选中状态管理,支持单选和多选
|
||||
- **类型安全**: 完整的 TypeScript 类型定义
|
||||
- **可访问性**: 支持键盘导航和屏幕阅读器
|
||||
|
||||
## 常见使用场景
|
||||
|
||||
1. **场地选择**: 室内/室外/半室外
|
||||
2. **时间选择**: 时间段、日期范围
|
||||
3. **分类选择**: 兴趣爱好、技能标签
|
||||
4. **设置选项**: 主题、语言、通知设置
|
||||
5. **筛选条件**: 价格范围、评分、距离等
|
||||
6. **导航菜单**: 顶部导航、侧边栏菜单
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保传入的 `options` 数组不为空
|
||||
2. 多选模式下,`value` 应该是数组类型
|
||||
3. 单选模式下,`value` 可以是字符串或数字类型
|
||||
4. 组件会自动处理选中状态的样式变化
|
||||
5. 支持图标和描述,让选项更加丰富
|
||||
6. 响应式设计,自动适应不同屏幕尺寸
|
||||
7. 可以通过 `disabled` 属性禁用整个组件或单个选项
|
||||
@@ -1,235 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import Bubble, { BubbleOption } from './index';
|
||||
|
||||
// 室内外选择示例(如UI图所示)
|
||||
export const LocationSelector: React.FC = () => {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>('');
|
||||
|
||||
const locationOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '室内', value: 'indoor' },
|
||||
{ id: 2, label: '室外', value: 'outdoor' },
|
||||
{ id: 3, label: '半室外', value: 'semi-outdoor' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>选择场地类型</h3>
|
||||
<Bubble
|
||||
options={locationOptions}
|
||||
value={selectedLocation}
|
||||
onChange={(value) => setSelectedLocation(value as string)}
|
||||
layout="horizontal"
|
||||
size="small"
|
||||
/>
|
||||
<p>当前选择: {selectedLocation || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 时间选择器示例
|
||||
export const TimeSelector: React.FC = () => {
|
||||
const [selectedTime, setSelectedTime] = useState<string>('');
|
||||
|
||||
const timeOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '晨间 6:00-10:00', value: 'morning' },
|
||||
{ id: 2, label: '上午 10:00-12:00', value: 'forenoon' },
|
||||
{ id: 3, label: '中午 12:00-14:00', value: 'noon' },
|
||||
{ id: 4, label: '下午 14:00-18:00', value: 'afternoon' },
|
||||
{ id: 5, label: '晚上 18:00-22:00', value: 'evening' },
|
||||
{ id: 6, label: '夜间 22:00-24:00', value: 'night' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3>选择时间段</h3>
|
||||
<Bubble
|
||||
options={timeOptions}
|
||||
value={selectedTime}
|
||||
onChange={(value) => setSelectedTime(value as string)}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={3}
|
||||
/>
|
||||
<p>当前选择: {selectedTime || '未选择'}</p>
|
||||
<hr />
|
||||
<h3>选择时间段</h3>
|
||||
<Bubble
|
||||
options={timeOptions}
|
||||
value={selectedTime}
|
||||
onChange={(value) => setSelectedTime(value as string)}
|
||||
layout="grid"
|
||||
columns={2}
|
||||
size="small"
|
||||
/>
|
||||
<p>当前选择: {selectedTime || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 多选示例
|
||||
export const MultiSelectExample: React.FC = () => {
|
||||
const [selectedValues, setSelectedValues] = useState<string[]>([]);
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{ id: 1, label: '运动', value: 'sports' },
|
||||
{ id: 2, label: '音乐', value: 'music' },
|
||||
{ id: 3, label: '阅读', value: 'reading' },
|
||||
{ id: 4, label: '旅行', value: 'travel' },
|
||||
{ id: 5, label: '美食', value: 'food' },
|
||||
{ id: 6, label: '摄影', value: 'photography' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>多选示例 - 兴趣爱好</h3>
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValues}
|
||||
onChange={(value) => setSelectedValues(value as string[])}
|
||||
multiple={true}
|
||||
layout="grid"
|
||||
columns={3}
|
||||
size="medium"
|
||||
/>
|
||||
<p>当前选择: {selectedValues.join(', ') || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 带图标和描述的示例
|
||||
export const IconExample: React.FC = () => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>('');
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{
|
||||
id: 1,
|
||||
label: '网球',
|
||||
value: 'tennis',
|
||||
icon: '🎾',
|
||||
description: '室内外均可'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: '篮球',
|
||||
value: 'basketball',
|
||||
icon: '🏀',
|
||||
description: '室内场地'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: '足球',
|
||||
value: 'football',
|
||||
icon: '⚽',
|
||||
description: '室外场地'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>带图标和描述的运动选择</h3>
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={(value) => setSelectedValue(value as string)}
|
||||
layout="vertical"
|
||||
size="large"
|
||||
/>
|
||||
<p>当前选择: {selectedValue || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 带禁用状态的示例
|
||||
export const DisabledExample: React.FC = () => {
|
||||
const [selectedValue, setSelectedValue] = useState<string>('');
|
||||
|
||||
const options: BubbleOption[] = [
|
||||
{ id: 1, label: '可用选项1', value: 'option1' },
|
||||
{ id: 2, label: '禁用选项2', value: 'option2', disabled: true },
|
||||
{ id: 3, label: '可用选项3', value: 'option3' },
|
||||
{ id: 4, label: '禁用选项4', value: 'option4', disabled: true }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>带禁用状态的示例</h3>
|
||||
<Bubble
|
||||
options={options}
|
||||
value={selectedValue}
|
||||
onChange={(value) => setSelectedValue(value as string)}
|
||||
layout="grid"
|
||||
columns={2}
|
||||
size="medium"
|
||||
/>
|
||||
<p>当前选择: {selectedValue || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 不同尺寸的示例
|
||||
export const SizeExample: React.FC = () => {
|
||||
const [selectedSize, setSelectedSize] = useState<string>('');
|
||||
|
||||
const sizeOptions: BubbleOption[] = [
|
||||
{ id: 1, label: '小尺寸', value: 'small' },
|
||||
{ id: 2, label: '中尺寸', value: 'medium' },
|
||||
{ id: 3, label: '大尺寸', value: 'large' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h3>不同尺寸的示例</h3>
|
||||
|
||||
<h4>小尺寸</h4>
|
||||
<Bubble
|
||||
options={sizeOptions}
|
||||
value={selectedSize}
|
||||
onChange={(value) => setSelectedSize(value as string)}
|
||||
layout="horizontal"
|
||||
size="small"
|
||||
/>
|
||||
|
||||
<h4>中尺寸</h4>
|
||||
<Bubble
|
||||
options={sizeOptions}
|
||||
value={selectedSize}
|
||||
onChange={(value) => setSelectedSize(value as string)}
|
||||
layout="horizontal"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<h4>大尺寸</h4>
|
||||
<Bubble
|
||||
options={sizeOptions}
|
||||
value={selectedSize}
|
||||
onChange={(value) => setSelectedSize(value as string)}
|
||||
layout="horizontal"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<p>当前选择: {selectedSize || '未选择'}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 主示例组件
|
||||
export const BubbleExamples: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Bubble 通用气泡组件示例</h1>
|
||||
<LocationSelector />
|
||||
<hr />
|
||||
<TimeSelector />
|
||||
<hr />
|
||||
<MultiSelectExample />
|
||||
<hr />
|
||||
<IconExample />
|
||||
<hr />
|
||||
<DisabledExample />
|
||||
<hr />
|
||||
<SizeExample />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BubbleExamples;
|
||||
@@ -1,32 +1,7 @@
|
||||
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;
|
||||
}
|
||||
import {BubbleOption, BubbleProps} from '../../../types/list/types'
|
||||
|
||||
const Bubble: React.FC<BubbleProps> = ({
|
||||
options,
|
||||
@@ -40,6 +15,7 @@ const Bubble: React.FC<BubbleProps> = ({
|
||||
itemClassName = "",
|
||||
style = {},
|
||||
disabled = false,
|
||||
name,
|
||||
}) => {
|
||||
const [selectedValues, setSelectedValues] = useState<(string | number)[]>([]);
|
||||
|
||||
@@ -74,9 +50,10 @@ const Bubble: React.FC<BubbleProps> = ({
|
||||
const selectedOptions = options.filter((opt) =>
|
||||
newSelectedValues.includes(opt.value)
|
||||
);
|
||||
onChange(newSelectedValues, selectedOptions);
|
||||
onChange(name, newSelectedValues, selectedOptions);
|
||||
} else {
|
||||
onChange(option.value, option);
|
||||
console.log('===111', name, option.value)
|
||||
onChange(name, option.value, option);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import MenuComponent from "./index";
|
||||
|
||||
export default function Example() {
|
||||
const [value, setValue] = useState("a");
|
||||
const options = [
|
||||
{ text: "默认排序", value: "a" },
|
||||
{ text: "好评排序", value: "b" },
|
||||
{ text: "销量排序", value: "c" },
|
||||
];
|
||||
return (
|
||||
<MenuComponent
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,53 @@
|
||||
.menuWrap {
|
||||
padding: 5px 20px 10px;
|
||||
$height: 26px;
|
||||
|
||||
.menuIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
border-bottom-left-radius: 30px;
|
||||
border-bottom-right-radius: 30px;
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.nut-menu-bar {
|
||||
:global(.nut-menu-bar) {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.nut-menu-bar) {
|
||||
color: #000000;
|
||||
line-height: 1;
|
||||
width: 66px;
|
||||
height: $height;
|
||||
border-radius: $height;
|
||||
line-height: $height;
|
||||
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;
|
||||
}
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nut-menu-title-text {
|
||||
padding-left: 0;
|
||||
}
|
||||
:global(.nut-menu-title) {
|
||||
color: inherit !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.nut-menu-title-text) {
|
||||
--nutui-menu-title-padding: 0 6px 0 0;
|
||||
}
|
||||
|
||||
.positionWrap {
|
||||
@@ -48,13 +66,15 @@
|
||||
.cityName {
|
||||
font-size: 13px;
|
||||
font-weight: 400;
|
||||
color: #3C3C43;
|
||||
color: #3c3c43;
|
||||
}
|
||||
|
||||
.distanceWrap {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.distanceBubbleItem {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,33 @@
|
||||
import { Menu } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import { useState, useRef } from "react";
|
||||
import Bubble, { BubbleOption } from "../Bubble";
|
||||
import Bubble from "../Bubble";
|
||||
import { Image } from "@tarojs/components";
|
||||
import img from "../../config/images";
|
||||
import {DistanceFilterProps} from '../../../types/list/types'
|
||||
|
||||
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 MenuComponent = (props: DistanceFilterProps) => {
|
||||
const { value, onChange, wrapperClassName, itemClassName, options, name } =
|
||||
props;
|
||||
const [isChange, setIsChange] = useState(false);
|
||||
const [iOpen, setIsOpen] = useState(false);
|
||||
const itemRef = useRef(null);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
console.log("===value", value);
|
||||
const handleChange = (name: string, value: string) => {
|
||||
setIsChange(true);
|
||||
onChange && onChange(value);
|
||||
onChange && onChange(name, value);
|
||||
(itemRef.current as any)?.toggle(false);
|
||||
};
|
||||
|
||||
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" },
|
||||
];
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -35,11 +35,25 @@ const MenuComponent = (props: IProps) => {
|
||||
isChange ? styles.active : ""
|
||||
}`}
|
||||
activeColor="#000"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
icon={
|
||||
<Image
|
||||
className={`${styles.menuIcon} ${iOpen ? styles.rotate : ""}`}
|
||||
src={isChange ? img.ICON_ARROW_DOWN_WHITE : img.ICON_ARROW_DOWN}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu.Item
|
||||
title="全城"
|
||||
className={`${styles.menuItem} ${itemClassName}`}
|
||||
title={value}
|
||||
className={`${styles.menuItem} ${itemClassName ? itemClassName : ""}`}
|
||||
ref={itemRef}
|
||||
icon={
|
||||
<Image
|
||||
className={styles.itemIcon}
|
||||
src={img.ICON_MENU_ITEM_SELECTED}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className={styles.positionWrap}>
|
||||
<p className={styles.title}>当前位置</p>
|
||||
@@ -54,6 +68,7 @@ const MenuComponent = (props: IProps) => {
|
||||
size="small"
|
||||
columns={4}
|
||||
itemClassName={styles.distanceBubbleItem}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface CommonPopupProps {
|
||||
zIndex?: number
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
|
||||
const CommonPopup: React.FC<CommonPopupProps> = ({
|
||||
@@ -34,6 +35,7 @@ const CommonPopup: React.FC<CommonPopupProps> = ({
|
||||
position = 'bottom',
|
||||
round = true,
|
||||
zIndex,
|
||||
style,
|
||||
children
|
||||
}) => {
|
||||
const handleCancel = () => {
|
||||
@@ -52,7 +54,7 @@ const CommonPopup: React.FC<CommonPopupProps> = ({
|
||||
closeable={false}
|
||||
onClose={onClose}
|
||||
className={`${styles['common-popup']} ${className ? className : ''}`}
|
||||
style={zIndex ? { zIndex } : undefined}
|
||||
style={{ zIndex: zIndex ? zIndex : undefined, ...style }}
|
||||
>
|
||||
{showHeader && (
|
||||
<View className={styles['common-popup__header']}>
|
||||
@@ -78,4 +80,4 @@ const CommonPopup: React.FC<CommonPopupProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default CommonPopup
|
||||
export default CommonPopup
|
||||
3
src/components/CourtType/index.module.scss
Normal file
3
src/components/CourtType/index.module.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.courtTypeWrapper {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
31
src/components/CourtType/index.tsx
Normal file
31
src/components/CourtType/index.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { View, Image } from "@tarojs/components";
|
||||
import TitleComponent from "@/components/Title";
|
||||
import img from "@/config/images";
|
||||
import Bubble from "../Bubble";
|
||||
import { BubbleOption } from "types/list/types";
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface IProps {
|
||||
name: string;
|
||||
options: BubbleOption[];
|
||||
value: string;
|
||||
onChange: (name: string, value: string) => void;
|
||||
}
|
||||
const GamePlayType = (props: IProps) => {
|
||||
const { name, onChange , options, value} = props;
|
||||
return (
|
||||
<View className={styles.courtTypeWrapper}>
|
||||
<TitleComponent title="场地类型" icon={<Image src={img.ICON_SITE} />} />
|
||||
<Bubble
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={3}
|
||||
name={name}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default GamePlayType;
|
||||
50
src/components/CustomNavbar/index.module.scss
Normal file
50
src/components/CustomNavbar/index.module.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
.customerNavbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 999;
|
||||
background-color: #ffffff;
|
||||
|
||||
.container {
|
||||
padding-left: 17px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.line {
|
||||
width: 1px;
|
||||
height: 25px;
|
||||
background-color: #0000000F;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.change {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.cityWrapper {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.city {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.infoWrapper {
|
||||
line-height: 12px;
|
||||
}
|
||||
|
||||
.info {
|
||||
font-weight: 400;
|
||||
font-size: 10px;
|
||||
line-height: 12px;
|
||||
color: #3C3C4399;
|
||||
}
|
||||
}
|
||||
73
src/components/CustomNavbar/index.tsx
Normal file
73
src/components/CustomNavbar/index.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { View, Text, Image } from "@tarojs/components";
|
||||
import img from "@/config/images";
|
||||
import { getCurrentLocation } from "@/utils/locationUtils";
|
||||
import styles from "./index.module.scss";
|
||||
import { useEffect } from "react";
|
||||
import { useGlobalState } from "@/store/global";
|
||||
import { useListState } from "@/store/listStore";
|
||||
|
||||
const ListHeader = () => {
|
||||
const {
|
||||
updateState,
|
||||
location,
|
||||
getLocationText,
|
||||
getLocationLoading,
|
||||
statusNavbarHeightInfo,
|
||||
} = useGlobalState();
|
||||
const { gamesNum } = useListState();
|
||||
console.log("===statusNavbarHeightInfo", statusNavbarHeightInfo);
|
||||
const { statusBarHeight, navbarHeight, totalHeight } = statusNavbarHeightInfo;
|
||||
|
||||
// 获取位置信息
|
||||
const getCurrentLocal = () => {
|
||||
updateState({
|
||||
getLocationLoading: true,
|
||||
});
|
||||
getCurrentLocation().then((res) => {
|
||||
updateState({
|
||||
getLocationLoading: false,
|
||||
location: res || {},
|
||||
});
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
// getNavbarHeightInfo();
|
||||
getCurrentLocal();
|
||||
}, []);
|
||||
|
||||
const currentAddress = getLocationLoading
|
||||
? getLocationText
|
||||
: location?.address;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={styles.customerNavbar}
|
||||
style={{ height: `${totalHeight}px` }}
|
||||
>
|
||||
<View
|
||||
className={styles.container}
|
||||
style={{
|
||||
height: `${navbarHeight}px`,
|
||||
paddingTop: `${statusBarHeight}px`,
|
||||
}}
|
||||
>
|
||||
{/* logo */}
|
||||
<Image src={img.ICON_LOGO} className={styles.logo} />
|
||||
<View className={styles.line} />
|
||||
<View className={styles.content}>
|
||||
<View className={styles.cityWrapper}>
|
||||
{/* 位置 */}
|
||||
<Text className={styles.city}>{currentAddress}</Text>
|
||||
{!getLocationLoading && (
|
||||
<Image src={img.ICON_CHANGE} className={styles.change} />
|
||||
)}
|
||||
</View>
|
||||
<View className={styles.infoWrapper}>
|
||||
<Text className={styles.info}>附近${gamesNum}场球局</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default ListHeader;
|
||||
3
src/components/GamePlayType/index.module.scss
Normal file
3
src/components/GamePlayType/index.module.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.gamePlayWrapper {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
47
src/components/GamePlayType/index.tsx
Normal file
47
src/components/GamePlayType/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
// import PopupGameplay from "../../pages/publishBall/components/PopupGameplay";
|
||||
import { View, Image } from "@tarojs/components";
|
||||
import TitleComponent from "@/components/Title";
|
||||
import img from "@/config/images";
|
||||
import Bubble from "../Bubble";
|
||||
import styles from "./index.module.scss";
|
||||
import { BubbleOption } from "types/list/types";
|
||||
interface IProps {
|
||||
name: string;
|
||||
value: string;
|
||||
options: BubbleOption[];
|
||||
onChange: (name: string, value: string) => void;
|
||||
}
|
||||
const GamePlayType = (props: IProps) => {
|
||||
const { name, onChange, value, options } = props;
|
||||
return (
|
||||
<View className={styles.gamePlayWrapper}>
|
||||
<TitleComponent title="玩法" icon={<Image src={img.ICON_SITE} />} />
|
||||
<Bubble
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
layout="grid"
|
||||
size="small"
|
||||
columns={3}
|
||||
name={name}
|
||||
/>
|
||||
{/* <PopupGameplay
|
||||
onClose={() => {
|
||||
console.log("onClose");
|
||||
}}
|
||||
onConfirm={() => {
|
||||
console.log("onConfirm");
|
||||
}}
|
||||
visible={false}
|
||||
options={[
|
||||
{ label: "不限", value: "不限" },
|
||||
{ label: "单打", value: "单打" },
|
||||
{ label: "双打", value: "双打" },
|
||||
{ label: "娱乐", value: "娱乐" },
|
||||
{ label: "拉球", value: "拉球" },
|
||||
]}
|
||||
/> */}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default GamePlayType;
|
||||
90
src/components/GuideBar/index.scss
Normal file
90
src/components/GuideBar/index.scss
Normal file
@@ -0,0 +1,90 @@
|
||||
@use '~@/scss/images.scss' as img;
|
||||
|
||||
.guide-bar-container {
|
||||
padding-top: calc(60px + 20px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.guide-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: calc(60px + 20px + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 12px env(safe-area-inset-bottom);
|
||||
z-index: 999;
|
||||
|
||||
&-pages {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
width: 240px;
|
||||
height: 60px;
|
||||
padding: 8px 6px;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
||||
background: rgba(255, 255, 255, 0.40);
|
||||
box-shadow: 0 4px 64px 0 rgba(0, 0, 0, 0.16);
|
||||
backdrop-filter: blur(16px);
|
||||
|
||||
&-item {
|
||||
display: flex;
|
||||
width: 76px;
|
||||
height: 48px;
|
||||
// padding: 14px 0;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: rgba(60, 60, 67, 0.60);
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* 125% */
|
||||
}
|
||||
|
||||
&-item-active {
|
||||
display: flex;
|
||||
width: 76px;
|
||||
height: 48px;
|
||||
// padding: 14px 22px;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 999px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
background: #FFF;
|
||||
box-shadow: 0 4px 48px 0 rgba(0, 0, 0, 0.08);
|
||||
color: #000;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
line-height: 20px; /* 125% */
|
||||
}
|
||||
}
|
||||
|
||||
&-publish {
|
||||
display: flex;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 999px;
|
||||
// border: 2px solid rgba(0, 0, 0, 0.06);
|
||||
background: radial-gradient(75.92% 98.69% at 26.67% 8.33%, #BDFF4A 16.88%, #95F23E 54.19%, #32D838 100%);
|
||||
box-shadow: 0 4px 48px 0 rgba(0, 0, 0, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
|
||||
&-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
}
|
||||
}
|
||||
70
src/components/GuideBar/index.tsx
Normal file
70
src/components/GuideBar/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import img from '@/config/images'
|
||||
import './index.scss'
|
||||
|
||||
export type currentPageType = 'games' | 'message' | 'personal'
|
||||
|
||||
const GuideBar = (props) => {
|
||||
const { currentPage } = props
|
||||
|
||||
const guideItems = [
|
||||
{
|
||||
code: 'list',
|
||||
text: '球局',
|
||||
},
|
||||
{
|
||||
code: 'message',
|
||||
text: '消息',
|
||||
},
|
||||
{
|
||||
code: 'personal',
|
||||
text: '我的',
|
||||
},
|
||||
]
|
||||
|
||||
const handlePublish = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/publishBall/index',
|
||||
})
|
||||
}
|
||||
|
||||
const handlePageChange = (code: string) => {
|
||||
if (code === currentPage) {
|
||||
return
|
||||
}
|
||||
Taro.navigateTo({
|
||||
url: `/pages/${code}/index`,
|
||||
}).then(() => {
|
||||
Taro.pageScrollTo({
|
||||
scrollTop: 0,
|
||||
duration: 300,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='guide-bar-container'>
|
||||
<View className='guide-bar'>
|
||||
{/* guide area on the left */}
|
||||
<View className='guide-bar-pages'>
|
||||
{guideItems.map((item) => (
|
||||
<View
|
||||
className={`guide-bar-pages-item ${currentPage === item.code ? 'guide-bar-pages-item-active' : ''}`}
|
||||
onClick={() => handlePageChange(item.code)}
|
||||
>
|
||||
<Text>{item.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* publish button on the right */}
|
||||
<View className='guide-bar-publish' onClick={handlePublish}>
|
||||
<Image className='guide-bar-publish-icon' src={img.ICON_GUIDE_BAR_PUBLISH} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default GuideBar
|
||||
@@ -1,6 +1,5 @@
|
||||
.list {
|
||||
background: #fafafa;
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
295
src/components/ListCard/index.scss
Normal file
295
src/components/ListCard/index.scss
Normal file
@@ -0,0 +1,295 @@
|
||||
.listCard {
|
||||
background: linear-gradient(90deg, rgba(183, 248, 113, 0.5) 0%, rgba(183, 248, 113, 0.1) 100%);
|
||||
border-radius: 20px;
|
||||
border-width: 0.5px;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
display: flex;
|
||||
padding: 12px 15px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 0.5px solid #f0f0f0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% - 122px);
|
||||
}
|
||||
|
||||
.titleWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000000;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.title-right-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
color: #3C3C4399;
|
||||
}
|
||||
|
||||
.location-position {
|
||||
max-width: 66%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
font-size: 12px;
|
||||
color: #3C3C4399;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.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;
|
||||
box-sizing: border-box;
|
||||
|
||||
.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 {
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px;
|
||||
border: 0.5px solid #00000029;
|
||||
height: 20px;
|
||||
border-radius: 20px;
|
||||
min-width: 38px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #000000;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tag-text-max {
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.image-section {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-basis: 100px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1.5px solid #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
|
||||
.image {
|
||||
border-radius: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.single-image {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
|
||||
.image-container {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
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: translateX(4px) rotate(-10deg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
transform: translateX(-4px) 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: translateX(4px) 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;
|
||||
}
|
||||
|
||||
// 底部
|
||||
.smoothPlayingGame {
|
||||
padding: 5px 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
|
||||
.smoothWrapper,
|
||||
.localAreaWrapper {
|
||||
line-height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.smoothTitle {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.line {
|
||||
height: 8px;
|
||||
width: 1px;
|
||||
background: #00000040;
|
||||
border-radius: 99px;
|
||||
}
|
||||
|
||||
.iconListPlayingGame,
|
||||
.localArea {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.localArea {
|
||||
border: 0.5px solid #FFFFFFA6;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
146
src/components/ListCard/index.tsx
Normal file
146
src/components/ListCard/index.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { View, Text, Image } from "@tarojs/components";
|
||||
import Taro from "@tarojs/taro";
|
||||
import img from "../../config/images";
|
||||
import { ListCardProps } from "../../../types/list/types";
|
||||
import "./index.scss";
|
||||
|
||||
const ListCard: React.FC<ListCardProps> = ({
|
||||
id,
|
||||
title,
|
||||
dateTime,
|
||||
location,
|
||||
distance,
|
||||
registeredCount,
|
||||
maxCount,
|
||||
skillLevel,
|
||||
matchType,
|
||||
images = [],
|
||||
shinei,
|
||||
}) => {
|
||||
const renderItemImage = (src: string) => {
|
||||
return <Image src={src} className="image" mode="aspectFill" />;
|
||||
};
|
||||
|
||||
const handleViewDetail = () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/detail/index?id=${id || 1}&from=list&autoShare=0`,
|
||||
});
|
||||
};
|
||||
|
||||
// 根据图片数量决定展示样式
|
||||
const renderImages = () => {
|
||||
if (images?.length === 0) return null;
|
||||
|
||||
if (images?.length === 1) {
|
||||
return (
|
||||
<View className="single-image">
|
||||
<View className="image-container">{renderItemImage(images[0])}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (images?.length === 2) {
|
||||
return (
|
||||
<View className="double-image">
|
||||
<View className="image-container">{renderItemImage(images[0])}</View>
|
||||
<View className="image-container">{renderItemImage(images[1])}</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 3张或更多图片
|
||||
return (
|
||||
<View className="triple-image">
|
||||
<View className="image-container">{renderItemImage(images?.[0])}</View>
|
||||
<View className="image-container">{renderItemImage(images?.[1])}</View>
|
||||
<View className="image-container">{renderItemImage(images?.[2])}</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<View className="listCard">
|
||||
<View className="listItem" onClick={handleViewDetail}>
|
||||
{/* 左侧内容区域 */}
|
||||
<View className="content">
|
||||
{/* 标题 */}
|
||||
<View className="titleWrapper">
|
||||
<Text className="title">{title}</Text>
|
||||
<Image
|
||||
src={img.ICON_LIST_RIGHT_ARROW}
|
||||
className="title-right-arrow"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 时间信息 */}
|
||||
|
||||
<View className="date-time">
|
||||
<Text>{dateTime}</Text>
|
||||
</View>
|
||||
|
||||
{/* 地点,室内外,距离 */}
|
||||
|
||||
<View className="location">
|
||||
<Text className="location-text location-position">{location}</Text>
|
||||
<Text className="location-text location-time-distance">
|
||||
{shinei && `・${shinei}`}
|
||||
{distance && `・${distance}`}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 底部信息行:头像组、报名人数、技能等级、比赛类型 */}
|
||||
<View className="bottom-info">
|
||||
<View className="left-section">
|
||||
<View className="avatar-group">
|
||||
{Array.from({ length: Math.min(registeredCount, 3) }).map(
|
||||
(_, index) => (
|
||||
<View key={index} className="avatar">
|
||||
<Image
|
||||
className="avatar-image"
|
||||
src="https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="tags">
|
||||
<View className="tag">
|
||||
<Text className="tag-text">
|
||||
报名人数 {registeredCount}/
|
||||
<Text className="tag-text-max">{maxCount}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View className="tag">
|
||||
<Text className="tag-text">{skillLevel}</Text>
|
||||
</View>
|
||||
<View className="tag">
|
||||
<Text className="tag-text">{matchType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧图片区域 */}
|
||||
<View className="image-section">{renderImages()}</View>
|
||||
</View>
|
||||
{/* 畅打球局 */}
|
||||
<View className="smoothPlayingGame">
|
||||
<View className="smoothWrapper">
|
||||
<Image src={img.ICON_LIST_PLAYING_GAME} className="iconListPlayingGame" />
|
||||
<Text className="smoothTitle">畅打球局</Text>
|
||||
</View>
|
||||
<View className="line" />
|
||||
<View>场馆方:</View>
|
||||
<View className="localAreaWrapper">
|
||||
<Image className="localArea" src="https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center" />
|
||||
<Text className="localAreaText">仁恒河滨花园网球场</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListCard;
|
||||
@@ -1,35 +1,69 @@
|
||||
.list-item {
|
||||
display: flex;
|
||||
padding: 16px;
|
||||
padding: 12px 15px;
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
border: 0.5px solid #f0f0f0;
|
||||
justify-content: space-between;
|
||||
--nutui-skeleton-line-height: 24px;
|
||||
--nutui-skeleton-line-border-radius: 24px;
|
||||
|
||||
.nut-skeleton-block {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: calc(100% - 122px);
|
||||
}
|
||||
|
||||
.titleWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333333;
|
||||
line-height: 1.4;
|
||||
color: #000000;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.3;
|
||||
.title-right-arrow {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location {
|
||||
font-size: 14px;
|
||||
color: #666666;
|
||||
line-height: 1.3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.location-position {
|
||||
max-width: 66%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-text {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.date-time {
|
||||
font-size: 12px;
|
||||
color: #3C3C4399;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
margin-top: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bottom-info {
|
||||
@@ -58,6 +92,8 @@
|
||||
border: 2px solid #ffffff;
|
||||
margin-left: -8px;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
|
||||
.avatar-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -89,8 +125,10 @@
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 3px;
|
||||
border: 1px solid #f5f5f5;
|
||||
box-sizing: border-box;
|
||||
padding: 0 6px;
|
||||
border: 0.5px solid #00000029;
|
||||
height: 20px;
|
||||
border-radius: 20px;
|
||||
min-width: 38px;
|
||||
display: flex;
|
||||
@@ -98,7 +136,7 @@
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
color: #000000;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.tag-text-max {
|
||||
@@ -112,16 +150,32 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-basis: 100px;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
.image-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 2px;
|
||||
background: #ffffff;
|
||||
border: 1.5px solid #ffffff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
|
||||
.nut-skeleton,
|
||||
.nut-skeleton-content,
|
||||
.nut-skeleton-block {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.nut-skeleton-block {
|
||||
margin: 0;
|
||||
border-radius: unset;
|
||||
}
|
||||
|
||||
.image {
|
||||
border-radius: 10px;
|
||||
}
|
||||
@@ -132,7 +186,10 @@
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
|
||||
.image-container {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
}
|
||||
@@ -151,13 +208,13 @@
|
||||
|
||||
&:first-child {
|
||||
z-index: 2;
|
||||
transform: rotate(-10deg);
|
||||
transform: translateX(4px) rotate(-10deg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
transform: rotate(10deg);
|
||||
transform: translateX(-4px) rotate(10deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -177,7 +234,7 @@
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
z-index: 3;
|
||||
transform: rotate(-10deg);
|
||||
transform: translateX(4px) rotate(-10deg);
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
@@ -204,4 +261,4 @@
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
}
|
||||
56
src/components/ListCardSkeleton/index.tsx
Normal file
56
src/components/ListCardSkeleton/index.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { View } from "@tarojs/components";
|
||||
import { Skeleton } from "@nutui/nutui-react-taro";
|
||||
import "./index.scss";
|
||||
|
||||
const ListCard = () => {
|
||||
return (
|
||||
<View className="list-item">
|
||||
{/* 左侧内容区域 */}
|
||||
<View className="content">
|
||||
{/* 标题 */}
|
||||
<View className="titleWrapper">
|
||||
<Skeleton visible={false} style={{ width: "180px" }} />
|
||||
</View>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<View className="date-time">
|
||||
<Skeleton visible={false} style={{ width: "88px", }} />
|
||||
</View>
|
||||
|
||||
{/* 地点,室内外,距离 */}
|
||||
|
||||
<View className="location">
|
||||
<Skeleton visible={false} style={{ width: "60px", }} />
|
||||
</View>
|
||||
|
||||
{/* 底部信息行:头像组、报名人数、技能等级、比赛类型 */}
|
||||
<View className="bottom-info">
|
||||
<View className="left-section">
|
||||
<View className="avatar-group">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<View key={index} className="avatar">
|
||||
<Skeleton visible={false} style={{ width: "20px", height: '0' }} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="tags">
|
||||
<Skeleton visible={false} style={{ width: "64px" }} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧图片区域 */}
|
||||
<View className="image-section">
|
||||
<View className="single-image">
|
||||
<View className="image-container">
|
||||
<Skeleton visible={false} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListCard;
|
||||
@@ -1,121 +0,0 @@
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import './index.scss'
|
||||
|
||||
interface ListItemProps {
|
||||
title: string
|
||||
dateTime: string
|
||||
location: string
|
||||
distance: string
|
||||
registeredCount: number
|
||||
maxCount: number
|
||||
skillLevel: string
|
||||
matchType: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
const ListItem: React.FC<ListItemProps> = ({
|
||||
title,
|
||||
dateTime,
|
||||
location,
|
||||
distance,
|
||||
registeredCount,
|
||||
maxCount,
|
||||
skillLevel,
|
||||
matchType,
|
||||
images
|
||||
}) => {
|
||||
// 根据图片数量决定展示样式
|
||||
const renderImages = () => {
|
||||
if (images.length === 0) return null
|
||||
|
||||
if (images.length === 1) {
|
||||
return (
|
||||
<View className="single-image">
|
||||
<View className="image-container">
|
||||
<Image src={images[0]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
if (images.length === 2) {
|
||||
return (
|
||||
<View className="double-image">
|
||||
<View className="image-container">
|
||||
<Image src={images[0]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
<View className="image-container">
|
||||
<Image src={images[1]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 3张或更多图片
|
||||
return (
|
||||
<View className="triple-image">
|
||||
<View className="image-container">
|
||||
<Image src={images[0]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
<View className="image-container">
|
||||
<Image src={images[1]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
<View className="image-container">
|
||||
<Image src={images[2]} className="image" mode="aspectFill" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="list-item">
|
||||
{/* 左侧内容区域 */}
|
||||
<View className="content">
|
||||
{/* 标题 */}
|
||||
<Text className="title">{title}</Text>
|
||||
|
||||
{/* 时间信息 */}
|
||||
<Text className="date-time">{dateTime}</Text>
|
||||
|
||||
{/* 地点和距离 */}
|
||||
<Text className="location">{location}・{distance}</Text>
|
||||
|
||||
{/* 底部信息行:头像组、报名人数、技能等级、比赛类型 */}
|
||||
<View className="bottom-info">
|
||||
<View className="left-section">
|
||||
<View className="avatar-group">
|
||||
{Array.from({ length: Math.min(registeredCount, 3) }).map((_, index) => (
|
||||
<View key={index} className="avatar" >
|
||||
<Image
|
||||
className="avatar-image"
|
||||
src="https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center" className="avatar-image" mode="aspectFill" />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="tags">
|
||||
<View className="tag">
|
||||
<Text className="tag-text">
|
||||
报名人数 {registeredCount}/<Text className="tag-text-max">{maxCount}</Text>
|
||||
</Text>
|
||||
</View>
|
||||
<View className="tag">
|
||||
<Text className="tag-text">{skillLevel}</Text>
|
||||
</View>
|
||||
<View className="tag">
|
||||
<Text className="tag-text">{matchType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 右侧图片区域 */}
|
||||
<View className="image-section">
|
||||
{renderImages()}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItem
|
||||
44
src/components/ListLoadError/index.module.scss
Normal file
44
src/components/ListLoadError/index.module.scss
Normal file
@@ -0,0 +1,44 @@
|
||||
.listLoadError {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 400px;
|
||||
|
||||
.listLoadErrorImg {
|
||||
width: 154px;
|
||||
height: 154px;
|
||||
}
|
||||
|
||||
.listLoadErrorText {
|
||||
margin-top: 35px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 500;
|
||||
font-style: Medium;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0px;
|
||||
}
|
||||
|
||||
.listLoadErrorBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 76px;
|
||||
background: #00000008;
|
||||
border: 0.5px solid #0000001F;
|
||||
border-radius: 12px;
|
||||
padding: 12px 0;
|
||||
font-weight: 500;
|
||||
font-style: Medium;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
letter-spacing: 0px;
|
||||
|
||||
}
|
||||
|
||||
.reloadIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
24
src/components/ListLoadError/index.tsx
Normal file
24
src/components/ListLoadError/index.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Image, View, Text, Button } from "@tarojs/components";
|
||||
import styles from "./index.module.scss";
|
||||
import img from "@/config/images";
|
||||
|
||||
const ListLoadError = ({ reload }: { reload: () => void }) => {
|
||||
const handleReload = () => {
|
||||
reload && typeof reload === "function" && reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<View className={styles.listLoadError}>
|
||||
<Image
|
||||
className={styles.listLoadErrorImg}
|
||||
src={img.ICON_LIST_LOAD_ERROR}
|
||||
/>
|
||||
<Text className={styles.listLoadErrorText}>加载失败</Text>
|
||||
<Button className={styles.listLoadErrorBtn} onClick={handleReload}>
|
||||
<Image src={img?.ICON_LIST_RELOAD} className={styles.reloadIcon} />
|
||||
重试
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
export default ListLoadError;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import MenuComponent from "./index";
|
||||
|
||||
export default function Example() {
|
||||
const [value, setValue] = useState("a");
|
||||
const options = [
|
||||
{ text: "默认排序", value: "a" },
|
||||
{ text: "好评排序", value: "b" },
|
||||
{ text: "销量排序", value: "c" },
|
||||
];
|
||||
return (
|
||||
<MenuComponent
|
||||
options={options}
|
||||
value={value}
|
||||
onChange={(val) => setValue(val)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,46 @@
|
||||
.menuWrap {
|
||||
position: static;
|
||||
padding: 5px 20px 10px;
|
||||
--nutui-menu-title-padding: 0 6px 0 0;
|
||||
$height: 26px;
|
||||
|
||||
.menuItem {
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
border-bottom-left-radius: 30px;
|
||||
border-bottom-right-radius: 30px;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
&.rotate {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&.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-radius: $height;
|
||||
line-height: $height;
|
||||
height: $height;
|
||||
border: 1px solid #e5e5e5;
|
||||
line-height: 28px;
|
||||
font-size: 14px;
|
||||
width: max-content;
|
||||
|
||||
.nut-menu-title-text {
|
||||
padding-left: 0;
|
||||
}
|
||||
width: 94px;
|
||||
}
|
||||
|
||||
:global(.nut-menu-title) {
|
||||
@@ -33,13 +48,18 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:global(.nut-menu-title-text) {
|
||||
--nutui-menu-title-padding: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,55 @@
|
||||
import { Menu } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import { useState } from "react";
|
||||
import { Image } from "@tarojs/components";
|
||||
import img from "../../config/images";
|
||||
import { MenuFilterProps } from "../../../types/list/types";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
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 MenuComponent = (props: MenuFilterProps) => {
|
||||
const { options, value, onChange, wrapperClassName, itemClassName, name } =
|
||||
props;
|
||||
const [isChange, setIsChange] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
const handleChange = (val: Record<string, string>) => {
|
||||
setIsChange(true);
|
||||
onChange && onChange(value);
|
||||
onChange && onChange(name, val.value);
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
className={`${styles.menuWrap} ${wrapperClassName} ${isChange ? styles.active : ""}`}
|
||||
className={`${styles.menuWrap} ${wrapperClassName} ${
|
||||
isChange ? styles.active : ""
|
||||
}`}
|
||||
activeColor="#000"
|
||||
onOpen={handleOpen}
|
||||
onClose={handleClose}
|
||||
icon={
|
||||
<Image
|
||||
className={`${styles.menuIcon} ${isOpen ? styles.rotate : ""}`}
|
||||
src={isChange ? img.ICON_ARROW_DOWN_WHITE : img.ICON_ARROW_DOWN}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Menu.Item
|
||||
className={`${styles.menuItem} ${itemClassName}`}
|
||||
options={options}
|
||||
defaultValue={value}
|
||||
onChange={handleChange}
|
||||
icon={
|
||||
<Image
|
||||
className={styles.itemIcon}
|
||||
src={img.ICON_MENU_ITEM_SELECTED}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# NtrpRange 范围选择器组件
|
||||
|
||||
基于NutUI Range组件的双滑块范围选择器,通过CSS样式覆盖完全匹配设计稿,支持自定义范围、步长和回调函数。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎯 双滑块设计,支持选择范围区间
|
||||
- 🎨 精准还原设计稿的视觉效果
|
||||
- 📱 响应式设计,支持移动端
|
||||
- 🎮 流畅的拖拽交互体验
|
||||
- ⚙️ 可配置的最小值、最大值和步长
|
||||
- 🔒 支持禁用状态
|
||||
- 📊 实时值变化回调
|
||||
|
||||
## 基本用法
|
||||
|
||||
```tsx
|
||||
import NtrpRange from '@/components/Range';
|
||||
|
||||
const MyComponent = () => {
|
||||
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
|
||||
|
||||
return (
|
||||
<NtrpRange
|
||||
min={2.0}
|
||||
max={4.0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## 属性说明
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `min` | `number` | `2.0` | 最小值 |
|
||||
| `max` | `number` | `4.0` | 最大值 |
|
||||
| `step` | `number` | `0.5` | 步长 |
|
||||
| `value` | `[number, number]` | `[min, max]` | 当前选择的范围值 |
|
||||
| `onChange` | `(value: [number, number]) => void` | - | 值变化时的回调函数 |
|
||||
| `disabled` | `boolean` | `false` | 是否禁用 |
|
||||
|
||||
## 技术实现
|
||||
|
||||
- 基于NutUI Range组件,确保拖拽功能的可靠性
|
||||
- 通过CSS样式覆盖,完全匹配设计稿视觉效果
|
||||
- TypeScript + React Hooks
|
||||
- 响应式设计,支持移动端
|
||||
|
||||
## 设计规范
|
||||
|
||||
组件严格按照设计稿实现,包含以下视觉元素:
|
||||
|
||||
- **网球图标**: 黑色轮廓的网球图标
|
||||
- **标题**: "NTRP水平区间" 文字
|
||||
- **标签**: 左右两端的范围标签(如"2.0及以下"、"4.0及以上")
|
||||
- **滑块轨道**: 圆角矩形容器,带有浅灰色边框和阴影
|
||||
- **滑块手柄**: 两个白色圆形手柄,带有黑色边框和阴影
|
||||
- **轨道填充**: 黑色填充条,显示当前选择的范围
|
||||
- **标记点**: 四个浅灰色圆点,均匀分布在轨道上
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用 BEM 命名规范,可以通过 CSS 变量或覆盖样式来自定义外观:
|
||||
|
||||
```scss
|
||||
.ntrp-range {
|
||||
// 自定义样式
|
||||
&__track {
|
||||
background: #f5f5f5;
|
||||
border-color: #d0d0d0;
|
||||
}
|
||||
|
||||
&__handle {
|
||||
background: #007bff;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 确保 `min < max`,否则组件可能无法正常工作
|
||||
2. `step` 值应该能够整除 `max - min` 的差值
|
||||
3. 组件内部会确保左右滑块不会重叠,最小间距为 `step` 值
|
||||
4. 拖拽时会自动吸附到最近的步长值
|
||||
|
||||
## 示例
|
||||
|
||||
查看 `example.tsx` 文件获取更多使用示例。
|
||||
@@ -1,85 +0,0 @@
|
||||
/*
|
||||
* @Author: juguohong juguohong@flashhold.com
|
||||
* @Date: 2025-08-16 17:59:28
|
||||
* @LastEditors: juguohong juguohong@flashhold.com
|
||||
* @LastEditTime: 2025-08-16 23:48:25
|
||||
* @FilePath: /mini-programs/src/components/Range/example.tsx
|
||||
* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import NtrpRange from './index';
|
||||
|
||||
const RangeExample: React.FC = () => {
|
||||
const [ntrpRange, setNtrpRange] = useState<[number, number]>([2.0, 4.0]);
|
||||
const [customRange, setCustomRange] = useState<[number, number]>([0, 100]);
|
||||
|
||||
const handleNtrpChange = (value: [number, number]) => {
|
||||
console.log('NTRP range changed:', value);
|
||||
setNtrpRange(value);
|
||||
};
|
||||
|
||||
const handleCustomChange = (value: [number, number]) => {
|
||||
console.log('Custom range changed:', value);
|
||||
setCustomRange(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div >
|
||||
<h1>Range 组件示例</h1>
|
||||
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h2>NTRP 水平区间选择器</h2>
|
||||
<NtrpRange
|
||||
min={1.0}
|
||||
max={5.0}
|
||||
step={0.5}
|
||||
value={ntrpRange}
|
||||
onChange={handleNtrpChange}
|
||||
/>
|
||||
<div >
|
||||
当前选择范围: {ntrpRange[0]} - {ntrpRange[1]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h2>自定义范围选择器</h2>
|
||||
<NtrpRange
|
||||
min={0}
|
||||
max={100}
|
||||
step={10}
|
||||
value={customRange}
|
||||
onChange={handleCustomChange}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
|
||||
当前选择范围: {customRange[0]} - {customRange[1]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h2>禁用状态</h2>
|
||||
<NtrpRange
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[3, 7]}
|
||||
disabled={true}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
|
||||
此选择器已被禁用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '40px' }}>
|
||||
<h2>测试说明</h2>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
|
||||
<p>1. 点击并拖拽左右滑块手柄</p>
|
||||
<p>2. 查看控制台日志确认拖拽事件</p>
|
||||
<p>3. 观察滑块位置和值的实时变化</p>
|
||||
<p>4. 检查调试信息显示</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RangeExample;
|
||||
@@ -1,16 +1,19 @@
|
||||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { View, Text, Image } from "@tarojs/components";
|
||||
import { Range } from "@nutui/nutui-react-taro";
|
||||
import styles from "./index.module.scss";
|
||||
import TitleComponent from "../Title";
|
||||
import img from "../../config/images";
|
||||
|
||||
interface RangeProps {
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
value?: [number, number];
|
||||
onChange?: (value: [number, number]) => void;
|
||||
onChange?: (name: string, value: [number, number]) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
name: string;
|
||||
showTitle?: boolean;
|
||||
}
|
||||
|
||||
@@ -22,6 +25,7 @@ const NtrpRange: React.FC<RangeProps> = ({
|
||||
onChange,
|
||||
disabled = false,
|
||||
className,
|
||||
name,
|
||||
showTitle = true,
|
||||
}) => {
|
||||
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
|
||||
@@ -32,7 +36,7 @@ const NtrpRange: React.FC<RangeProps> = ({
|
||||
|
||||
const handleChange = (val: [number, number]) => {
|
||||
setCurrentValue(val);
|
||||
onChange?.(val);
|
||||
onChange?.(name, val);
|
||||
};
|
||||
|
||||
const marks = useMemo(() => {
|
||||
@@ -52,18 +56,16 @@ const NtrpRange: React.FC<RangeProps> = ({
|
||||
}, [JSON.stringify(currentValue || []), min, max]);
|
||||
|
||||
return (
|
||||
<div className={`${styles.nutRange} ${className ? className : ''} `}>
|
||||
{ showTitle && (
|
||||
<div className={`${styles.nutRange} ${className ? className : ""} `}>
|
||||
{showTitle && (
|
||||
<div className={styles.nutRangeHeader}>
|
||||
{/* <div className={styles.nutRangeHeaderLeft}>
|
||||
<div className="ntrp-range__icon">icon</div>
|
||||
<h3 className={styles.nutRangeHeaderTitle}>NTRP水平区间</h3>
|
||||
</div> */}
|
||||
<TitleComponent title='NTRP水平区间'/>
|
||||
<TitleComponent
|
||||
title="NTRP水平区间"
|
||||
icon={<Image src={img.ICON_PLAY} />}
|
||||
/>
|
||||
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div>
|
||||
<div className={`${styles.rangeWrapper} rangeContent`}>
|
||||
@@ -73,8 +75,9 @@ const NtrpRange: React.FC<RangeProps> = ({
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
// value={currentValue}
|
||||
value={currentValue}
|
||||
onEnd={handleChange}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
defaultValue={[min, max]}
|
||||
className={styles.rangeHandle}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import NtrpRange from './index';
|
||||
|
||||
const SimpleTest: React.FC = () => {
|
||||
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h2>简单测试</h2>
|
||||
<NtrpRange
|
||||
min={2.0}
|
||||
max={4.0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<p>当前值: {value[0]} - {value[1]}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleTest;
|
||||
@@ -1,68 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import NtrpRange from './index';
|
||||
|
||||
const StyleTest: React.FC = () => {
|
||||
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px', maxWidth: '600px', margin: '0 auto' }}>
|
||||
<h1>NtrpRange 样式测试</h1>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2>NTRP 水平区间选择器</h2>
|
||||
<NtrpRange
|
||||
min={2.0}
|
||||
max={4.0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
|
||||
当前选择范围: {value[0]} - {value[1]}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2>自定义范围选择器</h2>
|
||||
<NtrpRange
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={[20, 80]}
|
||||
onChange={(val) => console.log('Custom range:', val)}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#666' }}>
|
||||
固定范围: 20 - 80
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2>禁用状态</h2>
|
||||
<NtrpRange
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
value={[3, 7]}
|
||||
disabled={true}
|
||||
/>
|
||||
<div style={{ marginTop: '16px', fontSize: '14px', color: '#999' }}>
|
||||
此选择器已被禁用
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '30px' }}>
|
||||
<h2>样式说明</h2>
|
||||
<div style={{ fontSize: '14px', color: '#666', lineHeight: '1.6' }}>
|
||||
<p>✅ 网球图标 + "NTRP水平区间"标题</p>
|
||||
<p>✅ 左右范围标签("2.0及以下"、"4.0及以上")</p>
|
||||
<p>✅ 圆角矩形轨道容器,带有边框和阴影</p>
|
||||
<p>✅ 白色圆形滑块手柄,黑色边框和阴影</p>
|
||||
<p>✅ 黑色轨道填充条</p>
|
||||
<p>✅ 五个浅灰色标记点</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default StyleTest;
|
||||
@@ -1,35 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import NtrpRange from './index';
|
||||
|
||||
const TestPage: React.FC = () => {
|
||||
const [value, setValue] = useState<[number, number]>([2.0, 4.0]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '20px' }}>
|
||||
<h1>NtrpRange 组件测试</h1>
|
||||
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<NtrpRange
|
||||
min={2.0}
|
||||
max={4.0}
|
||||
step={0.5}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||
当前值: {value[0].toFixed(1)} - {value[1].toFixed(1)}
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: '20px', fontSize: '12px', color: '#999' }}>
|
||||
<p>测试说明:</p>
|
||||
<p>1. 拖拽左右滑块手柄</p>
|
||||
<p>2. 观察值的变化</p>
|
||||
<p>3. 检查样式是否正确</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestPage;
|
||||
@@ -1,23 +1,59 @@
|
||||
.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;
|
||||
--nutui-searchbar-padding: 10px 0 0 0;
|
||||
|
||||
:global(.nut-searchbar-content) {
|
||||
box-shadow: 0 4px 48px #00000014;
|
||||
}
|
||||
|
||||
.searchBarLeft {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.searchBarRight {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #0000000F;
|
||||
border: 1px solid #0000000f;
|
||||
background-color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.active {
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
|
||||
.filterIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.filterCount {
|
||||
background-color: #000000;
|
||||
position: absolute;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
right: -5px;
|
||||
bottom: -5px;
|
||||
color: #ffffff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.searchIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,51 @@
|
||||
import { SearchBar } from "@nutui/nutui-react-taro";
|
||||
import { View, Text, Image } from "@tarojs/components";
|
||||
import img from "../../config/images";
|
||||
import styles from "./index.module.scss";
|
||||
|
||||
import { SearchBar } from '@nutui/nutui-react-taro'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
const SearchBarComponent = () => {
|
||||
return (
|
||||
<>
|
||||
<SearchBar
|
||||
// leftIn={
|
||||
// <div>123</div>
|
||||
// }
|
||||
right={
|
||||
<div className={styles.searchBarRight}>筛</div>
|
||||
}
|
||||
className={styles.searchBar}
|
||||
placeholder='搜索上海的球局和场地'
|
||||
/>
|
||||
</>
|
||||
)
|
||||
interface IProps {
|
||||
handleFilterIcon: () => void;
|
||||
isSelect: boolean;
|
||||
filterCount: number;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export default SearchBarComponent
|
||||
const SearchBarComponent = (props: IProps) => {
|
||||
const { handleFilterIcon, isSelect, filterCount, onChange } = props;
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
onChange && onChange(value);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SearchBar
|
||||
leftIn={
|
||||
<View className={styles.searchBarLeft}>
|
||||
<Image className={styles.searchIcon} src={img.ICON_SEARCH} />
|
||||
</View>
|
||||
}
|
||||
right={
|
||||
<View
|
||||
className={`${styles.searchBarRight} ${
|
||||
isSelect ? styles.active : ""
|
||||
}`}
|
||||
onClick={handleFilterIcon}
|
||||
>
|
||||
<Image
|
||||
src={isSelect ? img.ICON_FILTER_SELECTED : img.ICON_FILTER}
|
||||
className={styles.filterIcon}
|
||||
/>
|
||||
{isSelect && (
|
||||
<Text className={styles.filterCount}>{filterCount}</Text>
|
||||
)}
|
||||
</View>
|
||||
}
|
||||
className={styles.searchBar}
|
||||
placeholder="搜索上海的球局和场地"
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBarComponent;
|
||||
|
||||
@@ -4,8 +4,19 @@
|
||||
margin-bottom: 10px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
||||
image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,16 @@ import styles from "./index.module.scss";
|
||||
interface IProps {
|
||||
title: string;
|
||||
className?: string;
|
||||
icon?: JSX.Element;
|
||||
}
|
||||
const TitleComponent = (props: IProps) => {
|
||||
const { title, className } = props;
|
||||
const { title, className, icon } = props;
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${styles.titleContainer} ${className ? className : ""} `}
|
||||
>
|
||||
<div>图</div>
|
||||
<div className={styles.icon}>{icon}</div>
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
</div>
|
||||
</>
|
||||
|
||||
127
src/components/UploadCover/index.scss
Normal file
127
src/components/UploadCover/index.scss
Normal file
@@ -0,0 +1,127 @@
|
||||
@use '~@/scss/images.scss' as img;
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.upload-cover-root {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 112px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
align-items: flex-end;
|
||||
|
||||
&.upload-cover-act-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.upload-cover-act {
|
||||
display: flex;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
padding: 16px 12px 10px 12px;
|
||||
margin-top: 4px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(0, 0, 0, 0.12);
|
||||
background: theme.$page-background-color;
|
||||
box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06);
|
||||
z-index: 1;
|
||||
|
||||
.upload-cover-act-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.upload-cover-text {
|
||||
color: var(--Labels-Secondary, var(--Labels-Secondary, rgba(60, 60, 67, 0.60)));
|
||||
font-feature-settings: 'liga' off, 'clig' off;
|
||||
font-family: "PingFang SC";
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
}
|
||||
|
||||
.cover-image-list-container {
|
||||
position: absolute;
|
||||
left: 114px;
|
||||
top: 0;
|
||||
width: calc(100% - 114px);
|
||||
overflow-x: scroll;
|
||||
height: 112px;
|
||||
|
||||
&.full {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cover-image-list {
|
||||
width: auto;
|
||||
height: 112px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
flex-wrap: nowrap;
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
|
||||
.cover-image-item {
|
||||
display: flex;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
|
||||
.cover-image-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.cover-image-item-delete {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-source-popup-container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 26px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.upload-source-popup-item {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 16px 24px;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
140
src/components/UploadCover/index.tsx
Normal file
140
src/components/UploadCover/index.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { Image, View, Text } from '@tarojs/components'
|
||||
import img from '../../config/images'
|
||||
import UploadSourcePopup, { sourceMap } from './upload-source-popup'
|
||||
import UploadFromWx from './upload-from-wx'
|
||||
import { CommonPopup } from '../'
|
||||
|
||||
import './index.scss'
|
||||
import { uploadFileResponseData } from '@/services/uploadFiles'
|
||||
|
||||
export type sourceType = 'album' | 'history' | 'preset'
|
||||
|
||||
export type source = sourceType[]
|
||||
|
||||
export type CoverImageValue = {
|
||||
id: string
|
||||
url: string
|
||||
tempFilePath?: string
|
||||
}
|
||||
|
||||
export interface UploadCoverProps {
|
||||
value: CoverImageValue[]
|
||||
onChange: (value: CoverImageValue[] | ((prev: CoverImageValue[]) => CoverImageValue[])
|
||||
) => void
|
||||
source?: source
|
||||
maxCount?: number
|
||||
align?: 'center' | 'left'
|
||||
}
|
||||
|
||||
// const values = [
|
||||
// 'http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/1a35ebbf-2361-44da-b338-7608561d0b31.png',
|
||||
// 'http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/cf5a82ba-90af-4138-a1b3-9119adcde9e0.png',
|
||||
// 'http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/49d7cdf0-b03c-4a0f-91c6-e7778080cfcd.png'
|
||||
// ]
|
||||
|
||||
const mergeCoverImages = (value: CoverImageValue[], images: CoverImageValue[]) => {
|
||||
console.log(value, images, 11111)
|
||||
// 根据id来更新url, 如果id不存在,则添加到value中
|
||||
const newImages = images
|
||||
const updatedValue = value.map(item => {
|
||||
const index = images.findIndex(image => image.id === item.id)
|
||||
if (index !== -1) {
|
||||
newImages.splice(index, 1)
|
||||
return { ...item, url: images[index].url }
|
||||
}
|
||||
return item
|
||||
})
|
||||
return [...updatedValue, ...newImages]
|
||||
}
|
||||
|
||||
export default function UploadCover(props: UploadCoverProps) {
|
||||
const {
|
||||
value = [],
|
||||
onChange = () => void 0,
|
||||
source = ['album', 'history', 'preset'] as source,
|
||||
maxCount = 9,
|
||||
align = 'center',
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
const uploadSourcePopupRef = useRef<{
|
||||
show: (sourceType: sourceType, maxCount: number) => void
|
||||
}>(null)
|
||||
|
||||
const onAdd = useCallback((images: CoverImageValue[]) => {
|
||||
// FIXME: prev is not latest value
|
||||
onChange(prev => mergeCoverImages(prev, images))
|
||||
setVisible(false)
|
||||
}, [value])
|
||||
|
||||
const onWxAdd = useCallback((images: CoverImageValue[], onFileUploaded: Promise<{ id: string, data: uploadFileResponseData }[]>) => {
|
||||
onAdd(images)
|
||||
onFileUploaded.then(res => {
|
||||
onAdd(res.map(item => ({
|
||||
id: item.id,
|
||||
url: item.data.file_path,
|
||||
})))
|
||||
})
|
||||
}, [onAdd])
|
||||
const onDelete = (image: CoverImageValue) => {
|
||||
onChange(value.filter(item => item.id !== image.id))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
round
|
||||
position="bottom"
|
||||
hideFooter
|
||||
zIndex={1000}
|
||||
>
|
||||
<View className="upload-source-popup-container" style={{ height: source.length * 56 + 52 + 'px' }}>
|
||||
{
|
||||
source.map((item) => {
|
||||
return (
|
||||
<View className="upload-source-popup-item" key={item}>
|
||||
{
|
||||
item === 'album' ? (
|
||||
<UploadFromWx onAdd={onWxAdd} maxCount={maxCount - value.length} />
|
||||
) : (
|
||||
<View className="upload-source-popup-item-text" onClick={() => uploadSourcePopupRef.current?.show(item, maxCount - value.length)}>
|
||||
<Text>{sourceMap.get(item)}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
</View>
|
||||
</CommonPopup>
|
||||
<UploadSourcePopup ref={uploadSourcePopupRef} onAdd={onAdd} />
|
||||
<div className={`upload-cover-root ${value.length === 0 && align === 'center' ? 'upload-cover-act-center' : ''}`}>
|
||||
{value.length < maxCount && (
|
||||
<div className="upload-cover-act" onClick={() => setVisible(true)}>
|
||||
<Image className='upload-cover-act-icon' src={img.ICON_ADD} />
|
||||
<div className="upload-cover-text">添加活动封面</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={`cover-image-list-container ${value.length === maxCount ? 'full' : ''}`}>
|
||||
<div className="cover-image-list">
|
||||
{
|
||||
value.map((item) => {
|
||||
return (
|
||||
<View className="cover-image-item" key={item.id}>
|
||||
<Image className="cover-image-item-image" src={item.url} />
|
||||
<Image className="cover-image-item-delete" src={img.ICON_REMOVE} onClick={() => onDelete(item)} />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/components/UploadCover/upload-from-wx.scss
Normal file
11
src/components/UploadCover/upload-from-wx.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.upload-from-wx-text {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 16px 24px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
47
src/components/UploadCover/upload-from-wx.tsx
Normal file
47
src/components/UploadCover/upload-from-wx.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import uploadApi from '@/services/uploadFiles'
|
||||
import './upload-from-wx.scss'
|
||||
import { CoverImageValue } from '.'
|
||||
import { uploadFileResponseData } from '@/services/uploadFiles'
|
||||
|
||||
export interface UploadFromWxProps {
|
||||
onAdd: (images: CoverImageValue[], onFileUploaded: Promise<{ id: string, data: uploadFileResponseData }[]>) => void
|
||||
maxCount: number
|
||||
}
|
||||
|
||||
export default function UploadFromWx(props: UploadFromWxProps) {
|
||||
const {
|
||||
onAdd = () => void 0,
|
||||
maxCount = 9, // calc from parent
|
||||
} = props
|
||||
const handleImportFromWx = () => {
|
||||
Taro.chooseImage({
|
||||
count: maxCount,
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
}).then(async (res) => {
|
||||
// TODO: compress image
|
||||
// TODO: cropping image to const size
|
||||
let count = 0
|
||||
const files = res.tempFiles.map(item => ({
|
||||
filePath: item.path,
|
||||
description: 'test',
|
||||
tags: 'test',
|
||||
is_public: 1 as unknown as 0 | 1,
|
||||
id: (Date.now() + count++).toString(),
|
||||
}))
|
||||
const onFileUploaded = uploadApi.batchUpload(files)
|
||||
onAdd(files.map(item => ({
|
||||
id: item.id,
|
||||
url: item.filePath,
|
||||
})), onFileUploaded) // TODO: add loading state
|
||||
})
|
||||
}
|
||||
return (
|
||||
<View onClick={handleImportFromWx}>
|
||||
<Text className="upload-from-wx-text">从相册添加</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
156
src/components/UploadCover/upload-source-popup.scss
Normal file
156
src/components/UploadCover/upload-source-popup.scss
Normal file
@@ -0,0 +1,156 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.upload-source-popup-text {
|
||||
width: 100%;
|
||||
height: 56px;
|
||||
padding: 16px 24px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.upload-popup {
|
||||
width: 100%;
|
||||
padding: 26px 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
.upload-popup-title {
|
||||
display: flex;
|
||||
padding: 18px 20px 10px 20px;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
font-family: "PingFang SC";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.23px;
|
||||
}
|
||||
|
||||
.upload-popup-scroll-view {
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
|
||||
.upload-popup-image-list {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 10px;
|
||||
|
||||
.upload-popup-image-item {
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
&:not(.selected) {
|
||||
&.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-popup-image-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 9px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.upload-popup-image-item-select {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&.selected {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
.select-image-icon {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-image-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.upload-popup-image-list-empty {
|
||||
width: 100%;
|
||||
height: 60vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.upload-popup-image-list-empty-image {
|
||||
width: 80%;
|
||||
aspect-ratio: 4/3;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.upload-popup-image-list-empty-text {
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.23px;
|
||||
}
|
||||
}
|
||||
|
||||
.upload-popup-footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 62px;
|
||||
padding: 8px 10px 10px 10px;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
|
||||
.upload-popup-footer-cancel, .upload-popup-footer-confirm {
|
||||
font-feature-settings: 'liga' off, 'clig' off;
|
||||
font-family: "PingFang SC";
|
||||
box-sizing: border-box;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.upload-popup-footer-cancel {
|
||||
background: theme.$page-background-color;
|
||||
}
|
||||
|
||||
.upload-popup-footer-confirm {
|
||||
background: theme.$primary-color;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
&.active {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/components/UploadCover/upload-source-popup.tsx
Normal file
173
src/components/UploadCover/upload-source-popup.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import React, { useState, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { Image, View, Text, ScrollView, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import img from '../../config/images'
|
||||
import publishService from '../../services/publishService'
|
||||
import { CommonPopup } from '../'
|
||||
import emptyStatus from '../../static/emptyStatus/publish-empty.png'
|
||||
|
||||
import './upload-source-popup.scss'
|
||||
|
||||
type SourceType = 'history' | 'preset'
|
||||
|
||||
type ImageItem = {
|
||||
id: string
|
||||
url: string
|
||||
tempFilePath?: string
|
||||
}
|
||||
|
||||
interface UploadImageProps {
|
||||
onAdd: (images: ImageItem[]) => void
|
||||
}
|
||||
|
||||
export const sourceMap = new Map<SourceType, string>([
|
||||
['history', '历史图库'],
|
||||
['preset', '预设图库']
|
||||
])
|
||||
|
||||
const checkImageSelected = (images: ImageItem[], image: ImageItem) => {
|
||||
return images.some(item => item.id === image.id)
|
||||
}
|
||||
|
||||
export default forwardRef(function UploadImage(props: UploadImageProps, ref) {
|
||||
const {
|
||||
onAdd = () => void 0,
|
||||
} = props
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [sourceType, setSourceType] = useState<SourceType>('history')
|
||||
const [maxCount, setMaxCount] = useState(9)
|
||||
const [images, setImages] = useState<ImageItem[]>([])
|
||||
const [selectedImages, setSelectedImages] = useState<ImageItem[]>([])
|
||||
|
||||
const handleImageClick = (image: ImageItem) => {
|
||||
if (checkImageSelected(selectedImages, image)) {
|
||||
setSelectedImages(selectedImages.filter(item => item.id !== image.id))
|
||||
} else if (!outOfMax) {
|
||||
setSelectedImages([...selectedImages, image])
|
||||
} else {
|
||||
Taro.showToast({
|
||||
title: `最多选择${maxCount}张图片`,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: (sourceType: SourceType, maxCount: number) => {
|
||||
setVisible(true)
|
||||
setSourceType(sourceType)
|
||||
setMaxCount(maxCount)
|
||||
fetchImages()
|
||||
}
|
||||
}))
|
||||
|
||||
function fetchImages() {
|
||||
publishService.getPictures({
|
||||
pageOption: {
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
},
|
||||
seachOption: {
|
||||
tag: '',
|
||||
resource_type: 'image',
|
||||
dateRange: [],
|
||||
},
|
||||
}).then(res => {
|
||||
if (res.success) {
|
||||
let start = 0
|
||||
setImages(res.data.data.rows.map(item => ({
|
||||
id: (Date.now() + start++).toString(),
|
||||
url: item.thumbnail_url,
|
||||
})))
|
||||
} else {
|
||||
// TODO: 显示错误信息
|
||||
Taro.showToast({
|
||||
title: res.message,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
setVisible(false)
|
||||
setSelectedImages([])
|
||||
setImages([])
|
||||
setSourceType('history')
|
||||
setMaxCount(9)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedImages.length > 0) {
|
||||
onAdd(selectedImages)
|
||||
setVisible(false)
|
||||
} else {
|
||||
Taro.showToast({
|
||||
title: '请选择图片',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const outOfMax = selectedImages.length >= maxCount
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
round
|
||||
hideFooter
|
||||
position='bottom'
|
||||
zIndex={1001}
|
||||
>
|
||||
<View className="upload-popup">
|
||||
<View className="upload-popup-title">{sourceMap.get(sourceType)}</View>
|
||||
{/* TODO: 分页 加载更多 */}
|
||||
{/* TODO: 图片加载失败 */}
|
||||
{/* TODO: 图片加载中 */}
|
||||
<ScrollView
|
||||
scrollY
|
||||
className="upload-popup-scroll-view"
|
||||
>
|
||||
{images.length > 0 ? (
|
||||
<View className="upload-popup-image-list">
|
||||
{images.map(item => {
|
||||
const isSelected = checkImageSelected(selectedImages, item)
|
||||
return (
|
||||
<View className={`upload-popup-image-item ${outOfMax ? 'disabled' : ''} ${isSelected ? 'selected' : ''}`} onClick={() => handleImageClick(item)}>
|
||||
<Image className="upload-popup-image-item-image" src={item.url} />
|
||||
<View className={`upload-popup-image-item-select ${isSelected ? 'selected' : ''}`}>
|
||||
{isSelected ? (
|
||||
<Image className="select-image-icon" src={img.ICON_CIRCLE_SELECT_ARROW} />
|
||||
) : (
|
||||
<Image className="select-image-icon" src={img.ICON_CIRCLE_UNSELECT} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<View className="upload-popup-image-list-empty">
|
||||
<Image className="upload-popup-image-list-empty-image" src={emptyStatus} />
|
||||
<Text className="upload-popup-image-list-empty-text">暂无内容</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
{images.length > 0 ? (
|
||||
<View className="upload-popup-footer">
|
||||
<Button className="upload-popup-footer-cancel" onClick={() => setVisible(false)}>取消</Button>
|
||||
<Button className={`upload-popup-footer-confirm ${selectedImages.length > 0 ? 'active' : ''}`} type='primary' onClick={handleConfirm}>完成</Button>
|
||||
</View>
|
||||
) : (
|
||||
<View className="upload-popup-footer">
|
||||
<Button className="upload-popup-footer-cancel" onClick={() => setVisible(false)}>取消</Button>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</CommonPopup>
|
||||
{/* <View className="upload-source-popup-text" onClick={() => setVisible(true)}>{sourceMap.get(sourceType)}选取</View> */}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import TimeSelector from './TimeSelector'
|
||||
import TitleTextarea from './TitleTextarea'
|
||||
import CommonPopup from './CommonPopup'
|
||||
import DateTimePicker from './DateTimePicker/DateTimePicker'
|
||||
<<<<<<< HEAD
|
||||
import TimePicker from './TimePicker/TimePicker'
|
||||
import CalendarCard, { DialogCalendarCard } from './CalendarCard'
|
||||
import CommonDialog from './CommonDialog'
|
||||
@@ -31,4 +32,21 @@ import PublishMenu from './PublishMenu/PublishMenu'
|
||||
CommonDialog,
|
||||
PublishMenu
|
||||
}
|
||||
=======
|
||||
import UploadCover from './UploadCover'
|
||||
|
||||
export {
|
||||
ActivityTypeSwitch,
|
||||
TextareaTag,
|
||||
FormSwitch,
|
||||
ImageUpload,
|
||||
Range,
|
||||
NumberInterval,
|
||||
TimeSelector,
|
||||
TitleTextarea,
|
||||
CommonPopup,
|
||||
DateTimePicker,
|
||||
UploadCover,
|
||||
}
|
||||
>>>>>>> d92419f3c5648a67d1b6857d66d6c92a4bece034
|
||||
|
||||
|
||||
Reference in New Issue
Block a user