增加获取场馆、字典

This commit is contained in:
筱野
2025-08-24 16:04:31 +08:00
parent c6f4f11259
commit bb6ec8c183
29 changed files with 1217 additions and 414 deletions

View File

@@ -10,12 +10,13 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
const baseConfig: UserConfigExport<'webpack5'> = { const baseConfig: UserConfigExport<'webpack5'> = {
projectName: 'playBallTogether', projectName: 'playBallTogether',
date: '2025-8-9', date: '2025-8-9',
designWidth: 375, designWidth: 390, // 改为 390
deviceRatio: { deviceRatio: {
640: 2.34 / 2, 640: 2.34 / 2 * (390 / 640), // 原值重新计算
750: 1, 750: 1 * (390 / 750), // 原值重新计算
375: 2, 375: 2 * (390 / 375), // 原值重新计算
828: 1.81 / 2 828: 1.81 / 2 * (390 / 828), // 原值重新计算
390: 2 // 新增基准设备
}, },
sourceRoot: 'src', sourceRoot: 'src',
outputRoot: 'dist', outputRoot: 'dist',
@@ -54,6 +55,11 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
pxtransform: { pxtransform: {
enable: true, enable: true,
config: { config: {
platform: 'weapp',
designWidth: 390, // 这里也要同步修改
deviceRatio: {
390: 2 // 这里只需要基准比例
},
selectorBlackList: ['nut-'] selectorBlackList: ['nut-']
} }
}, },

View File

@@ -1,18 +1,32 @@
import { Component, ReactNode } from 'react' import { Component, ReactNode } from 'react'
import './app.scss' import './app.scss'
import './nutui-theme.scss' import './nutui-theme.scss'
import { useDictionaryStore } from './store/dictionaryStore'
interface AppProps { interface AppProps {
children: ReactNode children: ReactNode
} }
class App extends Component<AppProps> { class App extends Component<AppProps> {
componentDidMount() {} componentDidMount() {
// 初始化字典数据
this.initDictionaryData()
}
componentDidShow() {} componentDidShow() {}
componentDidHide() {} componentDidHide() {}
// 初始化字典数据
private async initDictionaryData() {
try {
const { fetchDictionary } = useDictionaryStore.getState()
await fetchDictionary()
} catch (error) {
console.error('初始化字典数据失败:', error)
}
}
render() { render() {
// this.props.children 是将要会渲染的页面 // this.props.children 是将要会渲染的页面
return this.props.children return this.props.children

View File

@@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { Picker, Popup } from '@nutui/nutui-react-taro'
import styles from './index.module.scss'
export interface DateTimePickerProps {
visible: boolean
onClose: () => void
onConfirm: (year: number, month: number) => void
defaultYear?: number
defaultMonth?: number
minYear?: number
maxYear?: number
}
const DateTimePicker: React.FC<DateTimePickerProps> = ({
visible,
onClose,
onConfirm,
defaultYear = new Date().getFullYear(),
defaultMonth = new Date().getMonth() + 1,
minYear = 2020,
maxYear = 2030
}) => {
const [selectedYear, setSelectedYear] = useState(defaultYear)
const [selectedMonth, setSelectedMonth] = useState(defaultMonth)
// 生成年份选项
const yearOptions = Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
text: `${minYear + index}`,
value: minYear + index
}))
// 生成月份选项
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
text: `${index + 1}`,
value: index + 1
}))
useEffect(() => {
if (visible) {
setSelectedYear(defaultYear)
setSelectedMonth(defaultMonth)
}
}, [visible, defaultYear, defaultMonth])
const handleYearChange = (value: any) => {
setSelectedYear(value[0])
}
const handleMonthChange = (value: any) => {
setSelectedMonth(value[0])
}
const handleConfirm = () => {
onConfirm(selectedYear, selectedMonth)
onClose()
}
const handleCancel = () => {
onClose()
}
return (
<Popup
visible={visible}
position="bottom"
round
onClose={onClose}
className={styles['date-time-picker-popup']}
>
{/* 拖拽手柄 */}
<View className={styles['popup-handle']} />
{/* 时间选择器 */}
<View className={styles['picker-container']}>
<View className={styles['picker-columns']}>
{/* 年份选择 */}
<View className={styles['picker-column']}>
<Text className={styles['picker-label']}></Text>
<Picker
value={[selectedYear]}
options={yearOptions}
onChange={handleYearChange}
className={styles['year-picker']}
/>
</View>
{/* 月份选择 */}
<View className={styles['picker-column']}>
<Text className={styles['picker-label']}></Text>
<Picker
value={[selectedMonth]}
options={monthOptions}
onChange={handleMonthChange}
className={styles['month-picker']}
/>
</View>
</View>
</View>
{/* 操作按钮 */}
<View className={styles['action-buttons']}>
<View className={styles['cancel-btn']} onClick={handleCancel}>
<Text className={styles['cancel-text']}></Text>
</View>
<View className={styles['confirm-btn']} onClick={handleConfirm}>
<Text className={styles['confirm-text']}></Text>
</View>
</View>
</Popup>
)
}
export default DateTimePicker

View File

@@ -0,0 +1,67 @@
# DateTimePicker 年月选择器
一个基于 NutUI 的年月切换弹窗组件,支持自定义年份范围和默认值。
## 功能特性
- 🎯 年月分别选择,操作简单直观
- 🎨 遵循设计稿样式,美观易用
- 📱 支持移动端手势操作
- ⚙️ 可自定义年份范围
- <20><> 基于 NutUI 组件库,稳定可靠
## 使用方法
```tsx
import { DateTimePicker } from '@/components'
const MyComponent = () => {
const [visible, setVisible] = useState(false)
const handleConfirm = (year: number, month: number) => {
console.log('选择的年月:', year, month)
setVisible(false)
}
return (
<DateTimePicker
visible={visible}
onClose={() => setVisible(false)}
onConfirm={handleConfirm}
defaultYear={2025}
defaultMonth={11}
minYear={2020}
maxYear={2030}
/>
)
}
```
## API 参数
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| visible | boolean | - | 是否显示弹窗 |
| onClose | () => void | - | 关闭弹窗的回调 |
| onConfirm | (year: number, month: number) => void | - | 确认选择的回调 |
| defaultYear | number | 当前年份 | 默认选中的年份 |
| defaultMonth | number | 当前月份 | 默认选中的月份 |
| minYear | number | 2020 | 可选择的最小年份 |
| maxYear | number | 2030 | 可选择的最大年份 |
## 样式定制
组件使用 CSS Modules可以通过修改 `index.module.scss` 文件来自定义样式。
主要样式类:
- `.date-time-picker-popup` - 弹窗容器
- `.picker-columns` - 选择器列容器
- `.picker-column` - 单列选择器
- `.action-buttons` - 操作按钮区域
## 注意事项
1. 组件基于 NutUI 的 Picker 和 Popup 组件
2. 年份范围建议不要设置过大,以免影响性能
3. 月份固定为 1-12 月
4. 组件会自动处理边界情况

View File

@@ -0,0 +1,45 @@
import React, { useState } from 'react'
import { View, Button } from '@tarojs/components'
import DateTimePicker from './DateTimePicker'
const DateTimePickerExample: React.FC = () => {
const [visible, setVisible] = useState(false)
const [selectedDate, setSelectedDate] = useState('')
const handleOpen = () => {
setVisible(true)
}
const handleClose = () => {
setVisible(false)
}
const handleConfirm = (year: number, month: number) => {
setSelectedDate(`${year}${month}`)
console.log('选择的日期:', year, month)
}
return (
<View style={{ padding: '20px' }}>
<Button onClick={handleOpen}></Button>
{selectedDate && (
<View style={{ marginTop: '20px', fontSize: '16px' }}>
: {selectedDate}
</View>
)}
<DateTimePicker
visible={visible}
onClose={handleClose}
onConfirm={handleConfirm}
defaultYear={2025}
defaultMonth={11}
minYear={2020}
maxYear={2030}
/>
</View>
)
}
export default DateTimePickerExample

View File

@@ -0,0 +1,102 @@
.date-time-picker-popup {
:global(.nut-popup) {
border-radius: 16px 16px 0 0;
background: #fff;
}
}
.popup-handle {
width: 40px;
height: 4px;
background: #e5e5e5;
border-radius: 2px;
margin: 12px auto 0;
}
.picker-container {
padding: 20px 0;
}
.picker-columns {
display: flex;
justify-content: center;
align-items: center;
gap: 60px;
}
.picker-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.picker-label {
font-size: 14px;
color: #999;
font-weight: 400;
}
.year-picker,
.month-picker {
:global(.nut-picker) {
width: 80px;
}
:global(.nut-picker__content) {
height: 200px;
}
:global(.nut-picker-item) {
height: 40px;
line-height: 40px;
font-size: 16px;
color: #333;
}
:global(.nut-picker-item--selected) {
color: #000;
font-weight: 500;
}
:global(.nut-picker-item--disabled) {
color: #ccc;
}
}
.action-buttons {
display: flex;
padding: 0 20px 20px;
gap: 12px;
}
.cancel-btn,
.confirm-btn {
flex: 1;
height: 44px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.cancel-btn {
background: #fff;
border: 1px solid #e5e5e5;
}
.cancel-text {
color: #666;
font-size: 16px;
}
.confirm-btn {
background: #000;
border: 1px solid #000;
}
.confirm-text {
color: #fff;
font-size: 16px;
}

View File

@@ -0,0 +1,2 @@
import DateTimePicker from './DateTimePicker'
export default DateTimePicker

View File

@@ -4,18 +4,18 @@ import './NumberInterval.scss'
import { InputNumber } from '@nutui/nutui-react-taro' import { InputNumber } from '@nutui/nutui-react-taro'
interface NumberIntervalProps { interface NumberIntervalProps {
minParticipants: number value: [number, number]
maxParticipants: number onChange: (value: [number, number]) => void
onMinParticipantsChange: (value: number) => void
onMaxParticipantsChange: (value: number) => void
} }
const NumberInterval: React.FC<NumberIntervalProps> = ({ const NumberInterval: React.FC<NumberIntervalProps> = ({
minParticipants, value,
maxParticipants, onChange
onMinParticipantsChange,
onMaxParticipantsChange
}) => { }) => {
const [minParticipants, maxParticipants] = value || [1, 4]
const handleChange = (value: [number | string, number | string]) => {
onChange([Number(value[0]), Number(value[1])])
}
return ( return (
<View className='participants-control-section'> <View className='participants-control-section'>
<View className='participant-control'> <View className='participant-control'>
@@ -23,9 +23,10 @@ const NumberInterval: React.FC<NumberIntervalProps> = ({
<View className='control-buttons'> <View className='control-buttons'>
<InputNumber <InputNumber
className="format-width" className="format-width"
defaultValue={4} defaultValue={minParticipants}
min={0} min={minParticipants}
max={4} max={maxParticipants}
onChange={(value) => handleChange([value, maxParticipants])}
formatter={(value) => `${value}`} formatter={(value) => `${value}`}
/> />
</View> </View>
@@ -35,9 +36,10 @@ const NumberInterval: React.FC<NumberIntervalProps> = ({
<View className='control-buttons'> <View className='control-buttons'>
<InputNumber <InputNumber
className="format-width" className="format-width"
defaultValue={4} defaultValue={maxParticipants}
min={0} onChange={(value) => handleChange([value, maxParticipants])}
max={4} min={minParticipants}
max={maxParticipants}
formatter={(value) => `${value}`} formatter={(value) => `${value}`}
/> />
</View> </View>

View File

@@ -6,8 +6,8 @@ import { Checkbox } from '@nutui/nutui-react-taro'
import './TextareaTag.scss' import './TextareaTag.scss'
interface TextareaTagProps { interface TextareaTagProps {
value: string value: { description: string, description_tag: string[] }
onChange: (value: string) => void onChange: (value: { description: string, description_tag: string[] }) => void
title?: string title?: string
showTitle?: boolean showTitle?: boolean
placeholder?: string placeholder?: string
@@ -22,27 +22,15 @@ const TextareaTag: React.FC<TextareaTagProps> = ({
maxLength = 500, maxLength = 500,
options = [] options = []
}) => { }) => {
// 处理输入变化 // 处理文本输入变化
const [tags, setTags] = useState<string[]>([]) const handleTextChange = useCallback((e: any) => {
console.log(value, 'options') onChange({...value, description: e.detail.value})
const handleInputChange = useCallback((e: any) => {
onChange(e.detail.value)
}, [onChange]) }, [onChange])
// 选择预设选项 // 处理标签选择变化
const handleSelectOption = useCallback((option: string) => { const handleTagChange = useCallback((selectedTags: string[]) => {
let newValue = '' onChange({...value, description_tag: selectedTags})
}, [onChange])
if (value) {
// 如果已有内容,用分号分隔添加
newValue = value + '' + option
} else {
// 如果没有内容,直接添加
newValue = option
}
onChange(newValue)
}, [value, onChange])
console.log(options, 'options') console.log(options, 'options')
return ( return (
@@ -54,8 +42,8 @@ const TextareaTag: React.FC<TextareaTagProps> = ({
<Checkbox.Group <Checkbox.Group
labelPosition="left" labelPosition="left"
direction="horizontal" direction="horizontal"
value={tags} value={value.description_tag}
onChange={(value) => setTags(value)} onChange={handleTagChange}
> >
{ {
options?.map((option, index) => ( options?.map((option, index) => (
@@ -76,9 +64,9 @@ const TextareaTag: React.FC<TextareaTagProps> = ({
<Textarea <Textarea
className='additional-input' className='additional-input'
placeholder={placeholder} placeholder={placeholder}
value={value} value={value.description}
placeholderClass='textarea-placeholder' placeholderClass='textarea-placeholder'
onInput={handleInputChange} onInput={handleTextChange}
maxlength={maxLength} maxlength={maxLength}
autoHeight={false} autoHeight={false}
/> />

View File

@@ -1,6 +1,7 @@
import React from 'react' import React, { useState } from 'react'
import { View, Text, Picker } from '@tarojs/components' import { View, Text, } from '@tarojs/components'
import { getDate, getTime } from '@/utils/timeUtils' import { getDate, getTime } from '@/utils/timeUtils'
import DateTimePicker from '@/components/DateTimePicker'
import './TimeSelector.scss' import './TimeSelector.scss'
export interface TimeRange { export interface TimeRange {
@@ -21,8 +22,10 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
onChange onChange
}) => { }) => {
// 格式化日期显示 // 格式化日期显示
const [visible, setVisible] = useState(false)
const handleConfirm = (year: number, month: number) => {
console.log('选择的日期:', year, month)
}
return ( return (
<View className='time-selector'> <View className='time-selector'>
<View className='time-section'> <View className='time-section'>
@@ -31,7 +34,7 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
<View className='time-label'> <View className='time-label'>
<View className='dot'></View> <View className='dot'></View>
</View> </View>
<View className='time-content'> <View className='time-content' onClick={() => setVisible(true)}>
<Text className='time-label'></Text> <Text className='time-label'></Text>
<view className='time-text-wrapper'> <view className='time-text-wrapper'>
<Text className='time-text'>{getDate(value.start_time)}</Text> <Text className='time-text'>{getDate(value.start_time)}</Text>
@@ -53,6 +56,15 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
</View> </View>
</View> </View>
</View> </View>
<DateTimePicker
visible={visible}
onClose={() => setVisible(false)}
onConfirm={handleConfirm}
defaultYear={2025}
defaultMonth={11}
minYear={2020}
maxYear={2030}
/>
</View> </View>
) )
} }

View File

@@ -8,6 +8,7 @@ import NumberInterval from './NumberInterval'
import TimeSelector from './TimeSelector' import TimeSelector from './TimeSelector'
import TitleTextarea from './TitleTextarea' import TitleTextarea from './TitleTextarea'
import CommonPopup from './CommonPopup' import CommonPopup from './CommonPopup'
import DateTimePicker from './DateTimePicker/DateTimePicker'
export { export {
ActivityTypeSwitch, ActivityTypeSwitch,
@@ -18,6 +19,7 @@ export {
NumberInterval, NumberInterval,
TimeSelector, TimeSelector,
TitleTextarea, TitleTextarea,
CommonPopup CommonPopup,
DateTimePicker
} }

View File

@@ -15,12 +15,13 @@ export enum FieldType {
NUMBERINTERVAL = 'numberinterval', NUMBERINTERVAL = 'numberinterval',
RANGE = 'range', RANGE = 'range',
TEXTAREATAG = 'textareaTag', TEXTAREATAG = 'textareaTag',
ACTIVITYINFO = 'activityInfo' ACTIVITYINFO = 'activityInfo',
WECHATCONTACT = 'wechatContact'
} }
// 表单字段配置接口 // 表单字段配置接口
export interface FormFieldConfig { export interface FormFieldConfig {
key: string prop: string
label: string label: string
type: FieldType type: FieldType
placeholder?: string placeholder?: string
@@ -43,7 +44,7 @@ export interface FormFieldConfig {
// 发布球局表单配置 // 发布球局表单配置
export const publishBallFormSchema: FormFieldConfig[] = [ export const publishBallFormSchema: FormFieldConfig[] = [
{ {
key: 'image_list', prop: 'image_list',
label: '活动封页', label: '活动封页',
type: FieldType.UPLOADIMAGE, type: FieldType.UPLOADIMAGE,
placeholder: '请选择活动类型', placeholder: '请选择活动类型',
@@ -53,7 +54,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
} }
}, },
{ {
key: 'title', prop: 'title',
label: '', label: '',
type: FieldType.TEXT, type: FieldType.TEXT,
placeholder: '好的标题更吸引人哦', placeholder: '好的标题更吸引人哦',
@@ -63,89 +64,84 @@ export const publishBallFormSchema: FormFieldConfig[] = [
} }
}, },
{ {
key: 'timeRange', prop: 'timeRange',
label: '', label: '',
type: FieldType.TIMEINTERVAL, type: FieldType.TIMEINTERVAL,
placeholder: '请选择活动日期', placeholder: '请选择活动日期',
required: true required: true
}, },
// {
// key: 'activityInfo',
// label: '活动信息',
// type: FieldType.ACTIVITYINFO,
// placeholder: '请选择活动时间',
// required: true,
// rules: [
// { required: true, message: '请选择活动时间' }
// ],
// children: [
// {
// key: 'price',
// 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: 'play_type',
// 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: 'players',
// label: '人数要求',
// type: FieldType.NUMBERINTERVAL,
// placeholder: '请输入最少参与人数',
// defaultValue: 1,
// props: {
// showSummary: true,
// summary: '最少1人最多4人',
// }
// },
// {
// key: 'skill_level',
// label: 'NTRP 水平要求',
// type: FieldType.RANGE,
// placeholder: '请选择开始时间',
// required: true,
// props: {
// showTitle: false,
// showSummary: true,
// className: 'ntrp-range',
// step: 0.5,
// min: 1.0,
// max: 5.0,
// }
// },
{ {
key: 'descriptionInfo', prop: 'activityInfo',
label: '活动信息',
type: FieldType.ACTIVITYINFO,
placeholder: '请选择活动时间',
required: true,
children: [
{
prop: 'price',
label: '费用',
iconType: 'ICON_COST',
type: FieldType.NUMBER,
placeholder: '请输入活动费用(元)',
defaultValue: 0,
rules: [
{ min: 0, message: '费用不能为负数' },
{ max: 1000, message: '费用不能超过1000元' }
],
},
{
prop: 'location_name',
label: '地点',
iconType: 'ICON_LOCATION',
type: FieldType.LOCATION,
placeholder: '请选择活动地点',
required: true,
},
{
prop: 'play_type',
label: '玩法',
iconType: 'ICON_GAMEPLAY',
type: FieldType.SELECT,
placeholder: '请选择玩法',
required: true,
options: [
{ label: '不限', value: '不限' },
{ label: '单打', value: '单打' },
{ label: '双打', value: '双打' },
{ label: '拉球', value: '拉球' }
],
}
]
},
{
prop: 'players',
label: '人数要求',
type: FieldType.NUMBERINTERVAL,
placeholder: '请输入最少参与人数',
defaultValue: 1,
props: {
showSummary: true,
summary: '最少1人最多4人',
}
},
{
prop: 'skill_level',
label: 'NTRP 水平要求',
type: FieldType.RANGE,
placeholder: '请选择开始时间',
required: true,
props: {
showTitle: false,
showSummary: true,
className: 'ntrp-range',
step: 0.5,
min: 1.0,
max: 5.0,
}
},
{
prop: 'descriptionInfo',
label: '补充要求(选填)', label: '补充要求(选填)',
type: FieldType.TEXTAREATAG, type: FieldType.TEXTAREATAG,
placeholder: '补充性别偏好、特殊要求和注意事项等信息', placeholder: '补充性别偏好、特殊要求和注意事项等信息',
@@ -157,7 +153,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
] ]
}, },
{ {
key: 'autoDegrade', prop: 'is_substitute_supported',
label: '', label: '',
type: FieldType.CHECKBOX, type: FieldType.CHECKBOX,
placeholder: '开启自动候补逻辑', placeholder: '开启自动候补逻辑',
@@ -167,5 +163,14 @@ export const publishBallFormSchema: FormFieldConfig[] = [
showToast: true, showToast: true,
description: '开启后,当活动人数不足时,系统会自动将活动状态改为“候补”,并通知用户。', description: '开启后,当活动人数不足时,系统会自动将活动状态改为“候补”,并通知用户。',
} }
},
{
prop: 'is_wechat_contact',
label: '',
type: FieldType.WECHATCONTACT,
required: true,
props:{
subTitle: '允许球友微信联系我',
}
} }
] ]

View File

@@ -1,12 +1,28 @@
import React, { useState } from 'react' import React, { useState, useCallback, useEffect } from 'react'
import { View, Text, Input, Image, Picker } from '@tarojs/components' import { View, Text, Input, Image, Picker } from '@tarojs/components'
import PopupGameplay from '../PopupGameplay' import PopupGameplay from '../PopupGameplay'
import img from '@/config/images'; import img from '@/config/images';
import './FormBasicInfo.scss'
import { FormFieldConfig } from '@/config/formSchema/publishBallFormSchema'; 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 { interface FormBasicInfoProps {
value: any value: PlayGame
onChange: (value: any) => void onChange: (value: any) => void
children: FormFieldConfig[] children: FormFieldConfig[]
} }
@@ -17,20 +33,49 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
children children
}) => { }) => {
const [gameplayVisible, setGameplayVisible] = useState(false) const [gameplayVisible, setGameplayVisible] = useState(false)
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
const [playGame, setPlayGame] = useState<{label: string, value: string }[]>([])
const handleGameplaySelect = () => { const handleGameplaySelect = () => {
setGameplayVisible(true) setGameplayVisible(true)
} }
const handleGameplayConfirm = (selectedGameplay: string) => { const handleGameplayConfirm = (selectedGameplay: string) => {
onGameplayChange(selectedGameplay) onChange({...value, [children[2].prop]: selectedGameplay})
setGameplayVisible(false) setGameplayVisible(false)
} }
const handleGameplayClose = () => { const handleGameplayClose = () => {
setGameplayVisible(false) 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, value: any) => {
onChange({...value, [key]: value})
}, [onChange])
useEffect(() => {
if (children.length > 2) {
const options = children[2]?.options || [];
setPlayGame(options)
}
}, [children])
const renderChildren = () => { const renderChildren = () => {
return children.map((child: any, index: number) => { return children.map((child: any, index: number) => {
return <View className='form-item'> return <View className='form-item'>
@@ -46,8 +91,8 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
placeholder='请输入' placeholder='请输入'
placeholderClass='title-placeholder' placeholderClass='title-placeholder'
type='digit' type='digit'
value={value[child.key]} value={value[child.prop]}
onInput={(e) => onChange(child.key, e.detail.value)} onInput={(e) => handleChange(child.prop, e.detail.value)}
/> />
<Text className='unit'>/</Text> <Text className='unit'>/</Text>
</View> </View>
@@ -56,9 +101,9 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
{ {
index === 1 && (<View className='form-wrapper'> index === 1 && (<View className='form-wrapper'>
<Text className='form-item-label'>{child.label}</Text> <Text className='form-item-label'>{child.label}</Text>
<View className='form-right-wrapper' onClick={() => {}}> <View className='form-right-wrapper' onClick={() => setShowStadiumSelector(true)}>
<Text className={`right-text ${value[child.key] ? 'selected' : ''}`}> <Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.key] ? value[child.key] : '请选择'} {value[child.prop] ? value[child.prop] : '请选择'}
</Text> </Text>
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/> <Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
</View> </View>
@@ -68,8 +113,8 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
index === 2 && ( <View className='form-wrapper'> index === 2 && ( <View className='form-wrapper'>
<Text className='form-item-label'>{child.label}</Text> <Text className='form-item-label'>{child.label}</Text>
<View className='form-right-wrapper' onClick={handleGameplaySelect}> <View className='form-right-wrapper' onClick={handleGameplaySelect}>
<Text className={`right-text ${value[child.key] ? 'selected' : ''}`}> <Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.key] ? value[child.key] : '请选择'} {value[child.prop] ? value[child.prop] : '请选择'}
</Text> </Text>
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/> <Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
</View> </View>
@@ -81,15 +126,21 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
return ( return (
<View className='form-basic-info'> <View className='form-basic-info'>
{/* 费用 */} {/* 费用 */}
{/* {renderChildren()} */} {renderChildren()}
{/* 玩法选择弹窗 */} {/* 玩法选择弹窗 */}
{/* <PopupGameplay <PopupGameplay
visible={gameplayVisible} visible={gameplayVisible}
onClose={handleGameplayClose} onClose={handleGameplayClose}
onConfirm={handleGameplayConfirm} onConfirm={handleGameplayConfirm}
selectedGameplay={value[children[2].key]} value={value[children[2].prop]}
/> */} options={playGame}
/>
{/* 场馆选择弹窗 */}
<SelectStadium
visible={showStadiumSelector}
onClose={() => setShowStadiumSelector(false)}
onConfirm={handleStadiumSelect}
/>
</View> </View>
) )
} }

View File

@@ -6,20 +6,20 @@ import styles from './PopupGameplay.module.scss'
interface PopupGameplayProps { interface PopupGameplayProps {
visible: boolean visible: boolean
onClose: () => void onClose: () => void
onConfirm: (selectedGameplay: string) => void onConfirm: (value: string) => void
selectedGameplay?: string value?: string
options?: { label: string, value: string }[]
} }
export default function PopupGameplay({ visible, onClose, onConfirm, selectedGameplay = '不限' }: PopupGameplayProps) { export default function PopupGameplay({ visible, onClose, onConfirm, value = '不限', options = [] }: PopupGameplayProps) {
const [selectedOption, setSelectedOption] = useState(selectedGameplay) const [selectedOption, setSelectedOption] = useState(value)
const options = ['不限', '单打', '双打', '拉球']
useEffect(() => { useEffect(() => {
if (visible && selectedGameplay) { if (visible && value) {
setSelectedOption(selectedGameplay) setSelectedOption(value)
} }
}, [visible, selectedGameplay]) }, [visible, value])
const handleOptionSelect = (option: string) => { const handleOptionSelect = (option: string) => {
setSelectedOption(option) setSelectedOption(option)
@@ -45,11 +45,11 @@ export default function PopupGameplay({ visible, onClose, onConfirm, selectedGam
<View className={styles.optionsList}> <View className={styles.optionsList}>
{options.map((option) => ( {options.map((option) => (
<View <View
key={option} key={option.value}
className={`${styles.optionItem} ${selectedOption === option ? styles.selected : ''}`} className={`${styles.optionItem} ${selectedOption === option.value ? styles.selected : ''}`}
onClick={() => handleOptionSelect(option)} onClick={() => handleOptionSelect(option.value)}
> >
<Text className={styles.optionText}>{option}</Text> <Text className={styles.optionText}>{option.label}</Text>
</View> </View>
))} ))}
</View> </View>

View File

@@ -134,7 +134,24 @@
} }
} }
} }
.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 { .stadium-list {
flex: 1; flex: 1;
@@ -172,6 +189,11 @@
font-weight: 600; font-weight: 600;
line-height: 24px; line-height: 24px;
display: flex; display: flex;
.highlight-text {
color: #007AFF;
font-weight: 700;
}
} }
.stadium-address{ .stadium-address{
display: flex; display: flex;

View File

@@ -1,10 +1,13 @@
import React, { useState } from 'react' import React, { useState, useRef, useEffect } from 'react'
import { View, Text, Input, ScrollView, Image } from '@tarojs/components' import { View, Text, Input, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import StadiumDetail from './StadiumDetail' import { Loading } from '@nutui/nutui-react-taro'
import StadiumDetail, { StadiumDetailRef } from './StadiumDetail'
import { CommonPopup } from '../../../../components' import { CommonPopup } from '../../../../components'
import './SelectStadium.scss' import { getLocation } from '@/utils/locationUtils'
import PublishService from '@/services/publishService'
import images from '@/config/images' import images from '@/config/images'
import './SelectStadium.scss'
export interface Stadium { export interface Stadium {
id?: string id?: string
@@ -21,17 +24,6 @@ interface SelectStadiumProps {
onConfirm: (stadium: Stadium | null) => void onConfirm: (stadium: Stadium | null) => void
} }
const stadiumList: Stadium[] = [
{ id: '1', name: '静安网球馆', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304},
{ id: '2', name: '芦湾体育馆', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '3', name: '静安网球馆', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '4', name: '徐汇游泳中心', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '5', name: '汇龙新城小区', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '6', name: '翠湖御苑小区', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '7', name: '仁恒河滨花园网球场', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '8', name: 'Our Tennis 东江球场', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 },
{ id: '9', name: '上海琦梦网球俱乐部', address: '浦东新区东园路18号', istance: '100米' , longitude: 121.4367, latitude: 31.2304 }
]
const SelectStadium: React.FC<SelectStadiumProps> = ({ const SelectStadium: React.FC<SelectStadiumProps> = ({
visible, visible,
@@ -41,6 +33,37 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
const [searchValue, setSearchValue] = useState('') const [searchValue, setSearchValue] = useState('')
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null) const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
const [showDetail, setShowDetail] = useState(false) 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 if (!visible) return null
@@ -55,11 +78,6 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
setShowDetail(true) setShowDetail(true)
} }
// 处理返回球馆列表
const handleBackToList = () => {
setShowDetail(false)
setSelectedStadium(null)
}
// 处理搜索框输入 // 处理搜索框输入
const handleSearchInput = (e: any) => { const handleSearchInput = (e: any) => {
@@ -70,7 +88,6 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
const handleMapLocation = () => { const handleMapLocation = () => {
Taro.chooseLocation({ Taro.chooseLocation({
success: (res) => { success: (res) => {
console.log('选择位置成功:', res)
setSelectedStadium({ setSelectedStadium({
name: res.name, name: res.name,
address: res.address, address: res.address,
@@ -81,32 +98,25 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
}, },
fail: (err) => { fail: (err) => {
console.error('选择位置失败:', err) console.error('选择位置失败:', err)
Taro.showToast({
title: '位置选择失败',
icon: 'error'
})
} }
}) })
} }
// 处理确认 // 处理确认
const handleConfirm = (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => { const handleConfirm = () => {
// 这里可以处理球馆详情的信息 if (stadiumDetailRef.current) {
console.log('球馆详情:', { stadium, venueType, groundMaterial, additionalInfo }) const formData = stadiumDetailRef.current.getFormData()
onConfirm(stadium) console.log('获取球馆表单数据:', formData)
setShowDetail(false) const { description, ...rest } = formData
onConfirm({ ...rest, ...description })
setSelectedStadium(null) setSelectedStadium(null)
setSearchValue('') setSearchValue('')
} }
}
// 处理球馆列表确认
const handleListConfirm = () => {
if (selectedStadium) {
onConfirm(selectedStadium)
setSelectedStadium(null)
setSearchValue('')
}
}
// 处理取消 // 处理取消
const handleCancel = () => { const handleCancel = () => {
@@ -117,25 +127,30 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
} }
const handleItemLocation = (stadium: Stadium) => { const handleItemLocation = (stadium: Stadium) => {
console.log(stadium,'stadiumstadium'); if (stadium.latitude && stadium.longitude) {
if(stadium.latitude && stadium.longitude){
Taro.openLocation({ Taro.openLocation({
latitude: stadium.latitude, latitude: stadium.latitude,
longitude: stadium.longitude, longitude: stadium.longitude,
name: stadium.name, name: stadium.name,
address: stadium.address, address: stadium.address,
success: (res) => { success: (res) => {
console.log(res,'resres'); console.log(res, 'resres')
} }
}) })
} }
} }
const markSearchText = (text: string) => { const markSearchText = (text: string) => {
return text.replace(searchValue, `<span style="color: #007AFF;">${searchValue}</span>`) if (!searchValue) return text
return text.replace(
new RegExp(searchValue, 'gi'),
`<span class="highlight-text">${searchValue}</span>`
)
} }
// 如果显示详情页面 // 如果显示详情页面
if (showDetail && selectedStadium) { if (showDetail && selectedStadium) {
return ( return (
@@ -146,14 +161,13 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
confirmText="确认" confirmText="确认"
className="select-stadium-popup" className="select-stadium-popup"
onCancel={handleCancel} onCancel={handleCancel}
onConfirm={handleListConfirm} onConfirm={handleConfirm}
position="bottom" position="bottom"
round round
> >
<StadiumDetail <StadiumDetail
ref={stadiumDetailRef}
stadium={selectedStadium} stadium={selectedStadium}
onBack={handleBackToList}
onConfirm={handleConfirm}
/> />
</CommonPopup> </CommonPopup>
) )
@@ -168,8 +182,6 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
cancelText="返回" cancelText="返回"
confirmText="完成" confirmText="完成"
className="select-stadium-popup" className="select-stadium-popup"
onCancel={handleCancel}
onConfirm={handleListConfirm}
position="bottom" position="bottom"
round round
> >
@@ -215,8 +227,12 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
</View> </View>
</View> </View>
</View> </View>
{
{/* 场馆列表 */} loading ? (
<View className='stadium-item-loading'>
<Loading type="circular" className='loading-icon'></Loading>
</View>
) : (
<ScrollView className='stadium-list' scrollY> <ScrollView className='stadium-list' scrollY>
{filteredStadiums.map((stadium) => ( {filteredStadiums.map((stadium) => (
<View <View
@@ -229,33 +245,50 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
</View> </View>
<View className='stadium-item-right'> <View className='stadium-item-right'>
<View className='stadium-name' dangerouslySetInnerHTML={{ __html: markSearchText(stadium.name) }}></View> <View className='stadium-name' dangerouslySetInnerHTML={{ __html: markSearchText(stadium.name) }}></View>
<View className='stadium-address' > <View className='stadium-address'>
<Text onClick={(e) => { e.stopPropagation(); handleItemLocation(stadium); }}>{stadium.istance} · </Text> <Text
<Text onClick={(e) => { e.stopPropagation(); handleItemLocation(stadium); }}>{stadium.address}</Text> className='stadium-distance'
onClick={(e) => {
e.stopPropagation()
handleItemLocation(stadium)
}}
>
{stadium.istance} ·
</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' /> <Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View> </View>
</View> </View>
</View> </View>
))} ))}
{ {searchValue && (
searchValue && (<View <View className='stadium-item map-search-item' onClick={handleMapLocation}>
className={`stadium-item`}
onClick={() => handleMapLocation()}
>
<View className='stadium-item-left'> <View className='stadium-item-left'>
<Image src={images.ICON_MAP_SEARCH} className='stadium-icon' /> <Image src={images.ICON_MAP_SEARCH} className='stadium-icon' />
</View> </View>
<View className='stadium-item-right'> <View className='stadium-item-right'>
<View className='stadium-name'></View> <View className='stadium-name'></View>
<View className='stadium-address'> <View className='stadium-address'>
<Text></Text> <Text className='map-search-text'></Text>
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' /> <Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View> </View>
</View> </View>
</View>) </View>
} )}
</ScrollView> </ScrollView>
)
}
{/* 场馆列表 */}
</View> </View>
</CommonPopup> </CommonPopup>
) )

View File

@@ -114,7 +114,6 @@
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
background: white; background: white;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
display: flex; display: flex;
flex: 1; flex: 1;
justify-content: center; justify-content: center;

View File

@@ -1,10 +1,11 @@
import React, { useState, useCallback } from 'react' import React, { useState, useCallback, forwardRef, useImperativeHandle } from 'react'
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
import { View, Text, Image } from '@tarojs/components' import { View, Text, Image } from '@tarojs/components'
import images from '@/config/images' import images from '@/config/images'
import './StadiumDetail.scss'
import TextareaTag from '@/components/TextareaTag' import TextareaTag from '@/components/TextareaTag'
import CoverImageUpload, { type CoverImage } from '@/components/ImageUpload' import CoverImageUpload, { type CoverImage } from '@/components/ImageUpload'
import { useDictionaryActions } from '@/store/dictionaryStore'
import './StadiumDetail.scss'
export interface Stadium { export interface Stadium {
id?: string id?: string
@@ -13,45 +14,27 @@ export interface Stadium {
longitude?: number longitude?: number
latitude?: number latitude?: number
istance?: string istance?: string
court_type?: string
court_surface?: string
description?: string
description_tag?: string[]
venue_image_list?: CoverImage[]
} }
interface StadiumDetailProps { interface StadiumDetailProps {
stadium: Stadium stadium: Stadium
onBack: () => void
onConfirm: (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => void
} }
const stadiumInfo = [ // 定义暴露给父组件的方法接口
{ export interface StadiumDetailRef {
label: '场地类型', getFormData: () => any
options: ['室内', '室外', '室外雨棚'], setFormData: (data: any) => void
prop: 'venueType', }
type: 'tags'
},
{
label: '地面材质',
options: ['硬地', '红土', '草地'],
prop: 'groundMaterial',
type: 'tags'
},
{
label: '场地信息补充',
options: ['1号场', '2号场', '3号场', '4号场', '有空调', '6号场','6号场'],
prop: 'additionalInfo',
type: 'textareaTag'
},
{
label: '场地预定截图',
options: ['有其他场地信息可备注'],
prop: 'imagesList',
type: 'image'
}
]
// 公共的标题组件 // 公共的标题组件
const SectionTitle: React.FC<{ title: string,prop: string }> = ({ title, prop }) => { const SectionTitle: React.FC<{ title: string,prop: string }> = ({ title, prop }) => {
console.log(prop,'propprop'); if (prop === 'venue_image_list') {
if (prop === 'imagesList') {
return ( return (
<View className='section-title'> <View className='section-title'>
<Text>{title}</Text> <Text>{title}</Text>
@@ -78,21 +61,61 @@ const SectionContainer: React.FC<{ title: string; children: React.ReactNode, pro
</View> </View>
) )
const StadiumDetail: React.FC<StadiumDetailProps> = ({ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
stadium, 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({ const [formData, setFormData] = useState({
stadiumName: stadium.name, name: stadium.name,
stadiumAddress: stadium.address, address: stadium.address,
stadiumLongitude: stadium.longitude, latitude: stadium.longitude,
stadiumLatitude: stadium.latitude, longitude: stadium.latitude,
istance: stadium.istance, istance: stadium.istance,
venueType: '室内', court_type: court_type[0] || '',
groundMaterial: '硬地', court_surface: court_surface[0] || '',
additionalInfo: '', additionalInfo: '',
imagesList: [] as CoverImage[] venue_image_list: [] as CoverImage[],
description:{
description: '',
description_tag: []
}
}) })
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
getFormData: () => formData,
setFormData: (data: any) => setFormData(data)
}), [formData, stadium])
const handleMapLocation = () => { const handleMapLocation = () => {
@@ -101,10 +124,10 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
console.log(res,'resres'); console.log(res,'resres');
setFormData({ setFormData({
...formData, ...formData,
stadiumName: res.name, name: res.name,
stadiumAddress: res.address, address: res.address,
stadiumLongitude: res.longitude, latitude: res.longitude,
stadiumLatitude: res.latitude longitude: res.latitude
}) })
}, },
fail: (err) => { fail: (err) => {
@@ -122,10 +145,10 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
}, []) }, [])
const getSelectedByLabel = useCallback((label: string) => { const getSelectedByLabel = useCallback((label: string) => {
if (label === '场地类型') return formData.venueType if (label === '场地类型') return formData.court_type
if (label === '地面材质') return formData.groundMaterial if (label === '地面材质') return formData.court_surface
return '' return ''
}, [formData.venueType, formData.groundMaterial]) }, [formData.court_type, formData.court_surface])
console.log(stadium,'stadiumstadium'); console.log(stadium,'stadiumstadium');
@@ -140,10 +163,10 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
<Image src={images.ICON_STADIUM} className='stadium-icon' /> <Image src={images.ICON_STADIUM} className='stadium-icon' />
</View> </View>
<View className='stadium-item-right'> <View className='stadium-item-right'>
<View className='stadium-name'>{formData.stadiumName}</View> <View className='stadium-name'>{formData.name}</View>
<View className='stadium-address'> <View className='stadium-address'>
<Text>{formData.istance} · </Text> <Text>{formData.istance} · </Text>
<Text>{formData.stadiumAddress}</Text> <Text>{formData.address}</Text>
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' /> <Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
</View> </View>
</View> </View>
@@ -172,7 +195,7 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
<SectionContainer key={item.label} title={item.label} prop={item.prop}> <SectionContainer key={item.label} title={item.label} prop={item.prop}>
<View className='textarea-tag-container'> <View className='textarea-tag-container'>
<TextareaTag <TextareaTag
value={formData.additionalInfo} value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop, value)} onChange={(value) => updateFormData(item.prop, value)}
placeholder='有其他场地信息可备注' placeholder='有其他场地信息可备注'
options={(item.options || []).map((o) => ({ label: o, value: o }))} options={(item.options || []).map((o) => ({ label: o, value: o }))}
@@ -186,7 +209,7 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
return ( return (
<SectionContainer key={item.label} title={item.label} prop={item.prop}> <SectionContainer key={item.label} title={item.label} prop={item.prop}>
<CoverImageUpload <CoverImageUpload
images={formData.imagesList} images={formData[item.prop]}
onChange={(images) => updateFormData(item.prop, images)} onChange={(images) => updateFormData(item.prop, images)}
/> />
</SectionContainer> </SectionContainer>
@@ -197,6 +220,6 @@ const StadiumDetail: React.FC<StadiumDetailProps> = ({
})} })}
</View> </View>
) )
} })
export default StadiumDetail export default StadiumDetail

View File

@@ -0,0 +1,41 @@
import React, { useState } from 'react'
import { View, Text } 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 }) => {
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>
{
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']}></View>
</View>
)
}
</View>
</>
)
}
export default FormSwitch

View File

@@ -0,0 +1,91 @@
.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);
}
}
}

View File

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

View File

@@ -34,9 +34,6 @@
.activity-type-switch{ .activity-type-switch{
padding: 4px 16px 0 16px; padding: 4px 16px 0 16px;
} }
.publish-form{
}
// 场次标题行 // 场次标题行
.session-header { .session-header {

View File

@@ -12,7 +12,7 @@ import images from '@/config/images'
const defaultFormData: PublishBallFormData = { const defaultFormData: PublishBallFormData = {
title: '', title: '',
image_list: [], image_list: ['https://static-o.oss-cn-shenzhen.aliyuncs.com/images/tpbj/tpss10.jpg'],
timeRange: { timeRange: {
start_time: getNextHourTime(), start_time: getNextHourTime(),
end_time: getEndTime(getNextHourTime()) end_time: getEndTime(getNextHourTime())
@@ -31,21 +31,15 @@ const defaultFormData: PublishBallFormData = {
venue_description: '', venue_description: '',
venue_image_list: [], venue_image_list: [],
}, },
players: { players: [1, 4],
current_players: 0, skill_level: [1.0, 5.0],
max_players: 0,
},
skill_level: {
skill_level_max: 5.0,
skill_level_min: 1.0,
},
descriptionInfo: { descriptionInfo: {
description: '', description: '',
description_tag: [], description_tag: [],
}, },
is_substitute_supported: false, is_substitute_supported: true,
is_wechat_contact: false, is_wechat_contact: true,
wechat_contact: '' wechat_contact: '14223332214'
} }
const PublishBall: React.FC = () => { const PublishBall: React.FC = () => {
@@ -65,9 +59,11 @@ const PublishBall: React.FC = () => {
// 更新表单数据 // 更新表单数据
const updateFormData = (key: keyof PublishBallFormData, value: any, index: number) => { const updateFormData = (key: keyof PublishBallFormData, value: any, index: number) => {
console.log(key, value, index, 'key, value, index');
setFormData(prev => { setFormData(prev => {
const newData = [...prev] const newData = [...prev]
newData[index] = { ...newData[index], [key]: value } newData[index] = { ...newData[index], [key]: value }
console.log(newData, 'newData');
return newData return newData
}) })
} }
@@ -132,30 +128,80 @@ const PublishBall: React.FC = () => {
} }
} }
const validateFormData = (formData: PublishBallFormData) => {
const { activityInfo, image_list, title } = formData;
const { play_type, price, location_name } = activityInfo;
if (!image_list.length) {
Taro.showToast({
title: `请上传活动封面`,
icon: 'none'
})
return false
}
if (!title) {
Taro.showToast({
title: `请输入活动标题`,
icon: 'none'
})
return false
}
if (!price) {
Taro.showToast({
title: `请输入费用`,
icon: 'none'
})
return false
}
if (!play_type) {
Taro.showToast({
title: `请选择玩法类型`,
icon: 'none'
})
return false
}
if (!location_name) {
Taro.showToast({
title: `请选择场地`,
icon: 'none'
})
return false
}
return true
}
// 提交表单 // 提交表单
const handleSubmit = async () => { const handleSubmit = async () => {
// 基础验证 // 基础验证
console.log(formData, 'formData');
// TODO: 实现提交逻辑 if (activityType === 'individual') {
const res = await PublishService.createPersonal({ const isValid = validateFormData(formData[0])
"title": "周末网球约球", if (!isValid) {
"venue_id": 1, return
"creator_id": 1, }
"game_date": "2024-06-15", const { activityInfo, descriptionInfo, timeRange, players, skill_level, ...rest } = formData[0];
"start_time": "14:00", const options = {
"end_time": "16:00", ...rest,
"max_participants": 4, ...activityInfo,
"current_participants": 2, ...descriptionInfo,
"ntrp_level": "2.0-4.0", ...timeRange,
"play_style": "单打", max_players: players[1],
"description": "周末约球,欢迎参加", current_players: players[0],
}) skill_level_min: skill_level[0],
console.log(res); skill_level_max: skill_level[1]
}
const res = await PublishService.createPersonal(options);
if (res.code === 0 && res.data) {
Taro.showToast({ Taro.showToast({
title: '发布成功', title: '发布成功',
icon: 'success' icon: 'success'
}) })
} else {
Taro.showToast({
title: res.message,
icon: 'none'
})
}
}
} }
return ( return (

View File

@@ -1,16 +1,13 @@
import React, { useState } from 'react' import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components' import { View, Text } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch } from '../../components' import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch } from '../../components'
import { SelectStadium } from './components/SelectStadium'
import FormBasicInfo from './components/FormBasicInfo' import FormBasicInfo from './components/FormBasicInfo'
import { type CoverImage } from '../../components/index.types' import { type CoverImage } from '../../components/index.types'
import { Stadium } from './components/SelectStadium'
import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema' import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema'
import { PublishBallFormData } from '../../../types/publishBall'; import { PublishBallFormData } from '../../../types/publishBall';
import WechatSwitch from './components/WechatSwitch/WechatSwitch'
import styles from './index.module.scss' import styles from './index.module.scss'
import { useDictionaryActions } from '../../store/dictionaryStore'
// 组件映射器 // 组件映射器
const componentMap = { const componentMap = {
@@ -22,6 +19,7 @@ const componentMap = {
[FieldType.UPLOADIMAGE]: ImageUpload, [FieldType.UPLOADIMAGE]: ImageUpload,
[FieldType.ACTIVITYINFO]: FormBasicInfo, [FieldType.ACTIVITYINFO]: FormBasicInfo,
[FieldType.CHECKBOX]: FormSwitch, [FieldType.CHECKBOX]: FormSwitch,
[FieldType.WECHATCONTACT]: WechatSwitch,
} }
const PublishForm: React.FC<{ const PublishForm: React.FC<{
@@ -29,8 +27,9 @@ const PublishForm: React.FC<{
onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void, onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void,
optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => { optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => {
const [coverImages, setCoverImages] = useState<CoverImage[]>([]) const [coverImages, setCoverImages] = useState<CoverImage[]>([])
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null) // 字典数据相关
const { getDictionaryValue } = useDictionaryActions()
// 处理封面图片变化 // 处理封面图片变化
const handleCoverImagesChange = (images: CoverImage[]) => { const handleCoverImagesChange = (images: CoverImage[]) => {
@@ -42,15 +41,47 @@ const PublishForm: React.FC<{
onChange(key, value) onChange(key, value)
} }
// 获取字典选项
const getDictionaryOptions = (key: string, defaultValue: any[] = []) => {
// 处理场馆选择 const dictValue = getDictionaryValue(key, defaultValue)
const handleStadiumSelect = (stadium: Stadium | null) => { if (Array.isArray(dictValue)) {
setSelectedStadium(stadium) return dictValue.map(item => ({
if (stadium) { label: item.label || item.name || item.value || item,
updateFormData('location', stadium.name) value: item.value || item.id || item
}))
} }
setShowStadiumSelector(false) 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 renderSummary = (item: FormFieldConfig) => { const renderSummary = (item: FormFieldConfig) => {
@@ -62,47 +93,23 @@ const PublishForm: React.FC<{
// 提交表单
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 }) const dynamicConfig = getDynamicFormConfig()
Taro.showToast({
title: '发布成功',
icon: 'success'
})
}
return ( return (
<View className={styles['publish-form']}> <View className={styles['publish-form']}>
<View className={styles['publish-ball__content']}> <View className={styles['publish-ball__content']}>
{ {
optionsConfig.map((item) => { dynamicConfig.map((item) => {
const Component = componentMap[item.type] const Component = componentMap[item.type]
const optionProps = { const optionProps = {
...item.props, ...item.props,
...(item.type === FieldType.TEXTAREATAG ? { options: item.options } : {}), ...(item.type === FieldType.TEXTAREATAG ? { options: item.options } : {}),
...(item.props?.className ? { className: styles[item.props.className] } : {}) ...(item.props?.className ? { className: styles[item.props.className] } : {}),
...(item.type === FieldType.WECHATCONTACT ? { wechatId: formData.wechat_contact } : {})
} }
console.log(item.props?.className)
console.log(optionProps, item.label, formData[item.key]);
if (item.type === FieldType.UPLOADIMAGE) { if (item.type === FieldType.UPLOADIMAGE) {
/* 活动封面 */ /* 活动封面 */
return <ImageUpload return <ImageUpload
@@ -124,8 +131,8 @@ const PublishForm: React.FC<{
<View className={styles['bg-section']}> <View className={styles['bg-section']}>
<FormBasicInfo <FormBasicInfo
children={item.children || []} children={item.children || []}
value={formData[item.key]} value={formData[item.prop]}
onChange={(value) => updateFormData(item.key as keyof PublishBallFormData, value)} onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps} {...optionProps}
/> />
</View> </View>
@@ -144,8 +151,8 @@ const PublishForm: React.FC<{
<View className={styles['bg-section']}> <View className={styles['bg-section']}>
<Component <Component
label={item.label} label={item.label}
value={formData[item.key]} value={formData[item.prop]}
onChange={(value) => updateFormData(item.key as keyof PublishBallFormData, value)} onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps} {...optionProps}
placeholder={item.placeholder} placeholder={item.placeholder}
/> />
@@ -156,13 +163,6 @@ const PublishForm: React.FC<{
} }
</View> </View>
{/* 场馆选择弹窗 */}
<SelectStadium
visible={showStadiumSelector}
onClose={() => setShowStadiumSelector(false)}
onConfirm={handleStadiumSelect}
/>
</View> </View>
) )
} }

View File

@@ -68,6 +68,13 @@ class CommonApiService {
}) })
} }
// 获取字典数据
async getDictionaryManyKey(keys: string): Promise<ApiResponse<any>> {
return httpService.get('/parameter/many_key', { keys }, {
showLoading: false,
})
}
} }
// 导出通用API服务实例 // 导出通用API服务实例

View File

@@ -3,17 +3,31 @@ import type { ApiResponse } from './httpService'
// 用户接口 // 用户接口
export interface PublishBallData { export interface PublishBallData {
title: string, title: string // 球局标题
venue_id: number, image_list: Array<string>[] // 球局封面
creator_id: number,
game_date: string,
start_time: string, start_time: string,
end_time: string, end_time: string
max_participants: number, play_type: string // 玩法类型
current_participants: number, price: number | string // 价格
ntrp_level: string, venue_id?: number | null // 场地id
play_style: string, location_name?: string // 场地名称
description: 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>[] // 场地图片
max_players: number // 人数要求
current_players: number // 人数要求
skill_level_min: number // 水平要求(NTRP)
skill_level_max: number // 水平要求(NTRP)
description: string // 备注
description_tag: Array<string>[] // 备注标签
is_substitute_supported: boolean // 是否支持替补
is_wechat_contact: boolean // 是否需要微信联系
wechat_contact?: string // 微信联系
} }
// 响应接口 // 响应接口
@@ -23,15 +37,35 @@ export interface Response {
data: any data: any
} }
// 响应接口
export interface StadiumListResponse {
rows: Stadium[]
}
export interface Stadium {
id?: string
name: string
address?: string
istance?: string
longitude?: number
latitude?: number
}
// 发布球局类 // 发布球局类
class PublishService { class PublishService {
// 用户登录 // 发布
async createPersonal(data: PublishBallData): Promise<ApiResponse<Response>> { async createPersonal(data: PublishBallData): Promise<ApiResponse<Response>> {
return httpService.post('/games/create', data, { return httpService.post('/games/create', data, {
showLoading: true, showLoading: true,
loadingText: '发布中...' loadingText: '发布中...'
}) })
} }
// 获取球场列表
async getStadiumList(data: { seachOption: { latitude: number, longitude: number }}): Promise<ApiResponse<StadiumListResponse>> {
return httpService.post('/venues/list', data, {
showLoading: false })
}
} }
// 导出认证服务实例 // 导出认证服务实例

View File

@@ -0,0 +1,79 @@
import { create } from 'zustand'
import commonApi from '../services/commonApi'
// 字典数据接口
export interface DictionaryData {
[key: string]: any
}
// 字典Store状态接口
interface DictionaryState {
dictionaryData: DictionaryData
isLoading: boolean
error: string | null
fetchDictionary: () => Promise<void>
getDictionaryValue: (key: string, defaultValue?: any) => any
clearDictionary: () => void
}
// 创建字典Store
export const useDictionaryStore = create<DictionaryState>()((set, get) => ({
// 初始状态
dictionaryData: {},
isLoading: false,
error: null,
// 获取字典数据
fetchDictionary: async () => {
set({ isLoading: true, error: null })
try {
const keys = 'publishing_requirements,court_type,court_surface,supplementary_information,game_play';
const response = await commonApi.getDictionaryManyKey(keys)
if (response.code === 0 && response.data) {
const dictionaryData = {};
keys.split(',').forEach(key => {
const list = response.data[key];
const listData = list.split('|');
dictionaryData[key] = listData;
})
set({
dictionaryData: dictionaryData || {},
isLoading: false
})
console.log('字典数据获取成功:', response.data)
} else {
throw new Error(response.message || '获取字典数据失败')
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '获取字典数据失败'
set({
error: errorMessage,
isLoading: false
})
console.error('获取字典数据失败:', error)
}
},
// 获取字典值
getDictionaryValue: (key: string, defaultValue?: any) => {
const { dictionaryData } = get()
return dictionaryData[key] !== undefined ? dictionaryData[key] : defaultValue
},
// 清空字典数据
clearDictionary: () => {
set({ dictionaryData: {}, error: null })
}
}))
// 导出hooks
export const useDictionaryData = () => useDictionaryStore((state) => state.dictionaryData)
export const useDictionaryLoading = () => useDictionaryStore((state) => state.isLoading)
export const useDictionaryError = () => useDictionaryStore((state) => state.error)
export const useDictionaryActions = () => useDictionaryStore((state) => ({
fetchDictionary: state.fetchDictionary,
getDictionaryValue: state.getDictionaryValue,
clearDictionary: state.clearDictionary
}))

View File

@@ -1,5 +1,12 @@
import Taro from '@tarojs/taro' import Taro from '@tarojs/taro'
export interface Location {
latitude: number
longitude: number
speed?: number
accuracy?: number
}
export interface LocationInfo { export interface LocationInfo {
latitude: number latitude: number
longitude: number longitude: number
@@ -56,6 +63,24 @@ export const chooseLocation = (): Promise<LocationInfo> => {
}) })
} }
export const getLocation = (): Promise<Location> => {
return new Promise((resolve, reject) => {
Taro.getLocation({
success: (res) => {
resolve({
latitude: res.latitude,
longitude: res.longitude,
speed: res.speed,
accuracy: res.accuracy
})
},
fail: (error) => {
reject(error)
}
})
})
}
// 逆地理编码简化版本实际项目中应该调用真实的地图服务API // 逆地理编码简化版本实际项目中应该调用真实的地图服务API
export const reverseGeocode = (latitude: number, longitude: number): Promise<string> => { export const reverseGeocode = (latitude: number, longitude: number): Promise<string> => {
return new Promise((resolve) => { return new Promise((resolve) => {

View File

@@ -20,14 +20,8 @@ export interface PublishBallFormData {
venue_description?: string // 场地描述 venue_description?: string // 场地描述
venue_image_list?: Array<string>[] // 场地图片 venue_image_list?: Array<string>[] // 场地图片
} }
players:{ players: [number, number] // 人数要求
current_players: number // 当前人数 skill_level: [number, number] // 水平要求(NTRP)
max_players: number // 最大人数
}
skill_level: {
skill_level_max: number // 最高水平要求(NTRP)
skill_level_min: number //最低水平要求(NTRP)
}
descriptionInfo: { descriptionInfo: {
description: string // 备注 description: string // 备注
description_tag: Array<string>[] // 备注标签 description_tag: Array<string>[] // 备注标签