发布页开发
@@ -3,6 +3,8 @@ import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'
|
||||
import devConfig from './dev'
|
||||
import prodConfig from './prod'
|
||||
import vitePluginImp from 'vite-plugin-imp'
|
||||
import path from 'path'
|
||||
|
||||
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
|
||||
export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
const baseConfig: UserConfigExport<'webpack5'> = {
|
||||
@@ -20,6 +22,16 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
plugins: ['@tarojs/plugin-html'],
|
||||
defineConstants: {
|
||||
},
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '..', 'src'),
|
||||
'@/assets': path.resolve(__dirname, '..', 'src/assets'),
|
||||
'@/components': path.resolve(__dirname, '..', 'src/components'),
|
||||
'@/utils': path.resolve(__dirname, '..', 'src/utils'),
|
||||
'@/services': path.resolve(__dirname, '..', 'src/services'),
|
||||
'@/store': path.resolve(__dirname, '..', 'src/store'),
|
||||
'@/config': path.resolve(__dirname, '..', 'src/config'),
|
||||
'@/static': path.resolve(__dirname, '..', 'src/static'),
|
||||
},
|
||||
copy: {
|
||||
patterns: [
|
||||
],
|
||||
|
||||
178
docs/PublishBall使用说明.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# 发布球局功能使用说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
发布球局功能允许用户创建和发布体育活动,包括图片上传、详细信息填写等完整流程。
|
||||
|
||||
## 主要特性
|
||||
|
||||
### 1. 图片上传组件 (UploadImages)
|
||||
- ✅ 支持最多9张图片上传
|
||||
- ✅ 6张以内显示网格布局,超过6张自动分页滑动
|
||||
- ✅ 支持图片预览和删除
|
||||
- ✅ 拍照或从相册选择
|
||||
- ✅ 响应式设计,支持暗色模式
|
||||
|
||||
### 2. 动态表单系统 (DynamicForm)
|
||||
- ✅ 基于配置的动态表单渲染
|
||||
- ✅ 支持多种字段类型:文本、数字、选择器、日期时间、开关、单选、多选、位置选择
|
||||
- ✅ 完整的表单验证
|
||||
- ✅ 实时错误提示
|
||||
- ✅ 美观的UI设计
|
||||
|
||||
### 3. 位置选择功能
|
||||
- ✅ 支持地图选择位置
|
||||
- ✅ 一键获取当前位置
|
||||
- ✅ 地址显示和验证
|
||||
|
||||
### 4. 草稿保存系统
|
||||
- ✅ 自动保存草稿(2秒延迟)
|
||||
- ✅ 页面刷新后自动恢复
|
||||
- ✅ 草稿过期管理(7天)
|
||||
- ✅ 提交成功后自动清除
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ ├── UploadImages/
|
||||
│ │ ├── index.tsx # 图片上传组件
|
||||
│ │ └── index.scss # 组件样式
|
||||
│ └── DynamicForm/
|
||||
│ ├── index.tsx # 动态表单组件
|
||||
│ └── index.scss # 组件样式
|
||||
├── config/
|
||||
│ └── formSchema/
|
||||
│ └── publishBallFormSchema.ts # 表单配置
|
||||
├── pages/
|
||||
│ └── publishBall/
|
||||
│ ├── index.tsx # 发布页面
|
||||
│ ├── index.scss # 页面样式
|
||||
│ └── index.config.ts # 页面配置
|
||||
└── utils/
|
||||
└── locationUtils.ts # 位置相关工具函数
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 1. 页面导航
|
||||
```typescript
|
||||
// 跳转到发布页面
|
||||
Taro.navigateTo({
|
||||
url: '/pages/publishBall/index'
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 组件使用
|
||||
|
||||
#### UploadImages组件
|
||||
```tsx
|
||||
import UploadImages from '../../components/UploadImages/index'
|
||||
|
||||
<UploadImages
|
||||
value={images}
|
||||
onChange={handleImagesChange}
|
||||
maxCount={9}
|
||||
className="custom-class"
|
||||
/>
|
||||
```
|
||||
|
||||
#### DynamicForm组件
|
||||
```tsx
|
||||
import DynamicForm from '../../components/DynamicForm/index'
|
||||
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema'
|
||||
|
||||
<DynamicForm
|
||||
schema={publishBallFormSchema}
|
||||
initialValues={initialValues}
|
||||
onValuesChange={handleFormValuesChange}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 自定义表单配置
|
||||
```typescript
|
||||
// 在 publishBallFormSchema.ts 中添加新字段
|
||||
{
|
||||
key: 'customField',
|
||||
label: '自定义字段',
|
||||
type: FieldType.TEXT,
|
||||
placeholder: '请输入',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '此字段为必填' }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 支持的表单字段类型
|
||||
|
||||
| 类型 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| TEXT | 文本输入 | 活动标题 |
|
||||
| TEXTAREA | 多行文本 | 活动描述 |
|
||||
| SELECT | 下拉选择 | 运动类型 |
|
||||
| DATE | 日期选择 | 活动日期 |
|
||||
| TIME | 时间选择 | 开始时间 |
|
||||
| NUMBER | 数字输入 | 人数限制 |
|
||||
| SWITCH | 开关按钮 | 公开活动 |
|
||||
| RADIO | 单选按钮 | 技能要求 |
|
||||
| CHECKBOX | 多选框 | 活动标签 |
|
||||
| LOCATION | 位置选择 | 活动地点 |
|
||||
|
||||
## 样式特性
|
||||
|
||||
### 设计系统
|
||||
- 🎨 统一的色彩方案
|
||||
- 📱 响应式设计
|
||||
- 🌙 深色模式支持
|
||||
- ✨ 流畅的动画效果
|
||||
- 🔧 可定制的主题
|
||||
|
||||
### 交互体验
|
||||
- 👆 触感反馈
|
||||
- 🔄 加载状态
|
||||
- ⚠️ 错误提示
|
||||
- 💾 自动保存
|
||||
- 🎯 焦点管理
|
||||
|
||||
## 开发注意事项
|
||||
|
||||
1. **图片上传**: 实际项目中需要实现真实的图片上传API
|
||||
2. **位置服务**: 需要配置地图服务的API密钥
|
||||
3. **表单验证**: 可根据业务需求扩展验证规则
|
||||
4. **草稿存储**: 使用本地存储,注意存储容量限制
|
||||
5. **权限管理**: 需要申请相册、相机、位置等权限
|
||||
|
||||
## 扩展性
|
||||
|
||||
### 添加新的字段类型
|
||||
1. 在 `FieldType` 枚举中添加新类型
|
||||
2. 在 `DynamicForm` 组件的 `renderField` 方法中添加处理逻辑
|
||||
3. 在样式文件中添加对应样式
|
||||
|
||||
### 自定义验证规则
|
||||
```typescript
|
||||
// 在表单配置中添加自定义验证
|
||||
rules: [
|
||||
{
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的手机号'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### 主题定制
|
||||
通过修改 SCSS 变量来定制主题颜色和样式。
|
||||
|
||||
## 性能优化
|
||||
|
||||
- 图片懒加载和压缩
|
||||
- 表单防抖处理
|
||||
- 组件按需加载
|
||||
- 样式按需引入
|
||||
|
||||
---
|
||||
|
||||
*此功能已完全按照设计稿实现,包括颜色、间距、样式等所有细节。*
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tarojs/runtime": "4.1.5",
|
||||
"@tarojs/shared": "4.1.5",
|
||||
"@tarojs/taro": "4.1.5",
|
||||
"qqmap-wx-jssdk": "^1.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"zustand": "^4.4.7"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/publishBall/index',
|
||||
'pages/mapDisplay/index',
|
||||
'pages/index/index'
|
||||
],
|
||||
window: {
|
||||
@@ -8,5 +9,20 @@ export default defineAppConfig({
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'WeChat',
|
||||
navigationBarTextStyle: 'black'
|
||||
},
|
||||
permission: {
|
||||
'scope.userLocation': {
|
||||
desc: '你的位置信息将用于小程序位置接口的效果展示'
|
||||
}
|
||||
},
|
||||
requiredPrivateInfos: [
|
||||
'getLocation',
|
||||
'chooseLocation'
|
||||
],
|
||||
plugins: {
|
||||
chooseLocation: {
|
||||
version: "1.0.12",
|
||||
provider: "wx76a9a06e5b4e693e"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
25
src/app.ts
@@ -1,19 +1,22 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import { useDidShow, useDidHide } from '@tarojs/taro'
|
||||
// 全局样式
|
||||
import { Component, ReactNode } from 'react'
|
||||
import './app.scss'
|
||||
import './nutui-theme.scss'
|
||||
|
||||
function App(props: any) {
|
||||
// 可以使用所有的 React Hooks
|
||||
useEffect(() => {})
|
||||
interface AppProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
// 对应 onShow
|
||||
useDidShow(() => {})
|
||||
class App extends Component<AppProps> {
|
||||
componentDidMount() {}
|
||||
|
||||
// 对应 onHide
|
||||
useDidHide(() => {})
|
||||
componentDidShow() {}
|
||||
|
||||
return props.children
|
||||
componentDidHide() {}
|
||||
|
||||
render() {
|
||||
// this.props.children 是将要会渲染的页面
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
50
src/components/ActivityTypeSwitch/index.scss
Normal file
@@ -0,0 +1,50 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.activity-type-switch {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
padding: 0 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
padding: 4px;
|
||||
overflow: hidden;
|
||||
.switch-tab {
|
||||
flex: 1;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #e5e5e5;
|
||||
color: theme.$primary-color;
|
||||
opacity: 0.3;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
.icon-style {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
&.active {
|
||||
background: white;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0px 4px 48px 0px rgba(0, 0, 0, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
37
src/components/ActivityTypeSwitch/index.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import images from '@/config/images'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
export type ActivityType = 'individual' | 'group'
|
||||
|
||||
interface ActivityTypeSwitchProps {
|
||||
value: ActivityType
|
||||
onChange: (type: ActivityType) => void
|
||||
}
|
||||
|
||||
const ActivityTypeSwitch: React.FC<ActivityTypeSwitchProps> = ({ value, onChange }) => {
|
||||
return (
|
||||
<View className='activity-type-switch'>
|
||||
<View
|
||||
className={`switch-tab ${value === 'individual' ? 'active' : ''}`}
|
||||
onClick={() => onChange('individual')}
|
||||
>
|
||||
<View className='tab-icon'>
|
||||
<Image src={images.ICON_PERSONAL} className='icon-style' />
|
||||
</View>
|
||||
<Text className='tab-text'>个人约球</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`switch-tab ${value === 'group' ? 'active' : ''}`}
|
||||
onClick={() => onChange('group')}
|
||||
>
|
||||
<Image src={images.ICON_CHANGDA} className='icon-style' />
|
||||
<Text className='tab-text'>畅打活动</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActivityTypeSwitch
|
||||
110
src/components/CoverImageUpload/CoverImageUpload.scss
Normal file
@@ -0,0 +1,110 @@
|
||||
// 在组件SCSS文件中
|
||||
@use '~@/scss/images.scss' as img;
|
||||
.cover-image-upload {
|
||||
margin-bottom: 8px;
|
||||
|
||||
.cover-scroll {
|
||||
white-space: nowrap;
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.cover-list {
|
||||
display: inline-flex;
|
||||
padding: 0 4px;
|
||||
min-width: 100%;
|
||||
transition: justify-content 0.3s ease;
|
||||
|
||||
&.center {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.cover-item {
|
||||
flex-shrink: 0;
|
||||
width: 108px;
|
||||
height: 108px;
|
||||
border-radius: 12px;
|
||||
margin-right: 12px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
&.add-btn {
|
||||
border: 2px dashed #d9d9d9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.add-icon {
|
||||
font-size: 32px;
|
||||
color: #999;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.add-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
|
||||
&.image-item {
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
transition: opacity 0.3s ease;
|
||||
|
||||
&:not([src]) {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.cover-image-upload {
|
||||
.cover-item.add-btn {
|
||||
background: #2d2d2d;
|
||||
border-color: #555;
|
||||
|
||||
.add-icon,
|
||||
.add-text {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/components/CoverImageUpload/CoverImageUpload.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import './CoverImageUpload.scss'
|
||||
|
||||
export interface CoverImage {
|
||||
id: string
|
||||
url: string
|
||||
tempFilePath?: string
|
||||
}
|
||||
|
||||
interface CoverImageUploadProps {
|
||||
images: CoverImage[]
|
||||
onChange: (images: CoverImage[]) => void
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
const CoverImageUpload: React.FC<CoverImageUploadProps> = ({
|
||||
images,
|
||||
onChange,
|
||||
maxCount = 9
|
||||
}) => {
|
||||
// 添加封面图片
|
||||
const handleAddCoverImage = useCallback(() => {
|
||||
if (images.length >= maxCount) {
|
||||
Taro.showToast({
|
||||
title: `最多只能上传${maxCount}张图片`,
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
Taro.chooseImage({
|
||||
count: maxCount - images.length,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const newImages = res.tempFilePaths.map((path, index) => ({
|
||||
id: Date.now() + index + '',
|
||||
url: path,
|
||||
tempFilePath: path
|
||||
}))
|
||||
onChange([...images, ...newImages])
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('选择图片失败:', err)
|
||||
}
|
||||
})
|
||||
}, [images.length, maxCount, onChange])
|
||||
|
||||
// 删除封面图片
|
||||
const handleDeleteCoverImage = useCallback((id: string) => {
|
||||
onChange(images.filter(img => img.id !== id))
|
||||
}, [images, onChange])
|
||||
|
||||
// 判断是否需要居中显示(总项目数不超过3个时居中)
|
||||
const shouldCenter = useMemo(() => (images.length + 1) <= 3, [images.length])
|
||||
|
||||
return (
|
||||
<View className='cover-image-upload'>
|
||||
<ScrollView className='cover-scroll' scrollX>
|
||||
<View className={`cover-list ${shouldCenter ? 'center' : ''}`}>
|
||||
{/* 添加按钮 */}
|
||||
<View className='cover-item add-btn' onClick={handleAddCoverImage}>
|
||||
<View className='add-icon'>+</View>
|
||||
<Text className='add-text'>添加活动封面</Text>
|
||||
</View>
|
||||
|
||||
{/* 已选择的图片 */}
|
||||
{images.map((image) => (
|
||||
<View key={image.id} className='cover-item image-item'>
|
||||
<Image
|
||||
className='cover-image'
|
||||
src={image.url}
|
||||
mode='aspectFill'
|
||||
/>
|
||||
<View
|
||||
className='delete-btn'
|
||||
onClick={() => handleDeleteCoverImage(image.id)}
|
||||
>
|
||||
×
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CoverImageUpload
|
||||
1
src/components/CoverImageUpload/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, type CoverImage } from './CoverImageUpload'
|
||||
@@ -1,102 +0,0 @@
|
||||
.dynamic-form {
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 200px;
|
||||
|
||||
.form-reminder {
|
||||
background: #fff8dc;
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
background: #fff;
|
||||
margin: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
|
||||
.delete-form-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #ff4757;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 20px 16px 30px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
.add-form-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
.plus-icon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s ease;
|
||||
|
||||
&.submitting {
|
||||
background: #999;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: #ccc;
|
||||
color: #999;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { Dialog } from '@nutui/nutui-react-taro'
|
||||
import { FormConfig, FormFieldConfig } from '../../config/formConfig'
|
||||
import FieldRenderer from './FieldRenderer'
|
||||
import commonApi from '../../services/commonApi'
|
||||
import './DynamicForm.scss'
|
||||
|
||||
interface DynamicFormProps {
|
||||
config: FormConfig
|
||||
formType?: string // 表单类型,用于API提交
|
||||
onSubmit?: (formData: any[]) => void | Promise<void>
|
||||
onSubmitSuccess?: (response: any) => void
|
||||
onSubmitError?: (error: any) => void
|
||||
onAddForm?: () => void
|
||||
onDeleteForm?: (index: number) => void
|
||||
enableApiSubmit?: boolean // 是否启用API提交
|
||||
}
|
||||
|
||||
interface FormData {
|
||||
id: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||
config,
|
||||
formType = 'default',
|
||||
onSubmit,
|
||||
onSubmitSuccess,
|
||||
onSubmitError,
|
||||
onAddForm,
|
||||
onDeleteForm,
|
||||
enableApiSubmit = true
|
||||
}) => {
|
||||
const [forms, setForms] = useState<FormData[]>([])
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [deleteIndex, setDeleteIndex] = useState<number>(-1)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
// 初始化表单数据
|
||||
useEffect(() => {
|
||||
const initialData = createInitialFormData()
|
||||
setForms([{
|
||||
id: Date.now().toString(),
|
||||
data: initialData
|
||||
}])
|
||||
}, [config])
|
||||
|
||||
// 根据配置创建初始数据
|
||||
const createInitialFormData = () => {
|
||||
const data: Record<string, any> = {}
|
||||
config.fields.forEach(field => {
|
||||
data[field.key] = field.defaultValue
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
// 添加新表单
|
||||
const handleAddForm = () => {
|
||||
const newForm: FormData = {
|
||||
id: Date.now().toString(),
|
||||
data: createInitialFormData()
|
||||
}
|
||||
setForms([...forms, newForm])
|
||||
onAddForm?.()
|
||||
}
|
||||
|
||||
// 删除表单
|
||||
const handleDeleteForm = (index: number) => {
|
||||
if (forms.length <= 1) return
|
||||
|
||||
setDeleteIndex(index)
|
||||
setShowDeleteDialog(true)
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteIndex >= 0 && forms.length > 1) {
|
||||
const newForms = forms.filter((_, i) => i !== deleteIndex)
|
||||
setForms(newForms)
|
||||
onDeleteForm?.(deleteIndex)
|
||||
}
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteIndex(-1)
|
||||
}
|
||||
|
||||
// 更新字段值
|
||||
const updateFieldValue = (formIndex: number, fieldKey: string, value: any) => {
|
||||
const newForms = [...forms]
|
||||
newForms[formIndex].data[fieldKey] = value
|
||||
setForms(newForms)
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (formData: Record<string, any>) => {
|
||||
const errors: string[] = []
|
||||
|
||||
config.fields.forEach(field => {
|
||||
if (field.required) {
|
||||
const value = formData[field.key]
|
||||
if (value === undefined || value === null || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0)) {
|
||||
errors.push(`${field.title}为必填项`)
|
||||
}
|
||||
}
|
||||
|
||||
// 数字验证
|
||||
if (field.validation && formData[field.key] !== '') {
|
||||
const value = Number(formData[field.key])
|
||||
if (field.validation.min !== undefined && value < field.validation.min) {
|
||||
errors.push(field.validation.message || `${field.title}不能小于${field.validation.min}`)
|
||||
}
|
||||
if (field.validation.max !== undefined && value > field.validation.max) {
|
||||
errors.push(field.validation.message || `${field.title}不能大于${field.validation.max}`)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
const handleImageUpload = async (formData: Record<string, any>): Promise<Record<string, any>> => {
|
||||
const processedData = { ...formData }
|
||||
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
// 检查是否为图片字段
|
||||
const fieldConfig = config.fields.find(field => field.key === key)
|
||||
if (fieldConfig?.type === 'image-upload' && Array.isArray(value) && value.length > 0) {
|
||||
try {
|
||||
// 过滤出本地图片路径(临时文件)
|
||||
const localImages = value.filter(url => url.startsWith('wxfile://') || url.startsWith('http://tmp/'))
|
||||
const remoteImages = value.filter(url => !url.startsWith('wxfile://') && !url.startsWith('http://tmp/'))
|
||||
|
||||
if (localImages.length > 0) {
|
||||
// 上传本地图片
|
||||
const uploadResult = await commonApi.uploadImages(localImages)
|
||||
const uploadedUrls = uploadResult.data.map(item => item.url)
|
||||
|
||||
// 合并远程图片和新上传的图片
|
||||
processedData[key] = [...remoteImages, ...uploadedUrls]
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('图片上传失败:', error)
|
||||
throw new Error(`${fieldConfig.title}上传失败`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return processedData
|
||||
}
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (isSubmitting) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
// 表单验证
|
||||
const allErrors: string[] = []
|
||||
const formDataList = forms.map((form, index) => {
|
||||
const errors = validateForm(form.data)
|
||||
if (errors.length > 0) {
|
||||
allErrors.push(`第${index + 1}${config.title || '表单'}: ${errors.join(', ')}`)
|
||||
}
|
||||
return form.data
|
||||
})
|
||||
|
||||
if (allErrors.length > 0) {
|
||||
Taro.showModal({
|
||||
title: '表单验证失败',
|
||||
content: allErrors.join('\n'),
|
||||
showCancel: false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 处理图片上传
|
||||
const processedFormDataList = await Promise.all(
|
||||
formDataList.map(formData => handleImageUpload(formData))
|
||||
)
|
||||
|
||||
// 执行自定义提交逻辑
|
||||
if (onSubmit) {
|
||||
await onSubmit(processedFormDataList)
|
||||
}
|
||||
|
||||
// API提交
|
||||
if (enableApiSubmit) {
|
||||
try {
|
||||
let response
|
||||
|
||||
// 根据表单类型选择不同的API
|
||||
switch (formType) {
|
||||
case 'publishBall':
|
||||
if (processedFormDataList.length === 1) {
|
||||
response = await commonApi.publishMatch(processedFormDataList[0] as any)
|
||||
} else {
|
||||
response = await commonApi.publishMultipleMatches(processedFormDataList as any)
|
||||
}
|
||||
break
|
||||
case 'userProfile':
|
||||
response = await commonApi.updateUserProfile(processedFormDataList[0] as any)
|
||||
break
|
||||
case 'feedback':
|
||||
response = await commonApi.submitFeedback(processedFormDataList[0] as any)
|
||||
break
|
||||
default:
|
||||
response = await commonApi.submitForm(formType, processedFormDataList)
|
||||
}
|
||||
|
||||
// 提交成功回调
|
||||
onSubmitSuccess?.(response)
|
||||
|
||||
Taro.showToast({
|
||||
title: '提交成功!',
|
||||
icon: 'success'
|
||||
})
|
||||
} catch (apiError) {
|
||||
console.error('API提交失败:', apiError)
|
||||
onSubmitError?.(apiError)
|
||||
throw apiError
|
||||
}
|
||||
} else {
|
||||
// 不使用API提交时,显示成功提示
|
||||
Taro.showToast({
|
||||
title: '提交成功!',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('表单提交失败:', error)
|
||||
|
||||
// 错误回调
|
||||
onSubmitError?.(error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '提交失败,请重试'
|
||||
Taro.showToast({
|
||||
title: errorMessage,
|
||||
icon: 'none',
|
||||
duration: 2000
|
||||
})
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='dynamic-form'>
|
||||
{/* 提醒文本 */}
|
||||
{config.reminder && (
|
||||
<View className='form-reminder'>
|
||||
<Text>{config.reminder}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 表单列表 */}
|
||||
{forms.map((form, formIndex) => (
|
||||
<View key={form.id} className='form-container'>
|
||||
{/* 删除按钮 */}
|
||||
{forms.length > 1 && (
|
||||
<View
|
||||
className='delete-form-btn'
|
||||
onClick={() => handleDeleteForm(formIndex)}
|
||||
>
|
||||
<Text>×</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 渲染所有字段 */}
|
||||
{config.fields.map((fieldConfig: FormFieldConfig) => (
|
||||
<FieldRenderer
|
||||
key={fieldConfig.key}
|
||||
config={fieldConfig}
|
||||
value={form.data[fieldConfig.key]}
|
||||
onChange={(value) => updateFieldValue(formIndex, fieldConfig.key, value)}
|
||||
matchId={form.id}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 底部操作区 */}
|
||||
<View className='form-actions'>
|
||||
{config.actions?.addText && (
|
||||
<View className='add-form-btn' onClick={handleAddForm}>
|
||||
<Text className='plus-icon'>+</Text>
|
||||
<Text>{config.actions.addText}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{config.actions?.submitText && (
|
||||
<Button
|
||||
className={`submit-btn ${isSubmitting ? 'submitting' : ''}`}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? '提交中...' : config.actions.submitText}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{config.actions?.disclaimer && (
|
||||
<Text className='disclaimer'>
|
||||
{config.actions.disclaimer}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
<Dialog
|
||||
visible={showDeleteDialog}
|
||||
title='确认删除'
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => {
|
||||
setShowDeleteDialog(false)
|
||||
setDeleteIndex(-1)
|
||||
}}
|
||||
>
|
||||
确定要删除这场约球吗?
|
||||
</Dialog>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default DynamicForm
|
||||
@@ -1,304 +0,0 @@
|
||||
.field-renderer {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.field-header {
|
||||
margin-bottom: 12px;
|
||||
|
||||
.field-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.field-content {
|
||||
// 图片上传
|
||||
.upload-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.upload-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
|
||||
.plus-icon {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ff4757;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 文本输入
|
||||
.text-input, .number-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// 时间显示
|
||||
.time-display {
|
||||
background: #f8f8f8;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
width: 40px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-left: 16px;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场地输入
|
||||
.venue-input-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding-right: 16px;
|
||||
|
||||
.venue-input {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 多选按钮
|
||||
.multi-select-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.select-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
&.selected {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计数器
|
||||
.counter-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.count-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.count-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.count-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.count-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.count-number {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// 滑动条
|
||||
.slider-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.slider-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slider-wrapper {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.slider-track {
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
top: -8px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 单选按钮组
|
||||
.radio-group-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.radio-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
&.selected {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 复选框
|
||||
.checkbox-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
.checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
|
||||
&.checked {
|
||||
background: #000;
|
||||
border-color: #000;
|
||||
|
||||
.checkmark {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Input, Image, Button } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { FormFieldConfig } from '../../config/formConfig'
|
||||
import './FieldRenderer.scss'
|
||||
|
||||
interface FieldRendererProps {
|
||||
config: FormFieldConfig
|
||||
value: any
|
||||
onChange: (value: any) => void
|
||||
matchId: string
|
||||
}
|
||||
|
||||
const FieldRenderer: React.FC<FieldRendererProps> = ({ config, value, onChange, matchId }) => {
|
||||
const { key, type, title, placeholder, hint, options, config: fieldConfig } = config
|
||||
|
||||
// 图片上传组件
|
||||
const renderImageUpload = () => {
|
||||
const handleImageUpload = () => {
|
||||
Taro.chooseImage({
|
||||
count: (fieldConfig?.maxImages || 9) - (value?.length || 0),
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const newImages = [...(value || []), ...res.tempFilePaths].slice(0, fieldConfig?.maxImages || 9)
|
||||
onChange(newImages)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeImage = (index: number) => {
|
||||
const newImages = (value || []).filter((_: any, i: number) => i !== index)
|
||||
onChange(newImages)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='upload-container'>
|
||||
<View className='upload-btn' onClick={handleImageUpload}>
|
||||
<Text className='plus-icon'>+</Text>
|
||||
</View>
|
||||
{(value || []).map((url: string, i: number) => (
|
||||
<View key={i} className='image-item'>
|
||||
<Image src={url} className='cover-image' mode='aspectFill' />
|
||||
<View className='remove-btn' onClick={() => removeImage(i)}>
|
||||
<Text>×</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 文本输入组件
|
||||
const renderTextInput = () => (
|
||||
<Input
|
||||
className='text-input'
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onInput={(e) => onChange(e.detail.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
// 数字输入组件
|
||||
const renderNumberInput = () => (
|
||||
<Input
|
||||
className='number-input'
|
||||
placeholder={placeholder}
|
||||
type='number'
|
||||
value={value || ''}
|
||||
onInput={(e) => onChange(e.detail.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
// 时间显示组件
|
||||
const renderTimeDisplay = () => (
|
||||
<View className='time-display'>
|
||||
<View className='time-row'>
|
||||
<Text className='time-label'>开始</Text>
|
||||
<Text className='time-value'>11/23/2025</Text>
|
||||
<Text className='time-value'>8:00 AM</Text>
|
||||
</View>
|
||||
<View className='time-row'>
|
||||
<Text className='time-label'>结束</Text>
|
||||
<Text className='time-value'>11/23/2025</Text>
|
||||
<Text className='time-value'>10:00 AM</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
// 场地输入组件
|
||||
const renderVenueInput = () => (
|
||||
<View className='venue-input-container'>
|
||||
<Input
|
||||
className='venue-input'
|
||||
placeholder={placeholder}
|
||||
value={value || ''}
|
||||
onInput={(e) => onChange(e.detail.value)}
|
||||
/>
|
||||
{fieldConfig?.showArrow && <Text className='arrow'>›</Text>}
|
||||
</View>
|
||||
)
|
||||
|
||||
// 多选按钮组件
|
||||
const renderMultiSelect = () => (
|
||||
<View className='multi-select-container'>
|
||||
{options?.map((option) => (
|
||||
<View
|
||||
key={option.value}
|
||||
className={`select-btn ${(value || []).includes(option.value) ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
const currentValues = value || []
|
||||
const newValues = currentValues.includes(option.value)
|
||||
? currentValues.filter((v: any) => v !== option.value)
|
||||
: [...currentValues, option.value]
|
||||
onChange(newValues)
|
||||
}}
|
||||
>
|
||||
<Text>{option.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
|
||||
// 计数器组件
|
||||
const renderCounter = () => {
|
||||
const minValue = fieldConfig?.minValue || 1
|
||||
const maxValue = fieldConfig?.maxValue || 50
|
||||
const currentValue = value || { min: 1, max: 4 }
|
||||
|
||||
return (
|
||||
<View className='counter-container'>
|
||||
<View className='count-group'>
|
||||
<Text className='count-label'>最少</Text>
|
||||
<View className='count-controls'>
|
||||
<View
|
||||
className='count-btn'
|
||||
onClick={() => onChange({
|
||||
...currentValue,
|
||||
min: Math.max(minValue, currentValue.min - 1)
|
||||
})}
|
||||
>
|
||||
<Text>-</Text>
|
||||
</View>
|
||||
<Text className='count-number'>{currentValue.min}</Text>
|
||||
<View
|
||||
className='count-btn'
|
||||
onClick={() => onChange({
|
||||
...currentValue,
|
||||
min: Math.min(maxValue, currentValue.min + 1)
|
||||
})}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='separator'>至</Text>
|
||||
<View className='count-group'>
|
||||
<View className='count-controls'>
|
||||
<View
|
||||
className='count-btn'
|
||||
onClick={() => onChange({
|
||||
...currentValue,
|
||||
max: Math.max(currentValue.min, currentValue.max - 1)
|
||||
})}
|
||||
>
|
||||
<Text>-</Text>
|
||||
</View>
|
||||
<Text className='count-number'>{currentValue.max}</Text>
|
||||
<View
|
||||
className='count-btn'
|
||||
onClick={() => onChange({
|
||||
...currentValue,
|
||||
max: Math.min(maxValue, currentValue.max + 1)
|
||||
})}
|
||||
>
|
||||
<Text>+</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='count-label'>最多</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 滑动条组件
|
||||
const renderSlider = () => {
|
||||
const range = fieldConfig?.range || [1, 7]
|
||||
const currentValue = value || [2.0, 4.0]
|
||||
|
||||
return (
|
||||
<View className='slider-container'>
|
||||
<Text className='slider-label'>2.0及以下</Text>
|
||||
<View className='slider-wrapper'>
|
||||
<View className='slider-track'>
|
||||
<View className='slider-fill' />
|
||||
<View
|
||||
className='slider-thumb left'
|
||||
style={{ left: `${((currentValue[0] - range[0]) / (range[1] - range[0])) * 100}%` }}
|
||||
/>
|
||||
<View
|
||||
className='slider-thumb right'
|
||||
style={{ left: `${((currentValue[1] - range[0]) / (range[1] - range[0])) * 100}%` }}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text className='slider-label'>4.0及以上</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 单选组件
|
||||
const renderRadioGroup = () => (
|
||||
<View className='radio-group-container'>
|
||||
{options?.map((option) => (
|
||||
<View
|
||||
key={option.value}
|
||||
className={`radio-btn ${value === option.value ? 'selected' : ''}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
<Text>{option.label}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
|
||||
// 复选框组件
|
||||
const renderCheckbox = () => (
|
||||
<View className='checkbox-container'>
|
||||
<View
|
||||
className={`checkbox ${value ? 'checked' : ''}`}
|
||||
onClick={() => onChange(!value)}
|
||||
>
|
||||
{value && <Text className='checkmark'>✓</Text>}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
|
||||
// 根据类型渲染不同组件
|
||||
const renderField = () => {
|
||||
switch (type) {
|
||||
case 'image-upload':
|
||||
return renderImageUpload()
|
||||
case 'text-input':
|
||||
return renderTextInput()
|
||||
case 'number-input':
|
||||
return renderNumberInput()
|
||||
case 'time-display':
|
||||
return renderTimeDisplay()
|
||||
case 'venue-input':
|
||||
return renderVenueInput()
|
||||
case 'multi-select':
|
||||
return renderMultiSelect()
|
||||
case 'counter':
|
||||
return renderCounter()
|
||||
case 'slider':
|
||||
return renderSlider()
|
||||
case 'radio-group':
|
||||
return renderRadioGroup()
|
||||
case 'checkbox':
|
||||
return renderCheckbox()
|
||||
default:
|
||||
return <Text>未知字段类型: {type}</Text>
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='field-renderer'>
|
||||
<View className='field-header'>
|
||||
<Text className='field-title'>{title}</Text>
|
||||
{hint && <Text className='field-hint'>{hint}</Text>}
|
||||
</View>
|
||||
<View className='field-content'>
|
||||
{renderField()}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default FieldRenderer
|
||||
@@ -1,9 +0,0 @@
|
||||
// DynamicForm组件统一导出
|
||||
export { default as DynamicForm } from './DynamicForm'
|
||||
export { default as FieldRenderer } from './FieldRenderer'
|
||||
|
||||
// 导出类型
|
||||
export type { FormConfig, FormFieldConfig, FieldType } from '../../config/formSchema/bulishBallFormSchema'
|
||||
|
||||
// 默认导出DynamicForm组件
|
||||
export { default } from './DynamicForm'
|
||||
89
src/components/FormBasicInfo/FormBasicInfo.scss
Normal file
@@ -0,0 +1,89 @@
|
||||
@use '~@/scss/images.scss' as img;
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
// FormBasicInfo 组件样式
|
||||
.form-basic-info{
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
|
||||
|
||||
// 费用项目
|
||||
.form-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding-left: 12px;
|
||||
&:last-child{
|
||||
.form-wrapper{
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
.form-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
padding-right: 14px;
|
||||
.lable-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
.form-wrapper{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
align-items: center;
|
||||
.form-item-label{
|
||||
display: flex;
|
||||
}
|
||||
.form-right-wrapper{
|
||||
display: flex;
|
||||
padding-right: 12px;
|
||||
height: 44px;
|
||||
line-height: 44px;
|
||||
align-items: center;
|
||||
.title-placeholder{
|
||||
font-size: 14px;
|
||||
color: theme.$textarea-placeholder-color;
|
||||
font-weight: 400;
|
||||
}
|
||||
.h5-input{
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
width: 50px;
|
||||
text-align: right;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.unit{
|
||||
font-size: 14px;
|
||||
color: theme.$primary-color;
|
||||
}
|
||||
.right-text{
|
||||
color: theme.$textarea-placeholder-color;
|
||||
font-size: 14px;
|
||||
padding-right: 8px;
|
||||
width: 200px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
.arrow{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: 4px;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
86
src/components/FormBasicInfo/FormBasicInfo.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Input, Image, Picker } from '@tarojs/components'
|
||||
import { Stadium } from '../SelectStadium'
|
||||
import img from '@/config/images';
|
||||
import './FormBasicInfo.scss'
|
||||
import { FormFieldConfig } from '@/config/formSchema/publishBallFormSchema';
|
||||
|
||||
interface FormBasicInfoProps {
|
||||
fee: string
|
||||
location: string
|
||||
gameplay: string
|
||||
selectedStadium: Stadium | null
|
||||
onFeeChange: (value: string) => void
|
||||
onLocationChange: (value: string) => void
|
||||
onGameplayChange: (value: string) => void
|
||||
onStadiumSelect: () => void
|
||||
children: FormFieldConfig[]
|
||||
}
|
||||
|
||||
const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
|
||||
fee,
|
||||
location,
|
||||
gameplay,
|
||||
selectedStadium,
|
||||
onFeeChange,
|
||||
onLocationChange,
|
||||
onGameplayChange,
|
||||
onStadiumSelect,
|
||||
children
|
||||
}) => {
|
||||
const renderChildren = () => {
|
||||
return children.map((child: any, index: number) => {
|
||||
return <View className='form-item'>
|
||||
<View className='form-label'>
|
||||
<Image className='lable-icon' src={img[child.iconType]} />
|
||||
</View>
|
||||
{
|
||||
index === 0 && (<View className='form-wrapper'>
|
||||
<Text className='form-item-label'>{child.label}</Text>
|
||||
<View className='form-right-wrapper'>
|
||||
<Input
|
||||
className='fee-input'
|
||||
placeholder='请输入'
|
||||
placeholderClass='title-placeholder'
|
||||
type='digit'
|
||||
value={fee}
|
||||
onInput={(e) => onFeeChange(e.detail.value)}
|
||||
/>
|
||||
<Text className='unit'>元/每人</Text>
|
||||
</View>
|
||||
</View>)
|
||||
}
|
||||
{
|
||||
index === 1 && (<View className='form-wrapper'>
|
||||
<Text className='form-item-label'>{child.label}</Text>
|
||||
<View className='form-right-wrapper' onClick={onStadiumSelect}>
|
||||
<Text className={`right-text ${selectedStadium ? 'selected' : ''}`}>
|
||||
{selectedStadium ? selectedStadium.name : '请选择'}
|
||||
</Text>
|
||||
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
|
||||
</View>
|
||||
</View>)
|
||||
}
|
||||
{
|
||||
index === 2 && ( <View className='form-wrapper'>
|
||||
<Text className='form-item-label'>{child.label}</Text>
|
||||
<View className='form-right-wrapper'>
|
||||
<Text className={`right-text ${gameplay ? 'selected' : ''}`}>
|
||||
{gameplay ? gameplay : '请选择'}
|
||||
</Text>
|
||||
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
|
||||
</View>
|
||||
</View>)
|
||||
}
|
||||
</View>
|
||||
})
|
||||
}
|
||||
return (
|
||||
<View className='form-basic-info'>
|
||||
{/* 费用 */}
|
||||
{renderChildren()}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormBasicInfo
|
||||
2
src/components/FormBasicInfo/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './FormBasicInfo'
|
||||
export type { FormBasicInfoProps } from './FormBasicInfo'
|
||||
36
src/components/FormSwitch/FormSwitch.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Checkbox } from '@nutui/nutui-react-taro'
|
||||
import { Image } from '@tarojs/components'
|
||||
import images from '@/config/images'
|
||||
import './index.scss'
|
||||
|
||||
interface FormSwitchProps {
|
||||
value: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
title: string
|
||||
infoIcon?: string
|
||||
showToast?: boolean
|
||||
}
|
||||
|
||||
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, title, infoIcon, showToast = false}) => {
|
||||
return (
|
||||
<View className='auto-degrade-section'>
|
||||
<View className='auto-degrade-item'>
|
||||
<View className='auto-degrade-content'>
|
||||
<Text className='auto-degrade-text'>{title}</Text>
|
||||
{
|
||||
showToast && <View className='info-icon'><Image src={images.ICON_TIPS || infoIcon} /></View>
|
||||
}
|
||||
</View>
|
||||
<Checkbox
|
||||
className='auto-degrade-checkbox nut-checkbox-black'
|
||||
checked={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormSwitch
|
||||
43
src/components/FormSwitch/index.scss
Normal file
@@ -0,0 +1,43 @@
|
||||
.auto-degrade-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 10px 12px;
|
||||
height: 44px;
|
||||
.auto-degrade-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.auto-degrade-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.auto-degrade-text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #999;
|
||||
color: #fff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-degrade-checkbox {
|
||||
:global(.nut-checkbox__icon) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/FormSwitch/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './AutoDegradeSwitch'
|
||||
215
src/components/MapDisplay/README.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# 腾讯地图SDK使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已集成腾讯地图SDK (`qqmap-wx-jssdk`),可以在小程序中使用腾讯地图的各种功能,包括地点搜索、地理编码等。现在已添加真实的腾讯地图组件,支持显示当前位置和交互功能。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
项目已安装 `qqmap-wx-jssdk` 依赖:
|
||||
|
||||
```bash
|
||||
npm install qqmap-wx-jssdk
|
||||
# 或
|
||||
yarn add qqmap-wx-jssdk
|
||||
```
|
||||
|
||||
## 基本使用
|
||||
|
||||
### 1. 引入SDK
|
||||
|
||||
```typescript
|
||||
import QQMapWX from "qqmap-wx-jssdk";
|
||||
```
|
||||
|
||||
### 2. 初始化SDK
|
||||
|
||||
```typescript
|
||||
const qqmapsdk = new QQMapWX({
|
||||
key: 'YOUR_API_KEY' // 替换为你的腾讯地图API密钥
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 使用search方法搜索地点
|
||||
|
||||
```typescript
|
||||
// 搜索地点
|
||||
qqmapsdk.search({
|
||||
keyword: '关键词', // 搜索关键词
|
||||
location: '39.908802,116.397502', // 搜索中心点(可选)
|
||||
page_size: 20, // 每页结果数量(可选)
|
||||
page_index: 1, // 页码(可选)
|
||||
success: (res) => {
|
||||
console.log('搜索成功:', res.data);
|
||||
// 处理搜索结果
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('搜索失败:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
|
||||
### MapDisplay组件
|
||||
|
||||
`MapDisplay` 组件已经封装了腾讯地图SDK的使用,包括:
|
||||
|
||||
- **自动初始化SDK**
|
||||
- **真实地图显示**: 使用Taro的Map组件显示腾讯地图
|
||||
- **当前位置显示**: 自动获取并显示用户当前位置
|
||||
- **地点搜索功能**: 支持关键词搜索地点
|
||||
- **搜索结果展示**: 在地图上标记搜索结果
|
||||
- **交互功能**: 支持地图缩放、拖动、标记点击等
|
||||
- **错误处理**: 完善的错误处理和用户提示
|
||||
|
||||
### 主要功能特性
|
||||
|
||||
#### 1. 地图显示
|
||||
- 使用真实的腾讯地图组件
|
||||
- 默认显示当前位置
|
||||
- 支持地图缩放、拖动、旋转
|
||||
- 响应式设计,适配不同屏幕尺寸
|
||||
|
||||
#### 2. 位置服务
|
||||
- 自动获取用户当前位置
|
||||
- 支持位置刷新
|
||||
- 逆地理编码获取地址信息
|
||||
- 位置信息悬浮显示
|
||||
|
||||
#### 3. 搜索功能
|
||||
- 实时搜索地点
|
||||
- 防抖优化(500ms)
|
||||
- 搜索结果在地图上标记
|
||||
- 点击结果可移动地图中心
|
||||
|
||||
#### 4. 地图标记
|
||||
- 当前位置标记(蓝色)
|
||||
- 搜索结果标记
|
||||
- 标记点击交互
|
||||
- 动态添加/移除标记
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { mapService } from './mapService';
|
||||
|
||||
// 搜索地点
|
||||
const results = await mapService.search({
|
||||
keyword: '体育馆',
|
||||
location: '39.908802,116.397502'
|
||||
});
|
||||
|
||||
console.log('搜索结果:', results);
|
||||
```
|
||||
|
||||
## API密钥配置
|
||||
|
||||
在 `mapService.ts` 中配置你的腾讯地图API密钥:
|
||||
|
||||
```typescript
|
||||
this.qqmapsdk = new QQMapWX({
|
||||
key: 'YOUR_API_KEY' // 替换为你的实际API密钥
|
||||
});
|
||||
```
|
||||
|
||||
## 组件属性
|
||||
|
||||
### Map组件属性
|
||||
- `longitude`: 地图中心经度
|
||||
- `latitude`: 地图中心纬度
|
||||
- `scale`: 地图缩放级别(1-20)
|
||||
- `markers`: 地图标记数组
|
||||
- `show-location`: 是否显示用户位置
|
||||
- `enable-zoom`: 是否支持缩放
|
||||
- `enable-scroll`: 是否支持拖动
|
||||
- `enable-rotate`: 是否支持旋转
|
||||
|
||||
### 标记属性
|
||||
```typescript
|
||||
interface Marker {
|
||||
id: string; // 标记唯一标识
|
||||
latitude: number; // 纬度
|
||||
longitude: number; // 经度
|
||||
title: string; // 标记标题
|
||||
iconPath?: string; // 图标路径
|
||||
width: number; // 图标宽度
|
||||
height: number; // 图标高度
|
||||
}
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 1. 地点搜索
|
||||
- 支持关键词搜索
|
||||
- 支持按位置范围搜索
|
||||
- 分页显示结果
|
||||
- 搜索结果地图标记
|
||||
|
||||
### 2. 位置服务
|
||||
- 获取当前位置
|
||||
- 地理编码
|
||||
- 逆地理编码
|
||||
- 位置刷新
|
||||
|
||||
### 3. 地图交互
|
||||
- 地图缩放
|
||||
- 地图拖动
|
||||
- 地图旋转
|
||||
- 标记点击
|
||||
- 地图点击
|
||||
|
||||
### 4. 错误处理
|
||||
- SDK初始化失败处理
|
||||
- 搜索失败处理
|
||||
- 网络异常处理
|
||||
- 位置获取失败处理
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API密钥**: 确保使用有效的腾讯地图API密钥
|
||||
2. **网络权限**: 小程序需要网络访问权限
|
||||
3. **位置权限**: 需要申请位置权限 (`scope.userLocation`)
|
||||
4. **错误处理**: 建议添加适当的错误处理和用户提示
|
||||
5. **地图组件**: 使用Taro的Map组件,确保兼容性
|
||||
|
||||
## 权限配置
|
||||
|
||||
在 `app.config.ts` 中添加位置权限:
|
||||
|
||||
```typescript
|
||||
export default defineAppConfig({
|
||||
// ... 其他配置
|
||||
permission: {
|
||||
'scope.userLocation': {
|
||||
desc: '你的位置信息将用于小程序位置接口的效果展示'
|
||||
}
|
||||
},
|
||||
requiredPrivateInfos: [
|
||||
'getLocation'
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: SDK初始化失败怎么办?
|
||||
A: 检查API密钥是否正确,网络连接是否正常
|
||||
|
||||
### Q: 搜索没有结果?
|
||||
A: 检查搜索关键词是否正确,API密钥是否有效
|
||||
|
||||
### Q: 如何获取用户当前位置?
|
||||
A: 使用小程序的 `wx.getLocation` API,已集成到地图服务中
|
||||
|
||||
### Q: 地图不显示怎么办?
|
||||
A: 检查网络连接,确保腾讯地图服务正常
|
||||
|
||||
### Q: 位置权限被拒绝?
|
||||
A: 引导用户手动开启位置权限,或使用默认位置
|
||||
|
||||
## 更多信息
|
||||
|
||||
- [腾讯地图小程序SDK官方文档](https://lbs.qq.com/miniProgram/jsSdk/jsSdkGuide/jsSdkOverview)
|
||||
- [API密钥申请](https://lbs.qq.com/dev/console/application/mine)
|
||||
- [Taro Map组件文档](https://taro-docs.jd.com/docs/components/map)
|
||||
382
src/components/MapDisplay/index.scss
Normal file
@@ -0,0 +1,382 @@
|
||||
.map-display {
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.map-section {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: #e8f4fd;
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.map-component {
|
||||
width: 100%;
|
||||
height: calc(100vh - 50%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.map-loading-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.location-info-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
|
||||
.location-info {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 16px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.location-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-info-overlay {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
|
||||
.center-info {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 16px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.center-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.moving-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(255, 193, 7, 0.9);
|
||||
border-radius: 12px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
||||
.moving-text {
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-center-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
|
||||
.center-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: #ff4757;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-info {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.location-text {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.sdk-status {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
z-index: 20;
|
||||
|
||||
.sdk-status-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 24px;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #d0d0d0;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background-color: #fff;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.results-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.results-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 300px;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-address {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.result-arrow {
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searching-indicator {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
|
||||
.searching-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
|
||||
.no-results-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.sdk-status-full {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
z-index: 1000;
|
||||
|
||||
.sdk-status-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
505
src/components/MapDisplay/index.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { View, Text, Input, ScrollView, Map } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { mapService, SearchResult, LocationInfo } from './mapService'
|
||||
import './index.scss'
|
||||
|
||||
const MapDisplay: React.FC = () => {
|
||||
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [mapContext, setMapContext] = useState<any>(null)
|
||||
const [isSDKReady, setIsSDKReady] = useState(false)
|
||||
const [mapMarkers, setMapMarkers] = useState<any[]>([])
|
||||
// 地图中心点状态
|
||||
const [mapCenter, setMapCenter] = useState<{lat: number, lng: number} | null>(null)
|
||||
// 用户点击的中心点标记
|
||||
const [centerMarker, setCenterMarker] = useState<any>(null)
|
||||
// 是否正在移动地图
|
||||
const [isMapMoving, setIsMapMoving] = useState(false)
|
||||
// 地图移动的动画帧ID
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
// 地图移动的目标位置
|
||||
const [targetCenter, setTargetCenter] = useState<{lat: number, lng: number} | null>(null)
|
||||
// 实时移动的定时器
|
||||
const moveTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// 地图移动状态
|
||||
const [mapMoveState, setMapMoveState] = useState({
|
||||
isMoving: false,
|
||||
startTime: 0,
|
||||
startCenter: null as {lat: number, lng: number} | null,
|
||||
lastUpdateTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
initializeMapService()
|
||||
return () => {
|
||||
// 清理动画帧和定时器
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 初始化地图服务
|
||||
const initializeMapService = async () => {
|
||||
try {
|
||||
const success = await mapService.initSDK()
|
||||
if (success) {
|
||||
setIsSDKReady(true)
|
||||
console.log('地图服务初始化成功')
|
||||
getCurrentLocation()
|
||||
} else {
|
||||
console.error('地图服务初始化失败')
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化地图服务异常:', error)
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化异常',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const location = await mapService.getLocation()
|
||||
if (location) {
|
||||
setCurrentLocation(location)
|
||||
// 设置地图中心为当前位置,但不显示标记
|
||||
setMapCenter({ lat: location.lat, lng: location.lng })
|
||||
// 清空所有标记
|
||||
setMapMarkers([])
|
||||
console.log('当前位置:', location)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
Taro.showToast({
|
||||
title: '获取位置失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地图加载完成
|
||||
const handleMapLoad = (e: any) => {
|
||||
console.log('地图加载完成:', e)
|
||||
setMapContext(e.detail)
|
||||
}
|
||||
|
||||
// 地图标记点击
|
||||
const handleMarkerTap = (e: any) => {
|
||||
const markerId = e.detail.markerId
|
||||
console.log('点击标记:', markerId)
|
||||
|
||||
if (markerId === 'center') {
|
||||
Taro.showToast({
|
||||
title: '中心点标记',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地图区域点击 - 设置中心点和标记
|
||||
const handleMapTap = (e: any) => {
|
||||
const { latitude, longitude } = e.detail
|
||||
console.log('地图点击:', { latitude, longitude })
|
||||
|
||||
// 设置新的地图中心点
|
||||
setMapCenter({ lat: latitude, lng: longitude })
|
||||
|
||||
// 设置中心点标记
|
||||
const newCenterMarker = {
|
||||
id: 'center',
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
title: '中心点',
|
||||
iconPath: '/assets/center-marker.png', // 可以添加自定义中心点图标
|
||||
width: 40,
|
||||
height: 40
|
||||
}
|
||||
setCenterMarker(newCenterMarker)
|
||||
|
||||
// 更新地图标记,只显示中心点标记
|
||||
setMapMarkers([newCenterMarker])
|
||||
|
||||
Taro.showToast({
|
||||
title: '已设置中心点',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
// 地图开始移动
|
||||
const handleMapMoveStart = () => {
|
||||
console.log('地图开始移动')
|
||||
setIsMapMoving(true)
|
||||
setMapMoveState(prev => ({
|
||||
...prev,
|
||||
isMoving: true,
|
||||
startTime: Date.now(),
|
||||
startCenter: mapCenter,
|
||||
lastUpdateTime: Date.now()
|
||||
}))
|
||||
|
||||
// 启动实时移动更新
|
||||
startRealTimeMoveUpdate()
|
||||
}
|
||||
|
||||
// 启动实时移动更新
|
||||
const startRealTimeMoveUpdate = () => {
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
}
|
||||
|
||||
// 每16ms更新一次(约60fps)
|
||||
moveTimerRef.current = setInterval(() => {
|
||||
if (mapMoveState.isMoving && centerMarker) {
|
||||
// 模拟地图移动过程中的位置更新
|
||||
// 这里我们基于时间计算一个平滑的移动轨迹
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - mapMoveState.startTime
|
||||
const moveDuration = 300 // 假设移动持续300ms
|
||||
|
||||
if (elapsed < moveDuration) {
|
||||
// 计算移动进度
|
||||
const progress = elapsed / moveDuration
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3) // 缓动函数
|
||||
|
||||
// 如果有目标位置,进行插值计算
|
||||
if (targetCenter && mapMoveState.startCenter) {
|
||||
const newLat = mapMoveState.startCenter.lat + (targetCenter.lat - mapMoveState.startCenter.lat) * easeProgress
|
||||
const newLng = mapMoveState.startCenter.lng + (targetCenter.lng - mapMoveState.startCenter.lng) * easeProgress
|
||||
|
||||
// 更新中心点标记位置
|
||||
const updatedCenterMarker = {
|
||||
...centerMarker,
|
||||
latitude: newLat,
|
||||
longitude: newLng
|
||||
}
|
||||
setCenterMarker(updatedCenterMarker)
|
||||
|
||||
// 更新地图标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([updatedCenterMarker, ...searchMarkers])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 16)
|
||||
}
|
||||
|
||||
// 地图区域变化 - 更新目标位置
|
||||
const handleRegionChange = (e: any) => {
|
||||
console.log('地图区域变化:', e.detail)
|
||||
|
||||
// 获取地图当前的中心点坐标
|
||||
if (e.detail && e.detail.centerLocation) {
|
||||
const { latitude, longitude } = e.detail.centerLocation
|
||||
const newCenter = { lat: latitude, lng: longitude }
|
||||
|
||||
// 设置目标位置
|
||||
setTargetCenter(newCenter)
|
||||
|
||||
// 更新地图中心点状态
|
||||
setMapCenter(newCenter)
|
||||
|
||||
// 如果有中心点标记,立即更新标记位置到新的地图中心
|
||||
if (centerMarker) {
|
||||
const updatedCenterMarker = {
|
||||
...centerMarker,
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
}
|
||||
setCenterMarker(updatedCenterMarker)
|
||||
|
||||
// 更新地图标记,保持搜索结果标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([updatedCenterMarker, ...searchMarkers])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 地图移动结束
|
||||
const handleMapMoveEnd = () => {
|
||||
console.log('地图移动结束')
|
||||
setIsMapMoving(false)
|
||||
setMapMoveState(prev => ({
|
||||
...prev,
|
||||
isMoving: false
|
||||
}))
|
||||
|
||||
// 停止实时移动更新
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
moveTimerRef.current = null
|
||||
}
|
||||
|
||||
// 清理动画帧
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入
|
||||
const handleSearchInput = (e: any) => {
|
||||
const value = e.detail.value
|
||||
setSearchValue(value)
|
||||
|
||||
// 如果输入内容为空,清空搜索结果
|
||||
if (!value.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖搜索
|
||||
clearTimeout((window as any).searchTimer)
|
||||
;(window as any).searchTimer = setTimeout(() => {
|
||||
performSearch(value)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (keyword: string) => {
|
||||
if (!keyword.trim() || !isSDKReady) return
|
||||
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
const results = await mapService.search({
|
||||
keyword,
|
||||
location: currentLocation ? `${currentLocation.lat},${currentLocation.lng}` : undefined
|
||||
})
|
||||
setSearchResults(results)
|
||||
|
||||
// 在地图上添加搜索结果标记
|
||||
if (results.length > 0) {
|
||||
const newMarkers = results.map((result, index) => ({
|
||||
id: `search_${index}`,
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
title: result.title,
|
||||
iconPath: '/assets/search-marker.png', // 可以添加自定义图标
|
||||
width: 24,
|
||||
height: 24
|
||||
}))
|
||||
|
||||
// 合并中心点标记和搜索结果标记
|
||||
const allMarkers = centerMarker ? [centerMarker, ...newMarkers] : newMarkers
|
||||
setMapMarkers(allMarkers)
|
||||
}
|
||||
|
||||
console.log('搜索结果:', results)
|
||||
} catch (error) {
|
||||
console.error('搜索异常:', error)
|
||||
Taro.showToast({
|
||||
title: '搜索失败',
|
||||
icon: 'none'
|
||||
})
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索结果点击 - 切换地图中心到对应地点
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
console.log('选择地点:', result)
|
||||
Taro.showToast({
|
||||
title: `已切换到: ${result.title}`,
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 点击搜索结果时,将地图中心移动到该位置
|
||||
const newCenter = { lat: result.location.lat, lng: result.location.lng }
|
||||
setMapCenter(newCenter)
|
||||
|
||||
// 更新中心点标记
|
||||
const newCenterMarker = {
|
||||
id: 'center',
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
title: '中心点',
|
||||
iconPath: '/assets/center-marker.png',
|
||||
width: 40,
|
||||
height: 40
|
||||
}
|
||||
setCenterMarker(newCenterMarker)
|
||||
|
||||
// 更新地图标记,保留搜索结果标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([newCenterMarker, ...searchMarkers])
|
||||
|
||||
// 如果地图上下文可用,也可以调用地图API移动
|
||||
if (mapContext && mapContext.moveToLocation) {
|
||||
mapContext.moveToLocation({
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
success: () => {
|
||||
console.log('地图移动到搜索结果位置')
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('地图移动失败:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索框清空
|
||||
const handleSearchClear = () => {
|
||||
setSearchValue('')
|
||||
setSearchResults([])
|
||||
// 清空搜索结果标记,只保留中心点标记
|
||||
setMapMarkers(centerMarker ? [centerMarker] : [])
|
||||
}
|
||||
|
||||
// 刷新位置
|
||||
const handleRefreshLocation = () => {
|
||||
getCurrentLocation()
|
||||
Taro.showToast({
|
||||
title: '正在刷新位置...',
|
||||
icon: 'loading'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='map-display'>
|
||||
{/* 地图区域 */}
|
||||
<View className='map-section'>
|
||||
<View className='map-container'>
|
||||
{currentLocation ? (
|
||||
<Map
|
||||
className='map-component'
|
||||
longitude={mapCenter?.lng || currentLocation.lng}
|
||||
latitude={mapCenter?.lat || currentLocation.lat}
|
||||
scale={16}
|
||||
markers={mapMarkers}
|
||||
show-location={true}
|
||||
onTap={handleMapTap}
|
||||
theme="dark"
|
||||
onRegionChange={handleRegionChange}
|
||||
onTouchStart={handleMapMoveStart}
|
||||
onTouchEnd={handleMapMoveEnd}
|
||||
onError={(e) => console.error('地图加载错误:', e)}
|
||||
/>
|
||||
) : (
|
||||
<View className='map-loading'>
|
||||
<Text className='map-loading-text'>地图加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 位置信息悬浮层 */}
|
||||
{currentLocation && (
|
||||
<View className='location-info-overlay'>
|
||||
<View className='location-info'>
|
||||
<Text className='location-text'>
|
||||
{currentLocation.address || `当前位置: ${currentLocation.lat.toFixed(6)}, ${currentLocation.lng.toFixed(6)}`}
|
||||
</Text>
|
||||
<View className='refresh-btn' onClick={handleRefreshLocation}>
|
||||
🔄
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 中心点信息悬浮层 */}
|
||||
{centerMarker && (
|
||||
<View className='center-info-overlay'>
|
||||
<View className='center-info'>
|
||||
<Text className='center-text'>
|
||||
中心点: {centerMarker.latitude.toFixed(6)}, {centerMarker.longitude.toFixed(6)}
|
||||
</Text>
|
||||
{isMapMoving && (
|
||||
<View className='moving-indicator'>
|
||||
<Text className='moving-text'>移动中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isSDKReady && (
|
||||
<View className='sdk-status'>
|
||||
<Text className='sdk-status-text'>地图服务初始化中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<View className='search-section'>
|
||||
<View className='search-wrapper'>
|
||||
<View className='search-icon'>🔍</View>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder={isSDKReady ? '搜索地点' : '地图服务初始化中...'}
|
||||
value={searchValue}
|
||||
onInput={handleSearchInput}
|
||||
disabled={!isSDKReady}
|
||||
/>
|
||||
{searchValue && (
|
||||
<View className='clear-btn' onClick={handleSearchClear}>
|
||||
✕
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<View className='search-results'>
|
||||
<View className='results-header'>
|
||||
<Text className='results-title'>搜索结果</Text>
|
||||
<Text className='results-count'>({searchResults.length})</Text>
|
||||
</View>
|
||||
<ScrollView className='results-list' scrollY>
|
||||
{searchResults.map((result) => (
|
||||
<View
|
||||
key={result.id}
|
||||
className='result-item'
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<View className='result-content'>
|
||||
<Text className='result-title'>{result.title}</Text>
|
||||
<Text className='result-address'>{result.address}</Text>
|
||||
</View>
|
||||
<View className='result-arrow'>›</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 搜索状态提示 */}
|
||||
{isSearching && (
|
||||
<View className='searching-indicator'>
|
||||
<Text className='searching-text'>搜索中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 无搜索结果提示 */}
|
||||
{searchValue && !isSearching && searchResults.length === 0 && isSDKReady && (
|
||||
<View className='no-results'>
|
||||
<Text className='no-results-text'>未找到相关地点</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* SDK状态提示 */}
|
||||
{!isSDKReady && (
|
||||
<View className='sdk-status-full'>
|
||||
<Text className='sdk-status-text'>正在初始化地图服务,请稍候...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapDisplay
|
||||
63
src/components/MapDisplay/mapPlugin.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button } from '@tarojs/components';
|
||||
import { mapService, SearchResult, LocationInfo } from './mapService'
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function MapPlugin() {
|
||||
const key = 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'; //使用在腾讯位置服务申请的key
|
||||
const referer = '八瓜一月'; //调用插件的app的名称
|
||||
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
|
||||
|
||||
const category = '';
|
||||
|
||||
const chooseLocation = () => {
|
||||
Taro.navigateTo({
|
||||
url: 'plugin://chooseLocation/index?key=' + key + '&referer=' + referer + '&latitude=' + currentLocation?.lat + '&longitude=' + currentLocation?.lng
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
initializeMapService()
|
||||
}, [])
|
||||
|
||||
// 初始化地图服务
|
||||
const initializeMapService = async () => {
|
||||
try {
|
||||
const success = await mapService.initSDK()
|
||||
if (success) {
|
||||
console.log('地图服务初始化成功')
|
||||
getCurrentLocation()
|
||||
} else {
|
||||
console.error('地图服务初始化失败')
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化地图服务异常:', error)
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化异常',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
// 获取当前位置
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const location = await mapService.getLocation()
|
||||
if (location) {
|
||||
setCurrentLocation(location)
|
||||
console.log('当前位置:', location)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
Taro.showToast({
|
||||
title: '获取位置失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button onClick={chooseLocation}>选择位置</Button>
|
||||
)
|
||||
}
|
||||
190
src/components/MapDisplay/mapService.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// 腾讯地图SDK服务
|
||||
import QQMapWX from "qqmap-wx-jssdk";
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
// 扩展Window接口,添加qqmapsdk属性
|
||||
declare global {
|
||||
interface Window {
|
||||
qqmapsdk?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocationInfo {
|
||||
lat: number
|
||||
lng: number
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
address: string
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
keyword: string
|
||||
location?: string
|
||||
page_size?: number
|
||||
page_index?: number
|
||||
}
|
||||
|
||||
class MapService {
|
||||
private qqmapsdk: any = null
|
||||
private isInitialized = false
|
||||
|
||||
// 初始化腾讯地图SDK
|
||||
async initSDK(): Promise<boolean> {
|
||||
if (this.isInitialized) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接使用QQMapWX,不需要通过window对象
|
||||
this.qqmapsdk = new QQMapWX({
|
||||
key: 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'
|
||||
});
|
||||
|
||||
this.isInitialized = true
|
||||
console.log('腾讯地图SDK初始化成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('初始化腾讯地图SDK失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索地点
|
||||
async search(options: SearchOptions): Promise<SearchResult[]> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initSDK()
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(this.qqmapsdk,11)
|
||||
if (this.qqmapsdk && this.qqmapsdk.search) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.qqmapsdk.getSuggestion({
|
||||
keyword: options.keyword,
|
||||
location: options.location || '39.908802,116.397502', // 默认北京
|
||||
page_size: options.page_size || 20,
|
||||
page_index: options.page_index || 1,
|
||||
success: (res: any) => {
|
||||
console.log('搜索成功:', res)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const results: SearchResult[] = res.data.map((item: any, index: number) => ({
|
||||
id: `search_${index}`,
|
||||
title: item.title || item.name || '未知地点',
|
||||
address: item.address || item.location || '地址未知',
|
||||
location: {
|
||||
lat: item.location?.lat || 0,
|
||||
lng: item.location?.lng || 0
|
||||
}
|
||||
}))
|
||||
resolve(results)
|
||||
} else {
|
||||
resolve([])
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('搜索失败:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 使用模拟数据
|
||||
console.log('使用模拟搜索数据')
|
||||
return this.getMockSearchResults(options.keyword)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索异常:', error)
|
||||
return this.getMockSearchResults(options.keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模拟搜索结果
|
||||
private getMockSearchResults(keyword: string): SearchResult[] {
|
||||
const mockResults: SearchResult[] = [
|
||||
{
|
||||
id: 'mock_1',
|
||||
title: `${keyword}相关地点1`,
|
||||
address: '模拟地址1 - 这是一个示例地址',
|
||||
location: { lat: 39.908802, lng: 116.397502 }
|
||||
},
|
||||
{
|
||||
id: 'mock_2',
|
||||
title: `${keyword}相关地点2`,
|
||||
address: '模拟地址2 - 这是另一个示例地址',
|
||||
location: { lat: 39.918802, lng: 116.407502 }
|
||||
},
|
||||
{
|
||||
id: 'mock_3',
|
||||
title: `${keyword}相关地点3`,
|
||||
address: '模拟地址3 - 第三个示例地址',
|
||||
location: { lat: 39.898802, lng: 116.387502 }
|
||||
}
|
||||
]
|
||||
return mockResults
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
async getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
// 这里可以集成实际的定位服务
|
||||
// 暂时返回模拟位置
|
||||
const res = await Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
isHighAccuracy: true
|
||||
})
|
||||
return {
|
||||
lat: res.latitude,
|
||||
lng: res.longitude
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
async getAddress(lat: number, lng: number): Promise<string | null | undefined> {
|
||||
try {
|
||||
const addressRes: any = await new Promise((resolve, reject) => {
|
||||
this.qqmapsdk.reverseGeocoder({
|
||||
location: {
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
},
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
return addressRes?.results?.address
|
||||
} catch (error) {
|
||||
console.error('获取地址失败:', error)
|
||||
}
|
||||
}
|
||||
async getLocation(): Promise<{ lat: number; lng: number; address: string } | null | undefined> {
|
||||
try {
|
||||
const currentInfo: any = {};
|
||||
const location = await this.getCurrentLocation();
|
||||
const { lat, lng } = location || {};
|
||||
|
||||
if (lat && lng) {
|
||||
currentInfo.lat = lat;
|
||||
currentInfo.lng = lng;
|
||||
const addressRes = await this.getAddress(lat, lng)
|
||||
if (addressRes) {
|
||||
currentInfo.address = addressRes;
|
||||
}
|
||||
}
|
||||
return currentInfo;
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mapService = new MapService()
|
||||
149
src/components/NTRPSlider/NTRPSlider.scss
Normal file
@@ -0,0 +1,149 @@
|
||||
.ntrp-slider {
|
||||
// 区域标题 - 灰色背景
|
||||
.section-title-wrapper {
|
||||
margin-bottom: 16px;
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section-summary {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// NTRP控制区域 - 白色块
|
||||
.ntrp-control-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ntrp-slider-container {
|
||||
.ntrp-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
|
||||
.ntrp-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.ntrp-slider-track {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
margin: 0 12px;
|
||||
|
||||
.slider-bg {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: #333;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&.active {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.thumb-value {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #333;
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
&.active .thumb-value {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 暗色模式适配
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.ntrp-slider {
|
||||
.section-title-wrapper {
|
||||
.section-title {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.section-summary {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.ntrp-control-section {
|
||||
background: #2d2d2d;
|
||||
|
||||
.ntrp-labels .ntrp-label {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.slider-bg {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.slider-range {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
background: #fff;
|
||||
border-color: #2d2d2d;
|
||||
|
||||
.thumb-value {
|
||||
background: #fff;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
133
src/components/NTRPSlider/NTRPSlider.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import './NTRPSlider.scss'
|
||||
|
||||
export interface NTRPRange {
|
||||
min: number
|
||||
max: number
|
||||
}
|
||||
|
||||
// 获取NTRP显示文本的工具函数
|
||||
export const getNTRPRangeText = (range: NTRPRange): string => {
|
||||
if (range.min === 2.0 && range.max === 4.0) {
|
||||
return '不限'
|
||||
}
|
||||
return `${range.min} - ${range.max}`
|
||||
}
|
||||
|
||||
interface NTRPSliderProps {
|
||||
value: NTRPRange
|
||||
onChange: (range: NTRPRange) => void
|
||||
title?: string
|
||||
showTitle?: boolean
|
||||
}
|
||||
|
||||
const NTRPSlider: React.FC<NTRPSliderProps> = ({
|
||||
value = {
|
||||
min: 1.0,
|
||||
max: 5.0
|
||||
},
|
||||
onChange,
|
||||
title = 'NTRP水平要求',
|
||||
showTitle = false
|
||||
}) => {
|
||||
const [activeThumb, setActiveThumb] = useState<'min' | 'max' | null>(null)
|
||||
|
||||
// 计算滑动条位置百分比
|
||||
const getSliderPercentage = useCallback((level: number) => {
|
||||
return ((level - 2.0) / 2.0) * 100
|
||||
}, [])
|
||||
|
||||
// 获取当前NTRP显示文本
|
||||
const currentRangeText = getNTRPRangeText(value)
|
||||
|
||||
const handleSliderTouchStart = useCallback((thumb: 'min' | 'max') => {
|
||||
setActiveThumb(thumb)
|
||||
}, [])
|
||||
|
||||
const handleSliderTouchMove = useCallback((e: any) => {
|
||||
if (!activeThumb) return
|
||||
|
||||
e.preventDefault()
|
||||
const query = Taro.createSelectorQuery()
|
||||
query.select('.ntrp-slider-track').boundingClientRect((rect: any) => {
|
||||
if (rect && !Array.isArray(rect)) {
|
||||
const touch = e.touches[0]
|
||||
const relativeX = touch.clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, relativeX / rect.width))
|
||||
const level = Number((2.0 + percentage * 2.0).toFixed(1))
|
||||
|
||||
if (activeThumb === 'min') {
|
||||
const newMin = Math.min(level, value.max - 0.1)
|
||||
onChange({ min: newMin, max: value.max })
|
||||
} else {
|
||||
const newMax = Math.max(level, value.min + 0.1)
|
||||
onChange({ min: value.min, max: newMax })
|
||||
}
|
||||
}
|
||||
}).exec()
|
||||
}, [activeThumb, value, onChange])
|
||||
|
||||
const handleSliderTouchEnd = useCallback(() => {
|
||||
setActiveThumb(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className='ntrp-slider'>
|
||||
{showTitle && (
|
||||
<View className='section-title-wrapper'>
|
||||
<Text className='section-title'>{title}</Text>
|
||||
<Text className='section-summary'>{currentRangeText}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className='ntrp-control-section'>
|
||||
<View className='ntrp-slider-container'>
|
||||
<View className='ntrp-labels'>
|
||||
<Text className='ntrp-label'>2.0及以下</Text>
|
||||
<Text className='ntrp-label'>4.0及以上</Text>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className='ntrp-slider-track'
|
||||
onTouchMove={handleSliderTouchMove}
|
||||
onTouchEnd={handleSliderTouchEnd}
|
||||
>
|
||||
{/* 背景轨道 */}
|
||||
<View className='slider-bg'></View>
|
||||
|
||||
{/* 选中区间 */}
|
||||
<View
|
||||
className='slider-range'
|
||||
style={{
|
||||
left: `${getSliderPercentage(value.min)}%`,
|
||||
width: `${getSliderPercentage(value.max) - getSliderPercentage(value.min)}%`
|
||||
}}
|
||||
></View>
|
||||
|
||||
{/* 最小值滑块 */}
|
||||
<View
|
||||
className={`slider-thumb ${activeThumb === 'min' ? 'active' : ''}`}
|
||||
style={{ left: `${getSliderPercentage(value.min)}%` }}
|
||||
onTouchStart={() => handleSliderTouchStart('min')}
|
||||
>
|
||||
<View className='thumb-value'>{value.min}</View>
|
||||
</View>
|
||||
|
||||
{/* 最大值滑块 */}
|
||||
<View
|
||||
className={`slider-thumb ${activeThumb === 'max' ? 'active' : ''}`}
|
||||
style={{ left: `${getSliderPercentage(value.max)}%` }}
|
||||
onTouchStart={() => handleSliderTouchStart('max')}
|
||||
>
|
||||
<View className='thumb-value'>{value.max}</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default NTRPSlider
|
||||
1
src/components/NTRPSlider/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, type NTRPRange, getNTRPRangeText } from './NTRPSlider'
|
||||
111
src/components/ParticipantsControl/ParticipantsControl.scss
Normal file
@@ -0,0 +1,111 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
// 人数控制区域 - 白色块
|
||||
.participants-control-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
height: 48px;
|
||||
box-sizing: border-box;
|
||||
.participant-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
&:first-child{
|
||||
width: 50%;
|
||||
&::after{
|
||||
content: '';
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: #E5E5E5;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.control-label {
|
||||
font-size: 13px;
|
||||
color: theme.$primary-color;
|
||||
white-space: nowrap;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.control-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
background-color: theme.$primary-background-color;
|
||||
border-radius: 6px;
|
||||
.format-width{
|
||||
.nut-input-minus{
|
||||
width: 33px;
|
||||
position: relative;
|
||||
&::after{
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: theme.$primary-background-color;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
.nut-number-input{
|
||||
min-width: 33px;
|
||||
background-color: transparent;
|
||||
font-size: 12px;
|
||||
|
||||
}
|
||||
.nut-input-add{
|
||||
width: 33px;
|
||||
position: relative;
|
||||
&::before{
|
||||
content: '';
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background-color: theme.$primary-background-color;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
.control-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
&.minus {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
&.plus {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.control-value {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
min-width: 36px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
src/components/ParticipantsControl/ParticipantsControl.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import './ParticipantsControl.scss'
|
||||
import { InputNumber } from '@nutui/nutui-react-taro'
|
||||
|
||||
interface ParticipantsControlProps {
|
||||
minParticipants: number
|
||||
maxParticipants: number
|
||||
onMinParticipantsChange: (value: number) => void
|
||||
onMaxParticipantsChange: (value: number) => void
|
||||
}
|
||||
|
||||
const ParticipantsControl: React.FC<ParticipantsControlProps> = ({
|
||||
minParticipants,
|
||||
maxParticipants,
|
||||
onMinParticipantsChange,
|
||||
onMaxParticipantsChange
|
||||
}) => {
|
||||
return (
|
||||
<View className='participants-control-section'>
|
||||
<View className='participant-control'>
|
||||
<Text className='control-label'>最少</Text>
|
||||
<View className='control-buttons'>
|
||||
<InputNumber
|
||||
className="format-width"
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={4}
|
||||
formatter={(value) => `${value}人`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className='participant-control'>
|
||||
<Text className='control-label'>最多</Text>
|
||||
<View className='control-buttons'>
|
||||
<InputNumber
|
||||
className="format-width"
|
||||
defaultValue={4}
|
||||
min={0}
|
||||
max={4}
|
||||
formatter={(value) => `${value}人`}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default ParticipantsControl
|
||||
2
src/components/ParticipantsControl/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ParticipantsControl'
|
||||
export type { ParticipantsControlProps } from './ParticipantsControl'
|
||||
94
src/components/SelectStadium/FLOW.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# 球馆选择流程说明
|
||||
|
||||
## 🎯 完整流程
|
||||
|
||||
### 1. 初始状态 - 球馆列表
|
||||
用户看到球馆选择弹窗,显示:
|
||||
- 搜索框(可点击)
|
||||
- 热门球场标题
|
||||
- 球馆列表
|
||||
- 底部取消/完成按钮
|
||||
|
||||
### 2. 点击搜索框
|
||||
- 搜索框变为可点击状态
|
||||
- 点击后跳转到地图选择页面
|
||||
|
||||
### 3. 地图选择页面
|
||||
用户在地图页面可以:
|
||||
- 查看地图,选择位置
|
||||
- 在搜索框输入关键词搜索地点
|
||||
- 从搜索结果中选择地点
|
||||
- 点击"确定"按钮确认选择
|
||||
|
||||
### 4. 返回球馆详情
|
||||
选择地点后:
|
||||
- 自动跳转回球馆选择页面
|
||||
- 显示球馆详情配置页面
|
||||
- 新选择的球馆名称会显示在"已选球场"部分
|
||||
|
||||
### 5. 配置球馆详情
|
||||
用户可以配置:
|
||||
- 场地类型(室内/室外/室外雨棚)
|
||||
- 地面材质(硬地/红土/草地)
|
||||
- 场地信息补充(文本输入)
|
||||
|
||||
### 6. 完成选择
|
||||
- 点击"完成"按钮
|
||||
- 关闭弹窗,返回主页面
|
||||
- 选中的球馆信息传递给父组件
|
||||
|
||||
## 🔄 状态管理
|
||||
|
||||
```typescript
|
||||
// 主要状态
|
||||
const [showDetail, setShowDetail] = useState(false) // 是否显示详情页
|
||||
const [showMapSelector, setShowMapSelector] = useState(false) // 是否显示地图选择器
|
||||
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null) // 选中的球馆
|
||||
```
|
||||
|
||||
## 📱 组件切换逻辑
|
||||
|
||||
```typescript
|
||||
// 组件渲染优先级
|
||||
if (showMapSelector) {
|
||||
return <MapSelector /> // 1. 地图选择器
|
||||
} else if (showDetail && selectedStadium) {
|
||||
return <StadiumDetail /> // 2. 球馆详情
|
||||
} else {
|
||||
return <SelectStadium /> // 3. 球馆列表
|
||||
}
|
||||
```
|
||||
|
||||
## 🗺️ 地图集成
|
||||
|
||||
- 使用 Taro 的 `Map` 组件
|
||||
- 支持地图标记和位置选择
|
||||
- 集成搜索功能,支持关键词搜索
|
||||
- 搜索结果包含地点名称、地址和距离信息
|
||||
|
||||
## 📋 数据传递
|
||||
|
||||
```typescript
|
||||
// 从地图选择器传递到球馆详情
|
||||
const handleMapLocationSelect = (location: Location) => {
|
||||
const newStadium: Stadium = {
|
||||
id: `map_${location.id}`,
|
||||
name: location.name, // 地图选择的球场名称
|
||||
address: location.address // 地图选择的球场地址
|
||||
}
|
||||
|
||||
// 添加到球馆列表并选择
|
||||
stadiumList.unshift(newStadium)
|
||||
setSelectedStadium(newStadium)
|
||||
setShowMapSelector(false)
|
||||
setShowDetail(true)
|
||||
}
|
||||
```
|
||||
|
||||
## 🎨 用户体验
|
||||
|
||||
1. **无缝切换**:三个页面共享同一个弹窗容器
|
||||
2. **状态保持**:选择的地点信息会正确传递
|
||||
3. **视觉反馈**:选中状态有明确的视觉指示
|
||||
4. **操作简单**:点击搜索即可进入地图选择
|
||||
5. **数据同步**:地图选择的球场会自动添加到球馆列表
|
||||
118
src/components/SelectStadium/README.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# SelectStadium 球馆选择组件
|
||||
|
||||
这是一个球馆选择和详情的复合组件,包含两个主要功能:
|
||||
1. 球馆列表选择
|
||||
2. 球馆详情配置
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🏟️ 球馆搜索和选择
|
||||
- 📱 响应式设计,适配移动端
|
||||
- 🔄 无缝切换球馆列表和详情页面
|
||||
- 🎯 支持场地类型、地面材质等配置
|
||||
- 📝 场地信息补充
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 基础用法
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react'
|
||||
import { SelectStadium, Stadium } from './components/SelectStadium'
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [showSelector, setShowSelector] = useState(false)
|
||||
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
|
||||
|
||||
const handleStadiumSelect = (stadium: Stadium | null) => {
|
||||
setSelectedStadium(stadium)
|
||||
setShowSelector(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => setShowSelector(true)}>
|
||||
选择球馆
|
||||
</button>
|
||||
|
||||
<SelectStadium
|
||||
visible={showSelector}
|
||||
onClose={() => setShowSelector(false)}
|
||||
onConfirm={handleStadiumSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## 组件结构
|
||||
|
||||
```
|
||||
SelectStadium/
|
||||
├── SelectStadium.tsx # 主组件,管理状态和切换逻辑
|
||||
├── StadiumDetail.tsx # 球馆详情组件
|
||||
├── SelectStadium.scss # 球馆列表样式
|
||||
├── StadiumDetail.scss # 球馆详情样式
|
||||
├── index.ts # 导出文件
|
||||
└── README.md # 说明文档
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
### SelectStadium
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| visible | boolean | 是 | 控制弹窗显示/隐藏 |
|
||||
| onClose | () => void | 是 | 关闭弹窗回调 |
|
||||
| onConfirm | (stadium: Stadium \| null) => void | 是 | 确认选择回调 |
|
||||
|
||||
### StadiumDetail
|
||||
|
||||
| 属性 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| stadium | Stadium | 是 | 选中的球馆信息 |
|
||||
| onBack | () => void | 是 | 返回球馆列表回调 |
|
||||
| onConfirm | (stadium, venueType, groundMaterial, additionalInfo) => void | 是 | 确认配置回调 |
|
||||
|
||||
## 数据接口
|
||||
|
||||
### Stadium
|
||||
|
||||
```typescript
|
||||
interface Stadium {
|
||||
id: string
|
||||
name: string
|
||||
address?: string
|
||||
}
|
||||
```
|
||||
|
||||
## 配置选项
|
||||
|
||||
### 场地类型
|
||||
- 室内
|
||||
- 室外
|
||||
- 室外雨棚
|
||||
|
||||
### 地面材质
|
||||
- 硬地
|
||||
- 红土
|
||||
- 草地
|
||||
|
||||
### 场地信息补充
|
||||
- 文本输入框,支持用户自定义备注信息
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用 SCSS 编写,可以通过修改以下文件来自定义样式:
|
||||
|
||||
- `SelectStadium.scss` - 球馆列表样式
|
||||
- `StadiumDetail.scss` - 球馆详情样式
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 组件依赖 `@nutui/nutui-react-taro` 的 `Popup` 组件
|
||||
2. 确保在 Taro 环境中使用
|
||||
3. 组件内部管理状态,外部只需要控制 `visible` 属性
|
||||
4. 球馆列表数据在组件内部硬编码,实际使用时可以通过 props 传入
|
||||
5. StadiumDetail 组件现在只包含场地配置选项,去掉了头部、提醒和活动封面部分
|
||||
201
src/components/SelectStadium/SelectStadium.scss
Normal file
@@ -0,0 +1,201 @@
|
||||
.select-stadium-popup {
|
||||
.nut-popup {
|
||||
max-height: 85vh;
|
||||
overflow: hidden;
|
||||
|
||||
.nut-popup__content {
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-stadium {
|
||||
width: 100%;
|
||||
height: 85vh;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
// 搜索区域
|
||||
.search-section {
|
||||
background: #f5f5f5;
|
||||
padding: 16px;
|
||||
|
||||
.search-wrapper {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 16px;
|
||||
background: #f0f8ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e0f0ff;
|
||||
}
|
||||
|
||||
.map-icon {
|
||||
font-size: 16px;
|
||||
color: #007AFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 热门球场区域
|
||||
.hot-section {
|
||||
background: #f5f5f5;
|
||||
padding: 0 16px 16px 16px;
|
||||
|
||||
.hot-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
|
||||
.hot-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.booking-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.booking-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.booking-status {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场馆列表
|
||||
.stadium-list {
|
||||
flex: 1;
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
.stadium-item {
|
||||
border-radius: 20px;
|
||||
height: 40px;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #000;
|
||||
position: relative;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: #f0f8ff;
|
||||
|
||||
&::after {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: #007AFF;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.stadium-name {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
padding-right: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮区域
|
||||
.bottom-actions {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
.cancel-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #333;
|
||||
|
||||
.confirm-text {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框占位符样式
|
||||
.search-input::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
201
src/components/SelectStadium/SelectStadium.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Input, ScrollView } from '@tarojs/components'
|
||||
import { Popup } from '@nutui/nutui-react-taro'
|
||||
import Taro from '@tarojs/taro'
|
||||
import StadiumDetail from './StadiumDetail'
|
||||
import './SelectStadium.scss'
|
||||
|
||||
export interface Stadium {
|
||||
id: string
|
||||
name: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
interface SelectStadiumProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (stadium: Stadium | null) => void
|
||||
}
|
||||
|
||||
const stadiumList: Stadium[] = [
|
||||
{ id: '1', name: '静安网球馆', address: '静安区' },
|
||||
{ id: '2', name: '芦湾体育馆', address: '芦湾区' },
|
||||
{ id: '3', name: '静安网球馆', address: '静安区' },
|
||||
{ id: '4', name: '徐汇游泳中心', address: '徐汇区' },
|
||||
{ id: '5', name: '汇龙新城小区', address: '新城区' },
|
||||
{ id: '6', name: '翠湖御苑小区', address: '翠湖区' },
|
||||
{ id: '7', name: '仁恒河滨花园网球场', address: '浦东新区' },
|
||||
{ id: '8', name: 'Our Tennis 东江球场', address: '浦东新区' },
|
||||
{ id: '9', name: '上海琦梦网球俱乐部', address: '浦东新区' }
|
||||
]
|
||||
|
||||
const SelectStadium: React.FC<SelectStadiumProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm
|
||||
}) => {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
// 过滤场馆列表
|
||||
const filteredStadiums = stadiumList.filter(stadium =>
|
||||
stadium.name.toLowerCase().includes(searchValue.toLowerCase())
|
||||
)
|
||||
|
||||
// 处理场馆选择
|
||||
const handleStadiumSelect = (stadium: Stadium) => {
|
||||
setSelectedStadium(stadium)
|
||||
setShowDetail(true)
|
||||
}
|
||||
|
||||
// 处理返回球馆列表
|
||||
const handleBackToList = () => {
|
||||
setShowDetail(false)
|
||||
setSelectedStadium(null)
|
||||
}
|
||||
|
||||
// 处理搜索框输入
|
||||
const handleSearchInput = (e: any) => {
|
||||
setSearchValue(e.detail.value)
|
||||
}
|
||||
|
||||
// 处理地图选择位置
|
||||
const handleMapLocation = () => {
|
||||
Taro.chooseLocation({
|
||||
success: (res) => {
|
||||
console.log('选择位置成功:', res)
|
||||
// 可以根据位置信息搜索附近的场馆
|
||||
// 这里可以调用相关API获取附近场馆信息
|
||||
Taro.showToast({
|
||||
title: '位置选择成功',
|
||||
icon: 'success'
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('选择位置失败:', err)
|
||||
Taro.showToast({
|
||||
title: '位置选择失败',
|
||||
icon: 'error'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 处理确认
|
||||
const handleConfirm = (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => {
|
||||
// 这里可以处理球馆详情的信息
|
||||
console.log('球馆详情:', { stadium, venueType, groundMaterial, additionalInfo })
|
||||
onConfirm(stadium)
|
||||
setShowDetail(false)
|
||||
setSelectedStadium(null)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
// 处理球馆列表确认
|
||||
const handleListConfirm = () => {
|
||||
if (selectedStadium) {
|
||||
onConfirm(selectedStadium)
|
||||
setSelectedStadium(null)
|
||||
setSearchValue('')
|
||||
}
|
||||
}
|
||||
|
||||
// 处理取消
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
setShowDetail(false)
|
||||
setSelectedStadium(null)
|
||||
setSearchValue('')
|
||||
}
|
||||
|
||||
// 如果显示详情页面
|
||||
if (showDetail && selectedStadium) {
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="bottom"
|
||||
round
|
||||
closeable={false}
|
||||
onClose={handleCancel}
|
||||
className="select-stadium-popup"
|
||||
>
|
||||
<StadiumDetail
|
||||
stadium={selectedStadium}
|
||||
onBack={handleBackToList}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
// 显示球馆列表
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="bottom"
|
||||
round
|
||||
closeable={false}
|
||||
onClose={handleCancel}
|
||||
className="select-stadium-popup"
|
||||
>
|
||||
<View className='select-stadium'>
|
||||
{/* 搜索框 */}
|
||||
<View className='search-section'>
|
||||
<View className='search-wrapper'>
|
||||
<View className='search-icon'>🔍</View>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder='搜索'
|
||||
value={searchValue}
|
||||
onInput={handleSearchInput}
|
||||
/>
|
||||
<View className='map-btn' onClick={handleMapLocation}>
|
||||
<Text className='map-icon'>📍</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 热门球场标题 */}
|
||||
<View className='hot-section'>
|
||||
<View className='hot-header'>
|
||||
<Text className='hot-title'>热门球场</Text>
|
||||
<View className='booking-section'>
|
||||
<Text className='booking-title'>预定球场</Text>
|
||||
<Text className='booking-status'>敬请期待</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 场馆列表 */}
|
||||
<ScrollView className='stadium-list' scrollY>
|
||||
{filteredStadiums.map((stadium) => (
|
||||
<View
|
||||
key={stadium.id}
|
||||
className={`stadium-item ${selectedStadium?.id === stadium.id ? 'selected' : ''}`}
|
||||
onClick={() => handleStadiumSelect(stadium)}
|
||||
>
|
||||
<Text className='stadium-name'>{stadium.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View className='bottom-actions'>
|
||||
<View className='action-buttons'>
|
||||
<View className='cancel-btn' onClick={handleCancel}>
|
||||
<Text className='cancel-text'>取消</Text>
|
||||
</View>
|
||||
<View className='confirm-btn' onClick={handleListConfirm}>
|
||||
<Text className='confirm-text'>完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectStadium
|
||||
202
src/components/SelectStadium/StadiumDetail.scss
Normal file
@@ -0,0 +1,202 @@
|
||||
.stadium-detail {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 60vh;
|
||||
background: white;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
|
||||
// 已选球场
|
||||
.selected-venue-section {
|
||||
padding: 20px 16px 16px 16px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.venue-button {
|
||||
background: #333;
|
||||
border-radius: 24px;
|
||||
padding: 12px 24px;
|
||||
display: inline-block;
|
||||
|
||||
.venue-name {
|
||||
color: white;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场地类型
|
||||
.venue-type-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.option-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.option-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.selected {
|
||||
background: #333;
|
||||
border-color: #333;
|
||||
|
||||
.option-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 地面材质
|
||||
.ground-material-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.option-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.option-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&.selected {
|
||||
background: #333;
|
||||
border-color: #333;
|
||||
|
||||
.option-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.option-text {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场地信息补充
|
||||
.additional-info-section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.additional-input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: white;
|
||||
height: 44px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部按钮
|
||||
.bottom-actions {
|
||||
background: white;
|
||||
padding: 16px;
|
||||
padding-bottom: calc(16px + env(safe-area-inset-bottom));
|
||||
border-top: 1px solid #e5e5e5;
|
||||
flex-shrink: 0;
|
||||
margin-top: auto;
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
height: 48px;
|
||||
border-radius: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: white;
|
||||
border: 1px solid #e0e0e0;
|
||||
|
||||
.cancel-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #333;
|
||||
|
||||
.confirm-text {
|
||||
font-size: 16px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
src/components/SelectStadium/StadiumDetail.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Input } from '@tarojs/components'
|
||||
import './StadiumDetail.scss'
|
||||
|
||||
export interface Stadium {
|
||||
id: string
|
||||
name: string
|
||||
address?: string
|
||||
}
|
||||
|
||||
interface StadiumDetailProps {
|
||||
stadium: Stadium
|
||||
onBack: () => void
|
||||
onConfirm: (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => void
|
||||
}
|
||||
|
||||
const StadiumDetail: React.FC<StadiumDetailProps> = ({
|
||||
stadium,
|
||||
onBack,
|
||||
onConfirm
|
||||
}) => {
|
||||
const [venueType, setVenueType] = useState('室内')
|
||||
const [groundMaterial, setGroundMaterial] = useState('硬地')
|
||||
const [additionalInfo, setAdditionalInfo] = useState('')
|
||||
|
||||
const venueTypes = ['室内', '室外', '室外雨棚']
|
||||
const groundMaterials = ['硬地', '红土', '草地']
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(stadium, venueType, groundMaterial, additionalInfo)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='stadium-detail'>
|
||||
{/* 已选球场 */}
|
||||
<View className='selected-venue-section'>
|
||||
<Text className='section-title'>已选球场</Text>
|
||||
<View className='venue-button'>
|
||||
<Text className='venue-name'>{stadium.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 场地类型 */}
|
||||
<View className='venue-type-section'>
|
||||
<Text className='section-title'>场地类型</Text>
|
||||
<View className='option-buttons'>
|
||||
{venueTypes.map((type) => (
|
||||
<View
|
||||
key={type}
|
||||
className={`option-btn ${venueType === type ? 'selected' : ''}`}
|
||||
onClick={() => setVenueType(type)}
|
||||
>
|
||||
<Text className='option-text'>{type}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 地面材质 */}
|
||||
<View className='ground-material-section'>
|
||||
<Text className='section-title'>地面材质</Text>
|
||||
<View className='option-buttons'>
|
||||
{groundMaterials.map((material) => (
|
||||
<View
|
||||
key={material}
|
||||
className={`option-btn ${groundMaterial === material ? 'selected' : ''}`}
|
||||
onClick={() => setGroundMaterial(material)}
|
||||
>
|
||||
<Text className='option-text'>{material}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 场地信息补充 */}
|
||||
<View className='additional-info-section'>
|
||||
<Text className='section-title'>场地信息补充</Text>
|
||||
<Input
|
||||
className='additional-input'
|
||||
placeholder='有其他场地信息可备注'
|
||||
value={additionalInfo}
|
||||
onInput={(e) => setAdditionalInfo(e.detail.value)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 底部按钮 */}
|
||||
<View className='bottom-actions'>
|
||||
<View className='action-buttons'>
|
||||
<View className='cancel-btn' onClick={onBack}>
|
||||
<Text className='cancel-text'>取消</Text>
|
||||
</View>
|
||||
<View className='confirm-btn' onClick={handleConfirm}>
|
||||
<Text className='confirm-text'>完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default StadiumDetail
|
||||
3
src/components/SelectStadium/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as SelectStadium } from './SelectStadium'
|
||||
export { default as StadiumDetail } from './StadiumDetail'
|
||||
export type { Stadium } from './SelectStadium'
|
||||
52
src/components/TextareaTag/TextareaTag.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
.textarea-tag {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 10px 16px 5px 10px;
|
||||
width: 100%;
|
||||
.input-wrapper {
|
||||
padding-bottom: 10px;
|
||||
|
||||
.additional-input {
|
||||
width: 100%;
|
||||
height: 46px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
line-height: 1.4;
|
||||
resize: none;
|
||||
.textarea-placeholder{
|
||||
color: theme.$textarea-placeholder-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options-wrapper {
|
||||
.options-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.options-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
.nut-checkbox{
|
||||
margin-right: 0;
|
||||
margin-bottom: 5px;
|
||||
.nut-checkbox-button{
|
||||
border: 1px solid theme.$primary-border-color;
|
||||
color: theme.$primary-color;
|
||||
background: transparent;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/components/TextareaTag/TextareaTag.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { View, Textarea } from '@tarojs/components'
|
||||
|
||||
import { Checkbox } from '@nutui/nutui-react-taro'
|
||||
|
||||
import './TextareaTag.scss'
|
||||
|
||||
interface TextareaTagProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
title?: string
|
||||
showTitle?: boolean
|
||||
placeholder?: string
|
||||
maxLength?: number
|
||||
options?: { label: string; value: any }[] | null
|
||||
}
|
||||
|
||||
const TextareaTag: React.FC<TextareaTagProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请输入',
|
||||
maxLength = 500,
|
||||
options = []
|
||||
}) => {
|
||||
// 处理输入框变化
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const handleInputChange = useCallback((e: any) => {
|
||||
onChange(e.detail.value)
|
||||
}, [onChange])
|
||||
|
||||
// 选择预设选项
|
||||
const handleSelectOption = useCallback((option: string) => {
|
||||
let newValue = ''
|
||||
|
||||
if (value) {
|
||||
// 如果已有内容,用分号分隔添加
|
||||
newValue = value + ';' + option
|
||||
} else {
|
||||
// 如果没有内容,直接添加
|
||||
newValue = option
|
||||
}
|
||||
|
||||
onChange(newValue)
|
||||
}, [value, onChange])
|
||||
|
||||
return (
|
||||
<View className='textarea-tag'>
|
||||
{/* 输入框 */}
|
||||
<View className='input-wrapper'>
|
||||
<Textarea
|
||||
className='additional-input'
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
placeholderClass='textarea-placeholder'
|
||||
onInput={handleInputChange}
|
||||
maxlength={maxLength}
|
||||
autoHeight={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 选择选项 */}
|
||||
<View className='options-wrapper'>
|
||||
<View className='options-list'>
|
||||
{
|
||||
<Checkbox.Group
|
||||
labelPosition="left"
|
||||
direction="horizontal"
|
||||
value={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
>
|
||||
{
|
||||
options?.map((option, index) => (
|
||||
<Checkbox
|
||||
key={index}
|
||||
shape="button"
|
||||
value={option.value}
|
||||
label={option.label}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Checkbox.Group>
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextareaTag
|
||||
1
src/components/TextareaTag/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './TextareaTag'
|
||||
73
src/components/TimeSelector/TimeSelector.scss
Normal file
@@ -0,0 +1,73 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
.time-selector {
|
||||
// 区域标题 - 灰色背景
|
||||
width: 100%;
|
||||
// 时间区域 - 合并的白色块
|
||||
.time-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
.time-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding-left: 12px;
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
.time-content {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.time-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 3px;
|
||||
font-size: 14px;
|
||||
color: theme.$primary-color;
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: theme.$primary-color;
|
||||
border: 1.5px solid theme.$primary-color;
|
||||
margin-right: 12px;
|
||||
&.hollow {
|
||||
background: transparent;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.time-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
line-height: 44px;
|
||||
justify-content: space-between;
|
||||
flex: 1;
|
||||
padding-right: 12px;
|
||||
.time-text-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
.time-text {
|
||||
font-size: 13px;
|
||||
color: theme.$primary-color;
|
||||
padding: 0 12px;
|
||||
background: theme.$primary-shallow-bg;
|
||||
height: 28px;
|
||||
line-height: 28px;
|
||||
border-radius: 14px;
|
||||
&.time-am {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/components/TimeSelector/TimeSelector.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { View, Text, Picker } from '@tarojs/components'
|
||||
import './TimeSelector.scss'
|
||||
|
||||
export interface TimeRange {
|
||||
startDate: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
interface TimeSelectorProps {
|
||||
value: TimeRange
|
||||
onChange: (timeRange: TimeRange) => void
|
||||
}
|
||||
|
||||
const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
value = {
|
||||
startDate: '',
|
||||
startTime: '',
|
||||
endTime: ''
|
||||
},
|
||||
onChange
|
||||
}) => {
|
||||
// 格式化日期显示
|
||||
const formatDate = (dateStr: string) => {
|
||||
return dateStr.replace(/-/g, '年').replace(/-/g, '月') + '日'
|
||||
}
|
||||
|
||||
// 处理开始日期变化
|
||||
const handleStartDateChange = (e: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
startDate: e.detail.value
|
||||
})
|
||||
}
|
||||
|
||||
// 处理开始时间变化
|
||||
const handleStartTimeChange = (e: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
startTime: e.detail.value
|
||||
})
|
||||
}
|
||||
|
||||
// 处理结束时间变化
|
||||
const handleEndTimeChange = (e: any) => {
|
||||
onChange({
|
||||
...value,
|
||||
endTime: e.detail.value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='time-selector'>
|
||||
<View className='time-section'>
|
||||
{/* 开始时间 */}
|
||||
<View className='time-item'>
|
||||
<View className='time-label'>
|
||||
<View className='dot'></View>
|
||||
</View>
|
||||
<View className='time-content'>
|
||||
<Text className='time-label'>开始时间</Text>
|
||||
<view className='time-text-wrapper'>
|
||||
<Text className='time-text'>2025年11月23日</Text>
|
||||
<Text className='time-text time-am'>8:00 AM</Text>
|
||||
</view>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 结束时间 */}
|
||||
<View className='time-item'>
|
||||
<View className='time-label'>
|
||||
<View className='dot hollow'></View>
|
||||
</View>
|
||||
<View className='time-content'>
|
||||
<Text className='time-label'>结束时间</Text>
|
||||
<view className='time-text-wrapper'>
|
||||
<Text className='time-text'>2025年11月23日</Text>
|
||||
<Text className='time-text time-am'>8:00 AM</Text>
|
||||
</view>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimeSelector
|
||||
1
src/components/TimeSelector/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default, type TimeRange } from './TimeSelector'
|
||||
34
src/components/TitleInput/TitleInput.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react'
|
||||
import { View, Input } from '@tarojs/components'
|
||||
|
||||
import './index.scss'
|
||||
|
||||
interface TitleInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
maxLength?: number
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const TitleInput: React.FC<TitleInputProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
maxLength = 20,
|
||||
placeholder = '好的标题更吸引人哦'
|
||||
}) => {
|
||||
return (
|
||||
<View className='title-input-wrapper'>
|
||||
<Input
|
||||
className='title-input'
|
||||
placeholder={placeholder}
|
||||
placeholderClass='title-placeholder'
|
||||
value={value}
|
||||
onInput={(e) => onChange(e.detail.value)}
|
||||
maxlength={maxLength}
|
||||
/>
|
||||
<View className='char-count'>{value.length}/{maxLength}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TitleInput
|
||||
35
src/components/TitleInput/index.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
.title-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
.title-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
line-height: 44px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
&:focus {
|
||||
border-color: #007aff;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.title-placeholder {
|
||||
color: #999;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.char-count {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
1
src/components/TitleInput/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './TitleInput'
|
||||
25
src/components/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import ActivityTypeSwitch from './ActivityTypeSwitch'
|
||||
import TextareaTag from './TextareaTag'
|
||||
import AutoDegradeSwitch from './AutoDegradeSwitch'
|
||||
import CoverImageUpload from './CoverImageUpload'
|
||||
import FormBasicInfo from './FormBasicInfo'
|
||||
import NTRPSlider from './NTRPSlider'
|
||||
import ParticipantsControl from './ParticipantsControl'
|
||||
import { SelectStadium, StadiumDetail } from './SelectStadium'
|
||||
import TimeSelector from './TimeSelector'
|
||||
import TitleInput from './TitleInput'
|
||||
|
||||
export {
|
||||
ActivityTypeSwitch,
|
||||
TextareaTag,
|
||||
AutoDegradeSwitch,
|
||||
CoverImageUpload,
|
||||
FormBasicInfo,
|
||||
NTRPSlider,
|
||||
ParticipantsControl,
|
||||
SelectStadium,
|
||||
TimeSelector,
|
||||
TitleInput,
|
||||
StadiumDetail
|
||||
}
|
||||
|
||||
6
src/components/index.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { getNTRPRangeText, type NTRPRange } from './NTRPSlider'
|
||||
import { type TimeRange } from './TimeSelector'
|
||||
import { type Stadium } from './SelectStadium'
|
||||
import { type ActivityType } from './ActivityTypeSwitch'
|
||||
import { type CoverImage } from './CoverImageUpload'
|
||||
export type { NTRPRange, getNTRPRangeText, TimeRange, Stadium, ActivityType, CoverImage }
|
||||
@@ -1,173 +0,0 @@
|
||||
// 表单字段类型定义
|
||||
export type FieldType =
|
||||
| 'image-upload'
|
||||
| 'text-input'
|
||||
| 'number-input'
|
||||
| 'time-display'
|
||||
| 'multi-select'
|
||||
| 'counter'
|
||||
| 'slider'
|
||||
| 'radio-group'
|
||||
| 'checkbox'
|
||||
| 'venue-input'
|
||||
|
||||
// 表单字段配置接口
|
||||
export interface FormFieldConfig {
|
||||
key: string // 字段名
|
||||
type: FieldType // 字段类型
|
||||
title: string // 显示标题
|
||||
required?: boolean // 是否必填
|
||||
placeholder?: string // 占位符
|
||||
hint?: string // 提示文字
|
||||
defaultValue?: any // 默认值
|
||||
validation?: { // 验证规则
|
||||
min?: number
|
||||
max?: number
|
||||
pattern?: string
|
||||
message?: string
|
||||
}
|
||||
options?: Array<{ // 选项(用于选择类型)
|
||||
label: string
|
||||
value: any
|
||||
}>
|
||||
config?: { // 特殊配置
|
||||
maxImages?: number // 图片上传最大数量
|
||||
minValue?: number // 计数器最小值
|
||||
maxValue?: number // 计数器最大值
|
||||
range?: [number, number] // 滑动条范围
|
||||
unit?: string // 单位
|
||||
showArrow?: boolean // 是否显示箭头
|
||||
}
|
||||
}
|
||||
|
||||
// 完整的表单配置
|
||||
export interface FormConfig {
|
||||
title: string // 表单标题
|
||||
reminder?: string // 提醒文本
|
||||
fields: FormFieldConfig[] // 字段列表
|
||||
actions?: { // 底部操作
|
||||
addText?: string
|
||||
submitText?: string
|
||||
disclaimer?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 发布球的表单配置
|
||||
export const publishBallFormConfig: FormConfig = {
|
||||
title: '发布个人约球',
|
||||
reminder: '提醒: 活动开始前x小时未达到最低招募人数,活动自动取消;活动结束2天后,报名费自动转入[钱包—余额],可提现到微信。',
|
||||
fields: [
|
||||
{
|
||||
key: 'cover',
|
||||
type: 'image-upload',
|
||||
title: '活动封面',
|
||||
required: false,
|
||||
defaultValue: [],
|
||||
config: {
|
||||
maxImages: 9
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
type: 'text-input',
|
||||
title: '活动主题 (选填)',
|
||||
required: false,
|
||||
placeholder: '好的主题更吸引人哦',
|
||||
defaultValue: ''
|
||||
},
|
||||
{
|
||||
key: 'time',
|
||||
type: 'time-display',
|
||||
title: '活动时间',
|
||||
required: true,
|
||||
defaultValue: {
|
||||
startTime: '2025-11-23 08:00',
|
||||
endTime: '2025-11-23 10:00'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
type: 'venue-input',
|
||||
title: '活动场地',
|
||||
required: true,
|
||||
placeholder: '选择活动地点',
|
||||
defaultValue: '',
|
||||
config: {
|
||||
showArrow: true
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
type: 'number-input',
|
||||
title: '人均价格/元',
|
||||
required: true,
|
||||
placeholder: '请填写每个人多少钱',
|
||||
defaultValue: '',
|
||||
validation: {
|
||||
min: 0,
|
||||
message: '价格不能为负数'
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playStyle',
|
||||
type: 'multi-select',
|
||||
title: '活动玩法',
|
||||
required: true,
|
||||
defaultValue: [],
|
||||
options: [
|
||||
{ label: '单打', value: '单打' },
|
||||
{ label: '双打', value: '双打' },
|
||||
{ label: '娱乐拉球', value: '娱乐拉球' },
|
||||
{ label: '到了再说', value: '到了再说' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playerCount',
|
||||
type: 'counter',
|
||||
title: '招募人数',
|
||||
required: true,
|
||||
defaultValue: { min: 1, max: 4 },
|
||||
config: {
|
||||
minValue: 1,
|
||||
maxValue: 50
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'ntrpRange',
|
||||
type: 'slider',
|
||||
title: 'NTRP水平区间',
|
||||
required: true,
|
||||
defaultValue: [2.0, 4.0],
|
||||
config: {
|
||||
range: [1.0, 7.0],
|
||||
unit: ''
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'genderPreference',
|
||||
type: 'radio-group',
|
||||
title: '补充要求 (选填)',
|
||||
hint: '补充性别偏好、特殊要求和注意事项等信息',
|
||||
required: false,
|
||||
defaultValue: 'unlimited',
|
||||
options: [
|
||||
{ label: '选择填入', value: 'select' },
|
||||
{ label: '仅限男生', value: 'male' },
|
||||
{ label: '仅限女生', value: 'female' },
|
||||
{ label: '性别不限', value: 'unlimited' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'autoStandby',
|
||||
type: 'checkbox',
|
||||
title: '开启自动候补逻辑',
|
||||
required: false,
|
||||
defaultValue: true
|
||||
}
|
||||
],
|
||||
actions: {
|
||||
addText: '再添加一场',
|
||||
submitText: '完成',
|
||||
disclaimer: '点击确定发布按钮,即表示已阅读并同意《约球规则》'
|
||||
}
|
||||
}
|
||||
177
src/config/formSchema/publishBallFormSchema.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// 表单字段类型枚举
|
||||
export enum FieldType {
|
||||
TEXT = 'text',
|
||||
TEXTAREA = 'textarea',
|
||||
SELECT = 'select',
|
||||
DATE = 'date',
|
||||
TIME = 'time',
|
||||
NUMBER = 'number',
|
||||
SWITCH = 'switch',
|
||||
RADIO = 'radio',
|
||||
CHECKBOX = 'checkbox',
|
||||
LOCATION = 'location',
|
||||
UPLOADIMAGE = 'uploadimage',
|
||||
TIMEINTERVAL = 'timeinterval',
|
||||
NUMBERINTERVAL = 'numberinterval',
|
||||
RANGE = 'range',
|
||||
TEXTAREATAG = 'textareaTag',
|
||||
ACTIVITYINFO = 'activityInfo'
|
||||
}
|
||||
|
||||
// 表单字段配置接口
|
||||
export interface FormFieldConfig {
|
||||
key: string
|
||||
label: string
|
||||
type: FieldType
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
defaultValue?: any
|
||||
options?: Array<{ label: string; value: any }>
|
||||
rules?: Array<{
|
||||
required?: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
pattern?: RegExp
|
||||
message: string
|
||||
}>
|
||||
props?: Record<string, any>
|
||||
description?: string
|
||||
children?: FormFieldConfig[]
|
||||
iconType?: string
|
||||
}
|
||||
|
||||
// 发布球局表单配置
|
||||
export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
{
|
||||
key: 'coverImages',
|
||||
label: '活动封页',
|
||||
type: FieldType.UPLOADIMAGE,
|
||||
placeholder: '请选择活动类型',
|
||||
required: true,
|
||||
props: {
|
||||
maxCount: 9
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'title',
|
||||
label: '',
|
||||
type: FieldType.TEXT,
|
||||
placeholder: '好的标题更吸引人哦',
|
||||
required: true,
|
||||
props: {
|
||||
maxLength: 20
|
||||
},
|
||||
rules: [
|
||||
{ required: true, message: '请输入活动标题' },
|
||||
{ max: 20, message: '标题不能超过20个字符' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
label: '',
|
||||
type: FieldType.TIMEINTERVAL,
|
||||
placeholder: '请选择活动日期',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请选择活动日期' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'timeRange',
|
||||
label: '活动信息',
|
||||
type: FieldType.ACTIVITYINFO,
|
||||
placeholder: '请选择活动时间',
|
||||
required: true,
|
||||
rules: [
|
||||
{ required: true, message: '请选择活动时间' }
|
||||
],
|
||||
children: [
|
||||
{
|
||||
key: 'fee',
|
||||
label: '费用',
|
||||
iconType: 'ICON_COST',
|
||||
type: FieldType.NUMBER,
|
||||
placeholder: '请输入活动费用(元)',
|
||||
defaultValue: 0,
|
||||
rules: [
|
||||
{ min: 0, message: '费用不能为负数' },
|
||||
{ max: 1000, message: '费用不能超过1000元' }
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: '地点',
|
||||
iconType: 'ICON_LOCATION',
|
||||
type: FieldType.LOCATION,
|
||||
placeholder: '请选择活动地点',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'sport',
|
||||
label: '玩法',
|
||||
iconType: 'ICON_GAMEPLAY',
|
||||
type: FieldType.SELECT,
|
||||
placeholder: '请选择玩法',
|
||||
required: true,
|
||||
options: [
|
||||
{ label: '篮球', value: 'basketball' },
|
||||
{ label: '足球', value: 'football' },
|
||||
{ label: '羽毛球', value: 'badminton' },
|
||||
{ label: '网球', value: 'tennis' },
|
||||
{ label: '乒乓球', value: 'pingpong' },
|
||||
{ label: '排球', value: 'volleyball' }
|
||||
],
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'minParticipants',
|
||||
label: '人数要求',
|
||||
type: FieldType.NUMBERINTERVAL,
|
||||
placeholder: '请输入最少参与人数',
|
||||
defaultValue: 1,
|
||||
rules: [
|
||||
{ min: 1, message: '最少参与人数不能为0' },
|
||||
{ max: 4, message: '最少参与人数不能超过100人' }
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
key: 'ntpLevel',
|
||||
label: 'NTRP 水平要求',
|
||||
type: FieldType.RANGE,
|
||||
placeholder: '请选择开始时间',
|
||||
required: true,
|
||||
props: {
|
||||
min: 2.0,
|
||||
max: 4.0,
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'additionalRequirements',
|
||||
label: '补充要求(选填)',
|
||||
type: FieldType.TEXTAREATAG,
|
||||
placeholder: '补充性别偏好、特殊要求和注意事项等信息',
|
||||
required: true,
|
||||
options:[
|
||||
{ label: '新手', value: 'beginner' },
|
||||
{ label: '进阶', value: 'intermediate' },
|
||||
{ label: '高手', value: 'advanced' },
|
||||
{ label: '不限', value: 'any' }
|
||||
],
|
||||
rules: [
|
||||
{ max: 100, message: '补充要求不能超过100个字符' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'autoDegrade',
|
||||
label: '开启自动候补逻辑',
|
||||
type: FieldType.CHECKBOX,
|
||||
placeholder: '开启自动候补逻辑',
|
||||
required: true,
|
||||
description: '开启后,当活动人数不足时,系统会自动将活动状态改为“候补”,并通知用户。',
|
||||
rules: [
|
||||
{ required: true, message: '请选择开启自动候补逻辑' }
|
||||
]
|
||||
}
|
||||
]
|
||||
11
src/config/images.js
Normal file
@@ -0,0 +1,11 @@
|
||||
export default {
|
||||
ICON_REMOVE: require('@/static/publishBall/icon-remove.svg'),
|
||||
ICON_UPLOAD: require('@/static/publishBall/icon-upload.svg'),
|
||||
ICON_LOCATION: require('@/static/publishBall/icon-location.svg'),
|
||||
ICON_GAMEPLAY: require('@/static/publishBall/icon-gameplay.svg'),
|
||||
ICON_PERSONAL: require('@/static/publishBall/icon-personal.svg'),
|
||||
ICON_CHANGDA: require('@/static/publishBall/icon-changda.svg'),
|
||||
ICON_COST: require('@/static/publishBall/icon-cost.svg'),
|
||||
ICON_TIPS: require('@/static/publishBall/icon-tips.svg'),
|
||||
ICON_ARROW_RIGHT: require('@/static/publishBall/icon-arrow-right.svg'),
|
||||
}
|
||||
227
src/nutui-theme.scss
Normal file
@@ -0,0 +1,227 @@
|
||||
// ==========================================
|
||||
// NutUI 专用黑色主题覆盖文件
|
||||
// ==========================================
|
||||
|
||||
// 引入NutUI原始样式(如果需要)
|
||||
// @import '@nutui/nutui-react-taro/dist/style.css';
|
||||
|
||||
// 全局主题变量覆盖
|
||||
$nut-primary-color: #000000 !important;
|
||||
$nut-primary-color-end: #000000 !important;
|
||||
|
||||
// 超强力样式覆盖 - 确保在所有环境下生效
|
||||
:global {
|
||||
// 页面根元素
|
||||
page,
|
||||
.taro_page,
|
||||
#app,
|
||||
.app {
|
||||
|
||||
// ===== Checkbox 复选框 =====
|
||||
.nut-checkbox {
|
||||
.nut-checkbox__icon {
|
||||
border-color: #ddd;
|
||||
|
||||
&.nut-checkbox__icon--checked {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&::after,
|
||||
&::before {
|
||||
color: #ffffff !important;
|
||||
border-color: #ffffff !important;
|
||||
}
|
||||
|
||||
.nut-icon,
|
||||
.nut-icon-check {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.nut-checkbox--checked {
|
||||
.nut-checkbox__icon {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Button 按钮 =====
|
||||
.nut-button {
|
||||
&.nut-button--primary {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
background-color: #333333 !important;
|
||||
border-color: #333333 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.nut-button--default {
|
||||
border-color: #000000 !important;
|
||||
color: #000000 !important;
|
||||
|
||||
&:active {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Switch 开关 =====
|
||||
.nut-switch {
|
||||
&.nut-switch--open,
|
||||
&.nut-switch--active {
|
||||
background-color: #000000 !important;
|
||||
|
||||
.nut-switch__button {
|
||||
background-color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Radio 单选框 =====
|
||||
.nut-radio {
|
||||
.nut-radio__icon {
|
||||
&.nut-radio__icon--checked {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
}
|
||||
|
||||
&.nut-radio--checked {
|
||||
.nut-radio__icon {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Input 输入框 =====
|
||||
.nut-input {
|
||||
&.nut-input--focus {
|
||||
.nut-input__input {
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Picker 选择器 =====
|
||||
.nut-picker {
|
||||
.nut-picker__confirm {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Slider 滑动条 =====
|
||||
.nut-slider {
|
||||
.nut-slider__track {
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
|
||||
.nut-slider__button {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== ActionSheet 动作面板 =====
|
||||
.nut-actionsheet {
|
||||
.nut-actionsheet__item {
|
||||
&.nut-actionsheet__item--active {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Tab 标签页 =====
|
||||
.nut-tabs {
|
||||
.nut-tabs__tab {
|
||||
&.nut-tabs__tab--active {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.nut-tabs__line {
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 超强力直接覆盖 - 针对所有可能的选择器
|
||||
.nut-checkbox__icon--checked,
|
||||
.nut-checkbox-icon-checked,
|
||||
.nut-checkbox__icon.nut-checkbox__icon--checked,
|
||||
.nut-checkbox .nut-checkbox__icon--checked,
|
||||
.nut-checkbox .nut-checkbox__icon.nut-checkbox__icon--checked {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
background: #000000 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
|
||||
// 针对内部图标
|
||||
.nut-checkbox__icon--checked .nut-icon,
|
||||
.nut-checkbox-icon-checked .nut-icon,
|
||||
.nut-checkbox__icon--checked::after,
|
||||
.nut-checkbox-icon-checked::after,
|
||||
.nut-checkbox__icon--checked::before,
|
||||
.nut-checkbox-icon-checked::before {
|
||||
color: #ffffff !important;
|
||||
background-color: #ffffff !important;
|
||||
border-color: #ffffff !important;
|
||||
}
|
||||
|
||||
// 针对所有可能的勾选图标
|
||||
.nut-icon-check,
|
||||
.nut-icon-checklist,
|
||||
.nut-checkbox__icon--checked .nut-icon-check,
|
||||
.nut-checkbox-icon-checked .nut-icon-check {
|
||||
color: #ffffff !important;
|
||||
fill: #ffffff !important;
|
||||
}
|
||||
|
||||
// 针对 checkbox button 激活状态
|
||||
.nut-checkbox-button-active,
|
||||
.nut-checkbox-button.nut-checkbox-button-active {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
background: #000000 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
|
||||
.nut-button--primary,
|
||||
.nut-button.nut-button--primary {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
background: #000000 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
|
||||
.nut-switch--open,
|
||||
.nut-switch.nut-switch--open {
|
||||
background-color: #000000 !important;
|
||||
background: #000000 !important;
|
||||
}
|
||||
|
||||
.nut-radio__icon--checked,
|
||||
.nut-radio-icon-checked,
|
||||
.nut-radio .nut-radio__icon--checked {
|
||||
background-color: #000000 !important;
|
||||
border-color: #000000 !important;
|
||||
color: #ffffff !important;
|
||||
background: #000000 !important;
|
||||
border: 1px solid #000000 !important;
|
||||
}
|
||||
1122
src/package/qqmap-wx-jssdk.js
Normal file
1
src/package/qqmap-wx-jssdk.min.js
vendored
Normal file
5
src/pages/mapDisplay/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
// import MapPlugin from "src/components/MapDisplay/mapPlugin";
|
||||
import MapDisplay from "src/components/MapDisplay";
|
||||
export default function MapDisplayPage() {
|
||||
return <MapDisplay />
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '发布个人约球'
|
||||
navigationBarTitleText: '发布',
|
||||
navigationBarBackgroundColor: '#FAFAFA'
|
||||
})
|
||||
@@ -1,402 +1,202 @@
|
||||
.publish-ball-page {
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 200px;
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.reminder {
|
||||
background: #fff8dc;
|
||||
padding: 16px;
|
||||
margin: 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.4;
|
||||
.publish-ball {
|
||||
min-height: 100vh;
|
||||
background: theme.$page-background-color;
|
||||
padding: 4px 16px 0 16px;
|
||||
position: relative;
|
||||
&__scroll {
|
||||
height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.match-form {
|
||||
background: #fff;
|
||||
margin: 16px;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
&__content {
|
||||
padding-bottom: 40px;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 15px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: #ff4757;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
|
||||
|
||||
// 标题区域 - 独立白色块
|
||||
.bg-section {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 活动描述文本 - 灰色背景
|
||||
.activity-description {
|
||||
margin-bottom: 20px;
|
||||
padding: 0 8px;
|
||||
|
||||
.description-text {
|
||||
font-size: 12px;
|
||||
color:rgba(60, 60, 67, 0.6) ;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
// 表单分组区域 - 费用地点玩法白色块
|
||||
.form-group-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
// 区域标题 - 灰色背景
|
||||
.section-title-wrapper {
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 44px;
|
||||
padding-top: 5px;
|
||||
box-sizing: border-box;
|
||||
font-size: 14px;
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
color: theme.$primary-color;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 24px;
|
||||
.section-summary {
|
||||
font-size: 14px;
|
||||
color: theme.$input-placeholder-color;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 12px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
// 封面上传
|
||||
.cover-upload {
|
||||
.upload-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.upload-btn {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border: 2px dashed #ddd;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fafafa;
|
||||
|
||||
.plus-icon {
|
||||
font-size: 24px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.image-item {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #ff4757;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主题输入
|
||||
.theme-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// 时间显示
|
||||
.time-display {
|
||||
background: #f8f8f8;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
// 自动降分选择 - 白色块
|
||||
.auto-degrade-section {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
.auto-degrade-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.time-label {
|
||||
width: 40px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.time-value {
|
||||
flex: 1;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-left: 16px;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 场地输入
|
||||
.venue-input-container {
|
||||
.auto-degrade-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding-right: 16px;
|
||||
flex: 1;
|
||||
|
||||
.venue-input {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
border: none;
|
||||
.auto-degrade-text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 18px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
// 价格输入
|
||||
.price-input {
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 8px;
|
||||
padding: 0 16px;
|
||||
font-size: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
// 玩法选择
|
||||
.play-style-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
|
||||
.play-style-btn {
|
||||
padding: 8px 20px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 20px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
&.selected {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 人数控制
|
||||
.player-count-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.count-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.count-label {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.count-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.count-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
font-size: 18px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.count-number {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
min-width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.separator {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
// NTRP滑动条
|
||||
.ntrp-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.ntrp-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
.slider-track {
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
|
||||
.slider-fill {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
background: #333;
|
||||
border-radius: 2px;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
}
|
||||
|
||||
.slider-thumb {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
top: -8px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 性别选择
|
||||
.requirements-hint {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.gender-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.gender-btn {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
&.selected {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border-color: #000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 自动候补
|
||||
.standby-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.checkbox {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
.info-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid #999;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fff;
|
||||
|
||||
&.checked {
|
||||
background: #000;
|
||||
border-color: #000;
|
||||
|
||||
.checkmark {
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.auto-degrade-checkbox {
|
||||
margin-left: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 底部操作区
|
||||
.bottom-actions {
|
||||
// 提交区域
|
||||
.submit-section {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
padding: 20px 16px 30px;
|
||||
background: white;
|
||||
padding: 16px;
|
||||
border-top: 1px solid #e0e0e0;
|
||||
|
||||
.add-match-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
height: 44px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
|
||||
.plus-icon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.complete-btn {
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
height: 48px;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #333;
|
||||
color: white;
|
||||
border-radius: 24px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 8px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
.submit-tip {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
|
||||
.link {
|
||||
color: #007AFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 加载状态遮罩保持原样
|
||||
&__loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,151 @@
|
||||
import React from 'react'
|
||||
import { View } from '@tarojs/components'
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Input, Button, Image, ScrollView, Picker } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import DynamicForm from '../../components/DynamicForm/DynamicForm'
|
||||
import { publishBallFormConfig } from '../../config/formSchema/bulishBallFormSchema'
|
||||
import ActivityTypeSwitch, { type ActivityType } from '../../components/ActivityTypeSwitch'
|
||||
import PublishForm from './publishForm'
|
||||
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema';
|
||||
import './index.scss'
|
||||
|
||||
const PublishBallPage: React.FC = () => {
|
||||
// 提交成功回调
|
||||
const handleSubmitSuccess = (response: any) => {
|
||||
console.log('发布成功:', response)
|
||||
interface FormData {
|
||||
activityType: ActivityType
|
||||
title: string
|
||||
timeRange: TimeRange
|
||||
fee: string
|
||||
location: string
|
||||
gameplay: string
|
||||
minParticipants: number
|
||||
maxParticipants: number
|
||||
ntpLevel: NTRPRange
|
||||
additionalRequirements: string
|
||||
autoDegrade: boolean
|
||||
}
|
||||
|
||||
const PublishBall: React.FC = () => {
|
||||
const [coverImages, setCoverImages] = useState<CoverImage[]>([])
|
||||
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
|
||||
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
activityType: 'individual', // 默认值
|
||||
title: '',
|
||||
timeRange: {
|
||||
startDate: '2025-11-23',
|
||||
startTime: '08:00',
|
||||
endTime: '10:00'
|
||||
},
|
||||
fee: '',
|
||||
location: '',
|
||||
gameplay: '',
|
||||
minParticipants: 1,
|
||||
maxParticipants: 4,
|
||||
ntpLevel: { min: 2.0, max: 4.0 },
|
||||
additionalRequirements: '',
|
||||
autoDegrade: false
|
||||
})
|
||||
|
||||
// 处理封面图片变化
|
||||
const handleCoverImagesChange = (images: CoverImage[]) => {
|
||||
setCoverImages(images)
|
||||
}
|
||||
|
||||
// 更新表单数据
|
||||
const updateFormData = (key: keyof FormData, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取人数要求显示文本
|
||||
const getParticipantsText = () => {
|
||||
return `最少${formData.minParticipants}人,最多${formData.maxParticipants}人`
|
||||
}
|
||||
|
||||
// 处理NTRP范围变化
|
||||
const handleNTRPChange = (range: NTRPRange) => {
|
||||
updateFormData('ntpLevel', range)
|
||||
}
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = (timeRange: TimeRange) => {
|
||||
updateFormData('timeRange', timeRange)
|
||||
}
|
||||
|
||||
// 处理补充要求变化
|
||||
const handleAdditionalRequirementsChange = (value: string) => {
|
||||
updateFormData('additionalRequirements', value)
|
||||
}
|
||||
|
||||
// 处理场馆选择
|
||||
const handleStadiumSelect = (stadium: Stadium | null) => {
|
||||
setSelectedStadium(stadium)
|
||||
if (stadium) {
|
||||
updateFormData('location', stadium.name)
|
||||
}
|
||||
setShowStadiumSelector(false)
|
||||
}
|
||||
|
||||
// 处理活动类型变化
|
||||
const handleActivityTypeChange = (type: ActivityType) => {
|
||||
updateFormData('activityType', type)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 基础验证
|
||||
if (!formData.title.trim()) {
|
||||
Taro.showToast({
|
||||
title: '请输入活动标题',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (coverImages.length === 0) {
|
||||
Taro.showToast({
|
||||
title: '请至少上传一张活动封面',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现提交逻辑
|
||||
console.log('提交数据:', { coverImages, formData })
|
||||
|
||||
Taro.showModal({
|
||||
Taro.showToast({
|
||||
title: '发布成功',
|
||||
content: response.data.length > 1 ?
|
||||
`成功发布${response.data.length}场约球活动!` :
|
||||
'约球活动发布成功!',
|
||||
showCancel: false,
|
||||
success: () => {
|
||||
// 可以跳转到活动列表页面
|
||||
// Taro.navigateTo({ url: '/pages/matchList/matchList' })
|
||||
}
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
// 提交失败回调
|
||||
const handleSubmitError = (error: any) => {
|
||||
console.error('发布失败:', error)
|
||||
|
||||
Taro.showModal({
|
||||
title: '发布失败',
|
||||
content: error.message || '网络错误,请稍后重试',
|
||||
showCancel: false
|
||||
})
|
||||
}
|
||||
|
||||
// 添加表单
|
||||
const handleAddForm = () => {
|
||||
console.log('添加新表单')
|
||||
}
|
||||
|
||||
// 删除表单
|
||||
const handleDeleteForm = (index: number) => {
|
||||
console.log('删除表单:', index)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='publish-ball-page'>
|
||||
<DynamicForm
|
||||
config={publishBallFormConfig}
|
||||
formType='publishBall'
|
||||
enableApiSubmit={true}
|
||||
onSubmitSuccess={handleSubmitSuccess}
|
||||
onSubmitError={handleSubmitError}
|
||||
onAddForm={handleAddForm}
|
||||
onDeleteForm={handleDeleteForm}
|
||||
<View className='publish-ball'>
|
||||
{/* 活动类型切换 */}
|
||||
<ActivityTypeSwitch
|
||||
value={formData.activityType}
|
||||
onChange={handleActivityTypeChange}
|
||||
/>
|
||||
<ScrollView className='publish-ball__scroll' scrollY>
|
||||
<PublishForm formData={formData} onChange={updateFormData} optionsConfig={publishBallFormSchema} />
|
||||
</ScrollView>
|
||||
|
||||
|
||||
{/* 完成按钮 */}
|
||||
<View className='submit-section'>
|
||||
<Button className='submit-btn' onClick={handleSubmit}>
|
||||
完成
|
||||
</Button>
|
||||
<Text className='submit-tip'>
|
||||
点击确定发布约球,即表示已经同意条款
|
||||
<Text className='link'>《约球规则》</Text>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishBallPage
|
||||
export default PublishBall
|
||||
|
||||
198
src/pages/publishBall/publishForm.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
|
||||
import Taro from '@tarojs/taro'
|
||||
import { CoverImageUpload, NTRPSlider, TimeSelector, TextareaTag, SelectStadium, ParticipantsControl, TitleInput, FormBasicInfo, AutoDegradeSwitch } from '../../components'
|
||||
import { type NTRPRange, type TimeRange, type Stadium, type ActivityType, type CoverImage } from '../../components/index.types'
|
||||
import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema'
|
||||
import './index.scss'
|
||||
|
||||
interface FormData {
|
||||
activityType: ActivityType
|
||||
title: string
|
||||
timeRange: TimeRange
|
||||
fee: string
|
||||
location: string
|
||||
gameplay: string
|
||||
minParticipants: number
|
||||
maxParticipants: number
|
||||
ntpLevel: NTRPRange
|
||||
TextareaTag: string
|
||||
autoDegrade: boolean
|
||||
}
|
||||
|
||||
// 组件映射器
|
||||
const componentMap = {
|
||||
[FieldType.TEXT]: TitleInput,
|
||||
[FieldType.TIMEINTERVAL]: TimeSelector,
|
||||
[FieldType.RANGE]: NTRPSlider,
|
||||
[FieldType.TEXTAREATAG]: TextareaTag,
|
||||
[FieldType.NUMBERINTERVAL]: ParticipantsControl,
|
||||
[FieldType.UPLOADIMAGE]: CoverImageUpload,
|
||||
[FieldType.ACTIVITYINFO]: FormBasicInfo,
|
||||
[FieldType.CHECKBOX]: AutoDegradeSwitch,
|
||||
}
|
||||
|
||||
const PublishForm: React.FC<{
|
||||
formData: FormData,
|
||||
onChange: (key: keyof FormData, value: any) => void,
|
||||
optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => {
|
||||
const [coverImages, setCoverImages] = useState<CoverImage[]>([])
|
||||
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
|
||||
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
|
||||
|
||||
// 处理封面图片变化
|
||||
const handleCoverImagesChange = (images: CoverImage[]) => {
|
||||
setCoverImages(images)
|
||||
}
|
||||
|
||||
// 更新表单数据
|
||||
const updateFormData = (key: keyof FormData, value: any) => {
|
||||
onChange(key, value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 获取人数要求显示文本
|
||||
const getParticipantsText = () => {
|
||||
return `最少${formData.minParticipants}人,最多${formData.maxParticipants}人`
|
||||
}
|
||||
|
||||
// 处理NTRP范围变化
|
||||
const handleNTRPChange = (range: NTRPRange) => {
|
||||
updateFormData('ntpLevel', range)
|
||||
}
|
||||
|
||||
// 处理时间范围变化
|
||||
const handleTimeRangeChange = (timeRange: TimeRange) => {
|
||||
updateFormData('timeRange', timeRange)
|
||||
}
|
||||
|
||||
// 处理补充要求变化
|
||||
const handleAdditionalRequirementsChange = (value: string) => {
|
||||
updateFormData('additionalRequirements', value)
|
||||
}
|
||||
|
||||
// 处理场馆选择
|
||||
const handleStadiumSelect = (stadium: Stadium | null) => {
|
||||
setSelectedStadium(stadium)
|
||||
if (stadium) {
|
||||
updateFormData('location', stadium.name)
|
||||
}
|
||||
setShowStadiumSelector(false)
|
||||
}
|
||||
|
||||
// 处理活动类型变化
|
||||
const handleActivityTypeChange = (type: ActivityType) => {
|
||||
updateFormData('activityType', type)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
// 基础验证
|
||||
if (!formData.title.trim()) {
|
||||
Taro.showToast({
|
||||
title: '请输入活动标题',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (coverImages.length === 0) {
|
||||
Taro.showToast({
|
||||
title: '请至少上传一张活动封面',
|
||||
icon: 'none'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: 实现提交逻辑
|
||||
console.log('提交数据:', { coverImages, formData })
|
||||
|
||||
Taro.showToast({
|
||||
title: '发布成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='publish-form'>
|
||||
<View className='publish-ball__content'>
|
||||
{
|
||||
optionsConfig.map((item) => {
|
||||
const Component = componentMap[item.type]
|
||||
const optionProps = {
|
||||
...item.props,
|
||||
...(item.key === 'additionalRequirements' ? { options: item.options } : {})
|
||||
}
|
||||
console.log(optionProps, item.label);
|
||||
if (item.type === FieldType.UPLOADIMAGE) {
|
||||
/* 活动封面 */
|
||||
return <CoverImageUpload
|
||||
images={coverImages}
|
||||
onChange={handleCoverImagesChange}
|
||||
{...item.props}
|
||||
/>
|
||||
}
|
||||
if (item.type === FieldType.ACTIVITYINFO) {
|
||||
return <>
|
||||
<View className='activity-description'>
|
||||
<Text className='description-text'>
|
||||
活动开始前2小时未达到最低人数,活动自动取消;活动
|
||||
结束后,报名者累计到达最低人数时,一旦到达期即有序。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 费用地点玩法区域 - 合并白色块 */}
|
||||
<View className='bg-section'>
|
||||
<FormBasicInfo
|
||||
fee={formData.fee}
|
||||
location={formData.location}
|
||||
gameplay={formData.gameplay}
|
||||
selectedStadium={selectedStadium}
|
||||
children={item.children || []}
|
||||
onFeeChange={(value) => updateFormData('fee', value)}
|
||||
onLocationChange={(value) => updateFormData('location', value)}
|
||||
onGameplayChange={(value) => updateFormData('gameplay', value)}
|
||||
onStadiumSelect={() => setShowStadiumSelector(true)}
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
}
|
||||
return (
|
||||
<View className='section-wrapper'>
|
||||
{
|
||||
item.label && <View className='section-title-wrapper' >
|
||||
<Text className='section-title'>{item.label}</Text>
|
||||
<Text className='section-summary'>最少1人,最多4人</Text>
|
||||
</View>
|
||||
}
|
||||
<View className='bg-section'>
|
||||
<Component
|
||||
value={formData[item.key]}
|
||||
onChange={(value) => updateFormData(item.key as keyof FormData, value)}
|
||||
{...optionProps}
|
||||
placeholder={item.placeholder}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
</View>
|
||||
|
||||
{/* 场馆选择弹窗 */}
|
||||
<SelectStadium
|
||||
visible={showStadiumSelector}
|
||||
onClose={() => setShowStadiumSelector(false)}
|
||||
onConfirm={handleStadiumSelect}
|
||||
/>
|
||||
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishForm
|
||||
30
src/scss/images.scss
Normal file
@@ -0,0 +1,30 @@
|
||||
// src/scss/images.scss
|
||||
// 暴露公共API (可选)
|
||||
@forward 'sass:map';
|
||||
@forward 'sass:meta';
|
||||
@use 'sass:map';
|
||||
|
||||
// 使用私有变量命名 (前缀加 -)
|
||||
$-static-path: '~@/static/' !default;
|
||||
|
||||
// 图片映射表
|
||||
$-images: (
|
||||
'icon-upload': '/publishBall/icon-upload.svg',
|
||||
'icon-add': '/publishBall/icon-add.svg',
|
||||
'icon-location': '/publishBall/icon-location.svg',
|
||||
'icon-gameplay': '/publishBall/icon-gameplay.svg',
|
||||
'icon-personal': '/publishBall/icon-personal.svg',
|
||||
'icon-changda': '/publishBall/icon-changda.svg',
|
||||
'icon-cost': '/publishBall/icon-cost.svg',
|
||||
'icon-remove': '/publishBall/icon-remove.svg'
|
||||
) !default;
|
||||
|
||||
// 图片获取函数
|
||||
@function taro-image($name) {
|
||||
@if not map.has-key($-images, $name) {
|
||||
@warn "Image `#{$name}` not found in $images map";
|
||||
@return url($-static-path + 'default.png');
|
||||
}
|
||||
@return url($-static-path + map.get($-images, $name));
|
||||
}
|
||||
|
||||
8
src/scss/themeColor.scss
Normal file
@@ -0,0 +1,8 @@
|
||||
$page-background-color: #FAFAFA;
|
||||
$primary-color: #000000;
|
||||
$primary-color-text: #FFFFFF;
|
||||
$primary-shallow-bg: rgb(245, 245, 245);
|
||||
$input-placeholder-color: rgba(60, 60, 67, 0.6);
|
||||
$textarea-placeholder-color: rgba(60, 60, 67, 0.3);
|
||||
$primary-background-color: rgba(0, 0, 0, 0.06);
|
||||
$primary-border-color: rgba(0, 0, 0, 0.16);
|
||||
3
src/static/publishBall/icon-add.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.5 4C13.5 3.44772 13.0523 3 12.5 3C11.9477 3 11.5 3.44772 11.5 4V11H4.5C3.94772 11 3.5 11.4477 3.5 12C3.5 12.5523 3.94772 13 4.5 13H11.5V20C11.5 20.5523 11.9477 21 12.5 21C13.0523 21 13.5 20.5523 13.5 20V13H20.5C21.0523 13 21.5 12.5523 21.5 12C21.5 11.4477 21.0523 11 20.5 11H13.5V4Z" fill="#3C3C43" fill-opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 436 B |
3
src/static/publishBall/icon-arrow-right.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.77774 2.98949C5.14223 2.625 5.73318 2.625 6.09767 2.98949L9.28329 6.17511C9.7389 6.63072 9.7389 7.36941 9.28329 7.82502L6.09767 11.0106C5.73318 11.3751 5.14223 11.3751 4.77774 11.0106C4.41325 10.6461 4.41325 10.0552 4.77774 9.6907L7.46838 7.00006L4.77774 4.30942C4.41325 3.94494 4.41325 3.35398 4.77774 2.98949Z" fill="#161823" fill-opacity="0.2"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 504 B |
21
src/static/publishBall/icon-changda.svg
Normal file
@@ -0,0 +1,21 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.2">
|
||||
<mask id="mask0_586_20634" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="1" y="2" width="18" height="16">
|
||||
<path d="M3.1509 4.88544C4.06659 3.22577 5.8122 2.19519 7.70772 2.19519H11.6591C13.5546 2.19519 15.3002 3.22577 16.2159 4.88544L17.6815 7.54183C18.5449 9.10671 18.5449 11.0052 17.6815 12.57L16.2159 15.2264C15.3002 16.8861 13.5546 17.9167 11.6591 17.9167H7.70772C5.8122 17.9167 4.06658 16.8861 3.1509 15.2264L1.68531 12.57C0.821926 11.0052 0.821927 9.10671 1.68531 7.54183L3.1509 4.88544Z" fill="#D9D9D9"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_586_20634)">
|
||||
<g filter="url(#filter0_f_586_20634)">
|
||||
<circle cx="16.5617" cy="9.78481" r="5.8549" fill="#0BBE61" fill-opacity="0.34"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M12.8129 17.7198H12.8036L7.82718 17.7042C7.2053 17.7017 6.59498 17.5359 6.05724 17.2236C5.51949 16.9112 5.07315 16.4632 4.76286 15.9243L2.28558 11.6088C1.97756 11.0697 1.81624 10.4592 1.8177 9.83824C1.81916 9.21729 1.98335 8.60758 2.29391 8.06987L4.792 3.76691C5.10437 3.23028 5.55178 2.78477 6.08975 2.4747C6.62771 2.16464 7.23748 2.00081 7.8584 1.99951H7.86777L12.8442 2.012C13.466 2.01462 14.0763 2.18037 14.614 2.49271C15.1518 2.80505 15.5981 3.25303 15.9085 3.79189L18.3858 8.10734C18.6946 8.64722 18.8563 9.25871 18.8546 9.88067C18.853 10.5026 18.6881 11.1133 18.3764 11.6515L15.8783 15.9555C15.5659 16.4916 15.1185 16.9366 14.5807 17.2461C14.0429 17.5556 13.4335 17.7189 12.8129 17.7198ZM7.8584 3.25168C7.45641 3.25272 7.06168 3.35896 6.71346 3.55982C6.36524 3.76068 6.07564 4.04918 5.87346 4.39664L3.37537 8.70064C3.17561 9.04916 3.0705 9.44388 3.0705 9.84559C3.0705 10.2473 3.17561 10.642 3.37537 10.9906L5.8464 15.3102C6.04817 15.6578 6.33753 15.9464 6.68563 16.1473C7.03372 16.3482 7.42839 16.4543 7.8303 16.4551L12.8067 16.4687C13.2099 16.4694 13.6062 16.364 13.9557 16.163C14.3053 15.9621 14.5957 15.6726 14.7979 15.3237L17.296 11.0207C17.4965 10.6727 17.6025 10.2782 17.6035 9.87647C17.6044 9.47476 17.5002 9.0798 17.3012 8.73082L14.8239 4.41433C14.6229 4.06531 14.3337 3.77518 13.9854 3.57297C13.6371 3.37076 13.2417 3.26356 12.839 3.26209L7.86257 3.2496L7.8584 3.25168Z" fill="#161823"/>
|
||||
<path d="M9.85676 12.836C9.70243 12.8346 9.55411 12.776 9.44041 12.6716L6.83824 10.2859C6.72076 10.1728 6.65216 10.0183 6.64709 9.85535C6.64202 9.69238 6.70089 9.5339 6.81112 9.41375C6.92134 9.2936 7.07418 9.22133 7.23698 9.21236C7.39979 9.2034 7.55964 9.25846 7.68238 9.36578L9.75268 11.2695L12.4308 7.44017C12.4773 7.37155 12.5369 7.31284 12.6062 7.26747C12.6755 7.22211 12.7532 7.19099 12.8347 7.17594C12.9161 7.16088 12.9998 7.16219 13.0808 7.17978C13.1617 7.19738 13.2384 7.2309 13.3062 7.27841C13.3741 7.32592 13.4319 7.38646 13.4761 7.4565C13.5204 7.52655 13.5502 7.6047 13.564 7.6864C13.5778 7.76811 13.5751 7.85173 13.5562 7.9324C13.5373 8.01307 13.5026 8.08918 13.454 8.15629L10.3678 12.5696C10.3168 12.6423 10.2509 12.7034 10.1744 12.7486C10.098 12.7938 10.0127 12.8222 9.92442 12.8319C9.90199 12.8348 9.87938 12.8362 9.85676 12.836Z" fill="#0BBE61"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_f_586_20634" x="5.50249" y="-1.27444" width="22.1185" height="22.1185" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.60218" result="effect1_foregroundBlur_586_20634"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
7
src/static/publishBall/icon-cost.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.00001 15.1666C11.6819 15.1666 14.6667 12.1819 14.6667 8.49998C14.6667 4.81808 11.6819 1.83331 8.00001 1.83331C4.31811 1.83331 1.33334 4.81808 1.33334 8.49998C1.33334 12.1819 4.31811 15.1666 8.00001 15.1666Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M6 7.83331H10" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M6 9.83331H10" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8.00278 7.83331V11.8333" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 5.5L8 7.5L6 5.5" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 848 B |
13
src/static/publishBall/icon-gameplay.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_586_20716)">
|
||||
<path d="M12.8374 3.67871C11.6155 2.53414 9.97294 1.83331 8.16668 1.83331C4.39274 1.83331 1.33334 4.89271 1.33334 8.66665C1.33334 12.4406 4.39274 15.5 8.16668 15.5C10.0383 15.5 11.7341 14.7475 12.9683 13.5287L8.00001 8.49998L12.8374 3.67871Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M13.3333 9.83335C14.0697 9.83335 14.6667 9.23639 14.6667 8.50002C14.6667 7.76365 14.0697 7.16669 13.3333 7.16669C12.597 7.16669 12 7.76365 12 8.50002C12 9.23639 12.597 9.83335 13.3333 9.83335Z" stroke="black" stroke-width="1.33333" stroke-linejoin="round"/>
|
||||
<path d="M5.66666 4.83331V7.49998" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M4.33334 6.16669H7.00001" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_586_20716">
|
||||
<rect width="16" height="16" fill="white" transform="translate(0 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
7
src/static/publishBall/icon-location.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.66668 4L1.33334 2V12L5.66668 14L10.3333 12L14.6667 14V4L10.3333 2L5.66668 4Z" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.3333 2V12" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M5.66666 4V14" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 3L5.66667 4L10.3333 2L12.5 3" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3.5 13L5.66667 14L10.3333 12L12.5 13" stroke="black" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 768 B |
19
src/static/publishBall/icon-personal.svg
Normal file
@@ -0,0 +1,19 @@
|
||||
<svg width="21" height="20" viewBox="0 0 21 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_586_20626)">
|
||||
<g filter="url(#filter0_f_586_20626)">
|
||||
<circle cx="15.125" cy="11.4583" r="5.625" fill="#0BBE61" fill-opacity="0.34"/>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M10.7901 16.8477C10.7901 15.0336 10.0679 13.2938 8.7824 12.0111C7.49688 10.7283 5.75333 10.0077 3.93532 10.0077C3.85764 10.0077 3.74156 10.0113 3.63737 10.0168C3.58619 10.0186 3.5414 10.0223 3.50941 10.0241L3.47102 10.0259H3.45823C3.38196 10.0331 3.30502 10.0249 3.23199 10.0018C3.15897 9.97869 3.09134 9.94116 3.03314 9.89145C2.97494 9.84174 2.92736 9.78086 2.89322 9.71243C2.85909 9.64399 2.83909 9.5694 2.83443 9.4931C2.82977 9.4168 2.84054 9.34034 2.8661 9.26828C2.89165 9.19621 2.93147 9.13001 2.98319 9.07362C3.03491 9.01722 3.09747 8.97178 3.16714 8.94001C3.23682 8.90823 3.31219 8.89076 3.38877 8.88864H3.4043L3.44543 8.8859L3.58253 8.87861C3.68855 8.87314 3.8293 8.86766 3.93532 8.86766C6.05633 8.86766 8.09047 9.70842 9.59025 11.205C11.09 12.7015 11.9326 14.7313 11.9326 16.8477C11.9316 16.9652 11.9276 17.0825 11.9207 17.1998L11.9134 17.332L11.9107 17.3712L11.9097 17.3822V17.3867C11.8991 17.5375 11.8288 17.678 11.7144 17.7771C11.6 17.8762 11.4508 17.9259 11.2997 17.9152C11.1485 17.9046 11.0078 17.8345 10.9085 17.7203C10.8092 17.6062 10.7594 17.4573 10.77 17.3065V17.3037V17.2946L10.7728 17.2591C10.7755 17.229 10.7773 17.1861 10.7801 17.1378C10.7856 17.0393 10.7901 16.928 10.7901 16.8477ZM9.64766 3.16761C9.64766 2.95602 9.6568 2.82196 9.67051 2.62588C9.67419 2.54981 9.6931 2.47525 9.72614 2.4066C9.75917 2.33795 9.80566 2.2766 9.86285 2.22618C9.92005 2.17575 9.98679 2.13728 10.0591 2.11302C10.1315 2.08876 10.208 2.07921 10.2841 2.08494C10.3602 2.09067 10.4343 2.11156 10.5022 2.14637C10.5701 2.18119 10.6303 2.22922 10.6793 2.28763C10.7282 2.34605 10.765 2.41366 10.7873 2.48648C10.8096 2.5593 10.8171 2.63585 10.8093 2.7116C10.7963 2.86326 10.7899 3.0154 10.7901 3.16761C10.7901 4.98171 11.5123 6.7215 12.7979 8.00427C14.0834 9.28703 15.8269 10.0077 17.6449 10.0077C17.8104 10.0077 17.9082 10.0013 18.0836 9.98944C18.2301 9.98665 18.3721 10.04 18.4803 10.1386C18.5885 10.2371 18.6547 10.3733 18.6652 10.5191C18.6758 10.665 18.6299 10.8092 18.537 10.9223C18.4441 11.0353 18.3113 11.1085 18.1659 11.1267C17.9926 11.1408 17.8188 11.1478 17.6449 11.1477C15.5239 11.1477 13.4898 10.3069 11.99 8.81038C10.4902 7.31382 9.64766 5.28406 9.64766 3.16761Z" fill="#0BBE61"/>
|
||||
<path d="M17.625 10C17.625 9.09717 17.4471 8.20317 17.1016 7.36906C16.7561 6.53494 16.2497 5.77705 15.6113 5.13865C14.9729 4.50024 14.215 3.99383 13.3809 3.64833C12.5468 3.30283 11.6528 3.125 10.75 3.125C9.84712 3.125 8.95312 3.30283 8.11901 3.64833C7.2849 3.99383 6.527 4.50024 5.8886 5.13865C5.2502 5.77705 4.74379 6.53494 4.39829 7.36906C4.05279 8.20317 3.87496 9.09717 3.87496 10C3.87496 11.8234 4.59929 13.5721 5.8886 14.8614C7.17791 16.1507 8.9266 16.875 10.75 16.875C12.5733 16.875 14.322 16.1507 15.6113 14.8614C16.9006 13.5721 17.625 11.8234 17.625 10ZM18.7708 10C18.7708 12.1273 17.9257 14.1674 16.4215 15.6716C14.9173 17.1758 12.8772 18.0208 10.75 18.0208C8.6227 18.0208 6.58257 17.1758 5.07837 15.6716C3.57418 14.1674 2.72913 12.1273 2.72913 10C2.72913 7.87275 3.57418 5.83262 5.07837 4.32842C6.58257 2.82422 8.6227 1.97917 10.75 1.97917C12.8772 1.97917 14.9173 2.82422 16.4215 4.32842C17.9257 5.83262 18.7708 7.87275 18.7708 10Z" fill="#161823"/>
|
||||
<defs>
|
||||
<filter id="filter0_f_586_20626" x="4.5" y="0.833328" width="21.25" height="21.25" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="BackgroundImageFix" result="shape"/>
|
||||
<feGaussianBlur stdDeviation="2.5" result="effect1_foregroundBlur_586_20626"/>
|
||||
</filter>
|
||||
<clipPath id="clip0_586_20626">
|
||||
<rect x="2.625" y="1.97917" width="16.0417" height="16.0417" rx="8.02083" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
3
src/static/publishBall/icon-remove.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.3333 10C18.3333 14.6024 14.6024 18.3333 10 18.3333C5.39763 18.3333 1.66667 14.6024 1.66667 10C1.66667 5.39763 5.39763 1.66667 10 1.66667C14.6024 1.66667 18.3333 5.39763 18.3333 10ZM13.5059 6.49408C13.1805 6.16865 12.6529 6.16865 12.3274 6.49408L10 8.82149L7.67259 6.49408C7.34716 6.16865 6.81952 6.16865 6.49408 6.49408C6.16865 6.81952 6.16865 7.34716 6.49408 7.67259L8.82149 10L6.49408 12.3274C6.16865 12.6529 6.16865 13.1805 6.49408 13.5059C6.81952 13.8314 7.34716 13.8314 7.67259 13.5059L10 11.1785L12.3274 13.5059C12.6529 13.8314 13.1805 13.8314 13.5059 13.5059C13.8314 13.1805 13.8314 12.6529 13.5059 12.3274L11.1785 10L13.5059 7.67259C13.8314 7.34716 13.8314 6.81952 13.5059 6.49408Z" fill="#161823" fill-opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 844 B |
5
src/static/publishBall/icon-tips.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6.49988 8.6C6.49988 8.87614 6.27602 9.1 5.99988 9.1C5.72374 9.1 5.49988 8.87614 5.49988 8.6V6.1C5.49988 5.82386 5.72374 5.6 5.99988 5.6C6.27602 5.6 6.49988 5.82386 6.49988 6.1V8.6Z" fill="black" fill-opacity="0.75"/>
|
||||
<path d="M5.99997 4C5.69622 4 5.44997 4.24624 5.44997 4.55C5.44997 4.85375 5.69622 5.1 5.99997 5.1C6.30373 5.1 6.54997 4.85375 6.54997 4.55C6.54997 4.24624 6.30373 4 5.99997 4Z" fill="black" fill-opacity="0.75"/>
|
||||
<path d="M6 11.5C8.76142 11.5 11 9.26142 11 6.5C11 3.73858 8.76142 1.5 6 1.5C3.23858 1.5 1 3.73858 1 6.5C1 9.26142 3.23858 11.5 6 11.5ZM6 10.5C3.79086 10.5 2 8.70914 2 6.5C2 4.29086 3.79086 2.5 6 2.5C8.20914 2.5 10 4.29086 10 6.5C10 8.70914 8.20914 10.5 6 10.5Z" fill="black" fill-opacity="0.75"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 841 B |
20
src/static/publishBall/icon-upload.svg
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
92
src/utils/locationUtils.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
export interface LocationInfo {
|
||||
latitude: number
|
||||
longitude: number
|
||||
address: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
export const getCurrentLocation = (): Promise<LocationInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.getLocation({
|
||||
type: 'wgs84',
|
||||
success: (res) => {
|
||||
// 使用逆地理编码获取地址信息
|
||||
reverseGeocode(res.latitude, res.longitude)
|
||||
.then(address => {
|
||||
resolve({
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
address
|
||||
})
|
||||
})
|
||||
.catch(() => {
|
||||
resolve({
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
address: `${res.latitude.toFixed(6)}, ${res.longitude.toFixed(6)}`
|
||||
})
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 选择地图位置
|
||||
export const chooseLocation = (): Promise<LocationInfo> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.chooseLocation({
|
||||
success: (res) => {
|
||||
resolve({
|
||||
latitude: res.latitude,
|
||||
longitude: res.longitude,
|
||||
address: res.address,
|
||||
name: res.name
|
||||
})
|
||||
},
|
||||
fail: (error) => {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 逆地理编码(简化版本,实际项目中应该调用真实的地图服务API)
|
||||
export const reverseGeocode = (latitude: number, longitude: number): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
// 这里应该调用真实的地图服务API,比如腾讯地图、高德地图等
|
||||
// 暂时返回坐标字符串
|
||||
setTimeout(() => {
|
||||
resolve(`纬度:${latitude.toFixed(6)}, 经度:${longitude.toFixed(6)}`)
|
||||
}, 500)
|
||||
})
|
||||
}
|
||||
|
||||
// 计算两点间距离(单位:米)
|
||||
export const calculateDistance = (
|
||||
lat1: number,
|
||||
lng1: number,
|
||||
lat2: number,
|
||||
lng2: number
|
||||
): number => {
|
||||
const radLat1 = lat1 * Math.PI / 180.0
|
||||
const radLat2 = lat2 * Math.PI / 180.0
|
||||
const a = radLat1 - radLat2
|
||||
const b = lng1 * Math.PI / 180.0 - lng2 * Math.PI / 180.0
|
||||
|
||||
let s = 2 * Math.asin(Math.sqrt(
|
||||
Math.pow(Math.sin(a/2), 2) +
|
||||
Math.cos(radLat1) * Math.cos(radLat2) *
|
||||
Math.pow(Math.sin(b/2), 2)
|
||||
))
|
||||
|
||||
s = s * 6378.137 // 地球半径
|
||||
s = Math.round(s * 10000) / 10000
|
||||
|
||||
return s * 1000 // 转换为米
|
||||
}
|
||||
@@ -20,7 +20,10 @@
|
||||
"resolveJsonModule": true,
|
||||
"typeRoots": [
|
||||
"node_modules/@types"
|
||||
]
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src", "./types"],
|
||||
"compileOnSave": false
|
||||
|
||||
12
yarn.lock
@@ -913,11 +913,16 @@
|
||||
dependencies:
|
||||
core-js-pure "^3.43.0"
|
||||
|
||||
"@babel/runtime@^7.21.5", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.6", "@babel/runtime@^7.8.7":
|
||||
"@babel/runtime@^7.21.5", "@babel/runtime@^7.24.4", "@babel/runtime@^7.7.6":
|
||||
version "7.28.2"
|
||||
resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.2.tgz#2ae5a9d51cc583bd1f5673b3bb70d6d819682473"
|
||||
integrity sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==
|
||||
|
||||
"@babel/runtime@^7.23.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7":
|
||||
version "7.28.3"
|
||||
resolved "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
|
||||
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
|
||||
|
||||
"@babel/template@^7.27.1", "@babel/template@^7.27.2":
|
||||
version "7.27.2"
|
||||
resolved "https://registry.npmmirror.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
||||
@@ -8283,6 +8288,11 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
|
||||
resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qqmap-wx-jssdk@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmmirror.com/qqmap-wx-jssdk/-/qqmap-wx-jssdk-1.0.0.tgz#8ab4b0d3aa900458217d6fbe52af832bb6c63c73"
|
||||
integrity sha512-wuaNetsA9/OKEQGgK1CNPsX6pppWpY10cQwQu1OHJplGMyMIMzK2bliMkNXjtry99qXYCsvDAWPqw2DI+/foJg==
|
||||
|
||||
qs@6.13.0:
|
||||
version "6.13.0"
|
||||
resolved "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"
|
||||
|
||||