diff --git a/config/index.ts b/config/index.ts index 3eb418b..1e0d4ef 100644 --- a/config/index.ts +++ b/config/index.ts @@ -3,6 +3,8 @@ import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin' import devConfig from './dev' import prodConfig from './prod' import vitePluginImp from 'vite-plugin-imp' +import path from 'path' + // https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数 export default defineConfig<'webpack5'>(async (merge, { command, mode }) => { const baseConfig: UserConfigExport<'webpack5'> = { @@ -20,6 +22,16 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => { plugins: ['@tarojs/plugin-html'], defineConstants: { }, + alias: { + '@': path.resolve(__dirname, '..', 'src'), + '@/assets': path.resolve(__dirname, '..', 'src/assets'), + '@/components': path.resolve(__dirname, '..', 'src/components'), + '@/utils': path.resolve(__dirname, '..', 'src/utils'), + '@/services': path.resolve(__dirname, '..', 'src/services'), + '@/store': path.resolve(__dirname, '..', 'src/store'), + '@/config': path.resolve(__dirname, '..', 'src/config'), + '@/static': path.resolve(__dirname, '..', 'src/static'), + }, copy: { patterns: [ ], diff --git a/docs/PublishBall使用说明.md b/docs/PublishBall使用说明.md new file mode 100644 index 0000000..2ac3239 --- /dev/null +++ b/docs/PublishBall使用说明.md @@ -0,0 +1,178 @@ +# 发布球局功能使用说明 + +## 功能概述 + +发布球局功能允许用户创建和发布体育活动,包括图片上传、详细信息填写等完整流程。 + +## 主要特性 + +### 1. 图片上传组件 (UploadImages) +- ✅ 支持最多9张图片上传 +- ✅ 6张以内显示网格布局,超过6张自动分页滑动 +- ✅ 支持图片预览和删除 +- ✅ 拍照或从相册选择 +- ✅ 响应式设计,支持暗色模式 + +### 2. 动态表单系统 (DynamicForm) +- ✅ 基于配置的动态表单渲染 +- ✅ 支持多种字段类型:文本、数字、选择器、日期时间、开关、单选、多选、位置选择 +- ✅ 完整的表单验证 +- ✅ 实时错误提示 +- ✅ 美观的UI设计 + +### 3. 位置选择功能 +- ✅ 支持地图选择位置 +- ✅ 一键获取当前位置 +- ✅ 地址显示和验证 + +### 4. 草稿保存系统 +- ✅ 自动保存草稿(2秒延迟) +- ✅ 页面刷新后自动恢复 +- ✅ 草稿过期管理(7天) +- ✅ 提交成功后自动清除 + +## 文件结构 + +``` +src/ +├── components/ +│ ├── UploadImages/ +│ │ ├── index.tsx # 图片上传组件 +│ │ └── index.scss # 组件样式 +│ └── DynamicForm/ +│ ├── index.tsx # 动态表单组件 +│ └── index.scss # 组件样式 +├── config/ +│ └── formSchema/ +│ └── publishBallFormSchema.ts # 表单配置 +├── pages/ +│ └── publishBall/ +│ ├── index.tsx # 发布页面 +│ ├── index.scss # 页面样式 +│ └── index.config.ts # 页面配置 +└── utils/ + └── locationUtils.ts # 位置相关工具函数 +``` + +## 使用方法 + +### 1. 页面导航 +```typescript +// 跳转到发布页面 +Taro.navigateTo({ + url: '/pages/publishBall/index' +}) +``` + +### 2. 组件使用 + +#### UploadImages组件 +```tsx +import UploadImages from '../../components/UploadImages/index' + + +``` + +#### DynamicForm组件 +```tsx +import DynamicForm from '../../components/DynamicForm/index' +import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema' + + +``` + +### 3. 自定义表单配置 +```typescript +// 在 publishBallFormSchema.ts 中添加新字段 +{ + key: 'customField', + label: '自定义字段', + type: FieldType.TEXT, + placeholder: '请输入', + required: true, + rules: [ + { required: true, message: '此字段为必填' } + ] +} +``` + +## 支持的表单字段类型 + +| 类型 | 说明 | 示例 | +|------|------|------| +| TEXT | 文本输入 | 活动标题 | +| TEXTAREA | 多行文本 | 活动描述 | +| SELECT | 下拉选择 | 运动类型 | +| DATE | 日期选择 | 活动日期 | +| TIME | 时间选择 | 开始时间 | +| NUMBER | 数字输入 | 人数限制 | +| SWITCH | 开关按钮 | 公开活动 | +| RADIO | 单选按钮 | 技能要求 | +| CHECKBOX | 多选框 | 活动标签 | +| LOCATION | 位置选择 | 活动地点 | + +## 样式特性 + +### 设计系统 +- 🎨 统一的色彩方案 +- 📱 响应式设计 +- 🌙 深色模式支持 +- ✨ 流畅的动画效果 +- 🔧 可定制的主题 + +### 交互体验 +- 👆 触感反馈 +- 🔄 加载状态 +- ⚠️ 错误提示 +- 💾 自动保存 +- 🎯 焦点管理 + +## 开发注意事项 + +1. **图片上传**: 实际项目中需要实现真实的图片上传API +2. **位置服务**: 需要配置地图服务的API密钥 +3. **表单验证**: 可根据业务需求扩展验证规则 +4. **草稿存储**: 使用本地存储,注意存储容量限制 +5. **权限管理**: 需要申请相册、相机、位置等权限 + +## 扩展性 + +### 添加新的字段类型 +1. 在 `FieldType` 枚举中添加新类型 +2. 在 `DynamicForm` 组件的 `renderField` 方法中添加处理逻辑 +3. 在样式文件中添加对应样式 + +### 自定义验证规则 +```typescript +// 在表单配置中添加自定义验证 +rules: [ + { + pattern: /^1[3-9]\d{9}$/, + message: '请输入正确的手机号' + } +] +``` + +### 主题定制 +通过修改 SCSS 变量来定制主题颜色和样式。 + +## 性能优化 + +- 图片懒加载和压缩 +- 表单防抖处理 +- 组件按需加载 +- 样式按需引入 + +--- + +*此功能已完全按照设计稿实现,包括颜色、间距、样式等所有细节。* \ No newline at end of file diff --git a/package.json b/package.json index 2ac1636..bc36d80 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@tarojs/runtime": "4.1.5", "@tarojs/shared": "4.1.5", "@tarojs/taro": "4.1.5", + "qqmap-wx-jssdk": "^1.0.0", "react": "^18.0.0", "react-dom": "^18.0.0", "zustand": "^4.4.7" diff --git a/src/app.config.ts b/src/app.config.ts index 60330e6..9d80db6 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,6 +1,7 @@ export default defineAppConfig({ pages: [ 'pages/publishBall/index', + 'pages/mapDisplay/index', 'pages/index/index' ], window: { @@ -8,5 +9,20 @@ export default defineAppConfig({ navigationBarBackgroundColor: '#fff', navigationBarTitleText: 'WeChat', navigationBarTextStyle: 'black' + }, + permission: { + 'scope.userLocation': { + desc: '你的位置信息将用于小程序位置接口的效果展示' + } + }, + requiredPrivateInfos: [ + 'getLocation', + 'chooseLocation' + ], + plugins: { + chooseLocation: { + version: "1.0.12", + provider: "wx76a9a06e5b4e693e" + } } }) diff --git a/src/app.ts b/src/app.ts index 7a3972b..b9f6294 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,19 +1,22 @@ -import React, { useEffect } from 'react' -import { useDidShow, useDidHide } from '@tarojs/taro' -// 全局样式 +import { Component, ReactNode } from 'react' import './app.scss' +import './nutui-theme.scss' -function App(props: any) { - // 可以使用所有的 React Hooks - useEffect(() => {}) +interface AppProps { + children: ReactNode +} - // 对应 onShow - useDidShow(() => {}) +class App extends Component { + componentDidMount() {} - // 对应 onHide - useDidHide(() => {}) + componentDidShow() {} - return props.children + componentDidHide() {} + + render() { + // this.props.children 是将要会渲染的页面 + return this.props.children + } } export default App diff --git a/src/components/ActivityTypeSwitch/index.scss b/src/components/ActivityTypeSwitch/index.scss new file mode 100644 index 0000000..c606d06 --- /dev/null +++ b/src/components/ActivityTypeSwitch/index.scss @@ -0,0 +1,50 @@ +@use '~@/scss/themeColor.scss' as theme; + +.activity-type-switch { + display: flex; + gap: 12px; + margin-bottom: 12px; + padding: 0 4px; + border: 1px solid rgba(0, 0, 0, 0.06); + height: 40px; + border-radius: 12px; + padding: 4px; + overflow: hidden; + .switch-tab { + flex: 1; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + cursor: pointer; + border: 1px solid #e5e5e5; + color: theme.$primary-color; + opacity: 0.3; + box-shadow: none; + border: none; + .icon-style { + width: 20px; + height: 20px; + } + &.active { + background: white; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0px 4px 48px 0px rgba(0, 0, 0, 0.08); + opacity: 1; + } + + .tab-icon { + font-size: 18px; + line-height: 1; + } + + .tab-text { + font-size: 14px; + color: #333; + font-weight: 500; + } + + + } +} diff --git a/src/components/ActivityTypeSwitch/index.tsx b/src/components/ActivityTypeSwitch/index.tsx new file mode 100644 index 0000000..481215c --- /dev/null +++ b/src/components/ActivityTypeSwitch/index.tsx @@ -0,0 +1,37 @@ +import React from 'react' +import { View, Text, Image } from '@tarojs/components' +import images from '@/config/images' + +import './index.scss' + +export type ActivityType = 'individual' | 'group' + +interface ActivityTypeSwitchProps { + value: ActivityType + onChange: (type: ActivityType) => void +} + +const ActivityTypeSwitch: React.FC = ({ value, onChange }) => { + return ( + + onChange('individual')} + > + + + + 个人约球 + + onChange('group')} + > + + 畅打活动 + + + ) +} + +export default ActivityTypeSwitch diff --git a/src/components/CoverImageUpload/CoverImageUpload.scss b/src/components/CoverImageUpload/CoverImageUpload.scss new file mode 100644 index 0000000..6c1d229 --- /dev/null +++ b/src/components/CoverImageUpload/CoverImageUpload.scss @@ -0,0 +1,110 @@ +// 在组件SCSS文件中 +@use '~@/scss/images.scss' as img; +.cover-image-upload { + margin-bottom: 8px; + + .cover-scroll { + white-space: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .cover-list { + display: inline-flex; + padding: 0 4px; + min-width: 100%; + transition: justify-content 0.3s ease; + + &.center { + justify-content: center; + } + } + + .cover-item { + flex-shrink: 0; + width: 108px; + height: 108px; + border-radius: 12px; + margin-right: 12px; + position: relative; + overflow: hidden; + transition: all 0.3s ease; + animation: slideIn 0.3s ease-out; + + @keyframes slideIn { + from { + opacity: 0; + transform: scale(0.8); + } + to { + opacity: 1; + transform: scale(1); + } + } + + &.add-btn { + border: 2px dashed #d9d9d9; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + .add-icon { + font-size: 32px; + color: #999; + margin-bottom: 8px; + } + + .add-text { + font-size: 12px; + color: #999; + text-align: center; + line-height: 1.2; + } + } + + &.image-item { + .cover-image { + width: 100%; + height: 100%; + border-radius: 12px; + transition: opacity 0.3s ease; + + &:not([src]) { + opacity: 0; + } + } + + .delete-btn { + position: absolute; + top: 8px; + right: 8px; + width: 20px; + height: 20px; + background: rgba(0, 0, 0, 0.6); + color: white; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + font-weight: bold; + } + } + } +} + +// 暗色模式适配 +@media (prefers-color-scheme: dark) { + .cover-image-upload { + .cover-item.add-btn { + background: #2d2d2d; + border-color: #555; + + .add-icon, + .add-text { + color: #999; + } + } + } +} \ No newline at end of file diff --git a/src/components/CoverImageUpload/CoverImageUpload.tsx b/src/components/CoverImageUpload/CoverImageUpload.tsx new file mode 100644 index 0000000..b1efa51 --- /dev/null +++ b/src/components/CoverImageUpload/CoverImageUpload.tsx @@ -0,0 +1,91 @@ +import React, { useMemo, useCallback } from 'react' +import { View, Text, Image, ScrollView } from '@tarojs/components' +import Taro from '@tarojs/taro' +import './CoverImageUpload.scss' + +export interface CoverImage { + id: string + url: string + tempFilePath?: string +} + +interface CoverImageUploadProps { + images: CoverImage[] + onChange: (images: CoverImage[]) => void + maxCount?: number +} + +const CoverImageUpload: React.FC = ({ + images, + onChange, + maxCount = 9 +}) => { + // 添加封面图片 + const handleAddCoverImage = useCallback(() => { + if (images.length >= maxCount) { + Taro.showToast({ + title: `最多只能上传${maxCount}张图片`, + icon: 'none' + }) + return + } + + Taro.chooseImage({ + count: maxCount - images.length, + sizeType: ['compressed'], + sourceType: ['album', 'camera'], + success: (res) => { + const newImages = res.tempFilePaths.map((path, index) => ({ + id: Date.now() + index + '', + url: path, + tempFilePath: path + })) + onChange([...images, ...newImages]) + }, + fail: (err) => { + console.error('选择图片失败:', err) + } + }) + }, [images.length, maxCount, onChange]) + + // 删除封面图片 + const handleDeleteCoverImage = useCallback((id: string) => { + onChange(images.filter(img => img.id !== id)) + }, [images, onChange]) + + // 判断是否需要居中显示(总项目数不超过3个时居中) + const shouldCenter = useMemo(() => (images.length + 1) <= 3, [images.length]) + + return ( + + + + {/* 添加按钮 */} + + + + 添加活动封面 + + + {/* 已选择的图片 */} + {images.map((image) => ( + + + handleDeleteCoverImage(image.id)} + > + × + + + ))} + + + + ) +} + +export default CoverImageUpload \ No newline at end of file diff --git a/src/components/CoverImageUpload/index.ts b/src/components/CoverImageUpload/index.ts new file mode 100644 index 0000000..b5e0cf9 --- /dev/null +++ b/src/components/CoverImageUpload/index.ts @@ -0,0 +1 @@ +export { default, type CoverImage } from './CoverImageUpload' \ No newline at end of file diff --git a/src/components/DynamicForm/DynamicForm.scss b/src/components/DynamicForm/DynamicForm.scss deleted file mode 100644 index adac9f0..0000000 --- a/src/components/DynamicForm/DynamicForm.scss +++ /dev/null @@ -1,102 +0,0 @@ -.dynamic-form { - background: #f5f5f5; - min-height: 100vh; - padding-bottom: 200px; - - .form-reminder { - background: #fff8dc; - padding: 16px; - margin: 16px; - border-radius: 8px; - font-size: 14px; - color: #666; - line-height: 1.4; - } - - .form-container { - background: #fff; - margin: 16px; - border-radius: 12px; - padding: 20px; - position: relative; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); - - .delete-form-btn { - position: absolute; - top: 15px; - right: 15px; - width: 28px; - height: 28px; - background: #ff4757; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-size: 18px; - font-weight: bold; - z-index: 10; - } - } - - .form-actions { - position: fixed; - bottom: 0; - left: 0; - right: 0; - background: #fff; - padding: 20px 16px 30px; - border-top: 1px solid #e0e0e0; - - .add-form-btn { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - height: 44px; - border: 1px solid #ddd; - border-radius: 8px; - background: #fff; - margin-bottom: 12px; - font-size: 14px; - color: #666; - - .plus-icon { - font-size: 20px; - color: #999; - } - } - - .submit-btn { - width: 100%; - height: 48px; - background: #000; - color: #fff; - border: none; - border-radius: 8px; - font-size: 16px; - font-weight: 600; - margin-bottom: 12px; - transition: all 0.3s ease; - - &.submitting { - background: #999; - opacity: 0.8; - } - - &:disabled { - background: #ccc; - color: #999; - cursor: not-allowed; - } - } - - .disclaimer { - font-size: 12px; - color: #999; - text-align: center; - line-height: 1.4; - display: block; - } - } -} \ No newline at end of file diff --git a/src/components/DynamicForm/DynamicForm.tsx b/src/components/DynamicForm/DynamicForm.tsx deleted file mode 100644 index 85a8b35..0000000 --- a/src/components/DynamicForm/DynamicForm.tsx +++ /dev/null @@ -1,326 +0,0 @@ -import React, { useState, useEffect } from 'react' -import { View, Text, Button } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { Dialog } from '@nutui/nutui-react-taro' -import { FormConfig, FormFieldConfig } from '../../config/formConfig' -import FieldRenderer from './FieldRenderer' -import commonApi from '../../services/commonApi' -import './DynamicForm.scss' - -interface DynamicFormProps { - config: FormConfig - formType?: string // 表单类型,用于API提交 - onSubmit?: (formData: any[]) => void | Promise - onSubmitSuccess?: (response: any) => void - onSubmitError?: (error: any) => void - onAddForm?: () => void - onDeleteForm?: (index: number) => void - enableApiSubmit?: boolean // 是否启用API提交 -} - -interface FormData { - id: string - data: Record -} - -const DynamicForm: React.FC = ({ - config, - formType = 'default', - onSubmit, - onSubmitSuccess, - onSubmitError, - onAddForm, - onDeleteForm, - enableApiSubmit = true -}) => { - const [forms, setForms] = useState([]) - const [showDeleteDialog, setShowDeleteDialog] = useState(false) - const [deleteIndex, setDeleteIndex] = useState(-1) - const [isSubmitting, setIsSubmitting] = useState(false) - - // 初始化表单数据 - useEffect(() => { - const initialData = createInitialFormData() - setForms([{ - id: Date.now().toString(), - data: initialData - }]) - }, [config]) - - // 根据配置创建初始数据 - const createInitialFormData = () => { - const data: Record = {} - 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) => { - 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): Promise> => { - 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 ( - - {/* 提醒文本 */} - {config.reminder && ( - - {config.reminder} - - )} - - {/* 表单列表 */} - {forms.map((form, formIndex) => ( - - {/* 删除按钮 */} - {forms.length > 1 && ( - handleDeleteForm(formIndex)} - > - × - - )} - - {/* 渲染所有字段 */} - {config.fields.map((fieldConfig: FormFieldConfig) => ( - updateFieldValue(formIndex, fieldConfig.key, value)} - matchId={form.id} - /> - ))} - - ))} - - {/* 底部操作区 */} - - {config.actions?.addText && ( - - + - {config.actions.addText} - - )} - - {config.actions?.submitText && ( - - )} - - {config.actions?.disclaimer && ( - - {config.actions.disclaimer} - - )} - - - {/* 删除确认弹窗 */} - { - setShowDeleteDialog(false) - setDeleteIndex(-1) - }} - > - 确定要删除这场约球吗? - - - ) -} - -export default DynamicForm \ No newline at end of file diff --git a/src/components/DynamicForm/FieldRenderer.scss b/src/components/DynamicForm/FieldRenderer.scss deleted file mode 100644 index ce2691d..0000000 --- a/src/components/DynamicForm/FieldRenderer.scss +++ /dev/null @@ -1,304 +0,0 @@ -.field-renderer { - margin-bottom: 24px; - - .field-header { - margin-bottom: 12px; - - .field-title { - font-size: 16px; - font-weight: 600; - color: #333; - display: block; - margin-bottom: 4px; - } - - .field-hint { - font-size: 12px; - color: #999; - display: block; - } - } - - .field-content { - // 图片上传 - .upload-container { - display: flex; - flex-wrap: wrap; - gap: 8px; - - .upload-btn { - width: 80px; - height: 80px; - border: 2px dashed #ddd; - border-radius: 8px; - display: flex; - align-items: center; - justify-content: center; - background: #fafafa; - - .plus-icon { - font-size: 24px; - color: #999; - } - } - - .image-item { - position: relative; - width: 80px; - height: 80px; - - .cover-image { - width: 100%; - height: 100%; - border-radius: 8px; - } - - .remove-btn { - position: absolute; - top: -6px; - right: -6px; - width: 20px; - height: 20px; - background: #ff4757; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-size: 14px; - } - } - } - - // 文本输入 - .text-input, .number-input { - width: 100%; - height: 44px; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding: 0 16px; - font-size: 14px; - background: #fff; - } - - // 时间显示 - .time-display { - background: #f8f8f8; - border-radius: 8px; - padding: 16px; - - .time-row { - display: flex; - align-items: center; - margin-bottom: 8px; - - &:last-child { - margin-bottom: 0; - } - - .time-label { - width: 40px; - font-size: 14px; - color: #666; - } - - .time-value { - flex: 1; - font-size: 14px; - color: #333; - margin-left: 16px; - - &:last-child { - text-align: right; - } - } - } - } - - // 场地输入 - .venue-input-container { - display: flex; - align-items: center; - border: 1px solid #e0e0e0; - border-radius: 8px; - padding-right: 16px; - - .venue-input { - flex: 1; - height: 44px; - padding: 0 16px; - font-size: 14px; - border: none; - } - - .arrow { - font-size: 18px; - color: #999; - } - } - - // 多选按钮 - .multi-select-container { - display: flex; - flex-wrap: wrap; - gap: 12px; - - .select-btn { - padding: 8px 20px; - border: 1px solid #e0e0e0; - border-radius: 20px; - background: #fff; - font-size: 14px; - color: #666; - - &.selected { - background: #000; - color: #fff; - border-color: #000; - } - } - } - - // 计数器 - .counter-container { - display: flex; - align-items: center; - justify-content: space-between; - - .count-group { - display: flex; - align-items: center; - gap: 8px; - - .count-label { - font-size: 14px; - color: #666; - } - - .count-controls { - display: flex; - align-items: center; - gap: 8px; - - .count-btn { - width: 32px; - height: 32px; - border: 1px solid #ddd; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - background: #fff; - font-size: 18px; - color: #333; - } - - .count-number { - font-size: 16px; - color: #333; - min-width: 24px; - text-align: center; - } - } - } - - .separator { - font-size: 14px; - color: #666; - } - } - - // 滑动条 - .slider-container { - display: flex; - align-items: center; - gap: 12px; - - .slider-label { - font-size: 12px; - color: #666; - white-space: nowrap; - } - - .slider-wrapper { - flex: 1; - position: relative; - - .slider-track { - height: 4px; - background: #e0e0e0; - border-radius: 2px; - position: relative; - - .slider-fill { - position: absolute; - height: 100%; - background: #333; - border-radius: 2px; - left: 20%; - right: 20%; - } - - .slider-thumb { - position: absolute; - width: 20px; - height: 20px; - background: #333; - border-radius: 50%; - top: -8px; - transform: translateX(-50%); - } - } - } - } - - // 单选按钮组 - .radio-group-container { - display: flex; - flex-wrap: wrap; - gap: 8px; - - .radio-btn { - padding: 8px 16px; - border: 1px solid #e0e0e0; - border-radius: 6px; - background: #fff; - font-size: 14px; - color: #666; - - &.selected { - background: #000; - color: #fff; - border-color: #000; - } - } - } - - // 复选框 - .checkbox-container { - display: flex; - justify-content: flex-end; - - .checkbox { - width: 20px; - height: 20px; - border: 1px solid #ddd; - border-radius: 3px; - display: flex; - align-items: center; - justify-content: center; - background: #fff; - - &.checked { - background: #000; - border-color: #000; - - .checkmark { - color: #fff; - font-size: 12px; - } - } - } - } - } -} \ No newline at end of file diff --git a/src/components/DynamicForm/FieldRenderer.tsx b/src/components/DynamicForm/FieldRenderer.tsx deleted file mode 100644 index fd0f82d..0000000 --- a/src/components/DynamicForm/FieldRenderer.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import React from 'react' -import { View, Text, Input, Image, Button } from '@tarojs/components' -import Taro from '@tarojs/taro' -import { FormFieldConfig } from '../../config/formConfig' -import './FieldRenderer.scss' - -interface FieldRendererProps { - config: FormFieldConfig - value: any - onChange: (value: any) => void - matchId: string -} - -const FieldRenderer: React.FC = ({ 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 ( - - - + - - {(value || []).map((url: string, i: number) => ( - - - removeImage(i)}> - × - - - ))} - - ) - } - - // 文本输入组件 - const renderTextInput = () => ( - onChange(e.detail.value)} - /> - ) - - // 数字输入组件 - const renderNumberInput = () => ( - onChange(e.detail.value)} - /> - ) - - // 时间显示组件 - const renderTimeDisplay = () => ( - - - 开始 - 11/23/2025 - 8:00 AM - - - 结束 - 11/23/2025 - 10:00 AM - - - ) - - // 场地输入组件 - const renderVenueInput = () => ( - - onChange(e.detail.value)} - /> - {fieldConfig?.showArrow && } - - ) - - // 多选按钮组件 - const renderMultiSelect = () => ( - - {options?.map((option) => ( - { - const currentValues = value || [] - const newValues = currentValues.includes(option.value) - ? currentValues.filter((v: any) => v !== option.value) - : [...currentValues, option.value] - onChange(newValues) - }} - > - {option.label} - - ))} - - ) - - // 计数器组件 - const renderCounter = () => { - const minValue = fieldConfig?.minValue || 1 - const maxValue = fieldConfig?.maxValue || 50 - const currentValue = value || { min: 1, max: 4 } - - return ( - - - 最少 - - onChange({ - ...currentValue, - min: Math.max(minValue, currentValue.min - 1) - })} - > - - - - {currentValue.min} - onChange({ - ...currentValue, - min: Math.min(maxValue, currentValue.min + 1) - })} - > - + - - - - - - - onChange({ - ...currentValue, - max: Math.max(currentValue.min, currentValue.max - 1) - })} - > - - - - {currentValue.max} - onChange({ - ...currentValue, - max: Math.min(maxValue, currentValue.max + 1) - })} - > - + - - - 最多 - - - ) - } - - // 滑动条组件 - const renderSlider = () => { - const range = fieldConfig?.range || [1, 7] - const currentValue = value || [2.0, 4.0] - - return ( - - 2.0及以下 - - - - - - - - 4.0及以上 - - ) - } - - // 单选组件 - const renderRadioGroup = () => ( - - {options?.map((option) => ( - onChange(option.value)} - > - {option.label} - - ))} - - ) - - // 复选框组件 - const renderCheckbox = () => ( - - onChange(!value)} - > - {value && } - - - ) - - // 根据类型渲染不同组件 - 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 未知字段类型: {type} - } - } - - return ( - - - {title} - {hint && {hint}} - - - {renderField()} - - - ) -} - -export default FieldRenderer \ No newline at end of file diff --git a/src/components/DynamicForm/index.ts b/src/components/DynamicForm/index.ts deleted file mode 100644 index f4671c8..0000000 --- a/src/components/DynamicForm/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// DynamicForm组件统一导出 -export { default as DynamicForm } from './DynamicForm' -export { default as FieldRenderer } from './FieldRenderer' - -// 导出类型 -export type { FormConfig, FormFieldConfig, FieldType } from '../../config/formSchema/bulishBallFormSchema' - -// 默认导出DynamicForm组件 -export { default } from './DynamicForm' \ No newline at end of file diff --git a/src/components/FormBasicInfo/FormBasicInfo.scss b/src/components/FormBasicInfo/FormBasicInfo.scss new file mode 100644 index 0000000..f60c080 --- /dev/null +++ b/src/components/FormBasicInfo/FormBasicInfo.scss @@ -0,0 +1,89 @@ +@use '~@/scss/images.scss' as img; +@use '~@/scss/themeColor.scss' as theme; +// FormBasicInfo 组件样式 +.form-basic-info{ + background: white; + border-radius: 16px; + width: 100%; + + + // 费用项目 + .form-item { + display: flex; + align-items: center; + justify-content: space-between; + height: 44px; + padding-left: 12px; + &:last-child{ + .form-wrapper{ + border-bottom: none; + } + } + .form-label { + display: flex; + align-items: center; + font-size: 14px; + padding-right: 14px; + .lable-icon { + width: 16px; + height: 16px; + } + text { + font-size: 16px; + color: #333; + font-weight: 500; + } + } + .form-wrapper{ + display: flex; + justify-content: space-between; + flex: 1; + border-bottom: 1px solid rgba(0, 0, 0, 0.06); + align-items: center; + .form-item-label{ + display: flex; + } + .form-right-wrapper{ + display: flex; + padding-right: 12px; + height: 44px; + line-height: 44px; + align-items: center; + .title-placeholder{ + font-size: 14px; + color: theme.$textarea-placeholder-color; + font-weight: 400; + } + .h5-input{ + font-size: 14px; + color: #333; + font-weight: 500; + width: 50px; + text-align: right; + margin-right: 8px; + } + .unit{ + font-size: 14px; + color: theme.$primary-color; + } + .right-text{ + color: theme.$textarea-placeholder-color; + font-size: 14px; + padding-right: 8px; + width: 200px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + text-align: right; + } + .arrow{ + width: 16px; + height: 16px; + margin-left: 4px; + + } + } + } + } + +} diff --git a/src/components/FormBasicInfo/FormBasicInfo.tsx b/src/components/FormBasicInfo/FormBasicInfo.tsx new file mode 100644 index 0000000..176bb23 --- /dev/null +++ b/src/components/FormBasicInfo/FormBasicInfo.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import { View, Text, Input, Image, Picker } from '@tarojs/components' +import { Stadium } from '../SelectStadium' +import img from '@/config/images'; +import './FormBasicInfo.scss' +import { FormFieldConfig } from '@/config/formSchema/publishBallFormSchema'; + +interface FormBasicInfoProps { + fee: string + location: string + gameplay: string + selectedStadium: Stadium | null + onFeeChange: (value: string) => void + onLocationChange: (value: string) => void + onGameplayChange: (value: string) => void + onStadiumSelect: () => void + children: FormFieldConfig[] +} + +const FormBasicInfo: React.FC = ({ + fee, + location, + gameplay, + selectedStadium, + onFeeChange, + onLocationChange, + onGameplayChange, + onStadiumSelect, + children +}) => { + const renderChildren = () => { + return children.map((child: any, index: number) => { + return + + + + { + index === 0 && ( + {child.label} + + onFeeChange(e.detail.value)} + /> + 元/每人 + + ) + } + { + index === 1 && ( + {child.label} + + + {selectedStadium ? selectedStadium.name : '请选择'} + + + + ) + } + { + index === 2 && ( + {child.label} + + + {gameplay ? gameplay : '请选择'} + + + + ) + } + + }) + } + return ( + + {/* 费用 */} + {renderChildren()} + + ) +} + +export default FormBasicInfo \ No newline at end of file diff --git a/src/components/FormBasicInfo/index.ts b/src/components/FormBasicInfo/index.ts new file mode 100644 index 0000000..97a6f31 --- /dev/null +++ b/src/components/FormBasicInfo/index.ts @@ -0,0 +1,2 @@ +export { default } from './FormBasicInfo' +export type { FormBasicInfoProps } from './FormBasicInfo' \ No newline at end of file diff --git a/src/components/FormSwitch/FormSwitch.tsx b/src/components/FormSwitch/FormSwitch.tsx new file mode 100644 index 0000000..b549a05 --- /dev/null +++ b/src/components/FormSwitch/FormSwitch.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { View, Text } from '@tarojs/components' +import { Checkbox } from '@nutui/nutui-react-taro' +import { Image } from '@tarojs/components' +import images from '@/config/images' +import './index.scss' + +interface FormSwitchProps { + value: boolean + onChange: (checked: boolean) => void + title: string + infoIcon?: string + showToast?: boolean +} + +const FormSwitch: React.FC = ({ value, onChange, title, infoIcon, showToast = false}) => { + return ( + + + + {title} + { + showToast && + } + + + + + ) +} + +export default FormSwitch \ No newline at end of file diff --git a/src/components/FormSwitch/index.scss b/src/components/FormSwitch/index.scss new file mode 100644 index 0000000..0657295 --- /dev/null +++ b/src/components/FormSwitch/index.scss @@ -0,0 +1,43 @@ +.auto-degrade-section { + background: #fff; + border-radius: 12px; + padding: 10px 12px; + height: 44px; + .auto-degrade-item { + display: flex; + align-items: center; + justify-content: space-between; + + .auto-degrade-content { + display: flex; + align-items: center; + gap: 8px; + + .auto-degrade-text { + font-size: 16px; + color: #333; + font-weight: 500; + } + + .info-icon { + width: 16px; + height: 16px; + background: #999; + color: #fff; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: bold; + } + } + + .auto-degrade-checkbox { + :global(.nut-checkbox__icon) { + width: 20px; + height: 20px; + } + } + } +} \ No newline at end of file diff --git a/src/components/FormSwitch/index.ts b/src/components/FormSwitch/index.ts new file mode 100644 index 0000000..74b89a1 --- /dev/null +++ b/src/components/FormSwitch/index.ts @@ -0,0 +1 @@ +export { default } from './AutoDegradeSwitch' \ No newline at end of file diff --git a/src/components/MapDisplay/README.md b/src/components/MapDisplay/README.md new file mode 100644 index 0000000..e4a89ba --- /dev/null +++ b/src/components/MapDisplay/README.md @@ -0,0 +1,215 @@ +# 腾讯地图SDK使用说明 + +## 概述 + +本项目已集成腾讯地图SDK (`qqmap-wx-jssdk`),可以在小程序中使用腾讯地图的各种功能,包括地点搜索、地理编码等。现在已添加真实的腾讯地图组件,支持显示当前位置和交互功能。 + +## 安装依赖 + +项目已安装 `qqmap-wx-jssdk` 依赖: + +```bash +npm install qqmap-wx-jssdk +# 或 +yarn add qqmap-wx-jssdk +``` + +## 基本使用 + +### 1. 引入SDK + +```typescript +import QQMapWX from "qqmap-wx-jssdk"; +``` + +### 2. 初始化SDK + +```typescript +const qqmapsdk = new QQMapWX({ + key: 'YOUR_API_KEY' // 替换为你的腾讯地图API密钥 +}); +``` + +### 3. 使用search方法搜索地点 + +```typescript +// 搜索地点 +qqmapsdk.search({ + keyword: '关键词', // 搜索关键词 + location: '39.908802,116.397502', // 搜索中心点(可选) + page_size: 20, // 每页结果数量(可选) + page_index: 1, // 页码(可选) + success: (res) => { + console.log('搜索成功:', res.data); + // 处理搜索结果 + }, + fail: (err) => { + console.error('搜索失败:', err); + } +}); +``` + +## 在组件中使用 + +### MapDisplay组件 + +`MapDisplay` 组件已经封装了腾讯地图SDK的使用,包括: + +- **自动初始化SDK** +- **真实地图显示**: 使用Taro的Map组件显示腾讯地图 +- **当前位置显示**: 自动获取并显示用户当前位置 +- **地点搜索功能**: 支持关键词搜索地点 +- **搜索结果展示**: 在地图上标记搜索结果 +- **交互功能**: 支持地图缩放、拖动、标记点击等 +- **错误处理**: 完善的错误处理和用户提示 + +### 主要功能特性 + +#### 1. 地图显示 +- 使用真实的腾讯地图组件 +- 默认显示当前位置 +- 支持地图缩放、拖动、旋转 +- 响应式设计,适配不同屏幕尺寸 + +#### 2. 位置服务 +- 自动获取用户当前位置 +- 支持位置刷新 +- 逆地理编码获取地址信息 +- 位置信息悬浮显示 + +#### 3. 搜索功能 +- 实时搜索地点 +- 防抖优化(500ms) +- 搜索结果在地图上标记 +- 点击结果可移动地图中心 + +#### 4. 地图标记 +- 当前位置标记(蓝色) +- 搜索结果标记 +- 标记点击交互 +- 动态添加/移除标记 + +### 使用示例 + +```typescript +import { mapService } from './mapService'; + +// 搜索地点 +const results = await mapService.search({ + keyword: '体育馆', + location: '39.908802,116.397502' +}); + +console.log('搜索结果:', results); +``` + +## API密钥配置 + +在 `mapService.ts` 中配置你的腾讯地图API密钥: + +```typescript +this.qqmapsdk = new QQMapWX({ + key: 'YOUR_API_KEY' // 替换为你的实际API密钥 +}); +``` + +## 组件属性 + +### Map组件属性 +- `longitude`: 地图中心经度 +- `latitude`: 地图中心纬度 +- `scale`: 地图缩放级别(1-20) +- `markers`: 地图标记数组 +- `show-location`: 是否显示用户位置 +- `enable-zoom`: 是否支持缩放 +- `enable-scroll`: 是否支持拖动 +- `enable-rotate`: 是否支持旋转 + +### 标记属性 +```typescript +interface Marker { + id: string; // 标记唯一标识 + latitude: number; // 纬度 + longitude: number; // 经度 + title: string; // 标记标题 + iconPath?: string; // 图标路径 + width: number; // 图标宽度 + height: number; // 图标高度 +} +``` + +## 主要功能 + +### 1. 地点搜索 +- 支持关键词搜索 +- 支持按位置范围搜索 +- 分页显示结果 +- 搜索结果地图标记 + +### 2. 位置服务 +- 获取当前位置 +- 地理编码 +- 逆地理编码 +- 位置刷新 + +### 3. 地图交互 +- 地图缩放 +- 地图拖动 +- 地图旋转 +- 标记点击 +- 地图点击 + +### 4. 错误处理 +- SDK初始化失败处理 +- 搜索失败处理 +- 网络异常处理 +- 位置获取失败处理 + +## 注意事项 + +1. **API密钥**: 确保使用有效的腾讯地图API密钥 +2. **网络权限**: 小程序需要网络访问权限 +3. **位置权限**: 需要申请位置权限 (`scope.userLocation`) +4. **错误处理**: 建议添加适当的错误处理和用户提示 +5. **地图组件**: 使用Taro的Map组件,确保兼容性 + +## 权限配置 + +在 `app.config.ts` 中添加位置权限: + +```typescript +export default defineAppConfig({ + // ... 其他配置 + permission: { + 'scope.userLocation': { + desc: '你的位置信息将用于小程序位置接口的效果展示' + } + }, + requiredPrivateInfos: [ + 'getLocation' + ] +}) +``` + +## 常见问题 + +### Q: SDK初始化失败怎么办? +A: 检查API密钥是否正确,网络连接是否正常 + +### Q: 搜索没有结果? +A: 检查搜索关键词是否正确,API密钥是否有效 + +### Q: 如何获取用户当前位置? +A: 使用小程序的 `wx.getLocation` API,已集成到地图服务中 + +### Q: 地图不显示怎么办? +A: 检查网络连接,确保腾讯地图服务正常 + +### Q: 位置权限被拒绝? +A: 引导用户手动开启位置权限,或使用默认位置 + +## 更多信息 + +- [腾讯地图小程序SDK官方文档](https://lbs.qq.com/miniProgram/jsSdk/jsSdkGuide/jsSdkOverview) +- [API密钥申请](https://lbs.qq.com/dev/console/application/mine) +- [Taro Map组件文档](https://taro-docs.jd.com/docs/components/map) \ No newline at end of file diff --git a/src/components/MapDisplay/index.scss b/src/components/MapDisplay/index.scss new file mode 100644 index 0000000..4ca44ef --- /dev/null +++ b/src/components/MapDisplay/index.scss @@ -0,0 +1,382 @@ +.map-display { + height: 100vh; + background-color: #f5f5f5; + display: flex; + flex-direction: column; + + .map-section { + flex: 1; + position: relative; + background-color: #e8f4fd; + + .map-container { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + position: relative; + + .map-component { + width: 100%; + height: calc(100vh - 50%); + border-radius: 0; + } + + .map-loading { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + + .map-loading-text { + font-size: 16px; + color: #666; + text-align: center; + padding: 20px; + } + } + + .map-placeholder { + font-size: 16px; + color: #666; + text-align: center; + padding: 20px; + } + + .location-info-overlay { + position: absolute; + top: 20px; + left: 20px; + right: 20px; + z-index: 10; + + .location-info { + background-color: rgba(255, 255, 255, 0.95); + padding: 12px 16px; + border-radius: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + justify-content: space-between; + backdrop-filter: blur(10px); + + .location-text { + font-size: 13px; + color: #333; + flex: 1; + margin-right: 12px; + line-height: 1.4; + } + + .refresh-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background-color: #f0f0f0; + border-radius: 50%; + cursor: pointer; + transition: all 0.2s; + font-size: 12px; + + &:hover { + background-color: #e0e0e0; + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + } + } + } + + .center-info-overlay { + position: absolute; + bottom: 20px; + left: 20px; + right: 20px; + z-index: 10; + + .center-info { + background-color: rgba(255, 255, 255, 0.95); + padding: 12px 16px; + border-radius: 24px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); + + .center-text { + font-size: 13px; + color: #333; + text-align: center; + line-height: 1.4; + margin-bottom: 4px; + } + + .moving-indicator { + display: flex; + align-items: center; + justify-content: center; + padding: 4px 8px; + background-color: rgba(255, 193, 7, 0.9); + border-radius: 12px; + animation: pulse 1.5s ease-in-out infinite; + + .moving-text { + font-size: 11px; + color: #333; + font-weight: 500; + } + } + + @keyframes pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.8; + transform: scale(1.05); + } + } + } + } + + .fixed-center-indicator { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 15; + pointer-events: none; + + .center-dot { + width: 20px; + height: 20px; + background-color: #ff4757; + border: 3px solid #fff; + border-radius: 50%; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + animation: pulse 2s infinite; + } + + @keyframes pulse { + 0% { + transform: scale(1); + opacity: 1; + } + 50% { + transform: scale(1.2); + opacity: 0.8; + } + 100% { + transform: scale(1); + opacity: 1; + } + } + } + + .location-info { + position: absolute; + top: 20px; + left: 20px; + background-color: rgba(255, 255, 255, 0.9); + padding: 8px 12px; + border-radius: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + .location-text { + font-size: 12px; + color: #333; + } + } + + .sdk-status { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 12px 20px; + border-radius: 20px; + font-size: 14px; + z-index: 20; + + .sdk-status-text { + color: white; + } + } + } + } + + .search-section { + background-color: #fff; + padding: 16px; + border-bottom: 1px solid #eee; + + .search-wrapper { + display: flex; + align-items: center; + background-color: #f8f8f8; + border-radius: 24px; + padding: 0 16px; + position: relative; + + .search-icon { + font-size: 16px; + color: #999; + margin-right: 8px; + } + + .search-input { + flex: 1; + height: 40px; + font-size: 14px; + color: #333; + background: transparent; + border: none; + outline: none; + + &::placeholder { + color: #999; + } + + &:disabled { + color: #ccc; + } + } + + .clear-btn { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + background-color: #e0e0e0; + border-radius: 50%; + cursor: pointer; + font-size: 12px; + color: #666; + transition: all 0.2s; + + &:hover { + background-color: #d0d0d0; + color: #333; + } + } + } + } + + .search-results { + background-color: #fff; + flex: 1; + overflow: hidden; + + .results-header { + padding: 16px; + border-bottom: 1px solid #eee; + display: flex; + align-items: center; + + .results-title { + font-size: 16px; + font-weight: 500; + color: #333; + } + + .results-count { + font-size: 14px; + color: #999; + margin-left: 8px; + } + } + + .results-list { + max-height: 300px; + + .result-item { + display: flex; + align-items: center; + padding: 16px; + border-bottom: 1px solid #f5f5f5; + cursor: pointer; + transition: background-color 0.2s; + + &:hover { + background-color: #f8f8f8; + } + + .result-content { + flex: 1; + + .result-title { + font-size: 14px; + font-weight: 500; + color: #333; + margin-bottom: 4px; + display: block; + } + + .result-address { + font-size: 12px; + color: #999; + display: block; + } + } + + .result-arrow { + font-size: 16px; + color: #ccc; + margin-left: 12px; + } + } + } + } + + .searching-indicator { + padding: 20px; + text-align: center; + background-color: #fff; + + .searching-text { + font-size: 14px; + color: #999; + } + } + + .no-results { + padding: 40px 20px; + text-align: center; + background-color: #fff; + + .no-results-text { + font-size: 14px; + color: #999; + } + } + + .sdk-status-full { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background-color: rgba(0, 0, 0, 0.8); + color: white; + padding: 20px 30px; + border-radius: 12px; + font-size: 16px; + z-index: 1000; + + .sdk-status-text { + color: white; + } + } +} \ No newline at end of file diff --git a/src/components/MapDisplay/index.tsx b/src/components/MapDisplay/index.tsx new file mode 100644 index 0000000..5f54d56 --- /dev/null +++ b/src/components/MapDisplay/index.tsx @@ -0,0 +1,505 @@ +import React, { useState, useEffect, useRef } from 'react' +import { View, Text, Input, ScrollView, Map } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { mapService, SearchResult, LocationInfo } from './mapService' +import './index.scss' + +const MapDisplay: React.FC = () => { + const [currentLocation, setCurrentLocation] = useState(null) + const [searchValue, setSearchValue] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [isSearching, setIsSearching] = useState(false) + const [mapContext, setMapContext] = useState(null) + const [isSDKReady, setIsSDKReady] = useState(false) + const [mapMarkers, setMapMarkers] = useState([]) + // 地图中心点状态 + const [mapCenter, setMapCenter] = useState<{lat: number, lng: number} | null>(null) + // 用户点击的中心点标记 + const [centerMarker, setCenterMarker] = useState(null) + // 是否正在移动地图 + const [isMapMoving, setIsMapMoving] = useState(false) + // 地图移动的动画帧ID + const animationFrameRef = useRef(null) + // 地图移动的目标位置 + const [targetCenter, setTargetCenter] = useState<{lat: number, lng: number} | null>(null) + // 实时移动的定时器 + const moveTimerRef = useRef(null) + // 地图移动状态 + const [mapMoveState, setMapMoveState] = useState({ + isMoving: false, + startTime: 0, + startCenter: null as {lat: number, lng: number} | null, + lastUpdateTime: 0 + }) + + useEffect(() => { + initializeMapService() + return () => { + // 清理动画帧和定时器 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + } + if (moveTimerRef.current) { + clearInterval(moveTimerRef.current) + } + } + }, []) + + // 初始化地图服务 + const initializeMapService = async () => { + try { + const success = await mapService.initSDK() + if (success) { + setIsSDKReady(true) + console.log('地图服务初始化成功') + getCurrentLocation() + } else { + console.error('地图服务初始化失败') + Taro.showToast({ + title: '地图服务初始化失败', + icon: 'none' + }) + } + } catch (error) { + console.error('初始化地图服务异常:', error) + Taro.showToast({ + title: '地图服务初始化异常', + icon: 'none' + }) + } + } + + // 获取当前位置 + const getCurrentLocation = async () => { + try { + const location = await mapService.getLocation() + if (location) { + setCurrentLocation(location) + // 设置地图中心为当前位置,但不显示标记 + setMapCenter({ lat: location.lat, lng: location.lng }) + // 清空所有标记 + setMapMarkers([]) + console.log('当前位置:', location) + } + } catch (error) { + console.error('获取位置失败:', error) + Taro.showToast({ + title: '获取位置失败', + icon: 'none' + }) + } + } + + // 地图加载完成 + const handleMapLoad = (e: any) => { + console.log('地图加载完成:', e) + setMapContext(e.detail) + } + + // 地图标记点击 + const handleMarkerTap = (e: any) => { + const markerId = e.detail.markerId + console.log('点击标记:', markerId) + + if (markerId === 'center') { + Taro.showToast({ + title: '中心点标记', + icon: 'success' + }) + } + } + + // 地图区域点击 - 设置中心点和标记 + const handleMapTap = (e: any) => { + const { latitude, longitude } = e.detail + console.log('地图点击:', { latitude, longitude }) + + // 设置新的地图中心点 + setMapCenter({ lat: latitude, lng: longitude }) + + // 设置中心点标记 + const newCenterMarker = { + id: 'center', + latitude: latitude, + longitude: longitude, + title: '中心点', + iconPath: '/assets/center-marker.png', // 可以添加自定义中心点图标 + width: 40, + height: 40 + } + setCenterMarker(newCenterMarker) + + // 更新地图标记,只显示中心点标记 + setMapMarkers([newCenterMarker]) + + Taro.showToast({ + title: '已设置中心点', + icon: 'success' + }) + } + + // 地图开始移动 + const handleMapMoveStart = () => { + console.log('地图开始移动') + setIsMapMoving(true) + setMapMoveState(prev => ({ + ...prev, + isMoving: true, + startTime: Date.now(), + startCenter: mapCenter, + lastUpdateTime: Date.now() + })) + + // 启动实时移动更新 + startRealTimeMoveUpdate() + } + + // 启动实时移动更新 + const startRealTimeMoveUpdate = () => { + if (moveTimerRef.current) { + clearInterval(moveTimerRef.current) + } + + // 每16ms更新一次(约60fps) + moveTimerRef.current = setInterval(() => { + if (mapMoveState.isMoving && centerMarker) { + // 模拟地图移动过程中的位置更新 + // 这里我们基于时间计算一个平滑的移动轨迹 + const currentTime = Date.now() + const elapsed = currentTime - mapMoveState.startTime + const moveDuration = 300 // 假设移动持续300ms + + if (elapsed < moveDuration) { + // 计算移动进度 + const progress = elapsed / moveDuration + const easeProgress = 1 - Math.pow(1 - progress, 3) // 缓动函数 + + // 如果有目标位置,进行插值计算 + if (targetCenter && mapMoveState.startCenter) { + const newLat = mapMoveState.startCenter.lat + (targetCenter.lat - mapMoveState.startCenter.lat) * easeProgress + const newLng = mapMoveState.startCenter.lng + (targetCenter.lng - mapMoveState.startCenter.lng) * easeProgress + + // 更新中心点标记位置 + const updatedCenterMarker = { + ...centerMarker, + latitude: newLat, + longitude: newLng + } + setCenterMarker(updatedCenterMarker) + + // 更新地图标记 + const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_')) + setMapMarkers([updatedCenterMarker, ...searchMarkers]) + } + } + } + }, 16) + } + + // 地图区域变化 - 更新目标位置 + const handleRegionChange = (e: any) => { + console.log('地图区域变化:', e.detail) + + // 获取地图当前的中心点坐标 + if (e.detail && e.detail.centerLocation) { + const { latitude, longitude } = e.detail.centerLocation + const newCenter = { lat: latitude, lng: longitude } + + // 设置目标位置 + setTargetCenter(newCenter) + + // 更新地图中心点状态 + setMapCenter(newCenter) + + // 如果有中心点标记,立即更新标记位置到新的地图中心 + if (centerMarker) { + const updatedCenterMarker = { + ...centerMarker, + latitude: latitude, + longitude: longitude + } + setCenterMarker(updatedCenterMarker) + + // 更新地图标记,保持搜索结果标记 + const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_')) + setMapMarkers([updatedCenterMarker, ...searchMarkers]) + } + } + } + + // 地图移动结束 + const handleMapMoveEnd = () => { + console.log('地图移动结束') + setIsMapMoving(false) + setMapMoveState(prev => ({ + ...prev, + isMoving: false + })) + + // 停止实时移动更新 + if (moveTimerRef.current) { + clearInterval(moveTimerRef.current) + moveTimerRef.current = null + } + + // 清理动画帧 + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current) + animationFrameRef.current = null + } + } + + // 处理搜索输入 + const handleSearchInput = (e: any) => { + const value = e.detail.value + setSearchValue(value) + + // 如果输入内容为空,清空搜索结果 + if (!value.trim()) { + setSearchResults([]) + return + } + + // 防抖搜索 + clearTimeout((window as any).searchTimer) + ;(window as any).searchTimer = setTimeout(() => { + performSearch(value) + }, 500) + } + + // 执行搜索 + const performSearch = async (keyword: string) => { + if (!keyword.trim() || !isSDKReady) return + + setIsSearching(true) + + try { + const results = await mapService.search({ + keyword, + location: currentLocation ? `${currentLocation.lat},${currentLocation.lng}` : undefined + }) + setSearchResults(results) + + // 在地图上添加搜索结果标记 + if (results.length > 0) { + const newMarkers = results.map((result, index) => ({ + id: `search_${index}`, + latitude: result.location.lat, + longitude: result.location.lng, + title: result.title, + iconPath: '/assets/search-marker.png', // 可以添加自定义图标 + width: 24, + height: 24 + })) + + // 合并中心点标记和搜索结果标记 + const allMarkers = centerMarker ? [centerMarker, ...newMarkers] : newMarkers + setMapMarkers(allMarkers) + } + + console.log('搜索结果:', results) + } catch (error) { + console.error('搜索异常:', error) + Taro.showToast({ + title: '搜索失败', + icon: 'none' + }) + setSearchResults([]) + } finally { + setIsSearching(false) + } + } + + // 处理搜索结果点击 - 切换地图中心到对应地点 + const handleResultClick = (result: SearchResult) => { + console.log('选择地点:', result) + Taro.showToast({ + title: `已切换到: ${result.title}`, + icon: 'success' + }) + + // 点击搜索结果时,将地图中心移动到该位置 + const newCenter = { lat: result.location.lat, lng: result.location.lng } + setMapCenter(newCenter) + + // 更新中心点标记 + const newCenterMarker = { + id: 'center', + latitude: result.location.lat, + longitude: result.location.lng, + title: '中心点', + iconPath: '/assets/center-marker.png', + width: 40, + height: 40 + } + setCenterMarker(newCenterMarker) + + // 更新地图标记,保留搜索结果标记 + const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_')) + setMapMarkers([newCenterMarker, ...searchMarkers]) + + // 如果地图上下文可用,也可以调用地图API移动 + if (mapContext && mapContext.moveToLocation) { + mapContext.moveToLocation({ + latitude: result.location.lat, + longitude: result.location.lng, + success: () => { + console.log('地图移动到搜索结果位置') + }, + fail: (err: any) => { + console.error('地图移动失败:', err) + } + }) + } + } + + // 处理搜索框清空 + const handleSearchClear = () => { + setSearchValue('') + setSearchResults([]) + // 清空搜索结果标记,只保留中心点标记 + setMapMarkers(centerMarker ? [centerMarker] : []) + } + + // 刷新位置 + const handleRefreshLocation = () => { + getCurrentLocation() + Taro.showToast({ + title: '正在刷新位置...', + icon: 'loading' + }) + } + + return ( + + {/* 地图区域 */} + + + {currentLocation ? ( + console.error('地图加载错误:', e)} + /> + ) : ( + + 地图加载中... + + )} + + {/* 位置信息悬浮层 */} + {currentLocation && ( + + + + {currentLocation.address || `当前位置: ${currentLocation.lat.toFixed(6)}, ${currentLocation.lng.toFixed(6)}`} + + + 🔄 + + + + )} + + {/* 中心点信息悬浮层 */} + {centerMarker && ( + + + + 中心点: {centerMarker.latitude.toFixed(6)}, {centerMarker.longitude.toFixed(6)} + + {isMapMoving && ( + + 移动中... + + )} + + + )} + + {!isSDKReady && ( + + 地图服务初始化中... + + )} + + + + {/* 搜索区域 */} + + + 🔍 + + {searchValue && ( + + ✕ + + )} + + + + {/* 搜索结果列表 */} + {searchResults.length > 0 && ( + + + 搜索结果 + ({searchResults.length}) + + + {searchResults.map((result) => ( + handleResultClick(result)} + > + + {result.title} + {result.address} + + + + ))} + + + )} + + {/* 搜索状态提示 */} + {isSearching && ( + + 搜索中... + + )} + + {/* 无搜索结果提示 */} + {searchValue && !isSearching && searchResults.length === 0 && isSDKReady && ( + + 未找到相关地点 + + )} + + {/* SDK状态提示 */} + {!isSDKReady && ( + + 正在初始化地图服务,请稍候... + + )} + + ) +} + +export default MapDisplay \ No newline at end of file diff --git a/src/components/MapDisplay/mapPlugin.tsx b/src/components/MapDisplay/mapPlugin.tsx new file mode 100644 index 0000000..7c334a1 --- /dev/null +++ b/src/components/MapDisplay/mapPlugin.tsx @@ -0,0 +1,63 @@ +import Taro from '@tarojs/taro'; +import { Button } from '@tarojs/components'; +import { mapService, SearchResult, LocationInfo } from './mapService' +import { useEffect, useState } from 'react'; + +export default function MapPlugin() { + const key = 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'; //使用在腾讯位置服务申请的key +const referer = '八瓜一月'; //调用插件的app的名称 +const [currentLocation, setCurrentLocation] = useState(null) + +const category = ''; + +const chooseLocation = () => { + Taro.navigateTo({ + url: 'plugin://chooseLocation/index?key=' + key + '&referer=' + referer + '&latitude=' + currentLocation?.lat + '&longitude=' + currentLocation?.lng + }); +} +useEffect(() => { + initializeMapService() + }, []) + + // 初始化地图服务 + const initializeMapService = async () => { + try { + const success = await mapService.initSDK() + if (success) { + console.log('地图服务初始化成功') + getCurrentLocation() + } else { + console.error('地图服务初始化失败') + Taro.showToast({ + title: '地图服务初始化失败', + icon: 'none' + }) + } + } catch (error) { + console.error('初始化地图服务异常:', error) + Taro.showToast({ + title: '地图服务初始化异常', + icon: 'none' + }) + } + } + // 获取当前位置 + const getCurrentLocation = async () => { + try { + const location = await mapService.getLocation() + if (location) { + setCurrentLocation(location) + console.log('当前位置:', location) + } + } catch (error) { + console.error('获取位置失败:', error) + Taro.showToast({ + title: '获取位置失败', + icon: 'none' + }) + } + } + return ( + + ) +} \ No newline at end of file diff --git a/src/components/MapDisplay/mapService.ts b/src/components/MapDisplay/mapService.ts new file mode 100644 index 0000000..04cb68a --- /dev/null +++ b/src/components/MapDisplay/mapService.ts @@ -0,0 +1,190 @@ +// 腾讯地图SDK服务 +import QQMapWX from "qqmap-wx-jssdk"; +import Taro from '@tarojs/taro'; + +// 扩展Window接口,添加qqmapsdk属性 +declare global { + interface Window { + qqmapsdk?: any; + } +} + +export interface LocationInfo { + lat: number + lng: number + address?: string +} + +export interface SearchResult { + id: string + title: string + address: string + location: { + lat: number + lng: number + } +} + +export interface SearchOptions { + keyword: string + location?: string + page_size?: number + page_index?: number +} + +class MapService { + private qqmapsdk: any = null + private isInitialized = false + + // 初始化腾讯地图SDK + async initSDK(): Promise { + if (this.isInitialized) { + return true + } + + try { + // 直接使用QQMapWX,不需要通过window对象 + this.qqmapsdk = new QQMapWX({ + key: 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX' + }); + + this.isInitialized = true + console.log('腾讯地图SDK初始化成功') + return true + } catch (error) { + console.error('初始化腾讯地图SDK失败:', error) + return false + } + } + + // 搜索地点 + async search(options: SearchOptions): Promise { + if (!this.isInitialized) { + await this.initSDK() + } + + try { + console.log(this.qqmapsdk,11) + if (this.qqmapsdk && this.qqmapsdk.search) { + return new Promise((resolve, reject) => { + this.qqmapsdk.getSuggestion({ + keyword: options.keyword, + location: options.location || '39.908802,116.397502', // 默认北京 + page_size: options.page_size || 20, + page_index: options.page_index || 1, + success: (res: any) => { + console.log('搜索成功:', res) + if (res.data && res.data.length > 0) { + const results: SearchResult[] = res.data.map((item: any, index: number) => ({ + id: `search_${index}`, + title: item.title || item.name || '未知地点', + address: item.address || item.location || '地址未知', + location: { + lat: item.location?.lat || 0, + lng: item.location?.lng || 0 + } + })) + resolve(results) + } else { + resolve([]) + } + }, + fail: (err: any) => { + console.error('搜索失败:', err) + reject(err) + } + }) + }) + } else { + // 使用模拟数据 + console.log('使用模拟搜索数据') + return this.getMockSearchResults(options.keyword) + } + } catch (error) { + console.error('搜索异常:', error) + return this.getMockSearchResults(options.keyword) + } + } + + // 获取模拟搜索结果 + private getMockSearchResults(keyword: string): SearchResult[] { + const mockResults: SearchResult[] = [ + { + id: 'mock_1', + title: `${keyword}相关地点1`, + address: '模拟地址1 - 这是一个示例地址', + location: { lat: 39.908802, lng: 116.397502 } + }, + { + id: 'mock_2', + title: `${keyword}相关地点2`, + address: '模拟地址2 - 这是另一个示例地址', + location: { lat: 39.918802, lng: 116.407502 } + }, + { + id: 'mock_3', + title: `${keyword}相关地点3`, + address: '模拟地址3 - 第三个示例地址', + location: { lat: 39.898802, lng: 116.387502 } + } + ] + return mockResults + } + + // 获取当前位置 + async getCurrentLocation(): Promise<{ lat: number; lng: number } | null> { + try { + // 这里可以集成实际的定位服务 + // 暂时返回模拟位置 + const res = await Taro.getLocation({ + type: 'gcj02', + isHighAccuracy: true + }) + return { + lat: res.latitude, + lng: res.longitude + } + } catch (error) { + console.error('获取位置失败:', error) + return null + } + } + async getAddress(lat: number, lng: number): Promise { + try { + const addressRes: any = await new Promise((resolve, reject) => { + this.qqmapsdk.reverseGeocoder({ + location: { + latitude: lat, + longitude: lng + }, + success: resolve, + fail: reject + }) + }) + return addressRes?.results?.address + } catch (error) { + console.error('获取地址失败:', error) + } + } + async getLocation(): Promise<{ lat: number; lng: number; address: string } | null | undefined> { + try { + const currentInfo: any = {}; + const location = await this.getCurrentLocation(); + const { lat, lng } = location || {}; + + if (lat && lng) { + currentInfo.lat = lat; + currentInfo.lng = lng; + const addressRes = await this.getAddress(lat, lng) + if (addressRes) { + currentInfo.address = addressRes; + } + } + return currentInfo; + } catch (error) { + console.error('获取位置失败:', error) + } + } +} + +export const mapService = new MapService() \ No newline at end of file diff --git a/src/components/NTRPSlider/NTRPSlider.scss b/src/components/NTRPSlider/NTRPSlider.scss new file mode 100644 index 0000000..ba28159 --- /dev/null +++ b/src/components/NTRPSlider/NTRPSlider.scss @@ -0,0 +1,149 @@ +.ntrp-slider { + // 区域标题 - 灰色背景 + .section-title-wrapper { + margin-bottom: 16px; + padding: 0 4px; + display: flex; + align-items: center; + justify-content: space-between; + + .section-title { + font-size: 16px; + color: #333; + font-weight: 600; + } + + .section-summary { + font-size: 14px; + color: #999; + white-space: nowrap; + } + } + + // NTRP控制区域 - 白色块 + .ntrp-control-section { + background: white; + border-radius: 16px; + padding: 20px 16px; + margin-bottom: 16px; + + .ntrp-slider-container { + .ntrp-labels { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + + .ntrp-label { + font-size: 12px; + color: #666; + } + } + + .ntrp-slider-track { + position: relative; + height: 40px; + margin: 0 12px; + + .slider-bg { + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 100%; + height: 4px; + background: #e0e0e0; + border-radius: 2px; + } + + .slider-range { + position: absolute; + top: 50%; + transform: translateY(-50%); + height: 4px; + background: #333; + border-radius: 2px; + z-index: 1; + } + + .slider-thumb { + position: absolute; + top: 50%; + width: 24px; + height: 24px; + background: #333; + border: 2px solid #fff; + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + z-index: 2; + cursor: pointer; + transition: all 0.2s ease; + + &.active { + transform: translate(-50%, -50%) scale(1.2); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + } + + .thumb-value { + position: absolute; + top: -30px; + left: 50%; + transform: translateX(-50%); + background: #333; + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + white-space: nowrap; + opacity: 0; + transition: opacity 0.2s ease; + } + + &.active .thumb-value { + opacity: 1; + } + } + } + } + } +} + +// 暗色模式适配 +@media (prefers-color-scheme: dark) { + .ntrp-slider { + .section-title-wrapper { + .section-title { + color: #fff; + } + + .section-summary { + color: #999; + } + } + + .ntrp-control-section { + background: #2d2d2d; + + .ntrp-labels .ntrp-label { + color: #999; + } + + .slider-bg { + background: #555; + } + + .slider-range { + background: #fff; + } + + .slider-thumb { + background: #fff; + border-color: #2d2d2d; + + .thumb-value { + background: #fff; + color: #333; + } + } + } + } +} diff --git a/src/components/NTRPSlider/NTRPSlider.tsx b/src/components/NTRPSlider/NTRPSlider.tsx new file mode 100644 index 0000000..ec09b41 --- /dev/null +++ b/src/components/NTRPSlider/NTRPSlider.tsx @@ -0,0 +1,133 @@ +import React, { useState, useCallback } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import './NTRPSlider.scss' + +export interface NTRPRange { + min: number + max: number +} + +// 获取NTRP显示文本的工具函数 +export const getNTRPRangeText = (range: NTRPRange): string => { + if (range.min === 2.0 && range.max === 4.0) { + return '不限' + } + return `${range.min} - ${range.max}` +} + +interface NTRPSliderProps { + value: NTRPRange + onChange: (range: NTRPRange) => void + title?: string + showTitle?: boolean +} + +const NTRPSlider: React.FC = ({ + value = { + min: 1.0, + max: 5.0 + }, + onChange, + title = 'NTRP水平要求', + showTitle = false +}) => { + const [activeThumb, setActiveThumb] = useState<'min' | 'max' | null>(null) + + // 计算滑动条位置百分比 + const getSliderPercentage = useCallback((level: number) => { + return ((level - 2.0) / 2.0) * 100 + }, []) + + // 获取当前NTRP显示文本 + const currentRangeText = getNTRPRangeText(value) + + const handleSliderTouchStart = useCallback((thumb: 'min' | 'max') => { + setActiveThumb(thumb) + }, []) + + const handleSliderTouchMove = useCallback((e: any) => { + if (!activeThumb) return + + e.preventDefault() + const query = Taro.createSelectorQuery() + query.select('.ntrp-slider-track').boundingClientRect((rect: any) => { + if (rect && !Array.isArray(rect)) { + const touch = e.touches[0] + const relativeX = touch.clientX - rect.left + const percentage = Math.max(0, Math.min(1, relativeX / rect.width)) + const level = Number((2.0 + percentage * 2.0).toFixed(1)) + + if (activeThumb === 'min') { + const newMin = Math.min(level, value.max - 0.1) + onChange({ min: newMin, max: value.max }) + } else { + const newMax = Math.max(level, value.min + 0.1) + onChange({ min: value.min, max: newMax }) + } + } + }).exec() + }, [activeThumb, value, onChange]) + + const handleSliderTouchEnd = useCallback(() => { + setActiveThumb(null) + }, []) + + return ( + + {showTitle && ( + + {title} + {currentRangeText} + + )} + + + + + 2.0及以下 + 4.0及以上 + + + + {/* 背景轨道 */} + + + {/* 选中区间 */} + + + {/* 最小值滑块 */} + handleSliderTouchStart('min')} + > + {value.min} + + + {/* 最大值滑块 */} + handleSliderTouchStart('max')} + > + {value.max} + + + + + + ) +} + +export default NTRPSlider diff --git a/src/components/NTRPSlider/index.ts b/src/components/NTRPSlider/index.ts new file mode 100644 index 0000000..5cd76d2 --- /dev/null +++ b/src/components/NTRPSlider/index.ts @@ -0,0 +1 @@ +export { default, type NTRPRange, getNTRPRangeText } from './NTRPSlider' diff --git a/src/components/ParticipantsControl/ParticipantsControl.scss b/src/components/ParticipantsControl/ParticipantsControl.scss new file mode 100644 index 0000000..8aeddad --- /dev/null +++ b/src/components/ParticipantsControl/ParticipantsControl.scss @@ -0,0 +1,111 @@ +@use '~@/scss/themeColor.scss' as theme; +// 人数控制区域 - 白色块 +.participants-control-section { + background: white; + border-radius: 16px; + width: 100%; + padding: 9px 12px; + display: flex; + justify-content: space-between; + height: 48px; + box-sizing: border-box; + .participant-control { + display: flex; + align-items: center; + position: relative; + &:first-child{ + width: 50%; + &::after{ + content: ''; + display: block; + width: 1px; + height: 16px; + background: #E5E5E5; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + } + } + .control-label { + font-size: 13px; + color: theme.$primary-color; + white-space: nowrap; + padding-right: 10px; + } + + .control-buttons { + display: flex; + align-items: center; + height: 30px; + background-color: theme.$primary-background-color; + border-radius: 6px; + .format-width{ + .nut-input-minus{ + width: 33px; + position: relative; + &::after{ + content: ''; + width: 1px; + height: 16px; + background-color: theme.$primary-background-color; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + } + } + .nut-number-input{ + min-width: 33px; + background-color: transparent; + font-size: 12px; + + } + .nut-input-add{ + width: 33px; + position: relative; + &::before{ + content: ''; + width: 1px; + height: 16px; + background-color: theme.$primary-background-color; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + } + } + } + .control-btn { + width: 32px; + height: 32px; + border: 1px solid #ddd; + background: white; + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + color: #333; + margin: 0; + padding: 0; + + &.minus { + margin-right: 12px; + } + + &.plus { + margin-left: 12px; + } + } + + .control-value { + font-size: 16px; + color: #333; + font-weight: 500; + min-width: 36px; + text-align: center; + } + } + } +} \ No newline at end of file diff --git a/src/components/ParticipantsControl/ParticipantsControl.tsx b/src/components/ParticipantsControl/ParticipantsControl.tsx new file mode 100644 index 0000000..bf50099 --- /dev/null +++ b/src/components/ParticipantsControl/ParticipantsControl.tsx @@ -0,0 +1,49 @@ +import React from 'react' +import { View, Text, Button } from '@tarojs/components' +import './ParticipantsControl.scss' +import { InputNumber } from '@nutui/nutui-react-taro' + +interface ParticipantsControlProps { + minParticipants: number + maxParticipants: number + onMinParticipantsChange: (value: number) => void + onMaxParticipantsChange: (value: number) => void +} + +const ParticipantsControl: React.FC = ({ + minParticipants, + maxParticipants, + onMinParticipantsChange, + onMaxParticipantsChange +}) => { + return ( + + + 最少 + + `${value}人`} + /> + + + + 最多 + + `${value}人`} + /> + + + + ) +} + +export default ParticipantsControl \ No newline at end of file diff --git a/src/components/ParticipantsControl/index.ts b/src/components/ParticipantsControl/index.ts new file mode 100644 index 0000000..bd2824f --- /dev/null +++ b/src/components/ParticipantsControl/index.ts @@ -0,0 +1,2 @@ +export { default } from './ParticipantsControl' +export type { ParticipantsControlProps } from './ParticipantsControl' \ No newline at end of file diff --git a/src/components/SelectStadium/FLOW.md b/src/components/SelectStadium/FLOW.md new file mode 100644 index 0000000..3628022 --- /dev/null +++ b/src/components/SelectStadium/FLOW.md @@ -0,0 +1,94 @@ +# 球馆选择流程说明 + +## 🎯 完整流程 + +### 1. 初始状态 - 球馆列表 +用户看到球馆选择弹窗,显示: +- 搜索框(可点击) +- 热门球场标题 +- 球馆列表 +- 底部取消/完成按钮 + +### 2. 点击搜索框 +- 搜索框变为可点击状态 +- 点击后跳转到地图选择页面 + +### 3. 地图选择页面 +用户在地图页面可以: +- 查看地图,选择位置 +- 在搜索框输入关键词搜索地点 +- 从搜索结果中选择地点 +- 点击"确定"按钮确认选择 + +### 4. 返回球馆详情 +选择地点后: +- 自动跳转回球馆选择页面 +- 显示球馆详情配置页面 +- 新选择的球馆名称会显示在"已选球场"部分 + +### 5. 配置球馆详情 +用户可以配置: +- 场地类型(室内/室外/室外雨棚) +- 地面材质(硬地/红土/草地) +- 场地信息补充(文本输入) + +### 6. 完成选择 +- 点击"完成"按钮 +- 关闭弹窗,返回主页面 +- 选中的球馆信息传递给父组件 + +## 🔄 状态管理 + +```typescript +// 主要状态 +const [showDetail, setShowDetail] = useState(false) // 是否显示详情页 +const [showMapSelector, setShowMapSelector] = useState(false) // 是否显示地图选择器 +const [selectedStadium, setSelectedStadium] = useState(null) // 选中的球馆 +``` + +## 📱 组件切换逻辑 + +```typescript +// 组件渲染优先级 +if (showMapSelector) { + return // 1. 地图选择器 +} else if (showDetail && selectedStadium) { + return // 2. 球馆详情 +} else { + return // 3. 球馆列表 +} +``` + +## 🗺️ 地图集成 + +- 使用 Taro 的 `Map` 组件 +- 支持地图标记和位置选择 +- 集成搜索功能,支持关键词搜索 +- 搜索结果包含地点名称、地址和距离信息 + +## 📋 数据传递 + +```typescript +// 从地图选择器传递到球馆详情 +const handleMapLocationSelect = (location: Location) => { + const newStadium: Stadium = { + id: `map_${location.id}`, + name: location.name, // 地图选择的球场名称 + address: location.address // 地图选择的球场地址 + } + + // 添加到球馆列表并选择 + stadiumList.unshift(newStadium) + setSelectedStadium(newStadium) + setShowMapSelector(false) + setShowDetail(true) +} +``` + +## 🎨 用户体验 + +1. **无缝切换**:三个页面共享同一个弹窗容器 +2. **状态保持**:选择的地点信息会正确传递 +3. **视觉反馈**:选中状态有明确的视觉指示 +4. **操作简单**:点击搜索即可进入地图选择 +5. **数据同步**:地图选择的球场会自动添加到球馆列表 \ No newline at end of file diff --git a/src/components/SelectStadium/README.md b/src/components/SelectStadium/README.md new file mode 100644 index 0000000..bc73c67 --- /dev/null +++ b/src/components/SelectStadium/README.md @@ -0,0 +1,118 @@ +# SelectStadium 球馆选择组件 + +这是一个球馆选择和详情的复合组件,包含两个主要功能: +1. 球馆列表选择 +2. 球馆详情配置 + +## 功能特性 + +- 🏟️ 球馆搜索和选择 +- 📱 响应式设计,适配移动端 +- 🔄 无缝切换球馆列表和详情页面 +- 🎯 支持场地类型、地面材质等配置 +- 📝 场地信息补充 + +## 使用方法 + +### 基础用法 + +```tsx +import React, { useState } from 'react' +import { SelectStadium, Stadium } from './components/SelectStadium' + +const App: React.FC = () => { + const [showSelector, setShowSelector] = useState(false) + const [selectedStadium, setSelectedStadium] = useState(null) + + const handleStadiumSelect = (stadium: Stadium | null) => { + setSelectedStadium(stadium) + setShowSelector(false) + } + + return ( +
+ + + setShowSelector(false)} + onConfirm={handleStadiumSelect} + /> +
+ ) +} +``` + +## 组件结构 + +``` +SelectStadium/ +├── SelectStadium.tsx # 主组件,管理状态和切换逻辑 +├── StadiumDetail.tsx # 球馆详情组件 +├── SelectStadium.scss # 球馆列表样式 +├── StadiumDetail.scss # 球馆详情样式 +├── index.ts # 导出文件 +└── README.md # 说明文档 +``` + +## Props + +### SelectStadium + +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| visible | boolean | 是 | 控制弹窗显示/隐藏 | +| onClose | () => void | 是 | 关闭弹窗回调 | +| onConfirm | (stadium: Stadium \| null) => void | 是 | 确认选择回调 | + +### StadiumDetail + +| 属性 | 类型 | 必填 | 说明 | +|------|------|------|------| +| stadium | Stadium | 是 | 选中的球馆信息 | +| onBack | () => void | 是 | 返回球馆列表回调 | +| onConfirm | (stadium, venueType, groundMaterial, additionalInfo) => void | 是 | 确认配置回调 | + +## 数据接口 + +### Stadium + +```typescript +interface Stadium { + id: string + name: string + address?: string +} +``` + +## 配置选项 + +### 场地类型 +- 室内 +- 室外 +- 室外雨棚 + +### 地面材质 +- 硬地 +- 红土 +- 草地 + +### 场地信息补充 +- 文本输入框,支持用户自定义备注信息 + +## 样式定制 + +组件使用 SCSS 编写,可以通过修改以下文件来自定义样式: + +- `SelectStadium.scss` - 球馆列表样式 +- `StadiumDetail.scss` - 球馆详情样式 + +## 注意事项 + +1. 组件依赖 `@nutui/nutui-react-taro` 的 `Popup` 组件 +2. 确保在 Taro 环境中使用 +3. 组件内部管理状态,外部只需要控制 `visible` 属性 +4. 球馆列表数据在组件内部硬编码,实际使用时可以通过 props 传入 +5. StadiumDetail 组件现在只包含场地配置选项,去掉了头部、提醒和活动封面部分 \ No newline at end of file diff --git a/src/components/SelectStadium/SelectStadium.scss b/src/components/SelectStadium/SelectStadium.scss new file mode 100644 index 0000000..47405e3 --- /dev/null +++ b/src/components/SelectStadium/SelectStadium.scss @@ -0,0 +1,201 @@ +.select-stadium-popup { + .nut-popup { + max-height: 85vh; + overflow: hidden; + + .nut-popup__content { + overflow: hidden; + } + } +} + +.select-stadium { + width: 100%; + height: 85vh; + background: #f5f5f5; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: env(safe-area-inset-bottom); + + // 搜索区域 + .search-section { + background: #f5f5f5; + padding: 16px; + + .search-wrapper { + background: white; + border-radius: 12px; + padding: 12px 16px; + display: flex; + align-items: center; + gap: 8px; + + .search-icon { + font-size: 16px; + color: #999; + } + + .search-input { + flex: 1; + font-size: 16px; + color: #333; + border: none; + outline: none; + background: transparent; + } + + .map-btn { + width: 32px; + height: 32px; + border-radius: 16px; + background: #f0f8ff; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.2s; + + &:active { + background: #e0f0ff; + } + + .map-icon { + font-size: 16px; + color: #007AFF; + } + } + } + } + + // 热门球场区域 + .hot-section { + background: #f5f5f5; + padding: 0 16px 16px 16px; + + .hot-header { + display: flex; + align-items: center; + gap: 30px; + + .hot-title { + font-size: 16px; + font-weight: 600; + color: #333; + } + + .booking-section { + display: flex; + align-items: center; + gap: 8px; + + .booking-title { + font-size: 16px; + font-weight: 600; + color: #333; + } + + .booking-status { + font-size: 12px; + color: #999; + } + } + } + } + + // 场馆列表 + .stadium-list { + flex: 1; + width: auto; + padding: 0 16px; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + + .stadium-item { + border-radius: 20px; + height: 40px; + margin-bottom: 6px; + display: flex; + align-items: center; + justify-content: center; + color: #000; + position: relative; + border: 1px solid rgba(0, 0, 0, 0.12); + &:last-child { + margin-bottom: 0; + } + + &.selected { + background: #f0f8ff; + + &::after { + content: '✓'; + position: absolute; + right: 16px; + top: 50%; + transform: translateY(-50%); + color: #007AFF; + font-size: 16px; + font-weight: bold; + } + } + + .stadium-name { + font-size: 16px; + color: #333; + line-height: 1.4; + padding-right: 30px; + } + } + } + + // 底部按钮区域 + .bottom-actions { + background: white; + padding: 16px; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + border-top: 1px solid #e5e5e5; + flex-shrink: 0; + + .action-buttons { + display: flex; + gap: 12px; + + .cancel-btn, + .confirm-btn { + flex: 1; + height: 48px; + border-radius: 24px; + display: flex; + align-items: center; + justify-content: center; + } + + .cancel-btn { + background: #f5f5f5; + border: 1px solid #e0e0e0; + + .cancel-text { + font-size: 16px; + color: #666; + font-weight: 500; + } + } + + .confirm-btn { + background: #333; + + .confirm-text { + font-size: 16px; + color: white; + font-weight: 500; + } + } + } + } +} + +// 搜索框占位符样式 +.search-input::placeholder { + color: #999; +} \ No newline at end of file diff --git a/src/components/SelectStadium/SelectStadium.tsx b/src/components/SelectStadium/SelectStadium.tsx new file mode 100644 index 0000000..d688c67 --- /dev/null +++ b/src/components/SelectStadium/SelectStadium.tsx @@ -0,0 +1,201 @@ +import React, { useState } from 'react' +import { View, Text, Input, ScrollView } from '@tarojs/components' +import { Popup } from '@nutui/nutui-react-taro' +import Taro from '@tarojs/taro' +import StadiumDetail from './StadiumDetail' +import './SelectStadium.scss' + +export interface Stadium { + id: string + name: string + address?: string +} + +interface SelectStadiumProps { + visible: boolean + onClose: () => void + onConfirm: (stadium: Stadium | null) => void +} + +const stadiumList: Stadium[] = [ + { id: '1', name: '静安网球馆', address: '静安区' }, + { id: '2', name: '芦湾体育馆', address: '芦湾区' }, + { id: '3', name: '静安网球馆', address: '静安区' }, + { id: '4', name: '徐汇游泳中心', address: '徐汇区' }, + { id: '5', name: '汇龙新城小区', address: '新城区' }, + { id: '6', name: '翠湖御苑小区', address: '翠湖区' }, + { id: '7', name: '仁恒河滨花园网球场', address: '浦东新区' }, + { id: '8', name: 'Our Tennis 东江球场', address: '浦东新区' }, + { id: '9', name: '上海琦梦网球俱乐部', address: '浦东新区' } +] + +const SelectStadium: React.FC = ({ + visible, + onClose, + onConfirm +}) => { + const [searchValue, setSearchValue] = useState('') + const [selectedStadium, setSelectedStadium] = useState(null) + const [showDetail, setShowDetail] = useState(false) + + if (!visible) return null + + // 过滤场馆列表 + const filteredStadiums = stadiumList.filter(stadium => + stadium.name.toLowerCase().includes(searchValue.toLowerCase()) + ) + + // 处理场馆选择 + const handleStadiumSelect = (stadium: Stadium) => { + setSelectedStadium(stadium) + setShowDetail(true) + } + + // 处理返回球馆列表 + const handleBackToList = () => { + setShowDetail(false) + setSelectedStadium(null) + } + + // 处理搜索框输入 + const handleSearchInput = (e: any) => { + setSearchValue(e.detail.value) + } + + // 处理地图选择位置 + const handleMapLocation = () => { + Taro.chooseLocation({ + success: (res) => { + console.log('选择位置成功:', res) + // 可以根据位置信息搜索附近的场馆 + // 这里可以调用相关API获取附近场馆信息 + Taro.showToast({ + title: '位置选择成功', + icon: 'success' + }) + }, + fail: (err) => { + console.error('选择位置失败:', err) + Taro.showToast({ + title: '位置选择失败', + icon: 'error' + }) + } + }) + } + + // 处理确认 + const handleConfirm = (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => { + // 这里可以处理球馆详情的信息 + console.log('球馆详情:', { stadium, venueType, groundMaterial, additionalInfo }) + onConfirm(stadium) + setShowDetail(false) + setSelectedStadium(null) + setSearchValue('') + } + + // 处理球馆列表确认 + const handleListConfirm = () => { + if (selectedStadium) { + onConfirm(selectedStadium) + setSelectedStadium(null) + setSearchValue('') + } + } + + // 处理取消 + const handleCancel = () => { + onClose() + setShowDetail(false) + setSelectedStadium(null) + setSearchValue('') + } + + // 如果显示详情页面 + if (showDetail && selectedStadium) { + return ( + + + + ) + } + + // 显示球馆列表 + return ( + + + {/* 搜索框 */} + + + 🔍 + + + 📍 + + + + + {/* 热门球场标题 */} + + + 热门球场 + + 预定球场 + 敬请期待 + + + + + {/* 场馆列表 */} + + {filteredStadiums.map((stadium) => ( + handleStadiumSelect(stadium)} + > + {stadium.name} + + ))} + + + {/* 底部按钮 */} + + + + 取消 + + + 完成 + + + + + + ) +} + +export default SelectStadium \ No newline at end of file diff --git a/src/components/SelectStadium/StadiumDetail.scss b/src/components/SelectStadium/StadiumDetail.scss new file mode 100644 index 0000000..7b6517e --- /dev/null +++ b/src/components/SelectStadium/StadiumDetail.scss @@ -0,0 +1,202 @@ +.stadium-detail { + width: 100%; + height: auto; + min-height: 60vh; + background: white; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: env(safe-area-inset-bottom); + + // 已选球场 + .selected-venue-section { + padding: 20px 16px 16px 16px; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + + .section-title { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 12px; + display: block; + } + + .venue-button { + background: #333; + border-radius: 24px; + padding: 12px 24px; + display: inline-block; + + .venue-name { + color: white; + font-size: 16px; + font-weight: 500; + } + } + } + + // 场地类型 + .venue-type-section { + padding: 16px; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + + .section-title { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 12px; + display: block; + } + + .option-buttons { + display: flex; + gap: 12px; + + .option-btn { + padding: 8px 16px; + border-radius: 20px; + border: 1px solid #e0e0e0; + background: white; + cursor: pointer; + transition: all 0.2s; + + &.selected { + background: #333; + border-color: #333; + + .option-text { + color: white; + } + } + + .option-text { + font-size: 14px; + color: #333; + } + } + } + } + + // 地面材质 + .ground-material-section { + padding: 16px; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + + .section-title { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 12px; + display: block; + } + + .option-buttons { + display: flex; + gap: 12px; + + .option-btn { + padding: 8px 16px; + border-radius: 20px; + border: 1px solid #e0e0e0; + background: white; + cursor: pointer; + transition: all 0.2s; + + &.selected { + background: #333; + border-color: #333; + + .option-text { + color: white; + } + } + + .option-text { + font-size: 14px; + color: #333; + } + } + } + } + + // 场地信息补充 + .additional-info-section { + padding: 16px; + border-bottom: 1px solid #e5e5e5; + flex-shrink: 0; + + .section-title { + font-size: 16px; + font-weight: 600; + color: #333; + margin-bottom: 12px; + display: block; + } + + .additional-input { + width: 100%; + padding: 12px 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; + font-size: 14px; + color: #333; + background: white; + height: 44px; + box-sizing: border-box; + + &::placeholder { + color: #999; + } + } + } + + // 底部按钮 + .bottom-actions { + background: white; + padding: 16px; + padding-bottom: calc(16px + env(safe-area-inset-bottom)); + border-top: 1px solid #e5e5e5; + flex-shrink: 0; + margin-top: auto; + + .action-buttons { + display: flex; + gap: 12px; + + .cancel-btn, + .confirm-btn { + flex: 1; + height: 48px; + border-radius: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + } + + .cancel-btn { + background: white; + border: 1px solid #e0e0e0; + + .cancel-text { + font-size: 16px; + color: #666; + font-weight: 500; + } + } + + .confirm-btn { + background: #333; + + .confirm-text { + font-size: 16px; + color: white; + font-weight: 500; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/SelectStadium/StadiumDetail.tsx b/src/components/SelectStadium/StadiumDetail.tsx new file mode 100644 index 0000000..e6db410 --- /dev/null +++ b/src/components/SelectStadium/StadiumDetail.tsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react' +import { View, Text, Input } from '@tarojs/components' +import './StadiumDetail.scss' + +export interface Stadium { + id: string + name: string + address?: string +} + +interface StadiumDetailProps { + stadium: Stadium + onBack: () => void + onConfirm: (stadium: Stadium, venueType: string, groundMaterial: string, additionalInfo: string) => void +} + +const StadiumDetail: React.FC = ({ + stadium, + onBack, + onConfirm +}) => { + const [venueType, setVenueType] = useState('室内') + const [groundMaterial, setGroundMaterial] = useState('硬地') + const [additionalInfo, setAdditionalInfo] = useState('') + + const venueTypes = ['室内', '室外', '室外雨棚'] + const groundMaterials = ['硬地', '红土', '草地'] + + const handleConfirm = () => { + onConfirm(stadium, venueType, groundMaterial, additionalInfo) + } + + return ( + + {/* 已选球场 */} + + 已选球场 + + {stadium.name} + + + + {/* 场地类型 */} + + 场地类型 + + {venueTypes.map((type) => ( + setVenueType(type)} + > + {type} + + ))} + + + + {/* 地面材质 */} + + 地面材质 + + {groundMaterials.map((material) => ( + setGroundMaterial(material)} + > + {material} + + ))} + + + + {/* 场地信息补充 */} + + 场地信息补充 + setAdditionalInfo(e.detail.value)} + /> + + + {/* 底部按钮 */} + + + + 取消 + + + 完成 + + + + + ) +} + +export default StadiumDetail \ No newline at end of file diff --git a/src/components/SelectStadium/index.ts b/src/components/SelectStadium/index.ts new file mode 100644 index 0000000..4a6b86c --- /dev/null +++ b/src/components/SelectStadium/index.ts @@ -0,0 +1,3 @@ +export { default as SelectStadium } from './SelectStadium' +export { default as StadiumDetail } from './StadiumDetail' +export type { Stadium } from './SelectStadium' diff --git a/src/components/TextareaTag/TextareaTag.scss b/src/components/TextareaTag/TextareaTag.scss new file mode 100644 index 0000000..602f7dc --- /dev/null +++ b/src/components/TextareaTag/TextareaTag.scss @@ -0,0 +1,52 @@ + @use '~@/scss/themeColor.scss' as theme; +.textarea-tag { + background: white; + border-radius: 16px; + padding: 10px 16px 5px 10px; + width: 100%; + .input-wrapper { + padding-bottom: 10px; + + .additional-input { + width: 100%; + height: 46px; + font-size: 14px; + color: #333; + background: transparent; + border: none; + outline: none; + line-height: 1.4; + resize: none; + .textarea-placeholder{ + color: theme.$textarea-placeholder-color; + } + } + } + + .options-wrapper { + .options-label { + font-size: 12px; + color: #666; + margin-bottom: 10px; + display: block; + } + + .options-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + .nut-checkbox{ + margin-right: 0; + margin-bottom: 5px; + .nut-checkbox-button{ + border: 1px solid theme.$primary-border-color; + color: theme.$primary-color; + background: transparent; + font-size: 12px; + padding: 2px 6px; + margin-right: 6px; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/TextareaTag/TextareaTag.tsx b/src/components/TextareaTag/TextareaTag.tsx new file mode 100644 index 0000000..98ae9bb --- /dev/null +++ b/src/components/TextareaTag/TextareaTag.tsx @@ -0,0 +1,89 @@ +import React, { useCallback, useState } from 'react' +import { View, Textarea } from '@tarojs/components' + +import { Checkbox } from '@nutui/nutui-react-taro' + +import './TextareaTag.scss' + +interface TextareaTagProps { + value: string + onChange: (value: string) => void + title?: string + showTitle?: boolean + placeholder?: string + maxLength?: number + options?: { label: string; value: any }[] | null +} + +const TextareaTag: React.FC = ({ + value, + onChange, + placeholder = '请输入', + maxLength = 500, + options = [] +}) => { + // 处理输入框变化 + const [tags, setTags] = useState([]) + const handleInputChange = useCallback((e: any) => { + onChange(e.detail.value) + }, [onChange]) + + // 选择预设选项 + const handleSelectOption = useCallback((option: string) => { + let newValue = '' + + if (value) { + // 如果已有内容,用分号分隔添加 + newValue = value + ';' + option + } else { + // 如果没有内容,直接添加 + newValue = option + } + + onChange(newValue) + }, [value, onChange]) + + return ( + + {/* 输入框 */} + +