feat: 分包

This commit is contained in:
2025-09-12 16:28:46 +08:00
parent 19701bd246
commit ee0b5763de
39 changed files with 75 additions and 64 deletions

View File

@@ -0,0 +1,92 @@
@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;
&.selected{
color: #000;
}
}
.arrow{
width: 16px;
height: 16px;
margin-left: 4px;
}
}
}
}
}

View File

@@ -0,0 +1,195 @@
import React, { useState, useCallback, useEffect } from 'react'
import { View, Text, Input, Image } from '@tarojs/components'
import PopupGameplay from '../PopupGameplay'
import img from '@/config/images';
import { FormFieldConfig } from '@/config/formSchema/publishBallFormSchema';
import SelectStadium from '../SelectStadium/SelectStadium'
import { Stadium } from '../SelectStadium/StadiumDetail'
import './FormBasicInfo.scss'
type PlayGame = {
play_type: string // 玩法类型
price: number | string // 价格
venue_id?: number | null // 场地id
location_name?: string // 场地名称
location?: string // 场地地址
latitude?: string // 纬度
longitude?: string // 经度
court_type?: string // 场地类型 1: 室内 2: 室外
court_surface?: string // 场地表面 1: 硬地 2: 红土 3: 草地
venue_description_tag?: Array<string>[] // 场地描述标签
venue_description?: string // 场地描述
venue_image_list?: Array<string>[] // 场地图片
}
interface FormBasicInfoProps {
value: PlayGame
onChange: (value: any) => void
children: FormFieldConfig[]
}
const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
value,
onChange,
children
}) => {
const [gameplayVisible, setGameplayVisible] = useState(false)
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
const [playGame, setPlayGame] = useState<{label: string, value: string }[]>([])
const handleGameplaySelect = () => {
setGameplayVisible(true)
}
const handleGameplayConfirm = (selectedGameplay: string) => {
onChange({...value, [children[2].prop]: selectedGameplay})
setGameplayVisible(false)
}
const handleGameplayClose = () => {
setGameplayVisible(false)
}
// 处理场馆选择
const handleStadiumSelect = (stadium: Stadium | null) => {
console.log(stadium,'stadiumstadium');
const { address, name, latitude, longitude, court_type, court_surface, description, description_tag, venue_image_list} = stadium || {};
onChange({...value,
venue_id: stadium?.id,
location_name: name,
location: address,
latitude,
longitude,
court_type,
court_surface,
venue_description: description,
venue_description_tag: description_tag,
venue_image_list
})
setShowStadiumSelector(false)
}
const handleChange = useCallback((key: string, costValue: any) => {
// 价格输入限制¥0.009999.99
console.log(costValue, 'valuevalue');
if (key === children[0]?.prop) {
// 允许清空
if (costValue === '') {
onChange({...value, [key]: ''});
return;
}
// 只允许数字和一个小数点
const filteredValue = costValue.replace(/[^\d.]/g, '');
// 确保只有一个小数点
const parts = filteredValue.split('.');
if (parts.length > 2) {
return; // 不更新,保持原值
}
// 限制小数点后最多2位
if (parts.length === 2 && parts[1].length > 2) {
return; // 不更新,保持原值
}
const numValue = parseFloat(filteredValue);
if (isNaN(numValue)) {
onChange({...value, [key]: ''});
return;
}
if (numValue < 0) {
onChange({...value, [key]: '0'});
return;
}
if (numValue > 9999.99) {
onChange({...value, [key]: '9999.99'});
return;
}
// 使用过滤后的值
onChange({...value, [key]: filteredValue});
return;
}
onChange({...value, [key]: costValue})
}, [onChange, children])
useEffect(() => {
if (children.length > 2) {
const options = children[2]?.options || [];
setPlayGame(options)
}
}, [children])
useEffect(() => {
console.log(value, 'valuevalue');
}, [value])
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'
maxlength={7}
value={value[child.prop]}
onInput={(e) => handleChange(child.prop, 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={() => setShowStadiumSelector(true)}>
<Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.prop] ? value[child.prop] : '请选择'}
</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' onClick={handleGameplaySelect}>
<Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.prop] ? value[child.prop] : '请选择'}
</Text>
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
</View>
</View>)
}
</View>
})
}
return (
<View className='form-basic-info'>
{/* 费用 */}
{renderChildren()}
{/* 玩法选择弹窗 */}
<PopupGameplay
visible={gameplayVisible}
onClose={handleGameplayClose}
onConfirm={handleGameplayConfirm}
value={value[children[2].prop]}
options={playGame}
/>
{/* 场馆选择弹窗 */}
<SelectStadium
visible={showStadiumSelector}
onClose={() => setShowStadiumSelector(false)}
onConfirm={handleStadiumSelect}
/>
</View>
)
}
export default FormBasicInfo

View File

@@ -0,0 +1 @@
export { default } from './FormBasicInfo'

View File

@@ -0,0 +1,34 @@
.optionsList {
padding: 26px 15px 16px 15px;
display: flex;
flex-direction: column;
gap: 6px;
background-color: #fff;
}
.optionItem {
display: flex;
min-height: 40px;
justify-content: center;
align-items: center;
flex: 1 0 0;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: #FFF;
}
.optionItem.selected {
border: 0.5px solid rgba(0, 0, 0, 0.06);
color: #fff;
font-size: 14px;
background: #000;
.optionText {
color: #fff;
}
}
.optionText {
font-size: 14px;
color: #333;
}

View File

@@ -0,0 +1,58 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { CommonPopup } from '@/components'
import styles from './PopupGameplay.module.scss'
interface PopupGameplayProps {
visible: boolean
onClose: () => void
onConfirm: (value: string) => void
value?: string
options?: { label: string, value: string }[]
}
export default function PopupGameplay({ visible, onClose, onConfirm, value = '不限', options = [] }: PopupGameplayProps) {
const [selectedOption, setSelectedOption] = useState(value)
useEffect(() => {
if (visible && value) {
setSelectedOption(value)
}
}, [visible, value])
const handleOptionSelect = (option: string) => {
setSelectedOption(option)
}
const handleClose = () => {
onClose()
}
const handleConfirm = () => {
onConfirm(selectedOption)
}
return (
<CommonPopup
visible={visible}
onClose={handleClose}
showHeader={false}
onConfirm={handleConfirm}
confirmText='确定'
cancelText='取消'
>
<View className={styles.optionsList}>
{options.map((option) => (
<View
key={option.value}
className={`${styles.optionItem} ${selectedOption === option.value ? styles.selected : ''}`}
onClick={() => handleOptionSelect(option.value)}
>
<Text className={styles.optionText}>{option.label}</Text>
</View>
))}
</View>
</CommonPopup>
)
}

View File

@@ -0,0 +1 @@
export { default } from './PopupGameplay'

View 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. **数据同步**:地图选择的球场会自动添加到球馆列表

View 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 组件现在只包含场地配置选项,去掉了头部、提醒和活动封面部分

View File

@@ -0,0 +1,262 @@
.select-stadium {
width: 100%;
height: calc(100vh - 10px);
background: #f5f5f5;
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: env(safe-area-inset-bottom);
// 搜索区域
.search-section {
background: #f5f5f5;
padding: 26px 15px 0px 15px;
.search-wrapper {
display: flex;
align-items: center;
gap: 8px;
.search-bar {
font-size: 16px;
display: flex;
height: 44px;
padding: 0 12px;
align-items: center;
gap: 10px;
flex: 1;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
background: #FFF;
box-shadow: 0 4px 48px 0 rgba(0, 0, 0, 0.08);
.search-icon {
width: 20px;
height: 20px;
}
.clear-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
cursor: pointer;
&:active {
background: rgba(0, 0, 0, 0.04);
}
.clear-icon {
width: 20px;
height: 20px;
}
}
}
.search-input {
flex: 1;
font-size: 16px;
color: #333;
border: none;
outline: none;
background: transparent;
.search-placeholder{
color: rgba(60, 60, 67, 0.60);
}
}
.map-btn {
display: flex;
height: 44px;
padding: 0 12px;
align-items: center;
gap: 2px;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
background: #FFF;
box-shadow: 0 4px 48px 0 rgba(0, 0, 0, 0.08);
box-sizing: border-box;
&:active {
background: #e0f0ff;
}
.map-icon {
width: 20px;
height: 20px;
}
.map-text {
font-size: 16px;
color: #000;
}
}
}
}
// 热门球场区域
.hot-section {
padding: 23px 20px 10px 20px;
.hot-header {
display: flex;
align-items: center;
.hot-title {
font-size: 16px;
font-weight: 600;
color: #000;
}
.hot-stadium-line{
height: 6px;
width: 1px;
background: rgba(22, 24, 35, 0.12);
margin: 0 12px;;
}
.booking-section {
display: flex;
align-items: center;
color: rgba(0, 0, 0, 0.50);
gap: 4px;
.booking-title {
font-size: 15px;
}
.booking-status {
display: flex;
padding: 2px 5px;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.16);
background: #FFF;
}
}
}
}
.stadium-item-loading{
display: flex;
justify-content: center;
align-items: center;
height: 100%;
.loading-icon{
color: #666;
font-size: 30px;
.nut-loading-icon{
width: 20px;
height: 20px;
}
.nut-loading-text{
font-size: 14px;
color: #666;
}
}
}
// 场馆列表
.stadium-list {
flex: 1;
width: auto;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
.stadium-item {
padding: 16px 20px;
display: flex;
align-items: center;
position: relative;
gap: 12px;
.stadium-item-left{
display: flex;
padding: 14px;
justify-content: center;
align-items: center;
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.06);
.stadium-icon{
width: 20px;
height: 20px;
}
}
.stadium-item-right{
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.stadium-name{
font-size: 16px;
color: #000;
font-weight: 600;
line-height: 24px;
display: flex;
.highlight-text {
color: #007AFF;
font-weight: 700;
}
}
.stadium-address{
display: flex;
align-items: center;
gap: 4px;
color: rgba(0, 0, 0, 0.80);
font-size: 12px;
}
.stadium-map-icon{
width: 10px;
height: 10px;
}
}
}
}
// 底部按钮区域
.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;
}

View File

@@ -0,0 +1,306 @@
import React, { useState, useRef, useEffect } from 'react'
import { View, Text, Input, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Loading } from '@nutui/nutui-react-taro'
import StadiumDetail, { StadiumDetailRef } from './StadiumDetail'
import { CommonPopup } from '@/components'
import { getLocation } from '@/utils/locationUtils'
import PublishService from '@/services/publishService'
import images from '@/config/images'
import './SelectStadium.scss'
export interface Stadium {
id?: string
name: string
address?: string
distance_km?: number | null | undefined
longitude?: number
latitude?: number
}
interface SelectStadiumProps {
visible: boolean
onClose: () => void
onConfirm: (stadium: Stadium | null) => void
}
const SelectStadium: React.FC<SelectStadiumProps> = ({
visible,
onClose,
onConfirm
}) => {
const [searchValue, setSearchValue] = useState('')
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
const [showDetail, setShowDetail] = useState(false)
const stadiumDetailRef = useRef<StadiumDetailRef>(null)
const [stadiumList, setStadiumList] = useState<Stadium[]>([])
const [loading, setLoading] = useState(false)
const initData = async () => {
setLoading(true)
try {
const location = await getLocation()
if (location.latitude && location.longitude) {
const res = await PublishService.getStadiumList({
seachOption: {
latitude: location.latitude,
longitude: location.longitude
}
})
if (res.code === 0 && res.data) {
setStadiumList(res.data.rows || [])
}
}
} catch (error) {
console.error('获取场馆列表失败:', error)
} finally {
setLoading(false)
}
}
useEffect(() => {
if (visible) {
initData()
}
}, [visible])
if (!visible) return null
// 过滤场馆列表
const filteredStadiums = stadiumList.filter(stadium =>
stadium.name.toLowerCase().includes(searchValue.toLowerCase())
)
// 处理场馆选择
const handleStadiumSelect = (stadium: Stadium) => {
setSelectedStadium(stadium)
setShowDetail(true)
}
const calculateDistance = (stadium: Stadium) => {
const distance_km = stadium.distance_km
if (!distance_km) return ''
if (distance_km && distance_km > 1) {
return distance_km.toFixed(1) + 'km'
}
return (distance_km * 1000).toFixed(0) + 'm'
}
// 处理搜索框输入
const handleSearchInput = (e: any) => {
setSearchValue(e.detail.value)
}
// 处理地图选择位置
const handleMapLocation = () => {
Taro.chooseLocation({
success: (res) => {
setSelectedStadium({
name: res.name,
address: res.address,
longitude: res.longitude,
latitude: res.latitude
})
setShowDetail(true)
},
fail: (err) => {
console.error('选择位置失败:', err)
}
})
}
// 处理确认
const handleConfirm = () => {
if (stadiumDetailRef.current) {
const formData = stadiumDetailRef.current.getFormData()
console.log('获取球馆表单数据:', formData)
const { description, ...rest } = formData
onConfirm({ ...rest, ...description })
setSelectedStadium(null)
setSearchValue('')
}
}
// 处理取消
const handleCancel = () => {
onClose()
setShowDetail(false)
setSelectedStadium(null)
setSearchValue('')
}
const handleItemLocation = (stadium: Stadium) => {
if (stadium.latitude && stadium.longitude) {
Taro.openLocation({
latitude: stadium.latitude,
longitude: stadium.longitude,
name: stadium.name,
address: stadium.address,
success: (res) => {
console.log(res, 'resres')
}
})
}
}
const markSearchText = (text: string) => {
if (!searchValue) return text
return text.replace(
new RegExp(searchValue, 'gi'),
`<span class="highlight-text">${searchValue}</span>`
)
}
// 如果显示详情页面
if (showDetail && selectedStadium) {
return (
<CommonPopup
visible={visible}
onClose={handleCancel}
cancelText="返回"
confirmText="确认"
className="select-stadium-popup"
onCancel={handleCancel}
onConfirm={handleConfirm}
position="bottom"
round
>
<StadiumDetail
ref={stadiumDetailRef}
stadium={selectedStadium}
/>
</CommonPopup>
)
}
// 显示球馆列表
return (
<CommonPopup
visible={visible}
hideFooter
onClose={handleCancel}
cancelText="返回"
confirmText="完成"
className="select-stadium-popup"
position="bottom"
round
>
<View className='select-stadium'>
{/* 搜索框 */}
<View className='search-section'>
<View className='search-wrapper'>
<View className='search-bar'>
<Image src={images.ICON_SEARCH} className='search-icon' />
<Input
className='search-input'
placeholder='搜索'
placeholderClass='search-placeholder'
value={searchValue}
onInput={handleSearchInput}
/>
{searchValue && (
<View className='clear-btn' onClick={() => setSearchValue('')}>
<Image src={images.ICON_REMOVE} className='clear-icon' />
</View>
)}
</View>
{
!searchValue && (
<View className='map-btn' onClick={handleMapLocation}>
<Image src={images.ICON_MAP} className='map-icon' />
<Text className='map-text'></Text>
</View>
)
}
</View>
</View>
{/* 热门球场标题 */}
<View className='hot-section'>
<View className='hot-header'>
<Text className='hot-title'></Text>
<View className='hot-stadium-line'></View>
<View className='booking-section'>
<Text className='booking-title'></Text>
<Text className='booking-status'></Text>
</View>
</View>
</View>
{
loading ? (
<View className='stadium-item-loading'>
<Loading type="circular" className='loading-icon'></Loading>
</View>
) : (
<ScrollView className='stadium-list' scrollY>
{filteredStadiums.map((stadium) => (
<View
key={stadium.id}
className={`stadium-item ${selectedStadium?.id === stadium.id ? 'selected' : ''}`}
onClick={() => handleStadiumSelect(stadium)}
>
<View className='stadium-item-left'>
<Image src={images.ICON_STADIUM} className='stadium-icon' />
</View>
<View className='stadium-item-right'>
<View className='stadium-name' dangerouslySetInnerHTML={{ __html: markSearchText(stadium.name) }}></View>
<View className='stadium-address'>
<Text
className='stadium-distance'
onClick={(e) => {
e.stopPropagation()
handleItemLocation(stadium)
}}
>
{calculateDistance(stadium)} ·
</Text>
<Text
className='stadium-address-text'
onClick={(e) => {
e.stopPropagation()
handleItemLocation(stadium)
}}
>
{stadium.address}
</Text>
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View>
</View>
</View>
))}
{searchValue && (
<View className='stadium-item map-search-item' onClick={handleMapLocation}>
<View className='stadium-item-left'>
<Image src={images.ICON_MAP_SEARCH} className='stadium-icon' />
</View>
<View className='stadium-item-right'>
<View className='stadium-name'></View>
<View className='stadium-address'>
<Text className='map-search-text'></Text>
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View>
</View>
</View>
)}
</ScrollView>
)
}
{/* 场馆列表 */}
</View>
</CommonPopup>
)
}
export default SelectStadium

View File

@@ -0,0 +1,192 @@
.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);
// 已选球场
// 场馆列表
.stadium-item {
padding: 32px 20px 16px 20px;
display: flex;
align-items: center;
position: relative;
gap: 12px;
.stadium-item-left{
display: flex;
padding: 14px;
justify-content: center;
align-items: center;
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.06);
.stadium-icon{
width: 20px;
height: 20px;
}
}
.stadium-item-right{
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
.stadium-name{
font-size: 16px;
color: #000;
font-weight: 600;
line-height: 24px;
display: flex;
}
.stadium-address{
display: flex;
align-items: center;
gap: 4px;
color: rgba(0, 0, 0, 0.80);
font-size: 12px;
}
.stadium-map-icon{
width: 10px;
height: 10px;
}
}
}
// 场地类型
.venue-type-section {
flex-shrink: 0;
.section-title {
padding: 18px 20px 10px 20px;
font-size: 14px;
font-weight: 600;
color: #333;
display: block;
display: flex;
align-items: center;
gap: 6px;
.heart-wrapper{
position: relative;
display: flex;
align-items: center;
.heart-icon{
width: 22px;
height: 22px;
z-index: 1;
}
.icon-bg{
border-radius: 1.6px;
width: 165px;
height: 17px;
flex-shrink: 0;
border: 0.5px solid rgba(238, 255, 135, 0.00);
opacity: 0.4;
background: linear-gradient(258deg, rgba(220, 250, 97, 0.00) 6.85%, rgba(228, 255, 59, 0.82) 91.69%);
backdrop-filter: blur(1.25px);
position: absolute;
top: 2px;
left: 4px;
}
.heart-text{
font-size: 12px;
color: rgba(0, 0, 0, 0.90);
z-index: 2;
font-weight: normal;
}
}
}
.option-buttons {
display: flex;
gap: 16px;
padding: 0 15px;
.textarea-tag-container{
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: #FFF;
box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06);
}
.option-btn {
border-radius: 20px;
border: 1px solid #e0e0e0;
background: white;
cursor: pointer;
display: flex;
flex: 1;
justify-content: center;
align-items: center;
height: 40px;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: #FFF;
font-weight: 500;
&.selected {
background: #000;
border-color: #fff;
border-radius: 999px;
font-weight: 600;
border: 0.5px solid rgba(0, 0, 0, 0.06);
.option-text {
color: white;
}
}
.option-text {
font-size: 14px;
color: #333;
}
}
}
}
// 底部按钮
.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;
}
}
}
}
}

View File

@@ -0,0 +1,247 @@
import React, { useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import Taro from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components'
import images from '@/config/images'
import TextareaTag from '@/components/TextareaTag'
// import CoverImageUpload, { type CoverImage } from '@/components/ImageUpload'
import UploadCover, { type CoverImageValue } from '@/components/UploadCover'
import { useDictionaryActions } from '@/store/dictionaryStore'
import './StadiumDetail.scss'
export interface Stadium {
id?: string
name: string
address?: string
longitude?: number
latitude?: number
distance_km?: number | null
court_type?: string
court_surface?: string
description?: string
description_tag?: string[]
venue_image_list?: CoverImageValue[]
}
interface StadiumDetailProps {
stadium: Stadium
}
// 定义暴露给父组件的方法接口
export interface StadiumDetailRef {
getFormData: () => any
setFormData: (data: any) => void
}
// 公共的标题组件
const SectionTitle: React.FC<{ title: string,prop: string }> = ({ title, prop }) => {
if (prop === 'venue_image_list') {
return (
<View className='section-title'>
<Text>{title}</Text>
<View className='heart-wrapper'>
<Image src={images.ICON_HEART_CIRCLE} className='heart-icon' />
<View className='icon-bg'></View>
<Text className='heart-text'></Text>
</View>
</View>
)
}
return (
<Text className='section-title'>{title}</Text>
)
}
// 公共的容器组件
const SectionContainer: React.FC<{ title: string; children: React.ReactNode, prop: string }> = ({ title, children, prop }) => (
<View className='venue-type-section'>
<SectionTitle title={title} prop={prop}/>
<View className='option-buttons'>
{children}
</View>
</View>
)
const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
stadium,
}, ref) => {
const { getDictionaryValue } = useDictionaryActions()
const court_type = getDictionaryValue('court_type') || []
const court_surface = getDictionaryValue('court_surface') || []
const supplementary_information = getDictionaryValue('supplementary_information') || []
const stadiumInfo = [
{
label: '场地类型',
options: court_type,
prop: 'court_type',
type: 'tags'
},
{
label: '地面材质',
options: court_surface,
prop: 'court_surface',
type: 'tags'
},
{
label: '场地信息补充',
options: supplementary_information,
prop: 'description',
type: 'textareaTag'
},
{
label: '场地预定截图',
options: ['有其他场地信息可备注'],
prop: 'venue_image_list',
type: 'image'
}
]
const [formData, setFormData] = useState({
name: stadium.name,
address: stadium.address,
latitude: stadium.latitude,
longitude: stadium.longitude,
istance: stadium.distance_km,
court_type: court_type[0] || '',
court_surface: court_surface[0] || '',
additionalInfo: '',
venue_image_list: [] as CoverImageValue[],
description:{
description: '',
description_tag: []
}
})
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
getFormData: () => formData,
setFormData: (data: any) => setFormData(data)
}), [formData, stadium])
const calculateDistance = (distance_km: number | null) => {
if (!distance_km) return ''
if (distance_km && distance_km > 1) {
return distance_km.toFixed(1) + 'km'
}
return (distance_km * 1000).toFixed(0) + 'm'
}
const handleMapLocation = () => {
Taro.chooseLocation({
success: (res) => {
console.log(res,'resres');
setFormData({
...formData,
name: res.name,
address: res.address,
latitude: res.longitude,
longitude: res.latitude,
istance: null
})
},
fail: (err) => {
console.error('选择位置失败:', err)
Taro.showToast({
title: '位置选择失败',
icon: 'error'
})
}
})
}
const updateFormData = useCallback((prop: string, value: any) => {
setFormData(prev => ({ ...prev, [prop]: value }))
}, [])
const getSelectedByLabel = useCallback((label: string) => {
if (label === '场地类型') return formData.court_type
if (label === '地面材质') return formData.court_surface
return ''
}, [formData.court_type, formData.court_surface])
console.log(stadium,'stadiumstadium');
return (
<View className='stadium-detail'>
{/* 已选球场 */}
<View
className={`stadium-item`}
onClick={() => handleMapLocation()}
>
<View className='stadium-item-left'>
<Image src={images.ICON_STADIUM} className='stadium-icon' />
</View>
<View className='stadium-item-right'>
<View className='stadium-name'>{formData.name}</View>
<View className='stadium-address'>
<Text>{calculateDistance(formData.istance || null)} · </Text>
<Text>{formData.address}</Text>
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View>
</View>
</View>
{stadiumInfo.map((item) => {
if (item.type === 'tags') {
const selected = getSelectedByLabel(item.label)
return (
<SectionContainer key={item.label} title={item.label} prop={item.prop}>
{item.options.map((opt) => (
<View
key={opt}
className={`option-btn ${selected === opt ? 'selected' : ''}`}
onClick={() => updateFormData(item.prop, opt)}
>
<Text className='option-text'>{opt}</Text>
</View>
))}
</SectionContainer>
)
}
if (item.type === 'textareaTag') {
return (
<SectionContainer key={item.label} title={item.label} prop={item.prop}>
<View className='textarea-tag-container'>
<TextareaTag
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop, value)}
placeholder='有其他场地信息可备注'
options={(item.options || []).map((o) => ({ label: o, value: o }))}
/>
</View>
</SectionContainer>
)
}
if (item.type === 'image') {
return (
<SectionContainer key={item.label} title={item.label} prop={item.prop}>
<UploadCover
value={formData[item.prop]}
onChange={(value) => {
console.log(value, 'value')
if (value instanceof Function) {
const newValue = value(formData[item.prop])
console.log(newValue, 'newValue')
updateFormData(item.prop, newValue)
} else {
updateFormData(item.prop, value)
}
}}
maxCount={9}
source={['album', 'history']}
align='left'
tag="screenshot"
/>
</SectionContainer>
)
}
return null
})}
</View>
)
})
export default StadiumDetail

View File

@@ -0,0 +1,3 @@
export { default as SelectStadium } from './SelectStadium'
export { default as StadiumDetail } from './StadiumDetail'
export type { Stadium } from './SelectStadium'

View File

@@ -0,0 +1,68 @@
import React, { useCallback, useState } from 'react'
import { View, Text, Input } from '@tarojs/components'
import { Checkbox } from '@nutui/nutui-react-taro'
import styles from './index.module.scss'
interface FormSwitchProps {
value: boolean
onChange: (checked: boolean) => void
subTitle: string
wechatId?: string
}
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wechatId }) => {
const [editWechat, setEditWechat] = useState(false)
const [wechatIdValue, setWechatIdValue] = useState('')
const [wechat, setWechat] = useState(wechatId)
const editWechatId = () => {
setEditWechat(true)
}
const setWechatId = useCallback((e: any) => {
const value = e.target.value
onChange && onChange(value)
setWechatIdValue(value)
}, [onChange])
const fillWithPhone = () => {
if (wechat) {
setWechatIdValue(wechat)
}
}
return (
<>
<View className={styles['wechat-contact-section']}>
<View className={styles['wechat-contact-item']}>
<Checkbox
className={styles['wechat-contact-checkbox']}
checked={value}
onChange={onChange}
/>
<View className={styles['wechat-contact-content']}>
<Text className={styles['wechat-contact-text']}>{subTitle}</Text>
</View>
</View>
{
!editWechat && wechatId && (
<View className={styles['wechat-contact-id']}>
<Text className={styles['wechat-contact-text']}>: {wechatId.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}</Text>
<View className={styles['wechat-contact-edit']} onClick={editWechatId}></View>
</View>
)
}
{
editWechat && (
<View className={styles['wechat-contact-id']}>
<View className={styles['wechat-contact-edit-input']}>
<Input value={wechatIdValue} onInput={setWechatId} placeholder='请输入正确微信号' />
</View>
<View className={styles['wechat-contact-edit']} onClick={fillWithPhone}>{wechat}</View>
</View>
)
}
</View>
</>
)
}
export default FormSwitch

View File

@@ -0,0 +1,100 @@
@use '~@/scss/themeColor.scss' as theme;
.wechat-contact-section {
background: #fff;
border-radius: 12px;
padding: 10px 12px;
width: 100%;
box-sizing: border-box;
.wechat-contact-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
.wechat-contact-content {
display: flex;
align-items: center;
.wechat-contact-text {
font-size: 14px;
color: #333;
font-weight: 500;
}
.info-icon {
display: flex;
align-items: center;
justify-content: center;
padding-left: 4px;
position: relative;
.info-img{
width: 12px;
height: 12px;
}
.info-popover {
position: absolute;
bottom: 22px;
left: -65px;
width: 130px;
padding:12px;
background: rgba(57, 59, 68, 0.90);
color: #fff;
border-radius: 8px;
font-size: 12px;
line-height: 1.6;
z-index: 1001;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.info-popover::before {
content: '';
position: absolute;
bottom: -6px;
left: 68px; /* 对齐图标宽12px可按需微调 */
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(57, 59, 68, 0.90);
}
}
}
.wechat-contact-checkbox {
width: 18px;
height: 18px;
}
}
.wechat-contact-id {
display: flex;
align-items: center;
gap: 6px;
padding-top: 4px;
.wechat-contact-text {
font-size: 12px;
color: #000;
font-weight: normal;
line-height: 24px;
}
.wechat-contact-edit {
font-size: 12px;
color: #000;
display: flex;
padding: 2px 6px;
align-items: center;
font-weight: normal;
border-radius: 100px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
}
.wechat-contact-edit-input {
max-width: 200px;
font-size: 12px;
.input-placeholder{
color: theme.$textarea-placeholder-color;
}
}
}
}

View File

@@ -0,0 +1 @@
export { default } from './WechatSwitch'

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '发布',
navigationBarBackgroundColor: '#FAFAFA'
})

View File

@@ -0,0 +1,257 @@
@use '~@/scss/themeColor.scss' as theme;
.publish-ball {
min-height: 100vh;
background: theme.$page-background-color;
position: relative;
&__scroll {
height: calc(100vh - 120px);
overflow: auto;
padding: 4px 16px 72px 16px;
box-sizing: border-box;
}
&__content {
}
&__add{
margin-top: 2px;
border-radius: 12px;
border: 2px dashed rgba(22, 24, 35, 0.12);
display: flex;
width: 343px;
height: 60px;
justify-content: center;
align-items: center;
gap: 4px;
color: rgba(60, 60, 67, 0.50);
font-size: 14px;
&-icon{
width: 16px;
height: 16px;
}
}
.activity-type-switch{
padding: 4px 16px 0 16px;
}
// 场次标题行
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22px 4px;
.session-title {
font-size: 16px;
font-weight: 600;
color: theme.$primary-color;
display: flex;
align-items: center;
gap: 2px;
}
.session-delete {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
&-icon {
width: 16px;
height: 16px;
}
}
.session-actions {
display: flex;
gap: 12px;
}
.session-action-btn {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.16);
background: #000;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
backdrop-filter: blur(16px);
display: flex;
padding: 5px 8px;
justify-content: center;
align-items: center;
gap: 12px;
color: white;
font-size: 12px;
font-weight: 600;
.action-icon {
width: 14px;
height: 14px;
}
}
}
// 标题区域 - 独立白色块
.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;
.ntrp-range{
:global(.rangeContent){
border: none!important;
}
}
}
// 活动描述文本 - 灰色背景
.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-summary {
font-size: 14px;
color: theme.$input-placeholder-color;
white-space: nowrap;
}
}
// 提交区域
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
.submit-btn {
width: 100%;
color: white;
font-size: 16px;
font-weight: 600;
height: 52px;
line-height: 52px;
padding: 2px 6px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: #000;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
&.submit-btn-disabled {
color: rgba(255, 255, 255, 0.30);
}
}
.submit-tip {
text-align: center;
font-size: 12px;
color: #999;
line-height: 1.4;
display: flex;
justify-content: center;
padding: 12px 0;
align-items: center;
.link {
color: #007AFF;
}
}
.submit-checkbox {
width: 11px;
height: 11px;
:global(.nut-icon-Checked){
background: rgba(22, 24, 35, 0.75)!important;
}
}
}
// 加载状态遮罩保持原样
&__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);
}
}

View File

@@ -0,0 +1,531 @@
import React, { useState, useEffect } from "react";
import { View, Text, Button, Image } from "@tarojs/components";
import { Checkbox } from "@nutui/nutui-react-taro";
import Taro from "@tarojs/taro";
import { type ActivityType } from "@/components/ActivityTypeSwitch";
import CommonDialog from "@/components/CommonDialog";
import { withAuth } from "@/components";
import PublishForm from "./publishForm";
import {
FormFieldConfig,
publishBallFormSchema,
} from "@/config/formSchema/publishBallFormSchema";
import { PublishBallFormData } from "../../../types/publishBall";
import PublishService from "@/services/publishService";
import { getNextHourTime, getEndTime, delay } from "@/utils";
import images from "@/config/images";
import styles from "./index.module.scss";
import dayjs from "dayjs";
const defaultFormData: PublishBallFormData = {
title: "",
image_list: [],
timeRange: {
start_time: getNextHourTime(),
end_time: getEndTime(getNextHourTime()),
},
activityInfo: {
play_type: "不限",
price: "",
venue_id: null,
location_name: "",
location: "",
latitude: "",
longitude: "",
court_type: "",
court_surface: "",
venue_description_tag: [],
venue_description: "",
venue_image_list: [],
},
players: [1, 1],
skill_level: [1.0, 5.0],
descriptionInfo: {
description: "",
description_tag: [],
},
is_substitute_supported: true,
is_wechat_contact: true,
wechat_contact: "14223332214",
};
const PublishBall: React.FC = () => {
const [activityType, setActivityType] = useState<ActivityType>("individual");
const [isSubmitDisabled, setIsSubmitDisabled] = useState(false);
// 获取页面参数并设置导航标题
const [optionsConfig, setOptionsConfig] = useState<FormFieldConfig[]>(
publishBallFormSchema,
);
const [formData, setFormData] = useState<PublishBallFormData[]>([
defaultFormData,
]);
const [checked, setChecked] = useState(true);
// 删除确认弹窗状态
const [deleteConfirm, setDeleteConfirm] = useState<{
visible: boolean;
index: number;
}>({
visible: false,
index: -1,
});
// 更新表单数据
const updateFormData = (
key: keyof PublishBallFormData,
value: any,
index: number,
) => {
console.log(key, value, index, "key, value, index");
setFormData((prev) => {
const newData = [...prev];
newData[index] = { ...newData[index], [key]: value };
console.log(newData, "newData");
return newData;
});
};
// 处理活动类型变化
const handleActivityTypeChange = (type: ActivityType) => {
if (type === "group") {
setFormData([defaultFormData]);
} else {
setFormData([defaultFormData]);
}
};
// 检查相邻两组数据是否相同
const checkAdjacentDataSame = (formDataArray: PublishBallFormData[]) => {
if (formDataArray.length < 2) return false;
const lastIndex = formDataArray.length - 1;
const secondLastIndex = formDataArray.length - 2;
const lastData = formDataArray[lastIndex];
const secondLastData = formDataArray[secondLastIndex];
// 比较关键字段是否相同
return JSON.stringify(lastData) === JSON.stringify(secondLastData);
};
const handleAdd = () => {
// 检查最后两组数据是否相同
if (checkAdjacentDataSame(formData)) {
Taro.showToast({
title: "信息不可与前序场完全一致",
icon: "none",
});
return;
}
const newStartTime = getNextHourTime();
setFormData((prev) => [
...prev,
{
...defaultFormData,
title: "",
timeRange: {
start_time: newStartTime,
end_time: getEndTime(newStartTime),
},
},
]);
};
// 复制上一场数据
const handleCopyPrevious = (index: number) => {
if (index > 0) {
setFormData((prev) => {
const newData = [...prev];
newData[index] = { ...newData[index - 1] };
return newData;
});
Taro.showToast({
title: "复制上一场填入",
icon: "success",
});
}
};
// 删除确认弹窗
const showDeleteConfirm = (index: number) => {
setDeleteConfirm({
visible: true,
index,
});
};
// 关闭删除确认弹窗
const closeDeleteConfirm = () => {
setDeleteConfirm({
visible: false,
index: -1,
});
};
// 确认删除
const confirmDelete = () => {
if (deleteConfirm.index >= 0) {
setFormData((prev) =>
prev.filter((_, index) => index !== deleteConfirm.index),
);
closeDeleteConfirm();
Taro.showToast({
title: "已删除该场次",
icon: "success",
});
}
};
const validateFormData = (
formData: PublishBallFormData,
isOnSubmit: boolean = false,
) => {
const { activityInfo, image_list, title, timeRange } = formData;
const { play_type, price, location_name } = activityInfo;
if (!image_list?.length) {
if (!isOnSubmit) {
Taro.showToast({
title: `请上传活动封面`,
icon: "none",
});
}
return false;
}
if (!title) {
if (!isOnSubmit) {
Taro.showToast({
title: `请输入活动标题`,
icon: "none",
});
}
return false;
}
if (
!price ||
(typeof price === "number" && price <= 0) ||
(typeof price === "string" && !price.trim())
) {
if (!isOnSubmit) {
Taro.showToast({
title: `请输入费用`,
icon: "none",
});
}
return false;
}
if (!play_type || !play_type.trim()) {
if (!isOnSubmit) {
Taro.showToast({
title: `请选择玩法类型`,
icon: "none",
});
}
return false;
}
if (!location_name || !location_name.trim()) {
if (!isOnSubmit) {
Taro.showToast({
title: `请选择场地`,
icon: "none",
});
}
return false;
}
// 时间范围校验结束时间需晚于开始时间且至少间隔30分钟支持跨天
if (timeRange?.start_time && timeRange?.end_time) {
const start = dayjs(timeRange.start_time);
const end = dayjs(timeRange.end_time);
if (!end.isAfter(start)) {
if (!isOnSubmit) {
Taro.showToast({
title: `结束时间需晚于开始时间`,
icon: "none",
});
}
return false;
}
if (end.isBefore(start.add(30, "minute"))) {
if (!isOnSubmit) {
Taro.showToast({
title: `时间间隔至少30分钟`,
icon: "none",
});
}
return false;
}
}
return true;
};
const validateOnSubmit = () => {
const isValid =
activityType === "individual"
? validateFormData(formData[0], true)
: formData.every((item) => validateFormData(item, true));
if (!isValid) {
return false;
}
return true;
};
// 提交表单
const handleSubmit = async () => {
// 基础验证
console.log(formData, "formData");
if (activityType === "individual") {
const isValid = validateFormData(formData[0]);
if (!isValid) {
return;
}
const {
activityInfo,
descriptionInfo,
timeRange,
players,
skill_level,
image_list,
...rest
} = formData[0];
const options = {
...rest,
...activityInfo,
...descriptionInfo,
...timeRange,
max_players: players[1],
current_players: players[0],
skill_level_min: skill_level[0],
skill_level_max: skill_level[1],
image_list: image_list.map((item) => item.url),
};
const res = await PublishService.createPersonal(options);
if (res.code === 0 && res.data) {
Taro.showToast({
title: "发布成功",
icon: "success",
});
delay(1000);
// 如果是个人球局,则跳转到详情页,并自动分享
// 如果是畅打,则跳转第一个球局详情页,并自动分享 @刘杰
Taro.navigateTo({
// @ts-expect-error: id
url: `/game_pages/detail/index?id=${res.data.id || 1}&from=publish`,
});
} else {
Taro.showToast({
title: res.message,
icon: "none",
});
}
}
if (activityType === "group") {
const isValid = formData.every((item) => validateFormData(item));
if (!isValid) {
return;
}
if (checkAdjacentDataSame(formData)) {
Taro.showToast({
title: "信息不可与前序场完全一致",
icon: "none",
});
return;
}
const options = formData.map((item) => {
const {
activityInfo,
descriptionInfo,
timeRange,
players,
skill_level,
...rest
} = item;
return {
...rest,
...activityInfo,
...descriptionInfo,
...timeRange,
max_players: players[1],
current_players: players[0],
skill_level_min: skill_level[0],
skill_level_max: skill_level[1],
image_list: item.image_list.map((img) => img.url),
};
});
const res = await PublishService.create_play_pmoothlys({ rows: options });
if (res.code === 0 && res.data) {
Taro.showToast({
title: "发布成功",
icon: "success",
});
delay(1000);
// 如果是个人球局,则跳转到详情页,并自动分享
// 如果是畅打,则跳转第一个球局详情页,并自动分享 @刘杰
Taro.navigateTo({
// @ts-expect-error: id
url: `/game_pages/detail/index?id=${res.data?.[0].id || 1}&from=publish`,
});
} else {
Taro.showToast({
title: res.message,
icon: "none",
});
}
}
};
const initFormData = () => {
const currentInstance = Taro.getCurrentInstance();
const params = currentInstance.router?.params;
if (params?.type) {
const type = params.type as ActivityType;
if (type === "individual" || type === "group") {
setActivityType(type);
if (type === "group") {
const newFormSchema = publishBallFormSchema.reduce((acc, item) => {
if (item.prop === "is_wechat_contact") {
return acc;
}
if (item.prop === "image_list") {
if (item.props) {
item.props.source = ["album", "history"];
}
}
if (item.prop === "players") {
if (item.props) {
item.props.max = 100;
}
}
acc.push(item);
return acc;
}, [] as FormFieldConfig[]);
setOptionsConfig(newFormSchema);
setFormData([defaultFormData]);
}
// 根据type设置导航标题
if (type === "group") {
Taro.setNavigationBarTitle({
title: "发布畅打活动",
});
} else {
Taro.setNavigationBarTitle({
title: "发布",
});
}
}
handleActivityTypeChange(type);
}
};
const onCheckedChange = (checked: boolean) => {
setChecked(checked);
};
useEffect(() => {
const isValid = validateOnSubmit();
if (!isValid) {
setIsSubmitDisabled(true);
} else {
setIsSubmitDisabled(false);
}
console.log(formData, "formData");
}, [formData]);
useEffect(() => {
initFormData();
}, []);
return (
<View className={styles["publish-ball"]}>
{/* 活动类型切换 */}
<View className={styles["activity-type-switch"]}>
{/* <ActivityTypeSwitch
value={activityType}
onChange={handleActivityTypeChange}
/> */}
</View>
<View className={styles["publish-ball__scroll"]}>
{formData.map((item, index) => (
<View key={index}>
{/* 场次标题行 */}
{activityType === "group" && index > 0 && (
<View className={styles["session-header"]}>
<View className={styles["session-title"]}>
{index + 1}
<View
className={styles["session-delete"]}
onClick={() => showDeleteConfirm(index)}
>
<Image
src={images.ICON_DELETE}
className={styles["session-delete-icon"]}
/>
</View>
</View>
<View className={styles["session-actions"]}>
{index > 0 && (
<View
className={styles["session-action-btn"]}
onClick={() => handleCopyPrevious(index)}
>
</View>
)}
</View>
</View>
)}
<PublishForm
formData={item}
onChange={(key, value) => updateFormData(key, value, index)}
optionsConfig={optionsConfig}
/>
</View>
))}
{activityType === "group" && (
<View className={styles["publish-ball__add"]} onClick={handleAdd}>
<Image
src={images.ICON_ADD}
className={styles["publish-ball__add-icon"]}
/>
</View>
)}
</View>
{/* 删除确认弹窗 */}
<CommonDialog
visible={deleteConfirm.visible}
cancelText="再想想"
confirmText="确认移除"
onCancel={closeDeleteConfirm}
onConfirm={confirmDelete}
contentTitle="确认移除该场次?"
contentDesc="该操作不可恢复"
/>
{/* 完成按钮 */}
<View className={styles["submit-section"]}>
<Button
className={`${styles["submit-btn"]} ${isSubmitDisabled ? styles["submit-btn-disabled"] : ""}`}
onClick={handleSubmit}
>
</Button>
{activityType === "individual" && (
<Text className={styles["submit-tip"]}>
<Text className={styles["link"]}></Text>
</Text>
)}
{activityType === "group" && (
<View className={styles["submit-tip"]}>
<Checkbox
className={styles["submit-checkbox"]}
checked={checked}
onChange={onCheckedChange}
/>
</View>
)}
</View>
</View>
);
};
export default withAuth(PublishBall);

View File

@@ -0,0 +1,236 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch, UploadCover } from '@/components'
import FormBasicInfo from './components/FormBasicInfo'
import { type CoverImage } from '@/components/index.types'
import { FormFieldConfig, FieldType } from '@/config/formSchema/publishBallFormSchema'
import { PublishBallFormData } from '../../../types/publishBall';
import WechatSwitch from './components/WechatSwitch/WechatSwitch'
import styles from './index.module.scss'
import { useDictionaryActions } from '@/store/dictionaryStore'
// 组件映射器
const componentMap = {
[FieldType.TEXT]: TitleTextarea,
[FieldType.TIMEINTERVAL]: TimeSelector,
[FieldType.RANGE]: Range,
[FieldType.TEXTAREATAG]: TextareaTag,
[FieldType.NUMBERINTERVAL]: NumberInterval,
[FieldType.UPLOADIMAGE]: ImageUpload,
[FieldType.ACTIVITYINFO]: FormBasicInfo,
[FieldType.CHECKBOX]: FormSwitch,
[FieldType.WECHATCONTACT]: WechatSwitch,
}
const PublishForm: React.FC<{
formData: PublishBallFormData,
onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void,
optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => {
const [coverImages, setCoverImages] = useState<CoverImage[]>([])
// 字典数据相关
const { getDictionaryValue } = useDictionaryActions()
useEffect(() => {
setCoverImages(formData.image_list)
}, [formData.image_list])
// 处理封面图片变化
const handleCoverImagesChange = (fn: (images: CoverImage[]) => CoverImage[]) => {
const newImages = fn instanceof Function ? fn(coverImages) : fn
setCoverImages(newImages)
onChange('image_list', newImages)
}
// 更新表单数据
const updateFormData = (key: keyof PublishBallFormData, value: any) => {
onChange(key, value)
}
// 获取字典选项
const getDictionaryOptions = (key: string, defaultValue: any[] = []) => {
const dictValue = getDictionaryValue(key, defaultValue)
if (Array.isArray(dictValue)) {
return dictValue.map(item => ({
label: item.label || item.name || item.value || item,
value: item.value || item.id || item
}))
}
return defaultValue
}
// 动态生成表单配置,集成字典数据
const getDynamicFormConfig = (): FormFieldConfig[] => {
return optionsConfig.map(item => {
// 如果是玩法选择,从字典获取选项
if (item.prop === 'activityInfo' && item.children) {
const playTypeOptions = getDictionaryOptions('game_play', item.children.find(child => child.prop === 'play_type')?.options)
return {
...item,
children: item.children.map(child => {
if (child.prop === 'play_type') {
return { ...child, options: playTypeOptions }
}
return child
})
}
}
// 如果是补充要求,从字典获取选项
if (item.prop === 'descriptionInfo') {
const descriptionOptions = getDictionaryOptions('publishing_requirements', [])
return {
...item,
options: descriptionOptions
}
}
return item
})
}
const getNTRPText = (ntrp: [number, number] | any) => {
// 检查 ntrp 是否为数组
if (!Array.isArray(ntrp) || ntrp.length !== 2) {
console.warn('getNTRPText: ntrp 不是有效的数组格式:', ntrp);
return '未设置';
}
const [min, max] = ntrp;
// 检查 min 和 max 是否为有效数字
if (typeof min !== 'number' || typeof max !== 'number') {
console.warn('getNTRPText: min 或 max 不是有效数字:', { min, max });
return '未设置';
}
if (min === 1.0 && max === 5.0) {
return '不限'
}
if (min === 5.0 && max === 5.0) {
return '5.0 及以上'
}
if (min === 1.0 && max === 1.0) {
return `${min.toFixed(1)}`
}
if (min > 1.0 && max === 5.0) {
return `${min.toFixed(1)} 以上`
}
if (min === 1.0 && max < 5.0) {
return `${max.toFixed(1)} 以下`
}
if (min > 1.0 && max < 5.0) {
return `${min.toFixed(1)} - ${max.toFixed(1)}之间`
}
return '';
}
const getPlayersText = (players: [number, number] | any) => {
// 检查 players 是否为数组
if (!Array.isArray(players) || players.length !== 2) {
console.warn('getPlayersText: players 不是有效的数组格式:', players);
return '未设置';
}
const [min, max] = players;
// 检查 min 和 max 是否为有效数字
if (typeof min !== 'number' || typeof max !== 'number') {
console.warn('getPlayersText: min 或 max 不是有效数字:', { min, max });
return '未设置';
}
return `最少${min}人,最多${max}`
}
const renderSummary = (item: FormFieldConfig) => {
if (item.props?.showSummary) {
if (item.prop === 'skill_level') {
return <Text className={styles['section-summary']}>{getNTRPText(formData.skill_level)}</Text>
}
if (item.prop === 'players') {
return <Text className={styles['section-summary']}>{getPlayersText(formData.players)}</Text>
}
}
return null
}
// 获取动态表单配置
const dynamicConfig = getDynamicFormConfig()
return (
<View className={styles['publish-form']}>
<View className={styles['publish-ball__content']}>
{
dynamicConfig.map((item) => {
const Component = componentMap[item.type]
const optionProps = {
...item.props,
...(item.type === FieldType.TEXTAREATAG ? { options: item.options } : {}),
...(item.props?.className ? { className: styles[item.props.className] } : {}),
...(item.type === FieldType.WECHATCONTACT ? { wechatId: formData.wechat_contact } : {})
}
if (item.type === FieldType.UPLOADIMAGE) {
/* 活动封面 */
return <UploadCover
value={coverImages}
onChange={handleCoverImagesChange}
{...item.props}
/>
}
if (item.type === FieldType.ACTIVITYINFO) {
return <>
<View className={styles['activity-description']}>
<Text className={styles['description-text']}>
2
</Text>
</View>
{/* 费用地点玩法区域 - 合并白色块 */}
<View className={styles['bg-section']}>
<FormBasicInfo
children={item.children || []}
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps}
/>
</View>
</>
}
return (
<View className={styles['section-wrapper']}>
{
item.label && <View className={styles['section-title-wrapper']} >
<Text className={styles['section-title']}>{item.label}</Text>
{
item.props?.showSummary && <Text className={styles['section-summary']}>{renderSummary(item)}</Text>
}
</View>
}
<View className={styles['bg-section']}>
<Component
label={item.label}
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps}
placeholder={item.placeholder}
/>
</View>
</View>
)
})
}
</View>
</View>
)
}
export default PublishForm