增加发布
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
pages: [
|
pages: [
|
||||||
|
'pages/publishBall/index',
|
||||||
'pages/index/index'
|
'pages/index/index'
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
|
|||||||
102
src/components/DynamicForm/DynamicForm.scss
Normal file
102
src/components/DynamicForm/DynamicForm.scss
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
.dynamic-form {
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 200px;
|
||||||
|
|
||||||
|
.form-reminder {
|
||||||
|
background: #fff8dc;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
background: #fff;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.delete-form-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: #ff4757;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px 16px 30px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
.add-form-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&.submitting {
|
||||||
|
background: #999;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
color: #999;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
326
src/components/DynamicForm/DynamicForm.tsx
Normal file
326
src/components/DynamicForm/DynamicForm.tsx
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
import { View, Text, Button } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Dialog } from '@nutui/nutui-react-taro'
|
||||||
|
import { FormConfig, FormFieldConfig } from '../../config/formConfig'
|
||||||
|
import FieldRenderer from './FieldRenderer'
|
||||||
|
import commonApi from '../../services/commonApi'
|
||||||
|
import './DynamicForm.scss'
|
||||||
|
|
||||||
|
interface DynamicFormProps {
|
||||||
|
config: FormConfig
|
||||||
|
formType?: string // 表单类型,用于API提交
|
||||||
|
onSubmit?: (formData: any[]) => void | Promise<void>
|
||||||
|
onSubmitSuccess?: (response: any) => void
|
||||||
|
onSubmitError?: (error: any) => void
|
||||||
|
onAddForm?: () => void
|
||||||
|
onDeleteForm?: (index: number) => void
|
||||||
|
enableApiSubmit?: boolean // 是否启用API提交
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormData {
|
||||||
|
id: string
|
||||||
|
data: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DynamicForm: React.FC<DynamicFormProps> = ({
|
||||||
|
config,
|
||||||
|
formType = 'default',
|
||||||
|
onSubmit,
|
||||||
|
onSubmitSuccess,
|
||||||
|
onSubmitError,
|
||||||
|
onAddForm,
|
||||||
|
onDeleteForm,
|
||||||
|
enableApiSubmit = true
|
||||||
|
}) => {
|
||||||
|
const [forms, setForms] = useState<FormData[]>([])
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||||
|
const [deleteIndex, setDeleteIndex] = useState<number>(-1)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// 初始化表单数据
|
||||||
|
useEffect(() => {
|
||||||
|
const initialData = createInitialFormData()
|
||||||
|
setForms([{
|
||||||
|
id: Date.now().toString(),
|
||||||
|
data: initialData
|
||||||
|
}])
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
// 根据配置创建初始数据
|
||||||
|
const createInitialFormData = () => {
|
||||||
|
const data: Record<string, any> = {}
|
||||||
|
config.fields.forEach(field => {
|
||||||
|
data[field.key] = field.defaultValue
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加新表单
|
||||||
|
const handleAddForm = () => {
|
||||||
|
const newForm: FormData = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
data: createInitialFormData()
|
||||||
|
}
|
||||||
|
setForms([...forms, newForm])
|
||||||
|
onAddForm?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除表单
|
||||||
|
const handleDeleteForm = (index: number) => {
|
||||||
|
if (forms.length <= 1) return
|
||||||
|
|
||||||
|
setDeleteIndex(index)
|
||||||
|
setShowDeleteDialog(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteIndex >= 0 && forms.length > 1) {
|
||||||
|
const newForms = forms.filter((_, i) => i !== deleteIndex)
|
||||||
|
setForms(newForms)
|
||||||
|
onDeleteForm?.(deleteIndex)
|
||||||
|
}
|
||||||
|
setShowDeleteDialog(false)
|
||||||
|
setDeleteIndex(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段值
|
||||||
|
const updateFieldValue = (formIndex: number, fieldKey: string, value: any) => {
|
||||||
|
const newForms = [...forms]
|
||||||
|
newForms[formIndex].data[fieldKey] = value
|
||||||
|
setForms(newForms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单验证
|
||||||
|
const validateForm = (formData: Record<string, any>) => {
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
config.fields.forEach(field => {
|
||||||
|
if (field.required) {
|
||||||
|
const value = formData[field.key]
|
||||||
|
if (value === undefined || value === null || value === '' ||
|
||||||
|
(Array.isArray(value) && value.length === 0)) {
|
||||||
|
errors.push(`${field.title}为必填项`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字验证
|
||||||
|
if (field.validation && formData[field.key] !== '') {
|
||||||
|
const value = Number(formData[field.key])
|
||||||
|
if (field.validation.min !== undefined && value < field.validation.min) {
|
||||||
|
errors.push(field.validation.message || `${field.title}不能小于${field.validation.min}`)
|
||||||
|
}
|
||||||
|
if (field.validation.max !== undefined && value > field.validation.max) {
|
||||||
|
errors.push(field.validation.message || `${field.title}不能大于${field.validation.max}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
const handleImageUpload = async (formData: Record<string, any>): Promise<Record<string, any>> => {
|
||||||
|
const processedData = { ...formData }
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(formData)) {
|
||||||
|
// 检查是否为图片字段
|
||||||
|
const fieldConfig = config.fields.find(field => field.key === key)
|
||||||
|
if (fieldConfig?.type === 'image-upload' && Array.isArray(value) && value.length > 0) {
|
||||||
|
try {
|
||||||
|
// 过滤出本地图片路径(临时文件)
|
||||||
|
const localImages = value.filter(url => url.startsWith('wxfile://') || url.startsWith('http://tmp/'))
|
||||||
|
const remoteImages = value.filter(url => !url.startsWith('wxfile://') && !url.startsWith('http://tmp/'))
|
||||||
|
|
||||||
|
if (localImages.length > 0) {
|
||||||
|
// 上传本地图片
|
||||||
|
const uploadResult = await commonApi.uploadImages(localImages)
|
||||||
|
const uploadedUrls = uploadResult.data.map(item => item.url)
|
||||||
|
|
||||||
|
// 合并远程图片和新上传的图片
|
||||||
|
processedData[key] = [...remoteImages, ...uploadedUrls]
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('图片上传失败:', error)
|
||||||
|
throw new Error(`${fieldConfig.title}上传失败`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return processedData
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交表单
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (isSubmitting) return
|
||||||
|
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 表单验证
|
||||||
|
const allErrors: string[] = []
|
||||||
|
const formDataList = forms.map((form, index) => {
|
||||||
|
const errors = validateForm(form.data)
|
||||||
|
if (errors.length > 0) {
|
||||||
|
allErrors.push(`第${index + 1}${config.title || '表单'}: ${errors.join(', ')}`)
|
||||||
|
}
|
||||||
|
return form.data
|
||||||
|
})
|
||||||
|
|
||||||
|
if (allErrors.length > 0) {
|
||||||
|
Taro.showModal({
|
||||||
|
title: '表单验证失败',
|
||||||
|
content: allErrors.join('\n'),
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理图片上传
|
||||||
|
const processedFormDataList = await Promise.all(
|
||||||
|
formDataList.map(formData => handleImageUpload(formData))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 执行自定义提交逻辑
|
||||||
|
if (onSubmit) {
|
||||||
|
await onSubmit(processedFormDataList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API提交
|
||||||
|
if (enableApiSubmit) {
|
||||||
|
try {
|
||||||
|
let response
|
||||||
|
|
||||||
|
// 根据表单类型选择不同的API
|
||||||
|
switch (formType) {
|
||||||
|
case 'publishBall':
|
||||||
|
if (processedFormDataList.length === 1) {
|
||||||
|
response = await commonApi.publishMatch(processedFormDataList[0] as any)
|
||||||
|
} else {
|
||||||
|
response = await commonApi.publishMultipleMatches(processedFormDataList as any)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'userProfile':
|
||||||
|
response = await commonApi.updateUserProfile(processedFormDataList[0] as any)
|
||||||
|
break
|
||||||
|
case 'feedback':
|
||||||
|
response = await commonApi.submitFeedback(processedFormDataList[0] as any)
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
response = await commonApi.submitForm(formType, processedFormDataList)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交成功回调
|
||||||
|
onSubmitSuccess?.(response)
|
||||||
|
|
||||||
|
Taro.showToast({
|
||||||
|
title: '提交成功!',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
} catch (apiError) {
|
||||||
|
console.error('API提交失败:', apiError)
|
||||||
|
onSubmitError?.(apiError)
|
||||||
|
throw apiError
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 不使用API提交时,显示成功提示
|
||||||
|
Taro.showToast({
|
||||||
|
title: '提交成功!',
|
||||||
|
icon: 'success'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('表单提交失败:', error)
|
||||||
|
|
||||||
|
// 错误回调
|
||||||
|
onSubmitError?.(error)
|
||||||
|
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '提交失败,请重试'
|
||||||
|
Taro.showToast({
|
||||||
|
title: errorMessage,
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='dynamic-form'>
|
||||||
|
{/* 提醒文本 */}
|
||||||
|
{config.reminder && (
|
||||||
|
<View className='form-reminder'>
|
||||||
|
<Text>{config.reminder}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 表单列表 */}
|
||||||
|
{forms.map((form, formIndex) => (
|
||||||
|
<View key={form.id} className='form-container'>
|
||||||
|
{/* 删除按钮 */}
|
||||||
|
{forms.length > 1 && (
|
||||||
|
<View
|
||||||
|
className='delete-form-btn'
|
||||||
|
onClick={() => handleDeleteForm(formIndex)}
|
||||||
|
>
|
||||||
|
<Text>×</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 渲染所有字段 */}
|
||||||
|
{config.fields.map((fieldConfig: FormFieldConfig) => (
|
||||||
|
<FieldRenderer
|
||||||
|
key={fieldConfig.key}
|
||||||
|
config={fieldConfig}
|
||||||
|
value={form.data[fieldConfig.key]}
|
||||||
|
onChange={(value) => updateFieldValue(formIndex, fieldConfig.key, value)}
|
||||||
|
matchId={form.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 底部操作区 */}
|
||||||
|
<View className='form-actions'>
|
||||||
|
{config.actions?.addText && (
|
||||||
|
<View className='add-form-btn' onClick={handleAddForm}>
|
||||||
|
<Text className='plus-icon'>+</Text>
|
||||||
|
<Text>{config.actions.addText}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.actions?.submitText && (
|
||||||
|
<Button
|
||||||
|
className={`submit-btn ${isSubmitting ? 'submitting' : ''}`}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? '提交中...' : config.actions.submitText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{config.actions?.disclaimer && (
|
||||||
|
<Text className='disclaimer'>
|
||||||
|
{config.actions.disclaimer}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 删除确认弹窗 */}
|
||||||
|
<Dialog
|
||||||
|
visible={showDeleteDialog}
|
||||||
|
title='确认删除'
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
onCancel={() => {
|
||||||
|
setShowDeleteDialog(false)
|
||||||
|
setDeleteIndex(-1)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确定要删除这场约球吗?
|
||||||
|
</Dialog>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DynamicForm
|
||||||
304
src/components/DynamicForm/FieldRenderer.scss
Normal file
304
src/components/DynamicForm/FieldRenderer.scss
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
.field-renderer {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.field-header {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.field-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-content {
|
||||||
|
// 图片上传
|
||||||
|
.upload-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 2px dashed #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ff4757;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本输入
|
||||||
|
.text-input, .number-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间显示
|
||||||
|
.time-display {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
width: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-left: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场地输入
|
||||||
|
.venue-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding-right: 16px;
|
||||||
|
|
||||||
|
.venue-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 多选按钮
|
||||||
|
.multi-select-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计数器
|
||||||
|
.counter-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.count-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.count-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.count-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-number {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑动条
|
||||||
|
.slider-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.slider-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-wrapper {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.slider-fill {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 2px;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: -8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单选按钮组
|
||||||
|
.radio-group-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.radio-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 复选框
|
||||||
|
.checkbox-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
background: #000;
|
||||||
|
border-color: #000;
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
279
src/components/DynamicForm/FieldRenderer.tsx
Normal file
279
src/components/DynamicForm/FieldRenderer.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View, Text, Input, Image, Button } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { FormFieldConfig } from '../../config/formConfig'
|
||||||
|
import './FieldRenderer.scss'
|
||||||
|
|
||||||
|
interface FieldRendererProps {
|
||||||
|
config: FormFieldConfig
|
||||||
|
value: any
|
||||||
|
onChange: (value: any) => void
|
||||||
|
matchId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldRenderer: React.FC<FieldRendererProps> = ({ config, value, onChange, matchId }) => {
|
||||||
|
const { key, type, title, placeholder, hint, options, config: fieldConfig } = config
|
||||||
|
|
||||||
|
// 图片上传组件
|
||||||
|
const renderImageUpload = () => {
|
||||||
|
const handleImageUpload = () => {
|
||||||
|
Taro.chooseImage({
|
||||||
|
count: (fieldConfig?.maxImages || 9) - (value?.length || 0),
|
||||||
|
sizeType: ['original', 'compressed'],
|
||||||
|
sourceType: ['album', 'camera'],
|
||||||
|
success: (res) => {
|
||||||
|
const newImages = [...(value || []), ...res.tempFilePaths].slice(0, fieldConfig?.maxImages || 9)
|
||||||
|
onChange(newImages)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeImage = (index: number) => {
|
||||||
|
const newImages = (value || []).filter((_: any, i: number) => i !== index)
|
||||||
|
onChange(newImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='upload-container'>
|
||||||
|
<View className='upload-btn' onClick={handleImageUpload}>
|
||||||
|
<Text className='plus-icon'>+</Text>
|
||||||
|
</View>
|
||||||
|
{(value || []).map((url: string, i: number) => (
|
||||||
|
<View key={i} className='image-item'>
|
||||||
|
<Image src={url} className='cover-image' mode='aspectFill' />
|
||||||
|
<View className='remove-btn' onClick={() => removeImage(i)}>
|
||||||
|
<Text>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文本输入组件
|
||||||
|
const renderTextInput = () => (
|
||||||
|
<Input
|
||||||
|
className='text-input'
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value || ''}
|
||||||
|
onInput={(e) => onChange(e.detail.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 数字输入组件
|
||||||
|
const renderNumberInput = () => (
|
||||||
|
<Input
|
||||||
|
className='number-input'
|
||||||
|
placeholder={placeholder}
|
||||||
|
type='number'
|
||||||
|
value={value || ''}
|
||||||
|
onInput={(e) => onChange(e.detail.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 时间显示组件
|
||||||
|
const renderTimeDisplay = () => (
|
||||||
|
<View className='time-display'>
|
||||||
|
<View className='time-row'>
|
||||||
|
<Text className='time-label'>开始</Text>
|
||||||
|
<Text className='time-value'>11/23/2025</Text>
|
||||||
|
<Text className='time-value'>8:00 AM</Text>
|
||||||
|
</View>
|
||||||
|
<View className='time-row'>
|
||||||
|
<Text className='time-label'>结束</Text>
|
||||||
|
<Text className='time-value'>11/23/2025</Text>
|
||||||
|
<Text className='time-value'>10:00 AM</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 场地输入组件
|
||||||
|
const renderVenueInput = () => (
|
||||||
|
<View className='venue-input-container'>
|
||||||
|
<Input
|
||||||
|
className='venue-input'
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value || ''}
|
||||||
|
onInput={(e) => onChange(e.detail.value)}
|
||||||
|
/>
|
||||||
|
{fieldConfig?.showArrow && <Text className='arrow'>›</Text>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 多选按钮组件
|
||||||
|
const renderMultiSelect = () => (
|
||||||
|
<View className='multi-select-container'>
|
||||||
|
{options?.map((option) => (
|
||||||
|
<View
|
||||||
|
key={option.value}
|
||||||
|
className={`select-btn ${(value || []).includes(option.value) ? 'selected' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
const currentValues = value || []
|
||||||
|
const newValues = currentValues.includes(option.value)
|
||||||
|
? currentValues.filter((v: any) => v !== option.value)
|
||||||
|
: [...currentValues, option.value]
|
||||||
|
onChange(newValues)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text>{option.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 计数器组件
|
||||||
|
const renderCounter = () => {
|
||||||
|
const minValue = fieldConfig?.minValue || 1
|
||||||
|
const maxValue = fieldConfig?.maxValue || 50
|
||||||
|
const currentValue = value || { min: 1, max: 4 }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='counter-container'>
|
||||||
|
<View className='count-group'>
|
||||||
|
<Text className='count-label'>最少</Text>
|
||||||
|
<View className='count-controls'>
|
||||||
|
<View
|
||||||
|
className='count-btn'
|
||||||
|
onClick={() => onChange({
|
||||||
|
...currentValue,
|
||||||
|
min: Math.max(minValue, currentValue.min - 1)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='count-number'>{currentValue.min}</Text>
|
||||||
|
<View
|
||||||
|
className='count-btn'
|
||||||
|
onClick={() => onChange({
|
||||||
|
...currentValue,
|
||||||
|
min: Math.min(maxValue, currentValue.min + 1)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className='separator'>至</Text>
|
||||||
|
<View className='count-group'>
|
||||||
|
<View className='count-controls'>
|
||||||
|
<View
|
||||||
|
className='count-btn'
|
||||||
|
onClick={() => onChange({
|
||||||
|
...currentValue,
|
||||||
|
max: Math.max(currentValue.min, currentValue.max - 1)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text>-</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='count-number'>{currentValue.max}</Text>
|
||||||
|
<View
|
||||||
|
className='count-btn'
|
||||||
|
onClick={() => onChange({
|
||||||
|
...currentValue,
|
||||||
|
max: Math.min(maxValue, currentValue.max + 1)
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Text>+</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className='count-label'>最多</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 滑动条组件
|
||||||
|
const renderSlider = () => {
|
||||||
|
const range = fieldConfig?.range || [1, 7]
|
||||||
|
const currentValue = value || [2.0, 4.0]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='slider-container'>
|
||||||
|
<Text className='slider-label'>2.0及以下</Text>
|
||||||
|
<View className='slider-wrapper'>
|
||||||
|
<View className='slider-track'>
|
||||||
|
<View className='slider-fill' />
|
||||||
|
<View
|
||||||
|
className='slider-thumb left'
|
||||||
|
style={{ left: `${((currentValue[0] - range[0]) / (range[1] - range[0])) * 100}%` }}
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
className='slider-thumb right'
|
||||||
|
style={{ left: `${((currentValue[1] - range[0]) / (range[1] - range[0])) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className='slider-label'>4.0及以上</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单选组件
|
||||||
|
const renderRadioGroup = () => (
|
||||||
|
<View className='radio-group-container'>
|
||||||
|
{options?.map((option) => (
|
||||||
|
<View
|
||||||
|
key={option.value}
|
||||||
|
className={`radio-btn ${value === option.value ? 'selected' : ''}`}
|
||||||
|
onClick={() => onChange(option.value)}
|
||||||
|
>
|
||||||
|
<Text>{option.label}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 复选框组件
|
||||||
|
const renderCheckbox = () => (
|
||||||
|
<View className='checkbox-container'>
|
||||||
|
<View
|
||||||
|
className={`checkbox ${value ? 'checked' : ''}`}
|
||||||
|
onClick={() => onChange(!value)}
|
||||||
|
>
|
||||||
|
{value && <Text className='checkmark'>✓</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
// 根据类型渲染不同组件
|
||||||
|
const renderField = () => {
|
||||||
|
switch (type) {
|
||||||
|
case 'image-upload':
|
||||||
|
return renderImageUpload()
|
||||||
|
case 'text-input':
|
||||||
|
return renderTextInput()
|
||||||
|
case 'number-input':
|
||||||
|
return renderNumberInput()
|
||||||
|
case 'time-display':
|
||||||
|
return renderTimeDisplay()
|
||||||
|
case 'venue-input':
|
||||||
|
return renderVenueInput()
|
||||||
|
case 'multi-select':
|
||||||
|
return renderMultiSelect()
|
||||||
|
case 'counter':
|
||||||
|
return renderCounter()
|
||||||
|
case 'slider':
|
||||||
|
return renderSlider()
|
||||||
|
case 'radio-group':
|
||||||
|
return renderRadioGroup()
|
||||||
|
case 'checkbox':
|
||||||
|
return renderCheckbox()
|
||||||
|
default:
|
||||||
|
return <Text>未知字段类型: {type}</Text>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='field-renderer'>
|
||||||
|
<View className='field-header'>
|
||||||
|
<Text className='field-title'>{title}</Text>
|
||||||
|
{hint && <Text className='field-hint'>{hint}</Text>}
|
||||||
|
</View>
|
||||||
|
<View className='field-content'>
|
||||||
|
{renderField()}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FieldRenderer
|
||||||
9
src/components/DynamicForm/index.ts
Normal file
9
src/components/DynamicForm/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
// DynamicForm组件统一导出
|
||||||
|
export { default as DynamicForm } from './DynamicForm'
|
||||||
|
export { default as FieldRenderer } from './FieldRenderer'
|
||||||
|
|
||||||
|
// 导出类型
|
||||||
|
export type { FormConfig, FormFieldConfig, FieldType } from '../../config/formSchema/bulishBallFormSchema'
|
||||||
|
|
||||||
|
// 默认导出DynamicForm组件
|
||||||
|
export { default } from './DynamicForm'
|
||||||
173
src/config/formSchema/bulishBallFormSchema.ts
Normal file
173
src/config/formSchema/bulishBallFormSchema.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// 表单字段类型定义
|
||||||
|
export type FieldType =
|
||||||
|
| 'image-upload'
|
||||||
|
| 'text-input'
|
||||||
|
| 'number-input'
|
||||||
|
| 'time-display'
|
||||||
|
| 'multi-select'
|
||||||
|
| 'counter'
|
||||||
|
| 'slider'
|
||||||
|
| 'radio-group'
|
||||||
|
| 'checkbox'
|
||||||
|
| 'venue-input'
|
||||||
|
|
||||||
|
// 表单字段配置接口
|
||||||
|
export interface FormFieldConfig {
|
||||||
|
key: string // 字段名
|
||||||
|
type: FieldType // 字段类型
|
||||||
|
title: string // 显示标题
|
||||||
|
required?: boolean // 是否必填
|
||||||
|
placeholder?: string // 占位符
|
||||||
|
hint?: string // 提示文字
|
||||||
|
defaultValue?: any // 默认值
|
||||||
|
validation?: { // 验证规则
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
pattern?: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
options?: Array<{ // 选项(用于选择类型)
|
||||||
|
label: string
|
||||||
|
value: any
|
||||||
|
}>
|
||||||
|
config?: { // 特殊配置
|
||||||
|
maxImages?: number // 图片上传最大数量
|
||||||
|
minValue?: number // 计数器最小值
|
||||||
|
maxValue?: number // 计数器最大值
|
||||||
|
range?: [number, number] // 滑动条范围
|
||||||
|
unit?: string // 单位
|
||||||
|
showArrow?: boolean // 是否显示箭头
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整的表单配置
|
||||||
|
export interface FormConfig {
|
||||||
|
title: string // 表单标题
|
||||||
|
reminder?: string // 提醒文本
|
||||||
|
fields: FormFieldConfig[] // 字段列表
|
||||||
|
actions?: { // 底部操作
|
||||||
|
addText?: string
|
||||||
|
submitText?: string
|
||||||
|
disclaimer?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布球的表单配置
|
||||||
|
export const publishBallFormConfig: FormConfig = {
|
||||||
|
title: '发布个人约球',
|
||||||
|
reminder: '提醒: 活动开始前x小时未达到最低招募人数,活动自动取消;活动结束2天后,报名费自动转入[钱包—余额],可提现到微信。',
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
key: 'cover',
|
||||||
|
type: 'image-upload',
|
||||||
|
title: '活动封面',
|
||||||
|
required: false,
|
||||||
|
defaultValue: [],
|
||||||
|
config: {
|
||||||
|
maxImages: 9
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'theme',
|
||||||
|
type: 'text-input',
|
||||||
|
title: '活动主题 (选填)',
|
||||||
|
required: false,
|
||||||
|
placeholder: '好的主题更吸引人哦',
|
||||||
|
defaultValue: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
type: 'time-display',
|
||||||
|
title: '活动时间',
|
||||||
|
required: true,
|
||||||
|
defaultValue: {
|
||||||
|
startTime: '2025-11-23 08:00',
|
||||||
|
endTime: '2025-11-23 10:00'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'venue',
|
||||||
|
type: 'venue-input',
|
||||||
|
title: '活动场地',
|
||||||
|
required: true,
|
||||||
|
placeholder: '选择活动地点',
|
||||||
|
defaultValue: '',
|
||||||
|
config: {
|
||||||
|
showArrow: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'price',
|
||||||
|
type: 'number-input',
|
||||||
|
title: '人均价格/元',
|
||||||
|
required: true,
|
||||||
|
placeholder: '请填写每个人多少钱',
|
||||||
|
defaultValue: '',
|
||||||
|
validation: {
|
||||||
|
min: 0,
|
||||||
|
message: '价格不能为负数'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playStyle',
|
||||||
|
type: 'multi-select',
|
||||||
|
title: '活动玩法',
|
||||||
|
required: true,
|
||||||
|
defaultValue: [],
|
||||||
|
options: [
|
||||||
|
{ label: '单打', value: '单打' },
|
||||||
|
{ label: '双打', value: '双打' },
|
||||||
|
{ label: '娱乐拉球', value: '娱乐拉球' },
|
||||||
|
{ label: '到了再说', value: '到了再说' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playerCount',
|
||||||
|
type: 'counter',
|
||||||
|
title: '招募人数',
|
||||||
|
required: true,
|
||||||
|
defaultValue: { min: 1, max: 4 },
|
||||||
|
config: {
|
||||||
|
minValue: 1,
|
||||||
|
maxValue: 50
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'ntrpRange',
|
||||||
|
type: 'slider',
|
||||||
|
title: 'NTRP水平区间',
|
||||||
|
required: true,
|
||||||
|
defaultValue: [2.0, 4.0],
|
||||||
|
config: {
|
||||||
|
range: [1.0, 7.0],
|
||||||
|
unit: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'genderPreference',
|
||||||
|
type: 'radio-group',
|
||||||
|
title: '补充要求 (选填)',
|
||||||
|
hint: '补充性别偏好、特殊要求和注意事项等信息',
|
||||||
|
required: false,
|
||||||
|
defaultValue: 'unlimited',
|
||||||
|
options: [
|
||||||
|
{ label: '选择填入', value: 'select' },
|
||||||
|
{ label: '仅限男生', value: 'male' },
|
||||||
|
{ label: '仅限女生', value: 'female' },
|
||||||
|
{ label: '性别不限', value: 'unlimited' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'autoStandby',
|
||||||
|
type: 'checkbox',
|
||||||
|
title: '开启自动候补逻辑',
|
||||||
|
required: false,
|
||||||
|
defaultValue: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
actions: {
|
||||||
|
addText: '再添加一场',
|
||||||
|
submitText: '完成',
|
||||||
|
disclaimer: '点击确定发布按钮,即表示已阅读并同意《约球规则》'
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/pages/publishBall/index.config.ts
Normal file
3
src/pages/publishBall/index.config.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '发布个人约球'
|
||||||
|
})
|
||||||
402
src/pages/publishBall/index.scss
Normal file
402
src/pages/publishBall/index.scss
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
.publish-ball-page {
|
||||||
|
background: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding-bottom: 200px;
|
||||||
|
|
||||||
|
.reminder {
|
||||||
|
background: #fff8dc;
|
||||||
|
padding: 16px;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.match-form {
|
||||||
|
background: #fff;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: #ff4757;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 封面上传
|
||||||
|
.cover-upload {
|
||||||
|
.upload-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border: 2px dashed #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fafafa;
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-item {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: -6px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #ff4757;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 主题输入
|
||||||
|
.theme-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 时间显示
|
||||||
|
.time-display {
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
|
||||||
|
.time-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-label {
|
||||||
|
width: 40px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
margin-left: 16px;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 场地输入
|
||||||
|
.venue-input-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding-right: 16px;
|
||||||
|
|
||||||
|
.venue-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.arrow {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 价格输入
|
||||||
|
.price-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 玩法选择
|
||||||
|
.play-style-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.play-style-btn {
|
||||||
|
padding: 8px 20px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人数控制
|
||||||
|
.player-count-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.count-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.count-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.count-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.count-number {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
min-width: 24px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NTRP滑动条
|
||||||
|
.ntrp-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
.ntrp-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.slider-track {
|
||||||
|
height: 4px;
|
||||||
|
background: #e0e0e0;
|
||||||
|
border-radius: 2px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.slider-fill {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 2px;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-thumb {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #333;
|
||||||
|
border-radius: 50%;
|
||||||
|
top: -8px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 性别选择
|
||||||
|
.requirements-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gender-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.gender-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
&.selected {
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border-color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动候补
|
||||||
|
.standby-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.checkbox {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff;
|
||||||
|
|
||||||
|
&.checked {
|
||||||
|
background: #000;
|
||||||
|
border-color: #000;
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 底部操作区
|
||||||
|
.bottom-actions {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #fff;
|
||||||
|
padding: 20px 16px 30px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
|
||||||
|
.add-match-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 44px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
|
||||||
|
.plus-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.complete-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: #000;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disclaimer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/pages/publishBall/index.tsx
Normal file
62
src/pages/publishBall/index.tsx
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { View } from '@tarojs/components'
|
||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import DynamicForm from '../../components/DynamicForm/DynamicForm'
|
||||||
|
import { publishBallFormConfig } from '../../config/formSchema/bulishBallFormSchema'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
const PublishBallPage: React.FC = () => {
|
||||||
|
// 提交成功回调
|
||||||
|
const handleSubmitSuccess = (response: any) => {
|
||||||
|
console.log('发布成功:', response)
|
||||||
|
|
||||||
|
Taro.showModal({
|
||||||
|
title: '发布成功',
|
||||||
|
content: response.data.length > 1 ?
|
||||||
|
`成功发布${response.data.length}场约球活动!` :
|
||||||
|
'约球活动发布成功!',
|
||||||
|
showCancel: false,
|
||||||
|
success: () => {
|
||||||
|
// 可以跳转到活动列表页面
|
||||||
|
// Taro.navigateTo({ url: '/pages/matchList/matchList' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交失败回调
|
||||||
|
const handleSubmitError = (error: any) => {
|
||||||
|
console.error('发布失败:', error)
|
||||||
|
|
||||||
|
Taro.showModal({
|
||||||
|
title: '发布失败',
|
||||||
|
content: error.message || '网络错误,请稍后重试',
|
||||||
|
showCancel: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加表单
|
||||||
|
const handleAddForm = () => {
|
||||||
|
console.log('添加新表单')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除表单
|
||||||
|
const handleDeleteForm = (index: number) => {
|
||||||
|
console.log('删除表单:', index)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='publish-ball-page'>
|
||||||
|
<DynamicForm
|
||||||
|
config={publishBallFormConfig}
|
||||||
|
formType='publishBall'
|
||||||
|
enableApiSubmit={true}
|
||||||
|
onSubmitSuccess={handleSubmitSuccess}
|
||||||
|
onSubmitError={handleSubmitError}
|
||||||
|
onAddForm={handleAddForm}
|
||||||
|
onDeleteForm={handleDeleteForm}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PublishBallPage
|
||||||
Reference in New Issue
Block a user