合并代码

This commit is contained in:
筱野
2025-08-30 22:28:02 +08:00
143 changed files with 6369 additions and 2072 deletions

50
analyze.js Normal file
View File

@@ -0,0 +1,50 @@
const fs = require('fs');
const path = require('path');
// dist 目录路径,根据你项目实际情况修改
const DIST_DIR = path.join(__dirname, 'dist');
// 递归统计文件大小
function getFiles(dir) {
let results = [];
const list = fs.readdirSync(dir);
list.forEach(file => {
const filePath = path.join(dir, file);
const stat = fs.statSync(filePath);
if (stat && stat.isDirectory()) {
results = results.concat(getFiles(filePath));
} else {
results.push({ path: filePath, size: stat.size });
}
});
return results;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(2) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function analyze() {
if (!fs.existsSync(DIST_DIR)) {
console.error('dist 目录不存在,请先执行 taro build --type weapp');
return;
}
const files = getFiles(DIST_DIR);
const total = files.reduce((sum, f) => sum + f.size, 0);
console.log('文件大小分析(按从大到小排序):');
files
.sort((a, b) => b.size - a.size)
.forEach(f => {
console.log(
`${formatSize(f.size)} | ${(f.size / total * 100).toFixed(2)}% | ${path.relative(DIST_DIR, f.path)}`
);
});
console.log(`\n总大小: ${formatSize(total)}`);
}
analyze();

View File

@@ -2,7 +2,7 @@ import { defineConfig, type UserConfigExport } from '@tarojs/cli'
import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'
import devConfig from './dev'
import prodConfig from './prod'
import vitePluginImp from 'vite-plugin-imp'
// import vitePluginImp from 'vite-plugin-imp'
import path from 'path'
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
@@ -16,7 +16,7 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
750: 1,
375: 2,
828: 1.81 / 2,
390: 1.92
390: 1.92
},
sourceRoot: 'src',
outputRoot: 'dist',
@@ -66,9 +66,16 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
}
}
},
miniCssExtractPluginOption: {
ignoreOrder: true,
// filename: 'css/[name].[hash].css',
// chunkFilename: 'css/[name].[chunkhash].css'
},
webpackChain(chain) {
chain.resolve.plugin('tsconfig-paths').use(TsconfigPathsPlugin)
}
},
// @ts-expect-error: Taro 类型定义缺少 mini.hot
hot: true,
},
h5: {
publicPath: '/',

View File

@@ -0,0 +1,23 @@
{
"libVersion": "3.9.0",
"projectname": "playBallTogether",
"condition": {},
"setting": {
"urlCheck": true,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": false,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": true,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}

View File

@@ -1,12 +1,16 @@
export default defineAppConfig({
pages: [
'pages/list/index',
// 'pages/userInfo/myself/index',
'pages/login/index/index',
'pages/login/verification/index',
'pages/login/terms/index',
// 'pages/publishBall/index',
'pages/publishBall/index',
// 'pages/mapDisplay/index',
// 'pages/list/index',
'pages/index/index'
'pages/detail/index',
'pages/message/index',
'pages/personal/index',
'pages/orderCheck/index',
],
window: {
backgroundTextStyle: 'light',

View File

@@ -0,0 +1,4 @@
@font-face {
font-family: 'PoetsenOne';
src: url('./static/asserts/fonts/PoetsenOne-Regular.ttf') format('truetype');
}

View File

@@ -1,7 +1,10 @@
import { Component, ReactNode } from 'react'
import './app.scss'
import './nutui-theme.scss'
import './app.scss'
import { useDictionaryStore } from './store/dictionaryStore'
import { useGlobalStore } from './store/global'
// import { getNavbarHeight } from "@/utils/getNavbarHeight";
interface AppProps {
children: ReactNode
@@ -11,11 +14,12 @@ class App extends Component<AppProps> {
componentDidMount() {
// 初始化字典数据
this.initDictionaryData()
this.getNavBarHeight()
}
componentDidShow() {}
componentDidShow() { }
componentDidHide() {}
componentDidHide() { }
// 初始化字典数据
private async initDictionaryData() {
@@ -27,6 +31,13 @@ class App extends Component<AppProps> {
}
}
// 获取导航高度
getNavBarHeight = () => {
const { getNavbarHeightInfo } = useGlobalStore.getState()
getNavbarHeightInfo()
}
render() {
// this.props.children 是将要会渲染的页面
return this.props.children

View File

@@ -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,

View File

@@ -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` 可以是字符串或数字类型
- 组件会自动处理选中状态的样式变化
- 支持图标和描述,让选项更加丰富
- 响应式设计,自动适应不同屏幕尺寸

View File

@@ -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` 属性禁用整个组件或单个选项

View File

@@ -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;

View File

@@ -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);
}
}
};

View File

@@ -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)}
/>
);
}

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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

View File

@@ -0,0 +1,3 @@
.courtTypeWrapper {
margin-bottom: 18px;
}

View 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;

View 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;
}
}

View 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;

View File

@@ -0,0 +1,3 @@
.gamePlayWrapper {
margin-bottom: 18px;
}

View 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;

View 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;
}
}
}

View 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

View File

@@ -1,6 +1,5 @@
.list {
background: #fafafa;
margin-top: 12px;
display: flex;
flex-direction: column;
gap: 5px;

View 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%;
}
}

View 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;

View File

@@ -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;
}
}

View 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;

View File

@@ -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

View 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;
}
}

View 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;

View File

@@ -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)}
/>
);
}

View File

@@ -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;
}
}
}

View File

@@ -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>
);

View File

@@ -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` 文件获取更多使用示例。

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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>
</>

View 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;
}
}

View 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>
</>
);
};

View 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;
}

View 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>
)
}

View 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;
}
}
}
}

View 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> */}
</>
);
});

View File

@@ -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

View File

@@ -8,6 +8,17 @@ export default {
ICON_COST: require('@/static/publishBall/icon-cost.svg'),
ICON_TIPS: require('@/static/publishBall/icon-tips.svg'),
ICON_ARROW_RIGHT: require('@/static/publishBall/icon-arrow-right.svg'),
ICON_FILTER: require('@/static/list/icon-filter.svg'),
ICON_FILTER_SELECTED: require('@/static/list/icon-filter-selected.svg'),
ICON_SEARCH: require('@/static/list/icon-search.svg'),
ICON_PLAY: require('@/static/list/icon-play.svg'),
ICON_SITE: require('@/static/list/icon-site.svg'),
ICON_ARROW_DOWN: require('@/static/list/icon-arrow-down.svg'),
ICON_MENU_ITEM_SELECTED: require('@/static/list/icon-menu-item-selected.svg'),
ICON_ARROW_DOWN_WHITE: require('@/static/list/icon-arrow-down-white.svg'),
ICON_LIST_RIGHT_ARROW: require('@/static/list/icon-list-right-arrow.svg'),
ICON_ARROW_LEFT: require('@/static/detail/icon-arrow-left.svg'),
ICON_LOGO_GO: require('@/static/detail/icon-logo-go.svg'),
ICON_SEARCH: require('@/static/publishBall/icon-search.svg'),
ICON_MAP: require('@/static/publishBall/icon-map.svg'),
ICON_STADIUM: require('@/static/publishBall/icon-stadium.svg'),
@@ -17,9 +28,30 @@ export default {
ICON_ADD: require('@/static/publishBall/icon-add.svg'),
ICON_COPY: require('@/static/publishBall/icon-arrow-right.svg'),
ICON_DELETE: require('@/static/publishBall/icon-delete.svg'),
<<<<<<< HEAD
ICON_RIGHT_MAX: require('@/static/publishBall/icon-right-max.svg'),
ICON_PLUS: require('@/static/publishBall/icon-plus.svg'),
ICON_GROUP: require('@/static/publishBall/icon-group.svg'),
ICON_PERSON: require('@/static/publishBall/icon-person.svg'),
ICON_PUBLISH: require('@/static/publishBall/icon-publish.png'),
=======
ICON_CIRCLE_UNSELECT: require('@/static/publishBall/icon-circle-unselect.svg'),
ICON_CIRCLE_SELECT: require('@/static/publishBall/icon-circle-select-ring.svg'),
ICON_CIRCLE_SELECT_ARROW: require('@/static/publishBall/icon-circle-select-arrow.svg'),
ICON_LOGO: require('@/static/logo.svg'),
ICON_CHANGE: require('@/static/list/icon-change.svg'),
ICON_WEATHER_SUN: require('@/static/detail/icon-weather-sun.svg'),
ICON_DETAIL_MAP: require('@/static/detail/icon-map.svg'),
ICON_DETAIL_ARROW_RIGHT: require('@/static/detail/icon-arrow-right.svg'),
ICON_DETAIL_NOTICE: require('@/static/detail/icon-notice.svg'),
ICON_DETAIL_APPLICATION_ADD: require('@/static/detail/icon-application-add.svg'),
ICON_DETAIL_COMMENT: require('@/static/detail/icon-comment.svg'),
ICON_DETAIL_COMMENT_DARK: require('@/static/detail/icon-comment-dark.svg'),
ICON_DETAIL_SHARE: require('@/static/detail/icon-share-dark.svg'),
ICON_GUIDE_BAR_PUBLISH: require('@/static/common/guide-bar-publish.svg'),
ICON_NAVIGATOR_BACK: require('@/static/common/navigator-back.svg'),
ICON_LIST_PLAYING_GAME: require('@/static/list/icon-paying-game.svg'),
ICON_LIST_LOAD_ERROR: require('@/static/list/icon-load-error.svg'),
ICON_LIST_RELOAD: require('@/static/list/icon-reload.svg'),
>>>>>>> d92419f3c5648a67d1b6857d66d6c92a4bece034
}

View File

@@ -0,0 +1,8 @@
.listContentWrapper {
padding: 0 5px;
background: #fafafa;
display: flex;
flex-direction: column;
gap: 5px;
background-color: red;
}

View File

@@ -0,0 +1,52 @@
import { View } from "@tarojs/components";
import ListCard from "@/components/ListCard";
import ListLoadError from "@/components/ListLoadError";
import ListCardSkeleton from "@/components/ListCardSkeleton";
// import { useGlobalState } from "@/store/global";
const ListContainer = (props) => {
const { loading, data = [], error, reload } = props;
// const { statusNavbarHeightInfo } = useGlobalState() || {};
// const { totalHeight } = statusNavbarHeightInfo;
const renderList = () => {
if (loading && data.length > 0) {
return (
<>
{new Array(10).fill(0).map(() => {
return <ListCardSkeleton />;
})}
</>
);
}
if (error) {
return <ListLoadError reload={reload} />;
}
if (loading && data.length === 0) {
return null;
}
return (
<>
{data.map((match, index) => (
<ListCard key={match.id || index} {...match} />
))}
</>
);
};
return (
<View
className="listContentWrapper"
// style={{
// minHeight: `calc(100vh - ${totalHeight}px)`,
// }}
>
{renderList()}
</View>
);
};
export default ListContainer;

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '球局详情',
navigationStyle: 'custom',
})

1034
src/pages/detail/index.scss Normal file

File diff suppressed because it is too large Load Diff

556
src/pages/detail/index.tsx Normal file
View File

@@ -0,0 +1,556 @@
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
import { View, Text, Button, Swiper, SwiperItem, Image, Map, ScrollView } from '@tarojs/components'
import { Cell, Avatar, Progress, Popover } from '@nutui/nutui-react-taro'
import Taro, { useRouter, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
// 导入API服务
import DetailService from '../../services/detailService'
import {
useUserStats,
useUserActions
} from '../../store/userStore'
import img from '../../config/images'
import { getTextColorOnImage } from '../../utils'
import './index.scss'
import { CommonPopup } from '@/components'
const images = [
'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'
]
function insertDotInTags(tags: string[]) {
return tags.join('-·-').split('-')
}
const SharePopup = forwardRef(({ id, from }: { id: string, from: string }, ref) => {
const [visible, setVisible] = useState(false)
useImperativeHandle(ref, () => ({
show: () => {
setVisible(true)
}
}))
function handleShareToWechat() {
useShareAppMessage(() => {
return {
title: '分享',
path: `/pages/detail/index?id=${id}&from=${from}`,
}
})
}
function handleShareToWechatMoments() {
useShareTimeline(() => {
return {
title: '分享',
path: `/pages/detail/index?id=${id}&from=${from}`,
}
})
}
function handleSaveToLocal() {
Taro.saveImageToPhotosAlbum({
filePath: images[0],
success: () => {
Taro.showToast({ title: '保存成功', icon: 'success' })
},
fail: () => {
Taro.showToast({ title: '保存失败', icon: 'none' })
},
})
}
return (
<CommonPopup
title="分享"
visible={visible}
onClose={() => { setVisible(false) }}
hideFooter
style={{ minHeight: '100px' }}
>
<View catchMove className='share-popup-content'>
<View onClick={handleShareToWechat}>
<Image src={img.ICON_DETAIL_SHARE} />
<Text></Text>
</View>
<View onClick={handleShareToWechatMoments}>
<Image src={img.ICON_DETAIL_SHARE} />
<Text></Text>
</View>
<View onClick={handleSaveToLocal}>
<Image src={img.ICON_DETAIL_SHARE} />
<Text></Text>
</View>
</View>
</CommonPopup>
)
})
function Index() {
// 使用Zustand store
// const userStats = useUserStats()
// const { incrementRequestCount, resetUserStats } = useUserActions()
const [current, setCurrent] = useState(0)
const [colors, setColors] = useState<string []>([])
const [detail, setDetail] = useState<any>(null)
const { params } = useRouter()
const { id, autoShare, from } = params
console.log('from', from)
// 本地状态管理
const [loading, setLoading] = useState(false)
const sharePopupRef = useRef<any>(null)
// 页面加载时获取数据
useEffect(() => {
fetchDetail()
calcBgMainColors()
}, [])
const fetchDetail = async () => {
const res = await DetailService.getDetail(Number(id))
if (res.code === 0) {
console.log(res.data)
setDetail(res.data)
}
}
const calcBgMainColors = async () => {
const textcolors: string[] = []
for (const index in images) {
const { textColor } = await getTextColorOnImage(images[index])
textcolors[index] = textColor
}
setColors(textcolors)
}
function handleShare() {
sharePopupRef.current.show()
}
const openMap = () => {
Taro.openLocation({
latitude: detail?.latitude, // 纬度(必填)
longitude: detail?.longitude, // 经度(必填)
name: '上海体育场', // 位置名(可选)
address: '上海市徐汇区肇嘉浜路128号', // 地址详情(可选)
scale: 15, // 地图缩放级别1-28
})
}
const handleJoinGame = () => {
Taro.navigateTo({
url: `/pages/orderCheck/index?id=${id}`,
})
}
const tags = [{
name: '🕙 急招',
icon: '',
}, {
name: '🔥 本周热门',
icon: '',
}, {
name: '🎉 新活动',
icon: '',
}, {
name: '官方组织',
icon: '',
}]
const detailTags = ['室内', '硬地', '2号场', '有停车场', '有淋浴间', '有更衣室']
const { title, longitude, latitude } = detail || {}
const requirements = [{
title: 'NTRP水平要求',
desc: '2.0 - 4.5 之间',
}, {
title: '活动玩法',
desc: '双打',
}, {
title: '人员构成',
desc: '个人球局 · 组织者参与活动',
}]
const participants = detail?.participants || []
const supplementalNotesTags = ['仅限男生', '装备自备', '其他']
const recommendGames = [
{
title: '黄浦日场对拉',
time: '2025-08-25 9:00',
timeLength: '2小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
{
title: '黄浦夜场对拉',
time: '2025-08-25 19:00',
timeLength: '2小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
{
title: '黄浦全天对拉',
time: '2025-08-25 9:00',
timeLength: '12小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
]
return (
<View className='detail-page'>
{/* custom navbar */}
<view className="custom-navbar">
<View className='detail-navigator'>
<View className='detail-navigator-back' onClick={() => { Taro.navigateBack() }}>
<Image className='detail-navigator-back-icon' src={img.ICON_ARROW_LEFT} />
</View>
<View className='detail-navigator-icon'>
<Image className='detail-navigator-logo-icon' src={img.ICON_LOGO_GO} />
</View>
</View>
</view>
<View className='detail-page-bg' style={{ backgroundImage: `url(${images[current]})` }} />
<View className='detail-page-bg-text' />
{/* swiper */}
<Swiper
className='detail-swiper'
indicatorDots={false}
circular
nextMargin="20px"
onChange={(e) => { setCurrent(e.detail.current) }}
>
{images.map((imageUrl, index) => (
<SwiperItem
key={index}
className='detail-swiper-item'
>
<Image
src={imageUrl}
mode="aspectFill"
className='detail-swiper-item-image'
style={{
transform: index !== current ? 'scale(0.8) translateX(-12%)' : 'scale(0.95)', // 前后图缩小
}}
/>
</SwiperItem>
))}
</Swiper>
{/* content */}
<View className='detail-page-content'>
{/* avatar and tags */}
<View className='detail-page-content-avatar-tags'>
<View className='detail-page-content-avatar-tags-avatar'>
{/* network image mock */}
<Image className='detail-page-content-avatar-tags-avatar-image' src="https://img.yzcdn.cn/vant/cat.jpeg" />
</View>
<View className='detail-page-content-avatar-tags-tags'>
{tags.map((tag, index) => (
<View key={index} className='detail-page-content-avatar-tags-tags-tag'>
{tag.icon && <Image src={tag.icon} />}
<Text>{tag.name}</Text>
</View>
))}
</View>
</View>
{/* title */}
<View className='detail-page-content-title'>
<Text className='detail-page-content-title-text'>{title}</Text>
</View>
{/* Date and Place and weather */}
<View className='detail-page-content-game-info'>
{/* Date and Weather */}
<View className='detail-page-content-game-info-date-weather'>
{/* Calendar and Date time */}
<View className='detail-page-content-game-info-date-weather-calendar-date'>
{/* Calendar */}
<View className='detail-page-content-game-info-date-weather-calendar-date-calendar'>
<View className="month">3</View>
<View className="day">25</View>
</View>
{/* Date time */}
<View className='detail-page-content-game-info-date-weather-calendar-date-date'>
<View className="date">325 </View>
<View className="venue-time">19:00-21:00 2</View>
</View>
</View>
{/* Weather */}
<View className='detail-page-content-game-info-date-weather-weather'>
{/* Weather icon */}
<View className='detail-page-content-game-info-date-weather-weather-icon'>
<Image className="weather-icon" src={img.ICON_WEATHER_SUN} />
</View>
{/* Weather text and temperature */}
<View className='detail-page-content-game-info-date-weather-weather-text-temperature'>
<Text>28 - 32</Text>
</View>
</View>
</View>
{/* Place */}
<View className='detail-page-content-game-info-place'>
{/* venue location message */}
<View className='location-message'>
{/* location icon */}
<View className='location-message-icon'>
<Image className='location-message-icon-image' src={img.ICON_DETAIL_MAP} />
</View>
{/* location message */}
<View className='location-message-text'>
{/* venue name and distance */}
<View className='location-message-text-name-distance' onClick={openMap}>
<Text></Text>
<Text>·</Text>
<Text>1.2km</Text>
<Image className='location-message-text-name-distance-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
{/* venue address */}
<View className='location-message-text-address'>
<Text>128</Text>
</View>
</View>
</View>
{/* venue map */}
<View className='location-map'>
<Map
className='location-map-map'
longitude={longitude}
latitude={latitude}
onError={() => {}}
// hide business msg
showLocation
theme='dark'
/>
</View>
</View>
</View>
{/* detail */}
<View className='detail-page-content-detail'>
{/* venue detail title and venue ordered status */}
<View className='venue-detail-title'>
<Text></Text>
<Text>·</Text>
<Text></Text>
<Popover
title="场地预定截图"
description={<View>
<Image src="https://img.yzcdn.cn/vant/cat.jpeg" />
</View>}
location='top'
visible={false}
>
<Image className='venue-detail-title-notice-icon' src={img.ICON_DETAIL_NOTICE} />
</Popover>
</View>
{/* venue detail content */}
<View className='venue-detail-content'>
{/* venue detail tags */}
<View className='venue-detail-content-tags'>
{insertDotInTags(detailTags).map((tag, index) => (
<View key={index} className='venue-detail-content-tags-tag'>
<Text>{tag}</Text>
</View>
))}
</View>
{/* venue remarks */}
<View className='venue-detail-content-remarks'>
<Text>西</Text>
</View>
</View>
</View>
{/* gameplay requirements */}
<View className='detail-page-content-gameplay-requirements'>
{/* title */}
<View className="gameplay-requirements-title">
<Text></Text>
</View>
{/* requirements */}
<View className='gameplay-requirements'>
{requirements.map((item, index) => (
<View key={index} className='gameplay-requirements-item'>
<Text className='gameplay-requirements-item-title'>{item.title}</Text>
<Text className='gameplay-requirements-item-desc'>{item.desc}</Text>
</View>
))}
</View>
</View>
{/* participants */}
<View className='detail-page-content-participants'>
<View className='participants-title'>
<Text></Text>
<Text>·</Text>
<Text> 3</Text>
</View>
<View className='participants-list'>
{/* application */}
<View className='participants-list-application' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
<Image className='participants-list-application-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
<Text className='participants-list-application-text'></Text>
</View>
{/* participants list */}
<ScrollView className='participants-list-scroll' scrollX>
<View className='participants-list-scroll-content' style={{ width: `${participants.length * 103 + (participants.length - 1) * 8}px` }}>
{participants.map((participant) => (
<View key={participant.id} className='participants-list-item'>
{/* <Avatar className='participants-list-item-avatar' src={participant.user.avatar_url} /> */}
{/* network image mock random */}
<Avatar className='participants-list-item-avatar' src={`https://picsum.photos/800/600?random=${participant.id}`} />
<Text className='participants-list-item-name'>{participant.user.nickname || '未知'}</Text>
<Text className='participants-list-item-level'>{participant.level || '未知'}</Text>
<Text className='participants-list-item-role'>{participant.role || '参与者'}</Text>
</View>
))}
</View>
</ScrollView>
</View>
</View>
{/* supplemental notes */}
<View className='detail-page-content-supplemental-notes'>
<View className='supplemental-notes-title'>
<Text></Text>
</View>
<View className='supplemental-notes-content'>
{/* supplemental notes tags */}
<View className='supplemental-notes-content-tags'>
{insertDotInTags(supplementalNotesTags).map((tag, index) => (
<View key={index} className='supplemental-notes-content-tags-tag'>
<Text>{tag}</Text>
</View>
))}
</View>
{/* supplemental notes content */}
<View className='supplemental-notes-content-text'>
<Text>西西</Text>
</View>
</View>
</View>
{/* organizer and recommend games by organizer */}
<View className='detail-page-content-organizer-recommend-games'>
{/* orgnizer title */}
<View className='organizer-title'>
<Text></Text>
</View>
{/* organizer avatar and name */}
<View className='organizer-avatar-name'>
<Avatar className='organizer-avatar-name-avatar' src="https://img.yzcdn.cn/vant/cat.jpeg" />
<View className='organizer-avatar-name-message'>
<Text className='organizer-avatar-name-message-name'>Light</Text>
<View className='organizer-avatar-name-message-stats'>
<Text> 8 </Text>
<View className='organizer-avatar-name-message-stats-separator' />
<Text>NTRP 3.5</Text>
</View>
</View>
<View className="organizer-actions">
<View className="organizer-actions-follow">
<Image className='organizer-actions-follow-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
<Text className='organizer-actions-follow-text'></Text>
</View>
<View className="organizer-actions-comment">
<Image className='organizer-actions-comment-icon' src={img.ICON_DETAIL_COMMENT} />
</View>
</View>
</View>
{/* recommend games by organizer */}
<View className='organizer-recommend-games'>
<View className='organizer-recommend-games-title'>
<Text>TA的更多活动</Text>
<Image className='organizer-recommend-games-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
<ScrollView className='recommend-games-list' scrollX>
<View className='recommend-games-list-content'>
{recommendGames.map((game, index) => (
<View key={index} className='recommend-games-list-item'>
{/* game title */}
<View className='recommend-games-list-item-title'>
<Text>{game.title}</Text>
<Image className='recommend-games-list-item-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
{/* game time and range */}
<View className='recommend-games-list-item-time-range'>
<Text>{game.time}</Text>
<Text>{game.timeLength}</Text>
</View>
{/* game location、vunue、distance */}
<View className='recommend-games-list-item-location-venue-distance'>
<Text>{game.venue}</Text>
<Text>·</Text>
<Text>{game.veuneType}</Text>
<Text>·</Text>
<Text>{game.distance}</Text>
</View>
{/* organizer avatar、applications、level requirements、play type */}
<View className='recommend-games-list-item-addon'>
<Avatar className='recommend-games-list-item-addon-avatar' src={game.avatar} />
<View className='recommend-games-list-item-addon-message'>
<View className='recommend-games-list-item-addon-message-applications'>
<Text> {game.checkedApplications}/{game.applications}</Text>
</View>
<View className='recommend-games-list-item-addon-message-level-requirements'>
<Text>{game.levelRequirements}</Text>
</View>
<View className='recommend-games-list-item-addon-message-play-type'>
<Text>{game.playType}</Text>
</View>
</View>
</View>
</View>
))}
</View>
</ScrollView>
</View>
</View>
{/* sticky bottom action bar */}
<View className="sticky-bottom-bar">
<View className="sticky-bottom-bar-share-and-comment">
<View className='sticky-bottom-bar-share' onClick={handleShare}>
<Image className='sticky-bottom-bar-share-icon' src={img.ICON_DETAIL_SHARE} />
<Text className='sticky-bottom-bar-share-text'></Text>
</View>
<View className='sticky-bottom-bar-share-and-comment-separator' />
<View className='sticky-bottom-bar-comment' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
<Image className='sticky-bottom-bar-comment-icon' src={img.ICON_DETAIL_COMMENT_DARK} />
<Text className='sticky-bottom-bar-comment-text'>32</Text>
</View>
</View>
<View className="sticky-bottom-bar-join-game" onClick={handleJoinGame}>
<Text>🎾</Text>
<Text></Text>
<View className='game-price'>
<Text>¥ 65</Text>
</View>
</View>
</View>
{/* share popup */}
<SharePopup ref={sharePopupRef} id={id} from={from} />
</View>
</View>
)
}
export default Index

View File

@@ -1,3 +0,0 @@
export default definePageConfig({
navigationBarTitleText: '首页'
})

View File

@@ -1 +0,0 @@

View File

@@ -1,261 +0,0 @@
.index-page {
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
.page-header {
text-align: center;
margin-bottom: 24px;
.page-title {
font-size: 28px;
font-weight: bold;
color: white;
display: block;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 16px;
color: rgba(255, 255, 255, 0.8);
display: block;
}
}
.user-card {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.user-header {
display: flex;
align-items: center;
gap: 16px;
.user-info {
flex: 1;
.username {
font-size: 20px;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 4px;
}
.user-level {
font-size: 14px;
color: #666;
display: block;
margin-bottom: 4px;
}
.join-date {
font-size: 12px;
color: #999;
display: block;
}
}
}
}
.stats-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
:global {
.nut-cell {
background: #f8f9fa;
border-radius: 12px;
margin-bottom: 8px;
border: none;
&:last-child {
margin-bottom: 0;
}
.nut-cell__title {
font-weight: 500;
color: #555;
}
.nut-cell__value {
font-weight: bold;
color: #007bff;
}
}
}
}
.action-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.section-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
.button-group {
display: flex;
flex-direction: column;
gap: 12px;
.custom-button {
border-radius: 12px;
font-weight: 500;
height: 48px;
border: none;
margin-bottom: 12px;
font-size: 16px;
color: white;
&.primary-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&.success-btn {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&.warning-btn {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&:disabled {
opacity: 0.6;
}
}
// 保留 NutUI 按钮样式(备用)
:global {
.nut-button {
border-radius: 12px;
font-weight: 500;
height: 48px;
border: none;
&--primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
&--success {
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
&--warning {
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
}
&:disabled {
opacity: 0.6;
}
}
}
}
}
.loading-section {
background: white;
border-radius: 16px;
padding: 20px;
margin-bottom: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
text-align: center;
.loading-text {
font-size: 16px;
color: #666;
margin-bottom: 12px;
display: block;
}
:global {
.nut-progress {
.nut-progress-outer {
background: #f0f0f0;
border-radius: 10px;
}
.nut-progress-inner {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 10px;
}
}
}
}
.tips-section {
background: rgba(255, 255, 255, 0.95);
border-radius: 16px;
padding: 20px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
.tips-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 16px;
display: block;
}
.tips-content {
.tip-item {
font-size: 14px;
color: #666;
line-height: 1.6;
margin-bottom: 8px;
display: block;
&:last-child {
margin-bottom: 0;
}
}
}
}
}
// 响应式设计
@media (max-width: 768px) {
.index-page {
padding: 16px;
.page-header {
.page-title {
font-size: 24px;
}
.page-subtitle {
font-size: 14px;
}
}
.user-card,
.stats-section,
.action-section,
.loading-section,
.tips-section {
padding: 16px;
margin-bottom: 16px;
}
}
}

View File

@@ -1,46 +1,81 @@
import { Popup } from "@nutui/nutui-react-taro";
import Range from "../../components/Range";
import Bubble, { BubbleOption } from "../../components/Bubble";
import Bubble from "../../components/Bubble";
import styles from "./filterPopup.module.scss";
import TitleComponent from "src/components/Title";
import TitleComponent from "@/components/Title";
import { Button } from "@nutui/nutui-react-taro";
import { Image } from "@tarojs/components";
import img from "../../config/images";
import { useListStore } from "src/store/listStore";
import { FilterPopupProps } from "../../../types/list/types";
// 场地
import CourtType from "@/components/CourtType";
// 玩法
import GamePlayType from "@/components/GamePlayType";
import { useDictionaryActions } from "@/store/dictionaryStore";
import { useMemo } from "react";
const timeOptions: BubbleOption[] = [
{ id: 1, label: "晨间 6:00-10:00", value: "morning" },
{ id: 2, label: "上午 10:00-12:00", value: "forenoon" },
{ id: 3, label: "中午 12:00-14:00", value: "noon" },
{ id: 4, label: "下午 14:00-18:00", value: "afternoon" },
{ id: 5, label: "晚上 18:00-22:00", value: "evening" },
{ id: 6, label: "夜间 22:00-24:00", value: "night" },
];
const FilterPopup = (props: FilterPopupProps) => {
const {
loading,
onCancel,
onConfirm,
onChange,
filterOptions,
onClear,
visible,
onClose,
statusNavbarHeigh,
} = props;
const locationOptions: BubbleOption[] = [
{ id: 1, label: "室内", value: "1" },
{ id: 2, label: "室外", value: "2" },
{ id: 3, label: "半室外", value: "3" },
];
const store = useListStore() || {};
const { getDictionaryValue } = useDictionaryActions() || {};
const { timeBubbleData } = store;
const handleOptions = (dictionaryValue: []) => {
return dictionaryValue?.map((item) => ({ label: item, value: item })) || [];
};
const courtType = getDictionaryValue("court_type") || [];
const locationOptions = useMemo(() => {
return courtType ? handleOptions(courtType) : [];
}, [courtType]);
const gamePlay = getDictionaryValue("game_play") || [];
const gamePlayOptions = useMemo(() => {
return gamePlay ? handleOptions(gamePlay) : [];
}, [gamePlay]);
const handleFilterChange = (name, value) => {
onChange({ [name]: value });
};
const handleClearFilter = () => {
onClear();
onCancel();
};
const FilterPopup = () => {
return (
<>
<Popup
visible={true}
destroyOnClose
position="top"
round
closeOnOverlayClick={false}
onClose={() => {
// setShowTop(false)
}}
visible={visible}
onClose={onClose}
style={{ marginTop: statusNavbarHeigh + "px" }}
overlayStyle={{ marginTop: statusNavbarHeigh + "px" }}
>
<div className={styles.filterPopupWrapper}>
{/* 时间气泡选项 */}
<Bubble
options={timeOptions}
value={(value) => {}}
onChange={(value) => {}}
options={timeBubbleData}
value={filterOptions?.time}
onChange={handleFilterChange}
layout="grid"
size="small"
columns={3}
name="time"
/>
{/* 范围选择 */}
@@ -49,19 +84,58 @@ const FilterPopup = () => {
max={5.0}
step={0.5}
className={styles.filterPopupRange}
onChange={handleFilterChange}
value={filterOptions?.ntrp}
name="ntrp"
/>
{/* 场次气泡选项 */}
<div>
<TitleComponent title="场地类型" />
{/* <div>
<TitleComponent
title="场地类型"
icon={<Image src={img.ICON_SITE} />}
/>
<Bubble
options={locationOptions}
value={(value) => {}}
onChange={(value) => {}}
value={filterOptions?.site}
onChange={handleFilterChange}
layout="grid"
size="small"
columns={3}
name="site"
/>
</div> */}
{/* CourtType */}
<CourtType
onChange={handleFilterChange}
name="court_type"
options={locationOptions}
value={filterOptions?.site}
/>
{/* 玩法 */}
<GamePlayType
onChange={handleFilterChange}
name="game_play"
options={gamePlayOptions}
value={filterOptions?.game_play}
/>
{/* 按钮 */}
<div className={styles.filterPopupBtnWrapper}>
<Button
className={styles.btn}
type="default"
onClick={handleClearFilter}
>
</Button>
<Button
className={`${styles.btn} ${styles.confirm}`}
type="default"
loading={loading}
onClick={onConfirm}
>
332
</Button>
</div>
</div>
</Popup>

View File

@@ -1,8 +1,35 @@
.filterPopupWrapper {
position: relative;
$m18: 18px;
padding: $m18;
.filterPopupRange {
margin-top: $m18;
margin-bottom: $m18;
}
.filterPopupBtnWrapper {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
position: sticky;
bottom: 0;
background-color: #ffffff;
padding: 8px 0;
.btn {
flex: 1;
}
--nutui-button-border-width: 0.5px;
--nutui-button-default-border-color: #0000000F;
--nutui-button-border-radius: 16px;
--nutui-button-default-height: 44px;
--nutui-button-default-color: #000000;
.confirm {
--nutui-button-default-color: #ffffff;
--nutui-button-default-background-color: #000000;
}
}
}

View File

@@ -1,5 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '',
enablePullDownRefresh: true,
backgroundTextStyle: 'dark'
backgroundTextStyle: 'dark',
navigationStyle: 'custom',
})

View File

@@ -0,0 +1,26 @@
.listPage {
background-color: #fafafa;
.listTopSearchWrapper {
padding: 0 15px;
position: sticky;
background: #fefefe;
z-index: 999;
}
.isScroll {
border-bottom: 0.5px solid #0000000F;
}
.listTopFilterWrapper {
display: flex;
align-items: center;
padding-top: 10px;
padding-bottom: 10px;
gap: 5px;
}
.menuFilter {
padding: 0;
}
}

View File

@@ -1,29 +1,51 @@
import ListItem from "../../components/ListItem";
import List from "../../components/List";
import Bubble from "../../components/Bubble/example";
import Range from "../../components/Range/example";
import Menu from "../../components/Menu/example";
import CityFilter from "../../components/CityFilter/example";
import Menu from "../../components/Menu";
import CityFilter from "../../components/CityFilter";
import SearchBar from "../../components/SearchBar";
import FilterPopup from "./FilterPopup";
import styles from "./index.module.scss";
import { useEffect } from "react";
import Taro from "@tarojs/taro";
import {
useTennisMatches,
useTennisLoading,
useTennisError,
useTennisLastRefresh,
useTennisActions,
} from "../../store/listStore";
import "./index.scss";
import Taro, { usePageScroll, useReachBottom } from "@tarojs/taro";
import { useListStore } from "@/store/listStore";
import { useGlobalState } from "@/store/global";
import { View } from "@tarojs/components";
import CustomerNavBar from "@/components/CustomNavbar";
import GuideBar from "@/components/GuideBar";
import ListContainer from "@/container/listContainer";
const ListPage = () => {
// 从 store 获取数据和方法
const matches = useTennisMatches();
const loading = useTennisLoading();
const error = useTennisError();
const lastRefreshTime = useTennisLastRefresh();
const { fetchMatches, refreshMatches, clearError } = useTennisActions();
const store = useListStore() || {};
const { statusNavbarHeightInfo } = useGlobalState() || {};
const {
isShowFilterPopup,
error,
matches,
loading,
fetchMatches,
refreshMatches,
updateState,
filterCount,
updateFilterOptions, // 更新筛选条件
filterOptions,
clearFilterOptions,
distanceData,
quickFilterData,
distanceQuickFilter,
isScrollTop,
} = store;
usePageScroll((res) => {
if (res?.scrollTop > 0 && !isScrollTop) {
updateState({ isScrollTop: true });
}
});
useReachBottom(() => {
console.log("触底了");
// 调用 store 的加载更多方法
// loadMoreMatches();
});
useEffect(() => {
// 页面加载时获取数据
@@ -59,147 +81,97 @@ const ListPage = () => {
});
});
// 错误处理
useEffect(() => {
if (error) {
Taro.showToast({
title: error,
icon: "error",
duration: 2000,
});
// 3秒后自动清除错误
setTimeout(() => {
clearError();
}, 3000);
}
}, [error, clearError]);
// 格式化时间显示
const formatRefreshTime = (timeString: string | null) => {
if (!timeString) return "";
const date = new Date(timeString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return "刚刚";
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
return date.toLocaleDateString();
const toggleShowPopup = () => {
updateState({ isShowFilterPopup: !isShowFilterPopup });
};
// 加载状态显示
if (loading && matches.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
}}
>
<div style={{ marginBottom: "10px" }}>...</div>
<div style={{ fontSize: "12px", color: "#ccc" }}>
</div>
</div>
);
}
/**
* @description 更新筛选条件
* @param {Record<string, any>} params 筛选项
*/
const handleUpdateFilterOptions = (params: Record<string, any>) => {
updateFilterOptions(params);
};
// 错误状态显示
if (error && matches.length === 0) {
return (
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
}}
>
<div style={{ marginBottom: "10px" }}></div>
<div style={{ marginBottom: "15px", fontSize: "12px", color: "#ccc" }}>
{error}
</div>
<button
onClick={() => fetchMatches()}
style={{
padding: "8px 16px",
fontSize: "12px",
color: "#fff",
backgroundColor: "#007aff",
border: "none",
borderRadius: "4px",
}}
>
</button>
</div>
);
}
const handleSearchChange = () => {};
// 距离筛选
const handleDistanceOrQuickChange = (name, value) => {
updateState({
distanceQuickFilter: {
...distanceQuickFilter,
[name]: value,
},
});
};
return (
<div>
<SearchBar />
{/* 综合筛选 */}
<div>
<FilterPopup />
</div>
{/* 筛选 */}
<div>
{/* 全城筛选 */}
<CityFilter />
{/* 智能排序 */}
<Menu />
</div>
<>
<CustomerNavBar />
{/* 列表内容 */}
<List>
{matches.map((match, index) => (
<ListItem key={match.id || index} {...match} />
))}
</List>
{/* 空状态 */}
{!loading && matches.length === 0 && (
<div
<View className={styles.listPage}>
<View
className={`${styles.listTopSearchWrapper} ${
isScrollTop ? styles.isScroll : ""
}`}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
height: "200px",
fontSize: "14px",
color: "#999",
top: statusNavbarHeightInfo?.totalHeight,
}}
>
<div style={{ marginBottom: "10px" }}></div>
<button
onClick={() => fetchMatches()}
style={{
padding: "8px 16px",
fontSize: "12px",
color: "#fff",
backgroundColor: "#007aff",
border: "none",
borderRadius: "4px",
}}
>
</button>
</div>
)}
</div>
<SearchBar
handleFilterIcon={toggleShowPopup}
isSelect={filterCount > 0}
filterCount={filterCount}
onChange={handleSearchChange}
/>
{/* 综合筛选 */}
{isShowFilterPopup && (
<div>
<FilterPopup
loading={loading}
onCancel={toggleShowPopup}
onConfirm={toggleShowPopup}
onChange={handleUpdateFilterOptions}
filterOptions={filterOptions}
onClear={clearFilterOptions}
visible={isShowFilterPopup}
onClose={toggleShowPopup}
statusNavbarHeigh={statusNavbarHeightInfo?.totalHeight}
/>
</div>
)}
{/* 筛选 */}
<div className={styles.listTopFilterWrapper}>
{/* 全城筛选 */}
<CityFilter
options={distanceData}
value={distanceQuickFilter?.distance}
wrapperClassName={styles.menuFilter}
onChange={handleDistanceOrQuickChange}
name="distance"
/>
{/* 智能排序 */}
<Menu
options={quickFilterData}
value={distanceQuickFilter?.quick}
onChange={handleDistanceOrQuickChange}
wrapperClassName={styles.menuFilter}
name="quick"
/>
</div>
</View>
{/* 列表内容 */}
<ListContainer
data={matches}
loading={loading}
error={error}
reload={refreshMatches}
/>
</View>
<GuideBar currentPage="list" />
</>
);
};

View File

@@ -370,7 +370,7 @@
font-family: 'PingFang SC';
font-weight: 400;
font-size: 16px;
color: #07C160;
color: #000000;
text-decoration: underline;
cursor: pointer;
transition: all 0.3s ease;

View File

@@ -10,7 +10,7 @@ const LoginPage: React.FC = () => {
const [show_terms_layer, set_show_terms_layer] = useState(false);
// 微信授权登录
const handle_wechat_login = async () => {
const handle_wechat_login = async (e: any) => {
if (!agree_terms) {
set_show_terms_layer(true);
Taro.showToast({
@@ -21,9 +21,20 @@ const LoginPage: React.FC = () => {
return;
}
// 检查是否获取到手机号
if (!e.detail || !e.detail.code) {
Taro.showToast({
title: '获取手机号失败,请重试',
icon: 'none',
duration: 2000
});
return;
}
set_is_loading(true);
try {
const response = await wechat_auth_login();
// 传递手机号code给登录服务
const response = await wechat_auth_login(e.detail.code);
if (response.success) {
save_login_state(response.token!, response.user_info!);
@@ -99,7 +110,7 @@ const LoginPage: React.FC = () => {
<View className="background_image">
<Image
className="bg_img"
src={require('../../../static/login/login_bg.png')}
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/2e00dea1-8723-42fe-ae42-84fe38e9ac3f.png"
mode="aspectFill"
/>
<View className="bg_overlay"></View>
@@ -123,7 +134,8 @@ const LoginPage: React.FC = () => {
{/* 微信快捷登录 */}
<Button
className={`login_button wechat_button ${is_loading ? 'loading' : ''}`}
onClick={handle_wechat_login}
openType="getPhoneNumber"
onGetPhoneNumber={handle_wechat_login}
disabled={is_loading}
>
<View className="wechat_icon">
@@ -217,7 +229,7 @@ const LoginPage: React.FC = () => {
{agree_terms ? '已同意' : '同意并继续'}
</Button>
</View>
</View>
)}

View File

@@ -157,7 +157,7 @@
position: relative;
z-index: 5;
flex: 1;
padding: 0px 24px ;
box-sizing: border-box;
overflow-y: auto;
@@ -181,6 +181,7 @@
line-height: 1.43em;
color: #000000;
margin-bottom: 24px;
}
// 条款详细内容
@@ -192,6 +193,14 @@
color: #000000;
margin-bottom: 40px;
white-space: pre-line;
padding: 0px 24px ;
.terms_first_line,
span.terms_first_line {
font-weight: 500;
display: block;
margin-bottom: 16px;
}
}
// 底部按钮

View File

@@ -24,7 +24,7 @@ const TermsPage: React.FC = () => {
case 'terms':
setPageTitle('条款和条件');
setTermsTitle('《开场的条款和条件》');
setTermsContent(`欢迎使用本平台(以下简称"本平台")发布与参与网球活动。为保障您的权益,请您务必仔细阅读并理解以下服务条款。
setTermsContent(`<span class="terms_first_line">欢迎使用本平台(以下简称"本平台")发布与参与网球活动。为保障您的权益,请您务必仔细阅读并理解以下服务条款。</span>
一、服务内容
1. 本平台为用户提供活动发布、报名、聊天室沟通、活动提醒等服务。
@@ -70,7 +70,7 @@ const TermsPage: React.FC = () => {
case 'binding':
setPageTitle('微信号绑定协议');
setTermsTitle('《开场与微信号绑定协议》');
setTermsContent(`欢迎使用本平台(以下简称"本平台")的微信绑定服务。为保障您的权益,请您务必仔细阅读并理解以下协议内容。
setTermsContent(`<span class="terms_first_line">欢迎使用本平台(以下简称"本平台")的微信绑定服务。为保障您的权益,请您务必仔细阅读并理解以下协议内容。</span>
一、绑定服务说明
1. 本平台提供微信账号绑定服务,用户可通过微信快捷登录方式使用平台功能。
@@ -115,7 +115,7 @@ const TermsPage: React.FC = () => {
case 'privacy':
setPageTitle('隐私权政策');
setTermsTitle('《隐私权政策》');
setTermsContent(`本平台(以下简称"我们")非常重视用户的隐私保护,本隐私权政策说明了我们如何收集、使用、存储和保护您的个人信息。
setTermsContent(`<span class="terms_first_line">本平台(以下简称"我们")非常重视用户的隐私保护,本隐私权政策说明了我们如何收集、使用、存储和保护您的个人信息。</span>
一、信息收集
1. 注册信息:包括手机号码、微信账号、昵称、头像等基本信息。
@@ -190,13 +190,14 @@ const TermsPage: React.FC = () => {
</View>
{/* 条款详细内容 */}
<View className="terms_content">
{termsContent}
</View>
<View
className="terms_content"
dangerouslySetInnerHTML={{ __html: termsContent }}
/>
</ScrollView>
</View>
);
};

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '消息',
// navigationBarBackgroundColor: '#FAFAFA',
navigationStyle: 'custom',
})

View File

@@ -0,0 +1,80 @@
@use '~@/scss/images.scss' as img;
$--Backgrounds-Primary: '#fff';
.message-container {
width: 100%;
height: 100vh;
background: radial-gradient(227.15% 100% at 50% 0%, #EEFFDC 0%, #FFF 36.58%), var(--Backgrounds-Primary, #FFF);
// padding-top: 100px;
box-sizing: border-box;
.custom-navbar {
height: 56px; /* 通常与原生导航栏高度一致 */
display: flex;
align-items: center;
justify-content: flex-start;
// background-color: #fff;
color: #000;
padding-top: 44px; /* 适配状态栏 */
position: sticky;
top: 0;
z-index: 100;
.message-navigator {
position: relative;
left: 15px;
top: -2px;
width: 80px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
.message-navigator-avatar {
width: 32px;
height: 32px;
}
.message-navigator-title {
font-size: 16px;
font-weight: 500;
color: #000;
}
}
}
.message-content {
.message-content-list {
display: flex;
flex-direction: column;
padding: 10px 15px;
box-sizing: border-box;
gap: 12px;
.message-item {
padding: 10px;
// border: 1px solid rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
background-color: rgba(255, 255, 255, 0.5);
border-radius: 10px;
}
.message-item-title {
font-size: 16px;
font-weight: 500;
color: #000;
}
.message-item-content {
font-size: 14px;
color: #666;
}
}
}
}

View File

@@ -0,0 +1,43 @@
import React from 'react'
import { View, Text, ScrollView } from '@tarojs/components'
import { Avatar } from '@nutui/nutui-react-taro'
import Taro from '@tarojs/taro'
import GuideBar from '@/components/GuideBar'
// import img from '@/config/images'
import './index.scss'
const Personal = () => {
const messageList = Array(10).fill(0).map((_, index) => ({
id: index + 1,
title: `消息${index + 1}消息${index + 1}消息${index + 1}消息${index + 1}`,
content: Array(Math.round(Math.random() * 40)).fill(0).map((_, index) => `消息${index + 1}`).join(''),
}))
return (
<View className='message-container'>
<View className='custom-navbar'>
<View className='message-navigator'>
<Avatar className='message-navigator-avatar' src="https://img.yzcdn.cn/vant/cat.jpeg" />
<Text className='message-navigator-title'></Text>
</View>
</View>
<ScrollView scrollY className='message-content'>
<View className='message-content-list'>
{messageList.map((item) => (
<View className='message-item' key={item.id}>
<View className='message-item-title'>
<Text>{item.title}</Text>
</View>
<View className='message-item-content'>
<Text>{item.content}</Text>
</View>
</View>
))}
</View>
</ScrollView>
<GuideBar currentPage='message' />
</View>
)
}
export default Personal

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '订单确认',
navigationBarBackgroundColor: '#FAFAFA'
})

View File

@@ -0,0 +1 @@
@use '~@/scss/images.scss' as img;

View File

@@ -0,0 +1,12 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
const OrderCheck = () => {
return (
<View>
<Text>OrderCheck</Text>
</View>
)
}
export default OrderCheck

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '个人中心',
// navigationBarBackgroundColor: '#FAFAFA',
navigationStyle: 'custom',
})

View File

@@ -0,0 +1,35 @@
@use '~@/scss/images.scss' as img;
$--Backgrounds-Primary: '#fff';
.personal-container {
width: 100%;
height: 100vh;
background: radial-gradient(227.15% 100% at 50% 0%, #EEFFDC 0%, #FFF 36.58%), var(--Backgrounds-Primary, #FFF);
padding-top: 100px;
box-sizing: border-box;
.personal-navigator {
position: fixed;
left: 10px;
top: 54px;
width: 32px;
height: 32px;
.personal-navigator-back {
width: 100%;
height: 100%;
}
}
.personal-content {
width: 100%;
height: calc(100vh - 300px);
display: flex;
justify-content: center;
align-items: center;
font-size: 32px;
font-weight: 500;
color: #000;
}
}

View File

@@ -0,0 +1,31 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import Taro, { useRouter } from '@tarojs/taro'
import GuideBar from '@/components/GuideBar'
import img from '@/config/images'
import './index.scss'
const Personal = () => {
const { params } = useRouter()
const { id } = params
const handleBack = () => {
Taro.navigateBack()
}
return (
<View className='personal-container'>
{id && (
<View className='personal-navigator' onClick={handleBack}>
<Image className='personal-navigator-back' src={img.ICON_NAVIGATOR_BACK} />
</View>
)}
<View className='personal-content'>
<Text>Personal</Text>
</View>
<GuideBar currentPage='personal' />
</View>
)
}
export default Personal

View File

@@ -3,7 +3,8 @@ import Taro from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import images from '@/config/images'
import TextareaTag from '@/components/TextareaTag'
import CoverImageUpload, { type CoverImage } from '@/components/ImageUpload'
// import CoverImageUpload, { type CoverImage } from '@/components/ImageUpload'
import UploadCover, { type CoverImageValue } from '@/components/UploadCover'
import { useDictionaryActions } from '@/store/dictionaryStore'
import './StadiumDetail.scss'
@@ -18,7 +19,7 @@ export interface Stadium {
court_surface?: string
description?: string
description_tag?: string[]
venue_image_list?: CoverImage[]
venue_image_list?: CoverImageValue[]
}
interface StadiumDetailProps {
@@ -103,7 +104,7 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
court_type: court_type[0] || '',
court_surface: court_surface[0] || '',
additionalInfo: '',
venue_image_list: [] as CoverImage[],
venue_image_list: [] as CoverImageValue[],
description:{
description: '',
description_tag: []
@@ -216,9 +217,21 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
if (item.type === 'image') {
return (
<SectionContainer key={item.label} title={item.label} prop={item.prop}>
<CoverImageUpload
images={formData[item.prop]}
onChange={(images) => updateFormData(item.prop, images)}
<UploadCover
value={formData[item.prop]}
onChange={(value) => {
console.log(value, 'value')
if (value instanceof Function) {
const newValue = value(formData[item.prop])
console.log(newValue, 'newValue')
updateFormData(item.prop, newValue)
} else {
updateFormData(item.prop, value)
}
}}
maxCount={9}
source={['album', 'history', 'preset']}
align='left'
/>
</SectionContainer>
)
@@ -230,4 +243,4 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
)
})
export default StadiumDetail
export default StadiumDetail

View File

@@ -8,40 +8,40 @@ import PublishForm from './publishForm'
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema';
import { PublishBallFormData } from '../../../types/publishBall';
import PublishService from '@/services/publishService';
import { getNextHourTime, getEndTime } from '@/utils/timeUtils';
import { getNextHourTime, getEndTime, delay } from '@/utils';
import images from '@/config/images'
import styles from './index.module.scss'
const defaultFormData: PublishBallFormData = {
title: '',
image_list: ['https://static-o.oss-cn-shenzhen.aliyuncs.com/images/tpbj/tpss10.jpg'],
title: '',
image_list: ['https://static-o.oss-cn-shenzhen.aliyuncs.com/images/tpbj/tpss10.jpg'],
timeRange: {
start_time: getNextHourTime(),
start_time: getNextHourTime(),
end_time: getEndTime(getNextHourTime())
},
activityInfo: {
activityInfo: {
play_type: '不限',
price: '',
venue_id: null,
venue_id: null,
location_name: '',
location: '',
latitude: '',
location: '',
latitude: '',
longitude: '',
court_type: '',
court_surface: '',
venue_description_tag: [],
venue_description: '',
venue_image_list: [],
court_type: '',
court_surface: '',
venue_description_tag: [],
venue_description: '',
venue_image_list: [],
},
players: [1, 4],
skill_level: [1.0, 5.0],
descriptionInfo: {
description: '',
description_tag: [],
description: '',
description_tag: [],
},
is_substitute_supported: true,
is_wechat_contact: true,
wechat_contact: '14223332214'
is_substitute_supported: true,
is_wechat_contact: true,
wechat_contact: '14223332214'
}
const PublishBall: React.FC = () => {
@@ -72,7 +72,7 @@ const PublishBall: React.FC = () => {
const [formData, setFormData] = useState<PublishBallFormData[]>([
defaultFormData
])
// 删除确认弹窗状态
const [deleteConfirm, setDeleteConfirm] = useState<{
visible: boolean;
@@ -225,6 +225,13 @@ const PublishBall: React.FC = () => {
title: '发布成功',
icon: 'success'
})
delay(1000)
// 如果是个人球局,则跳转到详情页,并自动分享
// 如果是畅打,则跳转第一个球局详情页,并自动分享 @刘杰
Taro.navigateTo({
// @ts-expect-error: id
url: `/pages/detail/index?id=${res.data.id || 1}&from=publish&autoShare=1`
})
} else {
Taro.showToast({
title: res.message,
@@ -268,12 +275,16 @@ const PublishBall: React.FC = () => {
<View className={styles['publish-ball']}>
{/* 活动类型切换 */}
<View className={styles['activity-type-switch']}>
<<<<<<< HEAD
{/* <ActivityTypeSwitch
=======
<ActivityTypeSwitch
>>>>>>> d92419f3c5648a67d1b6857d66d6c92a4bece034
value={activityType}
onChange={handleActivityTypeChange}
/> */}
</View>
<View className={styles['publish-ball__scroll']}>
{
formData.map((item, index) => (
@@ -283,19 +294,19 @@ const PublishBall: React.FC = () => {
<View className={styles['session-header']}>
<View className={styles['session-title']}>
{index + 1}
<View
className={styles['session-delete']}
<View
className={styles['session-delete']}
onClick={() => showDeleteConfirm(index)}
>
<Image src={images.ICON_DELETE} className={styles['session-delete-icon']} />
</View>
</View>
<View className={styles['session-actions']}>
{index > 0 && (
<View
className={styles['session-action-btn']}
<View
className={styles['session-action-btn']}
onClick={() => handleCopyPrevious(index)}
>
@@ -304,10 +315,10 @@ const PublishBall: React.FC = () => {
</View>
</View>
)}
<PublishForm
formData={item}
onChange={(key, value) => updateFormData(key, value, index)}
optionsConfig={publishBallFormSchema}
<PublishForm
formData={item}
onChange={(key, value) => updateFormData(key, value, index)}
optionsConfig={publishBallFormSchema}
/>
</View>
))
@@ -323,6 +334,7 @@ const PublishBall: React.FC = () => {
</View>
{/* 删除确认弹窗 */}
<<<<<<< HEAD
<CommonDialog
visible={deleteConfirm.visible}
cancelText="再想想"
@@ -332,6 +344,30 @@ const PublishBall: React.FC = () => {
contentTitle="确认移除该场次?"
contentDesc="该操作不可恢复"
/>
=======
{deleteConfirm.visible && (
<View className={styles['delete-modal']}>
<View className={styles['delete-modal__content']}>
<Text className={styles['delete-modal__title']}></Text>
<Text className={styles['delete-modal__desc']}></Text>
<View className={styles['delete-modal__actions']}>
<Button
className={styles['delete-modal__btn']}
onClick={closeDeleteConfirm}
>
</Button>
<Button
className={styles['delete-modal__btn']}
onClick={confirmDelete}
>
</Button>
</View>
</View>
</View>
)}
>>>>>>> d92419f3c5648a67d1b6857d66d6c92a4bece034
{/* 完成按钮 */}
<View className={styles['submit-section']}>

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch } from '../../components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch, UploadCover } from '../../components'
import FormBasicInfo from './components/FormBasicInfo'
import { type CoverImage } from '../../components/index.types'
import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema'
@@ -22,18 +22,22 @@ const componentMap = {
[FieldType.WECHATCONTACT]: WechatSwitch,
}
const PublishForm: React.FC<{
formData: PublishBallFormData,
onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void,
const PublishForm: React.FC<{
formData: PublishBallFormData,
onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void,
optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => {
const [coverImages, setCoverImages] = useState<CoverImage[]>([])
// 字典数据相关
const { getDictionaryValue } = useDictionaryActions()
// 处理封面图片变化
const handleCoverImagesChange = (images: CoverImage[]) => {
setCoverImages(images)
const handleCoverImagesChange = (fn: (images: CoverImage[]) => CoverImage[]) => {
if (fn instanceof Function) {
setCoverImages(fn(coverImages))
} else {
setCoverImages(fn)
}
}
// 更新表单数据
@@ -70,7 +74,7 @@ const PublishForm: React.FC<{
})
}
}
// 如果是补充要求,从字典获取选项
if (item.prop === 'descriptionInfo') {
const descriptionOptions = getDictionaryOptions('publishing_requirements', [])
@@ -79,7 +83,7 @@ const PublishForm: React.FC<{
options: descriptionOptions
}
}
return item
})
}
@@ -121,7 +125,7 @@ const PublishForm: React.FC<{
// 获取动态表单配置
const dynamicConfig = getDynamicFormConfig()
@@ -140,8 +144,8 @@ const PublishForm: React.FC<{
}
if (item.type === FieldType.UPLOADIMAGE) {
/* 活动封面 */
return <ImageUpload
images={coverImages}
return <UploadCover
value={coverImages}
onChange={handleCoverImagesChange}
{...item.props}
/>
@@ -182,7 +186,7 @@ const PublishForm: React.FC<{
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps}
placeholder={item.placeholder}
placeholder={item.placeholder}
/>
</View>
</View>

View File

@@ -0,0 +1,9 @@
export default {
navigationBarTitleText: '个人主页',
navigationBarBackgroundColor: '#FFFFFF',
navigationBarTextStyle: 'black',
backgroundColor: '#FAFAFA',
enablePullDownRefresh: false,
disableScroll: false,
navigationStyle: 'custom'
}

View File

@@ -0,0 +1,600 @@
// 个人页面样式
.myself_page {
min-height: 100vh;
background: radial-gradient(circle at 50% 0%, rgba(238, 255, 220, 1) 0%, rgba(255, 255, 255, 1) 37%);
position: relative;
overflow: hidden;
box-sizing: border-box;
}
// 主要内容区域
.main_content {
position: relative;
z-index: 5;
flex: 1;
margin-top: 0;
box-sizing: border-box;
overflow-y: auto;
padding: 15px 15px 15px;
// 用户信息区域
.user_info_section {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 16px;
// 基本信息
.basic_info {
display: flex;
align-items: center;
gap: 16px;
.avatar_container {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
box-shadow: 0px 8px 20px 0px rgba(0, 0, 0, 0.12), 0px 0px 1px 0px rgba(0, 0, 0, 0.2);
.avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.info_container {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.nickname {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.4em;
letter-spacing: 1.9%;
color: #000000;
}
.join_date {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.4em;
letter-spacing: 2.7%;
color: rgba(0, 0, 0, 0.35);
}
}
}
// 统计数据
.stats_section {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
.stats_container {
display: flex;
align-items: center;
gap: 20px;
.stat_item {
display: flex;
flex-direction: column;
align-items: center;
.stat_number {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 18px;
line-height: 1.4em;
letter-spacing: 2.1%;
color: rgba(0, 0, 0, 0.85);
}
.stat_label {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 12px;
line-height: 1.4em;
letter-spacing: 3.2%;
color: rgba(0, 0, 0, 0.35);
}
}
}
.action_buttons {
display: flex;
align-items: center;
gap: 12px;
.follow_button {
display: flex;
align-items: center;
gap: 4px;
padding: 12px 16px 12px 12px;
height: 40px;
background: #000000;
border: 0.5px solid rgba(0, 0, 0, 0.06);
border-radius: 999px;
cursor: pointer;
transition: all 0.3s ease;
&.following {
background: #FFFFFF;
color: #000000;
}
.button_icon {
width: 20px;
height: 20px;
}
.button_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 14px;
line-height: 1.4em;
color: #FFFFFF;
.following & {
color: #000000;
}
}
}
.message_button {
width: 40px;
height: 40px;
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.12);
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
.button_icon {
width: 18px;
height: 18px;
}
}
.edit_button {
min-width: 60px;
height: 40px;
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.12);
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
padding: 0 12px;
.button_text {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.4em;
color: #000000;
}
}
.share_button {
min-width: 60px;
height: 40px;
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.12);
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
padding: 0 12px;
margin: 0px !important;
.button_text {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
line-height: 1.4em;
color: #000000;
}
}
}
}
// 标签和简介
.tags_bio_section {
display: flex;
flex-direction: column;
gap: 10px;
.tags_container {
display: flex;
gap: 8px;
flex-wrap: wrap;
.tag_item {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
height: 20px;
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.16);
border-radius: 999px;
.tag_icon {
width: 12px;
height: 12px;
}
.tag_text {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 11px;
line-height: 1.8em;
letter-spacing: -2.1%;
color: #000000;
}
}
}
.bio_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.571em;
color: rgba(0, 0, 0, 0.65);
white-space: pre-line;
}
}
// 球局订单和收藏功能
.quick_actions_section {
margin-bottom: 16px;
.action_card {
display: flex;
align-items: center;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
overflow: hidden;
.action_content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px 0;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.action_icon {
width: 20px;
height: 20px;
}
.action_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 15px;
line-height: 1.4em;
color: #000000;
}
}
.action_divider {
width: 1px;
height: 16px;
background: rgba(0, 0, 0, 0.06);
}
}
}
}
// 球局类型标签页
.game_tabs_section {
margin-bottom: 16px;
.tab_container {
display: flex;
gap: 16px;
padding: 12px 15px;
.tab_item {
padding: 12px 0;
cursor: pointer;
transition: all 0.3s ease;
.tab_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.4em;
letter-spacing: 1.9%;
color: rgba(0, 0, 0, 0.85);
transition: color 0.3s ease;
}
&.active {
.tab_text {
color: #000000;
}
}
&:not(.active) {
.tab_text {
color: rgba(0, 0, 0, 0.2);
}
}
}
}
}
// 球局列表区域
.game_list_section {
.date_header {
display: flex;
align-items: center;
gap: 4px;
padding: 10px 15px;
margin-bottom: 16px;
.date_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 14px;
line-height: 1.4em;
letter-spacing: 2.71%;
color: rgba(0, 0, 0, 0.85);
}
.separator {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 18px;
line-height: 1.4em;
letter-spacing: 2.11%;
color: rgba(0, 0, 0, 0.35);
}
.weekday_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 14px;
line-height: 1.4em;
letter-spacing: 2.71%;
color: rgba(0, 0, 0, 0.85);
}
}
// 球局卡片
.game_cards {
display: flex;
flex-direction: column;
gap: 5px;
padding: 0 5px 15px;
.game_card {
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.08);
border-radius: 20px;
padding: 0 0 12px;
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
cursor: pointer;
transition: all 0.3s ease;
position: relative;
&:active {
transform: scale(0.98);
}
// 球局标题和类型
.game_header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px 0;
.game_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 16px;
line-height: 1.5em;
color: #000000;
}
.game_type_icon {
width: 16px;
height: 16px;
.type_icon {
width: 100%;
height: 100%;
}
}
}
// 球局时间
.game_time {
padding: 6px 15px 0;
.time_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 12px;
line-height: 1.5em;
color: rgba(60, 60, 67, 0.6);
}
}
// 球局地点和类型
.game_location {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 15px 0;
.location_text,
.type_text,
.distance_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 12px;
line-height: 1.5em;
color: rgba(60, 60, 67, 0.6);
}
.separator {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.3em;
color: rgba(60, 60, 67, 0.3);
}
}
// 球局图片
.game_images {
position: absolute;
top: 11px;
right: 5px;
width: 100px;
height: 100px;
box-shadow: 0px 4px 24px 0px rgba(0, 0, 0, 0.2);
.game_image {
position: absolute;
width: 56.44px;
height: 56.44px;
border-radius: 9px;
border: 1.5px solid #FFFFFF;
&:nth-child(1) {
top: 4.18px;
left: 19.18px;
}
&:nth-child(2) {
top: 26.5px;
left: 38px;
width: 61.86px;
height: 61.86px;
}
&:nth-child(3) {
top: 32.5px;
left: 0;
width: 62.04px;
height: 62.04px;
}
}
}
// 球局信息标签
.game_tags {
display: flex;
flex-direction: row;
gap: 6px;
padding: 8px 15px 0;
.participants_info {
display: flex;
gap: 4px;
.avatars {
display: flex;
align-items: center;
gap: -8px;
.participant_avatar {
width: 20px;
height: 20px;
border-radius: 50%;
border: 1px solid #FFFFFF;
}
}
.participants_count {
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.16);
border-radius: 999px;
padding: 6px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
.count_text {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 11px;
line-height: 1.8em;
letter-spacing: -2.1%;
color: #000000;
}
}
}
.game_info_tags {
display: flex;
gap: 4px;
.info_tag {
background: #FFFFFF;
border: 0.5px solid rgba(0, 0, 0, 0.16);
border-radius: 999px;
padding: 6px 8px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
.tag_text {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 11px;
line-height: 1.8em;
letter-spacing: -2.1%;
color: #000000;
}
}
}
}
}
}
}
}
// 底部指示器
.home_indicator {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 5px;
background: #000000;
border-radius: 2.5px;
z-index: 10;
}

View File

@@ -0,0 +1,377 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Image, ScrollView, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
// 用户信息接口
interface UserInfo {
id: string;
nickname: string;
avatar: string;
join_date: string;
stats: {
following: number;
friends: number;
hosted: number;
participated: number;
};
tags: string[];
bio: string;
location: string;
occupation: string;
ntrp_level: string;
}
// 球局记录接口
interface GameRecord {
id: string;
title: string;
date: string;
time: string;
duration: string;
location: string;
type: string;
distance: string;
participants: {
avatar: string;
nickname: string;
}[];
max_participants: number;
current_participants: number;
level_range: string;
game_type: string;
images: string[];
}
const MyselfPage: React.FC = () => {
// 获取页面参数
const instance = Taro.getCurrentInstance();
const user_id = instance.router?.params?.userid;
// 判断是否为当前用户
const is_current_user = !user_id;
// 模拟用户数据
const [user_info] = useState<UserInfo>({
id: '1',
nickname: '188的王晨',
avatar: require('../../../static/userInfo/default_avatar.svg'),
join_date: '2025年9月加入',
stats: {
following: 124,
friends: 24,
hosted: 7,
participated: 24
},
tags: ['上海黄浦', '互联网从业者', 'NTRP 4.0'],
bio: '网球入坑两年,偏好双打,正手进攻型选手\n平时在张江、世纪公园附近活动欢迎约球\n不卷分数但认真对待每一拍每一场球都想打得开心。有时候也会带相机来拍点照片📸',
location: '上海黄浦',
occupation: '互联网从业者',
ntrp_level: 'NTRP 4.0'
});
// 模拟球局数据
const [game_records] = useState<GameRecord[]>([
{
id: '1',
title: '女生轻松双打',
date: '明天(周五)',
time: '下午5点',
duration: '2小时',
location: '仁恒河滨花园网球场',
type: '室外',
distance: '3.5km',
participants: [
{ avatar: require('../../../static/userInfo/user1.svg'), nickname: '用户1' },
{ avatar: require('../../../static/userInfo/user2.svg'), nickname: '用户2' }
],
max_participants: 4,
current_participants: 2,
level_range: '2.0 至 2.5',
game_type: '双打',
images: [
require('../../../static/userInfo/game1.svg'),
require('../../../static/userInfo/game2.svg'),
require('../../../static/userInfo/game3.svg')
]
}
]);
// 关注状态
const [is_following, setIsFollowing] = useState(false);
// 当前激活的标签页
const [active_tab, setActiveTab] = useState<'hosted' | 'participated'>('hosted');
// 处理关注/取消关注
const handle_follow = () => {
setIsFollowing(!is_following);
Taro.showToast({
title: is_following ? '已取消关注' : '关注成功',
icon: 'success',
duration: 1500
});
};
// 处理分享
const handle_share = () => {
Taro.showShareMenu({
withShareTicket: true
});
};
// 处理返回
const handle_back = () => {
Taro.navigateBack();
};
// 处理编辑资料
const handle_edit_profile = () => {
Taro.navigateTo({
url: '/pages/userInfo/edit/index'
});
};
// 处理球局详情
const handle_game_detail = (game_id: string) => {
Taro.navigateTo({
url: `/pages/game/detail/index?id=${game_id}`
});
};
// 处理球局订单
const handle_game_orders = () => {
Taro.navigateTo({
url: '/pages/game/orders/index'
});
};
// 处理收藏
const handle_favorites = () => {
Taro.navigateTo({
url: '/pages/game/favorites/index'
});
};
return (
<View className="myself_page">
{/* 主要内容 */}
<ScrollView className="main_content" scrollY>
{/* 用户信息区域 */}
<View className="user_info_section">
{/* 头像和基本信息 */}
<View className="basic_info">
<View className="avatar_container">
<Image className="avatar" src={user_info.avatar} />
</View>
<View className="info_container">
<Text className="nickname">{user_info.nickname}</Text>
<Text className="join_date">{user_info.join_date}</Text>
</View>
</View>
{/* 统计数据 */}
<View className="stats_section">
<View className="stats_container">
<View className="stat_item">
<Text className="stat_number">{user_info.stats.following}</Text>
<Text className="stat_label"></Text>
</View>
<View className="stat_item">
<Text className="stat_number">{user_info.stats.friends}</Text>
<Text className="stat_label"></Text>
</View>
<View className="stat_item">
<Text className="stat_number">{user_info.stats.hosted}</Text>
<Text className="stat_label"></Text>
</View>
<View className="stat_item">
<Text className="stat_number">{user_info.stats.participated}</Text>
<Text className="stat_label"></Text>
</View>
</View>
<View className="action_buttons">
{/* 只有非当前用户才显示关注按钮 */}
{!is_current_user && (
<Button
className={`follow_button ${is_following ? 'following' : ''}`}
onClick={handle_follow}
>
<Image
className="button_icon"
src={require('../../../static/userInfo/plus.svg')}
/>
<Text className="button_text">
{is_following ? '已关注' : '关注'}
</Text>
</Button>
)}
{/* 只有非当前用户才显示消息按钮 */}
{!is_current_user && (
<Button className="message_button">
<Image
className="button_icon"
src={require('../../../static/userInfo/message.svg')}
/>
</Button>
)}
{/* 只有当前用户才显示编辑按钮 */}
{is_current_user && (
<Button className="edit_button" onClick={handle_edit_profile}>
<Text className="button_text"></Text>
</Button>
)}
{/* 只有当前用户才显示分享按钮 */}
{is_current_user && (
<Button className="share_button" onClick={handle_share}>
<Text className="button_text"></Text>
</Button>
)}
</View>
</View>
{/* 标签和简介 */}
<View className="tags_bio_section">
<View className="tags_container">
<View className="tag_item">
<Image
className="tag_icon"
src={require('../../../static/userInfo/location.svg')}
/>
<Text className="tag_text">{user_info.location}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.occupation}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.ntrp_level}</Text>
</View>
</View>
<Text className="bio_text">{user_info.bio}</Text>
</View>
{/* 球局订单和收藏功能 */}
<View className="quick_actions_section">
<View className="action_card">
<View className="action_content" onClick={handle_game_orders}>
<Image
className="action_icon"
src={require('../../../static/userInfo/tennis.svg')}
/>
<Text className="action_text"></Text>
</View>
<View className="action_divider"></View>
<View className="action_content" onClick={handle_favorites}>
<Image
className="action_icon"
src={require('../../../static/userInfo/tennis.svg')}
/>
<Text className="action_text"></Text>
</View>
</View>
</View>
</View>
{/* 球局类型标签页 */}
<View className="game_tabs_section">
<View className="tab_container">
<View className={`tab_item ${active_tab === 'hosted' ? 'active' : ''}`} onClick={() => setActiveTab('hosted')}>
<Text className="tab_text"></Text>
</View>
<View className={`tab_item ${active_tab === 'participated' ? 'active' : ''}`} onClick={() => setActiveTab('participated')}>
<Text className="tab_text"></Text>
</View>
</View>
</View>
{/* 球局列表 */}
<View className="game_list_section">
<View className="date_header">
<Text className="date_text">528</Text>
<Text className="separator">/</Text>
<Text className="weekday_text"></Text>
</View>
{/* 球局卡片 */}
<View className="game_cards">
{game_records.map((game) => (
<View
key={game.id}
className="game_card"
onClick={() => handle_game_detail(game.id)}
>
{/* 球局标题和类型 */}
<View className="game_header">
<Text className="game_title">{game.title}</Text>
<View className="game_type_icon">
<Image
className="type_icon"
src={require('../../../static/userInfo/tennis.svg')}
/>
</View>
</View>
{/* 球局时间 */}
<View className="game_time">
<Text className="time_text">
{game.date} {game.time} {game.duration}
</Text>
</View>
{/* 球局地点和类型 */}
<View className="game_location">
<Text className="location_text">{game.location}</Text>
<Text className="separator">·</Text>
<Text className="type_text">{game.type}</Text>
<Text className="separator">·</Text>
<Text className="distance_text">{game.distance}</Text>
</View>
{/* 球局图片 */}
<View className="game_images">
{game.images.map((image, index) => (
<Image
key={index}
className="game_image"
src={image}
/>
))}
</View>
{/* 球局信息标签 */}
<View className="game_tags">
<View className="participants_info">
<View className="avatars">
{game.participants.map((participant, index) => (
<Image
key={index}
className="participant_avatar"
src={participant.avatar}
/>
))}
</View>
<View className="participants_count">
<Text className="count_text">
{game.current_participants}/{game.max_participants}
</Text>
</View>
</View>
<View className="game_info_tags">
<View className="info_tag">
<Text className="tag_text">{game.level_range}</Text>
</View>
<View className="info_tag">
<Text className="tag_text">{game.game_type}</Text>
</View>
</View>
</View>
</View>
))}
</View>
</View>
</ScrollView>
</View>
);
};
export default MyselfPage;

View File

@@ -16,7 +16,30 @@ $-images: (
'icon-personal': '/publishBall/icon-personal.svg',
'icon-changda': '/publishBall/icon-changda.svg',
'icon-cost': '/publishBall/icon-cost.svg',
'icon-remove': '/publishBall/icon-remove.svg'
'icon-remove': '/publishBall/icon-remove.svg',
'icon-arrow-left': '/detail/icon-arrow-left.svg',
'icon-logo-go': '/detail/icon-logo-go.svg',
'icon-search': '/publishBall/icon-search.svg',
'icon-map': '/publishBall/icon-map.svg',
'icon-stadium': '/publishBall/icon-stadium.svg',
'icon-arrow-small': '/publishBall/icon-arrow-small.svg',
'icon-map-search': '/publishBall/icon-map-search.svg',
'icon-heartcircle': '/publishBall/icon-heartcircle.png',
'icon-copy': '/publishBall/icon-arrow-right.svg',
'icon-delete': '/publishBall/icon-delete.svg',
'icon-circle-unselect': '/publishBall/icon-circle-unselect.svg',
'icon-circle-select-ring': '/publishBall/icon-circle-select-ring.svg',
'icon-circle-select-arrow': '/publishBall/icon-circle-select-arrow.svg',
'icon-weather-sun': '/detail/icon-weather-sun.svg',
'icon-detail-map': '/detail/icon-map.svg',
'icon-detail-arrow-right': '/detail/icon-arrow-right.svg',
'icon-detail-notice': '/detail/icon-notice.svg',
'icon-detail-application-add': '/detail/icon-application-add.svg',
'icon-detail-comment': '/detail/icon-comment.svg',
'icon-detail-comment-dark': '/detail/icon-comment-dark.svg',
'icon-detail-share': '/detail/icon-share-dark.svg',
'icon-guide-bar-publish': '/common/guide-bar-publish.svg',
'icon-navigator-back': '/common/navigator-back.svg',
) !default;
// 图片获取函数

View File

@@ -0,0 +1,41 @@
import httpService from './httpService'
import type { ApiResponse } from './httpService'
// 用户接口
export interface GameDetail {
id: number,
title: string,
venue_id: number,
creator_id: number,
game_date: string,
start_time: string,
end_time: string,
max_participants: number,
current_participants: number,
ntrp_level: string,
play_style: string,
description: string,
status: string,
created_at: string,
updated_at: string,
}
// 响应接口
export interface Response {
code: string
message: string
data: GameDetail
}
// 发布球局类
class GameDetailService {
// 用户登录
async getDetail(id: number): Promise<ApiResponse<Response>> {
return httpService.post('/games/detail', { id }, {
showLoading: true,
})
}
}
// 导出认证服务实例
export default new GameDetailService()

View File

@@ -15,6 +15,7 @@ export interface RequestConfig {
needAuth?: boolean // 是否需要token认证
showLoading?: boolean // 是否显示加载提示
loadingText?: string // 加载提示文本
showToast?: boolean // 是否显示toast
}
// 响应数据接口
@@ -58,7 +59,7 @@ class HttpService {
// 构建完整URL
private buildUrl(url: string, params?: Record<string, any>): string {
const fullUrl = url.startsWith('http') ? url : `${this.baseURL}${url}`
if (params) {
const searchParams = new URLSearchParams()
Object.entries(params).forEach(([key, value]) => {
@@ -69,7 +70,7 @@ class HttpService {
const queryString = searchParams.toString()
return queryString ? `${fullUrl}?${queryString}` : fullUrl
}
return fullUrl
}
@@ -95,7 +96,7 @@ class HttpService {
const logMethod = console[level] || console.log
const timestamp = new Date().toLocaleTimeString()
if (data) {
logMethod(`[${timestamp}] HTTP ${level.toUpperCase()}: ${message}`, data)
} else {
@@ -165,9 +166,9 @@ class HttpService {
// 处理业务错误
private handleBusinessError(data: any): void {
const message = data.message || '操作失败'
this.log('error', `业务错误: ${message}`, data)
Taro.showToast({
title: message,
icon: 'none',
@@ -187,7 +188,7 @@ class HttpService {
} = config
const fullUrl = this.buildUrl(url, method === 'GET' ? params : undefined)
this.log('info', `发起请求: ${method} ${fullUrl}`, {
data: method !== 'GET' ? data : undefined,
params: method === 'GET' ? params : undefined
@@ -223,18 +224,18 @@ class HttpService {
return this.handleResponse<T>(response)
} catch (error) {
this.log('error', '请求失败', error)
// 在模拟模式下返回模拟数据
if (envConfig.enableMock && isDevelopment()) {
this.log('info', '使用模拟数据')
return this.getMockResponse<T>(url, method)
}
Taro.showToast({
title: '网络连接失败',
icon: 'none'
})
throw error
} finally {
// 隐藏加载提示
@@ -247,7 +248,7 @@ class HttpService {
// 获取模拟数据
private getMockResponse<T>(url: string, method: string): ApiResponse<T> {
this.log('info', `返回模拟数据: ${method} ${url}`)
return {
code: 200,
success: true,
@@ -323,4 +324,4 @@ class HttpService {
}
// 导出HTTP服务实例
export default new HttpService()
export default new HttpService()

View File

@@ -1,93 +1,67 @@
import { TennisMatch } from '../store/listStore'
import { TennisMatch } from "../store/listStore";
import httpService from "./httpService";
// 模拟网络延迟
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
// 模拟API响应格式
interface ApiResponse<T> {
code: number
message: string
data: T
timestamp: number
code: number;
message: string;
data: T;
timestamp: number;
}
// 模拟网球比赛数据
const mockTennisMatches: TennisMatch[] = [
{
id: '1',
title: '周一晚场浦东新区单打约球',
dateTime: '明天(周五)下午5点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
id: "1",
title: "周一晚场浦东新区单打约球",
dateTime: "明天(周五)下午5点 2小时",
location: "仁恒河滨花园网球场",
distance: "3.5km",
shinei: "室内",
registeredCount: 3,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
skillLevel: "2.0 至 2.5",
matchType: "双打",
images: [
'https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center'
]
"https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center",
"https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center",
"https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center",
],
},
{
id: '2',
title: '浦东新区单打约球',
dateTime: '明天(周五)下午5点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
id: "2",
title: "浦东新区单打约球",
dateTime: "明天(周五)下午5点 2小时",
location: "仁恒河滨花园网球场",
distance: "3.5km",
shinei: "室外",
registeredCount: 2,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
skillLevel: "2.0 至 2.5",
matchType: "双打",
images: [
'https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center',
'https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center'
]
"https://images.unsplash.com/photo-1571019613454-1cb2f99b2d8b?w=200&h=200&fit=crop&crop=center",
"https://images.unsplash.com/photo-1551698618-1dfe5d97d256?w=200&h=200&fit=crop&crop=center",
],
},
{
id: '3',
title: '黄浦区双打约球',
dateTime: '7月20日(周日)下午6点 2小时',
location: '仁恒河滨花园网球场・室外',
distance: '3.5km',
id: "3",
title: "黄浦区双打约球",
dateTime: "7月20日(周日)下午6点 2小时",
location: "仁恒河滨花园网球场",
distance: "3.5km",
registeredCount: 3,
maxCount: 4,
skillLevel: '2.0 至 2.5',
matchType: '双打',
skillLevel: "2.0 至 2.5",
matchType: "双打",
images: [
'https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center'
]
}
]
// 模拟数据变化
const generateDynamicData = (): TennisMatch[] => {
return mockTennisMatches.map(match => ({
...match,
// 随机更新注册人数
registeredCount: Math.min(
match.maxCount,
Math.max(0, match.registeredCount + Math.floor(Math.random() * 3) - 1)
),
// 随机更新距离
distance: `${(Math.random() * 5 + 1).toFixed(1)}km`,
// 随机更新时间
dateTime: Math.random() > 0.5 ? match.dateTime : '今天下午3点 2小时'
}))
}
// 模拟网络错误
const simulateNetworkError = (): boolean => {
// 10% 概率模拟网络错误
return Math.random() < 0.1
}
// 模拟网络超时
const simulateTimeout = (): boolean => {
// 5% 概率模拟超时
return Math.random() < 0.05
}
"https://images.unsplash.com/photo-1554068865-24cecd4e34b8?w=200&h=200&fit=crop&crop=center",
],
},
];
/**
* 获取网球比赛列表
@@ -95,59 +69,18 @@ const simulateTimeout = (): boolean => {
* @returns Promise<TennisMatch[]>
*/
export const getTennisMatches = async (params?: {
page?: number
pageSize?: number
location?: string
skillLevel?: string
}): Promise<TennisMatch[]> => {
page?: number;
pageSize?: number;
location?: string;
skillLevel?: string;
}) => {
try {
console.log('API调用: getTennisMatches', params)
// 模拟网络延迟 (800-1500ms)
const delayTime = 800 + Math.random() * 700
await delay(delayTime)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('网络连接失败,请检查网络设置')
}
// 模拟超时
if (simulateTimeout()) {
throw new Error('请求超时,请稍后重试')
}
// 生成动态数据
const matches = generateDynamicData()
// 模拟分页
if (params?.page && params?.pageSize) {
const start = (params.page - 1) * params.pageSize
const end = start + params.pageSize
return matches.slice(start, end)
}
// 模拟筛选
if (params?.location) {
return matches.filter(match =>
match.location.includes(params.location!)
)
}
if (params?.skillLevel) {
return matches.filter(match =>
match.skillLevel.includes(params.skillLevel!)
)
}
console.log('API响应成功:', matches.length, '条数据')
return matches
return httpService.post('/venues/list', params, { showLoading: false })
} catch (error) {
console.error('API调用失败:', error)
throw error
console.error("列表数据获取失败:", error);
throw error;
}
}
};
/**
* 刷新网球比赛数据
@@ -155,69 +88,12 @@ export const getTennisMatches = async (params?: {
*/
export const refreshTennisMatches = async (): Promise<TennisMatch[]> => {
try {
console.log('API调用: refreshTennisMatches')
// 模拟刷新延迟 (500-1000ms)
const delayTime = 500 + Math.random() * 500
await delay(delayTime)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('刷新失败,请稍后重试')
}
// 生成新的动态数据
const matches = generateDynamicData()
console.log('API刷新成功:', matches.length, '条数据')
return matches
const matches = generateDynamicData();
return matches;
} catch (error) {
console.error('API刷新失败:', error)
throw error
console.error("API刷新失败:", error);
throw error;
}
}
};
/**
* 获取比赛详情
* @param id 比赛ID
* @returns Promise<TennisMatch | null>
*/
export const getTennisMatchDetail = async (id: string): Promise<TennisMatch | null> => {
try {
console.log('API调用: getTennisMatchDetail', id)
// 模拟网络延迟
await delay(600 + Math.random() * 400)
// 模拟网络错误
if (simulateNetworkError()) {
throw new Error('获取详情失败,请稍后重试')
}
const match = mockTennisMatches.find(m => m.id === id)
if (!match) {
throw new Error('比赛不存在')
}
console.log('API获取详情成功:', match.title)
return match
} catch (error) {
console.error('API获取详情失败:', error)
throw error
}
}
/**
* 模拟API统计信息
*/
export const getApiStats = () => {
return {
totalCalls: 0,
successRate: 0.95,
averageResponseTime: 800,
lastCallTime: new Date().toISOString()
}
}

View File

@@ -36,7 +36,7 @@ export interface VerifyCodeResponse {
}
// 微信授权登录
export const wechat_auth_login = async (): Promise<LoginResponse> => {
export const wechat_auth_login = async (phone_code?: string): Promise<LoginResponse> => {
try {
// 先进行微信登录获取code
const login_result = await Taro.login();
@@ -48,15 +48,12 @@ export const wechat_auth_login = async (): Promise<LoginResponse> => {
};
}
// 使用 httpService 调用微信授权接口
const auth_response = await httpService.post('/user/wx_auth', {
code: login_result.code
// 使用 httpService 调用微信授权接口传递手机号code
const auth_response = await httpService.post('user/wx_auth', {
code: login_result.code,
phone_code: phone_code // 传递手机号加密code
});
if (auth_response.code === 0) {
return {
success: true,
@@ -89,7 +86,7 @@ export interface PhoneLoginParams {
export const phone_auth_login = async (params: PhoneLoginParams): Promise<LoginResponse> => {
try {
// 使用 httpService 调用验证验证码接口
const verify_response = await httpService.post('/user/sms/verify', {
const verify_response = await httpService.post('user/sms/verify', {
phone: params.phone,
code: params.verification_code
});
@@ -127,7 +124,7 @@ export const phone_auth_login = async (params: PhoneLoginParams): Promise<LoginR
// 发送短信验证码
export const send_sms_code = async (phone: string): Promise<SmsResponse> => {
try {
const response = await httpService.post('/user/sms/send', {
const response = await httpService.post('user/sms/send', {
phone: phone
});
@@ -155,7 +152,7 @@ export const send_sms_code = async (phone: string): Promise<SmsResponse> => {
// 验证短信验证码
export const verify_sms_code = async (phone: string, code: string): Promise<VerifyCodeResponse> => {
try {
const response = await httpService.post('/user/sms/verify', {
const response = await httpService.post('user/sms/verify', {
phone: phone,
code: code
});

View File

@@ -30,6 +30,12 @@ export interface PublishBallData {
wechat_contact?: string // 微信联系
}
export interface createGameData extends PublishBallData {
status: string,
created_at: string,
updated_at: string,
}
// 响应接口
export interface Response {
code: string
@@ -37,7 +43,6 @@ export interface Response {
data: any
}
// 响应接口
export interface StadiumListResponse {
rows: Stadium[]
}
@@ -51,10 +56,61 @@ export interface Stadium {
latitude?: number
}
// export type SourceType = 'history' | 'preset'
export interface getPicturesReq {
pageOption: {
page: number,
pageSize: number,
},
seachOption: {
tag: string,
resource_type: string,
dateRange: string[],
},
}
export interface getPicturesRes {
code: number,
message: string,
data: {
rows: [
{
user_id: string,
resource_type: string,
file_name: string,
original_name: string,
file_path: string,
file_url: string,
file_size: number,
mime_type: string,
width: number,
height: number,
duration: number,
thumbnail_url: string,
is_public: string,
tags: string[],
description: string,
view_count: number,
download_count: number,
status: string,
last_modify_time: string,
}
],
count: number,
page: number,
pageSize: number,
totalPages: number,
}
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 发布球局类
class PublishService {
// 发布
async createPersonal(data: PublishBallData): Promise<ApiResponse<Response>> {
// 用户登录
async createPersonal(data: PublishBallData): Promise<ApiResponse<createGameData>> {
return httpService.post('/games/create', data, {
showLoading: true,
loadingText: '发布中...'
@@ -66,14 +122,21 @@ class PublishService {
return httpService.post('/venues/list', data, {
showLoading: false })
}
<<<<<<< HEAD
// 畅打发布
async create_play_pmoothly(data: PublishBallData): Promise<ApiResponse<Response>> {
return httpService.post('/games/create_play_pmoothly', data, {
showLoading: true,
loadingText: '发布中...'
=======
async getPictures(req: getPicturesReq): Promise<ApiResponse<getPicturesRes>> {
return httpService.post('/gallery/sys_img_list', req, {
showLoading: false,
showToast: false,
>>>>>>> d92419f3c5648a67d1b6857d66d6c92a4bece034
})
}
}
// 导出认证服务实例
export default new PublishService()
export default new PublishService()

View File

@@ -0,0 +1,75 @@
import httpService from './httpService'
import type { ApiResponse } from './httpService'
import Taro from '@tarojs/taro'
import envConfig from '@/config/env'
// 用户接口
export interface UploadFilesData {
id: string,
filePath: string,
description?: string,
tags?: string,
is_public?: 0 | 1,
}
// {"code":0,"message":"请求成功!","data":{"tags":["test"],"create_time":"2025-08-24 22:51:03","last_modify_time":"2025-08-24 22:51:03","duration":"0","thumbnail_url":"","view_count":"0","download_count":"0","id":16,"user_id":1,"resource_type":"image","file_name":"front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","original_name":"QyoUvEsLG6ci57c7e25cca0845dafed3ee1fde07876d.png","file_path":"http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","file_url":"http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","file_size":17756,"mime_type":"image/png","description":"test","is_public":"1","status":"active","width":0,"height":0,"uploadInfo":{"success":true,"name":"front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","path":"http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","ossPath":"http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png","fileType":"image/png","fileSize":17756,"originalName":"QyoUvEsLG6ci57c7e25cca0845dafed3ee1fde07876d.png","suffix":"png","storagePath":"front/ball/images/63f56978-ffe9-4397-b897-8aca6f4fdcd8.png"}}}
export interface uploadFileResponse {
code: number,
message: string,
data: uploadFileResponseData,
}
export interface uploadFileResponseData {
id: number,
user_id: number,
file_name: string,
original_name: string,
file_path: string,
file_url: string,
file_size: number,
resource_type: string,
mime_type: string,
description: string,
tags: string[],
is_public: string,
view_count: number,
download_count: number,
created_at: string,
updated_at: string,
}
function delay(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms))
}
// 发布球局类
class UploadApi {
async upload(req: UploadFilesData): Promise<{ id: string, data: uploadFileResponseData }> {
// return httpService.post('/files/upload', req, {
// showLoading: true,
// })
const { id, ...rest } = req
return Taro.uploadFile({
url: `${envConfig.apiBaseURL}/api/gallery/upload`,
filePath: rest.filePath,
name: 'file',
formData: {
description: rest.description,
tags: rest.tags,
is_public: rest.is_public,
}
}).then(res => {
return {
id,
data: JSON.parse(res.data).data,
}
})
}
async batchUpload(req: UploadFilesData[]): Promise<{ id: string, data: uploadFileResponseData }[]> {
return Promise.all(req.map(item => this.upload(item)))
}
}
// 导出认证服务实例
export default new UploadApi()

Binary file not shown.

View File

@@ -0,0 +1,22 @@
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3006_12308)">
<g filter="url(#filter0_d_3006_12308)">
<path d="M18.0479 5.5C19.1523 5.50156 20.0464 6.39843 20.0449 7.50293L20.0342 16H28.5C29.6046 16 30.5 16.8954 30.5 18C30.5 19.1046 29.6046 20 28.5 20H20.0283L20.0176 28.5029C20.016 29.6074 19.1191 30.5014 18.0146 30.5C16.9104 30.4982 16.0161 29.6014 16.0176 28.4971L16.0283 20H7.5C6.39543 20 5.5 19.1046 5.5 18C5.5 16.8954 6.39543 16 7.5 16H16.0342L16.0449 7.49707C16.0465 6.39276 16.9435 5.49874 18.0479 5.5Z" fill="white"/>
</g>
</g>
<defs>
<filter id="filter0_d_3006_12308" x="-6.5" y="-2.5" width="49" height="49" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="6"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3006_12308"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3006_12308" result="shape"/>
</filter>
<clipPath id="clip0_3006_12308">
<rect width="36" height="36" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M20.6667 24L12.6667 16L20.6667 8" stroke="black" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 234 B

View File

@@ -0,0 +1,4 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.035 5.83337L14.0137 22.1667" stroke="white" stroke-opacity="0.6" stroke-width="2.22222" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.83333 14H22.1667" stroke="white" stroke-opacity="0.6" stroke-width="2.22222" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1,3 @@
<svg width="8" height="14" viewBox="0 0 8 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 1L1 7L7 13" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 205 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.33334 4L10.3333 8L6.33334 12" stroke="white" stroke-opacity="0.8" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 253 B

View File

@@ -0,0 +1,7 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M13.75 15.8333H9.16666V12.5H15V9.16663H18.3333V15.8333H16.25L15 17.0833L13.75 15.8333Z" stroke="black" stroke-opacity="0.85" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.66666 2.5H15V12.5H7.08333L5.41666 14.1667L3.75 12.5H1.66666V2.5Z" stroke="black" stroke-opacity="0.85" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.91666 7.5H8.33333" stroke="black" stroke-opacity="0.85" stroke-width="1.66667" stroke-linecap="round"/>
<path d="M10.8333 7.5H11.25" stroke="black" stroke-opacity="0.85" stroke-width="1.66667" stroke-linecap="round"/>
<path d="M5 7.5H5.41667" stroke="black" stroke-opacity="0.85" stroke-width="1.66667" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 836 B

View File

@@ -0,0 +1,7 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M11 12.6667H7.33334V10H12V7.33337H14.6667V12.6667H13L12 13.6667L11 12.6667Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M1.33334 2H12V10H5.66668L4.33334 11.3333L3.00001 10H1.33334V2Z" stroke="white" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.33334 6H6.66668" stroke="white" stroke-width="1.33333" stroke-linecap="round"/>
<path d="M8.66666 6H8.99999" stroke="white" stroke-width="1.33333" stroke-linecap="round"/>
<path d="M4 6H4.33333" stroke="white" stroke-width="1.33333" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 706 B

Some files were not shown because too many files have changed in this diff Show More