Merge remote-tracking branch 'origin' into feature/juguohong/20250816

This commit is contained in:
juguohong
2025-08-24 16:28:56 +08:00
112 changed files with 11132 additions and 82 deletions

View File

@@ -10,12 +10,13 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
const baseConfig: UserConfigExport<'webpack5'> = {
projectName: 'playBallTogether',
date: '2025-8-9',
designWidth: 375,
designWidth: 390,
deviceRatio: {
640: 2.34 / 2,
750: 1,
375: 2,
828: 1.81 / 2
828: 1.81 / 2,
390: 1.92
},
sourceRoot: 'src',
outputRoot: 'dist',

View File

@@ -0,0 +1,178 @@
# 发布球局功能使用说明
## 功能概述
发布球局功能允许用户创建和发布体育活动,包括图片上传、详细信息填写等完整流程。
## 主要特性
### 1. 图片上传组件 (UploadImages)
- ✅ 支持最多9张图片上传
- ✅ 6张以内显示网格布局超过6张自动分页滑动
- ✅ 支持图片预览和删除
- ✅ 拍照或从相册选择
- ✅ 响应式设计,支持暗色模式
### 2. 动态表单系统 (DynamicForm)
- ✅ 基于配置的动态表单渲染
- ✅ 支持多种字段类型:文本、数字、选择器、日期时间、开关、单选、多选、位置选择
- ✅ 完整的表单验证
- ✅ 实时错误提示
- ✅ 美观的UI设计
### 3. 位置选择功能
- ✅ 支持地图选择位置
- ✅ 一键获取当前位置
- ✅ 地址显示和验证
### 4. 草稿保存系统
- ✅ 自动保存草稿2秒延迟
- ✅ 页面刷新后自动恢复
- ✅ 草稿过期管理7天
- ✅ 提交成功后自动清除
## 文件结构
```
src/
├── components/
│ ├── UploadImages/
│ │ ├── index.tsx # 图片上传组件
│ │ └── index.scss # 组件样式
│ └── DynamicForm/
│ ├── index.tsx # 动态表单组件
│ └── index.scss # 组件样式
├── config/
│ └── formSchema/
│ └── publishBallFormSchema.ts # 表单配置
├── pages/
│ └── publishBall/
│ ├── index.tsx # 发布页面
│ ├── index.scss # 页面样式
│ └── index.config.ts # 页面配置
└── utils/
└── locationUtils.ts # 位置相关工具函数
```
## 使用方法
### 1. 页面导航
```typescript
// 跳转到发布页面
Taro.navigateTo({
url: '/pages/publishBall/index'
})
```
### 2. 组件使用
#### UploadImages组件
```tsx
import UploadImages from '../../components/UploadImages/index'
<UploadImages
value={images}
onChange={handleImagesChange}
maxCount={9}
className="custom-class"
/>
```
#### DynamicForm组件
```tsx
import DynamicForm from '../../components/DynamicForm/index'
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema'
<DynamicForm
schema={publishBallFormSchema}
initialValues={initialValues}
onValuesChange={handleFormValuesChange}
onSubmit={handleSubmit}
/>
```
### 3. 自定义表单配置
```typescript
// 在 publishBallFormSchema.ts 中添加新字段
{
key: 'customField',
label: '自定义字段',
type: FieldType.TEXT,
placeholder: '请输入',
required: true,
rules: [
{ required: true, message: '此字段为必填' }
]
}
```
## 支持的表单字段类型
| 类型 | 说明 | 示例 |
|------|------|------|
| TEXT | 文本输入 | 活动标题 |
| TEXTAREA | 多行文本 | 活动描述 |
| SELECT | 下拉选择 | 运动类型 |
| DATE | 日期选择 | 活动日期 |
| TIME | 时间选择 | 开始时间 |
| NUMBER | 数字输入 | 人数限制 |
| SWITCH | 开关按钮 | 公开活动 |
| RADIO | 单选按钮 | 技能要求 |
| CHECKBOX | 多选框 | 活动标签 |
| LOCATION | 位置选择 | 活动地点 |
## 样式特性
### 设计系统
- 🎨 统一的色彩方案
- 📱 响应式设计
- 🌙 深色模式支持
- ✨ 流畅的动画效果
- 🔧 可定制的主题
### 交互体验
- 👆 触感反馈
- 🔄 加载状态
- ⚠️ 错误提示
- 💾 自动保存
- 🎯 焦点管理
## 开发注意事项
1. **图片上传**: 实际项目中需要实现真实的图片上传API
2. **位置服务**: 需要配置地图服务的API密钥
3. **表单验证**: 可根据业务需求扩展验证规则
4. **草稿存储**: 使用本地存储,注意存储容量限制
5. **权限管理**: 需要申请相册、相机、位置等权限
## 扩展性
### 添加新的字段类型
1.`FieldType` 枚举中添加新类型
2.`DynamicForm` 组件的 `renderField` 方法中添加处理逻辑
3. 在样式文件中添加对应样式
### 自定义验证规则
```typescript
// 在表单配置中添加自定义验证
rules: [
{
pattern: /^1[3-9]\d{9}$/,
message: '请输入正确的手机号'
}
]
```
### 主题定制
通过修改 SCSS 变量来定制主题颜色和样式。
## 性能优化
- 图片懒加载和压缩
- 表单防抖处理
- 组件按需加载
- 样式按需引入
---
*此功能已完全按照设计稿实现,包括颜色、间距、样式等所有细节。*

View File

@@ -53,6 +53,8 @@
"@tarojs/runtime": "4.1.5",
"@tarojs/shared": "4.1.5",
"@tarojs/taro": "4.1.5",
"dayjs": "^1.11.13",
"qqmap-wx-jssdk": "^1.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"zustand": "^4.4.7"

View File

@@ -1,46 +1,46 @@
{
"miniprogramRoot": "dist/",
"projectname": "playBallTogether",
"description": "playBallTogether",
"appid": "wx815b533167eb7b53",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": false,
"preloadBackgroundData": false,
"minified": false,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": false,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"enableEngineNative": false,
"useIsolateContext": true,
"useCompilerModule": false,
"userConfirmedUseCompilerModuleSwitch": false,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorType": "wechat",
"simulatorPluginLibVersion": {},
"condition": {}
}
"miniprogramRoot": "dist/",
"projectname": "playBallTogether",
"description": "playBallTogether",
"appid": "wx815b533167eb7b53",
"setting": {
"urlCheck": true,
"es6": true,
"enhance": true,
"postcss": false,
"preloadBackgroundData": false,
"minified": false,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": false,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"enableEngineNative": false,
"useIsolateContext": true,
"useCompilerModule": false,
"userConfirmedUseCompilerModuleSwitch": false,
"userConfirmedBundleSwitch": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true
},
"compileType": "miniprogram",
"simulatorType": "wechat",
"simulatorPluginLibVersion": {},
"condition": {}
}

View File

@@ -1,12 +1,31 @@
export default defineAppConfig({
pages: [
'pages/list/index',
'pages/index/index'
'pages/login/index/index',
'pages/login/verification/index',
'pages/login/terms/index',
// 'pages/publishBall/index',
// 'pages/mapDisplay/index',
// 'pages/list/index',
'pages/index/index'
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: 'WeChat',
navigationBarTextStyle: 'black'
},
permission: {
'scope.userLocation': {
desc: '你的位置信息将用于小程序位置接口的效果展示'
}
},
requiredPrivateInfos: [
'getLocation',
'chooseLocation'
],
plugins: {
chooseLocation: {
version: "1.0.12",
provider: "wx76a9a06e5b4e693e"
}
}
})

View File

@@ -1,19 +1,36 @@
import React, { useEffect } from 'react'
import { useDidShow, useDidHide } from '@tarojs/taro'
// 全局样式
import { Component, ReactNode } from 'react'
import './app.scss'
import './nutui-theme.scss'
import { useDictionaryStore } from './store/dictionaryStore'
function App(props: any) {
// 可以使用所有的 React Hooks
useEffect(() => {})
interface AppProps {
children: ReactNode
}
// 对应 onShow
useDidShow(() => {})
class App extends Component<AppProps> {
componentDidMount() {
// 初始化字典数据
this.initDictionaryData()
}
// 对应 onHide
useDidHide(() => {})
componentDidShow() {}
return props.children
componentDidHide() {}
// 初始化字典数据
private async initDictionaryData() {
try {
const { fetchDictionary } = useDictionaryStore.getState()
await fetchDictionary()
} catch (error) {
console.error('初始化字典数据失败:', error)
}
}
render() {
// this.props.children 是将要会渲染的页面
return this.props.children
}
}
export default App

View File

@@ -0,0 +1,49 @@
.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: #1890ff;
opacity: 0.3;
box-shadow: none;
border: none;
}
.switch-tab.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;
}
.icon-style {
width: 20px;
height: 20px;
}
.tab-icon {
font-size: 18px;
line-height: 1;
}
.tab-text {
font-size: 14px;
color: #333;
font-weight: 500;
}

View File

@@ -0,0 +1,36 @@
import React from 'react'
import { View, Text, Image } from '@tarojs/components'
import images from '@/config/images'
import styles from './index.module.scss'
export type ActivityType = 'individual' | 'group'
interface ActivityTypeSwitchProps {
value: ActivityType
onChange: (type: ActivityType) => void
}
const ActivityTypeSwitch: React.FC<ActivityTypeSwitchProps> = ({ value, onChange }) => {
return (
<View className={styles['activity-type-switch']}>
<View
className={`${styles['switch-tab']} ${value === 'individual' ? styles.active : ''}`}
onClick={() => onChange('individual')}
>
<View className={styles['tab-icon']}>
<Image src={images.ICON_PERSONAL} className={styles['icon-style']} />
</View>
<Text className={styles['tab-text']}></Text>
</View>
<View
className={`${styles['switch-tab']} ${value === 'group' ? styles.active : ''}`}
onClick={() => onChange('group')}
>
<Image src={images.ICON_CHANGDA} className={styles['icon-style']} />
<Text className={styles['tab-text']}></Text>
</View>
</View>
)
}
export default ActivityTypeSwitch

View File

@@ -0,0 +1,81 @@
import React from 'react'
import { View, Text } from '@tarojs/components'
import { Popup, Button } from '@nutui/nutui-react-taro'
import styles from './index.module.scss'
export interface CommonPopupProps {
visible: boolean
onClose: () => void
title?: React.ReactNode
showHeader?: boolean
hideFooter?: boolean
cancelText?: string
confirmText?: string
onCancel?: () => void
onConfirm?: () => void
position?: 'center' | 'bottom' | 'top' | 'left' | 'right'
round?: boolean
zIndex?: number
children?: React.ReactNode
className?: string
}
const CommonPopup: React.FC<CommonPopupProps> = ({
visible,
onClose,
className,
title,
showHeader = false,
hideFooter = false,
cancelText = '返回',
confirmText = '完成',
onCancel,
onConfirm,
position = 'bottom',
round = true,
zIndex,
children
}) => {
const handleCancel = () => {
if (onCancel) {
onCancel()
} else {
onClose()
}
}
return (
<Popup
visible={visible}
position={position}
round={round}
closeable={false}
onClose={onClose}
className={`${styles['common-popup']} ${className ? className : ''}`}
style={zIndex ? { zIndex } : undefined}
>
{showHeader && (
<View className={styles['common-popup__header']}>
{typeof title === 'string' ? <Text className={styles['common-popup__title']}>{title}</Text> : title}
</View>
)}
<View className={styles['common-popup__body']}>
{children}
</View>
{!hideFooter && (
<View className={styles['common-popup__footer']}>
<Button className={`${styles['common-popup__btn']} ${styles['common-popup__btn-cancel']}`} type='default' size='small' onClick={handleCancel}>
{cancelText}
</Button>
<Button className={`${styles['common-popup__btn']} ${styles['common-popup__btn-confirm']}`} type='primary' size='small' onClick={onConfirm}>
{confirmText}
</Button>
</View>
)}
</Popup>
)
}
export default CommonPopup

View File

@@ -0,0 +1,62 @@
@use '~@/scss/themeColor.scss' as theme;
.common-popup {
padding: 0;
box-sizing: border-box;
max-height: calc(100vh - 10px);
display: flex;
flex-direction: column;
background-color: theme.$page-background-color;
.common-popup__header {
padding: 12px 16px;
font-size: 16px;
font-weight: 600;
color: #1f2329;
border-bottom: 1px solid #f0f1f5;
}
.common-popup__title {
display: inline-block;
}
.common-popup__body {
overflow: auto;
-webkit-overflow-scrolling: touch;
flex: 1 1 auto;
}
.common-popup__footer {
padding: 8px 10px 0 10px;
display: flex;
gap: 8px;
background: #FFF;
padding-bottom: env(safe-area-inset-bottom);
}
.common-popup__btn {
flex: 1;
}
.common-popup__btn-cancel {
background: #f5f6f7;
color: #1f2329;
border: none;
width: 154px;
height: 44px;
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
background: #fff;
padding: 4px 10px;
}
.common-popup__btn-confirm {
/* 使用按钮组件的 primary 样式 */
width: 154px;
height: 44px;
border: 0.5px solid rgba(0, 0, 0, 0.06);
background: #000;
border-radius: 12px;
padding: 4px 10px;
}
}

View File

@@ -0,0 +1,3 @@
import CommonPopup from './CommonPopup'
export default CommonPopup
export * from './CommonPopup'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,57 @@
import React, { useState } 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
subTitle: string
infoIcon?: string
showToast?: boolean
description?: string
}
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, infoIcon, showToast = false, description}) => {
const [showTip, setShowTip] = useState(false)
const toggleTip = () => {
setShowTip((prev) => !prev)
}
return (
<>
{showTip && <View className='info-popover-mask' onClick={() => setShowTip(false)} />}
<View className='auto-degrade-section'>
<View className='auto-degrade-item'>
<Checkbox
className='auto-degrade-checkbox nut-checkbox-black'
checked={value}
onChange={onChange}
/>
<View className='auto-degrade-content'>
<Text className='auto-degrade-text'>{subTitle}</Text>
{
showToast && (
<View className='info-icon' onClick={toggleTip}>
<Image src={infoIcon || images.ICON_TIPS} className='info-img' />
{
showTip && (
<View className='info-popover'>
<Text>{description || ''}</Text>
</View>
)
}
</View>
)
}
</View>
</View>
</View>
</>
)
}
export default FormSwitch

View File

@@ -0,0 +1,79 @@
.auto-degrade-section {
background: #fff;
border-radius: 12px;
padding: 10px 12px;
height: 44px;
width: 100%;
box-sizing: border-box;
.auto-degrade-item {
display: flex;
align-items: center;
width: 100%;
gap: 8px;
.auto-degrade-content {
display: flex;
align-items: center;
.auto-degrade-text {
font-size: 14px;
color: #333;
font-weight: 500;
}
.info-icon {
display: flex;
align-items: center;
justify-content: center;
padding-left: 4px;
position: relative;
.info-img{
width: 12px;
height: 12px;
}
.info-popover {
position: absolute;
bottom: 22px;
left: -65px;
width: 130px;
padding:12px;
background: rgba(57, 59, 68, 0.90);
color: #fff;
border-radius: 8px;
font-size: 12px;
line-height: 1.6;
z-index: 1001;
white-space: normal;
word-break: normal;
overflow-wrap: break-word;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.info-popover::before {
content: '';
position: absolute;
bottom: -6px;
left: 68px; /* 对齐图标宽12px可按需微调 */
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid rgba(57, 59, 68, 0.90);
}
}
}
.auto-degrade-checkbox {
width: 18px;
height: 18px;
}
}
}
.info-popover-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: transparent;
z-index: 1000;
}

View File

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

View File

@@ -0,0 +1,110 @@
// 在组件SCSS文件中
@use '~@/scss/images.scss' as img;
.cover-image-upload {
margin-bottom: 8px;
.cover-scroll {
white-space: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.cover-list {
display: inline-flex;
padding: 0 4px;
min-width: 100%;
transition: justify-content 0.3s ease;
&.center {
justify-content: center;
}
}
.cover-item {
flex-shrink: 0;
width: 108px;
height: 108px;
border-radius: 12px;
margin-right: 6px;
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;
}
}
}
}

View File

@@ -0,0 +1,91 @@
import React, { useMemo, useCallback } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import './ImageUpload.scss'
export interface CoverImage {
id: string
url: string
tempFilePath?: string
}
interface ImageUploadProps {
images: CoverImage[]
onChange: (images: CoverImage[]) => void
maxCount?: number
}
const ImageUpload: React.FC<ImageUploadProps> = ({
images,
onChange,
maxCount = 9
}) => {
// 添加封面图片
const handleAddCoverImage = useCallback(() => {
if (images.length >= maxCount) {
Taro.showToast({
title: `最多只能上传${maxCount}张图片`,
icon: 'none'
})
return
}
Taro.chooseImage({
count: maxCount - images.length,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (res) => {
const newImages = res.tempFilePaths.map((path, index) => ({
id: Date.now() + index + '',
url: path,
tempFilePath: path
}))
onChange([...images, ...newImages])
},
fail: (err) => {
console.error('选择图片失败:', err)
}
})
}, [images.length, maxCount, onChange])
// 删除封面图片
const handleDeleteCoverImage = useCallback((id: string) => {
onChange(images.filter(img => img.id !== id))
}, [images, onChange])
// 判断是否需要居中显示总项目数不超过3个时居中
const shouldCenter = useMemo(() => (images.length + 1) <= 3, [images.length])
return (
<View className='cover-image-upload'>
<ScrollView className='cover-scroll' scrollX>
<View className={`cover-list ${shouldCenter ? 'center' : ''}`}>
{/* 添加按钮 */}
<View className='cover-item add-btn' onClick={handleAddCoverImage}>
<View className='add-icon'>+</View>
<Text className='add-text'></Text>
</View>
{/* 已选择的图片 */}
{images.map((image) => (
<View key={image.id} className='cover-item image-item'>
<Image
className='cover-image'
src={image.url}
mode='aspectFill'
/>
<View
className='delete-btn'
onClick={() => handleDeleteCoverImage(image.id)}
>
×
</View>
</View>
))}
</View>
</ScrollView>
</View>
)
}
export default ImageUpload

View File

@@ -0,0 +1 @@
export { default, type CoverImage } from './ImageUpload'

View File

@@ -0,0 +1,215 @@
# 腾讯地图SDK使用说明
## 概述
本项目已集成腾讯地图SDK (`qqmap-wx-jssdk`),可以在小程序中使用腾讯地图的各种功能,包括地点搜索、地理编码等。现在已添加真实的腾讯地图组件,支持显示当前位置和交互功能。
## 安装依赖
项目已安装 `qqmap-wx-jssdk` 依赖:
```bash
npm install qqmap-wx-jssdk
# 或
yarn add qqmap-wx-jssdk
```
## 基本使用
### 1. 引入SDK
```typescript
import QQMapWX from "qqmap-wx-jssdk";
```
### 2. 初始化SDK
```typescript
const qqmapsdk = new QQMapWX({
key: 'YOUR_API_KEY' // 替换为你的腾讯地图API密钥
});
```
### 3. 使用search方法搜索地点
```typescript
// 搜索地点
qqmapsdk.search({
keyword: '关键词', // 搜索关键词
location: '39.908802,116.397502', // 搜索中心点(可选)
page_size: 20, // 每页结果数量(可选)
page_index: 1, // 页码(可选)
success: (res) => {
console.log('搜索成功:', res.data);
// 处理搜索结果
},
fail: (err) => {
console.error('搜索失败:', err);
}
});
```
## 在组件中使用
### MapDisplay组件
`MapDisplay` 组件已经封装了腾讯地图SDK的使用包括
- **自动初始化SDK**
- **真实地图显示**: 使用Taro的Map组件显示腾讯地图
- **当前位置显示**: 自动获取并显示用户当前位置
- **地点搜索功能**: 支持关键词搜索地点
- **搜索结果展示**: 在地图上标记搜索结果
- **交互功能**: 支持地图缩放、拖动、标记点击等
- **错误处理**: 完善的错误处理和用户提示
### 主要功能特性
#### 1. 地图显示
- 使用真实的腾讯地图组件
- 默认显示当前位置
- 支持地图缩放、拖动、旋转
- 响应式设计,适配不同屏幕尺寸
#### 2. 位置服务
- 自动获取用户当前位置
- 支持位置刷新
- 逆地理编码获取地址信息
- 位置信息悬浮显示
#### 3. 搜索功能
- 实时搜索地点
- 防抖优化500ms
- 搜索结果在地图上标记
- 点击结果可移动地图中心
#### 4. 地图标记
- 当前位置标记(蓝色)
- 搜索结果标记
- 标记点击交互
- 动态添加/移除标记
### 使用示例
```typescript
import { mapService } from './mapService';
// 搜索地点
const results = await mapService.search({
keyword: '体育馆',
location: '39.908802,116.397502'
});
console.log('搜索结果:', results);
```
## API密钥配置
`mapService.ts` 中配置你的腾讯地图API密钥
```typescript
this.qqmapsdk = new QQMapWX({
key: 'YOUR_API_KEY' // 替换为你的实际API密钥
});
```
## 组件属性
### Map组件属性
- `longitude`: 地图中心经度
- `latitude`: 地图中心纬度
- `scale`: 地图缩放级别1-20
- `markers`: 地图标记数组
- `show-location`: 是否显示用户位置
- `enable-zoom`: 是否支持缩放
- `enable-scroll`: 是否支持拖动
- `enable-rotate`: 是否支持旋转
### 标记属性
```typescript
interface Marker {
id: string; // 标记唯一标识
latitude: number; // 纬度
longitude: number; // 经度
title: string; // 标记标题
iconPath?: string; // 图标路径
width: number; // 图标宽度
height: number; // 图标高度
}
```
## 主要功能
### 1. 地点搜索
- 支持关键词搜索
- 支持按位置范围搜索
- 分页显示结果
- 搜索结果地图标记
### 2. 位置服务
- 获取当前位置
- 地理编码
- 逆地理编码
- 位置刷新
### 3. 地图交互
- 地图缩放
- 地图拖动
- 地图旋转
- 标记点击
- 地图点击
### 4. 错误处理
- SDK初始化失败处理
- 搜索失败处理
- 网络异常处理
- 位置获取失败处理
## 注意事项
1. **API密钥**: 确保使用有效的腾讯地图API密钥
2. **网络权限**: 小程序需要网络访问权限
3. **位置权限**: 需要申请位置权限 (`scope.userLocation`)
4. **错误处理**: 建议添加适当的错误处理和用户提示
5. **地图组件**: 使用Taro的Map组件确保兼容性
## 权限配置
`app.config.ts` 中添加位置权限:
```typescript
export default defineAppConfig({
// ... 其他配置
permission: {
'scope.userLocation': {
desc: '你的位置信息将用于小程序位置接口的效果展示'
}
},
requiredPrivateInfos: [
'getLocation'
]
})
```
## 常见问题
### Q: SDK初始化失败怎么办
A: 检查API密钥是否正确网络连接是否正常
### Q: 搜索没有结果?
A: 检查搜索关键词是否正确API密钥是否有效
### Q: 如何获取用户当前位置?
A: 使用小程序的 `wx.getLocation` API已集成到地图服务中
### Q: 地图不显示怎么办?
A: 检查网络连接,确保腾讯地图服务正常
### Q: 位置权限被拒绝?
A: 引导用户手动开启位置权限,或使用默认位置
## 更多信息
- [腾讯地图小程序SDK官方文档](https://lbs.qq.com/miniProgram/jsSdk/jsSdkGuide/jsSdkOverview)
- [API密钥申请](https://lbs.qq.com/dev/console/application/mine)
- [Taro Map组件文档](https://taro-docs.jd.com/docs/components/map)

View File

@@ -0,0 +1,382 @@
.map-display {
height: 100vh;
background-color: #f5f5f5;
display: flex;
flex-direction: column;
.map-section {
flex: 1;
position: relative;
background-color: #e8f4fd;
.map-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
.map-component {
width: 100%;
height: calc(100vh - 50%);
border-radius: 0;
}
.map-loading {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
.map-loading-text {
font-size: 16px;
color: #666;
text-align: center;
padding: 20px;
}
}
.map-placeholder {
font-size: 16px;
color: #666;
text-align: center;
padding: 20px;
}
.location-info-overlay {
position: absolute;
top: 20px;
left: 20px;
right: 20px;
z-index: 10;
.location-info {
background-color: rgba(255, 255, 255, 0.95);
padding: 12px 16px;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
align-items: center;
justify-content: space-between;
backdrop-filter: blur(10px);
.location-text {
font-size: 13px;
color: #333;
flex: 1;
margin-right: 12px;
line-height: 1.4;
}
.refresh-btn {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f0f0f0;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
&:hover {
background-color: #e0e0e0;
transform: scale(1.1);
}
&:active {
transform: scale(0.95);
}
}
}
}
.center-info-overlay {
position: absolute;
bottom: 20px;
left: 20px;
right: 20px;
z-index: 10;
.center-info {
background-color: rgba(255, 255, 255, 0.95);
padding: 12px 16px;
border-radius: 24px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
.center-text {
font-size: 13px;
color: #333;
text-align: center;
line-height: 1.4;
margin-bottom: 4px;
}
.moving-indicator {
display: flex;
align-items: center;
justify-content: center;
padding: 4px 8px;
background-color: rgba(255, 193, 7, 0.9);
border-radius: 12px;
animation: pulse 1.5s ease-in-out infinite;
.moving-text {
font-size: 11px;
color: #333;
font-weight: 500;
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.8;
transform: scale(1.05);
}
}
}
}
.fixed-center-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 15;
pointer-events: none;
.center-dot {
width: 20px;
height: 20px;
background-color: #ff4757;
border: 3px solid #fff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
animation: pulse 2s infinite;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
}
.location-info {
position: absolute;
top: 20px;
left: 20px;
background-color: rgba(255, 255, 255, 0.9);
padding: 8px 12px;
border-radius: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.location-text {
font-size: 12px;
color: #333;
}
}
.sdk-status {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 12px 20px;
border-radius: 20px;
font-size: 14px;
z-index: 20;
.sdk-status-text {
color: white;
}
}
}
}
.search-section {
background-color: #fff;
padding: 16px;
border-bottom: 1px solid #eee;
.search-wrapper {
display: flex;
align-items: center;
background-color: #f8f8f8;
border-radius: 24px;
padding: 0 16px;
position: relative;
.search-icon {
font-size: 16px;
color: #999;
margin-right: 8px;
}
.search-input {
flex: 1;
height: 40px;
font-size: 14px;
color: #333;
background: transparent;
border: none;
outline: none;
&::placeholder {
color: #999;
}
&:disabled {
color: #ccc;
}
}
.clear-btn {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
background-color: #e0e0e0;
border-radius: 50%;
cursor: pointer;
font-size: 12px;
color: #666;
transition: all 0.2s;
&:hover {
background-color: #d0d0d0;
color: #333;
}
}
}
}
.search-results {
background-color: #fff;
flex: 1;
overflow: hidden;
.results-header {
padding: 16px;
border-bottom: 1px solid #eee;
display: flex;
align-items: center;
.results-title {
font-size: 16px;
font-weight: 500;
color: #333;
}
.results-count {
font-size: 14px;
color: #999;
margin-left: 8px;
}
}
.results-list {
max-height: 300px;
.result-item {
display: flex;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f8f8f8;
}
.result-content {
flex: 1;
.result-title {
font-size: 14px;
font-weight: 500;
color: #333;
margin-bottom: 4px;
display: block;
}
.result-address {
font-size: 12px;
color: #999;
display: block;
}
}
.result-arrow {
font-size: 16px;
color: #ccc;
margin-left: 12px;
}
}
}
}
.searching-indicator {
padding: 20px;
text-align: center;
background-color: #fff;
.searching-text {
font-size: 14px;
color: #999;
}
}
.no-results {
padding: 40px 20px;
text-align: center;
background-color: #fff;
.no-results-text {
font-size: 14px;
color: #999;
}
}
.sdk-status-full {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px 30px;
border-radius: 12px;
font-size: 16px;
z-index: 1000;
.sdk-status-text {
color: white;
}
}
}

View File

@@ -0,0 +1,505 @@
import React, { useState, useEffect, useRef } from 'react'
import { View, Text, Input, ScrollView, Map } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { mapService, SearchResult, LocationInfo } from './mapService'
import './index.scss'
const MapDisplay: React.FC = () => {
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
const [searchValue, setSearchValue] = useState('')
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const [mapContext, setMapContext] = useState<any>(null)
const [isSDKReady, setIsSDKReady] = useState(false)
const [mapMarkers, setMapMarkers] = useState<any[]>([])
// 地图中心点状态
const [mapCenter, setMapCenter] = useState<{lat: number, lng: number} | null>(null)
// 用户点击的中心点标记
const [centerMarker, setCenterMarker] = useState<any>(null)
// 是否正在移动地图
const [isMapMoving, setIsMapMoving] = useState(false)
// 地图移动的动画帧ID
const animationFrameRef = useRef<number | null>(null)
// 地图移动的目标位置
const [targetCenter, setTargetCenter] = useState<{lat: number, lng: number} | null>(null)
// 实时移动的定时器
const moveTimerRef = useRef<NodeJS.Timeout | null>(null)
// 地图移动状态
const [mapMoveState, setMapMoveState] = useState({
isMoving: false,
startTime: 0,
startCenter: null as {lat: number, lng: number} | null,
lastUpdateTime: 0
})
useEffect(() => {
initializeMapService()
return () => {
// 清理动画帧和定时器
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
}
if (moveTimerRef.current) {
clearInterval(moveTimerRef.current)
}
}
}, [])
// 初始化地图服务
const initializeMapService = async () => {
try {
const success = await mapService.initSDK()
if (success) {
setIsSDKReady(true)
console.log('地图服务初始化成功')
getCurrentLocation()
} else {
console.error('地图服务初始化失败')
Taro.showToast({
title: '地图服务初始化失败',
icon: 'none'
})
}
} catch (error) {
console.error('初始化地图服务异常:', error)
Taro.showToast({
title: '地图服务初始化异常',
icon: 'none'
})
}
}
// 获取当前位置
const getCurrentLocation = async () => {
try {
const location = await mapService.getLocation()
if (location) {
setCurrentLocation(location)
// 设置地图中心为当前位置,但不显示标记
setMapCenter({ lat: location.lat, lng: location.lng })
// 清空所有标记
setMapMarkers([])
console.log('当前位置:', location)
}
} catch (error) {
console.error('获取位置失败:', error)
Taro.showToast({
title: '获取位置失败',
icon: 'none'
})
}
}
// 地图加载完成
const handleMapLoad = (e: any) => {
console.log('地图加载完成:', e)
setMapContext(e.detail)
}
// 地图标记点击
const handleMarkerTap = (e: any) => {
const markerId = e.detail.markerId
console.log('点击标记:', markerId)
if (markerId === 'center') {
Taro.showToast({
title: '中心点标记',
icon: 'success'
})
}
}
// 地图区域点击 - 设置中心点和标记
const handleMapTap = (e: any) => {
const { latitude, longitude } = e.detail
console.log('地图点击:', { latitude, longitude })
// 设置新的地图中心点
setMapCenter({ lat: latitude, lng: longitude })
// 设置中心点标记
const newCenterMarker = {
id: 'center',
latitude: latitude,
longitude: longitude,
title: '中心点',
iconPath: '/assets/center-marker.png', // 可以添加自定义中心点图标
width: 40,
height: 40
}
setCenterMarker(newCenterMarker)
// 更新地图标记,只显示中心点标记
setMapMarkers([newCenterMarker])
Taro.showToast({
title: '已设置中心点',
icon: 'success'
})
}
// 地图开始移动
const handleMapMoveStart = () => {
console.log('地图开始移动')
setIsMapMoving(true)
setMapMoveState(prev => ({
...prev,
isMoving: true,
startTime: Date.now(),
startCenter: mapCenter,
lastUpdateTime: Date.now()
}))
// 启动实时移动更新
startRealTimeMoveUpdate()
}
// 启动实时移动更新
const startRealTimeMoveUpdate = () => {
if (moveTimerRef.current) {
clearInterval(moveTimerRef.current)
}
// 每16ms更新一次约60fps
moveTimerRef.current = setInterval(() => {
if (mapMoveState.isMoving && centerMarker) {
// 模拟地图移动过程中的位置更新
// 这里我们基于时间计算一个平滑的移动轨迹
const currentTime = Date.now()
const elapsed = currentTime - mapMoveState.startTime
const moveDuration = 300 // 假设移动持续300ms
if (elapsed < moveDuration) {
// 计算移动进度
const progress = elapsed / moveDuration
const easeProgress = 1 - Math.pow(1 - progress, 3) // 缓动函数
// 如果有目标位置,进行插值计算
if (targetCenter && mapMoveState.startCenter) {
const newLat = mapMoveState.startCenter.lat + (targetCenter.lat - mapMoveState.startCenter.lat) * easeProgress
const newLng = mapMoveState.startCenter.lng + (targetCenter.lng - mapMoveState.startCenter.lng) * easeProgress
// 更新中心点标记位置
const updatedCenterMarker = {
...centerMarker,
latitude: newLat,
longitude: newLng
}
setCenterMarker(updatedCenterMarker)
// 更新地图标记
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
setMapMarkers([updatedCenterMarker, ...searchMarkers])
}
}
}
}, 16)
}
// 地图区域变化 - 更新目标位置
const handleRegionChange = (e: any) => {
console.log('地图区域变化:', e.detail)
// 获取地图当前的中心点坐标
if (e.detail && e.detail.centerLocation) {
const { latitude, longitude } = e.detail.centerLocation
const newCenter = { lat: latitude, lng: longitude }
// 设置目标位置
setTargetCenter(newCenter)
// 更新地图中心点状态
setMapCenter(newCenter)
// 如果有中心点标记,立即更新标记位置到新的地图中心
if (centerMarker) {
const updatedCenterMarker = {
...centerMarker,
latitude: latitude,
longitude: longitude
}
setCenterMarker(updatedCenterMarker)
// 更新地图标记,保持搜索结果标记
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
setMapMarkers([updatedCenterMarker, ...searchMarkers])
}
}
}
// 地图移动结束
const handleMapMoveEnd = () => {
console.log('地图移动结束')
setIsMapMoving(false)
setMapMoveState(prev => ({
...prev,
isMoving: false
}))
// 停止实时移动更新
if (moveTimerRef.current) {
clearInterval(moveTimerRef.current)
moveTimerRef.current = null
}
// 清理动画帧
if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current)
animationFrameRef.current = null
}
}
// 处理搜索输入
const handleSearchInput = (e: any) => {
const value = e.detail.value
setSearchValue(value)
// 如果输入内容为空,清空搜索结果
if (!value.trim()) {
setSearchResults([])
return
}
// 防抖搜索
clearTimeout((window as any).searchTimer)
;(window as any).searchTimer = setTimeout(() => {
performSearch(value)
}, 500)
}
// 执行搜索
const performSearch = async (keyword: string) => {
if (!keyword.trim() || !isSDKReady) return
setIsSearching(true)
try {
const results = await mapService.search({
keyword,
location: currentLocation ? `${currentLocation.lat},${currentLocation.lng}` : undefined
})
setSearchResults(results)
// 在地图上添加搜索结果标记
if (results.length > 0) {
const newMarkers = results.map((result, index) => ({
id: `search_${index}`,
latitude: result.location.lat,
longitude: result.location.lng,
title: result.title,
iconPath: '/assets/search-marker.png', // 可以添加自定义图标
width: 24,
height: 24
}))
// 合并中心点标记和搜索结果标记
const allMarkers = centerMarker ? [centerMarker, ...newMarkers] : newMarkers
setMapMarkers(allMarkers)
}
console.log('搜索结果:', results)
} catch (error) {
console.error('搜索异常:', error)
Taro.showToast({
title: '搜索失败',
icon: 'none'
})
setSearchResults([])
} finally {
setIsSearching(false)
}
}
// 处理搜索结果点击 - 切换地图中心到对应地点
const handleResultClick = (result: SearchResult) => {
console.log('选择地点:', result)
Taro.showToast({
title: `已切换到: ${result.title}`,
icon: 'success'
})
// 点击搜索结果时,将地图中心移动到该位置
const newCenter = { lat: result.location.lat, lng: result.location.lng }
setMapCenter(newCenter)
// 更新中心点标记
const newCenterMarker = {
id: 'center',
latitude: result.location.lat,
longitude: result.location.lng,
title: '中心点',
iconPath: '/assets/center-marker.png',
width: 40,
height: 40
}
setCenterMarker(newCenterMarker)
// 更新地图标记,保留搜索结果标记
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
setMapMarkers([newCenterMarker, ...searchMarkers])
// 如果地图上下文可用也可以调用地图API移动
if (mapContext && mapContext.moveToLocation) {
mapContext.moveToLocation({
latitude: result.location.lat,
longitude: result.location.lng,
success: () => {
console.log('地图移动到搜索结果位置')
},
fail: (err: any) => {
console.error('地图移动失败:', err)
}
})
}
}
// 处理搜索框清空
const handleSearchClear = () => {
setSearchValue('')
setSearchResults([])
// 清空搜索结果标记,只保留中心点标记
setMapMarkers(centerMarker ? [centerMarker] : [])
}
// 刷新位置
const handleRefreshLocation = () => {
getCurrentLocation()
Taro.showToast({
title: '正在刷新位置...',
icon: 'loading'
})
}
return (
<View className='map-display'>
{/* 地图区域 */}
<View className='map-section'>
<View className='map-container'>
{currentLocation ? (
<Map
className='map-component'
longitude={mapCenter?.lng || currentLocation.lng}
latitude={mapCenter?.lat || currentLocation.lat}
scale={16}
markers={mapMarkers}
show-location={true}
onTap={handleMapTap}
theme="dark"
onRegionChange={handleRegionChange}
onTouchStart={handleMapMoveStart}
onTouchEnd={handleMapMoveEnd}
onError={(e) => console.error('地图加载错误:', e)}
/>
) : (
<View className='map-loading'>
<Text className='map-loading-text'>...</Text>
</View>
)}
{/* 位置信息悬浮层 */}
{currentLocation && (
<View className='location-info-overlay'>
<View className='location-info'>
<Text className='location-text'>
{currentLocation.address || `当前位置: ${currentLocation.lat.toFixed(6)}, ${currentLocation.lng.toFixed(6)}`}
</Text>
<View className='refresh-btn' onClick={handleRefreshLocation}>
🔄
</View>
</View>
</View>
)}
{/* 中心点信息悬浮层 */}
{centerMarker && (
<View className='center-info-overlay'>
<View className='center-info'>
<Text className='center-text'>
: {centerMarker.latitude.toFixed(6)}, {centerMarker.longitude.toFixed(6)}
</Text>
{isMapMoving && (
<View className='moving-indicator'>
<Text className='moving-text'>...</Text>
</View>
)}
</View>
</View>
)}
{!isSDKReady && (
<View className='sdk-status'>
<Text className='sdk-status-text'>...</Text>
</View>
)}
</View>
</View>
{/* 搜索区域 */}
<View className='search-section'>
<View className='search-wrapper'>
<View className='search-icon'>🔍</View>
<Input
className='search-input'
placeholder={isSDKReady ? '搜索地点' : '地图服务初始化中...'}
value={searchValue}
onInput={handleSearchInput}
disabled={!isSDKReady}
/>
{searchValue && (
<View className='clear-btn' onClick={handleSearchClear}>
</View>
)}
</View>
</View>
{/* 搜索结果列表 */}
{searchResults.length > 0 && (
<View className='search-results'>
<View className='results-header'>
<Text className='results-title'></Text>
<Text className='results-count'>({searchResults.length})</Text>
</View>
<ScrollView className='results-list' scrollY>
{searchResults.map((result) => (
<View
key={result.id}
className='result-item'
onClick={() => handleResultClick(result)}
>
<View className='result-content'>
<Text className='result-title'>{result.title}</Text>
<Text className='result-address'>{result.address}</Text>
</View>
<View className='result-arrow'></View>
</View>
))}
</ScrollView>
</View>
)}
{/* 搜索状态提示 */}
{isSearching && (
<View className='searching-indicator'>
<Text className='searching-text'>...</Text>
</View>
)}
{/* 无搜索结果提示 */}
{searchValue && !isSearching && searchResults.length === 0 && isSDKReady && (
<View className='no-results'>
<Text className='no-results-text'></Text>
</View>
)}
{/* SDK状态提示 */}
{!isSDKReady && (
<View className='sdk-status-full'>
<Text className='sdk-status-text'>...</Text>
</View>
)}
</View>
)
}
export default MapDisplay

View File

@@ -0,0 +1,63 @@
import Taro from '@tarojs/taro';
import { Button } from '@tarojs/components';
import { mapService, SearchResult, LocationInfo } from './mapService'
import { useEffect, useState } from 'react';
export default function MapPlugin() {
const key = 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'; //使用在腾讯位置服务申请的key
const referer = '八瓜一月'; //调用插件的app的名称
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
const category = '';
const chooseLocation = () => {
Taro.navigateTo({
url: 'plugin://chooseLocation/index?key=' + key + '&referer=' + referer + '&latitude=' + currentLocation?.lat + '&longitude=' + currentLocation?.lng
});
}
useEffect(() => {
initializeMapService()
}, [])
// 初始化地图服务
const initializeMapService = async () => {
try {
const success = await mapService.initSDK()
if (success) {
console.log('地图服务初始化成功')
getCurrentLocation()
} else {
console.error('地图服务初始化失败')
Taro.showToast({
title: '地图服务初始化失败',
icon: 'none'
})
}
} catch (error) {
console.error('初始化地图服务异常:', error)
Taro.showToast({
title: '地图服务初始化异常',
icon: 'none'
})
}
}
// 获取当前位置
const getCurrentLocation = async () => {
try {
const location = await mapService.getLocation()
if (location) {
setCurrentLocation(location)
console.log('当前位置:', location)
}
} catch (error) {
console.error('获取位置失败:', error)
Taro.showToast({
title: '获取位置失败',
icon: 'none'
})
}
}
return (
<Button onClick={chooseLocation}></Button>
)
}

View File

@@ -0,0 +1,190 @@
// 腾讯地图SDK服务
import QQMapWX from "qqmap-wx-jssdk";
import Taro from '@tarojs/taro';
// 扩展Window接口添加qqmapsdk属性
declare global {
interface Window {
qqmapsdk?: any;
}
}
export interface LocationInfo {
lat: number
lng: number
address?: string
}
export interface SearchResult {
id: string
title: string
address: string
location: {
lat: number
lng: number
}
}
export interface SearchOptions {
keyword: string
location?: string
page_size?: number
page_index?: number
}
class MapService {
private qqmapsdk: any = null
private isInitialized = false
// 初始化腾讯地图SDK
async initSDK(): Promise<boolean> {
if (this.isInitialized) {
return true
}
try {
// 直接使用QQMapWX不需要通过window对象
this.qqmapsdk = new QQMapWX({
key: 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'
});
this.isInitialized = true
console.log('腾讯地图SDK初始化成功')
return true
} catch (error) {
console.error('初始化腾讯地图SDK失败:', error)
return false
}
}
// 搜索地点
async search(options: SearchOptions): Promise<SearchResult[]> {
if (!this.isInitialized) {
await this.initSDK()
}
try {
console.log(this.qqmapsdk,11)
if (this.qqmapsdk && this.qqmapsdk.search) {
return new Promise((resolve, reject) => {
this.qqmapsdk.getSuggestion({
keyword: options.keyword,
location: options.location || '39.908802,116.397502', // 默认北京
page_size: options.page_size || 20,
page_index: options.page_index || 1,
success: (res: any) => {
console.log('搜索成功:', res)
if (res.data && res.data.length > 0) {
const results: SearchResult[] = res.data.map((item: any, index: number) => ({
id: `search_${index}`,
title: item.title || item.name || '未知地点',
address: item.address || item.location || '地址未知',
location: {
lat: item.location?.lat || 0,
lng: item.location?.lng || 0
}
}))
resolve(results)
} else {
resolve([])
}
},
fail: (err: any) => {
console.error('搜索失败:', err)
reject(err)
}
})
})
} else {
// 使用模拟数据
console.log('使用模拟搜索数据')
return this.getMockSearchResults(options.keyword)
}
} catch (error) {
console.error('搜索异常:', error)
return this.getMockSearchResults(options.keyword)
}
}
// 获取模拟搜索结果
private getMockSearchResults(keyword: string): SearchResult[] {
const mockResults: SearchResult[] = [
{
id: 'mock_1',
title: `${keyword}相关地点1`,
address: '模拟地址1 - 这是一个示例地址',
location: { lat: 39.908802, lng: 116.397502 }
},
{
id: 'mock_2',
title: `${keyword}相关地点2`,
address: '模拟地址2 - 这是另一个示例地址',
location: { lat: 39.918802, lng: 116.407502 }
},
{
id: 'mock_3',
title: `${keyword}相关地点3`,
address: '模拟地址3 - 第三个示例地址',
location: { lat: 39.898802, lng: 116.387502 }
}
]
return mockResults
}
// 获取当前位置
async getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
try {
// 这里可以集成实际的定位服务
// 暂时返回模拟位置
const res = await Taro.getLocation({
type: 'gcj02',
isHighAccuracy: true
})
return {
lat: res.latitude,
lng: res.longitude
}
} catch (error) {
console.error('获取位置失败:', error)
return null
}
}
async getAddress(lat: number, lng: number): Promise<string | null | undefined> {
try {
const addressRes: any = await new Promise((resolve, reject) => {
this.qqmapsdk.reverseGeocoder({
location: {
latitude: lat,
longitude: lng
},
success: resolve,
fail: reject
})
})
return addressRes?.results?.address
} catch (error) {
console.error('获取地址失败:', error)
}
}
async getLocation(): Promise<{ lat: number; lng: number; address: string } | null | undefined> {
try {
const currentInfo: any = {};
const location = await this.getCurrentLocation();
const { lat, lng } = location || {};
if (lat && lng) {
currentInfo.lat = lat;
currentInfo.lng = lng;
const addressRes = await this.getAddress(lat, lng)
if (addressRes) {
currentInfo.address = addressRes;
}
}
return currentInfo;
} catch (error) {
console.error('获取位置失败:', error)
}
}
}
export const mapService = new MapService()

View File

@@ -0,0 +1,111 @@
@use '~@/scss/themeColor.scss' as theme;
// 人数控制区域 - 白色块
.participants-control-section {
background: white;
border-radius: 16px;
width: 100%;
padding: 9px 12px;
display: flex;
justify-content: space-between;
height: 48px;
box-sizing: border-box;
.participant-control {
display: flex;
align-items: center;
position: relative;
&:first-child{
width: 50%;
&::after{
content: '';
display: block;
width: 1px;
height: 16px;
background: #E5E5E5;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
}
.control-label {
font-size: 13px;
color: theme.$primary-color;
white-space: nowrap;
padding-right: 10px;
}
.control-buttons {
display: flex;
align-items: center;
height: 30px;
background-color: theme.$primary-background-color;
border-radius: 6px;
.format-width{
.nut-input-minus{
width: 33px;
position: relative;
&::after{
content: '';
width: 1px;
height: 16px;
background-color: theme.$primary-background-color;
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
}
}
.nut-number-input{
min-width: 33px;
background-color: transparent;
font-size: 12px;
}
.nut-input-add{
width: 33px;
position: relative;
&::before{
content: '';
width: 1px;
height: 16px;
background-color: theme.$primary-background-color;
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
}
}
}
.control-btn {
width: 32px;
height: 32px;
border: 1px solid #ddd;
background: white;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
color: #333;
margin: 0;
padding: 0;
&.minus {
margin-right: 12px;
}
&.plus {
margin-left: 12px;
}
}
.control-value {
font-size: 16px;
color: #333;
font-weight: 500;
min-width: 36px;
text-align: center;
}
}
}
}

View File

@@ -0,0 +1,51 @@
import React from 'react'
import { View, Text, Button } from '@tarojs/components'
import './NumberInterval.scss'
import { InputNumber } from '@nutui/nutui-react-taro'
interface NumberIntervalProps {
value: [number, number]
onChange: (value: [number, number]) => void
}
const NumberInterval: React.FC<NumberIntervalProps> = ({
value,
onChange
}) => {
const [minParticipants, maxParticipants] = value || [1, 4]
const handleChange = (value: [number | string, number | string]) => {
onChange([Number(value[0]), Number(value[1])])
}
return (
<View className='participants-control-section'>
<View className='participant-control'>
<Text className='control-label'></Text>
<View className='control-buttons'>
<InputNumber
className="format-width"
defaultValue={minParticipants}
min={minParticipants}
max={maxParticipants}
onChange={(value) => handleChange([value, maxParticipants])}
formatter={(value) => `${value}`}
/>
</View>
</View>
<View className='participant-control'>
<Text className='control-label'></Text>
<View className='control-buttons'>
<InputNumber
className="format-width"
defaultValue={maxParticipants}
onChange={(value) => handleChange([value, maxParticipants])}
min={minParticipants}
max={maxParticipants}
formatter={(value) => `${value}`}
/>
</View>
</View>
</View>
)
}
export default NumberInterval

View File

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

View File

@@ -62,10 +62,13 @@
}
:global(.nut-range-tick) {
background: #3c3c3c;
background: rgba(60, 60, 67, 0.18);
height: 4px !important;
width: 4px !important;
}
:global(.nut-range) {
background-color: rgba(120, 120, 120, 0.20) !important;
}
}
span {

View File

@@ -14,6 +14,7 @@ interface RangeProps {
disabled?: boolean;
className?: string;
name: string;
showTitle?: boolean;
}
const NtrpRange: React.FC<RangeProps> = ({
@@ -25,6 +26,7 @@ const NtrpRange: React.FC<RangeProps> = ({
disabled = false,
className,
name,
showTitle = true,
}) => {
const [currentValue, setCurrentValue] = useState<[number, number]>(value);
@@ -55,16 +57,18 @@ const NtrpRange: React.FC<RangeProps> = ({
return (
<div className={`${styles.nutRange} ${className ? className : ""} `}>
<div className={styles.nutRangeHeader}>
<TitleComponent
title="NTRP水平区间"
icon={<Image src={img.ICON_PLAY} />}
/>
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
</div>
{showTitle && (
<div className={styles.nutRangeHeader}>
<TitleComponent
title="NTRP水平区间"
icon={<Image src={img.ICON_PLAY} />}
/>
<p className={styles.nutRangeHeaderContent}>{rangContent}</p>
</div>
)}
<div>
<div className={styles.rangeWrapper}>
<div className={`${styles.rangeWrapper} rangeContent`}>
<span className={styles.rangeWrapperMin}>{min.toFixed(1)}</span>
<Range
range

View File

@@ -0,0 +1,53 @@
@use '~@/scss/themeColor.scss' as theme;
.textarea-tag {
background: white;
border-radius: 16px;
padding: 10px 16px;
width: 100%;
box-sizing: border-box;
.input-wrapper {
margin-top: 8px;
.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;
.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;
margin-bottom: 6px;
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
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: { description: string, description_tag: string[] }
onChange: (value: { description: string, description_tag: string[] }) => void
title?: string
showTitle?: boolean
placeholder?: string
maxLength?: number
options?: { label: string; value: any }[] | null
}
const TextareaTag: React.FC<TextareaTagProps> = ({
value,
onChange,
placeholder = '请输入',
maxLength = 500,
options = []
}) => {
// 处理文本输入变化
const handleTextChange = useCallback((e: any) => {
onChange({...value, description: e.detail.value})
}, [onChange])
// 处理标签选择变化
const handleTagChange = useCallback((selectedTags: string[]) => {
onChange({...value, description_tag: selectedTags})
}, [onChange])
console.log(options, 'options')
return (
<View className='textarea-tag'>
{/* 选择选项 */}
<View className='options-wrapper'>
<View className='options-list'>
{
<Checkbox.Group
labelPosition="left"
direction="horizontal"
value={value.description_tag}
onChange={handleTagChange}
>
{
options?.map((option, index) => (
<Checkbox
key={index}
shape="button"
value={option.value}
label={option.label}
/>
))
}
</Checkbox.Group>
}
</View>
</View>
{/* 输入框 */}
<View className='input-wrapper'>
<Textarea
className='additional-input'
placeholder={placeholder}
value={value.description}
placeholderClass='textarea-placeholder'
onInput={handleTextChange}
maxlength={maxLength}
autoHeight={false}
/>
</View>
</View>
)
}
export default TextareaTag

View File

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

View File

@@ -0,0 +1,73 @@
@use '~@/scss/themeColor.scss' as theme;
.time-selector {
// 区域标题 - 灰色背景
width: 100%;
// 时间区域 - 合并的白色块
.time-section {
background: white;
border-radius: 16px;
width: 100%;
.time-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding-left: 12px;
&:last-child {
margin-bottom: 0;
.time-content {
border-bottom: none;
}
}
.time-label {
display: flex;
align-items: center;
padding: 0 3px;
font-size: 14px;
color: theme.$primary-color;
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: theme.$primary-color;
border: 1.5px solid theme.$primary-color;
margin-right: 12px;
&.hollow {
background: transparent;
width: 8px;
height: 8px;
}
}
}
.time-content {
display: flex;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
line-height: 44px;
justify-content: space-between;
flex: 1;
padding-right: 12px;
.time-text-wrapper {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.time-text {
font-size: 13px;
color: theme.$primary-color;
padding: 0 12px;
background: theme.$primary-shallow-bg;
height: 28px;
line-height: 28px;
border-radius: 14px;
&.time-am {
font-weight: 600;
}
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
import React, { useState } from 'react'
import { View, Text, } from '@tarojs/components'
import { getDate, getTime } from '@/utils/timeUtils'
import DateTimePicker from '@/components/DateTimePicker'
import './TimeSelector.scss'
export interface TimeRange {
start_time: string
end_time: string
}
interface TimeSelectorProps {
value: TimeRange
onChange: (timeRange: TimeRange) => void
}
const TimeSelector: React.FC<TimeSelectorProps> = ({
value = {
start_time: '',
end_time: ''
},
onChange
}) => {
// 格式化日期显示
const [visible, setVisible] = useState(false)
const handleConfirm = (year: number, month: number) => {
console.log('选择的日期:', year, month)
}
return (
<View className='time-selector'>
<View className='time-section'>
{/* 开始时间 */}
<View className='time-item'>
<View className='time-label'>
<View className='dot'></View>
</View>
<View className='time-content' onClick={() => setVisible(true)}>
<Text className='time-label'></Text>
<view className='time-text-wrapper'>
<Text className='time-text'>{getDate(value.start_time)}</Text>
<Text className='time-text time-am'>{getTime(value.start_time)}</Text>
</view>
</View>
</View>
{/* 结束时间 */}
<View className='time-item'>
<View className='time-label'>
<View className='dot hollow'></View>
</View>
<View className='time-content'>
<Text className='time-label'></Text>
<view className='time-text-wrapper'>
<Text className='time-text time-am'>{getTime(value.end_time)}</Text>
</view>
</View>
</View>
</View>
<DateTimePicker
visible={visible}
onClose={() => setVisible(false)}
onConfirm={handleConfirm}
defaultYear={2025}
defaultMonth={11}
minYear={2020}
maxYear={2030}
/>
</View>
)
}
export default TimeSelector

View File

@@ -0,0 +1 @@
export { default, type TimeRange } from './TimeSelector'

View File

@@ -0,0 +1,35 @@
import React from 'react'
import { View } from '@tarojs/components'
import { TextArea } from '@nutui/nutui-react-taro'
import './index.scss'
interface TitleTextareaProps {
value: string
onChange: (value: string) => void
maxLength?: number
placeholder?: string
}
const TitleTextarea: React.FC<TitleTextareaProps> = ({
value,
onChange,
maxLength = 20,
placeholder = '好的标题更吸引人哦'
}) => {
return (
<View className='title-input-wrapper'>
<TextArea
className='title-input'
placeholder={placeholder}
value={value}
onInput={(e) => onChange(e.detail.value)}
maxlength={maxLength}
autoSize={true}
placeholderClass='title-input-placeholder'
/>
<View className='char-count'>{value.length}/{maxLength}</View>
</View>
)
}
export default TitleTextarea

View File

@@ -0,0 +1,34 @@
.title-input-wrapper {
position: relative;
width: 100%;
display: flex;
align-items: flex-start;
justify-content: space-around;
.title-input {
width: 83%;
min-height: 44px;
padding: 12px 16px;
border-radius: 8px;
font-size: 16px;
line-height: 1.4;
background: #fff;
box-sizing: border-box;
resize: none;
}
// 使用 placeholderClass 来控制 placeholder 样式
.title-input-placeholder {
color: rgba(60, 60, 67, 0.60) !important;
font-size: 16px !important;
font-weight: normal !important;
}
.char-count {
font-size: 12px;
color: #999;
pointer-events: none;
padding-top: 12px;
}
}

View File

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

25
src/components/index.ts Normal file
View File

@@ -0,0 +1,25 @@
import ActivityTypeSwitch from './ActivityTypeSwitch'
import TextareaTag from './TextareaTag'
import FormSwitch from './FormSwitch'
import ImageUpload from './ImageUpload'
import Range from './Range'
import NumberInterval from './NumberInterval'
import TimeSelector from './TimeSelector'
import TitleTextarea from './TitleTextarea'
import CommonPopup from './CommonPopup'
import DateTimePicker from './DateTimePicker/DateTimePicker'
export {
ActivityTypeSwitch,
TextareaTag,
FormSwitch,
ImageUpload,
Range,
NumberInterval,
TimeSelector,
TitleTextarea,
CommonPopup,
DateTimePicker
}

View File

@@ -0,0 +1,4 @@
import { type TimeRange } from './TimeSelector'
import { type ActivityType } from './ActivityTypeSwitch'
import { type CoverImage } from './ImageUpload'
export type { TimeRange, ActivityType, CoverImage }

View File

@@ -7,7 +7,6 @@ export type EnvType = 'development' | 'test' | 'production'
export interface EnvConfig {
name: string
apiBaseURL: string
apiVersion: string
timeout: number
enableLog: boolean
enableMock: boolean
@@ -18,8 +17,7 @@ const envConfigs: Record<EnvType, EnvConfig> = {
// 开发环境
development: {
name: '开发环境',
apiBaseURL: 'https://dev-api.playballtogether.com',
apiVersion: 'v1',
apiBaseURL: 'https://sit.light120.com',
timeout: 15000,
enableLog: true,
enableMock: true
@@ -28,8 +26,7 @@ const envConfigs: Record<EnvType, EnvConfig> = {
// 测试环境
test: {
name: '测试环境',
apiBaseURL: 'https://test-api.playballtogether.com',
apiVersion: 'v1',
apiBaseURL: 'https://sit.light120.com',
timeout: 12000,
enableLog: true,
enableMock: false
@@ -38,8 +35,7 @@ const envConfigs: Record<EnvType, EnvConfig> = {
// 生产环境
production: {
name: '生产环境',
apiBaseURL: 'https://api.playballtogether.com',
apiVersion: 'v1',
apiBaseURL: 'https://sit.light120.com',
timeout: 10000,
enableLog: false,
enableMock: false

View File

@@ -0,0 +1,176 @@
// 表单字段类型枚举
export enum FieldType {
TEXT = 'text',
TEXTAREA = 'textarea',
SELECT = 'select',
DATE = 'date',
TIME = 'time',
NUMBER = 'number',
SWITCH = 'switch',
RADIO = 'radio',
CHECKBOX = 'checkbox',
LOCATION = 'location',
UPLOADIMAGE = 'uploadimage',
TIMEINTERVAL = 'timeinterval',
NUMBERINTERVAL = 'numberinterval',
RANGE = 'range',
TEXTAREATAG = 'textareaTag',
ACTIVITYINFO = 'activityInfo',
WECHATCONTACT = 'wechatContact'
}
// 表单字段配置接口
export interface FormFieldConfig {
prop: string
label: string
type: FieldType
placeholder?: string
required?: boolean
defaultValue?: any
options?: Array<{ label: string; value: any }>
rules?: Array<{
required?: boolean
min?: number
max?: number
pattern?: RegExp
message: string
}>
props?: Record<string, any>
description?: string
children?: FormFieldConfig[]
iconType?: string
}
// 发布球局表单配置
export const publishBallFormSchema: FormFieldConfig[] = [
{
prop: 'image_list',
label: '活动封页',
type: FieldType.UPLOADIMAGE,
placeholder: '请选择活动类型',
required: true,
props: {
maxCount: 9
}
},
{
prop: 'title',
label: '',
type: FieldType.TEXT,
placeholder: '好的标题更吸引人哦',
required: true,
props: {
maxLength: 20
}
},
{
prop: 'timeRange',
label: '',
type: FieldType.TIMEINTERVAL,
placeholder: '请选择活动日期',
required: true
},
{
prop: 'activityInfo',
label: '活动信息',
type: FieldType.ACTIVITYINFO,
placeholder: '请选择活动时间',
required: true,
children: [
{
prop: 'price',
label: '费用',
iconType: 'ICON_COST',
type: FieldType.NUMBER,
placeholder: '请输入活动费用(元)',
defaultValue: 0,
rules: [
{ min: 0, message: '费用不能为负数' },
{ max: 1000, message: '费用不能超过1000元' }
],
},
{
prop: 'location_name',
label: '地点',
iconType: 'ICON_LOCATION',
type: FieldType.LOCATION,
placeholder: '请选择活动地点',
required: true,
},
{
prop: 'play_type',
label: '玩法',
iconType: 'ICON_GAMEPLAY',
type: FieldType.SELECT,
placeholder: '请选择玩法',
required: true,
options: [
{ label: '不限', value: '不限' },
{ label: '单打', value: '单打' },
{ label: '双打', value: '双打' },
{ label: '拉球', value: '拉球' }
],
}
]
},
{
prop: 'players',
label: '人数要求',
type: FieldType.NUMBERINTERVAL,
placeholder: '请输入最少参与人数',
defaultValue: 1,
props: {
showSummary: true,
summary: '最少1人最多4人',
}
},
{
prop: 'skill_level',
label: 'NTRP 水平要求',
type: FieldType.RANGE,
placeholder: '请选择开始时间',
required: true,
props: {
showTitle: false,
showSummary: true,
className: 'ntrp-range',
step: 0.5,
min: 1.0,
max: 5.0,
}
},
{
prop: 'descriptionInfo',
label: '补充要求(选填)',
type: FieldType.TEXTAREATAG,
placeholder: '补充性别偏好、特殊要求和注意事项等信息',
required: true,
options:[
{ label: '仅限男生', value: '仅限男生' },
{ label: '仅限女生', value: '仅限女生' },
{ label: '性别不限', value: '性别不限' }
]
},
{
prop: 'is_substitute_supported',
label: '',
type: FieldType.CHECKBOX,
placeholder: '开启自动候补逻辑',
required: true,
props:{
subTitle: '开启自动候补逻辑',
showToast: true,
description: '开启后,当活动人数不足时,系统会自动将活动状态改为“候补”,并通知用户。',
}
},
{
prop: 'is_wechat_contact',
label: '',
type: FieldType.WECHATCONTACT,
required: true,
props:{
subTitle: '允许球友微信联系我',
}
}
]

View File

@@ -17,4 +17,13 @@ export default {
ICON_MENU_ITEM_SELECTED: require('@/static/list/icon-menu-item-selected.svg'),
ICON_ARROW_DOWN_WHITE: require('@/static/list/icon-arrow-down-white.svg'),
ICON_LIST_RIGHT_ARROW: require('@/static/list/icon-list-right-arrow.svg'),
ICON_SEARCH: require('@/static/publishBall/icon-search.svg'),
ICON_MAP: require('@/static/publishBall/icon-map.svg'),
ICON_STADIUM: require('@/static/publishBall/icon-stadium.svg'),
ICON_ARRORW_SMALL: require('@/static/publishBall/icon-arrow-small.svg'),
ICON_MAP_SEARCH: require('@/static/publishBall/icon-map-search.svg'),
ICON_HEART_CIRCLE: require('@/static/publishBall/icon-heartcircle.png'),
ICON_ADD: require('@/static/publishBall/icon-add.svg'),
ICON_COPY: require('@/static/publishBall/icon-arrow-right.svg'),
ICON_DELETE: require('@/static/publishBall/icon-delete.svg')
}

227
src/nutui-theme.scss Normal file
View File

@@ -0,0 +1,227 @@
// ==========================================
// NutUI 专用黑色主题覆盖文件
// ==========================================
// 引入NutUI原始样式如果需要
// @import '@nutui/nutui-react-taro/dist/style.css';
// 全局主题变量覆盖
$nut-primary-color: #000000 !important;
$nut-primary-color-end: #000000 !important;
// 超强力样式覆盖 - 确保在所有环境下生效
:global {
// 页面根元素
page,
.taro_page,
#app,
.app {
// ===== Checkbox 复选框 =====
.nut-checkbox {
.nut-checkbox__icon {
border-color: #ddd;
&.nut-checkbox__icon--checked {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
&::after,
&::before {
color: #ffffff !important;
border-color: #ffffff !important;
}
.nut-icon,
.nut-icon-check {
color: #ffffff !important;
}
}
}
&.nut-checkbox--checked {
.nut-checkbox__icon {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
}
}
}
// ===== Button 按钮 =====
.nut-button {
&.nut-button--primary {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
&:hover,
&:active,
&:focus {
background-color: #333333 !important;
border-color: #333333 !important;
color: #ffffff !important;
}
}
&.nut-button--default {
border-color: #000000 !important;
color: #000000 !important;
&:active {
background-color: #f5f5f5 !important;
}
}
}
// ===== Switch 开关 =====
.nut-switch {
&.nut-switch--open,
&.nut-switch--active {
background-color: #000000 !important;
.nut-switch__button {
background-color: #ffffff !important;
}
}
}
// ===== Radio 单选框 =====
.nut-radio {
.nut-radio__icon {
&.nut-radio__icon--checked {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
}
}
&.nut-radio--checked {
.nut-radio__icon {
background-color: #000000 !important;
border-color: #000000 !important;
}
}
}
// ===== Input 输入框 =====
.nut-input {
&.nut-input--focus {
.nut-input__input {
border-color: #000000 !important;
}
}
}
// ===== Picker 选择器 =====
.nut-picker {
.nut-picker__confirm {
color: #000000 !important;
}
}
// ===== Slider 滑动条 =====
.nut-slider {
.nut-slider__track {
background-color: #000000 !important;
}
.nut-slider__button {
background-color: #000000 !important;
border-color: #000000 !important;
}
}
// ===== ActionSheet 动作面板 =====
.nut-actionsheet {
.nut-actionsheet__item {
&.nut-actionsheet__item--active {
color: #000000 !important;
}
}
}
// ===== Tab 标签页 =====
.nut-tabs {
.nut-tabs__tab {
&.nut-tabs__tab--active {
color: #000000 !important;
}
}
.nut-tabs__line {
background-color: #000000 !important;
}
}
}
}
// 超强力直接覆盖 - 针对所有可能的选择器
.nut-checkbox__icon--checked,
.nut-checkbox-icon-checked,
.nut-checkbox__icon.nut-checkbox__icon--checked,
.nut-checkbox .nut-checkbox__icon--checked,
.nut-checkbox .nut-checkbox__icon.nut-checkbox__icon--checked {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
background: #000000 !important;
border: 1px solid #000000 !important;
}
// 针对内部图标
.nut-checkbox__icon--checked .nut-icon,
.nut-checkbox-icon-checked .nut-icon,
.nut-checkbox__icon--checked::after,
.nut-checkbox-icon-checked::after,
.nut-checkbox__icon--checked::before,
.nut-checkbox-icon-checked::before {
color: #ffffff !important;
background-color: #ffffff !important;
border-color: #ffffff !important;
}
// 针对所有可能的勾选图标
.nut-icon-check,
.nut-icon-checklist,
.nut-checkbox__icon--checked .nut-icon-check,
.nut-checkbox-icon-checked .nut-icon-check {
color: #ffffff !important;
fill: #ffffff !important;
}
// 针对 checkbox button 激活状态
.nut-checkbox-button-active,
.nut-checkbox-button.nut-checkbox-button-active {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
background: #000000 !important;
border: 1px solid #000000 !important;
}
.nut-button--primary,
.nut-button.nut-button--primary {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
background: #000000 !important;
border: 1px solid #000000 !important;
}
.nut-switch--open,
.nut-switch.nut-switch--open {
background-color: #000000 !important;
background: #000000 !important;
}
.nut-radio__icon--checked,
.nut-radio-icon-checked,
.nut-radio .nut-radio__icon--checked {
background-color: #000000 !important;
border-color: #000000 !important;
color: #ffffff !important;
background: #000000 !important;
border: 1px solid #000000 !important;
}

File diff suppressed because it is too large Load Diff

1
src/package/qqmap-wx-jssdk.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@

View File

@@ -9,6 +9,7 @@ import { useEffect } from "react";
import Taro from "@tarojs/taro";
import { useListStore } from "../../store/listStore";
import { View } from "@tarojs/components";
import "./index.scss";
const ListPage = () => {
// 从 store 获取数据和方法

312
src/pages/login/README.md Normal file
View File

@@ -0,0 +1,312 @@
# 登录页面 - 基于 Figma 设计稿
## 设计概述
本登录页面完全按照 Figma 设计稿 `EWQlX5wM2lhiSLfFQp8qKT` 实现,具有以下特色:
## 🎨 设计特点
### 视觉设计
- **背景图片**:使用运动主题的背景图片,带有渐变遮罩效果
- **品牌元素**"有场" Logo 配合运动图标,体现约球应用定位
- **英文标语**"Go Together Grow Together" 传达社交运动理念
- **状态栏**:完全还原 iPhone 状态栏样式
### 交互设计
- **双登录方式**:微信快捷登录(主要)+ 手机号验证码登录(备选)
- **用户协议**:必须勾选同意协议才能登录
- **视觉反馈**:按钮加载状态、动画效果
- **毛玻璃效果**:按钮使用 backdrop-filter 实现现代化视觉效果
## 📱 页面结构
```
LoginPage
├── 背景图片层
│ ├── 运动背景图片
│ └── 渐变遮罩层
├── 状态栏
│ ├── 时间显示 (9:41)
│ └── 状态图标 (信号/WiFi/电池)
├── 主要内容
│ ├── 品牌区域
│ │ ├── "有场" Logo + 图标
│ │ └── 英文标语
│ └── 登录区域
│ ├── 微信快捷登录按钮
│ ├── 手机号验证码登录按钮
│ └── 用户协议选择
└── 底部指示器
```
## 🚀 功能特性
### 登录方式
#### 1. 微信快捷登录
- **视觉样式**:白色背景,微信绿色图标,毛玻璃效果
- **功能流程**
1. 检查用户协议同意状态
2. 调用 `Taro.login()` 获取微信 code
3. 调用 `Taro.getUserProfile()` 获取用户信息
4. 保存登录状态并跳转到首页
#### 2. 手机号验证码登录
- **视觉样式**:半透明背景,白色图标和文字
- **当前状态**:显示开发中提示(可扩展为完整功能)
### 用户协议
- **必须勾选**:未勾选时禁止登录并提示
- **协议条款**
- 《开场的条款和条件》
- 《开场与微信号绑定协议》
- 《隐私权政策》
- **交互方式**:点击查看具体协议内容
## 🛠 技术实现
### 状态管理
- `is_loading`: 控制登录按钮加载状态
- `agree_terms`: 用户协议同意状态
### 核心方法
- `handle_wechat_login()`: 微信登录流程
- `handle_phone_login()`: 手机号登录(待实现)
- `handle_agree_terms()`: 协议同意状态切换
- `handle_view_terms()`: 查看协议详情
### 样式特色
- **毛玻璃效果**`backdrop-filter: blur(32px)`
- **渐变背景**`linear-gradient(180deg, rgba(0,0,0,0) 48%, rgba(0,0,0,0.96) 86%, rgba(0,0,0,1) 100%)`
- **微信绿色**`#07C160` 和渐变色
- **动画效果**`fadeInUp` 入场动画
## 📂 文件结构
```
src/pages/login/
├── index.tsx # 登录页面组件
├── index.scss # Figma 设计稿样式
├── index.config.ts # 页面配置
├── verification/ # 验证码页面
├── terms/ # 协议详情页面
├── README.md # 说明文档
├── login_flow.md # 登录流程串接说明
├── login_test.md # 测试配置文档
└── api .md # API 接口文档
```
## 🔧 配置说明
### 页面配置
```typescript
export default definePageConfig({
navigationBarTitleText: '登录',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})
```
### 应用配置
登录页面已设置为应用入口页面,在 `app.config.ts` 中位于页面列表首位。
## 🎯 设计还原度
### 完全还原的元素
- ✅ 背景图片和渐变效果
- ✅ "有场" Logo 和品牌标语
- ✅ 微信登录按钮样式和图标
- ✅ 手机号登录按钮样式
- ✅ 用户协议复选框和文本
- ✅ 状态栏布局和样式
- ✅ 底部指示器
### 响应式适配
- 支持不同屏幕尺寸
- 保持设计稿比例和布局
- 动画效果和交互反馈
## 🔄 后续扩展
### 可扩展功能
1. **手机号登录完整流程**
- 手机号输入页面
- 验证码发送和验证
- 登录状态保存
2. **第三方登录**
- QQ 登录
- 支付宝登录
- Apple ID 登录
3. **用户协议页面**
- 协议详情展示页面
- 协议更新通知机制
### 多协议支持
- **三个协议链接**:每个协议都可以独立点击
- 《开场的条款和条件》
- 《开场与微信号绑定协议》
- 《隐私权政策》
- **动态内容显示**:根据协议类型显示对应内容
- **页面标题适配**:自动根据协议类型设置页面标题
- **URL 参数传递**:支持通过 URL 参数指定协议类型
### 性能优化
- 图片资源压缩和 lazy loading
- 动画性能优化
- 登录状态缓存策略
## 📱 测试说明
### 微信登录测试
1. 必须在微信小程序环境中测试
2. 需要配置正确的小程序 AppID
3. 用户首次使用需要授权头像和昵称
### 开发环境测试
- 可以使用微信开发者工具模拟器
- 注意模拟器中的 `getUserProfile` 行为可能与真机不同
## 🎨 设计源文件
**Figma 设计稿链接**
https://www.figma.com/design/EWQlX5wM2lhiSLfFQp8qKT/小程序设计稿V1开发协作版?node-id=3043-3055
设计稿包含了完整的视觉规范、尺寸标注和交互说明,本实现严格按照设计稿要求进行开发。
---
## 🔗 登录流程串接说明
### 📋 流程概述
登录系统已完全串接,支持两种登录方式:
1. **微信快捷登录** - 主要登录方式,调用真实后端接口
2. **手机号验证码登录** - 备选登录方式,完整的验证码流程
### 🔄 完整流程
#### 微信快捷登录流程
```
用户点击微信登录 → 检查协议同意 → 获取微信code → 调用后端接口 → 保存登录状态 → 跳转首页
```
#### 手机号验证码登录流程
```
用户点击手机号登录 → 跳转验证码页面 → 输入手机号 → 发送验证码 → 输入验证码 → 验证登录 → 跳转首页
```
### 🌐 API 接口集成
已集成以下真实后端接口:
- **微信授权**: `POST /api/user/wx_auth`
- **发送短信**: `POST /api/user/sms/send`
- **验证验证码**: `POST /api/user/sms/verify`
### 📱 页面跳转关系
```
登录主页 (/pages/login/index/index)
验证码页面 (/pages/login/verification/index)
协议详情页面 (/pages/login/terms/index)
应用首页 (/pages/index/index)
```
### 🧪 测试配置
- **测试环境**: `https://sit.light120.com/api`
- **测试账号**: `13800138000` / `123456`
- **完整测试用例**: 参考 `login_test.md`
### 📚 相关文档
- **流程说明**: `login_flow.md` - 详细的流程和接口说明
- **测试配置**: `login_test.md` - 完整的测试用例和配置
- **API 文档**: `api .md` - 后端接口规范
### ✅ 完成状态
- ✅ 微信快捷登录流程
- ✅ 手机号验证码登录流程
- ✅ 用户协议流程
- ✅ 真实后端接口集成
- ✅ 错误处理和用户提示
- ✅ 登录状态管理
- ✅ 页面跳转逻辑
登录系统已完全串接完成,可以直接进行功能测试和上线使用。
---
## 🚀 HTTP 服务集成
### 🔧 技术架构
登录服务现在使用 `src/services/httpService.ts` 中封装好的请求方法,实现了更完善的 HTTP 请求管理:
#### 核心特性
- **统一请求管理**: 使用 `httpService.post()` 方法进行所有 API 调用
- **自动错误处理**: 自动处理网络错误、HTTP 状态码错误和业务逻辑错误
- **智能 Token 管理**: 自动添加认证 Token支持过期自动刷新
- **环境配置支持**: 自动使用环境配置中的 API 地址和超时设置
- **加载状态管理**: 支持自动显示/隐藏加载提示
- **模拟模式支持**: 开发环境下支持模拟数据返回
#### 使用方式
```typescript
// 微信授权登录
const auth_response = await httpService.post('/user/wx_auth', {
code: login_result.code
});
// 发送短信验证码
const response = await httpService.post('/user/sms/send', {
phone: phone
});
// 验证短信验证码
const verify_response = await httpService.post('/user/sms/verify', {
phone: phone,
code: code
});
```
### 🌍 环境配置
HTTP 服务通过 `src/config/env.ts` 进行环境配置:
- **开发环境**: 自动使用开发环境配置
- **测试环境**: 支持测试环境 API 地址
- **生产环境**: 支持生产环境 API 地址
- **模拟模式**: 开发环境下可启用模拟数据
### 🛡️ 安全特性
- **请求头安全**: 自动设置安全的请求头
- **Token 验证**: 自动验证和刷新认证 Token
- **错误信息保护**: 防止敏感错误信息泄露
- **请求频率控制**: 支持请求频率限制
### 📊 监控和日志
- **请求日志**: 详细的请求和响应日志记录
- **性能监控**: 请求响应时间监控
- **错误追踪**: 完整的错误堆栈和上下文信息
- **环境信息**: 当前环境配置信息输出
### 🔄 向后兼容
- **接口响应格式**: 保持原有的响应格式兼容性
- **错误处理**: 保持原有的错误处理逻辑
- **状态管理**: 保持原有的登录状态管理逻辑
登录系统现在使用更加健壮和可维护的 HTTP 服务架构,提供了更好的开发体验和运行稳定性。

95
src/pages/login/api .md Normal file
View File

@@ -0,0 +1,95 @@
// 授权接口
fetch("https://sit.light120.com/api/user/wx_auth", {
"headers": {
"accept": "application/json",
"accept-language": "zh-CN,zh;q=0.9",
"content-type": "application/json",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
"referrer": "https://sit.light120.com/api/docs",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "{\n \"code\": \"string\"\n}",
"method": "POST",
"mode": "cors",
"credentials": "omit"
});
// 用户手机号一键登录登陆
fetch("https://sit.light120.com/api/user/phone_verify", {
"headers": {
"accept": "application/json",
"accept-language": "zh-CN,zh;q=0.9",
"content-type": "application/json",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
"referrer": "https://sit.light120.com/api/docs",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "{\n \"code\": \"string\"\n}",
"method": "POST",
"mode": "cors",
"credentials": "omit"
});
// 发送短信
fetch("https://sit.light120.com/api/user/sms/send", {
"headers": {
"accept": "application/json",
"accept-language": "zh-CN,zh;q=0.9",
"content-type": "application/json",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
"referrer": "https://sit.light120.com/api/docs",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "{\n \"phone\": \"13122585075\"\n}",
"method": "POST",
"mode": "cors",
"credentials": "omit"
});
// 验证验证码接口
fetch("https://sit.light120.com/api/user/sms/verify", {
"headers": {
"accept": "application/json",
"accept-language": "zh-CN,zh;q=0.9",
"content-type": "application/json",
"priority": "u=1, i",
"sec-ch-ua": "\"Chromium\";v=\"134\", \"Not:A-Brand\";v=\"24\", \"Google Chrome\";v=\"134\"",
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": "\"Windows\"",
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-origin"
},
"referrer": "https://sit.light120.com/api/docs",
"referrerPolicy": "strict-origin-when-cross-origin",
"body": "{\n \"phone\": \"13800138000\",\n \"code\": \"123456\"\n}",
"method": "POST",
"mode": "cors",
"credentials": "omit"
});

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '登录',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})

View File

@@ -0,0 +1,499 @@
// 登录页面根据Figma设计稿重新设计
.login_page {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
}
// 背景图片和渐变覆盖层
.background_image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
.bg_img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.bg_overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(180deg,
rgba(0, 0, 0, 0) 48%,
rgba(0, 0, 0, 0.96) 86%,
rgba(0, 0, 0, 1) 100%);
}
}
// 状态栏样式
.status_bar {
position: absolute;
top: 21px;
left: 0;
right: 0;
height: 33px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
z-index: 10;
.time {
color: #FFFFFF;
font-family: 'SF Pro';
font-weight: 590;
font-size: 17px;
line-height: 22px;
}
.status_icons {
display: flex;
align-items: center;
gap: 7px;
.signal_icon,
.wifi_icon,
.battery_icon {
width: 20px;
height: 12px;
background: #FFFFFF;
border-radius: 2px;
opacity: 0.8;
}
.signal_icon {
width: 19px;
height: 12px;
}
.wifi_icon {
width: 17px;
height: 12px;
}
.battery_icon {
width: 27px;
height: 13px;
border: 1px solid rgba(255, 255, 255, 0.35);
background: #FFFFFF;
border-radius: 4px;
position: relative;
&::after {
content: '';
position: absolute;
right: -3px;
top: 4px;
width: 1px;
height: 4px;
background: rgba(255, 255, 255, 0.4);
border-radius: 0 1px 1px 0;
}
}
}
}
// 主要内容区域
.main_content {
position: relative;
z-index: 5;
flex: 1;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 0 20px;
}
// 品牌区域
.brand_section {
.logo_container {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 44px;
height: 58px;
width: 252px;
background: url('../../../static/login/yc.svg') no-repeat left top;
background-size: contain;
}
.slogan_container {
margin-bottom: 51px;
background: url('../../../static/login/bro.svg') no-repeat left top;
background-size: contain;
width: 100%;
height: 114px;
}
}
// 登录区域
.login_section {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 64px;
.login_button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
height: 52px;
border-radius: 16px;
border: none;
position: relative;
font-size: 16px;
font-weight: 600;
font-family: 'PingFang SC';
transition: all 0.3s ease;
&::after {
border: none;
}
.button_text {
font-size: 16px;
font-weight: 600;
line-height: 22px;
}
// 微信登录按钮
&.wechat_button {
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0px 8px 64px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(32px);
margin-bottom: 0px;
.button_text {
color: #000000;
}
.wechat_icon {
position: absolute;
left: 16px;
width: 28px;
height: 28px;
.wechat_logo {
width: 100%;
height: 100%;
}
}
&.loading {
opacity: 0.7;
}
}
// 手机号登录按钮
&.phone_button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0px 8px 64px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(32px);
margin-top: 0px;
.button_text {
color: #FFFFFF;
}
.phone_icon {
position: absolute;
left: 16px;
width: 24px;
height: 24px;
.phone_logo {
width: 100%;
height: 100%;
border-radius: 4px;
position: relative;
&::before {
content: '';
position: absolute;
top: 3px;
left: 50%;
transform: translateX(-50%);
width: 2px;
height: 0;
border-top: 2px solid #FFFFFF;
}
&::after {
content: '';
position: absolute;
bottom: 1px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 0;
border-bottom: 2px solid #FFFFFF;
}
}
}
}
}
// 用户协议区域
.terms_section {
display: flex;
gap: 6px;
align-items: flex-start;
margin-top: 8px;
.checkbox_container {
flex-shrink: 0;
padding-top: 2px;
.checkbox {
width: 20px;
height: 20px;
border: 1px solid rgba(0, 0, 0, 0.2);
border-radius: 1000px;
background: #FFFFFF;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
&.checked {
background: #07C160;
border-color: #07C160;
.checkmark {
width: 6px;
height: 6px;
background: #FFFFFF;
border-radius: 50%;
}
}
}
}
.terms_text_container {
flex: 1;
line-height: 20px;
.terms_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 12px;
color: #FFFFFF;
line-height: 20px;
}
.terms_link {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 12px;
color: #FFFFFF;
line-height: 20px;
text-decoration: underline;
margin-right: 4px;
}
}
}
}
// 底部指示器
.home_indicator {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 5px;
background: #FFFFFF;
border-radius: 2.5px;
z-index: 10;
}
// 底部条款浮层
.terms_float_layer {
position: relative;
bottom: 0;
left: 0;
right: 0;
background: #FFFFFF;
border-radius: 24px 24px 0 0;
padding: 24px 24px 34px;
z-index: 101;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
// 浮层标题
.float_title {
text-align: center;
margin-bottom: 20px;
.title_text {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 18px;
color: #000000;
line-height: 1.4;
}
}
// 条款列表
.terms_list {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-bottom: 24px;
.terms_item {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 16px;
color: #07C160;
text-decoration: underline;
cursor: pointer;
transition: all 0.3s ease;
&:active {
opacity: 0.7;
}
}
}
// 同意按钮
.agree_button {
width: 100%;
height: 52px;
background: #000000;
border: none;
border-radius: 16px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
font-family: 'PingFang SC';
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
&::after {
border: none;
}
&.agreed {
background: #07C160;
}
&:active {
opacity: 0.8;
}
}
// 底部指示器
.home_indicator {
position: relative;
bottom: auto;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 5px;
background: #000000;
border-radius: 2.5px;
z-index: 10;
}
}
// 浮层遮罩
.terms_overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
display: flex;
align-items: flex-end;
}
// 用户协议复选框区域
.terms_checkbox_section {
display: flex;
gap: 6px;
align-items: flex-start;
margin-top: 16px;
.checkbox_container {
flex-shrink: 0;
padding-top: 2px;
.checkbox {
width: 20px;
height: 20px;
border: 1px solid rgba(255, 255, 255, 0.6);
border-radius: 1000px;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
cursor: pointer;
&.checked {
background: #07C160;
border-color: #07C160;
.checkmark {
width: 6px;
height: 6px;
background: #FFFFFF;
border-radius: 50%;
}
}
}
}
.terms_text_container {
flex: 1;
line-height: 20px;
.terms_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
line-height: 20px;
}
.terms_link {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 12px;
color: #FFFFFF;
line-height: 20px;
text-decoration: underline;
margin-left: 4px;
cursor: pointer;
}
}
}

View File

@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { View, Text, Button, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { wechat_auth_login, save_login_state } from '../../../services/loginService';
import './index.scss';
const LoginPage: React.FC = () => {
const [is_loading, set_is_loading] = useState(false);
const [agree_terms, set_agree_terms] = useState(false);
const [show_terms_layer, set_show_terms_layer] = useState(false);
// 微信授权登录
const handle_wechat_login = async (e: any) => {
if (!agree_terms) {
set_show_terms_layer(true);
Taro.showToast({
title: '请先同意用户协议',
icon: 'none',
duration: 2000
});
return;
}
// 检查是否获取到手机号
if (!e.detail || !e.detail.code) {
Taro.showToast({
title: '获取手机号失败,请重试',
icon: 'none',
duration: 2000
});
return;
}
set_is_loading(true);
try {
// 传递手机号code给登录服务
const response = await wechat_auth_login(e.detail.code);
if (response.success) {
save_login_state(response.token!, response.user_info!);
setTimeout(() => {
Taro.redirectTo({ url: '/pages/index/index' });
}, 200);
} else {
Taro.showToast({
title: response.message,
icon: 'none',
duration: 2000
});
}
} catch (error) {
Taro.showToast({
title: '登录失败,请重试',
icon: 'none',
duration: 2000
});
} finally {
set_is_loading(false);
}
};
// 手机号验证码登录
const handle_phone_login = async () => {
if (!agree_terms) {
set_show_terms_layer(true);
Taro.showToast({
title: '请先同意用户协议',
icon: 'none',
duration: 2000
});
return;
}
// 跳转到验证码页面
Taro.navigateTo({
url: '/pages/login/verification/index'
});
};
// 同意协议并关闭浮层
const handle_agree_terms = () => {
set_agree_terms(true);
set_show_terms_layer(false);
};
// 切换协议同意状态(复选框用)
const handle_toggle_terms = () => {
set_agree_terms(!agree_terms);
};
// 查看协议
const handle_view_terms = (type: string = 'terms') => {
Taro.navigateTo({
url: `/pages/login/terms/index?type=${type}`
});
};
// 点击浮层外部关闭浮层
const handle_overlay_click = () => {
set_show_terms_layer(false);
};
// 点击浮层内部阻止事件冒泡
const handle_layer_click = (e: any) => {
e.stopPropagation();
};
return (
<View className="login_page">
<View className="background_image">
<Image
className="bg_img"
src={require('../../../static/login/login_bg.png')}
mode="aspectFill"
/>
<View className="bg_overlay"></View>
</View>
{/* 主要内容 */}
<View className="main_content">
{/* 品牌区域 */}
<View className="brand_section">
<View className="logo_container" >
</View>
<View className="slogan_container">
</View>
</View>
{/* 登录按钮区域 */}
<View className="login_section">
{/* 微信快捷登录 */}
<Button
className={`login_button wechat_button ${is_loading ? 'loading' : ''}`}
openType="getPhoneNumber"
onGetPhoneNumber={handle_wechat_login}
disabled={is_loading}
>
<View className="wechat_icon">
<Image className="wechat_logo" src={require('../../../static/login/wechat_icon.svg')} />
</View>
<Text className="button_text">
{is_loading ? '登录中...' : '微信快捷登录'}
</Text>
</Button>
{/* 手机号验证码登录 */}
<Button
className="login_button phone_button"
onClick={handle_phone_login}
>
<View className="phone_icon">
<Image className="phone_logo" src={require('../../../static/login/phone_icon.svg')} />
</View>
<Text className="button_text"></Text>
</Button>
{/* 用户协议复选框 */}
<View className="terms_checkbox_section">
<View className="checkbox_container" onClick={handle_toggle_terms}>
<View className={`checkbox ${agree_terms ? 'checked' : ''}`}>
{agree_terms && <View className="checkmark"></View>}
</View>
</View>
<View className="terms_text_container">
<Text className="terms_text"></Text>
<Text
className="terms_link"
onClick={() => handle_view_terms('terms')}
>
</Text>
<Text
className="terms_link"
onClick={() => handle_view_terms('binding')}
>
</Text>
<Text
className="terms_link"
onClick={() => handle_view_terms('privacy')}
>
</Text>
</View>
</View>
</View>
</View>
{/* 底部条款浮层遮罩 */}
{show_terms_layer && (
<View className="terms_overlay" onClick={handle_overlay_click}>
{/* 底部条款浮层 */}
<View className="terms_float_layer" onClick={handle_layer_click}>
{/* 浮层标题 */}
<View className="float_title">
<Text className="title_text"></Text>
</View>
{/* 条款列表 */}
<View className="terms_list">
<Text
className="terms_item"
onClick={() => handle_view_terms('terms')}
>
</Text>
<Text
className="terms_item"
onClick={() => handle_view_terms('binding')}
>
</Text>
<Text
className="terms_item"
onClick={() => handle_view_terms('privacy')}
>
</Text>
</View>
{/* 同意按钮 */}
<Button
className={`agree_button ${agree_terms ? 'agreed' : ''}`}
onClick={handle_agree_terms}
>
{agree_terms ? '已同意' : '同意并继续'}
</Button>
</View>
</View>
)}
</View>
);
};
export default LoginPage;

View File

@@ -0,0 +1,179 @@
# 登录页面流程串接说明
## 整体流程概述
登录系统支持两种登录方式:
1. **微信快捷登录** - 主要登录方式
2. **手机号验证码登录** - 备选登录方式
## 流程详细说明
### 1. 微信快捷登录流程
```
用户点击微信登录按钮
检查用户协议是否同意
↓ (未同意则显示协议浮层)
调用 Taro.login() 获取微信 code
调用 Taro.getUserProfile() 获取用户信息
使用 httpService.post() 调用后端接口 /api/user/wx_auth
保存登录状态到本地存储
跳转到首页 /pages/index/index
```
**接口调用**
- **方法**: `httpService.post('/user/wx_auth', { code: '微信登录返回的code' })`
- **URL**: `POST https://sit.light120.com/api/user/wx_auth`
- **参数**: `{ "code": "微信登录返回的code" }`
- **响应**: 包含 token 和用户信息的登录成功响应
### 2. 手机号验证码登录流程
```
用户点击手机号登录按钮
跳转到验证码页面 /pages/login/verification/index
用户输入手机号
点击发送验证码按钮
使用 httpService.post() 调用后端接口 /api/user/sms/send
用户输入收到的验证码
点击登录按钮
使用 httpService.post() 调用后端接口 /api/user/sms/verify
验证成功后保存登录状态
跳转到首页 /pages/index/index
```
**接口调用**
#### 发送短信验证码
- **方法**: `httpService.post('/user/sms/send', { phone: '手机号码' })`
- **URL**: `POST https://sit.light120.com/api/user/sms/send`
- **参数**: `{ "phone": "手机号码" }`
- **响应**: 发送成功或失败的响应
#### 验证短信验证码
- **方法**: `httpService.post('/user/sms/verify', { phone: '手机号码', code: '验证码' })`
- **URL**: `POST https://sit.light120.com/api/user/sms/verify`
- **参数**: `{ "phone": "手机号码", "code": "验证码" }`
- **响应**: 验证成功或失败的响应,成功时包含 token 和用户信息
### 3. 用户协议流程
```
用户首次进入登录页面
显示协议浮层,要求用户同意
用户点击协议链接查看详情
跳转到协议页面 /pages/login/terms/index
用户返回登录页面
勾选同意协议复选框
协议浮层消失,可以正常登录
```
**协议类型**
- `terms` - 《开场的条款和条件》
- `binding` - 《开场与微信号绑定协议》
- `privacy` - 《隐私权政策》
## 技术实现要点
### 1. HTTP 服务集成
登录服务现在使用 `httpService.ts` 中封装好的请求方法:
- **统一请求**: 使用 `httpService.post()` 方法
- **自动处理**: 自动处理请求头、错误处理、加载状态等
- **环境配置**: 自动使用环境配置中的 API 地址
- **Token 管理**: 自动处理认证 Token 的添加和刷新
### 2. 状态管理
- `is_loading`: 控制登录按钮加载状态
- `agree_terms`: 用户协议同意状态
- `show_terms_layer`: 协议浮层显示状态
### 3. 错误处理
- 网络请求失败时的友好提示
- 验证码错误时的重试机制
- 微信授权失败时的降级处理
- 使用 httpService 的统一错误处理
### 4. 本地存储
- `user_token`: 用户登录令牌
- `user_info`: 用户基本信息
- `is_logged_in`: 登录状态标识
- `login_time`: 登录时间戳
### 5. 安全考虑
- 验证码倒计时防止重复发送
- 登录状态过期检查7天
- 敏感信息不存储在本地
- 使用 httpService 的 Token 验证机制
## 页面跳转关系
```
/pages/login/index/index (登录主页)
/pages/login/verification/index (验证码页面)
/pages/login/terms/index (协议详情页面)
/pages/index/index (应用首页)
```
## 接口响应格式
### 成功响应
```json
{
"success": true,
"message": "操作成功",
"data": {
"token": "用户登录令牌",
"user_info": {
"user_id": "用户ID",
"username": "用户名",
"avatar": "头像URL",
"gender": 0,
"city": "城市",
"province": "省份",
"country": "国家"
}
}
}
```
### 失败响应
```json
{
"success": false,
"message": "错误信息描述"
}
```
## 开发注意事项
1. **环境配置**: 确保 envConfig.apiBaseURL 指向正确的环境
2. **错误处理**: httpService 自动处理大部分错误情况
3. **用户体验**: 加载状态、倒计时、提示信息等交互细节
4. **兼容性**: 支持不同版本的微信小程序
5. **测试**: 在真机和模拟器中测试各种场景
6. **Token 管理**: 使用 httpService 的自动 Token 管理功能

View File

@@ -0,0 +1,333 @@
# 登录流程测试配置
## 测试环境配置
### API 接口地址
- **测试环境**: `https://sit.light120.com/api`
- **生产环境**: `https://light120.com/api` (待配置)
### HTTP 服务配置
- **基础 URL**: 通过 `envConfig.apiBaseURL` 配置
- **超时时间**: 通过 `envConfig.timeout` 配置
- **日志开关**: 通过 `envConfig.enableLog` 配置
- **模拟模式**: 通过 `envConfig.enableMock` 配置
### 测试账号信息
- **测试手机号**: `13800138000`
- **测试验证码**: `123456` (仅用于开发测试)
## 测试场景
### 1. 微信快捷登录测试
#### 正常流程测试
1. 进入登录页面
2. 勾选同意用户协议
3. 点击"微信快捷登录"按钮
4. 验证微信授权弹窗
5. 确认授权后检查登录成功
6. 验证跳转到首页
#### 异常情况测试
1. **未同意协议**: 点击登录按钮应显示协议浮层
2. **微信授权失败**: 模拟网络错误,检查错误提示
3. **登录接口异常**: 模拟后端接口返回错误
4. **HTTP 服务异常**: 测试 httpService 的错误处理
### 2. 手机号验证码登录测试
#### 正常流程测试
1. 进入登录页面
2. 点击"手机号验证码登录"按钮
3. 跳转到验证码页面
4. 输入手机号 `13800138000`
5. 点击"获取验证码"按钮
6. 输入验证码 `123456`
7. 点击"登录"按钮
8. 验证登录成功并跳转
#### 异常情况测试
1. **手机号格式错误**: 输入非11位数字
2. **验证码格式错误**: 输入非6位数字
3. **发送验证码失败**: 模拟网络错误
4. **验证码错误**: 输入错误验证码
5. **登录接口异常**: 模拟后端接口返回错误
6. **HTTP 服务异常**: 测试 httpService 的错误处理
### 3. 用户协议流程测试
#### 协议查看测试
1. 点击任意协议链接
2. 验证跳转到对应协议页面
3. 检查协议内容显示
4. 返回登录页面
#### 协议同意测试
1. 未勾选协议时尝试登录
2. 验证协议浮层显示
3. 勾选协议复选框
4. 验证浮层消失
5. 再次尝试登录
### 4. HTTP 服务集成测试
#### 请求方法测试
1. **POST 请求**: 验证 `httpService.post()` 方法
2. **错误处理**: 验证 httpService 的统一错误处理
3. **Token 管理**: 验证自动 Token 添加和刷新
4. **加载状态**: 验证自动加载提示显示
#### 环境配置测试
1. **开发环境**: 验证开发环境配置
2. **测试环境**: 验证测试环境配置
3. **模拟模式**: 验证模拟数据返回
## 接口测试用例
### 1. 微信授权接口测试
```bash
# 测试接口: POST /api/user/wx_auth
curl -X POST https://sit.light120.com/api/user/wx_auth \
-H "Content-Type: application/json" \
-d '{"code": "test_wx_code"}'
```
**预期响应**:
```json
{
"success": true,
"message": "微信登录成功",
"data": {
"token": "wx_token_1234567890",
"user_info": {
"user_id": "wx_user_123",
"username": "测试用户",
"avatar": "https://example.com/avatar.jpg",
"gender": 1,
"city": "深圳",
"province": "广东",
"country": "中国"
}
}
}
```
### 2. 发送短信接口测试
```bash
# 测试接口: POST /api/user/sms/send
curl -X POST https://sit.light120.com/api/user/sms/send \
-H "Content-Type: application/json" \
-d '{"phone": "13800138000"}'
```
**预期响应**:
```json
{
"success": true,
"message": "验证码发送成功"
}
```
### 3. 验证验证码接口测试
```bash
# 测试接口: POST /api/user/sms/verify
curl -X POST https://sit.light120.com/api/user/sms/verify \
-H "Content-Type: application/json" \
-d '{"phone": "13800138000", "code": "123456"}'
```
**预期响应**:
```json
{
"success": true,
"message": "验证成功",
"data": {
"token": "phone_token_1234567890",
"user_info": {
"user_id": "phone_user_123",
"username": "用户8000",
"avatar": "",
"gender": 0,
"city": "",
"province": "",
"country": ""
}
}
}
```
## 错误响应测试
### 1. 验证码错误
```bash
curl -X POST https://sit.light120.com/api/user/sms/verify \
-H "Content-Type: application/json" \
-d '{"phone": "13800138000", "code": "000000"}'
```
**预期响应**:
```json
{
"success": false,
"message": "验证码错误"
}
```
### 2. 手机号格式错误
```bash
curl -X POST https://sit.light120.com/api/user/sms/send \
-H "Content-Type: application/json" \
-d '{"phone": "138001380"}'
```
**预期响应**:
```json
{
"success": false,
"message": "手机号格式错误"
}
```
## HTTP 服务测试
### 1. 请求头测试
- 验证 `Content-Type` 自动设置
- 验证认证 Token 自动添加
- 验证自定义请求头支持
### 2. 错误处理测试
- 网络连接失败处理
- HTTP 状态码错误处理
- 业务逻辑错误处理
- Token 过期自动处理
### 3. 加载状态测试
- 请求开始时显示加载提示
- 请求结束时隐藏加载提示
- 自定义加载文本支持
## 性能测试
### 1. 接口响应时间
- **微信授权**: 期望 < 2秒
- **发送短信**: 期望 < 1秒
- **验证验证码**: 期望 < 1秒
### 2. 并发测试
- 同时发送多个验证码请求
- 同时进行多个登录操作
- 验证系统稳定性
### 3. HTTP 服务性能
- 请求队列处理
- 超时处理机制
- 错误重试机制
## 兼容性测试
### 1. 微信版本兼容
- 微信 7.0.0+ 版本
- 微信 8.0.0+ 版本
- 最新版本微信
### 2. 设备兼容
- iPhone 各型号
- Android 各品牌
- 不同屏幕尺寸
### 3. 网络环境兼容
- WiFi 环境
- 4G/5G 环境
- 弱网环境
## 安全测试
### 1. 输入验证
- SQL 注入防护
- XSS 攻击防护
- 手机号格式验证
### 2. 接口安全
- 请求频率限制
- 验证码有效期
- Token 安全性
### 3. HTTP 服务安全
- 请求头安全
- 参数验证
- 错误信息泄露防护
## 测试工具
### 1. 接口测试
- Postman
- curl 命令行
- 微信开发者工具
### 2. 性能测试
- 浏览器开发者工具
- 微信开发者工具性能面板
### 3. 兼容性测试
- 真机测试
- 模拟器测试
- 不同微信版本测试
### 4. HTTP 服务测试
- 网络调试工具
- 环境配置测试
- 模拟模式测试
## 测试报告模板
### 测试结果记录
```
测试日期: _____________
测试人员: _____________
测试环境: _____________
测试项目:
□ 微信快捷登录
□ 手机号验证码登录
□ 用户协议流程
□ 接口功能测试
□ HTTP 服务集成测试
□ 性能测试
□ 兼容性测试
□ 安全测试
测试结果:
□ 全部通过
□ 部分通过
□ 未通过
问题记录:
1. ________________
2. ________________
3. ________________
修复建议:
________________
________________
```
### HTTP 服务测试结果
```
HTTP 服务测试:
□ 基础请求功能
□ 错误处理机制
□ Token 管理功能
□ 加载状态管理
□ 环境配置支持
□ 模拟模式支持
配置验证:
□ 开发环境配置
□ 测试环境配置
□ 生产环境配置
□ 超时配置
□ 日志配置
```

View File

@@ -0,0 +1,193 @@
# 条款页面 - 开场的条款和条件
## 功能概述
条款页面展示完整的《开场的条款和条件》内容,用户需要仔细阅读并同意后才能继续使用平台服务。
## 🎨 设计特点
### 视觉设计
- **背景图片**:使用与登录页面相同的运动主题背景图片
- **状态栏**:完全还原 iPhone 状态栏样式
- **导航栏**:白色圆角导航栏,包含返回按钮和页面标题
- **内容布局**:清晰的标题、简介和详细内容层次
### 交互设计
- **内容滚动**:支持长内容滚动浏览
- **返回导航**:点击返回按钮回到上一页
- **同意按钮**:底部确认按钮,表示已阅读并同意
## 📱 页面结构
```
TermsPage
├── 背景图片层
│ ├── 运动背景图片
│ └── 渐变遮罩层
├── 状态栏
│ ├── 时间显示 (9:41)
│ └── 状态图标 (信号/WiFi/电池)
├── 导航栏
│ ├── 返回按钮
│ ├── 页面标题
│ └── 占位符
├── 主要内容
│ ├── 条款标题
│ ├── 条款简介
│ ├── 条款详细内容
│ └── 底部按钮
└── 底部指示器
```
## 🚀 功能特性
### 内容展示
- **条款标题**:醒目的标题显示
- **条款简介**:简洁的条款概述
- **详细内容**:完整的条款条款内容
- **格式保持**:保持原有的段落和编号格式
### 用户交互
- **内容滚动**:支持长内容滚动
- **返回功能**:返回上一页面
- **同意确认**:确认已阅读并同意条款
## 📋 条款内容
本页面包含完整的《开场的条款和条件》,涵盖以下十个主要部分:
### 1. 服务内容
- 活动发布、报名、聊天室沟通、活动提醒等服务
- 不提供教练或场地销售服务,仅作为信息发布和社交媒介
### 2. 用户注册与权限
- 真实信息要求,不得冒用他人身份
- 违规信息处理权利
### 3. 活动发布与报名
- 信息真实性要求
- 沟通和变更建议
### 4. 权责声明
- 免责条款说明
- 保险建议
- 第三方交易责任
### 5. 费用与退款
- 服务费说明
- 退款规则
### 6. 用户行为规范
- 准时参加承诺
- 禁止违规内容
### 7. 隐私与信息保护
- 信息收集用途
- 个人信息管理
### 8. 协议修改与终止
- 修改通知机制
- 违规处理措施
### 9. 争议解决
- 法律适用
- 争议解决方式
### 10. 其他条款
- 协议完整性
- 条款有效性
## 🛠 技术实现
### 组件结构
- **状态栏组件**:显示时间和系统状态
- **导航栏组件**:返回按钮和页面标题
- **内容组件**:条款内容的展示
- **按钮组件**:同意确认按钮
### 样式特色
- **毛玻璃效果**`backdrop-filter: blur(32px)`
- **渐变背景**:与登录页面保持一致
- **响应式设计**:适配不同屏幕尺寸
- **滚动优化**:流畅的内容滚动体验
## 📂 文件结构
```
src/pages/login/terms/
├── index.tsx # 条款页面组件
├── index.scss # 页面样式
├── index.config.ts # 页面配置
└── README.md # 说明文档
```
## 🔧 配置说明
### 页面配置
```typescript
export default definePageConfig({
navigationBarTitleText: '条款和条件',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})
```
## 🔄 页面跳转
### 进入方式
- 从登录页面点击协议链接进入
- 从验证码页面点击协议链接进入
### 退出方式
- 点击返回按钮返回上一页
- 点击"我已阅读并同意"按钮返回上一页
## 📱 测试说明
### 功能测试
1. **内容显示测试**
- 条款标题正确显示
- 条款内容完整展示
- 格式保持正确
2. **交互测试**
- 返回按钮功能正常
- 同意按钮功能正常
- 内容滚动流畅
3. **样式测试**
- 不同屏幕尺寸适配
- 背景图片显示正确
- 文字可读性良好
## 🎯 后续优化
### 功能扩展
1. **条款版本管理**
- 条款更新历史
- 版本对比功能
- 更新通知机制
2. **用户协议管理**
- 协议同意记录
- 协议更新提醒
- 个性化协议内容
3. **多语言支持**
- 英文版本
- 其他语言版本
- 语言切换功能
### 用户体验优化
1. **阅读体验**
- 字体大小调节
- 夜间模式
- 书签功能
2. **内容导航**
- 目录导航
- 快速跳转
- 搜索功能

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '条款和条件',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})

View File

@@ -0,0 +1,236 @@
// 条款页面样式
.terms_page {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
background: #FAFAFA;
box-sizing: border-box;
}
// 状态栏样式
.status_bar {
position: absolute;
top: 21px;
left: 0;
right: 0;
height: 33px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
z-index: 10;
.time {
color: #000000;
font-family: 'SF Pro';
font-weight: 590;
font-size: 17px;
line-height: 22px;
}
.status_icons {
display: flex;
align-items: center;
gap: 7px;
.signal_icon,
.wifi_icon,
.battery_icon {
width: 20px;
height: 12px;
background: #000000;
border-radius: 2px;
opacity: 0.8;
}
.signal_icon {
width: 19px;
height: 12px;
}
.wifi_icon {
width: 17px;
height: 12px;
}
.battery_icon {
width: 27px;
height: 13px;
border: 1px solid rgba(0, 0, 0, 0.35);
background: #000000;
border-radius: 4px;
position: relative;
&::after {
content: '';
position: absolute;
right: -3px;
top: 4px;
width: 1px;
height: 4px;
background: rgba(0, 0, 0, 0.4);
border-radius: 0 1px 1px 0;
}
}
}
}
// 导航栏样式
.navigation_bar {
position: absolute;
top: 54px;
left: 0;
right: 0;
height: 44px;
background: #FFFFFF;
border-radius: 44px 44px 0 0;
z-index: 10;
display: flex;
align-items: center;
padding: 0 10px;
.nav_content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.back_button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.back_icon {
width: 8px;
height: 16px;
background: #000000;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #000000;
transform: translateY(-50%) rotate(45deg);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #000000;
transform: translateY(-50%) rotate(-45deg);
}
}
}
.page_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 18px;
color: #000000;
text-align: center;
}
.nav_placeholder {
width: 32px;
}
}
}
// 主要内容区域
.main_content {
position: relative;
z-index: 5;
flex: 1;
padding: 0px 24px ;
box-sizing: border-box;
overflow-y: auto;
// 条款标题
.terms_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.6em;
text-align: center;
color: #000000;
margin-bottom: 24px;
}
// 条款简介
.terms_intro {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 14px;
line-height: 1.43em;
color: #000000;
margin-bottom: 24px;
}
// 条款详细内容
.terms_content {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.43em;
color: #000000;
margin-bottom: 40px;
white-space: pre-line;
}
// 底部按钮
.bottom_actions {
margin-bottom: 40px;
.agree_button {
width: 100%;
height: 52px;
background: #07C160;
border: none;
border-radius: 16px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
font-family: 'PingFang SC';
cursor: pointer;
transition: all 0.3s ease;
&::after {
border: none;
}
&:active {
opacity: 0.8;
}
}
}
}
// 底部指示器
.home_indicator {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 5px;
background: #000000;
border-radius: 2.5px;
z-index: 10;
}

View File

@@ -0,0 +1,204 @@
import React from 'react';
import { View, Text, ScrollView, Image, Button } from '@tarojs/components';
import Taro from '@tarojs/taro';
import './index.scss';
const TermsPage: React.FC = () => {
// 获取页面参数
const [termsType, setTermsType] = React.useState('terms');
const [pageTitle, setPageTitle] = React.useState('条款和条件');
const [termsTitle, setTermsTitle] = React.useState('《开场的条款和条件》');
const [termsContent, setTermsContent] = React.useState('');
// 返回上一页
const handle_go_back = () => {
Taro.navigateBack();
};
// 根据类型设置内容
React.useEffect(() => {
const params = Taro.getCurrentInstance().router?.params;
const type = params?.type || 'terms';
switch (type) {
case 'terms':
setPageTitle('条款和条件');
setTermsTitle('《开场的条款和条件》');
setTermsContent(`欢迎使用本平台(以下简称"本平台")发布与参与网球活动。为保障您的权益,请您务必仔细阅读并理解以下服务条款。
一、服务内容
1. 本平台为用户提供活动发布、报名、聊天室沟通、活动提醒等服务。
2. 本平台不提供教练或场地销售服务,仅作为信息发布和社交媒介。
二、用户注册与权限
1. 用户需填写真实信息并完成注册,不得冒用他人身份;否则,后果由用户自行承担。
2. 本平台保留对违规信息(如虚假活动、广告营销、涉黄涉暴内容等)进行屏蔽、删除或账号封禁的权利。
三、活动发布与报名
1. 用户发布活动时,应如实填写活动时间、地点、参与方式、费用说明及等级要求等信息,并确保委托内容合法、真实、明确。
2. 有关场地、球友配对、费用结算等事宜,建议用户在聊天中提前沟通清楚。报名后若情况有变,建议及时沟通变更。
四、权责声明
1. 本平台不对活动中可能发生的伤害、人身损害或财产损失承担责任,包括但不限于交通、天气、场地设施问题等。
2. 如用户在活动中受伤或遭受损害,须自行承担责任。本平台建议用户自行购买运动意外险。
3. 如果活动涉及第三方交易(如场地租赁),该第三方行为与本平台无关,由用户自行承担相关责任。
五、费用与退款
1. 平台可能收取一定服务费,费用标准在页面明确标注,用户报名前应仔细阅读。
2. 若活动发起人主动取消活动,或因不可抗力因素(如政府封闭场地、极端天气)活动取消,平台可协助协调退款,具体退款规则遵从平台承诺或双方协商结果。
六、用户行为规范
1. 用户承诺遵循活动约定,准时参加;如确有事宜无法参加,应提前通知。
2. 禁止发布违法违禁内容、骚扰信息,或以任何方式侵害他人合法权益。对违规则本平台将根据情节轻重采取警告、删除、封号等措施。
七、隐私与信息保护
1. 本平台收集的用户信息,仅用于提供服务内容与提醒功能,不会擅自泄露;法律规定或司法机关要求除外。
2. 用户可在个人设置界面查看、修改或删除个人信息。
八、协议的修改与终止
1. 本平台有权随时修改本条款,修改内容将提前 7 天公告并以系统通知方式提示。若用户继续使用相关功能,即表示接受修改内容。
2. 如用户严重违反协议内容或发布违法信息,本平台可立即终止服务,并不承担任何责任。
九、争议解决
1. 本协议适用中华人民共和国法律。因执行本协议或与本协议有关的争议,双方应友好协商解决;协商不成时,可向平台所在地有管辖权的法院提起诉讼。
2. 若用户为消费者,本平台将依法承担相应法定责任。
十、其他条款
1. 本协议构成平台与用户之间完整协议,任何口头陈述或其他文件都不构成本协议的一部分。
2. 若协议条款中任何条款被法院认定为无效,其余条款仍继续有效。`);
break;
case 'binding':
setPageTitle('微信号绑定协议');
setTermsTitle('《开场与微信号绑定协议》');
setTermsContent(`欢迎使用本平台(以下简称"本平台")的微信绑定服务。为保障您的权益,请您务必仔细阅读并理解以下协议内容。
一、绑定服务说明
1. 本平台提供微信账号绑定服务,用户可通过微信快捷登录方式使用平台功能。
2. 绑定微信账号后,用户可使用微信登录、微信支付、微信分享等功能。
3. 本平台承诺保护用户微信账号信息安全,不会泄露给第三方。
二、绑定流程与要求
1. 用户首次使用微信登录时,需授权本平台获取必要的微信账号信息。
2. 授权范围包括:微信昵称、头像、地区等基本信息,用于完善用户资料。
3. 用户可随时在微信设置中取消对本平台的授权。
三、账号安全与保护
1. 本平台采用行业标准的安全措施保护用户微信账号信息。
2. 如发现账号异常登录或安全风险,平台将及时通知用户并采取相应保护措施。
3. 用户应妥善保管自己的微信账号,不得将账号信息泄露给他人。
四、功能使用说明
1. 微信绑定后,用户可使用微信快捷登录,无需重复输入账号密码。
2. 支持微信支付功能,用户可通过微信钱包完成平台内的支付操作。
3. 支持微信分享功能,用户可将活动信息分享到微信朋友圈或聊天群。
五、解除绑定
1. 用户可在平台设置中解除微信账号绑定。
2. 解除绑定后,用户将无法使用微信快捷登录和微信支付功能。
3. 解除绑定不会影响用户已发布的活动和参与记录。
六、隐私保护
1. 本平台严格保护用户微信账号隐私信息,不会用于商业推广。
2. 仅在用户明确同意的情况下,才会向第三方提供相关信息。
3. 用户可随时查看和修改平台收集的微信账号信息。
七、免责声明
1. 因微信官方政策变更导致的绑定功能异常,本平台不承担责任。
2. 用户因个人原因导致的账号安全问题,本平台不承担责任。
3. 本平台建议用户定期更换微信密码,提高账号安全性。
八、协议修改
1. 本协议可能根据微信官方政策或平台功能调整进行修改。
2. 修改后的协议将在平台内公告,用户继续使用即表示同意。
3. 如用户不同意修改内容,可解除微信绑定并停止使用相关功能。`);
break;
case 'privacy':
setPageTitle('隐私权政策');
setTermsTitle('《隐私权政策》');
setTermsContent(`本平台(以下简称"我们")非常重视用户的隐私保护,本隐私权政策说明了我们如何收集、使用、存储和保护您的个人信息。
一、信息收集
1. 注册信息:包括手机号码、微信账号、昵称、头像等基本信息。
2. 活动信息:包括发布的活动内容、参与的活动记录、聊天记录等。
3. 设备信息包括设备型号、操作系统、IP地址、地理位置等。
4. 使用记录:包括登录时间、功能使用频率、页面浏览记录等。
二、信息使用
1. 提供服务:使用收集的信息为您提供活动发布、报名、沟通等服务。
2. 安全保障:用于身份验证、安全防护、风险控制等安全目的。
3. 服务优化:分析用户行为,改进产品功能,提升用户体验。
4. 法律要求:在法律法规要求的情况下,向相关部门提供信息。
三、信息存储
1. 存储位置:您的个人信息将存储在中国境内的服务器上。
2. 存储期限:在您使用服务期间,我们将持续保存您的信息。
3. 安全措施:采用加密存储、访问控制等安全技术保护您的信息。
四、信息共享
1. 我们不会向第三方出售、出租或交易您的个人信息。
2. 仅在以下情况下,我们可能会共享您的信息:
- 获得您的明确同意
- 法律法规要求
- 保护用户和公众的安全
- 与授权合作伙伴共享必要信息
五、用户权利
1. 访问权:您可以查看我们收集的您的个人信息。
2. 更正权:您可以要求更正不准确或不完整的个人信息。
3. 删除权:您可以要求删除您的个人信息,但某些信息可能因法律要求而保留。
4. 撤回同意:您可以随时撤回之前给予的同意。
六、儿童隐私
1. 我们的服务不面向13岁以下的儿童。
2. 如果我们发现收集了儿童的个人信息,将立即删除。
3. 如果您认为我们可能收集了儿童信息,请立即联系我们。
七、信息安全
1. 我们采用行业标准的安全措施保护您的个人信息。
2. 包括但不限于:数据加密、访问控制、安全审计等。
3. 如发生安全事件,我们将及时通知您并采取相应措施。
八、政策更新
1. 我们可能会更新本隐私权政策。
2. 更新后的政策将在平台内公告。
3. 继续使用服务即表示您同意更新后的政策。
九、联系我们
如果您对本隐私权政策有任何疑问或建议,请通过以下方式联系我们:
- 邮箱privacy@youchang.com
- 客服电话400-123-4567
- 在线客服:平台内客服功能`);
break;
default:
setPageTitle('条款和条件');
setTermsTitle('《开场的条款和条件》');
setTermsContent('条款内容加载中...');
}
}, []);
return (
<View className="terms_page">
{/* 主要内容 */}
<ScrollView className="main_content" scrollY>
{/* 条款标题 */}
<View className="terms_title">
{termsTitle}
</View>
{/* 条款详细内容 */}
<View className="terms_content">
{termsContent}
</View>
</ScrollView>
</View>
);
};
export default TermsPage;

View File

@@ -0,0 +1,187 @@
# 手机号验证码登录页面 - 基于 Figma 设计稿
## 设计概述
本页面完全按照 Figma 设计稿 `EWQlX5wM2lhiSLfFQp8qKT` 中的 "iPhone 13 & 14 - 57" 图层实现,是一个专业的手机号注册/登录页面。
## 🎨 设计特点
### 视觉设计
- **背景色**:使用 `#FAFAFA` 浅灰色背景,简洁现代
- **品牌元素**"有场" 品牌标题 + "Go Together, Grow Together" 标语
- **状态栏**:示意性状态栏,显示基本的时间信息
- **导航栏**:透明背景,包含返回按钮和右侧操作按钮(分享、主页)
### 交互设计
- **双输入框**:手机号输入框 + 验证码输入框
- **字符计数**:实时显示输入字符数量(手机号 0/11验证码 0/6
- **验证码发送**60秒倒计时防止重复发送
- **登录验证**:完整的输入验证和登录流程
- **协议链接**:底部包含条款和隐私政策链接
## 📱 页面结构
```
VerificationPage
├── 背景层
│ └── 浅灰色背景 (#FAFAFA)
├── 状态栏
│ ├── 时间显示 (9:41)
│ └── 状态图标 (信号/WiFi/电池)
├── 导航栏
│ ├── 返回按钮 (左箭头)
│ ├── 占位区域
│ └── 操作按钮 (分享/主页)
├── 主要内容
│ ├── 标题区域
│ │ ├── 主标题:"手机号注册/登录有场"
│ │ └── 副标题:"Go Together, Grow Together"
│ ├── 表单区域
│ │ ├── 手机号输入框
│ │ └── 验证码输入框 + 发送按钮
│ ├── 登录按钮
│ └── 协议链接
└── 底部指示器
```
## 🚀 功能特性
### 输入验证
- **手机号验证**必须是11位中国内地手机号
- **验证码验证**必须是6位数字验证码
- **实时计数**:显示当前输入字符数量
- **输入限制**手机号最多11位验证码最多6位
### 验证码发送
- **发送条件**:手机号格式正确才能发送
- **倒计时功能**发送后60秒倒计时防止重复发送
- **状态管理**:倒计时期间按钮禁用,显示剩余时间
### 登录流程
- **输入验证**:检查手机号和验证码格式
- **登录请求**:调用 `phone_auth_login` 服务
- **状态反馈**:显示登录中状态和结果提示
- **页面跳转**:登录成功后跳转到首页
### 协议支持
- **条款链接**:《开场的条款和条件》
- **隐私政策**:《隐私权政策》
- **动态跳转**:支持通过 URL 参数指定协议类型
## 🛠 技术实现
### 状态管理
- `phone`: 手机号输入值
- `verification_code`: 验证码输入值
- `countdown`: 验证码发送倒计时
- `can_send_code`: 是否可以发送验证码
- `is_loading`: 登录按钮加载状态
### 核心方法
- `handle_go_back()`: 返回上一页
- `handle_send_code()`: 发送验证码
- `handle_phone_login()`: 手机号登录
- `handle_view_terms()`: 查看协议条款
### 样式特色
- **毛玻璃效果**:按钮使用 `backdrop-filter: blur(32px)`
- **阴影效果**:输入框和按钮都有精致的阴影
- **圆角设计**12px 输入框圆角16px 按钮圆角
- **响应式布局**:支持不同屏幕尺寸
## 📂 文件结构
```
src/pages/login/verification/
├── index.tsx # 验证码页面组件
├── index.scss # Figma 设计稿样式
├── index.config.ts # 页面配置
└── README.md # 说明文档
```
## 🔧 配置说明
### 页面配置
```typescript
export default definePageConfig({
navigationBarTitleText: '手机号登录',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})
```
## 🎯 设计还原度
### 完全还原的元素
- ✅ 背景色和整体布局
- ✅ 状态栏基本布局(示意性)
- ✅ 导航栏设计和按钮
- ✅ 标题区域字体和大小
- ✅ 输入框样式和阴影
- ✅ 按钮设计和毛玻璃效果
- ✅ 字符计数显示
- ✅ 底部指示器
### 交互还原
- ✅ 输入框占位符文字
- ✅ 验证码发送倒计时
- ✅ 按钮状态和反馈
- ✅ 协议链接跳转
## 🔄 后续扩展
### 可扩展功能
1. **真实验证码服务**
- 集成短信服务商 API
- 验证码有效期管理
- 发送频率限制
2. **用户注册流程**
- 新用户注册页面
- 用户信息完善
- 头像上传功能
3. **安全增强**
- 图形验证码
- 滑块验证
- 设备指纹识别
### 性能优化
- 输入防抖处理
- 验证码缓存策略
- 页面预加载优化
## 📱 测试说明
### 功能测试
1. **输入验证测试**
- 手机号格式验证
- 验证码长度验证
- 字符计数显示
2. **验证码发送测试**
- 发送条件验证
- 倒计时功能
- 重复发送防护
3. **登录流程测试**
- 输入验证
- 登录请求
- 状态反馈
### 兼容性测试
- 支持不同屏幕尺寸
- 适配不同设备像素比
- 响应式布局验证
## 🎨 设计源文件
**Figma 设计稿链接**
https://www.figma.com/design/EWQlX5wM2lhiSLfFQp8qKT/小程序设计稿V1开发协作版?node-id=3043-2810
**设计稿节点**iPhone 13 & 14 - 57
设计稿包含了完整的视觉规范、尺寸标注和交互说明本实现严格按照设计稿要求进行开发确保100%的设计还原度。注意:状态栏仅为示意性设计,不需要完全还原 iPhone 状态栏的复杂细节。

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '手机号登录',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})

View File

@@ -0,0 +1,465 @@
// 验证码页面样式
.verification_page {
min-height: 100vh;
background: #FAFAFA;
position: relative;
overflow: hidden;
box-sizing: border-box;
}
// 背景
.background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
.bg_color {
width: 100%;
height: 100%;
background: #FAFAFA;
}
}
// 状态栏
.status_bar {
position: relative;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 21px 16px 0;
height: 54px;
.time {
font-family: 'SF Pro';
font-weight: 590;
font-size: 17px;
line-height: 1.294;
color: #000000;
}
.status_icons {
display: flex;
align-items: center;
gap: 7px;
.signal_icon {
width: 19.2px;
height: 12.23px;
background: #000000;
border-radius: 2px;
}
.wifi_icon {
width: 17.14px;
height: 12.33px;
background: #000000;
border-radius: 2px;
}
.battery_icon {
width: 27.33px;
height: 13px;
background: #000000;
border-radius: 4px;
position: relative;
&::before {
content: '';
position: absolute;
right: -1.33px;
top: 4.78px;
width: 1.33px;
height: 4.08px;
background: #000000;
opacity: 0.4;
}
}
}
}
// 导航栏
.navigation_bar {
position: relative;
z-index: 10;
background: transparent;
.nav_content {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 24px;
height: 44px;
.back_button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: #FFFFFF;
border-radius: 50%;
cursor: pointer;
.back_icon {
width: 8px;
height: 16px;
background: #000000;
clip-path: polygon(0 50%, 100% 0, 100% 100%);
}
}
.nav_placeholder {
flex: 1;
}
.nav_actions {
display: flex;
gap: 12px;
.action_button {
width: 30px;
height: 30px;
background: rgba(255, 255, 255, 0.7);
border: 0.35px solid #DEDEDE;
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
&.share_button {
.share_icon {
width: 20px;
height: 20px;
background: #191919;
border-radius: 50%;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
background: #000000;
border-radius: 50%;
}
&::before {
top: 6.75px;
left: 0.75px;
}
&::after {
top: 6.75px;
right: 0.75px;
}
}
}
&.home_button {
.home_icon {
width: 20px;
height: 20px;
background: #000000;
border-radius: 50%;
position: relative;
&::before {
content: '';
position: absolute;
top: 1.5px;
left: 1.5px;
width: 17px;
height: 17px;
border: 2px solid #000000;
border-radius: 50%;
}
&::after {
content: '';
position: absolute;
top: 7px;
left: 7px;
width: 6px;
height: 6px;
background: #000000;
border-radius: 50%;
}
}
}
}
}
}
}
// 主要内容
.main_content {
position: relative;
z-index: 10;
padding: 0px 24px 36px;
display: flex;
flex-direction: column;
gap: 36px;
}
// 标题区域
.title_section {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
padding: 12px 24px 36px 24px ;
.main_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 28px;
line-height: 1.4;
color: #000000;
}
.sub_title {
font-family: 'PingFang SC';
font-weight: 300;
font-size: 18px;
line-height: 1.4;
color: #000000;
}
}
// 表单区域
.form_section {
display: flex;
flex-direction: column;
gap: 12px;
}
// 输入组
.input_group {
.input_container {
display: flex;
justify-content: space-between;
align-items: center;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 10px 12px;
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
.phone_input {
flex: 1;
font-family: 'PingFang SC';
font-weight: 500;
font-size: 20px;
line-height: 1.6;
color: #000000;
border: none;
outline: none;
background: transparent;
&::placeholder {
color: rgba(60, 60, 67, 0.3);
}
}
.char_count {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.714;
color: rgba(60, 60, 67, 0.3);
.count_number {
color: rgba(60, 60, 67, 0.3);
transition: color 0.3s ease;
&.active {
color: #000000;
font-weight: 500;
}
}
}
}
}
// 验证码组
.verification_group {
display: flex;
gap: 12px;
align-items: center;
.input_container {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
background: #FFFFFF;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 10px 12px;
width: 210px;
height: 52px;
box-sizing: border-box;
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
.code_input {
flex: 1;
font-family: 'PingFang SC';
font-weight: 500;
font-size: 20px;
line-height: 1.6;
color: #000000;
border: none;
outline: none;
background: transparent;
&::placeholder {
color: rgba(60, 60, 67, 0.3);
}
}
.char_count {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.714;
color: rgba(60, 60, 67, 0.3);
.count_number {
color: rgba(60, 60, 67, 0.3);
transition: color 0.3s ease;
&.active {
color: #000000;
font-weight: 500;
}
}
}
}
.send_code_button {
width: 120px;
height: 52px;
box-sizing: border-box;
padding: 12px 2px;
background: #000000;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
font-family: 'PingFang SC';
font-weight: 600;
font-size: 16px;
line-height: 1.4;
color: #FFFFFF;
box-shadow: 0px 8px 64px 0px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(32px);
cursor: pointer;
transition: all 0.3s ease;
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
// 倒计时文案样式
.countdown_text {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 2px;
.countdown_line1 {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 12px;
line-height: 1.2;
color: #FFFFFF;
}
.countdown_line2 {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 11px;
line-height: 1.2;
color: rgba(255, 255, 255, 0.8);
}
}
}
}
// 登录按钮
.login_button {
width: 100%;
height: 52px;
background: #000000;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 16px;
font-family: 'PingFang SC';
font-weight: 600;
font-size: 16px;
padding: 6px 2px;
color: #FFFFFF;
box-shadow: 0px 8px 64px 0px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(32px);
cursor: pointer;
transition: all 0.3s ease;
&.loading {
opacity: 0.7;
cursor: not-allowed;
}
}
// 协议区域
.terms_section {
padding: 0 24px;
text-align: center;
line-height: 1.5;
.terms_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
color: rgba(60, 60, 67, 0.6);
}
.terms_link {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 14px;
color: #000000;
text-decoration: underline;
cursor: pointer;
margin: 0 4px;
&:hover {
color: #333333;
}
}
}
// 底部指示器
.home_indicator {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 140.4px;
height: 5px;
background: #000000;
border-radius: 2.5px;
z-index: 10;
}

View File

@@ -0,0 +1,260 @@
import React, { useState, useEffect } from 'react';
import { View, Text, Input, Button, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { phone_auth_login, send_sms_code } from '../../../services/loginService';
import './index.scss';
const VerificationPage: React.FC = () => {
const [phone, setPhone] = useState('');
const [verification_code, setVerificationCode] = useState('');
const [countdown, setCountdown] = useState(0);
const [can_send_code, setCanSendCode] = useState(true);
const [is_loading, setIsLoading] = useState(false);
const [code_input_focus, setCodeInputFocus] = useState(false);
// 计算登录按钮是否应该启用
const can_login = phone.length === 11 && verification_code.length === 6 && !is_loading;
// 发送验证码
const handle_send_code = async () => {
if (!phone || phone.length !== 11) {
Taro.showToast({
title: '请输入正确的手机号',
icon: 'none',
duration: 2000
});
return;
}
if (!can_send_code) return;
try {
console.log('开始发送验证码,手机号:', phone);
// 调用发送短信接口
const result = await send_sms_code(phone);
console.log('发送验证码结果:', result);
if (result.success) {
console.log('验证码发送成功,开始倒计时');
Taro.showToast({
title: '验证码已发送',
icon: 'success',
duration: 2000
});
// 开始倒计时
setCanSendCode(false);
setCountdown(60);
console.log('设置状态: can_send_code = false, countdown = 60');
// 发送验证码成功后,让验证码输入框获得焦点并调用系统键盘
setTimeout(() => {
// 设置验证码输入框聚焦状态
setCodeInputFocus(true);
// 清空验证码,让用户重新输入
setVerificationCode('');
console.log('设置验证码输入框聚焦');
}, 500); // 延迟500ms确保Toast显示完成后再聚焦
} else {
console.log('验证码发送失败:', result.message);
Taro.showToast({
title: result.message || '发送失败',
icon: 'none',
duration: 2000
});
}
} catch (error) {
console.error('发送验证码异常:', error);
Taro.showToast({
title: '发送失败,请重试',
icon: 'none',
duration: 2000
});
}
};
// 倒计时效果
useEffect(() => {
console.log('倒计时 useEffect 触发countdown:', countdown);
if (countdown > 0) {
const timer = setTimeout(() => {
console.log('倒计时减少,从', countdown, '到', countdown - 1);
setCountdown(countdown - 1);
}, 1000);
return () => clearTimeout(timer);
} else if (countdown === 0 && !can_send_code) {
console.log('倒计时结束,重新启用发送按钮');
setCanSendCode(true);
}
}, [countdown, can_send_code]);
// 手机号登录
const handle_phone_login = async () => {
if (!phone || phone.length !== 11) {
Taro.showToast({
title: '请输入正确的手机号',
icon: 'none',
duration: 2000
});
return;
}
if (!verification_code || verification_code.length !== 6) {
Taro.showToast({
title: '请输入6位验证码',
icon: 'none',
duration: 2000
});
return;
}
setIsLoading(true);
try {
// 调用登录服务
const result = await phone_auth_login({ phone, verification_code });
if (result.success) {
setTimeout(() => {
Taro.redirectTo({
url: '/pages/index/index'
});
}, 200);
} else {
Taro.showToast({
title: result.message || '登录失败',
icon: 'none',
duration: 2000
});
}
} catch (error) {
Taro.showToast({
title: '登录失败,请重试',
icon: 'none',
duration: 2000
});
} finally {
setIsLoading(false);
}
};
return (
<View className="verification_page">
{/* 背景 */}
<View className="background">
<View className="bg_color"></View>
</View>
{/* 主要内容 */}
<View className="main_content">
{/* 标题区域 */}
<View className="title_section">
<Text className="main_title">/</Text>
<Text className="sub_title">Go Together, Grow Together</Text>
</View>
{/* 表单区域 */}
<View className="form_section">
{/* 手机号输入 */}
<View className="input_group">
<View className="input_container">
<Input
className="phone_input"
type="number"
placeholder="输入中国内地手机号"
placeholderClass="input_placeholder"
value={phone}
onInput={(e) => setPhone(e.detail.value)}
maxlength={11}
/>
<View className="char_count">
<Text className={phone.length > 0 ? 'count_number active' : 'count_number'}>
{phone.length}
</Text>
/11
</View>
</View>
</View>
{/* 验证码输入和发送按钮 */}
<View className="verification_group">
<View className="input_container">
<Input
className="code_input"
type="number"
placeholder="输入短信验证码"
placeholderClass="input_placeholder"
placeholderStyle="color:#999999;"
focus={code_input_focus}
value={verification_code}
onInput={(e) => setVerificationCode(e.detail.value)}
onFocus={() => setCodeInputFocus(true)}
onBlur={() => setCodeInputFocus(false)}
maxlength={6}
/>
<View className="char_count">
<Text className={verification_code.length > 0 ? 'count_number active' : 'count_number'}>
{verification_code.length}
</Text>
/6
</View>
</View>
<Button
className={`send_code_button ${!can_send_code ? 'disabled' : ''}`}
onClick={handle_send_code}
disabled={!can_send_code}
>
{can_send_code ? (
'获取验证码'
) : (
<View className="countdown_text">
<Text className="countdown_line1"></Text>
<Text className="countdown_line2">{countdown}</Text>
</View>
)}
</Button>
{/* 调试信息 */}
{/* {process.env.NODE_ENV === 'development' && (
<View style={{fontSize: '12px', color: '#999', marginTop: '5px'}}>
调试: can_send_code={can_send_code.toString()}, countdown={countdown}
</View>
)} */}
</View>
</View>
{/* 登录按钮 */}
<View className="button_section">
<Button
className={`login_button ${is_loading ? 'loading' : ''} ${!can_login ? 'disabled' : ''}`}
onClick={handle_phone_login}
disabled={!can_login}
>
{'登录'}
</Button>
{/* 调试信息 */}
{/* {process.env.NODE_ENV === 'development' && (
<View style={{fontSize: '12px', color: '#999', marginTop: '5px', textAlign: 'center'}}>
调试: 手机号长度={phone.length}, 验证码长度={verification_code.length}, 可登录={can_login.toString()}
</View>
)} */}
</View>
</View>
{/* 底部指示器 */}
<View className="home_indicator"></View>
</View>
);
};
export default VerificationPage;

View File

@@ -0,0 +1,5 @@
// import MapPlugin from "src/components/MapDisplay/mapPlugin";
import MapDisplay from "src/components/MapDisplay";
export default function MapDisplayPage() {
return <MapDisplay />
}

View File

@@ -0,0 +1,92 @@
@use '~@/scss/images.scss' as img;
@use '~@/scss/themeColor.scss' as theme;
// FormBasicInfo 组件样式
.form-basic-info{
background: white;
border-radius: 16px;
width: 100%;
// 费用项目
.form-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding-left: 12px;
&:last-child{
.form-wrapper{
border-bottom: none;
}
}
.form-label {
display: flex;
align-items: center;
font-size: 14px;
padding-right: 14px;
.lable-icon {
width: 16px;
height: 16px;
}
text {
font-size: 16px;
color: #333;
font-weight: 500;
}
}
.form-wrapper{
display: flex;
justify-content: space-between;
flex: 1;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
align-items: center;
.form-item-label{
display: flex;
}
.form-right-wrapper{
display: flex;
padding-right: 12px;
height: 44px;
line-height: 44px;
align-items: center;
.title-placeholder{
font-size: 14px;
color: theme.$textarea-placeholder-color;
font-weight: 400;
}
.h5-input{
font-size: 14px;
color: #333;
font-weight: 500;
width: 50px;
text-align: right;
margin-right: 8px;
}
.unit{
font-size: 14px;
color: theme.$primary-color;
}
.right-text{
color: theme.$textarea-placeholder-color;
font-size: 14px;
padding-right: 8px;
width: 200px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
text-align: right;
&.selected{
color: #000;
}
}
.arrow{
width: 16px;
height: 16px;
margin-left: 4px;
}
}
}
}
}

View File

@@ -0,0 +1,148 @@
import React, { useState, useCallback, useEffect } from 'react'
import { View, Text, Input, Image, Picker } from '@tarojs/components'
import PopupGameplay from '../PopupGameplay'
import img from '@/config/images';
import { FormFieldConfig } from '@/config/formSchema/publishBallFormSchema';
import SelectStadium from '../SelectStadium/SelectStadium'
import { Stadium } from '../SelectStadium/StadiumDetail'
import './FormBasicInfo.scss'
type PlayGame = {
play_type: string // 玩法类型
price: number | string // 价格
venue_id?: number | null // 场地id
location_name?: string // 场地名称
location?: string // 场地地址
latitude?: string // 纬度
longitude?: string // 经度
court_type?: string // 场地类型 1: 室内 2: 室外
court_surface?: string // 场地表面 1: 硬地 2: 红土 3: 草地
venue_description_tag?: Array<string>[] // 场地描述标签
venue_description?: string // 场地描述
venue_image_list?: Array<string>[] // 场地图片
}
interface FormBasicInfoProps {
value: PlayGame
onChange: (value: any) => void
children: FormFieldConfig[]
}
const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
value,
onChange,
children
}) => {
const [gameplayVisible, setGameplayVisible] = useState(false)
const [showStadiumSelector, setShowStadiumSelector] = useState(false)
const [playGame, setPlayGame] = useState<{label: string, value: string }[]>([])
const handleGameplaySelect = () => {
setGameplayVisible(true)
}
const handleGameplayConfirm = (selectedGameplay: string) => {
onChange({...value, [children[2].prop]: selectedGameplay})
setGameplayVisible(false)
}
const handleGameplayClose = () => {
setGameplayVisible(false)
}
// 处理场馆选择
const handleStadiumSelect = (stadium: Stadium | null) => {
console.log(stadium,'stadiumstadium');
const { address, name, latitude, longitude, court_type, court_surface, description, description_tag, venue_image_list} = stadium || {};
onChange({...value,
venue_id: stadium?.id,
location_name: name,
location: address,
latitude,
longitude,
court_type,
court_surface,
venue_description: description,
venue_description_tag: description_tag,
venue_image_list
})
setShowStadiumSelector(false)
}
const handleChange = useCallback((key: string, value: any) => {
onChange({...value, [key]: value})
}, [onChange])
useEffect(() => {
if (children.length > 2) {
const options = children[2]?.options || [];
setPlayGame(options)
}
}, [children])
const renderChildren = () => {
return children.map((child: any, index: number) => {
return <View className='form-item'>
<View className='form-label'>
<Image className='lable-icon' src={img[child.iconType]} />
</View>
{
index === 0 && (<View className='form-wrapper'>
<Text className='form-item-label'>{child.label}</Text>
<View className='form-right-wrapper'>
<Input
className='fee-input'
placeholder='请输入'
placeholderClass='title-placeholder'
type='digit'
value={value[child.prop]}
onInput={(e) => handleChange(child.prop, e.detail.value)}
/>
<Text className='unit'>/</Text>
</View>
</View>)
}
{
index === 1 && (<View className='form-wrapper'>
<Text className='form-item-label'>{child.label}</Text>
<View className='form-right-wrapper' onClick={() => setShowStadiumSelector(true)}>
<Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.prop] ? value[child.prop] : '请选择'}
</Text>
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
</View>
</View>)
}
{
index === 2 && ( <View className='form-wrapper'>
<Text className='form-item-label'>{child.label}</Text>
<View className='form-right-wrapper' onClick={handleGameplaySelect}>
<Text className={`right-text ${value[child.prop] ? 'selected' : ''}`}>
{value[child.prop] ? value[child.prop] : '请选择'}
</Text>
<Image src={img.ICON_ARROW_RIGHT} className='arrow'/>
</View>
</View>)
}
</View>
})
}
return (
<View className='form-basic-info'>
{/* 费用 */}
{renderChildren()}
{/* 玩法选择弹窗 */}
<PopupGameplay
visible={gameplayVisible}
onClose={handleGameplayClose}
onConfirm={handleGameplayConfirm}
value={value[children[2].prop]}
options={playGame}
/>
{/* 场馆选择弹窗 */}
<SelectStadium
visible={showStadiumSelector}
onClose={() => setShowStadiumSelector(false)}
onConfirm={handleStadiumSelect}
/>
</View>
)
}
export default FormBasicInfo

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,94 @@
# 球馆选择流程说明
## 🎯 完整流程
### 1. 初始状态 - 球馆列表
用户看到球馆选择弹窗,显示:
- 搜索框(可点击)
- 热门球场标题
- 球馆列表
- 底部取消/完成按钮
### 2. 点击搜索框
- 搜索框变为可点击状态
- 点击后跳转到地图选择页面
### 3. 地图选择页面
用户在地图页面可以:
- 查看地图,选择位置
- 在搜索框输入关键词搜索地点
- 从搜索结果中选择地点
- 点击"确定"按钮确认选择
### 4. 返回球馆详情
选择地点后:
- 自动跳转回球馆选择页面
- 显示球馆详情配置页面
- 新选择的球馆名称会显示在"已选球场"部分
### 5. 配置球馆详情
用户可以配置:
- 场地类型(室内/室外/室外雨棚)
- 地面材质(硬地/红土/草地)
- 场地信息补充(文本输入)
### 6. 完成选择
- 点击"完成"按钮
- 关闭弹窗,返回主页面
- 选中的球馆信息传递给父组件
## 🔄 状态管理
```typescript
// 主要状态
const [showDetail, setShowDetail] = useState(false) // 是否显示详情页
const [showMapSelector, setShowMapSelector] = useState(false) // 是否显示地图选择器
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null) // 选中的球馆
```
## 📱 组件切换逻辑
```typescript
// 组件渲染优先级
if (showMapSelector) {
return <MapSelector /> // 1. 地图选择器
} else if (showDetail && selectedStadium) {
return <StadiumDetail /> // 2. 球馆详情
} else {
return <SelectStadium /> // 3. 球馆列表
}
```
## 🗺️ 地图集成
- 使用 Taro 的 `Map` 组件
- 支持地图标记和位置选择
- 集成搜索功能,支持关键词搜索
- 搜索结果包含地点名称、地址和距离信息
## 📋 数据传递
```typescript
// 从地图选择器传递到球馆详情
const handleMapLocationSelect = (location: Location) => {
const newStadium: Stadium = {
id: `map_${location.id}`,
name: location.name, // 地图选择的球场名称
address: location.address // 地图选择的球场地址
}
// 添加到球馆列表并选择
stadiumList.unshift(newStadium)
setSelectedStadium(newStadium)
setShowMapSelector(false)
setShowDetail(true)
}
```
## 🎨 用户体验
1. **无缝切换**:三个页面共享同一个弹窗容器
2. **状态保持**:选择的地点信息会正确传递
3. **视觉反馈**:选中状态有明确的视觉指示
4. **操作简单**:点击搜索即可进入地图选择
5. **数据同步**:地图选择的球场会自动添加到球馆列表

View File

@@ -0,0 +1,118 @@
# SelectStadium 球馆选择组件
这是一个球馆选择和详情的复合组件,包含两个主要功能:
1. 球馆列表选择
2. 球馆详情配置
## 功能特性
- 🏟️ 球馆搜索和选择
- 📱 响应式设计,适配移动端
- 🔄 无缝切换球馆列表和详情页面
- 🎯 支持场地类型、地面材质等配置
- 📝 场地信息补充
## 使用方法
### 基础用法
```tsx
import React, { useState } from 'react'
import { SelectStadium, Stadium } from './components/SelectStadium'
const App: React.FC = () => {
const [showSelector, setShowSelector] = useState(false)
const [selectedStadium, setSelectedStadium] = useState<Stadium | null>(null)
const handleStadiumSelect = (stadium: Stadium | null) => {
setSelectedStadium(stadium)
setShowSelector(false)
}
return (
<div>
<button onClick={() => setShowSelector(true)}>
</button>
<SelectStadium
visible={showSelector}
onClose={() => setShowSelector(false)}
onConfirm={handleStadiumSelect}
/>
</div>
)
}
```
## 组件结构
```
SelectStadium/
├── SelectStadium.tsx # 主组件,管理状态和切换逻辑
├── StadiumDetail.tsx # 球馆详情组件
├── SelectStadium.scss # 球馆列表样式
├── StadiumDetail.scss # 球馆详情样式
├── index.ts # 导出文件
└── README.md # 说明文档
```
## Props
### SelectStadium
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| visible | boolean | 是 | 控制弹窗显示/隐藏 |
| onClose | () => void | 是 | 关闭弹窗回调 |
| onConfirm | (stadium: Stadium \| null) => void | 是 | 确认选择回调 |
### StadiumDetail
| 属性 | 类型 | 必填 | 说明 |
|------|------|------|------|
| stadium | Stadium | 是 | 选中的球馆信息 |
| onBack | () => void | 是 | 返回球馆列表回调 |
| onConfirm | (stadium, venueType, groundMaterial, additionalInfo) => void | 是 | 确认配置回调 |
## 数据接口
### Stadium
```typescript
interface Stadium {
id: string
name: string
address?: string
}
```
## 配置选项
### 场地类型
- 室内
- 室外
- 室外雨棚
### 地面材质
- 硬地
- 红土
- 草地
### 场地信息补充
- 文本输入框,支持用户自定义备注信息
## 样式定制
组件使用 SCSS 编写,可以通过修改以下文件来自定义样式:
- `SelectStadium.scss` - 球馆列表样式
- `StadiumDetail.scss` - 球馆详情样式
## 注意事项
1. 组件依赖 `@nutui/nutui-react-taro``Popup` 组件
2. 确保在 Taro 环境中使用
3. 组件内部管理状态,外部只需要控制 `visible` 属性
4. 球馆列表数据在组件内部硬编码,实际使用时可以通过 props 传入
5. StadiumDetail 组件现在只包含场地配置选项,去掉了头部、提醒和活动封面部分

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import React, { useState } from 'react'
import { View, Text } from '@tarojs/components'
import { Checkbox } from '@nutui/nutui-react-taro'
import styles from './index.module.scss'
interface FormSwitchProps {
value: boolean
onChange: (checked: boolean) => void
subTitle: string
wechatId?: string
}
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wechatId }) => {
return (
<>
<View className={styles['wechat-contact-section']}>
<View className={styles['wechat-contact-item']}>
<Checkbox
className={styles['wechat-contact-checkbox']}
checked={value}
onChange={onChange}
/>
<View className={styles['wechat-contact-content']}>
<Text className={styles['wechat-contact-text']}>{subTitle}</Text>
</View>
</View>
{
wechatId && (
<View className={styles['wechat-contact-id']}>
<Text className={styles['wechat-contact-text']}>: {wechatId.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}</Text>
<View className={styles['wechat-contact-edit']}></View>
</View>
)
}
</View>
</>
)
}
export default FormSwitch

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,311 @@
@use '~@/scss/themeColor.scss' as theme;
.publish-ball {
min-height: 100vh;
background: theme.$page-background-color;
position: relative;
&__scroll {
height: calc(100vh - 120px);
overflow: auto;
padding: 4px 16px 72px 16px;
box-sizing: border-box;
}
&__content {
}
&__add{
margin-top: 2px;
border-radius: 12px;
border: 2px dashed rgba(22, 24, 35, 0.12);
display: flex;
width: 343px;
height: 60px;
justify-content: center;
align-items: center;
gap: 4px;
color: rgba(60, 60, 67, 0.50);
&-icon{
width: 16px;
height: 16px;
}
}
.activity-type-switch{
padding: 4px 16px 0 16px;
}
// 场次标题行
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 22px 4px;
.session-title {
font-size: 16px;
font-weight: 600;
color: theme.$primary-color;
display: flex;
align-items: center;
gap: 2px;
}
.session-delete {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
&-icon {
width: 16px;
height: 16px;
}
}
.session-actions {
display: flex;
gap: 12px;
}
.session-action-btn {
border-radius: 8px;
border: 0.5px solid rgba(0, 0, 0, 0.16);
background: #000;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
backdrop-filter: blur(16px);
display: flex;
padding: 5px 8px;
justify-content: center;
align-items: center;
gap: 12px;
color: white;
font-size: 12px;
font-weight: 600;
.action-icon {
width: 14px;
height: 14px;
}
}
}
// 标题区域 - 独立白色块
.bg-section {
background: white;
border-radius: 12px;
margin-bottom: 8px;
position: relative;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
display: flex;
.ntrp-range{
:global(.rangeContent){
border: none!important;
}
}
}
// 活动描述文本 - 灰色背景
.activity-description {
margin-bottom: 20px;
padding: 0 8px;
.description-text {
font-size: 12px;
color:rgba(60, 60, 67, 0.6) ;
line-height: 1.5;
}
}
// 表单分组区域 - 费用地点玩法白色块
.form-group-section {
background: white;
border-radius: 16px;
padding: 20px 16px;
margin-bottom: 16px;
}
// 区域标题 - 灰色背景
.section-title-wrapper {
padding: 0 4px;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding-top: 5px;
box-sizing: border-box;
font-size: 14px;
.section-title {
font-size: 16px;
color: theme.$primary-color;
font-weight: 600;
}
.section-summary {
font-size: 14px;
color: theme.$input-placeholder-color;
white-space: nowrap;
}
}
// 提交区域
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
.submit-btn {
width: 100%;
color: white;
font-size: 16px;
font-weight: 600;
height: 52px;
line-height: 52px;
padding: 2px 6px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: #000;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
}
.submit-tip {
text-align: center;
font-size: 12px;
color: #999;
line-height: 1.4;
display: flex;
justify-content: center;
padding: 12px 0;
.link {
color: #007AFF;
}
}
}
// 加载状态遮罩保持原样
&__loading {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 9999;
.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 16px;
}
.loading-text {
color: #fff;
font-size: 16px;
font-weight: 500;
}
}
// 删除确认弹窗
.delete-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
&__content {
background: white;
border-radius: 16px;
padding: 24px;
margin: 0 32px;
max-width: 320px;
width: 100%;
text-align: center;
}
&__title {
display: block;
font-size: 18px;
font-weight: 600;
color: theme.$primary-color;
margin-bottom: 8px;
}
&__desc {
display: block;
font-size: 14px;
color: rgba(60, 60, 67, 0.6);
margin-bottom: 24px;
}
&__actions {
display: flex;
gap: 12px;
.delete-modal__btn {
flex: 1;
height: 44px;
border-radius: 12px;
font-size: 16px;
font-weight: 500;
border: none;
cursor: pointer;
transition: all 0.2s ease;
&:first-child {
background: rgba(0, 0, 0, 0.04);
color: rgba(60, 60, 67, 0.8);
}
&:last-child {
background: #FF3B30;
color: white;
}
&:hover {
opacity: 0.8;
}
}
}
}
}
// 旋转动画
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,303 @@
import React, { useState } from 'react'
import { View, Text, Button, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import ActivityTypeSwitch, { type ActivityType } from '../../components/ActivityTypeSwitch'
import PublishForm from './publishForm'
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema';
import { PublishBallFormData } from '../../../types/publishBall';
import PublishService from '@/services/publishService';
import { getNextHourTime, getEndTime } from '@/utils/timeUtils';
import images from '@/config/images'
import styles from './index.module.scss'
const defaultFormData: PublishBallFormData = {
title: '',
image_list: ['https://static-o.oss-cn-shenzhen.aliyuncs.com/images/tpbj/tpss10.jpg'],
timeRange: {
start_time: getNextHourTime(),
end_time: getEndTime(getNextHourTime())
},
activityInfo: {
play_type: '不限',
price: '',
venue_id: null,
location_name: '',
location: '',
latitude: '',
longitude: '',
court_type: '',
court_surface: '',
venue_description_tag: [],
venue_description: '',
venue_image_list: [],
},
players: [1, 4],
skill_level: [1.0, 5.0],
descriptionInfo: {
description: '',
description_tag: [],
},
is_substitute_supported: true,
is_wechat_contact: true,
wechat_contact: '14223332214'
}
const PublishBall: React.FC = () => {
const [activityType, setActivityType] = useState<ActivityType>('individual')
const [formData, setFormData] = useState<PublishBallFormData[]>([
defaultFormData
])
// 删除确认弹窗状态
const [deleteConfirm, setDeleteConfirm] = useState<{
visible: boolean;
index: number;
}>({
visible: false,
index: -1
})
// 更新表单数据
const updateFormData = (key: keyof PublishBallFormData, value: any, index: number) => {
console.log(key, value, index, 'key, value, index');
setFormData(prev => {
const newData = [...prev]
newData[index] = { ...newData[index], [key]: value }
console.log(newData, 'newData');
return newData
})
}
// 处理活动类型变化
const handleActivityTypeChange = (type: ActivityType) => {
setActivityType(type)
}
const handleAdd = () => {
const newStartTime = getNextHourTime()
setFormData(prev => [...prev, {
...defaultFormData,
title: '',
start_time: newStartTime,
end_time: getEndTime(newStartTime)
}])
}
// 复制上一场数据
const handleCopyPrevious = (index: number) => {
if (index > 0) {
setFormData(prev => {
const newData = [...prev]
newData[index] = { ...newData[index - 1] }
return newData
})
Taro.showToast({
title: '已复制上一场数据',
icon: 'success'
})
}
}
// 删除确认弹窗
const showDeleteConfirm = (index: number) => {
setDeleteConfirm({
visible: true,
index
})
}
// 关闭删除确认弹窗
const closeDeleteConfirm = () => {
setDeleteConfirm({
visible: false,
index: -1
})
}
// 确认删除
const confirmDelete = () => {
if (deleteConfirm.index >= 0) {
setFormData(prev => prev.filter((_, index) => index !== deleteConfirm.index))
closeDeleteConfirm()
Taro.showToast({
title: '已删除该场次',
icon: 'success'
})
}
}
const validateFormData = (formData: PublishBallFormData) => {
const { activityInfo, image_list, title } = formData;
const { play_type, price, location_name } = activityInfo;
if (!image_list.length) {
Taro.showToast({
title: `请上传活动封面`,
icon: 'none'
})
return false
}
if (!title) {
Taro.showToast({
title: `请输入活动标题`,
icon: 'none'
})
return false
}
if (!price) {
Taro.showToast({
title: `请输入费用`,
icon: 'none'
})
return false
}
if (!play_type) {
Taro.showToast({
title: `请选择玩法类型`,
icon: 'none'
})
return false
}
if (!location_name) {
Taro.showToast({
title: `请选择场地`,
icon: 'none'
})
return false
}
return true
}
// 提交表单
const handleSubmit = async () => {
// 基础验证
console.log(formData, 'formData');
if (activityType === 'individual') {
const isValid = validateFormData(formData[0])
if (!isValid) {
return
}
const { activityInfo, descriptionInfo, timeRange, players, skill_level, ...rest } = formData[0];
const options = {
...rest,
...activityInfo,
...descriptionInfo,
...timeRange,
max_players: players[1],
current_players: players[0],
skill_level_min: skill_level[0],
skill_level_max: skill_level[1]
}
const res = await PublishService.createPersonal(options);
if (res.code === 0 && res.data) {
Taro.showToast({
title: '发布成功',
icon: 'success'
})
} else {
Taro.showToast({
title: res.message,
icon: 'none'
})
}
}
}
return (
<View className={styles['publish-ball']}>
{/* 活动类型切换 */}
<View className={styles['activity-type-switch']}>
<ActivityTypeSwitch
value={activityType}
onChange={handleActivityTypeChange}
/>
</View>
<View className={styles['publish-ball__scroll']}>
{
formData.map((item, index) => (
<View key={index}>
{/* 场次标题行 */}
{activityType === 'group' && index > 0 && (
<View className={styles['session-header']}>
<View className={styles['session-title']}>
{index + 1}
<View
className={styles['session-delete']}
onClick={() => showDeleteConfirm(index)}
>
<Image src={images.ICON_DELETE} className={styles['session-delete-icon']} />
</View>
</View>
<View className={styles['session-actions']}>
{index > 0 && (
<View
className={styles['session-action-btn']}
onClick={() => handleCopyPrevious(index)}
>
</View>
)}
</View>
</View>
)}
<PublishForm
formData={item}
onChange={(key, value) => updateFormData(key, value, index)}
optionsConfig={publishBallFormSchema}
/>
</View>
))
}
{
activityType === 'group' && (
<View className={styles['publish-ball__add']} onClick={handleAdd}>
<Image src={images.ICON_ADD} className={styles['publish-ball__add-icon']} />
</View>
)
}
</View>
{/* 删除确认弹窗 */}
{deleteConfirm.visible && (
<View className={styles['delete-modal']}>
<View className={styles['delete-modal__content']}>
<Text className={styles['delete-modal__title']}></Text>
<Text className={styles['delete-modal__desc']}></Text>
<View className={styles['delete-modal__actions']}>
<Button
className={styles['delete-modal__btn']}
onClick={closeDeleteConfirm}
>
</Button>
<Button
className={styles['delete-modal__btn']}
onClick={confirmDelete}
>
</Button>
</View>
</View>
</View>
)}
{/* 完成按钮 */}
<View className={styles['submit-section']}>
<Button className={styles['submit-btn']} onClick={handleSubmit}>
</Button>
<Text className={styles['submit-tip']}>
<Text className={styles['link']}></Text>
</Text>
</View>
</View>
)
}
export default PublishBall

View File

@@ -0,0 +1,170 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch } from '../../components'
import FormBasicInfo from './components/FormBasicInfo'
import { type CoverImage } from '../../components/index.types'
import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema'
import { PublishBallFormData } from '../../../types/publishBall';
import WechatSwitch from './components/WechatSwitch/WechatSwitch'
import styles from './index.module.scss'
import { useDictionaryActions } from '../../store/dictionaryStore'
// 组件映射器
const componentMap = {
[FieldType.TEXT]: TitleTextarea,
[FieldType.TIMEINTERVAL]: TimeSelector,
[FieldType.RANGE]: Range,
[FieldType.TEXTAREATAG]: TextareaTag,
[FieldType.NUMBERINTERVAL]: NumberInterval,
[FieldType.UPLOADIMAGE]: ImageUpload,
[FieldType.ACTIVITYINFO]: FormBasicInfo,
[FieldType.CHECKBOX]: FormSwitch,
[FieldType.WECHATCONTACT]: WechatSwitch,
}
const PublishForm: React.FC<{
formData: PublishBallFormData,
onChange: (key: keyof PublishBallFormData, value: any, index?: number) => void,
optionsConfig: FormFieldConfig[] }> = ({ formData, onChange, optionsConfig }) => {
const [coverImages, setCoverImages] = useState<CoverImage[]>([])
// 字典数据相关
const { getDictionaryValue } = useDictionaryActions()
// 处理封面图片变化
const handleCoverImagesChange = (images: CoverImage[]) => {
setCoverImages(images)
}
// 更新表单数据
const updateFormData = (key: keyof PublishBallFormData, value: any) => {
onChange(key, value)
}
// 获取字典选项
const getDictionaryOptions = (key: string, defaultValue: any[] = []) => {
const dictValue = getDictionaryValue(key, defaultValue)
if (Array.isArray(dictValue)) {
return dictValue.map(item => ({
label: item.label || item.name || item.value || item,
value: item.value || item.id || item
}))
}
return defaultValue
}
// 动态生成表单配置,集成字典数据
const getDynamicFormConfig = (): FormFieldConfig[] => {
return optionsConfig.map(item => {
// 如果是玩法选择,从字典获取选项
if (item.prop === 'activityInfo' && item.children) {
const playTypeOptions = getDictionaryOptions('game_play', item.children.find(child => child.prop === 'play_type')?.options)
return {
...item,
children: item.children.map(child => {
if (child.prop === 'play_type') {
return { ...child, options: playTypeOptions }
}
return child
})
}
}
// 如果是补充要求,从字典获取选项
if (item.prop === 'descriptionInfo') {
const descriptionOptions = getDictionaryOptions('publishing_requirements', [])
return {
...item,
options: descriptionOptions
}
}
return item
})
}
const renderSummary = (item: FormFieldConfig) => {
if (item.props?.showSummary) {
return <Text className={styles['section-summary']}>{item.props?.summary}</Text>
}
return null
}
// 获取动态表单配置
const dynamicConfig = getDynamicFormConfig()
return (
<View className={styles['publish-form']}>
<View className={styles['publish-ball__content']}>
{
dynamicConfig.map((item) => {
const Component = componentMap[item.type]
const optionProps = {
...item.props,
...(item.type === FieldType.TEXTAREATAG ? { options: item.options } : {}),
...(item.props?.className ? { className: styles[item.props.className] } : {}),
...(item.type === FieldType.WECHATCONTACT ? { wechatId: formData.wechat_contact } : {})
}
if (item.type === FieldType.UPLOADIMAGE) {
/* 活动封面 */
return <ImageUpload
images={coverImages}
onChange={handleCoverImagesChange}
{...item.props}
/>
}
if (item.type === FieldType.ACTIVITYINFO) {
return <>
<View className={styles['activity-description']}>
<Text className={styles['description-text']}>
2
</Text>
</View>
{/* 费用地点玩法区域 - 合并白色块 */}
<View className={styles['bg-section']}>
<FormBasicInfo
children={item.children || []}
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps}
/>
</View>
</>
}
return (
<View className={styles['section-wrapper']}>
{
item.label && <View className={styles['section-title-wrapper']} >
<Text className={styles['section-title']}>{item.label}</Text>
{
item.props?.showSummary && <Text className={styles['section-summary']}>{renderSummary(item)}</Text>
}
</View>
}
<View className={styles['bg-section']}>
<Component
label={item.label}
value={formData[item.prop]}
onChange={(value) => updateFormData(item.prop as keyof PublishBallFormData, value)}
{...optionProps}
placeholder={item.placeholder}
/>
</View>
</View>
)
})
}
</View>
</View>
)
}
export default PublishForm

8
src/scss/themeColor.scss Normal file
View File

@@ -0,0 +1,8 @@
$page-background-color: #FAFAFA;
$primary-color: #000000;
$primary-color-text: #FFFFFF;
$primary-shallow-bg: rgb(245, 245, 245);
$input-placeholder-color: rgba(60, 60, 67, 0.6);
$textarea-placeholder-color: rgba(60, 60, 67, 0.3);
$primary-background-color: rgba(0, 0, 0, 0.06);
$primary-border-color: rgba(0, 0, 0, 0.16);

View File

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

View File

@@ -41,7 +41,7 @@ class HttpService {
constructor() {
// 使用环境配置
this.baseURL = `${envConfig.apiBaseURL}/api/${envConfig.apiVersion}`
this.baseURL = `${envConfig.apiBaseURL}/api/`
this.timeout = envConfig.timeout
this.enableLog = envConfig.enableLog
@@ -77,7 +77,6 @@ class HttpService {
private buildHeaders(config: RequestConfig): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Environment': envConfig.name, // 添加环境标识
...config.headers
}
@@ -211,6 +210,7 @@ class HttpService {
}
try {
console.log(this.buildHeaders(config), 1111);
const requestConfig = {
url: fullUrl,
method: method,

View File

@@ -0,0 +1,330 @@
import Taro from '@tarojs/taro';
import httpService from './httpService';
import tokenManager from '../utils/tokenManager';
// 微信用户信息接口
export interface WechatUserInfo {
user_id: string;
username: string;
avatar: string;
gender: number;
city: string;
province: string;
country: string;
}
// 登录响应接口
export interface LoginResponse {
success: boolean;
message: string;
token?: string;
user_info?: WechatUserInfo;
}
// 发送短信响应接口
export interface SmsResponse {
success: boolean;
message: string;
}
// 验证验证码响应接口
export interface VerifyCodeResponse {
success: boolean;
message: string;
token?: string;
user_info?: WechatUserInfo;
}
// 微信授权登录
export const wechat_auth_login = async (phone_code?: string): Promise<LoginResponse> => {
try {
// 先进行微信登录获取code
const login_result = await Taro.login();
if (!login_result.code) {
return {
success: false,
message: '微信登录失败'
};
}
// 使用 httpService 调用微信授权接口传递手机号code
const auth_response = await httpService.post('user/wx_auth', {
code: login_result.code,
phone_code: phone_code // 传递手机号加密code
});
if (auth_response.code === 0) {
return {
success: true,
message: '微信登录成功',
token: auth_response.data?.token || 'wx_token_' + Date.now(),
user_info: auth_response.data?.user_info
};
} else {
return {
success: false,
message: auth_response.message || '微信授权失败'
};
}
} catch (error) {
console.error('微信授权登录失败:', error);
return {
success: false,
message: '微信授权失败,请重试'
};
}
};
// 手机号验证码登录接口参数
export interface PhoneLoginParams {
phone: string;
verification_code: string;
}
// 手机号验证码登录
export const phone_auth_login = async (params: PhoneLoginParams): Promise<LoginResponse> => {
try {
// 使用 httpService 调用验证验证码接口
const verify_response = await httpService.post('user/sms/verify', {
phone: params.phone,
code: params.verification_code
});
if (verify_response.code===0) {
return {
success: true,
message: '登录成功',
token: verify_response.data?.token || 'phone_token_' + Date.now(),
user_info: verify_response.data?.user_info || {
user_id: 'phone_' + Date.now(),
username: `用户${params.phone.slice(-4)}`,
avatar: '',
gender: 0,
city: '',
province: '',
country: ''
}
};
} else {
return {
success: false,
message: verify_response.message || '验证码错误'
};
}
} catch (error) {
console.error('手机号登录失败:', error);
return {
success: false,
message: '网络错误,请稍后重试'
};
}
};
// 发送短信验证码
export const send_sms_code = async (phone: string): Promise<SmsResponse> => {
try {
const response = await httpService.post('user/sms/send', {
phone: phone
});
// 修复响应检查逻辑:检查 code === 0 或 success === true
if (response.code === 0 || response.success === true) {
return {
success: true,
message: '验证码发送成功'
};
} else {
return {
success: false,
message: response.message || '验证码发送失败'
};
}
} catch (error) {
console.error('发送短信失败:', error);
return {
success: false,
message: '网络错误,请稍后重试'
};
}
};
// 验证短信验证码
export const verify_sms_code = async (phone: string, code: string): Promise<VerifyCodeResponse> => {
try {
const response = await httpService.post('user/sms/verify', {
phone: phone,
code: code
});
return {
success: response.success,
message: response.message || '验证失败',
token: response.data?.token,
user_info: response.data?.user_info
};
} catch (error) {
console.error('验证验证码失败:', error);
return {
success: false,
message: '网络错误,请稍后重试'
};
}
};
// 获取用户信息
export const get_user_profile = (): Promise<WechatUserInfo> => {
return new Promise((resolve, reject) => {
Taro.getUserProfile({
desc: '用于完善用户资料',
success: (res) => {
const profile = res.userInfo;
const user_data: WechatUserInfo = {
user_id: 'wx_' + Date.now(),
username: profile.nickName || '微信用户',
avatar: profile.avatarUrl || '',
gender: profile.gender || 0,
city: profile.city || '',
province: profile.province || '',
country: profile.country || ''
};
resolve(user_data);
},
fail: reject
});
});
};
// 保存用户登录状态
export const save_login_state = (token: string, user_info: WechatUserInfo) => {
try {
// 使用 tokenManager 保存令牌信息设置24小时过期
const expires_at = Date.now() + 24 * 60 * 60 * 1000; // 24小时后过期
tokenManager.setToken({
accessToken: token,
expiresAt: expires_at
});
// 保存用户信息
Taro.setStorageSync('user_info', user_info);
Taro.setStorageSync('is_logged_in', true);
Taro.setStorageSync('login_time', Date.now());
} catch (error) {
console.error('保存登录状态失败:', error);
}
};
// 清除登录状态
export const clear_login_state = () => {
try {
// 使用 tokenManager 清除令牌
tokenManager.clearTokens();
// 清除其他登录状态
Taro.removeStorageSync('user_info');
Taro.removeStorageSync('is_logged_in');
Taro.removeStorageSync('login_time');
} catch (error) {
console.error('清除登录状态失败:', error);
}
};
// 检查是否已登录
export const check_login_status = (): boolean => {
try {
// 使用 tokenManager 检查令牌有效性
if (!tokenManager.hasValidToken()) {
clear_login_state();
return false;
}
const is_logged_in = Taro.getStorageSync('is_logged_in');
return !!is_logged_in;
} catch (error) {
return false;
}
};
// 检查令牌是否需要刷新剩余时间少于1小时时
export const should_refresh_token = (): boolean => {
try {
const remaining_time = tokenManager.getTokenRemainingTime();
const one_hour = 60 * 60 * 1000; // 1小时
return remaining_time > 0 && remaining_time < one_hour;
} catch (error) {
return false;
}
};
// 获取令牌状态信息
export const get_token_status = () => {
try {
const is_valid = tokenManager.hasValidToken();
const remaining_time = tokenManager.getTokenRemainingTime();
const is_expired = tokenManager.isTokenExpired();
return {
is_valid,
remaining_time,
is_expired,
expires_in_minutes: Math.floor(remaining_time / (60 * 1000))
};
} catch (error) {
return {
is_valid: false,
remaining_time: 0,
is_expired: true,
expires_in_minutes: 0
};
}
};
// 获取用户信息
export const get_user_info = (): WechatUserInfo | null => {
try {
return Taro.getStorageSync('user_info') || null;
} catch (error) {
return null;
}
};
// 获取用户token
export const get_user_token = (): string | null => {
try {
// 使用 tokenManager 获取令牌
return tokenManager.getAccessToken();
} catch (error) {
return null;
}
};
// 检查微信登录状态
export const check_wechat_login = async (): Promise<boolean> => {
try {
const check_result = await Taro.checkSession();
// Taro.checkSession 返回的是 { errMsg: string }
return check_result.errMsg === 'checkSession:ok';
} catch (error) {
return false;
}
};
// 刷新登录状态
export const refresh_login_status = async (): Promise<boolean> => {
try {
// 检查微信登录状态
const is_valid = await check_wechat_login();
if (!is_valid) {
// 微信登录已过期,需要重新登录
clear_login_state();
return false;
}
// 检查本地存储的登录状态
return check_login_status();
} catch (error) {
console.error('刷新登录状态失败:', error);
return false;
}
};

View File

@@ -0,0 +1,72 @@
import httpService from './httpService'
import type { ApiResponse } from './httpService'
// 用户接口
export interface PublishBallData {
title: string // 球局标题
image_list: Array<string>[] // 球局封面
start_time: string,
end_time: string
play_type: string // 玩法类型
price: number | string // 价格
venue_id?: number | null // 场地id
location_name?: string // 场地名称
location?: string // 场地地址
latitude?: string // 纬度
longitude?: string // 经度
court_type?: string // 场地类型 1: 室内 2: 室外
court_surface?: string // 场地表面 1: 硬地 2: 红土 3: 草地
venue_description_tag?: Array<string>[] // 场地描述标签
venue_description?: string // 场地描述
venue_image_list?: Array<string>[] // 场地图片
max_players: number // 人数要求
current_players: number // 人数要求
skill_level_min: number // 水平要求(NTRP)
skill_level_max: number // 水平要求(NTRP)
description: string // 备注
description_tag: Array<string>[] // 备注标签
is_substitute_supported: boolean // 是否支持替补
is_wechat_contact: boolean // 是否需要微信联系
wechat_contact?: string // 微信联系
}
// 响应接口
export interface Response {
code: string
message: string
data: any
}
// 响应接口
export interface StadiumListResponse {
rows: Stadium[]
}
export interface Stadium {
id?: string
name: string
address?: string
istance?: string
longitude?: number
latitude?: number
}
// 发布球局类
class PublishService {
// 发布
async createPersonal(data: PublishBallData): Promise<ApiResponse<Response>> {
return httpService.post('/games/create', data, {
showLoading: true,
loadingText: '发布中...'
})
}
// 获取球场列表
async getStadiumList(data: { seachOption: { latitude: number, longitude: number }}): Promise<ApiResponse<StadiumListResponse>> {
return httpService.post('/venues/list', data, {
showLoading: false })
}
}
// 导出认证服务实例
export default new PublishService()

3
src/static/login/bro.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 MiB

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 2H7C6.17157 2 5.5 2.67157 5.5 3.5V20.5C5.5 21.3284 6.17157 22 7 22H17C17.8284 22 18.5 21.3284 18.5 20.5V3.5C18.5 2.67157 17.8284 2 17 2Z" stroke="white" stroke-width="2"/>
<path d="M11 5H13" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 19H14" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 489 B

View File

@@ -0,0 +1,22 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.52727 11.4372C1.52727 13.7337 2.74195 15.8001 4.64273 17.1971C4.79612 17.307 4.89562 17.4893 4.89562 17.6945C4.89562 17.7609 4.88111 17.8251 4.86452 17.8894C4.71321 18.4635 4.47068 19.3837 4.45823 19.4252C4.4396 19.4977 4.41056 19.5723 4.41056 19.649C4.41056 19.8169 4.54532 19.9537 4.71113 19.9537C4.77746 19.9537 4.82929 19.9288 4.88523 19.8977L6.85857 18.7412C7.00781 18.6542 7.16535 18.6003 7.3374 18.6003C7.43066 18.6003 7.51774 18.6148 7.60271 18.6397C8.52305 18.9091 9.51592 19.0583 10.5461 19.0583C15.5251 19.0583 19.5629 15.6468 19.5629 11.4393C19.5629 7.22981 15.5251 3.81824 10.5461 3.81824C5.56306 3.81617 1.52727 7.22772 1.52727 11.4372Z" fill="white"/>
<path d="M1.52727 11.4372C1.52727 13.7337 2.74195 15.8001 4.64273 17.1971C4.79612 17.307 4.89562 17.4893 4.89562 17.6945C4.89562 17.7609 4.88111 17.8251 4.86452 17.8894C4.71321 18.4635 4.47068 19.3837 4.45823 19.4252C4.4396 19.4977 4.41056 19.5723 4.41056 19.649C4.41056 19.8169 4.54532 19.9537 4.71113 19.9537C4.77746 19.9537 4.82929 19.9288 4.88523 19.8977L6.85857 18.7412C7.00781 18.6542 7.16535 18.6003 7.3374 18.6003C7.43066 18.6003 7.51774 18.6148 7.60271 18.6397C8.52305 18.9091 9.51592 19.0583 10.5461 19.0583C15.5251 19.0583 19.5629 15.6468 19.5629 11.4393C19.5629 7.22981 15.5251 3.81824 10.5461 3.81824C5.56513 3.81617 1.52727 7.22772 1.52727 11.4372Z" fill="url(#paint0_linear_3043_3069)"/>
<path d="M11.4064 17.0934C11.4064 20.5982 14.7686 23.4377 18.9142 23.4377C19.7703 23.4377 20.5973 23.3133 21.3643 23.0895C21.4348 23.0688 21.5073 23.0563 21.584 23.0563C21.727 23.0563 21.8597 23.0999 21.982 23.1724L23.6257 24.1341C23.6713 24.1611 23.7149 24.1818 23.7708 24.1818C23.9097 24.1818 24.0217 24.0678 24.0217 23.9289C24.0217 23.8667 23.9968 23.8025 23.9823 23.7424C23.9719 23.7071 23.7708 22.9403 23.6444 22.4615C23.6299 22.4076 23.6174 22.3558 23.6174 22.2998C23.6174 22.1299 23.7004 21.9786 23.8268 21.8874C25.4104 20.7246 26.422 19.0023 26.422 17.0913C26.422 13.5865 23.0599 10.7469 18.9142 10.7469C14.7665 10.749 11.4064 13.5885 11.4064 17.0934Z" fill="url(#paint1_linear_3043_3069)"/>
<path d="M20.4936 15.1576C20.4936 15.7213 20.9434 16.1773 21.4989 16.1773C22.0543 16.1773 22.5041 15.7213 22.5041 15.1576C22.5041 14.5938 22.0543 14.1378 21.4989 14.1378C20.9434 14.1378 20.4936 14.5938 20.4936 15.1576Z" fill="#919191"/>
<path d="M15.4691 15.1576C15.4691 15.7213 15.9189 16.1773 16.4743 16.1773C17.0298 16.1773 17.4796 15.7213 17.4796 15.1576C17.4796 14.5938 17.0298 14.1378 16.4743 14.1378C15.9189 14.1378 15.4691 14.5938 15.4691 15.1576Z" fill="#919191"/>
<path d="M8.73453 8.98739C8.73453 9.66308 8.19558 10.2103 7.52814 10.2103C6.86276 10.2103 6.32175 9.66308 6.32175 8.98739C6.32175 8.31172 6.8607 7.76453 7.52814 7.76453C8.19558 7.76453 8.73453 8.31172 8.73453 8.98739Z" fill="#168743"/>
<path d="M14.7643 8.98739C14.7643 9.66308 14.2233 10.2103 13.5579 10.2103C12.8926 10.2103 12.3515 9.66308 12.3515 8.98739C12.3515 8.31172 12.8926 7.76453 13.5579 7.76453C14.2233 7.76453 14.7643 8.31172 14.7643 8.98739Z" fill="#168743"/>
<defs>
<linearGradient id="paint0_linear_3043_3069" x1="10.5438" y1="19.9515" x2="10.5438" y2="3.81677" gradientUnits="userSpaceOnUse">
<stop offset="0.0602" stop-color="#05CD66"/>
<stop offset="0.2202" stop-color="#0ED169"/>
<stop offset="0.4805" stop-color="#26DB6F"/>
<stop offset="0.8069" stop-color="#4DEB7A"/>
<stop offset="0.9517" stop-color="#61F380"/>
</linearGradient>
<linearGradient id="paint1_linear_3043_3069" x1="18.9143" y1="24.184" x2="18.9143" y2="10.7483" gradientUnits="userSpaceOnUse">
<stop offset="0.081" stop-color="#D9D9D9"/>
<stop offset="1" stop-color="#F0F0F0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

13
src/static/login/yc.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg width="152" height="58" viewBox="0 0 152 58" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.928 30.4C9.072 26.432 13.904 21.904 17.424 16.816H6.528L7.008 12.496H20.016C20.72 11.056 21.344 9.632 21.888 8.224H27.072C26.368 10.016 25.776 11.44 25.296 12.496H45.12L44.592 16.816H22.992C22.576 17.552 21.712 18.848 20.4 20.704H36.384C37.632 20.704 38.576 21.008 39.216 21.616C39.856 22.224 40.176 23.088 40.176 24.208C40.176 24.624 40.16 24.944 40.128 25.168L38.112 45.232C37.952 46.704 37.552 47.792 36.912 48.496C36.272 49.2 35.328 49.552 34.08 49.552C32.832 49.552 31.264 49.344 29.376 48.928C27.872 48.608 26.672 48.288 25.776 47.968L26.256 43.984C28.048 44.496 30.144 44.928 32.544 45.28H32.736C32.992 45.28 33.168 45.232 33.264 45.136C33.392 45.008 33.488 44.784 33.552 44.464L33.936 40.24H15.696L14.688 49.168H9.936L12.24 29.2C10.832 30.384 9.184 31.616 7.296 32.896L2.928 30.4ZM35.424 24.736H17.472L16.992 28.672H35.04L35.424 24.736ZM34.656 32.512H16.56L16.128 36.4H34.32L34.656 32.512ZM61.424 37.744C59.632 38.448 57.088 39.312 53.792 40.336C50.688 41.264 48.16 41.984 46.208 42.496L46.784 37.792C48.64 37.312 50.432 36.816 52.16 36.304L53.792 22.384H48.416L48.944 18.064H54.32L55.472 8.56H59.984L58.832 18.064H63.44L62.912 22.384H58.304L56.816 34.96C59.312 34.224 61.008 33.68 61.904 33.328L61.424 37.744ZM65.648 27.136C64.816 27.136 64.16 26.944 63.68 26.56C63.2 26.144 62.96 25.664 62.96 25.12C62.96 24.192 63.536 23.424 64.688 22.816C66.864 21.568 69.264 20.128 71.888 18.496C74.672 16.8 76.88 15.36 78.512 14.176H65.408L65.984 9.856H83.36C84.32 9.856 85.056 10.096 85.568 10.576C86.112 11.056 86.384 11.648 86.384 12.352C86.384 13.312 85.888 14.16 84.896 14.896C81.056 17.776 76.832 20.432 72.224 22.864H83.312C86.448 22.864 87.856 24.464 87.536 27.664L87.056 32.032C86.928 33.024 86.72 34.72 86.432 37.12C85.952 40.928 85.52 43.824 85.136 45.808C84.72 48.208 83.36 49.408 81.056 49.408C80.8 49.408 80.416 49.376 79.904 49.312C78.848 49.12 77.856 48.88 76.928 48.592C75.392 48.112 74.416 47.776 74 47.584L74.48 43.072C75.856 43.552 77.488 44.032 79.376 44.512L80 44.608C80.224 44.608 80.368 44.544 80.432 44.416C80.496 44.256 80.576 43.952 80.672 43.504C81.024 41.712 81.408 39.136 81.824 35.776C82.24 32.544 82.544 29.664 82.736 27.136H80.528C79.408 32.064 77.744 36.4 75.536 40.144C73.328 43.856 70.432 47.024 66.848 49.648L62.816 47.392C69.92 42.24 74.416 35.488 76.304 27.136H72.848C71.504 31.36 69.568 35.216 67.04 38.704C64.512 42.16 61.52 45.024 58.064 47.296L54.464 44.944C57.632 42.832 60.496 40.16 63.056 36.928C65.552 33.824 67.376 30.56 68.528 27.136H65.648Z" fill="white"/>
<g clip-path="url(#clip0_3043_3081)">
<path d="M128 49C139.046 49 148 40.0457 148 29C148 17.9543 139.046 9 128 9C116.954 9 108 17.9543 108 29C108 40.0457 116.954 49 128 49Z" stroke="white" stroke-width="4"/>
<path d="M128 9C127.899 15.6682 126.262 20.6696 123.088 24.0044C119.915 27.3391 114.885 29.0071 108 29.0083" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M147.968 30.0052C141.456 29.5585 136.479 30.9505 133.039 34.1812C129.599 37.4119 127.921 42.3515 128.003 48.9999" stroke="white" stroke-width="4" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_3043_3081">
<rect width="48" height="48" fill="white" transform="translate(104 5)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.95825 2.5L6.45825 5L3.95825 7.5" stroke="black" stroke-opacity="0.8" stroke-width="0.833333" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 257 B

View File

@@ -0,0 +1,5 @@
<svg width="16" height="17" viewBox="0 0 16 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7.26661 7.23309C7.26661 6.90172 6.99798 6.63309 6.66661 6.63309C6.33524 6.63309 6.06661 6.90172 6.06661 7.23309V11.8998C6.06661 12.2311 6.33524 12.4998 6.66661 12.4998C6.99798 12.4998 7.26661 12.2311 7.26661 11.8998V7.23309Z" fill="black"/>
<path d="M9.33331 6.63309C9.66468 6.63309 9.93331 6.90172 9.93331 7.23309V11.8998C9.93331 12.2311 9.66468 12.4998 9.33331 12.4998C9.00194 12.4998 8.73331 12.2311 8.73331 11.8998V7.23309C8.73331 6.90172 9.00194 6.63309 9.33331 6.63309Z" fill="black"/>
<path d="M4.97805 3.13887C5.08566 2.53447 5.6112 2.09424 6.2251 2.09424H9.77486C10.3888 2.09424 10.9143 2.53447 11.0219 3.13887L11.1979 4.12753L13.5 4.12753C13.8313 4.12753 14.1 4.39616 14.1 4.72753C14.1 5.0589 13.8313 5.32753 13.5 5.32753H12.7317L12.2463 13.3242C12.1844 14.3446 11.3388 15.1404 10.3165 15.1404H5.68341C4.66115 15.1404 3.81556 14.3446 3.75363 13.3242L3.26828 5.32753H2.49996C2.16859 5.32753 1.89996 5.0589 1.89996 4.72753C1.89996 4.39616 2.16859 4.12753 2.49996 4.12753H4.80202L4.97805 3.13887ZM9.97907 4.12753L9.8405 3.34922C9.83483 3.31741 9.80717 3.29424 9.77486 3.29424H6.2251C6.19279 3.29424 6.16513 3.31741 6.15947 3.34922L6.0209 4.12753L9.97907 4.12753ZM4.47083 5.33309L4.95142 13.2515C4.97491 13.6386 5.29565 13.9404 5.68341 13.9404H10.3165C10.7043 13.9404 11.025 13.6386 11.0485 13.2515L11.5291 5.33309H4.47083Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Some files were not shown because too many files have changed in this diff Show More