Merge branch master into feature/juguohong/20250816
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
|
||||
'pages/home/index', //中转页
|
||||
|
||||
'pages/login/index/index',
|
||||
'pages/login/verification/index',
|
||||
'pages/login/terms/index',
|
||||
@@ -12,15 +14,25 @@ export default defineAppConfig({
|
||||
'pages/detail/index',
|
||||
'pages/message/index',
|
||||
'pages/orderCheck/index',
|
||||
|
||||
'pages/userInfo/myself/index', // 个人中心
|
||||
'pages/userInfo/edit/index', // 个人中心
|
||||
'pages/userInfo/favorites/index', // 个人中心
|
||||
'pages/userInfo/orders/index', // 个人中心
|
||||
|
||||
|
||||
// 'pages/mapDisplay/index',
|
||||
|
||||
],
|
||||
|
||||
subPackages: [
|
||||
{
|
||||
root: "mod_user",
|
||||
pages: [
|
||||
'pages/myself/index', // 个人中心
|
||||
'pages/edit/index', // 个人中心
|
||||
'pages/favorites/index', // 个人中心
|
||||
'pages/orders/index', // 个人中心
|
||||
|
||||
]
|
||||
|
||||
}
|
||||
],
|
||||
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
|
||||
@@ -32,17 +32,17 @@ export default function withAuth<P extends object>(WrappedComponent: React.Compo
|
||||
|
||||
if (!is_login) {
|
||||
const currentPage = getCurrentFullPath()
|
||||
Taro.redirectTo({
|
||||
url: `/pages/login/index/index${
|
||||
currentPage ? `?redirect=${encodeURIComponent(currentPage)}` : ''
|
||||
}`,
|
||||
})
|
||||
// Taro.redirectTo({
|
||||
// url: `/pages/login/index/index${
|
||||
// currentPage ? `?redirect=${encodeURIComponent(currentPage)}` : ''
|
||||
// }`,
|
||||
// })
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!authed) {
|
||||
return <View style={{ width: '100vh', height: '100vw', backgroundColor: 'white', position: 'fixed', top: 0, left: 0, zIndex: 999 }} /> // 空壳,避免 children 渲染出错
|
||||
}
|
||||
// if (!authed) {
|
||||
// return <View style={{ width: '100vh', height: '100vw', backgroundColor: 'white', position: 'fixed', top: 0, left: 0, zIndex: 999 }} /> // 空壳,避免 children 渲染出错
|
||||
// }
|
||||
|
||||
return <WrappedComponent {...props} />
|
||||
}
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
import images from '@/config/images'
|
||||
interface CalendarCardProps {
|
||||
value?: Date
|
||||
minDate?: Date
|
||||
maxDate?: Date
|
||||
onChange?: (date: Date) => void
|
||||
onNext?: (date: Date) => void
|
||||
onHeaderClick?: (date: Date) => void
|
||||
}
|
||||
|
||||
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
const endOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||
const addMonths = (date: Date, delta: number) => new Date(date.getFullYear(), date.getMonth() + delta, 1)
|
||||
|
||||
const formatHeader = (date: Date) => `${date.getMonth() + 1}月 ${date.getFullYear()}`
|
||||
|
||||
const CalendarCard: React.FC<CalendarCardProps> = ({
|
||||
value,
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onHeaderClick
|
||||
}) => {
|
||||
const today = new Date()
|
||||
const [current, setCurrent] = useState<Date>(value || startOfMonth(today))
|
||||
const [selected, setSelected] = useState<Date>(value || today)
|
||||
|
||||
|
||||
const firstDay = useMemo(() => startOfMonth(current), [current])
|
||||
const lastDay = useMemo(() => endOfMonth(current), [current])
|
||||
|
||||
const days = useMemo(() => {
|
||||
const startWeekday = firstDay.getDay() // 0 周日
|
||||
const prevPadding = startWeekday // 周日为第一列
|
||||
const total = prevPadding + lastDay.getDate()
|
||||
const rows = Math.ceil(total / 7)
|
||||
const grid: (Date | null)[] = []
|
||||
for (let i = 0; i < rows * 7; i++) {
|
||||
const day = i - prevPadding + 1
|
||||
if (day < 1 || day > lastDay.getDate()) {
|
||||
grid.push(null)
|
||||
} else {
|
||||
grid.push(new Date(current.getFullYear(), current.getMonth(), day))
|
||||
}
|
||||
}
|
||||
return grid
|
||||
}, [firstDay, lastDay, current])
|
||||
|
||||
const isDisabled = (d: Date) => {
|
||||
if (minDate && d < minDate) return true
|
||||
if (maxDate && d > maxDate) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const gotoMonth = (delta: number) => setCurrent(prev => addMonths(prev, delta))
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
onHeaderClick && onHeaderClick(current)
|
||||
}
|
||||
|
||||
const handleSelectDay = (d: Date | null) => {
|
||||
if (!d || isDisabled(d)) return
|
||||
setSelected(d)
|
||||
onChange && onChange(d)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<View className={styles['calendar-card']}>
|
||||
<View className={styles['header']}>
|
||||
<View className={styles['header-left']} onClick={handleHeaderClick}>
|
||||
<Text className={styles['header-text']}>{formatHeader(current)}</Text>
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['month-arrow']}}`} onClick={() => gotoMonth(1)} />
|
||||
</View>
|
||||
<View className={styles['header-actions']}>
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']} ${styles['left']}`} onClick={() => gotoMonth(-1)} />
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']}}`} onClick={() => gotoMonth(1)} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={styles['week-row']}>
|
||||
{['周日','周一','周二','周三','周四','周五','周六'].map((w) => (
|
||||
<Text key={w} className={styles['week-item']}>{w}</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className={styles['grid']}>
|
||||
{days.map((d, idx) => {
|
||||
const isSelected = !!(d && selected && d.toDateString() === new Date(selected.getFullYear(), selected.getMonth(), selected.getDate()).toDateString())
|
||||
return (
|
||||
<View
|
||||
key={idx}
|
||||
className={`${styles['cell']} ${!d ? styles['empty'] : ''} ${d && isDisabled(d) ? styles['disabled'] : ''} `}
|
||||
onClick={() => handleSelectDay(d)}
|
||||
>
|
||||
{d ? <Text className={`${styles['cell-text']} ${isSelected ? styles['selected'] : ''}`}>{d.getDate()}</Text> : null}
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
|
||||
|
||||
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default CalendarCard
|
||||
@@ -1,130 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import CommonPopup from '@/components/CommonPopup'
|
||||
import CalendarCard from './CalendarCard'
|
||||
import DateTimePicker from '@/components/DateTimePicker'
|
||||
import HourMinutePicker from '@/components/HourMinutePicker'
|
||||
export interface DialogCalendarCardProps {
|
||||
value?: Date
|
||||
minDate?: Date
|
||||
maxDate?: Date
|
||||
onChange?: (date: Date) => void
|
||||
onNext?: (date: Date) => void
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title?: React.ReactNode
|
||||
}
|
||||
|
||||
const DialogCalendarCard: React.FC<DialogCalendarCardProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
value,
|
||||
minDate,
|
||||
maxDate,
|
||||
onChange,
|
||||
onNext
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<Date>(value || new Date())
|
||||
const [type, setType] = useState<'year' | 'month' | 'time'>('year');
|
||||
const [selectedHour, setSelectedHour] = useState(8)
|
||||
const [selectedMinute, setSelectedMinute] = useState(0)
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (type === 'year') {
|
||||
// 年份选择完成后,进入月份选择
|
||||
setType('time')
|
||||
} else if (type === 'month') {
|
||||
// 月份选择完成后,进入时间选择
|
||||
setType('year')
|
||||
} else if (type === 'time') {
|
||||
// 时间选择完成后,调用onNext回调
|
||||
const finalDate = new Date(selected.getFullYear(), selected.getMonth(), selected.getDate(), selectedHour, selectedMinute)
|
||||
console.log('finalDate', finalDate)
|
||||
if (onChange) onChange(finalDate)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (d: Date) => {
|
||||
console.log('handleChange', d)
|
||||
setSelected(d)
|
||||
// if (onChange) onChange(d)
|
||||
}
|
||||
const onHeaderClick = (date: Date) => {
|
||||
console.log('onHeaderClick', date)
|
||||
setSelected(date)
|
||||
setType('month')
|
||||
}
|
||||
const getConfirmText = () => {
|
||||
if (type === 'time' || type === 'month') return '完成'
|
||||
return '下一步'
|
||||
}
|
||||
const handleDateTimePickerChange = (year: number, month: number) => {
|
||||
console.log('year', year)
|
||||
console.log('month', month)
|
||||
setSelected(new Date(year, month - 1, 1))
|
||||
}
|
||||
const dialogClose = () => {
|
||||
if (type === 'month') {
|
||||
setType('year')
|
||||
} else if (type === 'time') {
|
||||
setType('year')
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
setSelected(value || new Date())
|
||||
if (visible) {
|
||||
setType('year')
|
||||
setSelectedHour(8)
|
||||
setSelectedMinute(0)
|
||||
}
|
||||
}, [value, visible])
|
||||
|
||||
|
||||
return (
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={dialogClose}
|
||||
showHeader={!!title}
|
||||
title={title}
|
||||
hideFooter={false}
|
||||
cancelText='取消'
|
||||
confirmText={getConfirmText()}
|
||||
onConfirm={handleConfirm}
|
||||
position='bottom'
|
||||
round
|
||||
zIndex={1000}
|
||||
>
|
||||
{
|
||||
type === 'year' && <CalendarCard
|
||||
value={selected}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
onChange={handleChange}
|
||||
onHeaderClick={onHeaderClick}
|
||||
/>
|
||||
}
|
||||
{
|
||||
type === 'month' && <DateTimePicker
|
||||
onChange={handleDateTimePickerChange}
|
||||
defaultYear={selected.getFullYear()}
|
||||
defaultMonth={selected.getMonth() + 1}
|
||||
/>
|
||||
}
|
||||
{
|
||||
type === 'time' && <HourMinutePicker
|
||||
onChange={(hour, minute) => {
|
||||
setSelectedHour(hour)
|
||||
setSelectedMinute(minute)
|
||||
}}
|
||||
defaultHour={selectedHour}
|
||||
defaultMinute={selectedMinute}
|
||||
/>
|
||||
}
|
||||
</CommonPopup>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogCalendarCard
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default } from './CalendarCard'
|
||||
export { default as DialogCalendarCard } from './DialogCalendarCard'
|
||||
@@ -75,7 +75,7 @@
|
||||
height: 44px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
background: #000;
|
||||
border-radius: 12px;
|
||||
border-radius: 12px!important;
|
||||
padding: 4px 10px;
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, PickerView, PickerViewColumn } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
onChange: (year: number, month: number) => void
|
||||
defaultYear?: number
|
||||
defaultMonth?: number
|
||||
minYear?: number
|
||||
maxYear?: number
|
||||
}
|
||||
|
||||
const DateTimePicker: React.FC<DateTimePickerProps> = ({
|
||||
|
||||
onChange,
|
||||
defaultYear = new Date().getFullYear(),
|
||||
defaultMonth = new Date().getMonth() + 1,
|
||||
minYear = 2020,
|
||||
maxYear = 2030
|
||||
}) => {
|
||||
console.log('defaultYear', defaultYear)
|
||||
console.log('defaultMonth', defaultMonth)
|
||||
const [selectedYear, setSelectedYear] = useState(defaultYear)
|
||||
const [selectedMonth, setSelectedMonth] = useState(defaultMonth)
|
||||
|
||||
// 计算当前选项在数组中的索引
|
||||
const getYearIndex = (year: number) => year - minYear
|
||||
const getMonthIndex = (month: number) => month - 1
|
||||
|
||||
// 生成多列选择器的选项数据
|
||||
const pickerOptions = [
|
||||
// 年份列
|
||||
Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
|
||||
text: `${minYear + index}年`,
|
||||
value: minYear + index
|
||||
})),
|
||||
// 月份列
|
||||
Array.from({ length: 12 }, (_, index) => ({
|
||||
text: `${index + 1}月`,
|
||||
value: index + 1
|
||||
}))
|
||||
]
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedYear(defaultYear)
|
||||
setSelectedMonth(defaultMonth)
|
||||
}, [ defaultYear, defaultMonth])
|
||||
|
||||
const handlePickerChange = (event: any) => {
|
||||
const values = event.detail.value
|
||||
if (values && values.length >= 2) {
|
||||
// 根据索引获取实际值
|
||||
const yearIndex = values[0]
|
||||
const monthIndex = values[1]
|
||||
const year = minYear + yearIndex
|
||||
const month = monthIndex + 1
|
||||
setSelectedYear(year)
|
||||
setSelectedMonth(month)
|
||||
onChange(year, month)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles['date-time-picker-overlay']} >
|
||||
<View className={styles['date-time-picker-popup']} onClick={(e) => e.stopPropagation()}>
|
||||
{/* 拖拽手柄 */}
|
||||
<View className={styles['drag-handle']} />
|
||||
|
||||
{/* 时间选择器 */}
|
||||
<View className={styles['picker-container']}>
|
||||
{/* 多列选择器 */}
|
||||
<View className={styles['picker-wrapper']}>
|
||||
<PickerView
|
||||
value={[getYearIndex(selectedYear), getMonthIndex(selectedMonth)]}
|
||||
onChange={handlePickerChange}
|
||||
className={styles['multi-column-picker']}
|
||||
>
|
||||
<PickerViewColumn className={styles['picker-column']}>
|
||||
{pickerOptions[0].map((option, index) => (
|
||||
<View key={option.value} className={styles['picker-item']}>
|
||||
<Text className={styles['picker-item-text']}>{option.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</PickerViewColumn>
|
||||
<PickerViewColumn className={styles['picker-column']}>
|
||||
{pickerOptions[1].map((option, index) => (
|
||||
<View key={option.value} className={styles['picker-item']}>
|
||||
<Text className={styles['picker-item-text']}>{option.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
</PickerViewColumn>
|
||||
</PickerView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateTimePicker
|
||||
@@ -1,89 +0,0 @@
|
||||
/* 日期选择器弹出层样式 */
|
||||
.date-time-picker-popup {
|
||||
.common-popup-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-handle {
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.picker-container {
|
||||
padding: 26px 16px 0 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.picker-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.picker-title {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.multi-column-picker {
|
||||
width: 100%;
|
||||
height: 216px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: none;
|
||||
/* 自定义选择器样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 选中项指示器 */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: rgba(22, 24, 35, 0.05);
|
||||
transform: translateY(-50%);
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
color: #161823;
|
||||
transition: all 0.3s ease;
|
||||
&.picker-item-active {
|
||||
color: rgba(22, 24, 35, 0.05);
|
||||
font-weight: 600;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.picker-column {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picker-item-text {
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
import DateTimePicker from './DateTimePicker'
|
||||
export default DateTimePicker
|
||||
@@ -82,10 +82,11 @@
|
||||
|
||||
// 内容区域
|
||||
.modal_content {
|
||||
padding: 0px 16px 20px;
|
||||
padding: 16px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.input_container {
|
||||
display: flex;
|
||||
@@ -96,7 +97,13 @@
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
box-shadow: 0px 4px 36px 0px rgba(0, 0, 0, 0.06);
|
||||
min-height: 120px;
|
||||
|
||||
|
||||
// 名字输入时的容器样式
|
||||
&:has(.nickname_input) {
|
||||
min-height: 40px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
|
||||
.text_input {
|
||||
flex: 1;
|
||||
@@ -109,11 +116,21 @@
|
||||
background: transparent;
|
||||
outline: none;
|
||||
resize: none;
|
||||
min-height: 80px;
|
||||
|
||||
min-height: 120px;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(60, 60, 67, 0.3);
|
||||
}
|
||||
|
||||
// 名字输入特殊样式
|
||||
&.nickname_input {
|
||||
min-height: 80px;
|
||||
min-height: 20px;
|
||||
height: 20px;
|
||||
line-height: 20px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.char_count {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Textarea, Button } from '@tarojs/components';
|
||||
import { View, Text, Textarea, Input, Picker } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './EditModal.scss';
|
||||
|
||||
interface EditModalProps {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
type: string;
|
||||
placeholder: string;
|
||||
initialValue: string;
|
||||
maxLength: number;
|
||||
@@ -17,6 +18,7 @@ interface EditModalProps {
|
||||
const EditModal: React.FC<EditModalProps> = ({
|
||||
visible,
|
||||
title,
|
||||
type,
|
||||
placeholder,
|
||||
initialValue,
|
||||
maxLength,
|
||||
@@ -82,17 +84,34 @@ const EditModal: React.FC<EditModalProps> = ({
|
||||
<View className="modal_content">
|
||||
{/* 文本输入区域 */}
|
||||
<View className="input_container">
|
||||
<Textarea
|
||||
className="text_input"
|
||||
|
||||
{type === 'nickname' ? (
|
||||
<Input
|
||||
className="text_input nickname_input"
|
||||
value={value}
|
||||
type="nickname"
|
||||
placeholder={placeholder}
|
||||
maxlength={maxLength}
|
||||
onInput={handle_input_change}
|
||||
adjustPosition={true}
|
||||
confirmType="done"
|
||||
autoFocus={true}
|
||||
/>
|
||||
<View className="char_count">
|
||||
<Text className="count_text">{value.length}/{maxLength}</Text>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Textarea
|
||||
className="text_input"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
maxlength={maxLength}
|
||||
onInput={handle_input_change}
|
||||
autoFocus={true}
|
||||
/>
|
||||
<View className="char_count">
|
||||
<Text className="count_text">{value.length}/{maxLength}</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 验证提示 */}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Popup } from "@nutui/nutui-react-taro";
|
||||
import Range from "../../components/Range";
|
||||
import Bubble from "../../components/Bubble";
|
||||
@@ -5,15 +6,14 @@ import styles from "./index.module.scss";
|
||||
import { Button } from "@nutui/nutui-react-taro";
|
||||
import { useListStore } from "src/store/listStore";
|
||||
import { BubbleOption, FilterPopupProps } from "../../../types/list/types";
|
||||
import CalendarCard from "@/components/CalendarCard/index";
|
||||
import dateRangeUtils from '@/utils/dateRange'
|
||||
|
||||
import dayjs from "dayjs";
|
||||
import { CalendarUI } from "@/components";
|
||||
// 场地
|
||||
import CourtType from "@/components/CourtType";
|
||||
// 玩法
|
||||
import GamePlayType from "@/components/GamePlayType";
|
||||
import { useDictionaryActions } from "@/store/dictionaryStore";
|
||||
import { useMemo } from "react";
|
||||
import { View } from "@tarojs/components";
|
||||
|
||||
|
||||
@@ -44,6 +44,14 @@ const FilterPopup = (props: FilterPopupProps) => {
|
||||
* @param dictionaryValue 字典选项
|
||||
* @returns 选项列表
|
||||
*/
|
||||
const [selectedDates, setSelectedDates] = useState<String[]>([])
|
||||
|
||||
const handleDateChange = (dates: Date[]) => {
|
||||
const dateArray = dates.map(date => dayjs(date).format('YYYY-MM-DD'))
|
||||
setSelectedDates(dateArray)
|
||||
console.log('选中的日期:', dateArray)
|
||||
}
|
||||
|
||||
const handleOptions = (dictionaryValue: []) => {
|
||||
return dictionaryValue?.map((item) => ({ label: item, value: item })) || [];
|
||||
};
|
||||
@@ -131,13 +139,15 @@ const FilterPopup = (props: FilterPopupProps) => {
|
||||
name="dateRangeQuick"
|
||||
/>
|
||||
</View>
|
||||
<CalendarCard
|
||||
value={filterOptions?.dateRange?.[0]}
|
||||
// minDate={}
|
||||
// maxDate={}
|
||||
onChange={handleDateRangeChange} />
|
||||
<CalendarUI
|
||||
type="multiple"
|
||||
isBorder={true}
|
||||
value={selectedDates}
|
||||
onChange={handleDateChange}
|
||||
/>
|
||||
</View>
|
||||
{/* 时间气泡选项 */}
|
||||
|
||||
<Bubble
|
||||
options={timeBubbleData}
|
||||
value={filterOptions?.timeSlot}
|
||||
|
||||
@@ -37,7 +37,7 @@ const GuideBar = (props) => {
|
||||
|
||||
let url = `/pages/${code}/index`
|
||||
if (code === 'personal') {
|
||||
url = '/pages/userInfo/myself/index'
|
||||
url = '/mod_user/pages/myself/index'
|
||||
}
|
||||
Taro.redirectTo({
|
||||
url: url,
|
||||
|
||||
151
src/components/Picker/CalendarDialog/DialogCalendarCard.tsx
Normal file
151
src/components/Picker/CalendarDialog/DialogCalendarCard.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import CommonPopup from '@/components/CommonPopup'
|
||||
import { View } from '@tarojs/components'
|
||||
import CalendarUI, { CalendarUIRef } from '@/components/Picker/CalendarUI/CalendarUI'
|
||||
import { PickerCommon, PickerCommonRef } from '@/components/Picker'
|
||||
import dayjs from 'dayjs'
|
||||
import styles from './index.module.scss'
|
||||
export interface DialogCalendarCardProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date) => void
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
title?: React.ReactNode
|
||||
}
|
||||
|
||||
const DialogCalendarCard: React.FC<DialogCalendarCardProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
title,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [selected, setSelected] = useState<Date>(value || new Date())
|
||||
const calendarRef = useRef<CalendarUIRef>(null);
|
||||
const [type, setType] = useState<'year' | 'month' | 'time'>('year');
|
||||
const [selectedHour, setSelectedHour] = useState(8)
|
||||
const [selectedMinute, setSelectedMinute] = useState(0)
|
||||
const pickerRef = useRef<PickerCommonRef>(null);
|
||||
const hourMinutePickerRef = useRef<PickerCommonRef>(null);
|
||||
const [pendingJump, setPendingJump] = useState<{ year: number; month: number } | null>(null)
|
||||
const handleConfirm = () => {
|
||||
if (type === 'year') {
|
||||
// 年份选择完成后,进入月份选择
|
||||
setType('time')
|
||||
} else if (type === 'month') {
|
||||
// 月份选择完成后,进入时间选择
|
||||
const value = pickerRef.current?.getValue()
|
||||
if (value) {
|
||||
const year = value[0] as number
|
||||
const month = value[1] as number
|
||||
setSelected(new Date(year, month - 1, 1))
|
||||
setPendingJump({ year, month })
|
||||
}
|
||||
setType('year')
|
||||
} else if (type === 'time') {
|
||||
// 时间选择完成后,调用onNext回调
|
||||
const value = hourMinutePickerRef.current?.getValue()
|
||||
if (value) {
|
||||
const hour = value[0] as number
|
||||
const minute = value[1] as number
|
||||
setSelectedHour(hour)
|
||||
setSelectedMinute(minute)
|
||||
const finalDate = new Date(dayjs(selected).format('YYYY-MM-DD') + ' ' + hour + ':' + minute)
|
||||
if (onChange) onChange(finalDate)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (d: Date | Date[]) => {
|
||||
if (Array.isArray(d)) {
|
||||
setSelected(d[0])
|
||||
} else {
|
||||
setSelected(d)
|
||||
}
|
||||
}
|
||||
const onHeaderClick = (date: Date) => {
|
||||
setSelected(date)
|
||||
setType('month')
|
||||
}
|
||||
const getConfirmText = () => {
|
||||
if (type === 'time' || type === 'month') return '完成'
|
||||
return '下一步'
|
||||
}
|
||||
const handleDateTimePickerChange = (value: (string | number)[]) => {
|
||||
const year = value[0] as number
|
||||
const month = value[1] as number
|
||||
setSelected(new Date(year, month - 1, 1))
|
||||
}
|
||||
const dialogClose = () => {
|
||||
if (type === 'month') {
|
||||
setType('year')
|
||||
} else if (type === 'time') {
|
||||
setType('year')
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (visible && value) {
|
||||
setSelected(value || new Date())
|
||||
setSelectedHour(value ? dayjs(value).hour() : 8)
|
||||
setSelectedMinute(value ? dayjs(value).minute() : 0)
|
||||
}
|
||||
}, [value, visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'year' && pendingJump && calendarRef.current) {
|
||||
calendarRef.current.jumpTo(pendingJump.year, pendingJump.month)
|
||||
setPendingJump(null)
|
||||
}
|
||||
}, [type, pendingJump])
|
||||
|
||||
|
||||
return (
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={dialogClose}
|
||||
showHeader={!!title}
|
||||
title={title}
|
||||
hideFooter={false}
|
||||
cancelText='取消'
|
||||
confirmText={getConfirmText()}
|
||||
onConfirm={handleConfirm}
|
||||
position='bottom'
|
||||
round
|
||||
zIndex={1000}
|
||||
>
|
||||
{
|
||||
type === 'year' &&
|
||||
<View className={styles['calendar-container']}>
|
||||
<CalendarUI
|
||||
ref={calendarRef}
|
||||
value={selected}
|
||||
onChange={handleChange}
|
||||
showQuickActions={false}
|
||||
onHeaderClick={onHeaderClick}
|
||||
/></View>
|
||||
}
|
||||
{
|
||||
type === 'month' && <PickerCommon
|
||||
ref={pickerRef}
|
||||
onChange={handleDateTimePickerChange}
|
||||
type="month"
|
||||
value={[selected.getFullYear(), selected.getMonth() + 1]}
|
||||
/>
|
||||
|
||||
}
|
||||
{
|
||||
type === 'time' && <PickerCommon
|
||||
ref={hourMinutePickerRef}
|
||||
type="hour"
|
||||
value={[selectedHour, selectedMinute]}
|
||||
/>
|
||||
|
||||
}
|
||||
</CommonPopup>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogCalendarCard
|
||||
3
src/components/Picker/CalendarDialog/index.module.scss
Normal file
3
src/components/Picker/CalendarDialog/index.module.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.calendar-container{
|
||||
padding: 26px 12px 8px;
|
||||
}
|
||||
211
src/components/Picker/CalendarUI/CalendarUI.tsx
Normal file
211
src/components/Picker/CalendarUI/CalendarUI.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React, { useState, useEffect, useRef, useImperativeHandle } from 'react'
|
||||
import { CalendarCard } from '@nutui/nutui-react-taro'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import images from '@/config/images'
|
||||
import styles from './index.module.scss'
|
||||
import { getMonth, getWeekend, getWeekendOfCurrentWeek } from '@/utils/timeUtils'
|
||||
import { PopupPicker } from '@/components/Picker/index'
|
||||
interface NutUICalendarProps {
|
||||
type?: 'single' | 'range' | 'multiple'
|
||||
value?: string | Date | String[] | Date[]
|
||||
defaultValue?: string | string[]
|
||||
onChange?: (value: Date | Date[]) => void,
|
||||
isBorder?: boolean
|
||||
showQuickActions?: boolean,
|
||||
onHeaderClick?: (date: Date) => void
|
||||
}
|
||||
|
||||
export interface CalendarUIRef {
|
||||
jumpTo: (year: number, month: number) => void
|
||||
}
|
||||
|
||||
const NutUICalendar = React.forwardRef<CalendarUIRef, NutUICalendarProps>(({
|
||||
type = 'single',
|
||||
value,
|
||||
onChange,
|
||||
isBorder = false,
|
||||
showQuickActions = true,
|
||||
onHeaderClick
|
||||
}, ref) => {
|
||||
// 根据类型初始化选中值
|
||||
// const getInitialValue = (): Date | Date[] => {
|
||||
// console.log(value,defaultValue,'today')
|
||||
|
||||
// if (typeof value === 'string' && value) {
|
||||
// return new Date(value)
|
||||
// }
|
||||
// if (Array.isArray(value) && value.length > 0) {
|
||||
// return value.map(item => new Date(item))
|
||||
// }
|
||||
// if (typeof defaultValue === 'string' && defaultValue) {
|
||||
// return new Date(defaultValue)
|
||||
// }
|
||||
// if (Array.isArray(defaultValue) && defaultValue.length > 0) {
|
||||
// return defaultValue.map(item => new Date(item))
|
||||
// }
|
||||
// const today = new Date();
|
||||
// if (type === 'multiple') {
|
||||
// return [today]
|
||||
// }
|
||||
// return today
|
||||
// }
|
||||
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1)
|
||||
|
||||
const [selectedValue, setSelectedValue] = useState<Date | Date[]>()
|
||||
const [current, setCurrent] = useState<Date>(startOfMonth(new Date()))
|
||||
const calendarRef = useRef<any>(null)
|
||||
const [visible, setvisible] = useState(false)
|
||||
console.log('current', current)
|
||||
// 当外部 value 变化时更新内部状态
|
||||
useEffect(() => {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
setSelectedValue(value.map(item => new Date(item)))
|
||||
setCurrent(new Date(value[0]))
|
||||
}
|
||||
if ((typeof value === 'string' || value instanceof Date) && value) {
|
||||
setSelectedValue(new Date(value))
|
||||
setCurrent(new Date(value))
|
||||
}
|
||||
}, [value])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
jumpTo: (year: number, month: number) => {
|
||||
calendarRef.current?.jumpTo(year, month)
|
||||
}
|
||||
}))
|
||||
|
||||
const handleDateChange = (newValue: any) => {
|
||||
setSelectedValue(newValue)
|
||||
onChange?.(newValue as any)
|
||||
}
|
||||
const formatHeader = (date: Date) => `${getMonth(date)}`
|
||||
|
||||
const handlePageChange = (data: { year: number; month: number }) => {
|
||||
// 月份切换时的处理逻辑,如果需要的话
|
||||
console.log('月份切换:', data)
|
||||
}
|
||||
|
||||
const gotoMonth = (delta: number) => {
|
||||
const base = current instanceof Date ? new Date(current) : new Date()
|
||||
base.setMonth(base.getMonth() + delta)
|
||||
const next = startOfMonth(base)
|
||||
setCurrent(next)
|
||||
// 同步底部 CalendarCard 的月份
|
||||
try {
|
||||
calendarRef.current?.jump?.(delta)
|
||||
} catch (e) {
|
||||
console.warn('CalendarCardRef jump 调用失败', e)
|
||||
}
|
||||
handlePageChange({ year: next.getFullYear(), month: next.getMonth() + 1 })
|
||||
}
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
onHeaderClick && onHeaderClick(current)
|
||||
setvisible(true)
|
||||
}
|
||||
|
||||
|
||||
|
||||
const syncMonthTo = (anchor: Date) => {
|
||||
// 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta)
|
||||
const monthsDelta = (anchor.getFullYear() - current.getFullYear()) * 12 + (anchor.getMonth() - current.getMonth())
|
||||
if (monthsDelta !== 0) {
|
||||
gotoMonth(monthsDelta)
|
||||
}
|
||||
}
|
||||
const renderDay = (day: any) => {
|
||||
const { date, month, year} = day;
|
||||
const today = new Date()
|
||||
if (date === today.getDate() && month === today.getMonth() + 1 && year === today.getFullYear()) {
|
||||
return (
|
||||
<View class="day-container">
|
||||
{date}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
const selectWeekend = () => {
|
||||
const [start, end] = getWeekend()
|
||||
setSelectedValue([start, end])
|
||||
syncMonthTo(start)
|
||||
onChange?.([start, end])
|
||||
}
|
||||
const selectWeek = () => {
|
||||
const dayList = getWeekendOfCurrentWeek(7)
|
||||
setSelectedValue(dayList)
|
||||
syncMonthTo(dayList[0])
|
||||
onChange?.(dayList)
|
||||
}
|
||||
const selectMonth = () => {
|
||||
const dayList = getWeekendOfCurrentWeek(30)
|
||||
setSelectedValue(dayList)
|
||||
syncMonthTo(dayList[0])
|
||||
onChange?.(dayList)
|
||||
}
|
||||
|
||||
const handleMonthChange = (value: any) => {
|
||||
const [year, month] = value;
|
||||
const newDate = new Date(year, month - 1, 1);
|
||||
setCurrent(newDate);
|
||||
calendarRef.current?.jumpTo(year, month)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* 快速操作行 */}
|
||||
{
|
||||
showQuickActions &&
|
||||
<View className={styles['quick-actions']}>
|
||||
<View className={styles['quick-action']} onClick={selectWeekend}>本周末</View>
|
||||
<View className={styles['quick-action']} onClick={selectWeek}>一周内</View>
|
||||
<View className={styles['quick-action']} onClick={selectMonth}>一个月</View>
|
||||
</View>
|
||||
}
|
||||
<View className={`${styles['calendar-card']} ${isBorder ? styles['border'] : ''}`}>
|
||||
{/* 自定义头部显示周一到周日 */}
|
||||
<View className={styles['header']}>
|
||||
<View className={styles['header-left']} onClick={handleHeaderClick}>
|
||||
<Text className={styles['header-text']}>{formatHeader(current as Date)}</Text>
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['month-arrow']}`} onClick={() => gotoMonth(1)} />
|
||||
</View>
|
||||
<View className={styles['header-actions']}>
|
||||
<View className={styles['arrow-left-container']} onClick={() => gotoMonth(-1)}>
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']} ${styles['left']}`} />
|
||||
</View>
|
||||
<View className={styles['arrow-right-container']} onClick={() => gotoMonth(1)}>
|
||||
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']}`} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles['week-header']}>
|
||||
{[ '周日', '周一', '周二', '周三', '周四', '周五', '周六'].map((day) => (
|
||||
<Text key={day} className={styles['week-day']}>
|
||||
{day}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* NutUI CalendarCard 组件 */}
|
||||
<CalendarCard
|
||||
ref={calendarRef}
|
||||
type={type}
|
||||
value={selectedValue}
|
||||
renderDay={renderDay}
|
||||
onChange={handleDateChange}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</View>
|
||||
{ visible && <PopupPicker
|
||||
visible={visible}
|
||||
setvisible={setvisible}
|
||||
value={[current.getFullYear(), current.getMonth() + 1]}
|
||||
type="month"
|
||||
onChange={(value) => handleMonthChange(value)}/> }
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
export default NutUICalendar
|
||||
292
src/components/Picker/CalendarUI/index.module.scss
Normal file
292
src/components/Picker/CalendarUI/index.module.scss
Normal file
@@ -0,0 +1,292 @@
|
||||
.calendar-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
&.border{
|
||||
border-radius: 12px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
margin-bottom: 6px;
|
||||
padding: 12px 12px 8px;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 4px 11px 4px;
|
||||
height: 24px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.header-text {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
width: 60px;
|
||||
.arrow-left-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
width: 50%;
|
||||
flex: 1;
|
||||
}
|
||||
.arrow-right-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
width: 50%;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
.month-arrow{
|
||||
width: 8px;
|
||||
height: 24px;
|
||||
}
|
||||
.arrow {
|
||||
width: 10px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
}
|
||||
.arrow.left {
|
||||
left: 9px;
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
|
||||
.week-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
padding: 0 0 4px 0;
|
||||
}
|
||||
.week-item {
|
||||
text-align: center;
|
||||
color: rgba(60, 60, 67, 0.30);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
// 新增的周一到周日头部样式
|
||||
.week-header {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
padding: 8px 0;
|
||||
}
|
||||
.week-day {
|
||||
text-align: center;
|
||||
color: rgba(60, 60, 67, 0.30);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px 0;
|
||||
padding: 4px 0 16px;
|
||||
}
|
||||
.cell {
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.cell.empty {
|
||||
opacity: 0;
|
||||
}
|
||||
.cell.disabled {
|
||||
color: rgba(0,0,0,0.2);
|
||||
}
|
||||
.cell-text.selected {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// 时间段选择样式
|
||||
.cell-text.range-start {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell-text.range-end {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: rgba(0,0,0,0.9);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cell-text.in-range {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: rgba(0,0,0,0.1);
|
||||
color: #000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
background: rgba(0,0,0,0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.btn.primary {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hm-placeholder {
|
||||
height: 240px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
// CalendarRange 组件样式
|
||||
.calendar-range {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-select {
|
||||
display: flex;
|
||||
padding: 16px 12px 12px;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.quick-btn {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: 18px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-btn.active {
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
.quick-btn-text {
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.quick-btn.active .quick-btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.quick-action {
|
||||
border-radius: 999px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||||
background: #FFF;
|
||||
display: flex;
|
||||
height: 28px;
|
||||
padding: 4px 10px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
|
||||
// 隐藏 CalendarCard 默认头部
|
||||
:global {
|
||||
.nut-calendarcard {
|
||||
.nut-calendarcard-header {
|
||||
display: none !important;
|
||||
}
|
||||
.nut-calendarcard-content{
|
||||
.nut-calendarcard-days{
|
||||
&:first-child{
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.nut-calendarcard-day{
|
||||
margin-bottom:0px!important;
|
||||
height: 44px;
|
||||
width: 44px!important;
|
||||
&.active{
|
||||
background-color: #000!important;
|
||||
color: #fff!important;
|
||||
height: 44px;
|
||||
border-radius: 22px!important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px!important;
|
||||
font-size: 24px!important;
|
||||
.day-container{
|
||||
background-color: transparent!important;
|
||||
}
|
||||
}
|
||||
&.weekend{
|
||||
color: rgb(0,0,0)!important;
|
||||
&.active{
|
||||
color: #fff!important;
|
||||
}
|
||||
}
|
||||
}
|
||||
.nut-calendarcard-day-inner{
|
||||
font-size: 20px;
|
||||
.day-container{
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 22px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
86
src/components/Picker/Picker.tsx
Normal file
86
src/components/Picker/Picker.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { Picker, ConfigProvider } from '@nutui/nutui-react-taro'
|
||||
import { View } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
interface PickerOption {
|
||||
text: string | number
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
visible: boolean
|
||||
options?: PickerOption[][]
|
||||
defaultValue?: (string | number)[]
|
||||
onConfirm?: (options: PickerOption[], values: (string | number)[]) => void
|
||||
onChange?: (options: PickerOption[], values: (string | number)[], columnIndex: number) => void
|
||||
}
|
||||
|
||||
const CustomPicker = ({
|
||||
visible,
|
||||
options = [],
|
||||
defaultValue = [],
|
||||
onConfirm,
|
||||
onChange
|
||||
}: PickerProps) => {
|
||||
// 使用内部状态管理当前选中的值
|
||||
const [currentValue, setCurrentValue] = useState<(string | number)[]>(defaultValue)
|
||||
|
||||
// 当外部 defaultValue 变化时,同步更新内部状态
|
||||
useEffect(() => {
|
||||
setCurrentValue(defaultValue)
|
||||
}, [defaultValue])
|
||||
|
||||
const confirmPicker = (
|
||||
options: PickerOption[],
|
||||
values: (string | number)[]
|
||||
) => {
|
||||
let description = ''
|
||||
options.forEach((option: any) => {
|
||||
description += ` ${option.text}`
|
||||
})
|
||||
|
||||
if (onConfirm) {
|
||||
onConfirm(options, values)
|
||||
}
|
||||
}
|
||||
|
||||
const changePicker = useCallback((options: any[], values: any, columnIndex: number) => {
|
||||
// 更新内部状态
|
||||
setCurrentValue(values)
|
||||
|
||||
if (onChange) {
|
||||
onChange(options, values, columnIndex)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className={styles['picker-container']}>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
nutuiPickerItemHeight: '48px',
|
||||
nutuiPickerItemActiveLineBorder: 'none',
|
||||
nutuiPickerItemTextColor: '#000',
|
||||
nutuiPickerItemFontSize: '20px',
|
||||
}}
|
||||
>
|
||||
<Picker
|
||||
visible={visible}
|
||||
options={options}
|
||||
value={currentValue}
|
||||
onChange={changePicker}
|
||||
popupProps={{
|
||||
overlay: false,
|
||||
round: true,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
onConfirm={(list, values) => confirmPicker(list, values)}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</View>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomPicker
|
||||
70
src/components/Picker/PickerCommon.tsx
Normal file
70
src/components/Picker/PickerCommon.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import Picker from './Picker'
|
||||
import { renderYearMonth, renderHourMinute } from './PickerData'
|
||||
interface PickerOption {
|
||||
text: string | number
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
options?: PickerOption[][]
|
||||
value?: (string | number)[]
|
||||
type?: 'month' | 'hour' | null
|
||||
onConfirm?: (options: PickerOption[], values: (string | number)[]) => void
|
||||
onChange?: ( value: (string | number)[] ) => void
|
||||
}
|
||||
|
||||
export interface PickerCommonRef {
|
||||
getValue: () => (string | number)[]
|
||||
setValue: (v: (string | number)[]) => void
|
||||
}
|
||||
|
||||
const PopupPicker = ({
|
||||
value = [],
|
||||
onChange,
|
||||
options = [],
|
||||
type = null
|
||||
}: PickerProps, ref: React.Ref<PickerCommonRef>) => {
|
||||
const [defaultValue, setDefaultValue] = useState<(string | number)[]>([])
|
||||
|
||||
const [defaultOptions, setDefaultOptions] = useState<PickerOption[][]>([])
|
||||
const changePicker = (options: any[], values: any, columnIndex: number) => {
|
||||
console.log('picker onChange', columnIndex, values, options)
|
||||
setDefaultValue(values)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (type === 'month') {
|
||||
setDefaultOptions(renderYearMonth())
|
||||
} else if (type === 'hour') {
|
||||
setDefaultOptions(renderHourMinute())
|
||||
} else {
|
||||
setDefaultOptions(options)
|
||||
}
|
||||
}, [type])
|
||||
|
||||
useEffect(() => {
|
||||
// 同步初始值到内部状态,供 getValue 使用
|
||||
setDefaultValue(value)
|
||||
}, [value])
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
getValue: () => (defaultValue && defaultValue.length ? defaultValue : value),
|
||||
setValue: (v: (string | number)[]) => {
|
||||
setDefaultValue(v)
|
||||
},
|
||||
}), [defaultValue, value])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Picker
|
||||
visible={true}
|
||||
options={defaultOptions}
|
||||
defaultValue={defaultValue.length ? defaultValue : value}
|
||||
onChange={changePicker}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.forwardRef<PickerCommonRef, PickerProps>(PopupPicker)
|
||||
30
src/components/Picker/PickerData.js
Normal file
30
src/components/Picker/PickerData.js
Normal file
@@ -0,0 +1,30 @@
|
||||
export const renderYearMonth = (minYear = 2020, maxYear = 2099) => {
|
||||
return [
|
||||
// 年份列
|
||||
Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
|
||||
text: `${minYear + index}年`,
|
||||
value: minYear + index
|
||||
})),
|
||||
// 月份列
|
||||
Array.from({ length: 12 }, (_, index) => ({
|
||||
text: `${index + 1}月`,
|
||||
value: index + 1
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
export const renderHourMinute = (minHour = 0, maxHour = 23) => {
|
||||
// 生成小时和分钟的选项数据
|
||||
return [
|
||||
// 小时列
|
||||
Array.from({ length: maxHour - minHour + 1 }, (_, index) => ({
|
||||
text: `${minHour + index}时`,
|
||||
value: minHour + index
|
||||
})),
|
||||
// 分钟列 (5分钟间隔)
|
||||
Array.from({ length: 12 }, (_, index) => ({
|
||||
text: `${index * 5 < 10 ? '0' + index * 5 : index * 5}分`,
|
||||
value: index * 5
|
||||
}))
|
||||
]
|
||||
}
|
||||
90
src/components/Picker/PopupPicker.tsx
Normal file
90
src/components/Picker/PopupPicker.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import CommonPopup from '@/components/CommonPopup'
|
||||
import Picker from './Picker'
|
||||
import { renderYearMonth, renderHourMinute } from './PickerData'
|
||||
interface PickerOption {
|
||||
text: string | number
|
||||
value: string | number
|
||||
}
|
||||
|
||||
interface PickerProps {
|
||||
visible: boolean
|
||||
setvisible: (visible: boolean) => void
|
||||
options?: PickerOption[][]
|
||||
value?: (string | number)[]
|
||||
type?: 'month' | 'hour' | null
|
||||
onConfirm?: (options: PickerOption[], values: (string | number)[]) => void
|
||||
onChange?: ( value: (string | number)[] ) => void
|
||||
}
|
||||
|
||||
const PopupPicker = ({
|
||||
visible,
|
||||
setvisible,
|
||||
value = [],
|
||||
onConfirm,
|
||||
onChange,
|
||||
options = [],
|
||||
type = null
|
||||
}: PickerProps) => {
|
||||
|
||||
const [defaultValue, setDefaultValue] = useState<(string | number)[]>([])
|
||||
const [defaultOptions, setDefaultOptions] = useState<PickerOption[][]>([])
|
||||
const changePicker = (options: any[], values: any, columnIndex: number) => {
|
||||
if (onChange) {
|
||||
console.log('picker onChange', columnIndex, values, options)
|
||||
|
||||
setDefaultValue(values)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
console.log(defaultValue,'defaultValue');
|
||||
onChange(defaultValue)
|
||||
setvisible(false)
|
||||
}
|
||||
|
||||
const dialogClose = () => {
|
||||
setvisible(false)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (type === 'month') {
|
||||
setDefaultOptions(renderYearMonth())
|
||||
} else if (type === 'hour') {
|
||||
setDefaultOptions(renderHourMinute())
|
||||
} else {
|
||||
setDefaultOptions(options)
|
||||
}
|
||||
}, [type])
|
||||
|
||||
// useEffect(() => {
|
||||
// if (value.length > 0 && defaultOptions.length > 0) {
|
||||
// setDefaultValue([...value])
|
||||
// }
|
||||
// }, [value, defaultOptions])
|
||||
return (
|
||||
<>
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={dialogClose}
|
||||
showHeader={false}
|
||||
title={null}
|
||||
hideFooter={false}
|
||||
cancelText='取消'
|
||||
confirmText='完成'
|
||||
onConfirm={handleConfirm}
|
||||
position='bottom'
|
||||
round
|
||||
zIndex={1000}
|
||||
>
|
||||
<Picker
|
||||
visible={visible}
|
||||
options={defaultOptions}
|
||||
defaultValue={value}
|
||||
onChange={changePicker}
|
||||
/>
|
||||
</CommonPopup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PopupPicker
|
||||
25
src/components/Picker/index.module.scss
Normal file
25
src/components/Picker/index.module.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
.picker-container {
|
||||
:global{
|
||||
.nut-popup-round{
|
||||
position: relative!important;
|
||||
.nut-picker-control {
|
||||
display: none!important;
|
||||
}
|
||||
.nut-picker{
|
||||
&::after{
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 16px;
|
||||
right: 16px!important;
|
||||
width: calc(100% - 32px);
|
||||
height: 48px;
|
||||
background: rgba(22, 24, 35, 0.05);
|
||||
transform: translateY(-50%);
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
src/components/Picker/index.ts
Normal file
6
src/components/Picker/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as CustomPicker } from './Picker'
|
||||
export { default as PopupPicker } from './PopupPicker'
|
||||
export { default as PickerCommon } from './PickerCommon'
|
||||
export type { PickerCommonRef } from './PickerCommon'
|
||||
export { default as CalendarUI } from './CalendarUI/CalendarUI'
|
||||
export { default as DialogCalendarCard } from './CalendarDialog/DialogCalendarCard'
|
||||
@@ -10,7 +10,10 @@ interface RangeProps {
|
||||
max?: number;
|
||||
step?: number;
|
||||
value?: [number, number];
|
||||
onChange?: (name: string, value: [number, number]) => void;
|
||||
onChange?: {
|
||||
(value: [number, number]): void
|
||||
(name: string, value: [number, number]): void
|
||||
};
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
name: string;
|
||||
@@ -41,7 +44,11 @@ const NtrpRange: React.FC<RangeProps> = ({
|
||||
|
||||
const handleEndChange = (val: [number, number]) => {
|
||||
setCurrentValue(val);
|
||||
onChange?.(name, val);
|
||||
if (name) {
|
||||
onChange?.(name, val);
|
||||
} else {
|
||||
onChange?.(val)
|
||||
}
|
||||
};
|
||||
|
||||
const marks = useMemo(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, } from '@tarojs/components'
|
||||
import { getDate, getTime, getDateStr, getEndTime } from '@/utils/timeUtils'
|
||||
import DialogCalendarCard from '@/components/CalendarCard/DialogCalendarCard'
|
||||
import { DialogCalendarCard } from '@/components/index'
|
||||
import './TimeSelector.scss'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
export interface TimeRange {
|
||||
start_time: string
|
||||
@@ -23,12 +24,38 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
}) => {
|
||||
// 格式化日期显示
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [currentTimeValue, setCurrentTimeValue] = useState<Date>(new Date())
|
||||
const [currentTimeType, setCurrentTimeType] = useState<'start' | 'end'>('start')
|
||||
const [showEndTime, setShowEndTime] = useState(false)
|
||||
const handleConfirm = (date: Date) => {
|
||||
console.log('选择的日期:', date)
|
||||
const start_time = getDateStr(date)
|
||||
const end_time = getEndTime(start_time)
|
||||
const start_time = currentTimeType === 'start' ? getDateStr(date) : value.start_time;
|
||||
const isLater = dayjs(value.start_time).isAfter(dayjs(value.end_time));
|
||||
if (isLater) {
|
||||
if (onChange) onChange({start_time, end_time: getEndTime(start_time)})
|
||||
return
|
||||
}
|
||||
const initEndTime = value.end_time ? value.end_time : getEndTime(start_time)
|
||||
const end_time = currentTimeType === 'end' ? getDateStr(date) : initEndTime;
|
||||
if (onChange) onChange({start_time, end_time})
|
||||
}
|
||||
const openPicker = (type: 'start' | 'end') => {
|
||||
setCurrentTimeValue(type === 'start' ? new Date(value.start_time) : new Date(value.end_time))
|
||||
setCurrentTimeType(type)
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (value.start_time && value.end_time) {
|
||||
const start_time = dayjs(value.start_time).format('YYYY-MM-DD')
|
||||
const end_time = dayjs(value.end_time).format('YYYY-MM-DD')
|
||||
if (start_time === end_time) {
|
||||
setShowEndTime(false)
|
||||
} else {
|
||||
setShowEndTime(true)
|
||||
}
|
||||
}
|
||||
}, [value])
|
||||
return (
|
||||
<View className='time-selector'>
|
||||
<View className='time-section'>
|
||||
@@ -37,7 +64,7 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
<View className='time-label'>
|
||||
<View className='dot'></View>
|
||||
</View>
|
||||
<View className='time-content' onClick={() => setVisible(true)}>
|
||||
<View className='time-content' onClick={() => openPicker('start')}>
|
||||
<Text className='time-label'>开始时间</Text>
|
||||
<view className='time-text-wrapper'>
|
||||
<Text className='time-text'>{getDate(value.start_time)}</Text>
|
||||
@@ -51,9 +78,10 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
<View className='time-label'>
|
||||
<View className='dot hollow'></View>
|
||||
</View>
|
||||
<View className='time-content'>
|
||||
<View className='time-content' onClick={() => openPicker('end')}>
|
||||
<Text className='time-label'>结束时间</Text>
|
||||
<view className='time-text-wrapper'>
|
||||
{showEndTime && (<Text className='time-text'>{getDate(value.end_time)}</Text>)}
|
||||
<Text className='time-text time-am'>{getTime(value.end_time)}</Text>
|
||||
</view>
|
||||
</View>
|
||||
@@ -61,6 +89,7 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
</View>
|
||||
<DialogCalendarCard
|
||||
visible={visible}
|
||||
value={currentTimeValue}
|
||||
onChange={handleConfirm}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface UploadCoverProps {
|
||||
source?: source
|
||||
maxCount?: number
|
||||
align?: 'center' | 'left'
|
||||
tag?: 'cover' | 'screenshot'
|
||||
}
|
||||
|
||||
// const values = [
|
||||
@@ -34,7 +35,6 @@ export interface UploadCoverProps {
|
||||
// ]
|
||||
|
||||
const mergeCoverImages = (value: CoverImageValue[], images: CoverImageValue[]) => {
|
||||
console.log(value, images, 11111)
|
||||
// 根据id来更新url, 如果id不存在,则添加到value中
|
||||
const newImages = images
|
||||
const updatedValue = value.map(item => {
|
||||
@@ -55,6 +55,7 @@ export default function UploadCover(props: UploadCoverProps) {
|
||||
source = ['album', 'history', 'preset'] as source,
|
||||
maxCount = 9,
|
||||
align = 'center',
|
||||
tag = 'cover',
|
||||
} = props
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -68,12 +69,12 @@ export default function UploadCover(props: UploadCoverProps) {
|
||||
setVisible(false)
|
||||
}, [value])
|
||||
|
||||
const onWxAdd = useCallback((images: CoverImageValue[], onFileUploaded: Promise<{ id: string, data: uploadFileResponseData }[]>) => {
|
||||
const onWxAdd = useCallback((images: CoverImageValue[], onFileUpdate: Promise<{ id: string, url: string }[]>) => {
|
||||
onAdd(images)
|
||||
onFileUploaded.then(res => {
|
||||
onFileUpdate.then(res => {
|
||||
onAdd(res.map(item => ({
|
||||
id: item.id,
|
||||
url: item.data.file_path,
|
||||
url: item.url,
|
||||
})))
|
||||
})
|
||||
}, [onAdd])
|
||||
@@ -111,7 +112,7 @@ export default function UploadCover(props: UploadCoverProps) {
|
||||
}
|
||||
</View>
|
||||
</CommonPopup>
|
||||
<UploadSourcePopup ref={uploadSourcePopupRef} onAdd={onAdd} />
|
||||
<UploadSourcePopup tag={tag} ref={uploadSourcePopupRef} onAdd={onAdd} />
|
||||
<div className={`upload-cover-root ${value.length === 0 && align === 'center' ? 'upload-cover-act-center' : ''}`}>
|
||||
{value.length < maxCount && (
|
||||
<div className="upload-cover-act" onClick={() => setVisible(true)}>
|
||||
|
||||
@@ -4,13 +4,81 @@ import Taro from '@tarojs/taro'
|
||||
import uploadApi from '@/services/uploadFiles'
|
||||
import './upload-from-wx.scss'
|
||||
import { CoverImageValue } from '.'
|
||||
import { uploadFileResponseData } from '@/services/uploadFiles'
|
||||
|
||||
export interface UploadFromWxProps {
|
||||
onAdd: (images: CoverImageValue[], onFileUploaded: Promise<{ id: string, data: uploadFileResponseData }[]>) => void
|
||||
onAdd: (images: CoverImageValue[], onFileUploaded: Promise<{ id: string, url: string }[]>) => void
|
||||
maxCount: number
|
||||
}
|
||||
|
||||
async function convert_to_jpg_and_compress (src: string, { width, height }): Promise<string> {
|
||||
const canvas = Taro.createOffscreenCanvas({ type: '2d', width, height })
|
||||
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
|
||||
|
||||
const image = canvas.createImage()
|
||||
await new Promise(resolve => {
|
||||
image.onload = resolve
|
||||
image.src = src
|
||||
})
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.drawImage(image as unknown as CanvasImageSource, 0, 0, width, height)
|
||||
|
||||
// const imageData = ctx.getImageData(0, 0, width, height)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
Taro.canvasToTempFilePath({
|
||||
canvas: canvas as unknown as Taro.Canvas,
|
||||
fileType: 'jpg',
|
||||
quality: 0.7,
|
||||
success: res => resolve(res.tempFilePath),
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
async function compressImage(files) {
|
||||
const res: string[] = []
|
||||
for (const file of files) {
|
||||
const compressed_image = await convert_to_jpg_and_compress(file.path, { width: file.width, height: file.height })
|
||||
res.push(compressed_image)
|
||||
}
|
||||
return res
|
||||
}
|
||||
// 图片标准容器为 360 * 240 3:2
|
||||
// 压缩后图片最大宽高
|
||||
const IMAGE_MAX_SIZE = {
|
||||
width: 1080,
|
||||
height: 720,
|
||||
}
|
||||
|
||||
// 标准长宽比,判断标准
|
||||
const STANDARD_ASPECT_RATIO = IMAGE_MAX_SIZE.width / IMAGE_MAX_SIZE.height
|
||||
|
||||
type ChoosenImageRes = { path: string, size: number, width: number, height: number }
|
||||
// 根据图片标准重新设置图片尺寸
|
||||
async function onChooseImageSuccess(tempFiles) {
|
||||
const result: ChoosenImageRes[] = []
|
||||
for (const tempFile of tempFiles) {
|
||||
const { width, height } = await Taro.getImageInfo({ src: tempFile.path })
|
||||
const image_aspect_ratio = width / height
|
||||
let fileRes: ChoosenImageRes = { path: tempFile.path, size: tempFile.size, width: 0, height: 0 }
|
||||
// 如果图片长宽比小于标准长宽比,则依照图片高度以及图片最大高度来重新设置图片尺寸
|
||||
if (image_aspect_ratio < STANDARD_ASPECT_RATIO) {
|
||||
fileRes = {
|
||||
...fileRes,
|
||||
...(height > IMAGE_MAX_SIZE.height ? { width: Math.floor(IMAGE_MAX_SIZE.height * image_aspect_ratio), height: IMAGE_MAX_SIZE.height } : { width: Math.floor(height * image_aspect_ratio), height }),
|
||||
}
|
||||
} else {
|
||||
fileRes = {
|
||||
...fileRes,
|
||||
...(width > IMAGE_MAX_SIZE.width ? { width: IMAGE_MAX_SIZE.width, height: Math.floor(IMAGE_MAX_SIZE.width / image_aspect_ratio) } : { width, height: Math.floor(width / image_aspect_ratio) }),
|
||||
}
|
||||
}
|
||||
result.push(fileRes)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export default function UploadFromWx(props: UploadFromWxProps) {
|
||||
const {
|
||||
onAdd = () => void 0,
|
||||
@@ -22,21 +90,29 @@ export default function UploadFromWx(props: UploadFromWxProps) {
|
||||
sizeType: ['original', 'compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
}).then(async (res) => {
|
||||
// TODO: compress image
|
||||
// TODO: cropping image to const size
|
||||
let count = 0
|
||||
const files = res.tempFiles.map(item => ({
|
||||
filePath: item.path,
|
||||
const analyzedFiles = await onChooseImageSuccess(res.tempFiles)
|
||||
// compress image
|
||||
// cropping image to standard size
|
||||
const compressedTempFiles = await compressImage(analyzedFiles)
|
||||
|
||||
let start = Date.now()
|
||||
const files = compressedTempFiles.map(path => ({
|
||||
filePath: path,
|
||||
description: '封面图',
|
||||
tags: 'cover',
|
||||
is_public: 1 as unknown as 0 | 1,
|
||||
id: (Date.now() + count++).toString(),
|
||||
id: (start++).toString(),
|
||||
}))
|
||||
const onFileUploaded = uploadApi.batchUpload(files)
|
||||
const onFileUpdate = uploadApi.batchUpload(files).then(res => {
|
||||
return res.map(item => ({
|
||||
id: item.id,
|
||||
url: item.data.file_url
|
||||
}))
|
||||
})
|
||||
onAdd(files.map(item => ({
|
||||
id: item.id,
|
||||
url: item.filePath,
|
||||
})), onFileUploaded) // TODO: add loading state
|
||||
})), onFileUpdate)
|
||||
})
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,7 @@ type ImageItem = {
|
||||
|
||||
interface UploadImageProps {
|
||||
onAdd: (images: ImageItem[]) => void
|
||||
tag: 'cover' | 'screenshot'
|
||||
}
|
||||
|
||||
export const sourceMap = new Map<SourceType, string>([
|
||||
@@ -32,6 +33,7 @@ const checkImageSelected = (images: ImageItem[], image: ImageItem) => {
|
||||
export default forwardRef(function UploadImage(props: UploadImageProps, ref) {
|
||||
const {
|
||||
onAdd = () => void 0,
|
||||
tag = 'cover',
|
||||
} = props
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [sourceType, setSourceType] = useState<SourceType>('history')
|
||||
@@ -62,7 +64,7 @@ export default forwardRef(function UploadImage(props: UploadImageProps, ref) {
|
||||
}))
|
||||
|
||||
function fetchImages(st: SourceType) {
|
||||
publishService.getPictures({ type: st }).then(res => {
|
||||
publishService.getPictures({ type: st, tag }).then(res => {
|
||||
if (res.code === 0) {
|
||||
let start = 0
|
||||
setImages(res.data.rows.map(item => ({
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
// 基本信息
|
||||
.basic_info {
|
||||
@@ -220,6 +219,7 @@
|
||||
background: #FFFFFF;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.16);
|
||||
border-radius: 999px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.tag_icon {
|
||||
width: 12px;
|
||||
|
||||
@@ -15,15 +15,18 @@ export interface UserInfo {
|
||||
hosted: number;
|
||||
participated: number;
|
||||
};
|
||||
tags: string[];
|
||||
bio: string;
|
||||
personal_profile: string;
|
||||
location: string;
|
||||
occupation: string;
|
||||
ntrp_level: string;
|
||||
phone?: string;
|
||||
gender?: string;
|
||||
|
||||
latitude?: string,
|
||||
longitude?: string,
|
||||
}
|
||||
|
||||
|
||||
// 用户信息卡片组件属性
|
||||
interface UserInfoCardProps {
|
||||
user_info: UserInfo;
|
||||
@@ -35,12 +38,12 @@ interface UserInfoCardProps {
|
||||
}
|
||||
|
||||
|
||||
// 处理编辑用户信息
|
||||
const on_edit = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/edit/index'
|
||||
});
|
||||
};
|
||||
// 处理编辑用户信息
|
||||
const on_edit = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/mod_user/pages/edit/index'
|
||||
});
|
||||
};
|
||||
// 用户信息卡片组件
|
||||
export const UserInfoCard: React.FC<UserInfoCardProps> = ({
|
||||
user_info,
|
||||
@@ -61,11 +64,11 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
|
||||
<Text className="nickname">{user_info.nickname}</Text>
|
||||
<Text className="join_date">{user_info.join_date}</Text>
|
||||
</View>
|
||||
<View className='tag_item' onClick={on_edit}>
|
||||
<View className='tag_item' onClick={on_edit}>
|
||||
<Image
|
||||
className="tag_icon"
|
||||
src={require('../../static/userInfo/edit.svg')}
|
||||
/> </View>
|
||||
className="tag_icon"
|
||||
src={require('../../static/userInfo/edit.svg')}
|
||||
/> </View>
|
||||
</View>
|
||||
|
||||
{/* 统计数据 */}
|
||||
@@ -113,7 +116,7 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* 只有当前用户才显示分享按钮 */}
|
||||
{is_current_user && on_share && (
|
||||
<Button className="share_button" onClick={on_share}>
|
||||
@@ -126,21 +129,37 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
|
||||
{/* 标签和简介 */}
|
||||
<View className="tags_bio_section">
|
||||
<View className="tags_container">
|
||||
<View className="tag_item">
|
||||
|
||||
{user_info.gender === "0" && (
|
||||
<Image
|
||||
className="tag_icon"
|
||||
src={require('../../static/userInfo/male.svg')}
|
||||
/>
|
||||
)}
|
||||
{user_info.gender === "1" && (
|
||||
<Image
|
||||
className="tag_icon"
|
||||
src={require('../../static/userInfo/female.svg')}
|
||||
/>
|
||||
)}
|
||||
|
||||
</View>
|
||||
<View className="tag_item">
|
||||
<Text className="tag_text">{user_info.ntrp_level || '未设置'}</Text>
|
||||
</View>
|
||||
<View className="tag_item">
|
||||
<Text className="tag_text">{user_info.occupation || '未设置'}</Text>
|
||||
</View>
|
||||
<View className="tag_item">
|
||||
<Image
|
||||
className="tag_icon"
|
||||
src={require('../../static/userInfo/location.svg')}
|
||||
/>
|
||||
<Text className="tag_text">{user_info.location}</Text>
|
||||
</View>
|
||||
<View className="tag_item">
|
||||
<Text className="tag_text">{user_info.occupation}</Text>
|
||||
</View>
|
||||
<View className="tag_item">
|
||||
<Text className="tag_text">{user_info.ntrp_level}</Text>
|
||||
<Text className="tag_text">{user_info.location || '未设置'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="bio_text">{user_info.bio}</Text>
|
||||
<Text className="bio_text">{user_info.personal_profile}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -8,14 +8,14 @@ import NumberInterval from './NumberInterval'
|
||||
import TimeSelector from './TimeSelector'
|
||||
import TitleTextarea from './TitleTextarea'
|
||||
import CommonPopup from './CommonPopup'
|
||||
import DateTimePicker from './DateTimePicker/DateTimePicker'
|
||||
import TimePicker from './TimePicker/TimePicker'
|
||||
import CalendarCard, { DialogCalendarCard } from './CalendarCard'
|
||||
import { CalendarUI, DialogCalendarCard } from './Picker'
|
||||
import CommonDialog from './CommonDialog'
|
||||
import PublishMenu from './PublishMenu/PublishMenu'
|
||||
import UploadCover from './UploadCover'
|
||||
import EditModal from './EditModal/index'
|
||||
import withAuth from './Auth'
|
||||
import { CustomPicker, PopupPicker } from './Picker'
|
||||
|
||||
export {
|
||||
ActivityTypeSwitch,
|
||||
@@ -27,14 +27,14 @@ import withAuth from './Auth'
|
||||
TimeSelector,
|
||||
TitleTextarea,
|
||||
CommonPopup,
|
||||
DateTimePicker,
|
||||
TimePicker,
|
||||
CalendarCard,
|
||||
DialogCalendarCard,
|
||||
CalendarUI,
|
||||
CommonDialog,
|
||||
PublishMenu,
|
||||
UploadCover,
|
||||
EditModal,
|
||||
withAuth,
|
||||
CustomPicker,
|
||||
PopupPicker
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// API配置
|
||||
import envConfig from './env'// API配置
|
||||
|
||||
export const API_CONFIG = {
|
||||
// 基础URL
|
||||
BASE_URL: process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : 'https://api.example.com',
|
||||
BASE_URL: envConfig.apiBaseURL,
|
||||
|
||||
// 用户相关接口
|
||||
USER: {
|
||||
@@ -16,7 +17,8 @@ export const API_CONFIG = {
|
||||
// 文件上传接口
|
||||
UPLOAD: {
|
||||
AVATAR: '/gallery/upload',
|
||||
IMAGE: '/gallery/upload'
|
||||
IMAGE: '/gallery/upload',
|
||||
OSS_IMG: '/gallery/upload_oss_img'
|
||||
},
|
||||
|
||||
// 球局相关接口
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
// 环境类型
|
||||
export type EnvType = 'development' | 'test' | 'production'
|
||||
export type EnvType = 'development' | 'production'
|
||||
|
||||
// 环境配置接口
|
||||
export interface EnvConfig {
|
||||
@@ -17,22 +17,14 @@ const envConfigs: Record<EnvType, EnvConfig> = {
|
||||
// 开发环境
|
||||
development: {
|
||||
name: '开发环境',
|
||||
apiBaseURL: 'https://sit.light120.com',
|
||||
// apiBaseURL: 'http://localhost:9098',
|
||||
apiBaseURL: 'https://sit.light120.com',
|
||||
// apiBaseURL: 'http://localhost:9098',
|
||||
timeout: 15000,
|
||||
enableLog: true,
|
||||
enableMock: true
|
||||
},
|
||||
|
||||
// 测试环境
|
||||
test: {
|
||||
name: '测试环境',
|
||||
apiBaseURL: 'https://sit.light120.com',
|
||||
// apiBaseURL: 'http://localhost:9098',
|
||||
timeout: 12000,
|
||||
enableLog: true,
|
||||
enableMock: false
|
||||
},
|
||||
|
||||
|
||||
// 生产环境
|
||||
production: {
|
||||
@@ -48,18 +40,18 @@ const envConfigs: Record<EnvType, EnvConfig> = {
|
||||
export const getCurrentEnv = (): EnvType => {
|
||||
// 在小程序环境中,使用默认逻辑判断环境
|
||||
// 可以根据实际需要配置不同的判断逻辑
|
||||
|
||||
|
||||
// 可以根据实际部署情况添加更多判断逻辑
|
||||
// 比如通过 Taro.getEnv() 获取当前平台环境
|
||||
const currentEnv = Taro.getEnv()
|
||||
|
||||
|
||||
// 在开发调试时,可以通过修改这里的逻辑来切换环境
|
||||
// 默认在小程序中使用生产环境配置
|
||||
// if (currentEnv === Taro.ENV_TYPE.WEAPP) {
|
||||
// // 微信小程序环境
|
||||
// return 'production'
|
||||
// }
|
||||
|
||||
|
||||
// 默认返回开发环境(便于调试)
|
||||
return 'development'
|
||||
}
|
||||
@@ -85,10 +77,7 @@ export const isProduction = (): boolean => {
|
||||
return getCurrentEnv() === 'production'
|
||||
}
|
||||
|
||||
// 是否为测试环境
|
||||
export const isTest = (): boolean => {
|
||||
return getCurrentEnv() === 'test'
|
||||
}
|
||||
|
||||
|
||||
// 环境配置调试信息
|
||||
export const getEnvInfo = () => {
|
||||
@@ -97,9 +86,9 @@ export const getEnvInfo = () => {
|
||||
env: getCurrentEnv(),
|
||||
config,
|
||||
taroEnv: Taro.getEnv(),
|
||||
platform: Taro.getEnv() === Taro.ENV_TYPE.WEAPP ? '微信小程序' :
|
||||
Taro.getEnv() === Taro.ENV_TYPE.H5 ? 'H5' :
|
||||
Taro.getEnv() === Taro.ENV_TYPE.RN ? 'React Native' : '未知'
|
||||
platform: Taro.getEnv() === Taro.ENV_TYPE.WEAPP ? '微信小程序' :
|
||||
Taro.getEnv() === Taro.ENV_TYPE.H5 ? 'H5' :
|
||||
Taro.getEnv() === Taro.ENV_TYPE.RN ? 'React Native' : '未知'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,8 @@ export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
required: true,
|
||||
props: {
|
||||
maxCount: 9,
|
||||
source: ['album', 'history', 'preset']
|
||||
source: ['album', 'history', 'preset'],
|
||||
tag: 'cover',
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -97,7 +98,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
type: FieldType.LOCATION,
|
||||
placeholder: '请选择活动地点',
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
prop: 'play_type',
|
||||
label: '玩法',
|
||||
@@ -126,7 +127,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
max: 20,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
prop: 'skill_level',
|
||||
label: 'NTRP 水平要求',
|
||||
@@ -162,7 +163,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
label: '',
|
||||
type: FieldType.CHECKBOX,
|
||||
placeholder: '开启自动候补逻辑',
|
||||
required: true,
|
||||
required: true,
|
||||
props:{
|
||||
subTitle: '开启自动候补逻辑',
|
||||
showToast: true,
|
||||
@@ -173,9 +174,9 @@ export const publishBallFormSchema: FormFieldConfig[] = [
|
||||
prop: 'is_wechat_contact',
|
||||
label: '',
|
||||
type: FieldType.WECHATCONTACT,
|
||||
required: true,
|
||||
required: true,
|
||||
props:{
|
||||
subTitle: '允许球友微信联系我',
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Image, ScrollView, Input } from '@tarojs/components';
|
||||
import { View, Text, Image, ScrollView, Picker, Input } from '@tarojs/components';
|
||||
import Taro from '@tarojs/taro';
|
||||
import './index.scss';
|
||||
import { UserInfo } from '@/components/UserInfo';
|
||||
import { UserService } from '@/services/userService';
|
||||
import { clear_login_state } from '@/services/loginService';
|
||||
import { convert_db_gender_to_display } from '@/utils/genderUtils';
|
||||
import { EditModal } from '@/components';
|
||||
|
||||
const EditProfilePage: React.FC = () => {
|
||||
@@ -19,8 +21,7 @@ const EditProfilePage: React.FC = () => {
|
||||
hosted: 0,
|
||||
participated: 0
|
||||
},
|
||||
tags: ['加载中...'],
|
||||
bio: '加载中...',
|
||||
personal_profile: '加载中...',
|
||||
location: '加载中...',
|
||||
occupation: '加载中...',
|
||||
ntrp_level: 'NTRP 3.0',
|
||||
@@ -31,7 +32,7 @@ const EditProfilePage: React.FC = () => {
|
||||
// 表单状态
|
||||
const [form_data, setFormData] = useState({
|
||||
nickname: '',
|
||||
bio: '',
|
||||
personal_profile: '',
|
||||
location: '',
|
||||
occupation: '',
|
||||
ntrp_level: '4.0',
|
||||
@@ -59,11 +60,11 @@ const EditProfilePage: React.FC = () => {
|
||||
const user_data = await UserService.get_user_info();
|
||||
setUserInfo(user_data);
|
||||
setFormData({
|
||||
nickname: user_data.nickname,
|
||||
bio: user_data.bio,
|
||||
location: user_data.location,
|
||||
occupation: user_data.occupation,
|
||||
ntrp_level: user_data.ntrp_level.replace('NTRP ', ''),
|
||||
nickname: user_data.nickname || '',
|
||||
personal_profile: user_data.personal_profile || '',
|
||||
location: user_data.location || '',
|
||||
occupation: user_data.occupation || '',
|
||||
ntrp_level: user_data.ntrp_level || 'NTRP 4.0',
|
||||
phone: user_data.phone || '',
|
||||
gender: user_data.gender || '',
|
||||
birthday: '2000-01-01' // 默认生日,实际应该从用户数据获取
|
||||
@@ -80,17 +81,6 @@ const EditProfilePage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 处理输入变化
|
||||
const handle_input_change = (field: string, value: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
// 处理头像上传
|
||||
const handle_avatar_upload = () => {
|
||||
Taro.chooseImage({
|
||||
@@ -119,14 +109,42 @@ const EditProfilePage: React.FC = () => {
|
||||
|
||||
// 处理编辑弹窗
|
||||
const handle_open_edit_modal = (field: string) => {
|
||||
setEditingField(field);
|
||||
setEditModalVisible(true);
|
||||
if (field === 'nickname') {
|
||||
// 手动输入
|
||||
setEditingField(field);
|
||||
setEditModalVisible(true);
|
||||
} else {
|
||||
setEditingField(field);
|
||||
setEditModalVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handle_edit_modal_save = (value: string) => {
|
||||
setFormData(prev => ({ ...prev, [editing_field]: value }));
|
||||
setEditModalVisible(false);
|
||||
setEditingField('');
|
||||
const handle_edit_modal_save = async (value: string) => {
|
||||
try {
|
||||
// 调用更新用户信息接口,只传递修改的字段
|
||||
const update_data = { [editing_field]: value };
|
||||
await UserService.update_user_info(update_data);
|
||||
|
||||
// 更新本地状态
|
||||
setFormData(prev => ({ ...prev, [editing_field]: value }));
|
||||
setUserInfo(prev => ({ ...prev, [editing_field]: value }));
|
||||
|
||||
// 关闭弹窗
|
||||
setEditModalVisible(false);
|
||||
setEditingField('');
|
||||
|
||||
// 显示成功提示
|
||||
Taro.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
Taro.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handle_edit_modal_cancel = () => {
|
||||
@@ -134,6 +152,82 @@ const EditProfilePage: React.FC = () => {
|
||||
setEditingField('');
|
||||
};
|
||||
|
||||
// 处理字段编辑
|
||||
const handle_field_edit = async (field: string, value: string) => {
|
||||
try {
|
||||
// 调用更新用户信息接口,只传递修改的字段
|
||||
const update_data = { [field]: value };
|
||||
await UserService.update_user_info(update_data);
|
||||
|
||||
// 更新本地状态
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
setUserInfo(prev => ({ ...prev, [field]: value }));
|
||||
|
||||
// 显示成功提示
|
||||
Taro.showToast({
|
||||
title: '保存成功',
|
||||
icon: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
Taro.showToast({
|
||||
title: '保存失败',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 处理性别选择
|
||||
const handle_gender_change = (e: any) => {
|
||||
const gender_value = e.detail.value;
|
||||
// 使用工具函数转换:页面选择器值(0/1) -> 数据库值('0'/'1')
|
||||
const gender_db_value = gender_value === 0 ? '0' : '1';
|
||||
handle_field_edit('gender', gender_db_value);
|
||||
};
|
||||
|
||||
// 处理生日选择
|
||||
const handle_birthday_change = (e: any) => {
|
||||
const birthday_value = e.detail.value;
|
||||
handle_field_edit('birthday', birthday_value);
|
||||
};
|
||||
|
||||
// 处理职业输入 - 实时更新本地状态
|
||||
const handle_occupation_input = (e: any) => {
|
||||
const occupation_value = e.detail.value;
|
||||
setFormData(prev => ({ ...prev, occupation: occupation_value }));
|
||||
};
|
||||
|
||||
// 处理职业输入 - 失去焦点时保存到服务器
|
||||
const handle_occupation_blur = (e: any) => {
|
||||
const occupation_value = e.detail.value;
|
||||
handle_field_edit('occupation', occupation_value);
|
||||
};
|
||||
|
||||
// 处理地区输入 - 实时更新本地状态
|
||||
const handle_location_input = (e: any) => {
|
||||
const location_value = e.detail.value;
|
||||
setFormData(prev => ({ ...prev, location: location_value }));
|
||||
};
|
||||
|
||||
// 处理地区输入 - 失去焦点时保存到服务器
|
||||
const handle_location_blur = (e: any) => {
|
||||
const location_value = e.detail.value;
|
||||
handle_field_edit('location', location_value);
|
||||
};
|
||||
|
||||
// 处理手机号输入 - 实时更新本地状态
|
||||
const handle_phone_input = (e: any) => {
|
||||
const phone_value = e.detail.value;
|
||||
setFormData(prev => ({ ...prev, phone: phone_value }));
|
||||
};
|
||||
|
||||
// 处理手机号输入 - 失去焦点时保存到服务器
|
||||
const handle_phone_blur = (e: any) => {
|
||||
const phone_value = e.detail.value;
|
||||
handle_field_edit('phone', phone_value);
|
||||
};
|
||||
|
||||
|
||||
// 处理退出登录
|
||||
const handle_logout = () => {
|
||||
Taro.showModal({
|
||||
@@ -142,8 +236,8 @@ const EditProfilePage: React.FC = () => {
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
// 清除用户数据
|
||||
Taro.removeStorageSync('user_token');
|
||||
Taro.removeStorageSync('user_info');
|
||||
clear_login_state();
|
||||
|
||||
Taro.reLaunch({
|
||||
url: '/pages/login/index/index'
|
||||
});
|
||||
@@ -183,18 +277,13 @@ const EditProfilePage: React.FC = () => {
|
||||
<View className="form_section">
|
||||
{/* 名字 */}
|
||||
<View className="form_group">
|
||||
<View className="form_item">
|
||||
<View className="form_item" onClick={() => handle_open_edit_modal('nickname')}>
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/user1.svg')} />
|
||||
<Text className="item_label">名字</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Input
|
||||
className="item_input"
|
||||
value={form_data.nickname}
|
||||
placeholder="188的王晨"
|
||||
onInput={(e) => handle_input_change('nickname', e.detail.value)}
|
||||
/>
|
||||
<Text className="item_value">{form_data.nickname || '188的王晨'}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
</View>
|
||||
@@ -203,45 +292,60 @@ const EditProfilePage: React.FC = () => {
|
||||
|
||||
{/* 性别 */}
|
||||
<View className="form_group">
|
||||
<View className="form_item">
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/user2.svg')} />
|
||||
<Text className="item_label">性别</Text>
|
||||
<Picker
|
||||
mode="selector"
|
||||
range={['男', '女']}
|
||||
value={form_data.gender === '0' ? 0 : form_data.gender === '1' ? 1 : 0}
|
||||
onChange={handle_gender_change}
|
||||
>
|
||||
<View className="form_item">
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/user2.svg')} />
|
||||
<Text className="item_label">性别</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">
|
||||
{convert_db_gender_to_display(form_data.gender)}
|
||||
</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.gender || '男'}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
</View>
|
||||
</Picker>
|
||||
<View className="divider"></View>
|
||||
</View>
|
||||
|
||||
{/* 生日 */}
|
||||
<View className="form_group">
|
||||
<View className="form_item">
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/tennis.svg')} />
|
||||
<Text className="item_label">生日</Text>
|
||||
<Picker
|
||||
mode="date"
|
||||
value={form_data.birthday}
|
||||
onChange={handle_birthday_change}
|
||||
>
|
||||
<View className="form_item">
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/tennis.svg')} />
|
||||
<Text className="item_label">生日</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.birthday}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.birthday}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
</View>
|
||||
</Picker>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 简介编辑 */}
|
||||
<View className="form_section">
|
||||
<View className="form_group">
|
||||
<View className="form_item" onClick={() => handle_open_edit_modal('bio')}>
|
||||
<View className="form_item" onClick={() => handle_open_edit_modal('personal_profile')}>
|
||||
<View className="item_left">
|
||||
<Image className="item_icon" src={require('../../../static/userInfo/message.svg')} />
|
||||
<Text className="item_label">简介</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">
|
||||
{form_data.bio || '介绍一下自己'}
|
||||
{form_data.personal_profile || '介绍一下自己'}
|
||||
</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
</View>
|
||||
@@ -259,8 +363,13 @@ const EditProfilePage: React.FC = () => {
|
||||
<Text className="item_label">地区</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.location || '上海 黄浦'}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
<Input
|
||||
className="item_input"
|
||||
value={form_data.location}
|
||||
placeholder="请输入地区"
|
||||
onInput={handle_location_input}
|
||||
onBlur={handle_location_blur}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="divider"></View>
|
||||
@@ -285,8 +394,13 @@ const EditProfilePage: React.FC = () => {
|
||||
<Text className="item_label">职业</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.occupation || '互联网'}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
<Input
|
||||
className="item_input"
|
||||
value={form_data.occupation}
|
||||
placeholder="请输入职业"
|
||||
onInput={handle_occupation_input}
|
||||
onBlur={handle_occupation_blur}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -301,8 +415,14 @@ const EditProfilePage: React.FC = () => {
|
||||
<Text className="item_label">手机</Text>
|
||||
</View>
|
||||
<View className="item_right">
|
||||
<Text className="item_value">{form_data.phone || '+86 130 1234 1234'}</Text>
|
||||
<Image className="arrow_icon" src={require('../../../static/list/icon-list-right-arrow.svg')} />
|
||||
<Input
|
||||
className="item_input"
|
||||
value={form_data.phone}
|
||||
placeholder="请输入手机号"
|
||||
type="number"
|
||||
onInput={handle_phone_input}
|
||||
onBlur={handle_phone_blur}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="divider"></View>
|
||||
@@ -322,13 +442,14 @@ const EditProfilePage: React.FC = () => {
|
||||
{/* 编辑弹窗 */}
|
||||
<EditModal
|
||||
visible={edit_modal_visible}
|
||||
title="编辑简介"
|
||||
placeholder="介绍一下你的喜好,或者训练习惯"
|
||||
type={editing_field}
|
||||
title={editing_field === 'nickname' ? '编辑名字' : '编辑简介'}
|
||||
placeholder={editing_field === 'nickname' ? '请输入您的名字' : '介绍一下你的喜好,或者训练习惯'}
|
||||
initialValue={form_data[editing_field as keyof typeof form_data] || ''}
|
||||
maxLength={100}
|
||||
maxLength={editing_field === 'nickname' ? 20 : 100}
|
||||
onSave={handle_edit_modal_save}
|
||||
onCancel={handle_edit_modal_cancel}
|
||||
validationMessage="请填写 2-100 个字符"
|
||||
validationMessage={editing_field === 'nickname' ? '请填写 1-20 个字符' : '请填写 2-100 个字符'}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
@@ -17,14 +17,13 @@
|
||||
margin-top: 0;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
padding: 15px 15px 15px;
|
||||
padding: 0px 15px 15px 15px ;
|
||||
|
||||
// 用户信息区域
|
||||
.user_info_section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
margin-top: 98px;
|
||||
|
||||
// 加载状态
|
||||
@@ -146,7 +145,6 @@
|
||||
|
||||
// 球局类型标签页
|
||||
.game_tabs_section {
|
||||
margin-bottom: 16px;
|
||||
|
||||
.tab_container {
|
||||
display: flex;
|
||||
@@ -12,7 +12,7 @@ import { withAuth } from '@/components';
|
||||
const MyselfPage: React.FC = () => {
|
||||
// 获取页面参数
|
||||
const instance = Taro.getCurrentInstance();
|
||||
const user_id = instance.router?.params?.userid;
|
||||
const user_id = instance.router?.params?.userid || '';
|
||||
|
||||
// 判断是否为当前用户
|
||||
const is_current_user = !user_id;
|
||||
@@ -29,7 +29,6 @@ const MyselfPage: React.FC = () => {
|
||||
hosted: 0,
|
||||
participated: 0
|
||||
},
|
||||
tags: ['加载中...'],
|
||||
bio: '加载中...',
|
||||
location: '加载中...',
|
||||
occupation: '加载中...',
|
||||
@@ -58,9 +57,9 @@ const MyselfPage: React.FC = () => {
|
||||
// 获取球局记录
|
||||
let games_data;
|
||||
if (active_tab === 'hosted') {
|
||||
games_data = await UserService.get_hosted_games(user_id || '1');
|
||||
games_data = await UserService.get_hosted_games(user_id);
|
||||
} else {
|
||||
games_data = await UserService.get_participated_games(user_id || '1');
|
||||
games_data = await UserService.get_participated_games(user_id);
|
||||
}
|
||||
set_game_records(games_data);
|
||||
|
||||
@@ -93,9 +92,9 @@ const MyselfPage: React.FC = () => {
|
||||
try {
|
||||
let games_data;
|
||||
if (active_tab === 'hosted') {
|
||||
games_data = await UserService.get_hosted_games(user_id || '1');
|
||||
games_data = await UserService.get_hosted_games(user_id);
|
||||
} else {
|
||||
games_data = await UserService.get_participated_games(user_id || '1');
|
||||
games_data = await UserService.get_participated_games(user_id);
|
||||
}
|
||||
set_game_records(games_data);
|
||||
} catch (error) {
|
||||
@@ -106,7 +105,7 @@ const MyselfPage: React.FC = () => {
|
||||
// 处理关注/取消关注
|
||||
const handle_follow = async () => {
|
||||
try {
|
||||
const new_following_state = await UserService.toggle_follow(user_id || '1', is_following);
|
||||
const new_following_state = await UserService.toggle_follow(user_id, is_following);
|
||||
setIsFollowing(new_following_state);
|
||||
|
||||
Taro.showToast({
|
||||
@@ -129,14 +128,14 @@ const MyselfPage: React.FC = () => {
|
||||
// 处理球局订单
|
||||
const handle_game_orders = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/orders/index'
|
||||
url: '/mod_user/pages/orders/index'
|
||||
});
|
||||
};
|
||||
|
||||
// 处理收藏
|
||||
const handle_favorites = () => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/favorites/index'
|
||||
url: '/mod_user/pages/favorites/index'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.detail-navigator {
|
||||
@@ -63,34 +65,38 @@
|
||||
left: 0;
|
||||
top: 0;
|
||||
background-size: cover;
|
||||
filter: blur(40px);
|
||||
transform: scale(1.5);
|
||||
// filter: blur(40px);
|
||||
// transform: scale(1.5);
|
||||
z-index: -2;
|
||||
width: calc(100% + 20px);
|
||||
height: calc(100% + 20px);
|
||||
margin: -10px;
|
||||
}
|
||||
// width: calc(100% + 20px);
|
||||
// height: calc(100% + 20px);
|
||||
// margin: -10px;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
|
||||
.detail-page-bg-text {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background: linear-gradient(180deg, rgba(0, 0, 0, 0.80) 0%, rgba(0, 0, 0, 0.40) 100%);
|
||||
backdrop-filter: blur(100px);
|
||||
}
|
||||
}
|
||||
|
||||
.detail-swiper-container {
|
||||
height: 240px;
|
||||
margin-top: 15px;
|
||||
margin-left: 15px;
|
||||
margin-right: 15px;
|
||||
height: 270px;
|
||||
width: 100%;
|
||||
padding: 15px 15px 0;
|
||||
box-sizing: border-box;
|
||||
overflow-x: scroll;
|
||||
|
||||
.detail-swiper-scroll-container {
|
||||
display: flex;
|
||||
height: 240px;
|
||||
height: 250px;
|
||||
width: auto;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
@@ -99,13 +105,10 @@
|
||||
|
||||
.detail-swiper-item {
|
||||
flex: 0 0 auto;
|
||||
max-width: calc(100vw - 30px);
|
||||
max-height: 240px;
|
||||
height: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
border-radius: 12px;
|
||||
transition: transform 0.5s;
|
||||
}
|
||||
@@ -215,7 +218,6 @@
|
||||
display: flex;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -225,6 +227,7 @@
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
overflow: hidden;
|
||||
color: #FFF;
|
||||
background: #536272;
|
||||
|
||||
.month {
|
||||
width: 100%;
|
||||
@@ -235,20 +238,21 @@
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
// border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #7B828B;
|
||||
}
|
||||
|
||||
.day {
|
||||
display: flex;
|
||||
width: 48px;
|
||||
padding-bottom: 6px;
|
||||
height: 30px;
|
||||
// padding-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
// border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
// background: rgba(255, 255, 255, 0.25);
|
||||
// background-color: #536272;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +338,7 @@
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
// border: 0.5px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
// background: rgba(255, 255, 255, 0.25);
|
||||
|
||||
&-image {
|
||||
width: 20px;
|
||||
@@ -399,7 +403,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
&-detail {
|
||||
&-venue {
|
||||
padding: 24px 15px 0;
|
||||
box-sizing: border-box;
|
||||
|
||||
@@ -408,7 +412,7 @@
|
||||
height: 31px;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
padding-bottom: 6px;
|
||||
color: #FFF;
|
||||
text-overflow: ellipsis;
|
||||
@@ -419,9 +423,15 @@
|
||||
line-height: 24px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&-notice-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
.venue-reserve-status {
|
||||
display: inline-flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.venue-reserve-screenshot {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -461,6 +471,51 @@
|
||||
line-height: 24px; /* 160% */
|
||||
}
|
||||
}
|
||||
|
||||
.venue-screenshot-title {
|
||||
display: flex;
|
||||
padding: 18px 20px 10px 20px;
|
||||
align-items: center;
|
||||
align-self: stretch;
|
||||
font-family: "PingFang SC";
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 150% */
|
||||
letter-spacing: -0.23px;
|
||||
}
|
||||
|
||||
.venue-screenshot-scroll-view {
|
||||
max-height: calc(100vh - 260px);
|
||||
overflow-y: auto;
|
||||
padding-bottom: 40px;
|
||||
|
||||
.venue-screenshot-image-list {
|
||||
width: 100%;
|
||||
padding: 0 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px 10px;
|
||||
|
||||
.venue-screenshot-image-item {
|
||||
aspect-ratio: 1/1;
|
||||
border-radius: 9px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
.venue-screenshot-image-item-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 9px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-gameplay-requirements {
|
||||
@@ -591,7 +646,7 @@
|
||||
.participants-list-item {
|
||||
display: flex;
|
||||
width: 108px;
|
||||
padding: 16px 12px 10px 12px;
|
||||
padding: 16px 4px 10px 4px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
@@ -608,6 +663,7 @@
|
||||
}
|
||||
|
||||
&-name {
|
||||
width: 100%;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
text-align: center;
|
||||
font-feature-settings: 'liga' off, 'clig' off;
|
||||
@@ -616,6 +672,9 @@
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 24px; /* 184.615% */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&-level {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
|
||||
import { View, Text, Image, Map, ScrollView } from '@tarojs/components'
|
||||
import { Avatar, Popover } from '@nutui/nutui-react-taro'
|
||||
import { Avatar, Popover, ImagePreview } from '@nutui/nutui-react-taro'
|
||||
import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro'
|
||||
import dayjs from 'dayjs'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
@@ -23,6 +23,106 @@ function insertDotInTags(tags: string[]) {
|
||||
return tags.join('-·-').split('-')
|
||||
}
|
||||
|
||||
function GameTags(props) {
|
||||
const { detail } = props
|
||||
const tags = [{
|
||||
name: '🕙 急招',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '🔥 本周热门',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '🎉 新活动',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '官方组织',
|
||||
icon: '',
|
||||
}]
|
||||
return (
|
||||
<View className='detail-page-content-avatar-tags'>
|
||||
<View className='detail-page-content-avatar-tags-avatar'>
|
||||
{/* network image mock */}
|
||||
<Image className='detail-page-content-avatar-tags-avatar-image' src="https://img.yzcdn.cn/vant/cat.jpeg" />
|
||||
</View>
|
||||
<View className='detail-page-content-avatar-tags-tags'>
|
||||
{tags.map((tag, index) => (
|
||||
<View key={index} className='detail-page-content-avatar-tags-tags-tag'>
|
||||
{tag.icon && <Image src={tag.icon} />}
|
||||
<Text>{tag.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type CourselItemType = {
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
function Coursel(props) {
|
||||
const { detail } = props
|
||||
const [list, setList] = useState<CourselItemType[]>([])
|
||||
const [listWidth, setListWidth] = useState(0)
|
||||
const { image_list } = detail
|
||||
|
||||
async function getImagesMsg (imageList) {
|
||||
const latest_list: CourselItemType[] = []
|
||||
const sys_info = await Taro.getSystemInfo()
|
||||
console.log(sys_info, 'info')
|
||||
const max_width = sys_info.screenWidth - 30
|
||||
const max_height = 240
|
||||
const current_aspect_ratio = max_width / max_height
|
||||
let container_width = 0
|
||||
for (const imageUrl of imageList) {
|
||||
const { width, height } = await Taro.getImageInfo({ src: imageUrl })
|
||||
if (width && height) {
|
||||
const aspect_ratio = width / height
|
||||
const latest_w_h = { width, height }
|
||||
if (aspect_ratio < current_aspect_ratio) {
|
||||
latest_w_h.width = max_height * aspect_ratio
|
||||
latest_w_h.height = max_height
|
||||
} else {
|
||||
latest_w_h.width = max_width
|
||||
latest_w_h.height = max_width / aspect_ratio
|
||||
}
|
||||
container_width += latest_w_h.width + 12
|
||||
latest_list.push({
|
||||
url: imageUrl,
|
||||
width: latest_w_h.width,
|
||||
height: latest_w_h.height,
|
||||
})
|
||||
}
|
||||
}
|
||||
setList(latest_list)
|
||||
setListWidth(container_width)
|
||||
}
|
||||
|
||||
useEffect(() => { getImagesMsg(image_list || []) }, [image_list])
|
||||
|
||||
return (
|
||||
<View className="detail-swiper-container">
|
||||
<View className="detail-swiper-scroll-container" style={{ width: listWidth + 'px' }}>
|
||||
{
|
||||
list.map((item, index) => {
|
||||
return (
|
||||
<View className='detail-swiper-item' key={index}>
|
||||
<Image
|
||||
src={item.url}
|
||||
mode="aspectFill"
|
||||
className='detail-swiper-item-image'
|
||||
style={{ width: item.width + 'px', height: item.height + 'px' }}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
// 分享弹窗
|
||||
const SharePopup = forwardRef(({ id, from }: { id: string, from: string }, ref) => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -86,7 +186,6 @@ function StickyButton(props) {
|
||||
const { publisher_id, match_status, price } = detail || {}
|
||||
|
||||
const role = Number(publisher_id) === id ? 'ownner' : 'visitor'
|
||||
console.log(match_status, role)
|
||||
return (
|
||||
<View className="sticky-bottom-bar">
|
||||
<View className="sticky-bottom-bar-share-and-comment">
|
||||
@@ -214,117 +313,196 @@ function GameInfo(props) {
|
||||
)
|
||||
}
|
||||
|
||||
function Index() {
|
||||
// 使用Zustand store
|
||||
// const userStats = useUserStats()
|
||||
// const { incrementRequestCount, resetUserStats } = useUserActions()
|
||||
// 场馆信息
|
||||
function VenueInfo(props) {
|
||||
const { detail } = props
|
||||
const [visible, setVisible] = useState(false)
|
||||
const { venue_description, venue_description_tag = [], venue_image_list = [] } = detail
|
||||
|
||||
const [current, setCurrent] = useState(0)
|
||||
// const [textColor, setTextColor] = useState<string []>([])
|
||||
const [detail, setDetail] = useState<any>(null)
|
||||
const { params } = useRouter()
|
||||
const [currentLocation, setCurrentLocation] = useState<[number, number]>([0, 0])
|
||||
const { id, autoShare, from } = params
|
||||
const { fetchUserInfo, updateUserInfo } = useUserActions()
|
||||
|
||||
console.group('params')
|
||||
console.log(params)
|
||||
console.groupEnd()
|
||||
|
||||
// 本地状态管理
|
||||
const [loading, setLoading] = useState(false)
|
||||
const sharePopupRef = useRef<any>(null)
|
||||
|
||||
// 页面加载时获取数据
|
||||
// useEffect(() => {
|
||||
// fetchDetail()
|
||||
// calcBgMainColors()
|
||||
// }, [])
|
||||
|
||||
useDidShow(async () => {
|
||||
await updateLocation()
|
||||
await fetchUserInfo()
|
||||
await fetchDetail()
|
||||
// calcBgMainColors()
|
||||
})
|
||||
|
||||
const updateLocation = async () => {
|
||||
try {
|
||||
const location = await getCurrentLocation()
|
||||
setCurrentLocation([location.latitude, location.longitude])
|
||||
await updateUserInfo({ latitude: location.latitude, longitude: location.longitude })
|
||||
console.log('用户位置更新成功')
|
||||
} catch (error) {
|
||||
console.error('用户位置更新失败', error)
|
||||
}
|
||||
function showScreenShot() {
|
||||
setVisible(true)
|
||||
}
|
||||
function onClose() {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const res = await DetailService.getDetail(243/* Number(id) */)
|
||||
if (res.code === 0) {
|
||||
console.log(res.data)
|
||||
setDetail(res.data)
|
||||
}
|
||||
}
|
||||
|
||||
// const calcBgMainColors = async () => {
|
||||
// const textcolors: string[] = []
|
||||
// // for (const index in images) {
|
||||
// // const { textColor } = await getTextColorOnImage(images[index])
|
||||
// // textcolors[index] = textColor
|
||||
// // }
|
||||
// if (detail?.image_list?.length > 0) {
|
||||
// const { textColor } = await getTextColorOnImage(detail.image_list[0])
|
||||
// textcolors[0] = textColor
|
||||
// }
|
||||
// setColors(textcolors)
|
||||
// }
|
||||
|
||||
function handleShare() {
|
||||
sharePopupRef.current.show()
|
||||
}
|
||||
|
||||
const handleJoinGame = () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/orderCheck/index?gameId=${243/* id */}`,
|
||||
function previewImage(current_url) {
|
||||
Taro.previewImage({
|
||||
current: current_url,
|
||||
urls: venue_image_list.map(c => c.url),
|
||||
})
|
||||
}
|
||||
return (
|
||||
<View className='detail-page-content-venue'>
|
||||
{/* venue detail title and venue ordered status */}
|
||||
<View className='venue-detail-title'>
|
||||
<Text>场馆详情</Text>
|
||||
{venue_image_list?.length > 0 ?
|
||||
<>
|
||||
<Text>·</Text>
|
||||
<View className="venue-reserve-status" onClick={showScreenShot}>
|
||||
<Text>已订场</Text>
|
||||
<Image className="venue-reserve-screenshot" src={img.ICON_DETAIL_ARROW_RIGHT} />
|
||||
</View>
|
||||
</>
|
||||
:
|
||||
''
|
||||
}
|
||||
</View>
|
||||
{/* venue detail content */}
|
||||
<View className='venue-detail-content'>
|
||||
{/* venue detail tags */}
|
||||
<View className='venue-detail-content-tags'>
|
||||
{insertDotInTags(venue_description_tag).map((tag, index) => (
|
||||
<View key={index} className='venue-detail-content-tags-tag'>
|
||||
<Text>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* venue remarks */}
|
||||
<View className='venue-detail-content-remarks'>
|
||||
<Text>{venue_description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
round
|
||||
hideFooter
|
||||
position='bottom'
|
||||
zIndex={1001}
|
||||
>
|
||||
<View className="venue-screenshot-title">预定截图</View>
|
||||
<ScrollView
|
||||
scrollY
|
||||
className="venue-screenshot-scroll-view"
|
||||
>
|
||||
<View className="venue-screenshot-image-list">
|
||||
{venue_image_list.map(item => {
|
||||
return (
|
||||
<View className="venue-screenshot-image-item" onClick={previewImage.bind(null, item.url)}>
|
||||
<Image className="venue-screenshot-image-item-image" src={item.url} />
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</CommonPopup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const tags = [{
|
||||
name: '🕙 急招',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '🔥 本周热门',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '🎉 新活动',
|
||||
icon: '',
|
||||
}, {
|
||||
name: '官方组织',
|
||||
icon: '',
|
||||
}]
|
||||
function genNTRPRequirementText(min, max) {
|
||||
if (min && max) {
|
||||
return `${min} - ${max} 之间`
|
||||
} else if (max) {
|
||||
return `${max} 以上`
|
||||
}
|
||||
return '没有要求'
|
||||
}
|
||||
// 玩法要求
|
||||
function GamePlayAndRequirement(props) {
|
||||
const { detail: { skill_level_min, skill_level_max, play_type, game_type } } = props
|
||||
|
||||
const detailTags = ['室内', '硬地', '2号场', '有停车场', '有淋浴间', '有更衣室']
|
||||
const requirements = [
|
||||
{
|
||||
title: 'NTRP水平要求',
|
||||
desc: genNTRPRequirementText(skill_level_min, skill_level_max),
|
||||
},
|
||||
{
|
||||
title: '活动玩法',
|
||||
desc: play_type || '-',
|
||||
},
|
||||
{
|
||||
title: '人员构成',
|
||||
desc: game_type || '-',
|
||||
}
|
||||
]
|
||||
return (
|
||||
<View className='detail-page-content-gameplay-requirements'>
|
||||
{/* title */}
|
||||
<View className="gameplay-requirements-title">
|
||||
<Text>玩法要求</Text>
|
||||
</View>
|
||||
{/* requirements */}
|
||||
<View className='gameplay-requirements'>
|
||||
{requirements.map((item, index) => (
|
||||
<View key={index} className='gameplay-requirements-item'>
|
||||
<Text className='gameplay-requirements-item-title'>{item.title}</Text>
|
||||
<Text className='gameplay-requirements-item-desc'>{item.desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const { title, longitude, latitude } = detail || {}
|
||||
// 参与者
|
||||
function Participants(props) {
|
||||
const { detail = {} } = props
|
||||
const participants = detail.participants || []
|
||||
const organizer_id = Number(detail.publisher_id)
|
||||
return (
|
||||
<View className='detail-page-content-participants'>
|
||||
<View className='participants-title'>
|
||||
<Text>参与者</Text>
|
||||
<Text>·</Text>
|
||||
<Text>剩余空位 3</Text>
|
||||
</View>
|
||||
<View className='participants-list'>
|
||||
{/* application */}
|
||||
<View className='participants-list-application' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
|
||||
<Image className='participants-list-application-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
|
||||
<Text className='participants-list-application-text'>申请加入</Text>
|
||||
</View>
|
||||
{/* participants list */}
|
||||
<ScrollView className='participants-list-scroll' scrollX>
|
||||
<View className='participants-list-scroll-content' style={{ width: `${participants.length * 103 + (participants.length - 1) * 8}px` }}>
|
||||
{participants.map((participant) => {
|
||||
const { user: { avatar_url, nickname, level, id: participant_user_id } } = participant
|
||||
const role = participant_user_id === organizer_id ? '组织者' : '参与者'
|
||||
return (
|
||||
<View key={participant.id} className='participants-list-item'>
|
||||
<Avatar className='participants-list-item-avatar' src={avatar_url} />
|
||||
<Text className='participants-list-item-name'>{nickname || '未知'}</Text>
|
||||
<Text className='participants-list-item-level'>{level || '未知'}</Text>
|
||||
<Text className='participants-list-item-role'>{role}</Text>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
console.log(longitude, latitude, 2222)
|
||||
|
||||
const requirements = [{
|
||||
title: 'NTRP水平要求',
|
||||
desc: '2.0 - 4.5 之间',
|
||||
}, {
|
||||
title: '活动玩法',
|
||||
desc: '双打',
|
||||
}, {
|
||||
title: '人员构成',
|
||||
desc: '个人球局 · 组织者参与活动',
|
||||
}]
|
||||
|
||||
const participants = detail?.participants || []
|
||||
|
||||
const supplementalNotesTags = ['仅限男生', '装备自备', '其他']
|
||||
function SupplementalNotes(props) {
|
||||
const { detail: { description, description_tag = [] } } = props
|
||||
return (
|
||||
<View className='detail-page-content-supplemental-notes'>
|
||||
<View className='supplemental-notes-title'>
|
||||
<Text>补充说明</Text>
|
||||
</View>
|
||||
<View className='supplemental-notes-content'>
|
||||
{/* supplemental notes tags */}
|
||||
<View className='supplemental-notes-content-tags'>
|
||||
{insertDotInTags(description_tag).map((tag, index) => (
|
||||
<View key={index} className='supplemental-notes-content-tags-tag'>
|
||||
<Text>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* supplemental notes content */}
|
||||
<View className='supplemental-notes-content-text'>
|
||||
<Text>{description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function OrganizerInfo(props) {
|
||||
const recommendGames = [
|
||||
{
|
||||
title: '黄浦日场对拉',
|
||||
@@ -366,6 +544,126 @@ function Index() {
|
||||
playType: '双打',
|
||||
},
|
||||
]
|
||||
return (
|
||||
<View className='detail-page-content-organizer-recommend-games'>
|
||||
{/* orgnizer title */}
|
||||
<View className='organizer-title'>
|
||||
<Text>组织者</Text>
|
||||
</View>
|
||||
{/* organizer avatar and name */}
|
||||
<View className='organizer-avatar-name'>
|
||||
<Avatar className='organizer-avatar-name-avatar' src="https://img.yzcdn.cn/vant/cat.jpeg" />
|
||||
<View className='organizer-avatar-name-message'>
|
||||
<Text className='organizer-avatar-name-message-name'>Light</Text>
|
||||
<View className='organizer-avatar-name-message-stats'>
|
||||
<Text>已组织 8 次</Text>
|
||||
<View className='organizer-avatar-name-message-stats-separator' />
|
||||
<Text>NTRP 3.5</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="organizer-actions">
|
||||
<View className="organizer-actions-follow">
|
||||
<Image className='organizer-actions-follow-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
|
||||
<Text className='organizer-actions-follow-text'>关注</Text>
|
||||
</View>
|
||||
<View className="organizer-actions-comment">
|
||||
<Image className='organizer-actions-comment-icon' src={img.ICON_DETAIL_COMMENT} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{/* recommend games by organizer */}
|
||||
<View className='organizer-recommend-games'>
|
||||
<View className='organizer-recommend-games-title'>
|
||||
<Text>TA的更多活动</Text>
|
||||
<Image className='organizer-recommend-games-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
|
||||
</View>
|
||||
<ScrollView className='recommend-games-list' scrollX>
|
||||
<View className='recommend-games-list-content'>
|
||||
{recommendGames.map((game, index) => (
|
||||
<View key={index} className='recommend-games-list-item'>
|
||||
{/* game title */}
|
||||
<View className='recommend-games-list-item-title'>
|
||||
<Text>{game.title}</Text>
|
||||
<Image className='recommend-games-list-item-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
|
||||
</View>
|
||||
{/* game time and range */}
|
||||
<View className='recommend-games-list-item-time-range'>
|
||||
<Text>{game.time}</Text>
|
||||
<Text>{game.timeLength}</Text>
|
||||
</View>
|
||||
{/* game location、vunue、distance */}
|
||||
<View className='recommend-games-list-item-location-venue-distance'>
|
||||
<Text>{game.venue}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{game.veuneType}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{game.distance}</Text>
|
||||
</View>
|
||||
{/* organizer avatar、applications、level requirements、play type */}
|
||||
<View className='recommend-games-list-item-addon'>
|
||||
<Avatar className='recommend-games-list-item-addon-avatar' src={game.avatar} />
|
||||
<View className='recommend-games-list-item-addon-message'>
|
||||
<View className='recommend-games-list-item-addon-message-applications'>
|
||||
<Text>报名人数 {game.checkedApplications}/{game.applications}</Text>
|
||||
</View>
|
||||
<View className='recommend-games-list-item-addon-message-level-requirements'>
|
||||
<Text>{game.levelRequirements}</Text>
|
||||
</View>
|
||||
<View className='recommend-games-list-item-addon-message-play-type'>
|
||||
<Text>{game.playType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function Index() {
|
||||
const [detail, setDetail] = useState<any>({})
|
||||
const { params } = useRouter()
|
||||
const [currentLocation, setCurrentLocation] = useState<[number, number]>([0, 0])
|
||||
const { id, from } = params
|
||||
const { fetchUserInfo, updateUserInfo } = useUserActions()
|
||||
|
||||
const sharePopupRef = useRef<any>(null)
|
||||
|
||||
useDidShow(async () => {
|
||||
await updateLocation()
|
||||
await fetchUserInfo()
|
||||
await fetchDetail()
|
||||
})
|
||||
|
||||
const updateLocation = async () => {
|
||||
try {
|
||||
const location = await getCurrentLocation()
|
||||
setCurrentLocation([location.latitude, location.longitude])
|
||||
await updateUserInfo({ latitude: location.latitude, longitude: location.longitude })
|
||||
} catch (error) {
|
||||
console.error('用户位置更新失败', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const res = await DetailService.getDetail(Number(id))
|
||||
if (res.code === 0) {
|
||||
setDetail(res.data)
|
||||
}
|
||||
}
|
||||
|
||||
function handleShare() {
|
||||
sharePopupRef.current.show()
|
||||
}
|
||||
|
||||
const handleJoinGame = () => {
|
||||
Taro.navigateTo({
|
||||
url: `/pages/orderCheck/index?gameId=${id}`,
|
||||
})
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
const pages = Taro.getCurrentPages()
|
||||
@@ -380,6 +678,8 @@ function Index() {
|
||||
|
||||
|
||||
console.log('detail', detail)
|
||||
const backgroundImage = detail?.image_list?.[0] ? { backgroundImage: `url(${detail?.image_list?.[0]})` } : {}
|
||||
|
||||
return (
|
||||
<View className='detail-page'>
|
||||
{/* custom navbar */}
|
||||
@@ -393,248 +693,29 @@ function Index() {
|
||||
</View>
|
||||
</View>
|
||||
</view>
|
||||
<View className='detail-page-bg' style={detail?.image_list?.[0] ? { backgroundImage: `url(${detail?.image_list?.[0]})` } : {}} />
|
||||
<View className='detail-page-bg-text' />
|
||||
<View className='detail-page-bg' style={backgroundImage} />
|
||||
{/* swiper */}
|
||||
<View className="detail-swiper-container">
|
||||
<View className="detail-swiper-scroll-container">
|
||||
{
|
||||
detail?.image_list?.length > 0 && detail?.image_list.map((item, index) => {
|
||||
return (
|
||||
<View className='detail-swiper-item' key={index}>
|
||||
<Image
|
||||
src={item}
|
||||
mode="aspectFill"
|
||||
className='detail-swiper-item-image'
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
}
|
||||
</View>
|
||||
</View>
|
||||
{/* <Swiper
|
||||
className='detail-swiper'
|
||||
indicatorDots={false}
|
||||
circular
|
||||
nextMargin="20px"
|
||||
onChange={(e) => { setCurrent(e.detail.current) }}
|
||||
>
|
||||
{images.map((imageUrl, index) => (
|
||||
<SwiperItem
|
||||
key={index}
|
||||
className='detail-swiper-item'
|
||||
>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
mode="aspectFill"
|
||||
className='detail-swiper-item-image'
|
||||
style={{
|
||||
transform: index !== current ? 'scale(0.8) translateX(-12%)' : 'scale(0.95)', // 前后图缩小
|
||||
}}
|
||||
/>
|
||||
</SwiperItem>
|
||||
))}
|
||||
</Swiper> */}
|
||||
<Coursel detail={detail} />
|
||||
{/* content */}
|
||||
<View className='detail-page-content'>
|
||||
{/* avatar and tags */}
|
||||
<View className='detail-page-content-avatar-tags'>
|
||||
<View className='detail-page-content-avatar-tags-avatar'>
|
||||
{/* network image mock */}
|
||||
<Image className='detail-page-content-avatar-tags-avatar-image' src="https://img.yzcdn.cn/vant/cat.jpeg" />
|
||||
</View>
|
||||
<View className='detail-page-content-avatar-tags-tags'>
|
||||
{tags.map((tag, index) => (
|
||||
<View key={index} className='detail-page-content-avatar-tags-tags-tag'>
|
||||
{tag.icon && <Image src={tag.icon} />}
|
||||
<Text>{tag.name}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<GameTags detail={detail} />
|
||||
{/* title */}
|
||||
<View className='detail-page-content-title'>
|
||||
<Text className='detail-page-content-title-text'>{title}</Text>
|
||||
<Text className='detail-page-content-title-text'>{detail.title}</Text>
|
||||
</View>
|
||||
{/* Date and Place and weather */}
|
||||
<GameInfo detail={detail} currentLocation={currentLocation} />
|
||||
{/* detail */}
|
||||
<View className='detail-page-content-detail'>
|
||||
{/* venue detail title and venue ordered status */}
|
||||
<View className='venue-detail-title'>
|
||||
<Text>场馆详情</Text>
|
||||
<Text>·</Text>
|
||||
<Text>已订场</Text>
|
||||
<Popover
|
||||
title="场地预定截图"
|
||||
description={<View>
|
||||
<Image src="https://img.yzcdn.cn/vant/cat.jpeg" />
|
||||
</View>}
|
||||
location='top'
|
||||
visible={false}
|
||||
>
|
||||
<Image className='venue-detail-title-notice-icon' src={img.ICON_DETAIL_NOTICE} />
|
||||
</Popover>
|
||||
</View>
|
||||
{/* venue detail content */}
|
||||
<View className='venue-detail-content'>
|
||||
{/* venue detail tags */}
|
||||
<View className='venue-detail-content-tags'>
|
||||
{insertDotInTags(detailTags).map((tag, index) => (
|
||||
<View key={index} className='venue-detail-content-tags-tag'>
|
||||
<Text>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* venue remarks */}
|
||||
<View className='venue-detail-content-remarks'>
|
||||
<Text>其他这是用户在场地补充描述里自己写的东西啦啦啦啦啦啦啦啦啦啦啦啦</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<VenueInfo detail={detail} />
|
||||
{/* gameplay requirements */}
|
||||
<View className='detail-page-content-gameplay-requirements'>
|
||||
{/* title */}
|
||||
<View className="gameplay-requirements-title">
|
||||
<Text>玩法要求</Text>
|
||||
</View>
|
||||
{/* requirements */}
|
||||
<View className='gameplay-requirements'>
|
||||
{requirements.map((item, index) => (
|
||||
<View key={index} className='gameplay-requirements-item'>
|
||||
<Text className='gameplay-requirements-item-title'>{item.title}</Text>
|
||||
<Text className='gameplay-requirements-item-desc'>{item.desc}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<GamePlayAndRequirement detail={detail} />
|
||||
{/* participants */}
|
||||
<View className='detail-page-content-participants'>
|
||||
<View className='participants-title'>
|
||||
<Text>参与者</Text>
|
||||
<Text>·</Text>
|
||||
<Text>剩余空位 3</Text>
|
||||
</View>
|
||||
<View className='participants-list'>
|
||||
{/* application */}
|
||||
<View className='participants-list-application' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
|
||||
<Image className='participants-list-application-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
|
||||
<Text className='participants-list-application-text'>申请加入</Text>
|
||||
</View>
|
||||
{/* participants list */}
|
||||
<ScrollView className='participants-list-scroll' scrollX>
|
||||
<View className='participants-list-scroll-content' style={{ width: `${participants.length * 103 + (participants.length - 1) * 8}px` }}>
|
||||
{participants.map((participant) => (
|
||||
<View key={participant.id} className='participants-list-item'>
|
||||
{/* <Avatar className='participants-list-item-avatar' src={participant.user.avatar_url} /> */}
|
||||
{/* network image mock random */}
|
||||
<Avatar className='participants-list-item-avatar' src={`https://picsum.photos/800/600?random=${participant.id}`} />
|
||||
<Text className='participants-list-item-name'>{participant.user.nickname || '未知'}</Text>
|
||||
<Text className='participants-list-item-level'>{participant.level || '未知'}</Text>
|
||||
<Text className='participants-list-item-role'>{participant.role || '参与者'}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
<Participants detail={detail} />
|
||||
{/* supplemental notes */}
|
||||
<View className='detail-page-content-supplemental-notes'>
|
||||
<View className='supplemental-notes-title'>
|
||||
<Text>补充说明</Text>
|
||||
</View>
|
||||
<View className='supplemental-notes-content'>
|
||||
{/* supplemental notes tags */}
|
||||
<View className='supplemental-notes-content-tags'>
|
||||
{insertDotInTags(supplementalNotesTags).map((tag, index) => (
|
||||
<View key={index} className='supplemental-notes-content-tags-tag'>
|
||||
<Text>{tag}</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{/* supplemental notes content */}
|
||||
<View className='supplemental-notes-content-text'>
|
||||
<Text>其他这是用户在补充说明里自己写的东西啦啦啦啦啦啦啦啦啦啦啦啦其他这是用户在补充说明里自己写的东西啦啦啦啦啦啦啦啦啦啦啦啦</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<SupplementalNotes detail={detail} />
|
||||
{/* organizer and recommend games by organizer */}
|
||||
<View className='detail-page-content-organizer-recommend-games'>
|
||||
{/* orgnizer title */}
|
||||
<View className='organizer-title'>
|
||||
<Text>组织者</Text>
|
||||
</View>
|
||||
{/* organizer avatar and name */}
|
||||
<View className='organizer-avatar-name'>
|
||||
<Avatar className='organizer-avatar-name-avatar' src="https://img.yzcdn.cn/vant/cat.jpeg" />
|
||||
<View className='organizer-avatar-name-message'>
|
||||
<Text className='organizer-avatar-name-message-name'>Light</Text>
|
||||
<View className='organizer-avatar-name-message-stats'>
|
||||
<Text>已组织 8 次</Text>
|
||||
<View className='organizer-avatar-name-message-stats-separator' />
|
||||
<Text>NTRP 3.5</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className="organizer-actions">
|
||||
<View className="organizer-actions-follow">
|
||||
<Image className='organizer-actions-follow-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
|
||||
<Text className='organizer-actions-follow-text'>关注</Text>
|
||||
</View>
|
||||
<View className="organizer-actions-comment">
|
||||
<Image className='organizer-actions-comment-icon' src={img.ICON_DETAIL_COMMENT} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{/* recommend games by organizer */}
|
||||
<View className='organizer-recommend-games'>
|
||||
<View className='organizer-recommend-games-title'>
|
||||
<Text>TA的更多活动</Text>
|
||||
<Image className='organizer-recommend-games-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
|
||||
</View>
|
||||
<ScrollView className='recommend-games-list' scrollX>
|
||||
<View className='recommend-games-list-content'>
|
||||
{recommendGames.map((game, index) => (
|
||||
<View key={index} className='recommend-games-list-item'>
|
||||
{/* game title */}
|
||||
<View className='recommend-games-list-item-title'>
|
||||
<Text>{game.title}</Text>
|
||||
<Image className='recommend-games-list-item-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
|
||||
</View>
|
||||
{/* game time and range */}
|
||||
<View className='recommend-games-list-item-time-range'>
|
||||
<Text>{game.time}</Text>
|
||||
<Text>{game.timeLength}</Text>
|
||||
</View>
|
||||
{/* game location、vunue、distance */}
|
||||
<View className='recommend-games-list-item-location-venue-distance'>
|
||||
<Text>{game.venue}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{game.veuneType}</Text>
|
||||
<Text>·</Text>
|
||||
<Text>{game.distance}</Text>
|
||||
</View>
|
||||
{/* organizer avatar、applications、level requirements、play type */}
|
||||
<View className='recommend-games-list-item-addon'>
|
||||
<Avatar className='recommend-games-list-item-addon-avatar' src={game.avatar} />
|
||||
<View className='recommend-games-list-item-addon-message'>
|
||||
<View className='recommend-games-list-item-addon-message-applications'>
|
||||
<Text>报名人数 {game.checkedApplications}/{game.applications}</Text>
|
||||
</View>
|
||||
<View className='recommend-games-list-item-addon-message-level-requirements'>
|
||||
<Text>{game.levelRequirements}</Text>
|
||||
</View>
|
||||
<View className='recommend-games-list-item-addon-message-play-type'>
|
||||
<Text>{game.playType}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
<OrganizerInfo detail={detail} />
|
||||
{/* sticky bottom action bar */}
|
||||
<StickyButton handleShare={handleShare} handleJoinGame={handleJoinGame} detail={detail} />
|
||||
{/* share popup */}
|
||||
|
||||
7
src/pages/home/index.config.ts
Normal file
7
src/pages/home/index.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '请等待',
|
||||
enablePullDownRefresh: true,
|
||||
backgroundTextStyle: 'dark',
|
||||
navigationStyle: 'custom',
|
||||
})
|
||||
|
||||
21
src/pages/home/index.tsx
Normal file
21
src/pages/home/index.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import { View, } from '@tarojs/components';
|
||||
import { check_login_status } from '../../services/loginService';
|
||||
import Taro from '@tarojs/taro';
|
||||
const HomePage: React.FC = () => {
|
||||
let login_status = check_login_status()
|
||||
if (login_status) {
|
||||
Taro.redirectTo({ url: '/pages/list/index' })
|
||||
}
|
||||
else {
|
||||
Taro.redirectTo({ url: '/pages/login/index/index' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="home_page">
|
||||
|
||||
</View>)
|
||||
|
||||
}
|
||||
|
||||
export default HomePage;
|
||||
@@ -14,11 +14,16 @@ import { withAuth } from "@/components";
|
||||
// import img from "@/config/images";
|
||||
// import ShareCardCanvas from "@/components/ShareCardCanvas/example";
|
||||
|
||||
|
||||
|
||||
|
||||
const ListPage = () => {
|
||||
|
||||
// 从 store 获取数据和方法
|
||||
const store = useListStore() || {};
|
||||
|
||||
const { statusNavbarHeightInfo, location = {}, getCurrentLocationInfo } = useGlobalState() || {};
|
||||
const { statusNavbarHeightInfo, getCurrentLocationInfo } = useGlobalState() || {};
|
||||
|
||||
const { totalHeight } = statusNavbarHeightInfo || {};
|
||||
const {
|
||||
isShowFilterPopup,
|
||||
@@ -117,7 +122,7 @@ const ListPage = () => {
|
||||
updateFilterOptions(params);
|
||||
};
|
||||
|
||||
const handleSearchChange = () => {};
|
||||
const handleSearchChange = () => { };
|
||||
|
||||
// 距离筛选
|
||||
const handleDistanceOrQuickChange = (name, value) => {
|
||||
@@ -142,9 +147,8 @@ const ListPage = () => {
|
||||
{/* <ShareCardCanvas /> */}
|
||||
<View className={styles.listPage}>
|
||||
<View
|
||||
className={`${styles.listTopSearchWrapper} ${
|
||||
isScrollTop ? styles.isScroll : ""
|
||||
}`}
|
||||
className={`${styles.listTopSearchWrapper} ${isScrollTop ? styles.isScroll : ""
|
||||
}`}
|
||||
>
|
||||
<SearchBar
|
||||
handleFilterIcon={toggleShowPopup}
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
# 登录页面 - 基于 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/list/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 服务架构,提供了更好的开发体验和运行稳定性。
|
||||
@@ -1,95 +0,0 @@
|
||||
|
||||
// 授权接口
|
||||
|
||||
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"
|
||||
});
|
||||
@@ -45,12 +45,11 @@ const LoginPage: React.FC = () => {
|
||||
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
console.log('redirect:', decodeURIComponent(redirect))
|
||||
Taro.redirectTo({ url: decodeURIComponent(redirect) });
|
||||
} else {
|
||||
Taro.redirectTo({ url: '/pages/list/index' });
|
||||
}
|
||||
}, 200);
|
||||
}, 10);
|
||||
} else {
|
||||
Taro.showToast({
|
||||
title: response.message,
|
||||
@@ -118,9 +117,9 @@ const LoginPage: React.FC = () => {
|
||||
return (
|
||||
<View className="login_page">
|
||||
<View className="background_image">
|
||||
<Image
|
||||
<Image
|
||||
className="bg_img"
|
||||
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/2e00dea1-8723-42fe-ae42-84fe38e9ac3f.png"
|
||||
src={require('../../../static/login/login_bg.jpg')}
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<View className="bg_overlay"></View>
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# 登录页面流程串接说明
|
||||
|
||||
## 整体流程概述
|
||||
|
||||
登录系统支持两种登录方式:
|
||||
1. **微信快捷登录** - 主要登录方式
|
||||
2. **手机号验证码登录** - 备选登录方式
|
||||
|
||||
## 流程详细说明
|
||||
|
||||
### 1. 微信快捷登录流程
|
||||
|
||||
```
|
||||
用户点击微信登录按钮
|
||||
↓
|
||||
检查用户协议是否同意
|
||||
↓ (未同意则显示协议浮层)
|
||||
调用 Taro.login() 获取微信 code
|
||||
↓
|
||||
调用 Taro.getUserProfile() 获取用户信息
|
||||
↓
|
||||
使用 httpService.post() 调用后端接口 /api/user/wx_auth
|
||||
↓
|
||||
保存登录状态到本地存储
|
||||
↓
|
||||
跳转到首页 /pages/list/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/list/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/list/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 管理功能
|
||||
@@ -1,333 +0,0 @@
|
||||
# 登录流程测试配置
|
||||
|
||||
## 测试环境配置
|
||||
|
||||
### 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 管理功能
|
||||
□ 加载状态管理
|
||||
□ 环境配置支持
|
||||
□ 模拟模式支持
|
||||
|
||||
配置验证:
|
||||
□ 开发环境配置
|
||||
□ 测试环境配置
|
||||
□ 生产环境配置
|
||||
□ 超时配置
|
||||
□ 日志配置
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, Input, Button, Image } from '@tarojs/components';
|
||||
import Taro, { useRouter } from '@tarojs/taro';
|
||||
import { phone_auth_login, send_sms_code } from '../../../services/loginService';
|
||||
import { phone_auth_login, send_sms_code, save_login_state } from '../../../services/loginService';
|
||||
import './index.scss';
|
||||
|
||||
const VerificationPage: React.FC = () => {
|
||||
@@ -119,19 +119,26 @@ const VerificationPage: React.FC = () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 调用登录服务
|
||||
const result = await phone_auth_login({ phone, verification_code });
|
||||
|
||||
// 先进行微信登录获取code
|
||||
const login_result = await Taro.login();
|
||||
|
||||
if (!login_result.code) {
|
||||
return {
|
||||
success: false,
|
||||
message: '微信登录失败'
|
||||
};
|
||||
}
|
||||
|
||||
const result = await phone_auth_login({ phone, verification_code, user_code: login_result.code });
|
||||
|
||||
if (result.success) {
|
||||
|
||||
save_login_state(result.token!, result.user_info!)
|
||||
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
Taro.redirectTo({ url: decodeURIComponent(redirect) });
|
||||
} else {
|
||||
Taro.redirectTo({
|
||||
url: '/pages/list/index'
|
||||
});
|
||||
}
|
||||
Taro.redirectTo({
|
||||
url: '/pages/list/index'
|
||||
});
|
||||
}, 200);
|
||||
} else {
|
||||
Taro.showToast({
|
||||
|
||||
@@ -98,8 +98,8 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
const [formData, setFormData] = useState({
|
||||
name: stadium.name,
|
||||
address: stadium.address,
|
||||
latitude: stadium.longitude,
|
||||
longitude: stadium.latitude,
|
||||
latitude: stadium.latitude,
|
||||
longitude: stadium.longitude,
|
||||
istance: stadium.distance_km,
|
||||
court_type: court_type[0] || '',
|
||||
court_surface: court_surface[0] || '',
|
||||
@@ -230,8 +230,9 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
}
|
||||
}}
|
||||
maxCount={9}
|
||||
source={['album', 'history', 'preset']}
|
||||
source={['album', 'history']}
|
||||
align='left'
|
||||
tag="screenshot"
|
||||
/>
|
||||
</SectionContainer>
|
||||
)
|
||||
|
||||
@@ -11,13 +11,23 @@ interface FormSwitchProps {
|
||||
|
||||
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wechatId }) => {
|
||||
const [editWechat, setEditWechat] = useState(false)
|
||||
const [wechatIdValue, setWechatIdValue] = useState('')
|
||||
const [wechat, setWechat] = useState(wechatId)
|
||||
const editWechatId = () => {
|
||||
|
||||
setEditWechat(true)
|
||||
}
|
||||
const setWechatId = useCallback((e: any) => {
|
||||
const value = e.target.value
|
||||
onChange(value)
|
||||
}, [])
|
||||
onChange && onChange(value)
|
||||
setWechatIdValue(value)
|
||||
}, [onChange])
|
||||
|
||||
const fillWithPhone = () => {
|
||||
if (wechat) {
|
||||
setWechatIdValue(wechat)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className={styles['wechat-contact-section']}>
|
||||
@@ -32,7 +42,7 @@ const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wech
|
||||
</View>
|
||||
</View>
|
||||
{
|
||||
wechatId && (
|
||||
!editWechat && wechatId && (
|
||||
<View className={styles['wechat-contact-id']}>
|
||||
<Text className={styles['wechat-contact-text']}>微信号: {wechatId.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}</Text>
|
||||
<View className={styles['wechat-contact-edit']} onClick={editWechatId}>修改</View>
|
||||
@@ -41,8 +51,11 @@ const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wech
|
||||
}
|
||||
{
|
||||
editWechat && (
|
||||
<View className={styles['wechat-contact-edit']}>
|
||||
<Input value={wechatId} onInput={setWechatId} />
|
||||
<View className={styles['wechat-contact-id']}>
|
||||
<View className={styles['wechat-contact-edit-input']}>
|
||||
<Input value={wechatIdValue} onInput={setWechatId} placeholder='请输入正确微信号' />
|
||||
</View>
|
||||
<View className={styles['wechat-contact-edit']} onClick={fillWithPhone}>手机号填入:{wechat}</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.wechat-contact-section {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
@@ -86,6 +88,13 @@
|
||||
border-radius: 100px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.wechat-contact-edit-input {
|
||||
max-width: 200px;
|
||||
font-size: 12px;
|
||||
.input-placeholder{
|
||||
color: theme.$textarea-placeholder-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import PublishService from '@/services/publishService';
|
||||
import { getNextHourTime, getEndTime, delay } from '@/utils';
|
||||
import images from '@/config/images'
|
||||
import styles from './index.module.scss'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const defaultFormData: PublishBallFormData = {
|
||||
title: '',
|
||||
@@ -164,7 +165,7 @@ const PublishBall: React.FC = () => {
|
||||
}
|
||||
|
||||
const validateFormData = (formData: PublishBallFormData, isOnSubmit: boolean = false) => {
|
||||
const { activityInfo, image_list, title } = formData;
|
||||
const { activityInfo, image_list, title, timeRange } = formData;
|
||||
const { play_type, price, location_name } = activityInfo;
|
||||
if (!image_list?.length) {
|
||||
if (!isOnSubmit) {
|
||||
@@ -211,6 +212,30 @@ const PublishBall: React.FC = () => {
|
||||
}
|
||||
return false
|
||||
}
|
||||
// 时间范围校验:结束时间需晚于开始时间,且至少间隔30分钟(支持跨天)
|
||||
if (timeRange?.start_time && timeRange?.end_time) {
|
||||
const start = dayjs(timeRange.start_time)
|
||||
const end = dayjs(timeRange.end_time)
|
||||
if (!end.isAfter(start)) {
|
||||
if (!isOnSubmit) {
|
||||
Taro.showToast({
|
||||
title: `结束时间需晚于开始时间`,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (end.isBefore(start.add(30, 'minute'))) {
|
||||
if (!isOnSubmit) {
|
||||
Taro.showToast({
|
||||
title: `时间间隔至少30分钟`,
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
const validateOnSubmit = () => {
|
||||
|
||||
@@ -91,8 +91,21 @@ const PublishForm: React.FC<{
|
||||
})
|
||||
}
|
||||
|
||||
const getNTRPText = (ntrp: [number, number]) => {
|
||||
const [min, max] = ntrp
|
||||
const getNTRPText = (ntrp: [number, number] | any) => {
|
||||
// 检查 ntrp 是否为数组
|
||||
if (!Array.isArray(ntrp) || ntrp.length !== 2) {
|
||||
console.warn('getNTRPText: ntrp 不是有效的数组格式:', ntrp);
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
const [min, max] = ntrp;
|
||||
|
||||
// 检查 min 和 max 是否为有效数字
|
||||
if (typeof min !== 'number' || typeof max !== 'number') {
|
||||
console.warn('getNTRPText: min 或 max 不是有效数字:', { min, max });
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
if (min === 1.0 && max === 5.0) {
|
||||
return '不限'
|
||||
}
|
||||
@@ -112,12 +125,24 @@ const PublishForm: React.FC<{
|
||||
return `${min.toFixed(1)} - ${max.toFixed(1)}之间`
|
||||
}
|
||||
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const getPlayersText = (players: [number, number]) => {
|
||||
const [min, max] = players
|
||||
const getPlayersText = (players: [number, number] | any) => {
|
||||
// 检查 players 是否为数组
|
||||
if (!Array.isArray(players) || players.length !== 2) {
|
||||
console.warn('getPlayersText: players 不是有效的数组格式:', players);
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
const [min, max] = players;
|
||||
|
||||
// 检查 min 和 max 是否为有效数字
|
||||
if (typeof min !== 'number' || typeof max !== 'number') {
|
||||
console.warn('getPlayersText: min 或 max 不是有效数字:', { min, max });
|
||||
return '未设置';
|
||||
}
|
||||
|
||||
return `最少${min}人,最多${max}人`
|
||||
}
|
||||
|
||||
@@ -136,7 +161,6 @@ const PublishForm: React.FC<{
|
||||
|
||||
|
||||
|
||||
|
||||
// 获取动态表单配置
|
||||
const dynamicConfig = getDynamicFormConfig()
|
||||
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
# API接口集成说明
|
||||
|
||||
## 已集成的接口
|
||||
|
||||
### 1. 用户详情接口 `/user/detail`
|
||||
|
||||
**请求方式**: POST
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"user_id": "string" // 可选,不传则获取当前用户信息
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "string",
|
||||
"data": {
|
||||
"openid": "",
|
||||
"user_code": "",
|
||||
"unionid": "",
|
||||
"session_key": "",
|
||||
"nickname": "张三",
|
||||
"avatar_url": "https://example.com/avatar.jpg",
|
||||
"gender": "",
|
||||
"country": "",
|
||||
"province": "",
|
||||
"city": "",
|
||||
"language": "",
|
||||
"phone": "13800138000",
|
||||
"is_subscribed": "0",
|
||||
"latitude": "0",
|
||||
"longitude": "0",
|
||||
"subscribe_time": "2024-06-15 14:00:00",
|
||||
"last_login_time": "2024-06-15 14:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 用户信息更新接口 `/user/update`
|
||||
|
||||
**请求方式**: POST
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"nickname": "string",
|
||||
"avatar_url": "string",
|
||||
"gender": "string",
|
||||
"phone": "string",
|
||||
"latitude": 31.2304,
|
||||
"longitude": 121.4737,
|
||||
"city": "string",
|
||||
"province": "string",
|
||||
"country": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "string",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 头像上传接口 `/gallery/upload`
|
||||
|
||||
**请求方式**: POST (multipart/form-data)
|
||||
**请求参数**:
|
||||
- `file`: 图片文件
|
||||
|
||||
**响应格式**:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "请求成功!",
|
||||
"data": {
|
||||
"create_time": "2025-09-06 19:41:18",
|
||||
"last_modify_time": "2025-09-06 19:41:18",
|
||||
"duration": "0",
|
||||
"thumbnail_url": "",
|
||||
"view_count": "0",
|
||||
"download_count": "0",
|
||||
"is_delete": 0,
|
||||
"id": 67,
|
||||
"user_id": 1,
|
||||
"resource_type": "image",
|
||||
"file_name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"original_name": "微信图片_20250505175522.jpg",
|
||||
"file_path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"file_url": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"file_size": 264506,
|
||||
"mime_type": "image/jpeg",
|
||||
"description": "用户图像",
|
||||
"tags": "用户图像",
|
||||
"is_public": "1",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"uploadInfo": {
|
||||
"success": true,
|
||||
"name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"ossPath": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 264506,
|
||||
"originalName": "微信图片_20250505175522.jpg",
|
||||
"suffix": "jpg",
|
||||
"storagePath": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 上传成功后,使用 `data.file_url` 字段作为头像URL。
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 在页面中调用
|
||||
|
||||
```typescript
|
||||
import { UserService } from '@/services/userService';
|
||||
|
||||
// 获取用户信息
|
||||
const userInfo = await UserService.get_user_info('user_id');
|
||||
|
||||
// 更新用户信息
|
||||
await UserService.save_user_info({
|
||||
nickname: '新昵称',
|
||||
phone: '13800138000',
|
||||
gender: '男'
|
||||
});
|
||||
|
||||
// 上传头像
|
||||
const avatarUrl = await UserService.upload_avatar('/path/to/image.jpg');
|
||||
```
|
||||
|
||||
### API配置
|
||||
|
||||
API配置位于 `src/config/api.ts`,可以根据环境自动切换接口地址:
|
||||
|
||||
```typescript
|
||||
export const API_CONFIG = {
|
||||
BASE_URL: process.env.NODE_ENV === 'development'
|
||||
? 'http://localhost:3000'
|
||||
: 'https://api.example.com',
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有API调用都包含完整的错误处理:
|
||||
|
||||
1. **网络错误**: 自动捕获并显示友好提示
|
||||
2. **业务错误**: 根据返回的 `code` 和 `message` 处理
|
||||
3. **超时处理**: 10秒超时设置
|
||||
4. **降级处理**: API失败时返回默认数据
|
||||
|
||||
## 数据映射
|
||||
|
||||
### 用户信息映射
|
||||
|
||||
API返回的用户数据会自动映射到前端组件使用的格式:
|
||||
|
||||
```typescript
|
||||
// API数据 -> 前端组件数据
|
||||
{
|
||||
user_code -> id,
|
||||
nickname -> nickname,
|
||||
avatar_url -> avatar,
|
||||
subscribe_time -> join_date,
|
||||
city -> location,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **位置信息**: 更新用户信息时会自动获取当前位置
|
||||
2. **头像处理**: 上传失败时自动使用默认头像
|
||||
3. **表单验证**: 编辑资料页面包含完整的表单验证
|
||||
4. **类型安全**: 所有接口都有完整的TypeScript类型定义
|
||||
|
||||
## 扩展接口
|
||||
|
||||
如需添加新的用户相关接口,可以在 `UserService` 中添加新方法:
|
||||
|
||||
```typescript
|
||||
static async new_api_method(params: any): Promise<any> {
|
||||
try {
|
||||
const response = await Taro.request({
|
||||
url: `${API_CONFIG.BASE_URL}/new/endpoint`,
|
||||
method: 'POST',
|
||||
data: params,
|
||||
...REQUEST_CONFIG
|
||||
});
|
||||
|
||||
if (response.data.code === 0) {
|
||||
return response.data.data;
|
||||
} else {
|
||||
throw new Error(response.data.message);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API调用失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,240 +0,0 @@
|
||||
# 头像上传功能说明
|
||||
|
||||
## 接口更新
|
||||
|
||||
### 新的上传接口 `/gallery/upload`
|
||||
|
||||
**接口地址**: `/gallery/upload`
|
||||
**请求方式**: POST (multipart/form-data)
|
||||
**功能**: 上传图片文件到阿里云OSS
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 参数名 | 类型 | 必填 | 说明 |
|
||||
|--------|------|------|------|
|
||||
| file | File | 是 | 图片文件 |
|
||||
|
||||
### 响应格式
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "请求成功!",
|
||||
"data": {
|
||||
"create_time": "2025-09-06 19:41:18",
|
||||
"last_modify_time": "2025-09-06 19:41:18",
|
||||
"duration": "0",
|
||||
"thumbnail_url": "",
|
||||
"view_count": "0",
|
||||
"download_count": "0",
|
||||
"is_delete": 0,
|
||||
"id": 67,
|
||||
"user_id": 1,
|
||||
"resource_type": "image",
|
||||
"file_name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"original_name": "微信图片_20250505175522.jpg",
|
||||
"file_path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"file_url": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"file_size": 264506,
|
||||
"mime_type": "image/jpeg",
|
||||
"description": "用户图像",
|
||||
"tags": "用户图像",
|
||||
"is_public": "1",
|
||||
"width": 0,
|
||||
"height": 0,
|
||||
"uploadInfo": {
|
||||
"success": true,
|
||||
"name": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"path": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"ossPath": "http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg",
|
||||
"fileType": "image/jpeg",
|
||||
"fileSize": 264506,
|
||||
"originalName": "微信图片_20250505175522.jpg",
|
||||
"suffix": "jpg",
|
||||
"storagePath": "front/ball/images/f1bd8f63-a1e0-4750-9656-1e8405753416.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键字段说明
|
||||
|
||||
### 主要字段
|
||||
- `file_url`: 图片的完整访问URL,用于前端显示
|
||||
- `file_path`: 与file_url相同,图片的完整访问URL
|
||||
- `file_size`: 文件大小(字节)
|
||||
- `mime_type`: 文件MIME类型
|
||||
- `original_name`: 原始文件名
|
||||
|
||||
### 上传信息字段
|
||||
- `uploadInfo.success`: 上传是否成功
|
||||
- `uploadInfo.ossPath`: OSS存储路径
|
||||
- `uploadInfo.fileType`: 文件类型
|
||||
- `uploadInfo.fileSize`: 文件大小
|
||||
- `uploadInfo.suffix`: 文件后缀
|
||||
|
||||
## 前端实现
|
||||
|
||||
### TypeScript接口定义
|
||||
|
||||
```typescript
|
||||
interface UploadResponseData {
|
||||
create_time: string;
|
||||
last_modify_time: string;
|
||||
duration: string;
|
||||
thumbnail_url: string;
|
||||
view_count: string;
|
||||
download_count: string;
|
||||
is_delete: number;
|
||||
id: number;
|
||||
user_id: number;
|
||||
resource_type: string;
|
||||
file_name: string;
|
||||
original_name: string;
|
||||
file_path: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
description: string;
|
||||
tags: string;
|
||||
is_public: string;
|
||||
width: number;
|
||||
height: number;
|
||||
uploadInfo: {
|
||||
success: boolean;
|
||||
name: string;
|
||||
path: string;
|
||||
ossPath: string;
|
||||
fileType: string;
|
||||
fileSize: number;
|
||||
originalName: string;
|
||||
suffix: string;
|
||||
storagePath: string;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 上传方法实现
|
||||
|
||||
```typescript
|
||||
static async upload_avatar(file_path: string): Promise<string> {
|
||||
try {
|
||||
const uploadResponse = await Taro.uploadFile({
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.UPLOAD.AVATAR}`,
|
||||
filePath: file_path,
|
||||
name: 'file'
|
||||
});
|
||||
|
||||
const result = JSON.parse(uploadResponse.data) as ApiResponse<UploadResponseData>;
|
||||
if (result.code === 0) {
|
||||
// 使用file_url字段作为头像URL
|
||||
return result.data.file_url;
|
||||
} else {
|
||||
throw new Error(result.message || '头像上传失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
// 上传失败时返回默认头像
|
||||
return require('../../static/userInfo/default_avatar.svg');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 在编辑资料页面中使用
|
||||
|
||||
```typescript
|
||||
// 处理头像上传
|
||||
const handle_avatar_upload = () => {
|
||||
Taro.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const tempFilePath = res.tempFilePaths[0];
|
||||
try {
|
||||
const avatar_url = await UserService.upload_avatar(tempFilePath);
|
||||
setUserInfo(prev => ({ ...prev, avatar: avatar_url }));
|
||||
Taro.showToast({
|
||||
title: '头像上传成功',
|
||||
icon: 'success'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
Taro.showToast({
|
||||
title: '头像上传失败',
|
||||
icon: 'none'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
## 功能特点
|
||||
|
||||
### 1. OSS存储
|
||||
- 图片直接上传到阿里云OSS
|
||||
- 支持CDN加速访问
|
||||
- 自动生成唯一文件名
|
||||
|
||||
### 2. 文件信息完整
|
||||
- 记录文件大小、类型、原始名称
|
||||
- 支持文件描述和标签
|
||||
- 记录上传时间和修改时间
|
||||
|
||||
### 3. 错误处理
|
||||
- 上传失败时自动使用默认头像
|
||||
- 完整的错误日志记录
|
||||
- 用户友好的错误提示
|
||||
|
||||
### 4. 类型安全
|
||||
- 完整的TypeScript类型定义
|
||||
- 编译时类型检查
|
||||
- 智能代码提示
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **文件大小限制**: 建议限制上传文件大小,避免过大文件
|
||||
2. **文件类型验证**: 只允许上传图片格式文件
|
||||
3. **网络处理**: 上传过程中需要处理网络异常情况
|
||||
4. **用户体验**: 上传过程中显示加载状态
|
||||
5. **缓存策略**: 上传成功后更新本地缓存
|
||||
|
||||
## 扩展功能
|
||||
|
||||
### 图片压缩
|
||||
```typescript
|
||||
// 可以在上传前进行图片压缩
|
||||
const compressImage = (filePath: string) => {
|
||||
return Taro.compressImage({
|
||||
src: filePath,
|
||||
quality: 80
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### 进度显示
|
||||
```typescript
|
||||
// 显示上传进度
|
||||
const uploadWithProgress = (filePath: string) => {
|
||||
return Taro.uploadFile({
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.UPLOAD.AVATAR}`,
|
||||
filePath: filePath,
|
||||
name: 'file',
|
||||
success: (res) => {
|
||||
// 处理成功
|
||||
},
|
||||
fail: (err) => {
|
||||
// 处理失败
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**更新时间**: 2024年12月19日
|
||||
**接口版本**: v1.0
|
||||
**存储方式**: 阿里云OSS
|
||||
@@ -1,160 +0,0 @@
|
||||
# 个人页面API接口集成完成
|
||||
|
||||
## ✅ 已完成的工作
|
||||
|
||||
### 1. API接口集成
|
||||
- **用户详情接口** (`/user/detail`) - 获取用户信息
|
||||
- **用户更新接口** (`/user/update`) - 更新用户详细信息
|
||||
- **头像上传接口** (`/gallery/upload`) - 上传用户头像到OSS
|
||||
|
||||
### 2. 服务层优化
|
||||
- 创建了 `UserService` 类,统一管理用户相关API调用
|
||||
- 添加了完整的TypeScript类型定义
|
||||
- 实现了错误处理和降级机制
|
||||
- 支持位置信息自动获取
|
||||
|
||||
### 3. 配置管理
|
||||
- 创建了 `API_CONFIG` 配置文件
|
||||
- 支持开发/生产环境自动切换
|
||||
- 统一的请求配置和超时设置
|
||||
|
||||
### 4. 编辑资料页面增强
|
||||
- 新增手机号输入字段
|
||||
- 新增性别选择器(男/女)
|
||||
- 保留NTRP等级选择器
|
||||
- 完整的表单验证
|
||||
|
||||
### 5. 数据映射
|
||||
- API数据格式自动映射到前端组件格式
|
||||
- 支持默认值处理
|
||||
- 时间格式转换
|
||||
|
||||
## 🔧 技术特点
|
||||
|
||||
### API调用方式
|
||||
```typescript
|
||||
// 获取用户信息
|
||||
const userInfo = await UserService.get_user_info('user_id');
|
||||
|
||||
// 更新用户信息
|
||||
await UserService.save_user_info({
|
||||
nickname: '新昵称',
|
||||
phone: '13800138000',
|
||||
gender: '男',
|
||||
location: '上海'
|
||||
});
|
||||
|
||||
// 上传头像
|
||||
const avatarUrl = await UserService.upload_avatar('/path/to/image.jpg');
|
||||
```
|
||||
|
||||
### 错误处理
|
||||
- 网络错误自动捕获
|
||||
- 业务错误友好提示
|
||||
- API失败时降级到默认数据
|
||||
- 完整的日志记录
|
||||
|
||||
### 类型安全
|
||||
- 完整的TypeScript接口定义
|
||||
- API请求/响应类型约束
|
||||
- 组件属性类型检查
|
||||
|
||||
## 📱 功能亮点
|
||||
|
||||
### 1. 智能数据获取
|
||||
- 根据参数自动判断获取当前用户或指定用户信息
|
||||
- 支持用户ID参数传递
|
||||
- 自动处理数据格式转换
|
||||
|
||||
### 2. 位置服务集成
|
||||
- 更新用户信息时自动获取当前位置
|
||||
- 支持经纬度坐标传递
|
||||
- 城市信息自动填充
|
||||
|
||||
### 3. 文件上传优化
|
||||
- 支持图片压缩上传
|
||||
- 上传失败时自动使用默认头像
|
||||
- 进度提示和错误处理
|
||||
|
||||
### 4. 表单体验优化
|
||||
- 实时表单验证
|
||||
- 字符计数显示
|
||||
- 选择器交互优化
|
||||
|
||||
## 🚀 使用方式
|
||||
|
||||
### 页面导航
|
||||
```typescript
|
||||
// 访问个人页面
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/myself/index'
|
||||
});
|
||||
|
||||
// 访问他人页面
|
||||
Taro.navigateTo({
|
||||
url: `/pages/userInfo/other/index?userid=${user_id}`
|
||||
});
|
||||
|
||||
// 访问编辑资料页面
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/edit/index'
|
||||
});
|
||||
```
|
||||
|
||||
### API配置
|
||||
```typescript
|
||||
// 开发环境
|
||||
API_CONFIG.BASE_URL = 'http://localhost:3000'
|
||||
|
||||
// 生产环境
|
||||
API_CONFIG.BASE_URL = 'https://api.example.com'
|
||||
```
|
||||
|
||||
## 📋 接口规范
|
||||
|
||||
### 请求格式
|
||||
- 所有接口使用POST方法
|
||||
- 请求头: `Content-Type: application/json`
|
||||
- 超时设置: 10秒
|
||||
|
||||
### 响应格式
|
||||
```json
|
||||
{
|
||||
"code": 0, // 0表示成功,非0表示失败
|
||||
"message": "string", // 错误信息
|
||||
"data": {} // 响应数据
|
||||
}
|
||||
```
|
||||
|
||||
### 错误码处理
|
||||
- `code: 0` - 请求成功
|
||||
- `code: 非0` - 业务错误,显示message
|
||||
- 网络错误 - 显示"网络连接失败"
|
||||
|
||||
## 🔄 数据流
|
||||
|
||||
1. **页面加载** → 调用 `UserService.get_user_info()`
|
||||
2. **用户操作** → 调用相应的API方法
|
||||
3. **数据更新** → 自动刷新页面状态
|
||||
4. **错误处理** → 显示友好提示信息
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. **权限处理**: 需要确保用户已登录才能调用API
|
||||
2. **缓存策略**: 建议添加用户信息缓存机制
|
||||
3. **图片处理**: 头像上传需要后端支持文件上传
|
||||
4. **位置权限**: 需要用户授权位置信息访问
|
||||
|
||||
## 🎯 下一步优化
|
||||
|
||||
1. 添加用户信息缓存机制
|
||||
2. 实现离线数据支持
|
||||
3. 优化图片上传体验
|
||||
4. 添加更多用户统计信息接口
|
||||
5. 实现用户关注/粉丝功能
|
||||
|
||||
---
|
||||
|
||||
**集成完成时间**: 2024年12月19日
|
||||
**API版本**: v1.0
|
||||
**兼容性**: 支持所有Taro框架版本
|
||||
@@ -1,97 +0,0 @@
|
||||
# 个人页面功能说明
|
||||
|
||||
## 功能概述
|
||||
|
||||
个人页面模块包含三个主要功能页面:
|
||||
|
||||
1. **个人页面** (`/pages/userInfo/myself/index`) - 当前用户的主页
|
||||
2. **他人页面** (`/pages/userInfo/other/index`) - 其他用户的主页
|
||||
3. **编辑资料** (`/pages/userInfo/edit/index`) - 编辑个人资料
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 个人页面 (myself)
|
||||
- 显示当前用户的基本信息(头像、昵称、加入时间)
|
||||
- 显示统计数据(关注、球友、主办、参加)
|
||||
- 显示个人标签和简介
|
||||
- 提供编辑和分享功能
|
||||
- 显示球局订单和收藏快捷入口
|
||||
- 展示用户主办的球局和参与的球局
|
||||
|
||||
### 他人页面 (other)
|
||||
- 显示其他用户的基本信息
|
||||
- 提供关注/取消关注功能
|
||||
- 提供发送消息功能
|
||||
- 展示该用户主办的球局和参与的球局
|
||||
- 支持点击参与者头像查看其他用户主页
|
||||
|
||||
### 编辑资料 (edit)
|
||||
- 支持更换头像
|
||||
- 编辑昵称、个人简介、所在地区、职业
|
||||
- NTRP等级选择
|
||||
- 表单验证和保存功能
|
||||
|
||||
## 技术特点
|
||||
|
||||
### 组件化设计
|
||||
- `UserInfoCard` - 用户信息卡片组件
|
||||
- `GameCard` - 球局卡片组件
|
||||
- `GameTabs` - 球局标签页组件
|
||||
|
||||
### 服务层
|
||||
- `UserService` - 用户相关API服务
|
||||
- `get_user_info()` - 获取用户信息
|
||||
- `get_user_games()` - 获取用户球局记录
|
||||
- `toggle_follow()` - 关注/取消关注
|
||||
- `save_user_info()` - 保存用户信息
|
||||
- `upload_avatar()` - 上传头像
|
||||
|
||||
### 页面导航
|
||||
- 支持通过 `userid` 参数区分个人页面和他人页面
|
||||
- 页面间导航逻辑完善
|
||||
- 参数传递和状态管理
|
||||
|
||||
## 使用方式
|
||||
|
||||
### 访问个人页面
|
||||
```javascript
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/myself/index'
|
||||
});
|
||||
```
|
||||
|
||||
### 访问他人页面
|
||||
```javascript
|
||||
Taro.navigateTo({
|
||||
url: `/pages/userInfo/other/index?userid=${user_id}`
|
||||
});
|
||||
```
|
||||
|
||||
### 访问编辑资料页面
|
||||
```javascript
|
||||
Taro.navigateTo({
|
||||
url: '/pages/userInfo/edit/index'
|
||||
});
|
||||
```
|
||||
|
||||
## 样式特点
|
||||
|
||||
- 使用渐变背景设计
|
||||
- 卡片式布局
|
||||
- 响应式交互效果
|
||||
- 统一的视觉风格
|
||||
- 符合小程序设计规范
|
||||
|
||||
## 数据流
|
||||
|
||||
1. 页面加载时从 `UserService` 获取数据
|
||||
2. 用户操作通过回调函数处理
|
||||
3. 状态更新后重新渲染组件
|
||||
4. 支持异步操作和错误处理
|
||||
|
||||
## 扩展性
|
||||
|
||||
- 组件可复用性强
|
||||
- 服务层易于扩展
|
||||
- 支持更多用户功能扩展
|
||||
- 便于维护和测试
|
||||
@@ -176,6 +176,13 @@ class HttpService {
|
||||
})
|
||||
}
|
||||
|
||||
getHashParam(key) {
|
||||
const hash = window.location.hash;
|
||||
const queryString = hash.split('?')[1] || '';
|
||||
const params = new URLSearchParams(queryString);
|
||||
return params.get(key);
|
||||
}
|
||||
|
||||
// 统一请求方法
|
||||
async request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
|
||||
const {
|
||||
@@ -187,7 +194,20 @@ class HttpService {
|
||||
loadingText = '请求中...'
|
||||
} = config
|
||||
|
||||
const fullUrl = this.buildUrl(url, method === 'GET' ? params : undefined)
|
||||
let fullUrl = this.buildUrl(url, method === 'GET' ? params : undefined)
|
||||
|
||||
|
||||
// 后门id,用于调试
|
||||
let userid = this.getHashParam("userIdTest")
|
||||
if (userid) {
|
||||
if (fullUrl.indexOf("?") > -1) {
|
||||
fullUrl += `&userIdTest45=${userid}`
|
||||
}
|
||||
else {
|
||||
fullUrl += `?userIdTest45=${userid}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
this.log('info', `发起请求: ${method} ${fullUrl}`, {
|
||||
data: method !== 'GET' ? data : undefined,
|
||||
@@ -211,12 +231,13 @@ class HttpService {
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(this.buildHeaders(config), 1111);
|
||||
const reqHeader = this.buildHeaders(config)
|
||||
this.log('info', 'HTTP REQ HEADER: ', reqHeader)
|
||||
const requestConfig = {
|
||||
url: fullUrl,
|
||||
method: method,
|
||||
data: method !== 'GET' ? data : undefined,
|
||||
header: this.buildHeaders(config),
|
||||
header: reqHeader,
|
||||
timeout: this.timeout
|
||||
}
|
||||
|
||||
@@ -282,6 +303,12 @@ class HttpService {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
uploadFile(){
|
||||
|
||||
|
||||
}
|
||||
|
||||
// PUT请求
|
||||
put<T = any>(url: string, data?: any, config?: Partial<RequestConfig>): Promise<ApiResponse<T>> {
|
||||
return this.request<T>({
|
||||
|
||||
@@ -4,8 +4,8 @@ import tokenManager from '../utils/tokenManager';
|
||||
|
||||
// 微信用户信息接口
|
||||
export interface WechatUserInfo {
|
||||
user_id: string;
|
||||
username: string;
|
||||
id: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
gender: number;
|
||||
city: string;
|
||||
@@ -79,8 +79,8 @@ export const wechat_auth_login = async (phone_code?: string): Promise<LoginRespo
|
||||
return {
|
||||
success: true,
|
||||
message: '微信登录成功',
|
||||
token: auth_response.data?.token || 'wx_token_' + Date.now(),
|
||||
user_info: auth_response.data?.user_info
|
||||
token: auth_response.data?.token || '',
|
||||
user_info: auth_response.data?.userInfo
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -101,6 +101,7 @@ export const wechat_auth_login = async (phone_code?: string): Promise<LoginRespo
|
||||
export interface PhoneLoginParams {
|
||||
phone: string;
|
||||
verification_code: string;
|
||||
user_code: string
|
||||
}
|
||||
|
||||
// 手机号验证码登录
|
||||
@@ -109,23 +110,17 @@ export const phone_auth_login = async (params: PhoneLoginParams): Promise<LoginR
|
||||
// 使用 httpService 调用验证验证码接口
|
||||
const verify_response = await httpService.post('user/sms/verify', {
|
||||
phone: params.phone,
|
||||
code: params.verification_code
|
||||
code: params.verification_code,
|
||||
user_code: params.user_code
|
||||
});
|
||||
|
||||
|
||||
if (verify_response.code===0) {
|
||||
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: ''
|
||||
}
|
||||
token: verify_response.data?.token,
|
||||
user_info: verify_response.data?.userInfo
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
@@ -137,7 +132,7 @@ export const phone_auth_login = async (params: PhoneLoginParams): Promise<LoginR
|
||||
console.error('手机号登录失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '网络错误,请稍后重试'
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -165,7 +160,7 @@ export const send_sms_code = async (phone: string): Promise<SmsResponse> => {
|
||||
console.error('发送短信失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '网络错误,请稍后重试'
|
||||
message: error.message
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -182,39 +177,17 @@ export const verify_sms_code = async (phone: string, code: string): Promise<Veri
|
||||
success: response.success,
|
||||
message: response.message || '验证失败',
|
||||
token: response.data?.token,
|
||||
user_info: response.data?.user_info
|
||||
user_info: response.data?.userInfo
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('验证验证码失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '网络错误,请稍后重试'
|
||||
message: error.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) => {
|
||||
@@ -226,8 +199,9 @@ export const save_login_state = (token: string, user_info: WechatUserInfo) => {
|
||||
expiresAt: expires_at
|
||||
});
|
||||
|
||||
|
||||
// 保存用户信息
|
||||
Taro.setStorageSync('user_info', user_info);
|
||||
Taro.setStorageSync('user_info', JSON.stringify(user_info));
|
||||
Taro.setStorageSync('is_logged_in', true);
|
||||
Taro.setStorageSync('login_time', Date.now());
|
||||
} catch (error) {
|
||||
@@ -254,6 +228,8 @@ export const clear_login_state = () => {
|
||||
export const check_login_status = (): boolean => {
|
||||
try {
|
||||
// 使用 tokenManager 检查令牌有效性
|
||||
|
||||
|
||||
if (!tokenManager.hasValidToken()) {
|
||||
clear_login_state();
|
||||
return false;
|
||||
@@ -303,7 +279,11 @@ export const get_token_status = () => {
|
||||
// 获取用户信息
|
||||
export const get_user_info = (): WechatUserInfo | null => {
|
||||
try {
|
||||
return Taro.getStorageSync('user_info') || null;
|
||||
let userinfo = Taro.getStorageSync('user_info')
|
||||
if (userinfo) {
|
||||
return JSON.parse(userinfo)
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface createGameData extends PublishBallData {
|
||||
status: string,
|
||||
created_at: string,
|
||||
updated_at: string,
|
||||
id?: string | number,
|
||||
}
|
||||
|
||||
// 响应接口
|
||||
@@ -142,7 +143,7 @@ class PublishService {
|
||||
})
|
||||
}
|
||||
async getPictures(req) {
|
||||
const { type, otherReq = {} } = req
|
||||
const { type, tag, otherReq = {} } = req
|
||||
if (type === 'history') {
|
||||
return this.getHistoryImageList({
|
||||
pageOption: {
|
||||
@@ -150,7 +151,7 @@ class PublishService {
|
||||
pageSize: 100,
|
||||
},
|
||||
seachOption: {
|
||||
tag: 'cover',
|
||||
tag,
|
||||
resource_type: 'image',
|
||||
dateRange: [],
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import httpService from './httpService'
|
||||
import type { ApiResponse } from './httpService'
|
||||
import Taro from '@tarojs/taro'
|
||||
import envConfig from '@/config/env'
|
||||
import { API_CONFIG } from '@/config/api'
|
||||
import httpService from './httpService'
|
||||
|
||||
// 用户接口
|
||||
export interface UploadFilesData {
|
||||
@@ -39,18 +39,28 @@ export interface uploadFileResponseData {
|
||||
updated_at: string,
|
||||
}
|
||||
|
||||
function delay(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
// 发布球局类
|
||||
class UploadApi {
|
||||
async upload(req: UploadFilesData): Promise<{ id: string, data: uploadFileResponseData }> {
|
||||
// return httpService.post('/files/upload', req, {
|
||||
// showLoading: true,
|
||||
// })
|
||||
|
||||
let fullUrl = `${envConfig.apiBaseURL}/api/gallery/upload`
|
||||
// 后门id,用于调试
|
||||
let userid = httpService.getHashParam("userIdTest")
|
||||
if (userid) {
|
||||
if (fullUrl.indexOf("?") > -1) {
|
||||
fullUrl += `&userIdTest45=${userid}`
|
||||
}
|
||||
else {
|
||||
fullUrl += `?userIdTest45=${userid}`
|
||||
}
|
||||
}
|
||||
|
||||
const { id, ...rest } = req
|
||||
return Taro.uploadFile({
|
||||
url: `${envConfig.apiBaseURL}/api/gallery/upload`,
|
||||
url: fullUrl,
|
||||
filePath: rest.filePath,
|
||||
name: 'file',
|
||||
formData: {
|
||||
@@ -69,6 +79,42 @@ class UploadApi {
|
||||
async batchUpload(req: UploadFilesData[]): Promise<{ id: string, data: uploadFileResponseData }[]> {
|
||||
return Promise.all(req.map(item => this.upload(item)))
|
||||
}
|
||||
|
||||
// 上传单张图片到OSS
|
||||
async upload_oss_img(file_path: string): Promise<any> {
|
||||
try {
|
||||
let fullUrl = `${envConfig.apiBaseURL}/api${API_CONFIG.UPLOAD.OSS_IMG}`
|
||||
// 后门id,用于调试
|
||||
let userid = httpService.getHashParam("userIdTest")
|
||||
if (userid) {
|
||||
if (fullUrl.indexOf("?") > -1) {
|
||||
fullUrl += `&userIdTest45=${userid}`
|
||||
}
|
||||
else {
|
||||
fullUrl += `?userIdTest45=${userid}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await Taro.uploadFile({
|
||||
url: fullUrl,
|
||||
filePath: file_path,
|
||||
name: 'file',
|
||||
});
|
||||
|
||||
const result = JSON.parse(response.data);
|
||||
|
||||
if (result.code === 0) {
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message || '上传失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图片失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
// 导出认证服务实例
|
||||
|
||||
@@ -2,6 +2,7 @@ import { UserInfo } from '@/components/UserInfo';
|
||||
import Taro from '@tarojs/taro';
|
||||
import { API_CONFIG } from '@/config/api';
|
||||
import httpService from './httpService';
|
||||
import uploadFiles from './uploadFiles';
|
||||
|
||||
|
||||
// 用户详情接口
|
||||
@@ -20,8 +21,8 @@ interface UserDetailData {
|
||||
language: string;
|
||||
phone: string;
|
||||
is_subscribed: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
subscribe_time: string;
|
||||
last_login_time: string;
|
||||
create_time: string;
|
||||
@@ -40,11 +41,12 @@ interface UpdateUserParams {
|
||||
avatar_url: string;
|
||||
gender: string;
|
||||
phone: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
city: string;
|
||||
province: string;
|
||||
country: string;
|
||||
|
||||
}
|
||||
|
||||
// 上传响应接口
|
||||
@@ -105,8 +107,8 @@ interface BackendGameData {
|
||||
end_time: string;
|
||||
location_name: string | null;
|
||||
location: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
latitude?: string;
|
||||
longitude?: string;
|
||||
image_list?: string[];
|
||||
description_tag?: string[];
|
||||
venue_description_tag?: string[];
|
||||
@@ -157,8 +159,8 @@ export class UserService {
|
||||
}
|
||||
|
||||
// 处理距离 - 优先使用venue_dtl中的坐标,其次使用game中的坐标
|
||||
let latitude = game.latitude || 0;
|
||||
let longitude = game.longitude || 0;
|
||||
let latitude: number = typeof game.latitude === 'number' ? game.latitude : parseFloat(game.latitude || '0') || 0;
|
||||
let longitude: number = typeof game.longitude === 'number' ? game.longitude : parseFloat(game.longitude || '0') || 0;
|
||||
if (game.venue_dtl) {
|
||||
latitude = parseFloat(game.venue_dtl.latitude) || latitude;
|
||||
longitude = parseFloat(game.venue_dtl.longitude) || longitude;
|
||||
@@ -247,32 +249,28 @@ export class UserService {
|
||||
static async get_user_info(user_id?: string): Promise<UserInfo> {
|
||||
try {
|
||||
const response = await httpService.post<UserDetailData>(API_CONFIG.USER.DETAIL, user_id ? { user_id } : {}, {
|
||||
needAuth: false,
|
||||
|
||||
showLoading: false
|
||||
});
|
||||
|
||||
if (response.code === 0) {
|
||||
const userData = response.data;
|
||||
return {
|
||||
id: userData.user_code || user_id || '1',
|
||||
nickname: userData.nickname || '用户',
|
||||
avatar: userData.avatar_url || require('../static/userInfo/default_avatar.svg'),
|
||||
join_date: userData.subscribe_time ? `${new Date(userData.subscribe_time).getFullYear()}年${new Date(userData.subscribe_time).getMonth() + 1}月加入` : '未知时间加入',
|
||||
id: userData.user_code || user_id || '',
|
||||
nickname: userData.nickname || '',
|
||||
avatar: userData.avatar_url || '',
|
||||
join_date: userData.subscribe_time ? `${new Date(userData.subscribe_time).getFullYear()}年${new Date(userData.subscribe_time).getMonth() + 1}月加入` : '',
|
||||
stats: {
|
||||
following: userData.stats?.following_count || 0,
|
||||
friends: userData.stats?.followers_count || 0,
|
||||
hosted: userData.stats?.hosted_games_count || 0,
|
||||
participated: userData.stats?.participated_games_count || 0
|
||||
},
|
||||
tags: [
|
||||
userData.city || '未知地区',
|
||||
userData.province || '未知省份',
|
||||
'NTRP 3.0' // 默认等级,需要其他接口获取
|
||||
],
|
||||
bio: '这个人很懒,什么都没有写...',
|
||||
location: userData.city || '未知地区',
|
||||
occupation: '未知职业', // 需要其他接口获取
|
||||
ntrp_level: 'NTRP 3.0', // 需要其他接口获取
|
||||
|
||||
personal_profile: '',
|
||||
location: userData.province + userData.city || '',
|
||||
occupation: '',
|
||||
ntrp_level: '',
|
||||
phone: userData.phone || '',
|
||||
gender: userData.gender || ''
|
||||
};
|
||||
@@ -282,25 +280,44 @@ export class UserService {
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error);
|
||||
// 返回默认用户信息
|
||||
return {
|
||||
id: user_id || '1',
|
||||
nickname: '用户',
|
||||
avatar: require('../static/userInfo/default_avatar.svg'),
|
||||
join_date: '未知时间加入',
|
||||
stats: {
|
||||
following: 0,
|
||||
friends: 0,
|
||||
hosted: 0,
|
||||
participated: 0
|
||||
},
|
||||
tags: ['未知地区', '未知职业', 'NTRP 3.0'],
|
||||
bio: '这个人很懒,什么都没有写...',
|
||||
location: '未知地区',
|
||||
occupation: '未知职业',
|
||||
ntrp_level: 'NTRP 3.0',
|
||||
phone: '',
|
||||
gender: ''
|
||||
};
|
||||
return {} as UserInfo
|
||||
}
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
static async update_user_info(update_data: Partial<UserInfo>): Promise<void> {
|
||||
try {
|
||||
// 过滤掉空字段
|
||||
const filtered_data: Record<string, any> = {};
|
||||
|
||||
Object.keys(update_data).forEach(key => {
|
||||
const value = update_data[key as keyof UserInfo];
|
||||
// 只添加非空且非空字符串的字段
|
||||
if (value !== null && value !== undefined && value !== '') {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
filtered_data[key] = value.trim();
|
||||
} else if (typeof value !== 'string') {
|
||||
filtered_data[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 如果没有需要更新的字段,直接返回
|
||||
if (Object.keys(filtered_data).length === 0) {
|
||||
console.log('没有需要更新的字段');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await httpService.post(API_CONFIG.USER.UPDATE, filtered_data, {
|
||||
showLoading: true
|
||||
});
|
||||
|
||||
if (response.code !== 0) {
|
||||
throw new Error(response.message || '更新用户信息失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户信息失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -310,7 +327,7 @@ export class UserService {
|
||||
const response = await httpService.post<any>(API_CONFIG.USER.HOSTED_GAMES, {
|
||||
user_id
|
||||
}, {
|
||||
needAuth: false,
|
||||
|
||||
showLoading: false
|
||||
});
|
||||
|
||||
@@ -323,41 +340,7 @@ export class UserService {
|
||||
} catch (error) {
|
||||
console.error('获取主办球局失败:', error);
|
||||
// 返回符合ListContainer data格式的模拟数据
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
title: '女生轻松双打',
|
||||
dateTime: '明天(周五) 下午5点',
|
||||
location: '仁恒河滨花园网球场',
|
||||
distance: '3.5km',
|
||||
registeredCount: 2,
|
||||
maxCount: 4,
|
||||
skillLevel: '2.0-2.5',
|
||||
matchType: '双打',
|
||||
images: [
|
||||
require('../static/userInfo/game1.svg'),
|
||||
require('../static/userInfo/game2.svg'),
|
||||
require('../static/userInfo/game3.svg')
|
||||
],
|
||||
shinei: '室外'
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '新手友好局',
|
||||
dateTime: '周日 下午2点',
|
||||
location: '徐汇网球中心',
|
||||
distance: '1.8km',
|
||||
registeredCount: 4,
|
||||
maxCount: 6,
|
||||
skillLevel: '1.5-2.0',
|
||||
matchType: '双打',
|
||||
images: [
|
||||
require('../static/userInfo/game1.svg'),
|
||||
require('../static/userInfo/game2.svg')
|
||||
],
|
||||
shinei: '室外'
|
||||
}
|
||||
];
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,7 +350,7 @@ export class UserService {
|
||||
const response = await httpService.post<any>(API_CONFIG.USER.PARTICIPATED_GAMES, {
|
||||
user_id
|
||||
}, {
|
||||
needAuth: false,
|
||||
|
||||
showLoading: false
|
||||
});
|
||||
|
||||
@@ -380,56 +363,8 @@ export class UserService {
|
||||
} catch (error) {
|
||||
console.error('获取参与球局失败:', error);
|
||||
// 返回符合ListContainer data格式的模拟数据
|
||||
return [
|
||||
{
|
||||
id: 2,
|
||||
title: '周末双打练习',
|
||||
dateTime: '后天(周六) 上午10点',
|
||||
location: '上海网球中心',
|
||||
distance: '5.2km',
|
||||
registeredCount: 6,
|
||||
maxCount: 8,
|
||||
skillLevel: '3.0-3.5',
|
||||
matchType: '双打',
|
||||
images: [
|
||||
require('../static/userInfo/game2.svg'),
|
||||
require('../static/userInfo/game3.svg')
|
||||
],
|
||||
shinei: '室内'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '晨练单打',
|
||||
dateTime: '明天(周五) 早上7点',
|
||||
location: '浦东网球俱乐部',
|
||||
distance: '2.8km',
|
||||
registeredCount: 1,
|
||||
maxCount: 2,
|
||||
skillLevel: '2.5-3.0',
|
||||
matchType: '单打',
|
||||
images: [
|
||||
require('../static/userInfo/game1.svg')
|
||||
],
|
||||
shinei: '室外'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '夜场混双',
|
||||
dateTime: '今晚 晚上8点',
|
||||
location: '虹桥网球中心',
|
||||
distance: '4.1km',
|
||||
registeredCount: 3,
|
||||
maxCount: 4,
|
||||
skillLevel: '3.5-4.0',
|
||||
matchType: '混双',
|
||||
images: [
|
||||
require('../static/userInfo/game1.svg'),
|
||||
require('../static/userInfo/game2.svg'),
|
||||
require('../static/userInfo/game3.svg')
|
||||
],
|
||||
shinei: '室内'
|
||||
}
|
||||
];
|
||||
return [];
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -447,7 +382,7 @@ export class UserService {
|
||||
try {
|
||||
const endpoint = is_following ? API_CONFIG.USER.UNFOLLOW : API_CONFIG.USER.FOLLOW;
|
||||
const response = await httpService.post<any>(endpoint, { user_id }, {
|
||||
needAuth: false,
|
||||
|
||||
showLoading: true,
|
||||
loadingText: is_following ? '取消关注中...' : '关注中...'
|
||||
});
|
||||
@@ -466,25 +401,41 @@ export class UserService {
|
||||
// 保存用户信息
|
||||
static async save_user_info(user_info: Partial<UserInfo> & { phone?: string; gender?: string }): Promise<boolean> {
|
||||
try {
|
||||
// 获取当前位置信息
|
||||
const location = await Taro.getLocation({
|
||||
type: 'wgs84'
|
||||
});
|
||||
|
||||
const updateParams: UpdateUserParams = {
|
||||
nickname: user_info.nickname || '',
|
||||
avatar_url: user_info.avatar || '',
|
||||
gender: user_info.gender || '',
|
||||
phone: user_info.phone || '',
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
city: user_info.location || '',
|
||||
province: '', // 需要从用户信息中获取
|
||||
country: '' // 需要从用户信息中获取
|
||||
// 字段映射配置
|
||||
const field_mapping: Record<string, string> = {
|
||||
nickname: 'nickname',
|
||||
avatar: 'avatar_url',
|
||||
gender: 'gender',
|
||||
phone: 'phone',
|
||||
latitude: 'latitude',
|
||||
longitude: 'longitude',
|
||||
province: 'province',
|
||||
country:"country",
|
||||
city:"city",
|
||||
personal_profile: 'personal_profile',
|
||||
occupation: 'occupation',
|
||||
ntrp_level: 'ntrp_level'
|
||||
};
|
||||
|
||||
// 构建更新参数,只包含非空字段
|
||||
const updateParams: Record<string, string> = {};
|
||||
|
||||
// 循环处理所有字段
|
||||
Object.keys(field_mapping).forEach(key => {
|
||||
const value = user_info[key as keyof typeof user_info];
|
||||
if (value && typeof value === 'string' && value.trim() !== '') {
|
||||
updateParams[field_mapping[key]] = value.trim();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// 如果没有需要更新的字段,直接返回成功
|
||||
if (Object.keys(updateParams).length === 0) {
|
||||
console.log('没有需要更新的字段');
|
||||
return true;
|
||||
}
|
||||
|
||||
const response = await httpService.post<any>(API_CONFIG.USER.UPDATE, updateParams, {
|
||||
needAuth: false,
|
||||
showLoading: true,
|
||||
loadingText: '保存中...'
|
||||
});
|
||||
@@ -508,7 +459,7 @@ export class UserService {
|
||||
page,
|
||||
limit
|
||||
}, {
|
||||
needAuth: false,
|
||||
|
||||
showLoading: false
|
||||
});
|
||||
|
||||
@@ -527,19 +478,12 @@ export class UserService {
|
||||
static async upload_avatar(file_path: string): Promise<string> {
|
||||
try {
|
||||
// 先上传文件到服务器
|
||||
const uploadResponse = await Taro.uploadFile({
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.UPLOAD.AVATAR}`,
|
||||
filePath: file_path,
|
||||
name: 'file'
|
||||
});
|
||||
const result = await uploadFiles.upload_oss_img(file_path)
|
||||
|
||||
const result = JSON.parse(uploadResponse.data) as { code: number; message: string; data: UploadResponseData };
|
||||
if (result.code === 0) {
|
||||
// 使用新的响应格式中的file_url字段
|
||||
return result.data.file_url;
|
||||
} else {
|
||||
throw new Error(result.message || '头像上传失败');
|
||||
}
|
||||
await this.save_user_info({ avatar: result.ossPath })
|
||||
|
||||
// 使用新的响应格式中的file_url字段
|
||||
return result.ossPath;
|
||||
} catch (error) {
|
||||
console.error('头像上传失败:', error);
|
||||
// 如果上传失败,返回默认头像
|
||||
|
||||
BIN
src/static/login/login_bg.jpg
Normal file
BIN
src/static/login/login_bg.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
5
src/static/userInfo/female.svg
Normal file
5
src/static/userInfo/female.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4879 3.89007V1.64008H8.23792" stroke="#FF69B4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.60338 9.62816C3.9702 10.995 6.18628 10.995 7.55313 9.62816C8.23655 8.94476 8.57825 8.04901 8.57825 7.15328C8.57825 6.25756 8.23655 5.36183 7.55313 4.67841C6.18628 3.31158 3.9702 3.31158 2.60338 4.67841C1.23654 6.04526 1.23654 8.26133 2.60338 9.62816Z" stroke="#FF69B4" stroke-linejoin="round"/>
|
||||
<path d="M7.5 4.62796L9.98787 2.14008" stroke="#FF69B4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 624 B |
5
src/static/userInfo/male.svg
Normal file
5
src/static/userInfo/male.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="12" height="13" viewBox="0 0 12 13" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.4879 3.89007V1.64008H8.23792" stroke="#4169E1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M2.60338 9.62816C3.9702 10.995 6.18628 10.995 7.55313 9.62816C8.23655 8.94476 8.57825 8.04901 8.57825 7.15328C8.57825 6.25756 8.23655 5.36183 7.55313 4.67841C6.18628 3.31158 3.9702 3.31158 2.60338 4.67841C1.23654 6.04526 1.23654 8.26133 2.60338 9.62816Z" stroke="#4169E1" stroke-linejoin="round"/>
|
||||
<path d="M7.5 4.62796L9.98787 2.14008" stroke="#4169E1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 624 B |
@@ -34,7 +34,6 @@ export const useUser = create<UserState>()((set) => ({
|
||||
},
|
||||
updateUserInfo: async(userInfo: Partial<UserInfoType>) => {
|
||||
const res = await updateUserProfile(userInfo)
|
||||
console.log(res)
|
||||
set({ user: res.data })
|
||||
}
|
||||
}))
|
||||
|
||||
70
src/utils/genderUtils.ts
Normal file
70
src/utils/genderUtils.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 性别字段转换工具函数
|
||||
* 数据库存储:'0' = 男,'1' = 女
|
||||
* 页面显示:'男' = 男,'女' = 女
|
||||
* 微信返回:0 = 未知,1 = 男,2 = 女
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将数据库性别值转换为页面显示文本
|
||||
* @param db_gender 数据库性别值 ('0' | '1')
|
||||
* @returns 页面显示文本 ('男' | '女' | '未知')
|
||||
*/
|
||||
export const convert_db_gender_to_display = (db_gender: string): string => {
|
||||
switch (db_gender) {
|
||||
case '0':
|
||||
return '男';
|
||||
case '1':
|
||||
return '女';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将页面显示文本转换为数据库性别值
|
||||
* @param display_gender 页面显示文本 ('男' | '女')
|
||||
* @returns 数据库性别值 ('0' | '1')
|
||||
*/
|
||||
export const convert_display_gender_to_db = (display_gender: string): string => {
|
||||
switch (display_gender) {
|
||||
case '男':
|
||||
return '0';
|
||||
case '女':
|
||||
return '1';
|
||||
default:
|
||||
return '0'; // 默认返回男性
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将微信性别值转换为数据库性别值
|
||||
* @param wechat_gender 微信性别值 (0 | 1 | 2)
|
||||
* @returns 数据库性别值 ('0' | '1')
|
||||
*/
|
||||
export const convert_wechat_gender_to_db = (wechat_gender: number): string => {
|
||||
switch (wechat_gender) {
|
||||
case 1: // 微信:1 = 男
|
||||
return '0'; // 数据库:'0' = 男
|
||||
case 2: // 微信:2 = 女
|
||||
return '1'; // 数据库:'1' = 女
|
||||
default: // 微信:0 = 未知
|
||||
return '0'; // 默认返回男性
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 将数据库性别值转换为微信性别值
|
||||
* @param db_gender 数据库性别值 ('0' | '1')
|
||||
* @returns 微信性别值 (1 | 2)
|
||||
*/
|
||||
export const convert_db_gender_to_wechat = (db_gender: string): number => {
|
||||
switch (db_gender) {
|
||||
case '0':
|
||||
return 1; // 微信:1 = 男
|
||||
case '1':
|
||||
return 2; // 微信:2 = 女
|
||||
default:
|
||||
return 1; // 默认返回男性
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './getNavbarHeight'
|
||||
export * from './genderUtils'
|
||||
export * from './locationUtils'
|
||||
export * from './processImage'
|
||||
export * from './timeUtils'
|
||||
|
||||
@@ -29,6 +29,40 @@ export const getDate = (date: string): string => {
|
||||
return dayjs(date).format('YYYY年MM月DD日')
|
||||
}
|
||||
|
||||
export const getDay = (date?: string | Date): string => {
|
||||
if (!date) {
|
||||
return dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
return dayjs(date).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export const getMonth = (date?: string | Date): string => {
|
||||
if (!date) {
|
||||
return dayjs().format('MM月 YYYY')
|
||||
}
|
||||
return dayjs(date).format('MM月 YYYY')
|
||||
}
|
||||
|
||||
export const getWeekend = (date?: string | Date): [Date, Date] => {
|
||||
const today = dayjs(date);
|
||||
const currentDayOfWeek = today.day();
|
||||
console.log('currentDayOfWeek', currentDayOfWeek)
|
||||
const saturdayOffset = 6 - currentDayOfWeek
|
||||
const sundayOffset = 7 - currentDayOfWeek
|
||||
const sat = today.add(saturdayOffset, 'day')
|
||||
const sun = today.add(sundayOffset, 'day')
|
||||
return [sat.toDate(), sun.toDate()]
|
||||
}
|
||||
|
||||
export const getWeekendOfCurrentWeek = (days = 7): Date[] => {
|
||||
const dayList: Date[] = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const day = dayjs().add(i, 'day').toDate()
|
||||
dayList.push(day)
|
||||
}
|
||||
return dayList
|
||||
}
|
||||
|
||||
export const getTime = (time: string): string => {
|
||||
const timeObj = dayjs(time)
|
||||
const hour = timeObj.hour()
|
||||
|
||||
Reference in New Issue
Block a user