修改发布日历
This commit is contained in:
@@ -12,11 +12,11 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
date: '2025-8-9',
|
||||
designWidth: 390, // 改为 390
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2 * (390 / 640), // 原值重新计算
|
||||
750: 1 * (390 / 750), // 原值重新计算
|
||||
375: 2 * (390 / 375), // 原值重新计算
|
||||
828: 1.81 / 2 * (390 / 828), // 原值重新计算
|
||||
390: 2 // 新增基准设备
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
375: 2,
|
||||
828: 1.81 / 2,
|
||||
390: 1.92
|
||||
},
|
||||
sourceRoot: 'src',
|
||||
outputRoot: 'dist',
|
||||
@@ -55,11 +55,6 @@ export default defineConfig<'webpack5'>(async (merge, { command, mode }) => {
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
config: {
|
||||
platform: 'weapp',
|
||||
designWidth: 390, // 这里也要同步修改
|
||||
deviceRatio: {
|
||||
390: 2 // 这里只需要基准比例
|
||||
},
|
||||
selectorBlackList: ['nut-']
|
||||
}
|
||||
},
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
"@tarojs/shared": "4.1.5",
|
||||
"@tarojs/taro": "4.1.5",
|
||||
"dayjs": "^1.11.13",
|
||||
"qqmap-wx-jssdk": "^1.0.0",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"zustand": "^4.4.7"
|
||||
|
||||
1
postcss.config.js
Normal file
1
postcss.config.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index',
|
||||
'pages/publishBall/index',
|
||||
'pages/mapDisplay/index',
|
||||
'pages/index/index'
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
|
||||
117
src/components/CalendarCard/CalendarCard.tsx
Normal file
117
src/components/CalendarCard/CalendarCard.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
130
src/components/CalendarCard/DialogCalendarCard.tsx
Normal file
130
src/components/CalendarCard/DialogCalendarCard.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
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
|
||||
105
src/components/CalendarCard/index.module.scss
Normal file
105
src/components/CalendarCard/index.module.scss
Normal file
@@ -0,0 +1,105 @@
|
||||
.calendar-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 12px 12px 8px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 9px 16px 11px 16px;
|
||||
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;
|
||||
gap: 30px;
|
||||
}
|
||||
.month-arrow{
|
||||
width: 8px
|
||||
}
|
||||
.arrow {
|
||||
width: 10px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
2
src/components/CalendarCard/index.ts
Normal file
2
src/components/CalendarCard/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './CalendarCard'
|
||||
export { default as DialogCalendarCard } from './DialogCalendarCard'
|
||||
62
src/components/CommonDialog/CommonDialog.tsx
Normal file
62
src/components/CommonDialog/CommonDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
import { Dialog } from '@nutui/nutui-react-taro'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
export interface CommonDialogProps {
|
||||
visible: boolean
|
||||
title?: string
|
||||
content?: React.ReactNode
|
||||
cancelText?: string
|
||||
confirmText?: string
|
||||
onCancel?: () => void
|
||||
onConfirm?: () => void
|
||||
showCancel?: boolean
|
||||
showConfirm?: boolean
|
||||
children?: React.ReactNode
|
||||
contentTitle?: string
|
||||
contentDesc?: string
|
||||
}
|
||||
|
||||
const CommonDialog: React.FC<CommonDialogProps> = ({
|
||||
visible,
|
||||
title,
|
||||
content,
|
||||
cancelText = '取消',
|
||||
confirmText = '确认',
|
||||
onCancel,
|
||||
onConfirm,
|
||||
children,
|
||||
contentTitle,
|
||||
contentDesc
|
||||
}) => {
|
||||
|
||||
const getContent = () => {
|
||||
if (content) {
|
||||
return content
|
||||
}
|
||||
if (children) {
|
||||
return children
|
||||
}
|
||||
return (
|
||||
<View className={styles['confirm-content']}>
|
||||
<Text className={styles['confirm-content-title']}>{contentTitle}</Text>
|
||||
<Text className={styles['confirm-content-desc']}>{contentDesc}</Text>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
visible={visible}
|
||||
title={title}
|
||||
content={getContent()}
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
cancelText={cancelText}
|
||||
confirmText={confirmText}
|
||||
className={styles['custom-dialog']}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CommonDialog
|
||||
86
src/components/CommonDialog/index.module.scss
Normal file
86
src/components/CommonDialog/index.module.scss
Normal file
@@ -0,0 +1,86 @@
|
||||
@use '~@/scss/themeColor.scss' as theme;
|
||||
|
||||
.custom-dialog {
|
||||
:global(.nut-dialog) {
|
||||
border-radius: 12px !important;
|
||||
padding: 0 !important;
|
||||
max-width: 320px !important;
|
||||
width: 100% !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
:global(.nut-dialog-content) {
|
||||
margin:0 !important;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-header) {
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-title) {
|
||||
font-size: 18px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-content) {
|
||||
min-width: 280px !important;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-footer) {
|
||||
display: flex !important;
|
||||
padding: 0 !important;
|
||||
height: 47.5px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-top: 1px solid theme.$primary-border-light-color;
|
||||
border-bottom-left-radius: 12px;
|
||||
border-bottom-right-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-footer .nut-button) {
|
||||
flex: 1 !important;
|
||||
font-size: 15px !important;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: #fff;
|
||||
border-right: 1px solid theme.$primary-border-light-color !important;
|
||||
height: 100%;
|
||||
&:last-child {
|
||||
border-right: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.nut-dialog-footer .nut-button-default) {
|
||||
color: rgba(22, 24, 35, 0.75) !important;
|
||||
}
|
||||
|
||||
:global(.nut-dialog-footer .nut-button-primary) {
|
||||
color: #161823 !important;
|
||||
|
||||
}
|
||||
|
||||
:global(.nut-dialog-footer .nut-button:hover) {
|
||||
opacity: 0.8 !important;
|
||||
}
|
||||
.confirm-content{
|
||||
padding: 24px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
.confirm-content-title{
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 24px;
|
||||
color: #161823;
|
||||
}
|
||||
.confirm-content-desc{
|
||||
font-size: 14px;
|
||||
color: rgba(22, 24, 35, 0.75)
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/components/CommonDialog/index.ts
Normal file
2
src/components/CommonDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import CommonDialog from './CommonDialog.tsx'
|
||||
export default CommonDialog
|
||||
@@ -1,12 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { Picker, Popup } from '@nutui/nutui-react-taro'
|
||||
import { View, Text, PickerView, PickerViewColumn } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
|
||||
|
||||
export interface DateTimePickerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (year: number, month: number) => void
|
||||
onChange: (year: number, month: number) => void
|
||||
defaultYear?: number
|
||||
defaultMonth?: number
|
||||
minYear?: number
|
||||
@@ -14,101 +13,91 @@ export interface DateTimePickerProps {
|
||||
}
|
||||
|
||||
const DateTimePicker: React.FC<DateTimePickerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
|
||||
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 yearOptions = Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
|
||||
text: `${minYear + index}年`,
|
||||
value: minYear + index
|
||||
}))
|
||||
// 生成多列选择器的选项数据
|
||||
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
|
||||
}))
|
||||
]
|
||||
|
||||
|
||||
// 生成月份选项
|
||||
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||
text: `${index + 1}月`,
|
||||
value: index + 1
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedYear(defaultYear)
|
||||
setSelectedMonth(defaultMonth)
|
||||
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)
|
||||
}
|
||||
}, [visible, defaultYear, defaultMonth])
|
||||
|
||||
const handleYearChange = (value: any) => {
|
||||
setSelectedYear(value[0])
|
||||
}
|
||||
|
||||
const handleMonthChange = (value: any) => {
|
||||
setSelectedMonth(value[0])
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedYear, selectedMonth)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Popup
|
||||
visible={visible}
|
||||
position="bottom"
|
||||
round
|
||||
onClose={onClose}
|
||||
className={styles['date-time-picker-popup']}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<View className={styles['popup-handle']} />
|
||||
|
||||
{/* 时间选择器 */}
|
||||
<View className={styles['picker-container']}>
|
||||
<View className={styles['picker-columns']}>
|
||||
{/* 年份选择 */}
|
||||
<View className={styles['picker-column']}>
|
||||
<Text className={styles['picker-label']}>年</Text>
|
||||
<Picker
|
||||
value={[selectedYear]}
|
||||
options={yearOptions}
|
||||
onChange={handleYearChange}
|
||||
className={styles['year-picker']}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 月份选择 */}
|
||||
<View className={styles['picker-column']}>
|
||||
<Text className={styles['picker-label']}>月</Text>
|
||||
<Picker
|
||||
value={[selectedMonth]}
|
||||
options={monthOptions}
|
||||
onChange={handleMonthChange}
|
||||
className={styles['month-picker']}
|
||||
/>
|
||||
<View 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 className={styles['action-buttons']}>
|
||||
<View className={styles['cancel-btn']} onClick={handleCancel}>
|
||||
<Text className={styles['cancel-text']}>取消</Text>
|
||||
</View>
|
||||
<View className={styles['confirm-btn']} onClick={handleConfirm}>
|
||||
<Text className={styles['confirm-text']}>完成</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Popup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# DateTimePicker 年月选择器
|
||||
|
||||
一个基于 NutUI 的年月切换弹窗组件,支持自定义年份范围和默认值。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎯 年月分别选择,操作简单直观
|
||||
- 🎨 遵循设计稿样式,美观易用
|
||||
- 📱 支持移动端手势操作
|
||||
- ⚙️ 可自定义年份范围
|
||||
- <20><> 基于 NutUI 组件库,稳定可靠
|
||||
|
||||
## 使用方法
|
||||
|
||||
```tsx
|
||||
import { DateTimePicker } from '@/components'
|
||||
|
||||
const MyComponent = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
console.log('选择的年月:', year, month)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<DateTimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2025}
|
||||
defaultMonth={11}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## API 参数
|
||||
|
||||
| 参数 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否显示弹窗 |
|
||||
| onClose | () => void | - | 关闭弹窗的回调 |
|
||||
| onConfirm | (year: number, month: number) => void | - | 确认选择的回调 |
|
||||
| defaultYear | number | 当前年份 | 默认选中的年份 |
|
||||
| defaultMonth | number | 当前月份 | 默认选中的月份 |
|
||||
| minYear | number | 2020 | 可选择的最小年份 |
|
||||
| maxYear | number | 2030 | 可选择的最大年份 |
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用 CSS Modules,可以通过修改 `index.module.scss` 文件来自定义样式。
|
||||
|
||||
主要样式类:
|
||||
- `.date-time-picker-popup` - 弹窗容器
|
||||
- `.picker-columns` - 选择器列容器
|
||||
- `.picker-column` - 单列选择器
|
||||
- `.action-buttons` - 操作按钮区域
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 组件基于 NutUI 的 Picker 和 Popup 组件
|
||||
2. 年份范围建议不要设置过大,以免影响性能
|
||||
3. 月份固定为 1-12 月
|
||||
4. 组件会自动处理边界情况
|
||||
@@ -1,45 +0,0 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Button } from '@tarojs/components'
|
||||
import DateTimePicker from './DateTimePicker'
|
||||
|
||||
const DateTimePickerExample: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [selectedDate, setSelectedDate] = useState('')
|
||||
|
||||
const handleOpen = () => {
|
||||
setVisible(true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
setSelectedDate(`${year}年${month}月`)
|
||||
console.log('选择的日期:', year, month)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ padding: '20px' }}>
|
||||
<Button onClick={handleOpen}>选择年月</Button>
|
||||
|
||||
{selectedDate && (
|
||||
<View style={{ marginTop: '20px', fontSize: '16px' }}>
|
||||
已选择: {selectedDate}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DateTimePicker
|
||||
visible={visible}
|
||||
onClose={handleClose}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2025}
|
||||
defaultMonth={11}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default DateTimePickerExample
|
||||
@@ -1,102 +1,89 @@
|
||||
/* 日期选择器弹出层样式 */
|
||||
.date-time-picker-popup {
|
||||
:global(.nut-popup) {
|
||||
border-radius: 16px 16px 0 0;
|
||||
background: #fff;
|
||||
.common-popup-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.popup-handle {
|
||||
width: 40px;
|
||||
width: 32px;
|
||||
height: 4px;
|
||||
background: #e5e5e5;
|
||||
background: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
margin: 12px auto 0;
|
||||
margin: 12px auto;
|
||||
}
|
||||
|
||||
.picker-container {
|
||||
padding: 20px 0;
|
||||
padding: 26px 16px 0 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.picker-columns {
|
||||
.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;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 60px;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.picker-label {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.year-picker,
|
||||
.month-picker {
|
||||
:global(.nut-picker) {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
:global(.nut-picker__content) {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
:global(.nut-picker-item) {
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
:global(.nut-picker-item--selected) {
|
||||
color: #000;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.nut-picker-item--disabled) {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
padding: 0 20px 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.confirm-btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
color: #666;
|
||||
.picker-item-text {
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.confirm-btn {
|
||||
background: #000;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
|
||||
.confirm-text {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
97
src/components/HourMinutePicker/HourMinutePicker.tsx
Normal file
97
src/components/HourMinutePicker/HourMinutePicker.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, PickerView, PickerViewColumn } from '@tarojs/components'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
export interface HourMinutePickerProps {
|
||||
onChange: (hour: number, minute: number) => void
|
||||
defaultHour?: number
|
||||
defaultMinute?: number
|
||||
minHour?: number
|
||||
maxHour?: number
|
||||
}
|
||||
|
||||
const HourMinutePicker: React.FC<HourMinutePickerProps> = ({
|
||||
onChange,
|
||||
defaultHour = new Date().getHours(),
|
||||
defaultMinute = new Date().getMinutes(),
|
||||
minHour = 0,
|
||||
maxHour = 23
|
||||
}) => {
|
||||
console.log('defaultHour', defaultHour)
|
||||
console.log('defaultMinute', defaultMinute)
|
||||
const [selectedHour, setSelectedHour] = useState(defaultHour)
|
||||
const [selectedMinute, setSelectedMinute] = useState(defaultMinute)
|
||||
|
||||
// 计算当前选项在数组中的索引
|
||||
const getHourIndex = (hour: number) => hour - minHour
|
||||
const getMinuteIndex = (minute: number) => Math.floor(minute / 5)
|
||||
|
||||
// 生成小时和分钟的选项数据
|
||||
const pickerOptions = [
|
||||
// 小时列
|
||||
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
|
||||
}))
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedHour(defaultHour)
|
||||
setSelectedMinute(defaultMinute)
|
||||
}, [defaultHour, defaultMinute])
|
||||
|
||||
const handlePickerChange = (event: any) => {
|
||||
const values = event.detail.value
|
||||
if (values && values.length >= 2) {
|
||||
// 根据索引获取实际值
|
||||
const hourIndex = values[0]
|
||||
const minuteIndex = values[1]
|
||||
const hour = minHour + hourIndex
|
||||
const minute = minuteIndex * 5
|
||||
setSelectedHour(hour)
|
||||
setSelectedMinute(minute)
|
||||
onChange(hour, minute)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles['hour-minute-picker-popup']}>
|
||||
{/* 拖拽手柄 */}
|
||||
<View className={styles['drag-handle']} />
|
||||
|
||||
{/* 时间选择器 */}
|
||||
<View className={styles['picker-container']}>
|
||||
{/* 多列选择器 */}
|
||||
<View className={styles['picker-wrapper']}>
|
||||
<PickerView
|
||||
value={[getHourIndex(selectedHour), getMinuteIndex(selectedMinute)]}
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default HourMinutePicker
|
||||
52
src/components/HourMinutePicker/index.module.scss
Normal file
52
src/components/HourMinutePicker/index.module.scss
Normal file
@@ -0,0 +1,52 @@
|
||||
.hour-minute-picker-popup {
|
||||
background-color: #fff;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 26px 16px 0 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 2px;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
|
||||
.picker-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.picker-wrapper {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.multi-column-picker {
|
||||
height: 216px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.picker-column {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.picker-item-text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 400;
|
||||
}
|
||||
2
src/components/HourMinutePicker/index.ts
Normal file
2
src/components/HourMinutePicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './HourMinutePicker'
|
||||
export type { HourMinutePickerProps } from './HourMinutePicker'
|
||||
@@ -1,215 +0,0 @@
|
||||
# 腾讯地图SDK使用说明
|
||||
|
||||
## 概述
|
||||
|
||||
本项目已集成腾讯地图SDK (`qqmap-wx-jssdk`),可以在小程序中使用腾讯地图的各种功能,包括地点搜索、地理编码等。现在已添加真实的腾讯地图组件,支持显示当前位置和交互功能。
|
||||
|
||||
## 安装依赖
|
||||
|
||||
项目已安装 `qqmap-wx-jssdk` 依赖:
|
||||
|
||||
```bash
|
||||
npm install qqmap-wx-jssdk
|
||||
# 或
|
||||
yarn add qqmap-wx-jssdk
|
||||
```
|
||||
|
||||
## 基本使用
|
||||
|
||||
### 1. 引入SDK
|
||||
|
||||
```typescript
|
||||
import QQMapWX from "qqmap-wx-jssdk";
|
||||
```
|
||||
|
||||
### 2. 初始化SDK
|
||||
|
||||
```typescript
|
||||
const qqmapsdk = new QQMapWX({
|
||||
key: 'YOUR_API_KEY' // 替换为你的腾讯地图API密钥
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 使用search方法搜索地点
|
||||
|
||||
```typescript
|
||||
// 搜索地点
|
||||
qqmapsdk.search({
|
||||
keyword: '关键词', // 搜索关键词
|
||||
location: '39.908802,116.397502', // 搜索中心点(可选)
|
||||
page_size: 20, // 每页结果数量(可选)
|
||||
page_index: 1, // 页码(可选)
|
||||
success: (res) => {
|
||||
console.log('搜索成功:', res.data);
|
||||
// 处理搜索结果
|
||||
},
|
||||
fail: (err) => {
|
||||
console.error('搜索失败:', err);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## 在组件中使用
|
||||
|
||||
### MapDisplay组件
|
||||
|
||||
`MapDisplay` 组件已经封装了腾讯地图SDK的使用,包括:
|
||||
|
||||
- **自动初始化SDK**
|
||||
- **真实地图显示**: 使用Taro的Map组件显示腾讯地图
|
||||
- **当前位置显示**: 自动获取并显示用户当前位置
|
||||
- **地点搜索功能**: 支持关键词搜索地点
|
||||
- **搜索结果展示**: 在地图上标记搜索结果
|
||||
- **交互功能**: 支持地图缩放、拖动、标记点击等
|
||||
- **错误处理**: 完善的错误处理和用户提示
|
||||
|
||||
### 主要功能特性
|
||||
|
||||
#### 1. 地图显示
|
||||
- 使用真实的腾讯地图组件
|
||||
- 默认显示当前位置
|
||||
- 支持地图缩放、拖动、旋转
|
||||
- 响应式设计,适配不同屏幕尺寸
|
||||
|
||||
#### 2. 位置服务
|
||||
- 自动获取用户当前位置
|
||||
- 支持位置刷新
|
||||
- 逆地理编码获取地址信息
|
||||
- 位置信息悬浮显示
|
||||
|
||||
#### 3. 搜索功能
|
||||
- 实时搜索地点
|
||||
- 防抖优化(500ms)
|
||||
- 搜索结果在地图上标记
|
||||
- 点击结果可移动地图中心
|
||||
|
||||
#### 4. 地图标记
|
||||
- 当前位置标记(蓝色)
|
||||
- 搜索结果标记
|
||||
- 标记点击交互
|
||||
- 动态添加/移除标记
|
||||
|
||||
### 使用示例
|
||||
|
||||
```typescript
|
||||
import { mapService } from './mapService';
|
||||
|
||||
// 搜索地点
|
||||
const results = await mapService.search({
|
||||
keyword: '体育馆',
|
||||
location: '39.908802,116.397502'
|
||||
});
|
||||
|
||||
console.log('搜索结果:', results);
|
||||
```
|
||||
|
||||
## API密钥配置
|
||||
|
||||
在 `mapService.ts` 中配置你的腾讯地图API密钥:
|
||||
|
||||
```typescript
|
||||
this.qqmapsdk = new QQMapWX({
|
||||
key: 'YOUR_API_KEY' // 替换为你的实际API密钥
|
||||
});
|
||||
```
|
||||
|
||||
## 组件属性
|
||||
|
||||
### Map组件属性
|
||||
- `longitude`: 地图中心经度
|
||||
- `latitude`: 地图中心纬度
|
||||
- `scale`: 地图缩放级别(1-20)
|
||||
- `markers`: 地图标记数组
|
||||
- `show-location`: 是否显示用户位置
|
||||
- `enable-zoom`: 是否支持缩放
|
||||
- `enable-scroll`: 是否支持拖动
|
||||
- `enable-rotate`: 是否支持旋转
|
||||
|
||||
### 标记属性
|
||||
```typescript
|
||||
interface Marker {
|
||||
id: string; // 标记唯一标识
|
||||
latitude: number; // 纬度
|
||||
longitude: number; // 经度
|
||||
title: string; // 标记标题
|
||||
iconPath?: string; // 图标路径
|
||||
width: number; // 图标宽度
|
||||
height: number; // 图标高度
|
||||
}
|
||||
```
|
||||
|
||||
## 主要功能
|
||||
|
||||
### 1. 地点搜索
|
||||
- 支持关键词搜索
|
||||
- 支持按位置范围搜索
|
||||
- 分页显示结果
|
||||
- 搜索结果地图标记
|
||||
|
||||
### 2. 位置服务
|
||||
- 获取当前位置
|
||||
- 地理编码
|
||||
- 逆地理编码
|
||||
- 位置刷新
|
||||
|
||||
### 3. 地图交互
|
||||
- 地图缩放
|
||||
- 地图拖动
|
||||
- 地图旋转
|
||||
- 标记点击
|
||||
- 地图点击
|
||||
|
||||
### 4. 错误处理
|
||||
- SDK初始化失败处理
|
||||
- 搜索失败处理
|
||||
- 网络异常处理
|
||||
- 位置获取失败处理
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API密钥**: 确保使用有效的腾讯地图API密钥
|
||||
2. **网络权限**: 小程序需要网络访问权限
|
||||
3. **位置权限**: 需要申请位置权限 (`scope.userLocation`)
|
||||
4. **错误处理**: 建议添加适当的错误处理和用户提示
|
||||
5. **地图组件**: 使用Taro的Map组件,确保兼容性
|
||||
|
||||
## 权限配置
|
||||
|
||||
在 `app.config.ts` 中添加位置权限:
|
||||
|
||||
```typescript
|
||||
export default defineAppConfig({
|
||||
// ... 其他配置
|
||||
permission: {
|
||||
'scope.userLocation': {
|
||||
desc: '你的位置信息将用于小程序位置接口的效果展示'
|
||||
}
|
||||
},
|
||||
requiredPrivateInfos: [
|
||||
'getLocation'
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: SDK初始化失败怎么办?
|
||||
A: 检查API密钥是否正确,网络连接是否正常
|
||||
|
||||
### Q: 搜索没有结果?
|
||||
A: 检查搜索关键词是否正确,API密钥是否有效
|
||||
|
||||
### Q: 如何获取用户当前位置?
|
||||
A: 使用小程序的 `wx.getLocation` API,已集成到地图服务中
|
||||
|
||||
### Q: 地图不显示怎么办?
|
||||
A: 检查网络连接,确保腾讯地图服务正常
|
||||
|
||||
### Q: 位置权限被拒绝?
|
||||
A: 引导用户手动开启位置权限,或使用默认位置
|
||||
|
||||
## 更多信息
|
||||
|
||||
- [腾讯地图小程序SDK官方文档](https://lbs.qq.com/miniProgram/jsSdk/jsSdkGuide/jsSdkOverview)
|
||||
- [API密钥申请](https://lbs.qq.com/dev/console/application/mine)
|
||||
- [Taro Map组件文档](https://taro-docs.jd.com/docs/components/map)
|
||||
@@ -1,382 +0,0 @@
|
||||
.map-display {
|
||||
height: 100vh;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.map-section {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
background-color: #e8f4fd;
|
||||
|
||||
.map-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
.map-component {
|
||||
width: 100%;
|
||||
height: calc(100vh - 50%);
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.map-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.map-loading-text {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-placeholder {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.location-info-overlay {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
|
||||
.location-info {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 16px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.location-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
margin-right: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 12px;
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.center-info-overlay {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
z-index: 10;
|
||||
|
||||
.center-info {
|
||||
background-color: rgba(255, 255, 255, 0.95);
|
||||
padding: 12px 16px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.center-text {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
text-align: center;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.moving-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 8px;
|
||||
background-color: rgba(255, 193, 7, 0.9);
|
||||
border-radius: 12px;
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
|
||||
.moving-text {
|
||||
font-size: 11px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fixed-center-indicator {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
|
||||
.center-dot {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: #ff4757;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2);
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.location-info {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
.location-text {
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.sdk-status {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
z-index: 20;
|
||||
|
||||
.sdk-status-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-section {
|
||||
background-color: #fff;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
.search-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #f8f8f8;
|
||||
border-radius: 24px;
|
||||
padding: 0 16px;
|
||||
position: relative;
|
||||
|
||||
.search-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
height: 40px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #e0e0e0;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #d0d0d0;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
background-color: #fff;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
|
||||
.results-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.results-title {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.results-list {
|
||||
max-height: 300px;
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
|
||||
.result-title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.result-address {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.result-arrow {
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.searching-indicator {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
|
||||
.searching-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
background-color: #fff;
|
||||
|
||||
.no-results-text {
|
||||
font-size: 14px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.sdk-status-full {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 20px 30px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
z-index: 1000;
|
||||
|
||||
.sdk-status-text {
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { View, Text, Input, ScrollView, Map } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import { mapService, SearchResult, LocationInfo } from './mapService'
|
||||
import './index.scss'
|
||||
|
||||
const MapDisplay: React.FC = () => {
|
||||
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<SearchResult[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [mapContext, setMapContext] = useState<any>(null)
|
||||
const [isSDKReady, setIsSDKReady] = useState(false)
|
||||
const [mapMarkers, setMapMarkers] = useState<any[]>([])
|
||||
// 地图中心点状态
|
||||
const [mapCenter, setMapCenter] = useState<{lat: number, lng: number} | null>(null)
|
||||
// 用户点击的中心点标记
|
||||
const [centerMarker, setCenterMarker] = useState<any>(null)
|
||||
// 是否正在移动地图
|
||||
const [isMapMoving, setIsMapMoving] = useState(false)
|
||||
// 地图移动的动画帧ID
|
||||
const animationFrameRef = useRef<number | null>(null)
|
||||
// 地图移动的目标位置
|
||||
const [targetCenter, setTargetCenter] = useState<{lat: number, lng: number} | null>(null)
|
||||
// 实时移动的定时器
|
||||
const moveTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
// 地图移动状态
|
||||
const [mapMoveState, setMapMoveState] = useState({
|
||||
isMoving: false,
|
||||
startTime: 0,
|
||||
startCenter: null as {lat: number, lng: number} | null,
|
||||
lastUpdateTime: 0
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
initializeMapService()
|
||||
return () => {
|
||||
// 清理动画帧和定时器
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 初始化地图服务
|
||||
const initializeMapService = async () => {
|
||||
try {
|
||||
const success = await mapService.initSDK()
|
||||
if (success) {
|
||||
setIsSDKReady(true)
|
||||
console.log('地图服务初始化成功')
|
||||
getCurrentLocation()
|
||||
} else {
|
||||
console.error('地图服务初始化失败')
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化地图服务异常:', error)
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化异常',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const location = await mapService.getLocation()
|
||||
if (location) {
|
||||
setCurrentLocation(location)
|
||||
// 设置地图中心为当前位置,但不显示标记
|
||||
setMapCenter({ lat: location.lat, lng: location.lng })
|
||||
// 清空所有标记
|
||||
setMapMarkers([])
|
||||
console.log('当前位置:', location)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
Taro.showToast({
|
||||
title: '获取位置失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地图加载完成
|
||||
const handleMapLoad = (e: any) => {
|
||||
console.log('地图加载完成:', e)
|
||||
setMapContext(e.detail)
|
||||
}
|
||||
|
||||
// 地图标记点击
|
||||
const handleMarkerTap = (e: any) => {
|
||||
const markerId = e.detail.markerId
|
||||
console.log('点击标记:', markerId)
|
||||
|
||||
if (markerId === 'center') {
|
||||
Taro.showToast({
|
||||
title: '中心点标记',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 地图区域点击 - 设置中心点和标记
|
||||
const handleMapTap = (e: any) => {
|
||||
const { latitude, longitude } = e.detail
|
||||
console.log('地图点击:', { latitude, longitude })
|
||||
|
||||
// 设置新的地图中心点
|
||||
setMapCenter({ lat: latitude, lng: longitude })
|
||||
|
||||
// 设置中心点标记
|
||||
const newCenterMarker = {
|
||||
id: 'center',
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
title: '中心点',
|
||||
iconPath: '/assets/center-marker.png', // 可以添加自定义中心点图标
|
||||
width: 40,
|
||||
height: 40
|
||||
}
|
||||
setCenterMarker(newCenterMarker)
|
||||
|
||||
// 更新地图标记,只显示中心点标记
|
||||
setMapMarkers([newCenterMarker])
|
||||
|
||||
Taro.showToast({
|
||||
title: '已设置中心点',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
|
||||
// 地图开始移动
|
||||
const handleMapMoveStart = () => {
|
||||
console.log('地图开始移动')
|
||||
setIsMapMoving(true)
|
||||
setMapMoveState(prev => ({
|
||||
...prev,
|
||||
isMoving: true,
|
||||
startTime: Date.now(),
|
||||
startCenter: mapCenter,
|
||||
lastUpdateTime: Date.now()
|
||||
}))
|
||||
|
||||
// 启动实时移动更新
|
||||
startRealTimeMoveUpdate()
|
||||
}
|
||||
|
||||
// 启动实时移动更新
|
||||
const startRealTimeMoveUpdate = () => {
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
}
|
||||
|
||||
// 每16ms更新一次(约60fps)
|
||||
moveTimerRef.current = setInterval(() => {
|
||||
if (mapMoveState.isMoving && centerMarker) {
|
||||
// 模拟地图移动过程中的位置更新
|
||||
// 这里我们基于时间计算一个平滑的移动轨迹
|
||||
const currentTime = Date.now()
|
||||
const elapsed = currentTime - mapMoveState.startTime
|
||||
const moveDuration = 300 // 假设移动持续300ms
|
||||
|
||||
if (elapsed < moveDuration) {
|
||||
// 计算移动进度
|
||||
const progress = elapsed / moveDuration
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 3) // 缓动函数
|
||||
|
||||
// 如果有目标位置,进行插值计算
|
||||
if (targetCenter && mapMoveState.startCenter) {
|
||||
const newLat = mapMoveState.startCenter.lat + (targetCenter.lat - mapMoveState.startCenter.lat) * easeProgress
|
||||
const newLng = mapMoveState.startCenter.lng + (targetCenter.lng - mapMoveState.startCenter.lng) * easeProgress
|
||||
|
||||
// 更新中心点标记位置
|
||||
const updatedCenterMarker = {
|
||||
...centerMarker,
|
||||
latitude: newLat,
|
||||
longitude: newLng
|
||||
}
|
||||
setCenterMarker(updatedCenterMarker)
|
||||
|
||||
// 更新地图标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([updatedCenterMarker, ...searchMarkers])
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 16)
|
||||
}
|
||||
|
||||
// 地图区域变化 - 更新目标位置
|
||||
const handleRegionChange = (e: any) => {
|
||||
console.log('地图区域变化:', e.detail)
|
||||
|
||||
// 获取地图当前的中心点坐标
|
||||
if (e.detail && e.detail.centerLocation) {
|
||||
const { latitude, longitude } = e.detail.centerLocation
|
||||
const newCenter = { lat: latitude, lng: longitude }
|
||||
|
||||
// 设置目标位置
|
||||
setTargetCenter(newCenter)
|
||||
|
||||
// 更新地图中心点状态
|
||||
setMapCenter(newCenter)
|
||||
|
||||
// 如果有中心点标记,立即更新标记位置到新的地图中心
|
||||
if (centerMarker) {
|
||||
const updatedCenterMarker = {
|
||||
...centerMarker,
|
||||
latitude: latitude,
|
||||
longitude: longitude
|
||||
}
|
||||
setCenterMarker(updatedCenterMarker)
|
||||
|
||||
// 更新地图标记,保持搜索结果标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([updatedCenterMarker, ...searchMarkers])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 地图移动结束
|
||||
const handleMapMoveEnd = () => {
|
||||
console.log('地图移动结束')
|
||||
setIsMapMoving(false)
|
||||
setMapMoveState(prev => ({
|
||||
...prev,
|
||||
isMoving: false
|
||||
}))
|
||||
|
||||
// 停止实时移动更新
|
||||
if (moveTimerRef.current) {
|
||||
clearInterval(moveTimerRef.current)
|
||||
moveTimerRef.current = null
|
||||
}
|
||||
|
||||
// 清理动画帧
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索输入
|
||||
const handleSearchInput = (e: any) => {
|
||||
const value = e.detail.value
|
||||
setSearchValue(value)
|
||||
|
||||
// 如果输入内容为空,清空搜索结果
|
||||
if (!value.trim()) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
// 防抖搜索
|
||||
clearTimeout((window as any).searchTimer)
|
||||
;(window as any).searchTimer = setTimeout(() => {
|
||||
performSearch(value)
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 执行搜索
|
||||
const performSearch = async (keyword: string) => {
|
||||
if (!keyword.trim() || !isSDKReady) return
|
||||
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
const results = await mapService.search({
|
||||
keyword,
|
||||
location: currentLocation ? `${currentLocation.lat},${currentLocation.lng}` : undefined
|
||||
})
|
||||
setSearchResults(results)
|
||||
|
||||
// 在地图上添加搜索结果标记
|
||||
if (results.length > 0) {
|
||||
const newMarkers = results.map((result, index) => ({
|
||||
id: `search_${index}`,
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
title: result.title,
|
||||
iconPath: '/assets/search-marker.png', // 可以添加自定义图标
|
||||
width: 24,
|
||||
height: 24
|
||||
}))
|
||||
|
||||
// 合并中心点标记和搜索结果标记
|
||||
const allMarkers = centerMarker ? [centerMarker, ...newMarkers] : newMarkers
|
||||
setMapMarkers(allMarkers)
|
||||
}
|
||||
|
||||
console.log('搜索结果:', results)
|
||||
} catch (error) {
|
||||
console.error('搜索异常:', error)
|
||||
Taro.showToast({
|
||||
title: '搜索失败',
|
||||
icon: 'none'
|
||||
})
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索结果点击 - 切换地图中心到对应地点
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
console.log('选择地点:', result)
|
||||
Taro.showToast({
|
||||
title: `已切换到: ${result.title}`,
|
||||
icon: 'success'
|
||||
})
|
||||
|
||||
// 点击搜索结果时,将地图中心移动到该位置
|
||||
const newCenter = { lat: result.location.lat, lng: result.location.lng }
|
||||
setMapCenter(newCenter)
|
||||
|
||||
// 更新中心点标记
|
||||
const newCenterMarker = {
|
||||
id: 'center',
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
title: '中心点',
|
||||
iconPath: '/assets/center-marker.png',
|
||||
width: 40,
|
||||
height: 40
|
||||
}
|
||||
setCenterMarker(newCenterMarker)
|
||||
|
||||
// 更新地图标记,保留搜索结果标记
|
||||
const searchMarkers = mapMarkers.filter(marker => marker.id.startsWith('search_'))
|
||||
setMapMarkers([newCenterMarker, ...searchMarkers])
|
||||
|
||||
// 如果地图上下文可用,也可以调用地图API移动
|
||||
if (mapContext && mapContext.moveToLocation) {
|
||||
mapContext.moveToLocation({
|
||||
latitude: result.location.lat,
|
||||
longitude: result.location.lng,
|
||||
success: () => {
|
||||
console.log('地图移动到搜索结果位置')
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('地图移动失败:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理搜索框清空
|
||||
const handleSearchClear = () => {
|
||||
setSearchValue('')
|
||||
setSearchResults([])
|
||||
// 清空搜索结果标记,只保留中心点标记
|
||||
setMapMarkers(centerMarker ? [centerMarker] : [])
|
||||
}
|
||||
|
||||
// 刷新位置
|
||||
const handleRefreshLocation = () => {
|
||||
getCurrentLocation()
|
||||
Taro.showToast({
|
||||
title: '正在刷新位置...',
|
||||
icon: 'loading'
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View className='map-display'>
|
||||
{/* 地图区域 */}
|
||||
<View className='map-section'>
|
||||
<View className='map-container'>
|
||||
{currentLocation ? (
|
||||
<Map
|
||||
className='map-component'
|
||||
longitude={mapCenter?.lng || currentLocation.lng}
|
||||
latitude={mapCenter?.lat || currentLocation.lat}
|
||||
scale={16}
|
||||
markers={mapMarkers}
|
||||
show-location={true}
|
||||
onTap={handleMapTap}
|
||||
theme="dark"
|
||||
onRegionChange={handleRegionChange}
|
||||
onTouchStart={handleMapMoveStart}
|
||||
onTouchEnd={handleMapMoveEnd}
|
||||
onError={(e) => console.error('地图加载错误:', e)}
|
||||
/>
|
||||
) : (
|
||||
<View className='map-loading'>
|
||||
<Text className='map-loading-text'>地图加载中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 位置信息悬浮层 */}
|
||||
{currentLocation && (
|
||||
<View className='location-info-overlay'>
|
||||
<View className='location-info'>
|
||||
<Text className='location-text'>
|
||||
{currentLocation.address || `当前位置: ${currentLocation.lat.toFixed(6)}, ${currentLocation.lng.toFixed(6)}`}
|
||||
</Text>
|
||||
<View className='refresh-btn' onClick={handleRefreshLocation}>
|
||||
🔄
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 中心点信息悬浮层 */}
|
||||
{centerMarker && (
|
||||
<View className='center-info-overlay'>
|
||||
<View className='center-info'>
|
||||
<Text className='center-text'>
|
||||
中心点: {centerMarker.latitude.toFixed(6)}, {centerMarker.longitude.toFixed(6)}
|
||||
</Text>
|
||||
{isMapMoving && (
|
||||
<View className='moving-indicator'>
|
||||
<Text className='moving-text'>移动中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!isSDKReady && (
|
||||
<View className='sdk-status'>
|
||||
<Text className='sdk-status-text'>地图服务初始化中...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 搜索区域 */}
|
||||
<View className='search-section'>
|
||||
<View className='search-wrapper'>
|
||||
<View className='search-icon'>🔍</View>
|
||||
<Input
|
||||
className='search-input'
|
||||
placeholder={isSDKReady ? '搜索地点' : '地图服务初始化中...'}
|
||||
value={searchValue}
|
||||
onInput={handleSearchInput}
|
||||
disabled={!isSDKReady}
|
||||
/>
|
||||
{searchValue && (
|
||||
<View className='clear-btn' onClick={handleSearchClear}>
|
||||
✕
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 搜索结果列表 */}
|
||||
{searchResults.length > 0 && (
|
||||
<View className='search-results'>
|
||||
<View className='results-header'>
|
||||
<Text className='results-title'>搜索结果</Text>
|
||||
<Text className='results-count'>({searchResults.length})</Text>
|
||||
</View>
|
||||
<ScrollView className='results-list' scrollY>
|
||||
{searchResults.map((result) => (
|
||||
<View
|
||||
key={result.id}
|
||||
className='result-item'
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<View className='result-content'>
|
||||
<Text className='result-title'>{result.title}</Text>
|
||||
<Text className='result-address'>{result.address}</Text>
|
||||
</View>
|
||||
<View className='result-arrow'>›</View>
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 搜索状态提示 */}
|
||||
{isSearching && (
|
||||
<View className='searching-indicator'>
|
||||
<Text className='searching-text'>搜索中...</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 无搜索结果提示 */}
|
||||
{searchValue && !isSearching && searchResults.length === 0 && isSDKReady && (
|
||||
<View className='no-results'>
|
||||
<Text className='no-results-text'>未找到相关地点</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* SDK状态提示 */}
|
||||
{!isSDKReady && (
|
||||
<View className='sdk-status-full'>
|
||||
<Text className='sdk-status-text'>正在初始化地图服务,请稍候...</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default MapDisplay
|
||||
@@ -1,63 +0,0 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
import { Button } from '@tarojs/components';
|
||||
import { mapService, SearchResult, LocationInfo } from './mapService'
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export default function MapPlugin() {
|
||||
const key = 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'; //使用在腾讯位置服务申请的key
|
||||
const referer = '八瓜一月'; //调用插件的app的名称
|
||||
const [currentLocation, setCurrentLocation] = useState<LocationInfo | null>(null)
|
||||
|
||||
const category = '';
|
||||
|
||||
const chooseLocation = () => {
|
||||
Taro.navigateTo({
|
||||
url: 'plugin://chooseLocation/index?key=' + key + '&referer=' + referer + '&latitude=' + currentLocation?.lat + '&longitude=' + currentLocation?.lng
|
||||
});
|
||||
}
|
||||
useEffect(() => {
|
||||
initializeMapService()
|
||||
}, [])
|
||||
|
||||
// 初始化地图服务
|
||||
const initializeMapService = async () => {
|
||||
try {
|
||||
const success = await mapService.initSDK()
|
||||
if (success) {
|
||||
console.log('地图服务初始化成功')
|
||||
getCurrentLocation()
|
||||
} else {
|
||||
console.error('地图服务初始化失败')
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('初始化地图服务异常:', error)
|
||||
Taro.showToast({
|
||||
title: '地图服务初始化异常',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
// 获取当前位置
|
||||
const getCurrentLocation = async () => {
|
||||
try {
|
||||
const location = await mapService.getLocation()
|
||||
if (location) {
|
||||
setCurrentLocation(location)
|
||||
console.log('当前位置:', location)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
Taro.showToast({
|
||||
title: '获取位置失败',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Button onClick={chooseLocation}>选择位置</Button>
|
||||
)
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
// 腾讯地图SDK服务
|
||||
import QQMapWX from "qqmap-wx-jssdk";
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
// 扩展Window接口,添加qqmapsdk属性
|
||||
declare global {
|
||||
interface Window {
|
||||
qqmapsdk?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LocationInfo {
|
||||
lat: number
|
||||
lng: number
|
||||
address?: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
id: string
|
||||
title: string
|
||||
address: string
|
||||
location: {
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
keyword: string
|
||||
location?: string
|
||||
page_size?: number
|
||||
page_index?: number
|
||||
}
|
||||
|
||||
class MapService {
|
||||
private qqmapsdk: any = null
|
||||
private isInitialized = false
|
||||
|
||||
// 初始化腾讯地图SDK
|
||||
async initSDK(): Promise<boolean> {
|
||||
if (this.isInitialized) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
// 直接使用QQMapWX,不需要通过window对象
|
||||
this.qqmapsdk = new QQMapWX({
|
||||
key: 'AZNBZ-VCSC4-MLVUF-KBASD-6GZ6H-KBFTX'
|
||||
});
|
||||
|
||||
this.isInitialized = true
|
||||
console.log('腾讯地图SDK初始化成功')
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('初始化腾讯地图SDK失败:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索地点
|
||||
async search(options: SearchOptions): Promise<SearchResult[]> {
|
||||
if (!this.isInitialized) {
|
||||
await this.initSDK()
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(this.qqmapsdk,11)
|
||||
if (this.qqmapsdk && this.qqmapsdk.search) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.qqmapsdk.getSuggestion({
|
||||
keyword: options.keyword,
|
||||
location: options.location || '39.908802,116.397502', // 默认北京
|
||||
page_size: options.page_size || 20,
|
||||
page_index: options.page_index || 1,
|
||||
success: (res: any) => {
|
||||
console.log('搜索成功:', res)
|
||||
if (res.data && res.data.length > 0) {
|
||||
const results: SearchResult[] = res.data.map((item: any, index: number) => ({
|
||||
id: `search_${index}`,
|
||||
title: item.title || item.name || '未知地点',
|
||||
address: item.address || item.location || '地址未知',
|
||||
location: {
|
||||
lat: item.location?.lat || 0,
|
||||
lng: item.location?.lng || 0
|
||||
}
|
||||
}))
|
||||
resolve(results)
|
||||
} else {
|
||||
resolve([])
|
||||
}
|
||||
},
|
||||
fail: (err: any) => {
|
||||
console.error('搜索失败:', err)
|
||||
reject(err)
|
||||
}
|
||||
})
|
||||
})
|
||||
} else {
|
||||
// 使用模拟数据
|
||||
console.log('使用模拟搜索数据')
|
||||
return this.getMockSearchResults(options.keyword)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索异常:', error)
|
||||
return this.getMockSearchResults(options.keyword)
|
||||
}
|
||||
}
|
||||
|
||||
// 获取模拟搜索结果
|
||||
private getMockSearchResults(keyword: string): SearchResult[] {
|
||||
const mockResults: SearchResult[] = [
|
||||
{
|
||||
id: 'mock_1',
|
||||
title: `${keyword}相关地点1`,
|
||||
address: '模拟地址1 - 这是一个示例地址',
|
||||
location: { lat: 39.908802, lng: 116.397502 }
|
||||
},
|
||||
{
|
||||
id: 'mock_2',
|
||||
title: `${keyword}相关地点2`,
|
||||
address: '模拟地址2 - 这是另一个示例地址',
|
||||
location: { lat: 39.918802, lng: 116.407502 }
|
||||
},
|
||||
{
|
||||
id: 'mock_3',
|
||||
title: `${keyword}相关地点3`,
|
||||
address: '模拟地址3 - 第三个示例地址',
|
||||
location: { lat: 39.898802, lng: 116.387502 }
|
||||
}
|
||||
]
|
||||
return mockResults
|
||||
}
|
||||
|
||||
// 获取当前位置
|
||||
async getCurrentLocation(): Promise<{ lat: number; lng: number } | null> {
|
||||
try {
|
||||
// 这里可以集成实际的定位服务
|
||||
// 暂时返回模拟位置
|
||||
const res = await Taro.getLocation({
|
||||
type: 'gcj02',
|
||||
isHighAccuracy: true
|
||||
})
|
||||
return {
|
||||
lat: res.latitude,
|
||||
lng: res.longitude
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
async getAddress(lat: number, lng: number): Promise<string | null | undefined> {
|
||||
try {
|
||||
const addressRes: any = await new Promise((resolve, reject) => {
|
||||
this.qqmapsdk.reverseGeocoder({
|
||||
location: {
|
||||
latitude: lat,
|
||||
longitude: lng
|
||||
},
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
return addressRes?.results?.address
|
||||
} catch (error) {
|
||||
console.error('获取地址失败:', error)
|
||||
}
|
||||
}
|
||||
async getLocation(): Promise<{ lat: number; lng: number; address: string } | null | undefined> {
|
||||
try {
|
||||
const currentInfo: any = {};
|
||||
const location = await this.getCurrentLocation();
|
||||
const { lat, lng } = location || {};
|
||||
|
||||
if (lat && lng) {
|
||||
currentInfo.lat = lat;
|
||||
currentInfo.lng = lng;
|
||||
const addressRes = await this.getAddress(lat, lng)
|
||||
if (addressRes) {
|
||||
currentInfo.address = addressRes;
|
||||
}
|
||||
}
|
||||
return currentInfo;
|
||||
} catch (error) {
|
||||
console.error('获取位置失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mapService = new MapService()
|
||||
81
src/components/PublishMenu/PublishMenu.tsx
Normal file
81
src/components/PublishMenu/PublishMenu.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import styles from './index.module.scss'
|
||||
import images from '@/config/images'
|
||||
|
||||
export interface PublishMenuProps {
|
||||
onPersonalPublish?: () => void
|
||||
onActivityPublish?: () => void
|
||||
}
|
||||
|
||||
const PublishMenu: React.FC<PublishMenuProps> = () => {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
const handleIconClick = () => {
|
||||
setIsVisible(!isVisible)
|
||||
}
|
||||
|
||||
const handleMenuItemClick = (type: 'individual' | 'group') => {
|
||||
// 跳转到publishBall页面并传递type参数
|
||||
console.log(type, 'type');
|
||||
Taro.navigateTo({
|
||||
url: `/pages/publishBall/index?type=${type}`
|
||||
})
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<View className={styles.publishMenu}>
|
||||
|
||||
{/* 菜单选项 */}
|
||||
{isVisible && (
|
||||
<View className={styles.menuCard}>
|
||||
<View
|
||||
className={styles.menuItem}
|
||||
onClick={() => handleMenuItemClick('individual')}
|
||||
>
|
||||
<View className={styles.menuIcon}>
|
||||
<Image src={images.ICON_PERSON} />
|
||||
</View>
|
||||
<View className={styles.menuContent}>
|
||||
<Text className={styles.menuTitle}>发布个人约球</Text>
|
||||
<Text className={styles.menuDesc}>已订场,找球友;未订场,找搭子</Text>
|
||||
</View>
|
||||
<View className={styles.menuArrow}>
|
||||
<Image src={images.ICON_ARROW_RIGHT} className={styles.img} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View
|
||||
className={styles.menuItem}
|
||||
onClick={() => handleMenuItemClick('group')}
|
||||
>
|
||||
<View className={styles.menuIcon}>
|
||||
<Image src={images.ICON_GROUP} />
|
||||
</View>
|
||||
<View className={styles.menuContent}>
|
||||
<Text className={styles.menuTitle}>发布畅打活动</Text>
|
||||
<Text className={styles.menuDesc}>认证球场官方组织</Text>
|
||||
</View>
|
||||
<View className={styles.menuArrow}>
|
||||
<Image src={images.ICON_ARROW_RIGHT} className={styles.img} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 绿色圆形按钮 */}
|
||||
<View
|
||||
className={`${styles.greenButton} ${isVisible ? styles.rotated : ''}`}
|
||||
onClick={handleIconClick}
|
||||
>
|
||||
<Image src={images.ICON_PUBLISH} className={styles.closeIcon} />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishMenu
|
||||
206
src/components/PublishMenu/index.module.scss
Normal file
206
src/components/PublishMenu/index.module.scss
Normal file
@@ -0,0 +1,206 @@
|
||||
.publishMenu {
|
||||
position: fixed;
|
||||
bottom: 40px;
|
||||
right: 40px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: transparent;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.menuCard {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
right: 0;
|
||||
width: 302px;
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 12px;
|
||||
animation: slideIn 0.3s ease-out;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
/* 小三角指示器 */
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -8px;
|
||||
right: 20px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-top: 8px solid white;
|
||||
/* 移除阴影,避免连接处的黑色 */
|
||||
}
|
||||
|
||||
/* 为小三角添加单独的阴影效果 */
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -9px;
|
||||
right: 20px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-top: 8px solid rgba(0, 0, 0, 0.1);
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 20px;
|
||||
border: 0.5px solid rgba(0, 0, 0, 0.08);
|
||||
background: var(--Backgrounds-Primary, #FFF);
|
||||
height: 68px;
|
||||
}
|
||||
|
||||
.menuIcon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.ballIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid #333;
|
||||
border-radius: 50%;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border: 1px solid #333;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
border-radius: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.activityIcon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid #333;
|
||||
border-radius: 50% 50% 0 50%;
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '+';
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
}
|
||||
|
||||
.menuContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.menuTitle {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
margin-bottom: 2px;
|
||||
line-height: 24px; /* 150% */
|
||||
}
|
||||
|
||||
.menuDesc {
|
||||
font-size: 12px;
|
||||
color: rgba(60, 60, 67, 0.60);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.menuArrow {
|
||||
font-size: 16px;
|
||||
color: #ccc;
|
||||
margin-left: 8px;
|
||||
.img{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.greenButton {
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
&.rotated {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
|
||||
.closeIcon {
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
2
src/components/PublishMenu/index.ts
Normal file
2
src/components/PublishMenu/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './PublishMenu'
|
||||
export type { PublishMenuProps } from './PublishMenu'
|
||||
77
src/components/TimePicker/README.md
Normal file
77
src/components/TimePicker/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# TimePicker 时间选择器组件
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 使用自定义样式重写PickerViewColumn功能
|
||||
- 完全还原原生PickerView的样式和动画效果
|
||||
- 支持年份和月份选择
|
||||
- 平滑的滚动动画和切换效果
|
||||
- 响应式设计,支持触摸滚动
|
||||
- 渐变遮罩效果增强视觉层次
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 核心特性
|
||||
- 使用ScrollView替代PickerViewColumn
|
||||
- 自定义滚动逻辑实现选项对齐
|
||||
- CSS动画和过渡效果还原原生体验
|
||||
- 智能滚动位置计算和自动对齐
|
||||
|
||||
### 样式还原
|
||||
- 选中项指示器(高亮背景)
|
||||
- 渐变遮罩效果(顶部和底部)
|
||||
- 平滑的过渡动画
|
||||
- 精确的尺寸和间距
|
||||
|
||||
## 使用方法
|
||||
|
||||
```tsx
|
||||
import { TimePicker } from '@/components/TimePicker'
|
||||
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
<TimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={(year, month) => {
|
||||
console.log('选择的时间:', year, month)
|
||||
setVisible(false)
|
||||
}}
|
||||
defaultYear={2024}
|
||||
defaultMonth={6}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| visible | boolean | - | 是否显示选择器 |
|
||||
| visible | boolean | - | 是否显示选择器 |
|
||||
| onClose | () => void | - | 关闭回调 |
|
||||
| onConfirm | (year: number, month: number) => void | - | 确认选择回调 |
|
||||
| defaultYear | number | 当前年份 | 默认选中的年份 |
|
||||
| defaultMonth | number | 当前月份 | 默认选中的月份 |
|
||||
| minYear | number | 2020 | 最小年份 |
|
||||
| maxYear | number | 2030 | 最大年份 |
|
||||
|
||||
## 样式定制
|
||||
|
||||
组件使用CSS Modules,可以通过修改`index.module.scss`文件来自定义样式:
|
||||
|
||||
- `.time-picker-popup`: 弹出层容器
|
||||
- `.picker-container`: 选择器容器
|
||||
- `.custom-picker`: 自定义选择器
|
||||
- `.picker-indicator`: 选中项指示器
|
||||
- `.picker-column`: 选择列
|
||||
- `.picker-item`: 选择项
|
||||
- `.picker-item-active`: 激活状态的选择项
|
||||
|
||||
## 测试
|
||||
|
||||
运行测试页面:
|
||||
```tsx
|
||||
import TimePickerTest from '@/components/TimePicker/test'
|
||||
```
|
||||
233
src/components/TimePicker/TimePicker.tsx
Normal file
233
src/components/TimePicker/TimePicker.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { View, Text, ScrollView } from '@tarojs/components'
|
||||
import { CommonPopup } from '../index'
|
||||
import styles from './index.module.scss'
|
||||
|
||||
export interface TimePickerProps {
|
||||
visible: boolean
|
||||
onClose: () => void
|
||||
onConfirm: (year: number, month: number) => void
|
||||
defaultYear?: number
|
||||
defaultMonth?: number
|
||||
minYear?: number
|
||||
maxYear?: number
|
||||
}
|
||||
|
||||
const TimePicker: React.FC<TimePickerProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
onConfirm,
|
||||
defaultYear = new Date().getFullYear(),
|
||||
defaultMonth = new Date().getMonth() + 1,
|
||||
minYear = 2020,
|
||||
maxYear = 2030
|
||||
}) => {
|
||||
const [selectedYear, setSelectedYear] = useState(defaultYear)
|
||||
const [selectedMonth, setSelectedMonth] = useState(defaultMonth)
|
||||
const [yearScrollTop, setYearScrollTop] = useState(0)
|
||||
const [monthScrollTop, setMonthScrollTop] = useState(0)
|
||||
|
||||
const yearScrollRef = useRef<any>(null)
|
||||
const monthScrollRef = useRef<any>(null)
|
||||
|
||||
// 计算当前选项在数组中的索引
|
||||
const getYearIndex = (year: number) => year - minYear
|
||||
const getMonthIndex = (month: number) => month - 1
|
||||
|
||||
// 生成选择器的选项数据
|
||||
const yearOptions = Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({
|
||||
text: `${minYear + index}年`,
|
||||
value: minYear + index
|
||||
}))
|
||||
|
||||
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||
text: `${index + 1}月`,
|
||||
value: index + 1
|
||||
}))
|
||||
|
||||
// 计算滚动位置 - 确保每次只显示一个选项
|
||||
const calculateScrollTop = (index: number) => {
|
||||
const itemHeight = 48 // 每个选项的高度
|
||||
const containerHeight = 216 // 容器高度
|
||||
const centerOffset = (containerHeight - itemHeight) / 2
|
||||
return index * itemHeight - centerOffset
|
||||
}
|
||||
|
||||
// 获取当前可见的选项数量
|
||||
const getVisibleItemCount = () => {
|
||||
const containerHeight = 216
|
||||
const itemHeight = 48
|
||||
return Math.floor(containerHeight / itemHeight)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedYear(defaultYear)
|
||||
setSelectedMonth(defaultMonth)
|
||||
|
||||
// 设置初始滚动位置
|
||||
const yearScrollTop = calculateScrollTop(getYearIndex(defaultYear))
|
||||
const monthScrollTop = calculateScrollTop(getMonthIndex(defaultMonth))
|
||||
setYearScrollTop(yearScrollTop)
|
||||
setMonthScrollTop(monthScrollTop)
|
||||
}
|
||||
}, [visible, defaultYear, defaultMonth])
|
||||
|
||||
// 处理年份滚动
|
||||
const handleYearScroll = (event: any) => {
|
||||
const scrollTop = event.detail.scrollTop
|
||||
const itemHeight = 48
|
||||
const containerHeight = 216
|
||||
const centerOffset = (containerHeight - itemHeight) / 2
|
||||
|
||||
// 计算当前选中的年份索引
|
||||
const currentIndex = Math.round((scrollTop + centerOffset) / itemHeight)
|
||||
const clampedIndex = Math.max(0, Math.min(currentIndex, yearOptions.length - 1))
|
||||
const newYear = minYear + clampedIndex
|
||||
|
||||
if (newYear !== selectedYear) {
|
||||
setSelectedYear(newYear)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理年份滚动结束,自动对齐
|
||||
const handleYearScrollEnd = () => {
|
||||
const yearIndex = getYearIndex(selectedYear)
|
||||
const alignedScrollTop = calculateScrollTop(yearIndex)
|
||||
// 使用setTimeout确保滚动动画完成后再对齐
|
||||
setTimeout(() => {
|
||||
setYearScrollTop(alignedScrollTop)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
// 处理月份滚动
|
||||
const handleMonthScroll = (event: any) => {
|
||||
const scrollTop = event.detail.scrollTop
|
||||
const itemHeight = 48
|
||||
const containerHeight = 216
|
||||
const centerOffset = (containerHeight - itemHeight) / 2
|
||||
|
||||
// 计算当前选中的月份索引
|
||||
const currentIndex = Math.round((scrollTop + centerOffset) / itemHeight)
|
||||
const clampedIndex = Math.max(0, Math.min(currentIndex, monthOptions.length - 1))
|
||||
const newMonth = clampedIndex + 1
|
||||
|
||||
if (newMonth !== selectedMonth) {
|
||||
setSelectedMonth(newMonth)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理月份滚动结束,自动对齐
|
||||
const handleMonthScrollEnd = () => {
|
||||
const monthIndex = getMonthIndex(selectedMonth)
|
||||
const alignedScrollTop = calculateScrollTop(monthIndex)
|
||||
// 使用setTimeout确保滚动动画完成后再对齐
|
||||
setTimeout(() => {
|
||||
setMonthScrollTop(alignedScrollTop)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm(selectedYear, selectedMonth)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<CommonPopup
|
||||
visible={visible}
|
||||
onClose={onClose}
|
||||
onConfirm={handleConfirm}
|
||||
showHeader={false}
|
||||
hideFooter={false}
|
||||
cancelText="返回"
|
||||
confirmText="完成"
|
||||
position="bottom"
|
||||
round={true}
|
||||
className={styles['time-picker-popup']}
|
||||
>
|
||||
{/* 拖拽手柄 */}
|
||||
<View className={styles['popup-handle']} />
|
||||
|
||||
{/* 时间选择器 */}
|
||||
<View className={styles['picker-container']}>
|
||||
{/* 自定义多列选择器 */}
|
||||
<View className={styles['picker-wrapper']}>
|
||||
<View className={styles['custom-picker']}>
|
||||
{/* 选中项指示器 */}
|
||||
<View className={styles['picker-indicator']} />
|
||||
|
||||
{/* 年份列 */}
|
||||
<View className={styles['picker-column']}>
|
||||
<ScrollView
|
||||
ref={yearScrollRef}
|
||||
scrollY
|
||||
scrollTop={yearScrollTop}
|
||||
onScroll={handleYearScroll}
|
||||
onTouchEnd={handleYearScrollEnd}
|
||||
onScrollToLower={handleYearScrollEnd}
|
||||
onScrollToUpper={handleYearScrollEnd}
|
||||
className={styles['picker-scroll']}
|
||||
scrollWithAnimation={true}
|
||||
enhanced={true}
|
||||
showScrollbar={false}
|
||||
bounces={false}
|
||||
fastDeceleration={true}
|
||||
>
|
||||
<View className={styles['picker-padding']} />
|
||||
{yearOptions.map((option, index) => (
|
||||
<View
|
||||
key={option.value}
|
||||
className={`${styles['picker-item']} ${
|
||||
option.value === selectedYear ? styles['picker-item-active'] : ''
|
||||
}`}
|
||||
data-value={option.value}
|
||||
>
|
||||
<Text className={styles['picker-item-text']}>{option.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View className={styles['picker-padding']} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 月份列 */}
|
||||
<View className={styles['picker-column']}>
|
||||
<ScrollView
|
||||
ref={monthScrollRef}
|
||||
scrollY
|
||||
scrollTop={monthScrollTop}
|
||||
onScroll={handleMonthScroll}
|
||||
onTouchEnd={handleMonthScrollEnd}
|
||||
onScrollToLower={handleMonthScrollEnd}
|
||||
onScrollToUpper={handleMonthScrollEnd}
|
||||
className={styles['picker-scroll']}
|
||||
scrollWithAnimation={true}
|
||||
enhanced={true}
|
||||
showScrollbar={false}
|
||||
bounces={false}
|
||||
fastDeceleration={true}
|
||||
>
|
||||
<View className={styles['picker-padding']} />
|
||||
{monthOptions.map((option, index) => (
|
||||
<View
|
||||
key={option.value}
|
||||
className={`${styles['picker-item']} ${
|
||||
option.value === selectedMonth ? styles['picker-item-active'] : ''
|
||||
}`}
|
||||
data-value={option.value}
|
||||
>
|
||||
<Text className={styles['picker-item-text']}>{option.text}</Text>
|
||||
</View>
|
||||
))}
|
||||
<View className={styles['picker-padding']} />
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</CommonPopup>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePicker
|
||||
81
src/components/TimePicker/demo.module.scss
Normal file
81
src/components/TimePicker/demo.module.scss
Normal file
@@ -0,0 +1,81 @@
|
||||
.demoContainer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.demoButton {
|
||||
margin: 20px 0;
|
||||
width: 250px;
|
||||
height: 50px;
|
||||
border-radius: 25px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: scale(0.98);
|
||||
}
|
||||
}
|
||||
|
||||
.demoResult {
|
||||
margin: 30px 0;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
|
||||
text {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.demoFeatures {
|
||||
margin-top: 40px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
opacity: 0.9;
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.featureTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.featureItem {
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.5;
|
||||
}
|
||||
55
src/components/TimePicker/demo.tsx
Normal file
55
src/components/TimePicker/demo.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import TimePicker from './TimePicker'
|
||||
import styles from './demo.module.scss'
|
||||
|
||||
const TimePickerDemo: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [selectedTime, setSelectedTime] = useState('')
|
||||
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
setSelectedTime(`${year}年${month}月`)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.demoContainer}>
|
||||
<Text className={styles.title}>TimePicker 演示</Text>
|
||||
<Text className={styles.subtitle}>体验"一个一个往下翻"的效果</Text>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setVisible(true)}
|
||||
className={styles.demoButton}
|
||||
>
|
||||
打开时间选择器
|
||||
</Button>
|
||||
|
||||
{selectedTime && (
|
||||
<View className={styles.demoResult}>
|
||||
<Text>已选择: {selectedTime}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className={styles.demoFeatures}>
|
||||
<Text className={styles.featureTitle}>特性说明:</Text>
|
||||
<Text className={styles.featureItem}>• 每次只显示一个选项</Text>
|
||||
<Text className={styles.featureItem}>• 完美居中对齐</Text>
|
||||
<Text className={styles.featureItem}>• 平滑滚动动画</Text>
|
||||
<Text className={styles.featureItem}>• 触摸结束后自动对齐</Text>
|
||||
</View>
|
||||
|
||||
<TimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2024}
|
||||
defaultMonth={6}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePickerDemo
|
||||
187
src/components/TimePicker/index.module.scss
Normal file
187
src/components/TimePicker/index.module.scss
Normal file
@@ -0,0 +1,187 @@
|
||||
/* 时间选择器弹出层样式 */
|
||||
.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-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.custom-picker {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 216px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 确保只显示一个选项 */
|
||||
perspective: 1000px;
|
||||
/* 水平布局 */
|
||||
flex-direction: row;
|
||||
/* 确保列之间有适当间距 */
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* 选中项指示器 */
|
||||
.picker-indicator {
|
||||
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;
|
||||
box-shadow: inset 0 0 0 1px rgba(22, 24, 35, 0.1);
|
||||
/* 确保指示器完美覆盖选中项 */
|
||||
margin: 0 20px;
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
.picker-column {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* 水平居中布局 */
|
||||
min-width: 0;
|
||||
/* 确保列之间有适当间距 */
|
||||
padding: 0 8px;
|
||||
|
||||
&:first-child {
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 确保滚动容器正确显示 */
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 84px;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 0.8) 50%, rgba(255, 255, 255, 0) 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.picker-scroll {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
will-change: scroll-position;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* 确保滚动行为 */
|
||||
scroll-snap-type: y mandatory;
|
||||
/* 优化滚动性能 */
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.picker-padding {
|
||||
height: 84px; /* (216 - 48) / 2 = 84px,用于居中对齐 */
|
||||
/* 确保padding区域不可见 */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 48px;
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
color: #161823;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: relative;
|
||||
will-change: transform, color;
|
||||
/* 确保每个选项都能正确对齐 */
|
||||
scroll-snap-align: center;
|
||||
/* 水平居中 */
|
||||
text-align: center;
|
||||
|
||||
&.picker-item-active {
|
||||
color: #161823;
|
||||
font-weight: 600;
|
||||
transform: scale(1.02);
|
||||
|
||||
.picker-item-text {
|
||||
color: #161823;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.picker-item-active) {
|
||||
color: rgba(22, 24, 35, 0.6);
|
||||
|
||||
.picker-item-text {
|
||||
color: rgba(22, 24, 35, 0.6);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.picker-item-text {
|
||||
font-size: 16px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
line-height: 48px;
|
||||
white-space: nowrap;
|
||||
/* 确保文字完美居中 */
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
/* 强制居中对齐 */
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
/* 滚动条隐藏 */
|
||||
.picker-scroll {
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移除重复的渐变遮罩代码,已在.picker-column中定义 */
|
||||
2
src/components/TimePicker/index.ts
Normal file
2
src/components/TimePicker/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './TimePicker'
|
||||
export type { TimePickerProps } from './TimePicker'
|
||||
59
src/components/TimePicker/layout-test.module.scss
Normal file
59
src/components/TimePicker/layout-test.module.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
.testContainer {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.testTitle {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.testSubtitle {
|
||||
font-size: 16px;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.testInfo {
|
||||
margin: 20px 0;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
text-align: left;
|
||||
|
||||
text {
|
||||
font-size: 14px;
|
||||
margin: 8px 0;
|
||||
display: block;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.testButton {
|
||||
margin: 20px 0;
|
||||
width: 200px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
background: #007bff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.testResult {
|
||||
margin: 20px 0;
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
|
||||
text {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
51
src/components/TimePicker/layout-test.tsx
Normal file
51
src/components/TimePicker/layout-test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, Button } from '@tarojs/components'
|
||||
import TimePicker from './TimePicker'
|
||||
import styles from './layout-test.module.scss'
|
||||
|
||||
const LayoutTest: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [selectedTime, setSelectedTime] = useState('')
|
||||
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
setSelectedTime(`${year}年${month}月`)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.testContainer}>
|
||||
<Text className={styles.testTitle}>布局测试</Text>
|
||||
<Text className={styles.testSubtitle}>验证年份和月份的水平居中对齐</Text>
|
||||
|
||||
<View className={styles.testInfo}>
|
||||
<Text>• 年份和月份应该在同一行显示</Text>
|
||||
<Text>• 两个列应该水平居中对齐</Text>
|
||||
<Text>• 选中项指示器应该完美覆盖两个列</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setVisible(true)}
|
||||
className={styles.testButton}
|
||||
>
|
||||
测试布局
|
||||
</Button>
|
||||
|
||||
{selectedTime && (
|
||||
<View className={styles.testResult}>
|
||||
<Text>选择结果: {selectedTime}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2024}
|
||||
defaultMonth={6}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
36
src/components/TimePicker/test.module.scss
Normal file
36
src/components/TimePicker/test.module.scss
Normal file
@@ -0,0 +1,36 @@
|
||||
.container {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
background: #f5f5f5;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 30px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.button {
|
||||
margin: 20px 0;
|
||||
width: 200px;
|
||||
height: 44px;
|
||||
border-radius: 22px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.result {
|
||||
margin-top: 30px;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
|
||||
text {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
46
src/components/TimePicker/test.tsx
Normal file
46
src/components/TimePicker/test.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Button, Text } from '@tarojs/components'
|
||||
import TimePicker from './TimePicker'
|
||||
import styles from './test.module.scss'
|
||||
|
||||
const TimePickerTest: React.FC = () => {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [selectedTime, setSelectedTime] = useState('')
|
||||
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
setSelectedTime(`${year}年${month}月`)
|
||||
setVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={styles.container}>
|
||||
<Text className={styles.title}>TimePicker 组件测试</Text>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setVisible(true)}
|
||||
className={styles.button}
|
||||
>
|
||||
打开时间选择器
|
||||
</Button>
|
||||
|
||||
{selectedTime && (
|
||||
<View className={styles.result}>
|
||||
<Text>已选择: {selectedTime}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2024}
|
||||
defaultMonth={6}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TimePickerTest
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState } from 'react'
|
||||
import { View, Text, } from '@tarojs/components'
|
||||
import { getDate, getTime } from '@/utils/timeUtils'
|
||||
import DateTimePicker from '@/components/DateTimePicker'
|
||||
import { getDate, getTime, getDateStr, getEndTime } from '@/utils/timeUtils'
|
||||
import DialogCalendarCard from '@/components/CalendarCard/DialogCalendarCard'
|
||||
import './TimeSelector.scss'
|
||||
|
||||
export interface TimeRange {
|
||||
@@ -23,8 +23,11 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
}) => {
|
||||
// 格式化日期显示
|
||||
const [visible, setVisible] = useState(false)
|
||||
const handleConfirm = (year: number, month: number) => {
|
||||
console.log('选择的日期:', year, month)
|
||||
const handleConfirm = (date: Date) => {
|
||||
console.log('选择的日期:', date)
|
||||
const start_time = getDateStr(date)
|
||||
const end_time = getEndTime(start_time)
|
||||
if (onChange) onChange({start_time, end_time})
|
||||
}
|
||||
return (
|
||||
<View className='time-selector'>
|
||||
@@ -56,14 +59,10 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<DateTimePicker
|
||||
visible={visible}
|
||||
onClose={() => setVisible(false)}
|
||||
onConfirm={handleConfirm}
|
||||
defaultYear={2025}
|
||||
defaultMonth={11}
|
||||
minYear={2020}
|
||||
maxYear={2030}
|
||||
<DialogCalendarCard
|
||||
visible={visible}
|
||||
onChange={handleConfirm}
|
||||
onClose={() => setVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -9,17 +9,26 @@ import TimeSelector from './TimeSelector'
|
||||
import TitleTextarea from './TitleTextarea'
|
||||
import CommonPopup from './CommonPopup'
|
||||
import DateTimePicker from './DateTimePicker/DateTimePicker'
|
||||
|
||||
export {
|
||||
ActivityTypeSwitch,
|
||||
TextareaTag,
|
||||
FormSwitch,
|
||||
ImageUpload,
|
||||
Range,
|
||||
NumberInterval,
|
||||
TimeSelector,
|
||||
TitleTextarea,
|
||||
CommonPopup,
|
||||
DateTimePicker
|
||||
}
|
||||
import TimePicker from './TimePicker/TimePicker'
|
||||
import CalendarCard, { DialogCalendarCard } from './CalendarCard'
|
||||
import CommonDialog from './CommonDialog'
|
||||
import PublishMenu from './PublishMenu/PublishMenu'
|
||||
|
||||
export {
|
||||
ActivityTypeSwitch,
|
||||
TextareaTag,
|
||||
FormSwitch,
|
||||
ImageUpload,
|
||||
Range,
|
||||
NumberInterval,
|
||||
TimeSelector,
|
||||
TitleTextarea,
|
||||
CommonPopup,
|
||||
DateTimePicker,
|
||||
TimePicker,
|
||||
CalendarCard,
|
||||
DialogCalendarCard,
|
||||
CommonDialog,
|
||||
PublishMenu
|
||||
}
|
||||
|
||||
|
||||
@@ -16,5 +16,10 @@ export default {
|
||||
ICON_HEART_CIRCLE: require('@/static/publishBall/icon-heartcircle.png'),
|
||||
ICON_ADD: require('@/static/publishBall/icon-add.svg'),
|
||||
ICON_COPY: require('@/static/publishBall/icon-arrow-right.svg'),
|
||||
ICON_DELETE: require('@/static/publishBall/icon-delete.svg')
|
||||
ICON_DELETE: require('@/static/publishBall/icon-delete.svg'),
|
||||
ICON_RIGHT_MAX: require('@/static/publishBall/icon-right-max.svg'),
|
||||
ICON_PLUS: require('@/static/publishBall/icon-plus.svg'),
|
||||
ICON_GROUP: require('@/static/publishBall/icon-group.svg'),
|
||||
ICON_PERSON: require('@/static/publishBall/icon-person.svg'),
|
||||
ICON_PUBLISH: require('@/static/publishBall/icon-publish.png'),
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// ==========================================
|
||||
|
||||
// 引入NutUI原始样式(如果需要)
|
||||
// @import '@nutui/nutui-react-taro/dist/style.css';
|
||||
@import '@nutui/nutui-react-taro/dist/style.css';
|
||||
|
||||
// 全局主题变量覆盖
|
||||
$nut-primary-color: #000000 !important;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1
src/package/qqmap-wx-jssdk.min.js
vendored
1
src/package/qqmap-wx-jssdk.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@ import Taro from '@tarojs/taro'
|
||||
// 导入API服务
|
||||
import demoApi from '../../services/demoApi'
|
||||
import commonApi from '../../services/commonApi'
|
||||
import PublishMenu from '../../components/PublishMenu'
|
||||
import {
|
||||
useUserStats,
|
||||
useUserActions
|
||||
@@ -286,6 +287,13 @@ function Index() {
|
||||
<Text className='tip-item'>• 请求失败时会自动使用模拟数据</Text>
|
||||
</View>
|
||||
</View>
|
||||
<PublishMenu
|
||||
onPersonalPublish={() => {
|
||||
Taro.navigateTo({
|
||||
url: '/pages/publishBall/index'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
// import MapPlugin from "src/components/MapDisplay/mapPlugin";
|
||||
import MapDisplay from "src/components/MapDisplay";
|
||||
export default function MapDisplayPage() {
|
||||
return <MapDisplay />
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export interface Stadium {
|
||||
id?: string
|
||||
name: string
|
||||
address?: string
|
||||
istance?: string
|
||||
distance_km?: number | null | undefined
|
||||
longitude?: number
|
||||
latitude?: number
|
||||
}
|
||||
@@ -78,6 +78,15 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
|
||||
setShowDetail(true)
|
||||
}
|
||||
|
||||
const calculateDistance = (stadium: Stadium) => {
|
||||
const distance_km = stadium.distance_km
|
||||
if (!distance_km) return ''
|
||||
if (distance_km && distance_km > 1) {
|
||||
return distance_km.toFixed(1) + 'km'
|
||||
}
|
||||
return (distance_km * 1000).toFixed(0) + 'm'
|
||||
}
|
||||
|
||||
|
||||
// 处理搜索框输入
|
||||
const handleSearchInput = (e: any) => {
|
||||
@@ -253,7 +262,7 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
|
||||
handleItemLocation(stadium)
|
||||
}}
|
||||
>
|
||||
{stadium.istance} ·
|
||||
{calculateDistance(stadium)} ·
|
||||
</Text>
|
||||
<Text
|
||||
className='stadium-address-text'
|
||||
|
||||
@@ -13,7 +13,7 @@ export interface Stadium {
|
||||
address?: string
|
||||
longitude?: number
|
||||
latitude?: number
|
||||
istance?: string
|
||||
distance_km?: number | null
|
||||
court_type?: string
|
||||
court_surface?: string
|
||||
description?: string
|
||||
@@ -99,7 +99,7 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
address: stadium.address,
|
||||
latitude: stadium.longitude,
|
||||
longitude: stadium.latitude,
|
||||
istance: stadium.istance,
|
||||
istance: stadium.distance_km,
|
||||
court_type: court_type[0] || '',
|
||||
court_surface: court_surface[0] || '',
|
||||
additionalInfo: '',
|
||||
@@ -116,6 +116,13 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
setFormData: (data: any) => setFormData(data)
|
||||
}), [formData, stadium])
|
||||
|
||||
const calculateDistance = (distance_km: number | null) => {
|
||||
if (!distance_km) return ''
|
||||
if (distance_km && distance_km > 1) {
|
||||
return distance_km.toFixed(1) + 'km'
|
||||
}
|
||||
return (distance_km * 1000).toFixed(0) + 'm'
|
||||
}
|
||||
|
||||
|
||||
const handleMapLocation = () => {
|
||||
@@ -127,7 +134,8 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
name: res.name,
|
||||
address: res.address,
|
||||
latitude: res.longitude,
|
||||
longitude: res.latitude
|
||||
longitude: res.latitude,
|
||||
istance: null
|
||||
})
|
||||
},
|
||||
fail: (err) => {
|
||||
@@ -165,7 +173,7 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
|
||||
<View className='stadium-item-right'>
|
||||
<View className='stadium-name'>{formData.name}</View>
|
||||
<View className='stadium-address'>
|
||||
<Text>{formData.istance} · </Text>
|
||||
<Text>{calculateDistance(formData.istance || null)} · </Text>
|
||||
<Text>{formData.address}</Text>
|
||||
<Image src={images.ICON_ARRORW_SMALL} className='stadium-map-icon' />
|
||||
</View>
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: rgba(60, 60, 67, 0.50);
|
||||
|
||||
font-size: 14px;
|
||||
|
||||
&-icon{
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -230,74 +231,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 删除确认弹窗
|
||||
.delete-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
|
||||
&__content {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin: 0 32px;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: theme.$primary-color;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&__desc {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: rgba(60, 60, 67, 0.6);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
|
||||
.delete-modal__btn {
|
||||
flex: 1;
|
||||
height: 44px;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:first-child {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
color: rgba(60, 60, 67, 0.8);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
background: #FF3B30;
|
||||
color: white;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 旋转动画
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { View, Text, Button, Image } from '@tarojs/components'
|
||||
import Taro from '@tarojs/taro'
|
||||
import ActivityTypeSwitch, { type ActivityType } from '../../components/ActivityTypeSwitch'
|
||||
import { type ActivityType } from '../../components/ActivityTypeSwitch'
|
||||
// import ActivityTypeSwitch, { type ActivityType } from '../../components/ActivityTypeSwitch'
|
||||
import CommonDialog from '../../components/CommonDialog'
|
||||
import PublishForm from './publishForm'
|
||||
import { publishBallFormSchema } from '../../config/formSchema/publishBallFormSchema';
|
||||
import { PublishBallFormData } from '../../../types/publishBall';
|
||||
@@ -44,6 +46,29 @@ const defaultFormData: PublishBallFormData = {
|
||||
|
||||
const PublishBall: React.FC = () => {
|
||||
const [activityType, setActivityType] = useState<ActivityType>('individual')
|
||||
|
||||
// 获取页面参数并设置导航标题
|
||||
useEffect(() => {
|
||||
const currentInstance = Taro.getCurrentInstance()
|
||||
const params = currentInstance.router?.params
|
||||
if (params?.type) {
|
||||
const type = params.type as ActivityType
|
||||
if (type === 'individual' || type === 'group') {
|
||||
setActivityType(type)
|
||||
// 根据type设置导航标题
|
||||
if (type === 'group') {
|
||||
Taro.setNavigationBarTitle({
|
||||
title: '发布畅打活动'
|
||||
})
|
||||
} else {
|
||||
Taro.setNavigationBarTitle({
|
||||
title: '发布'
|
||||
})
|
||||
}
|
||||
}
|
||||
handleActivityTypeChange(type)
|
||||
}
|
||||
}, [])
|
||||
const [formData, setFormData] = useState<PublishBallFormData[]>([
|
||||
defaultFormData
|
||||
])
|
||||
@@ -69,10 +94,13 @@ const PublishBall: React.FC = () => {
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 处理活动类型变化
|
||||
const handleActivityTypeChange = (type: ActivityType) => {
|
||||
setActivityType(type)
|
||||
if (type === 'group') {
|
||||
setFormData([defaultFormData])
|
||||
} else {
|
||||
setFormData([defaultFormData])
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = () => {
|
||||
@@ -80,8 +108,10 @@ const PublishBall: React.FC = () => {
|
||||
setFormData(prev => [...prev, {
|
||||
...defaultFormData,
|
||||
title: '',
|
||||
start_time: newStartTime,
|
||||
end_time: getEndTime(newStartTime)
|
||||
timeRange: {
|
||||
start_time: newStartTime,
|
||||
end_time: getEndTime(newStartTime)
|
||||
}
|
||||
}])
|
||||
}
|
||||
|
||||
@@ -131,7 +161,7 @@ const PublishBall: React.FC = () => {
|
||||
const validateFormData = (formData: PublishBallFormData) => {
|
||||
const { activityInfo, image_list, title } = formData;
|
||||
const { play_type, price, location_name } = activityInfo;
|
||||
if (!image_list.length) {
|
||||
if (!image_list?.length) {
|
||||
Taro.showToast({
|
||||
title: `请上传活动封面`,
|
||||
icon: 'none'
|
||||
@@ -145,21 +175,21 @@ const PublishBall: React.FC = () => {
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!price) {
|
||||
if (!price || (typeof price === 'number' && price <= 0) || (typeof price === 'string' && !price.trim())) {
|
||||
Taro.showToast({
|
||||
title: `请输入费用`,
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!play_type) {
|
||||
if (!play_type || !play_type.trim()) {
|
||||
Taro.showToast({
|
||||
title: `请选择玩法类型`,
|
||||
icon: 'none'
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!location_name) {
|
||||
if (!location_name || !location_name.trim()) {
|
||||
Taro.showToast({
|
||||
title: `请选择场地`,
|
||||
icon: 'none'
|
||||
@@ -202,16 +232,46 @@ const PublishBall: React.FC = () => {
|
||||
})
|
||||
}
|
||||
}
|
||||
if (activityType === 'group') {
|
||||
const isValid = formData.every(item => validateFormData(item))
|
||||
if (!isValid) {
|
||||
return
|
||||
}
|
||||
formData.forEach(async (item) => {
|
||||
const { activityInfo, descriptionInfo, timeRange, players, skill_level, ...rest } = item;
|
||||
const options = {
|
||||
...rest,
|
||||
...activityInfo,
|
||||
...descriptionInfo,
|
||||
...timeRange,
|
||||
max_players: players[1],
|
||||
current_players: players[0],
|
||||
skill_level_min: skill_level[0],
|
||||
skill_level_max: skill_level[1]
|
||||
}
|
||||
const res = await PublishService.create_play_pmoothly(options);
|
||||
if (res.code === 0 && res.data) {
|
||||
Taro.showToast({
|
||||
title: '发布成功',
|
||||
icon: 'success'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(formData, 'formData');
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<View className={styles['publish-ball']}>
|
||||
{/* 活动类型切换 */}
|
||||
<View className={styles['activity-type-switch']}>
|
||||
<ActivityTypeSwitch
|
||||
{/* <ActivityTypeSwitch
|
||||
value={activityType}
|
||||
onChange={handleActivityTypeChange}
|
||||
/>
|
||||
/> */}
|
||||
</View>
|
||||
|
||||
<View className={styles['publish-ball__scroll']}>
|
||||
@@ -263,28 +323,15 @@ const PublishBall: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{deleteConfirm.visible && (
|
||||
<View className={styles['delete-modal']}>
|
||||
<View className={styles['delete-modal__content']}>
|
||||
<Text className={styles['delete-modal__title']}>确认移除该场次?</Text>
|
||||
<Text className={styles['delete-modal__desc']}>该操作不可恢复</Text>
|
||||
<View className={styles['delete-modal__actions']}>
|
||||
<Button
|
||||
className={styles['delete-modal__btn']}
|
||||
onClick={closeDeleteConfirm}
|
||||
>
|
||||
再想想
|
||||
</Button>
|
||||
<Button
|
||||
className={styles['delete-modal__btn']}
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
确认移除
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
<CommonDialog
|
||||
visible={deleteConfirm.visible}
|
||||
cancelText="再想想"
|
||||
confirmText="确认移除"
|
||||
onCancel={closeDeleteConfirm}
|
||||
onConfirm={confirmDelete}
|
||||
contentTitle="确认移除该场次?"
|
||||
contentDesc="该操作不可恢复"
|
||||
/>
|
||||
|
||||
{/* 完成按钮 */}
|
||||
<View className={styles['submit-section']}>
|
||||
|
||||
@@ -84,8 +84,36 @@ const PublishForm: React.FC<{
|
||||
})
|
||||
}
|
||||
|
||||
const getNTRPText = (ntrp: [number, number]) => {
|
||||
const [min, max] = ntrp
|
||||
if (min === 1.0 && max === 5.0) {
|
||||
return '不限'
|
||||
}
|
||||
if (min === 5.0 && max === 5.0) {
|
||||
return '5.0 及以上'
|
||||
}
|
||||
if (min === 1.0 && max === 1.0) {
|
||||
return `${min.toFixed(1)}`
|
||||
}
|
||||
if (min > 1.0 && max === 5.0) {
|
||||
return `${min.toFixed(1)} 以上`
|
||||
}
|
||||
if (min === 1.0 && max < 5.0) {
|
||||
return `${max.toFixed(1)} 以下`
|
||||
}
|
||||
if (min > 1.0 && max < 5.0) {
|
||||
return `${min.toFixed(1)} - ${max.toFixed(1)}之间`
|
||||
}
|
||||
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
const renderSummary = (item: FormFieldConfig) => {
|
||||
if (item.props?.showSummary) {
|
||||
if (item.prop === 'skill_level') {
|
||||
return <Text className={styles['section-summary']}>{getNTRPText(formData.skill_level)}</Text>
|
||||
}
|
||||
return <Text className={styles['section-summary']}>{item.props?.summary}</Text>
|
||||
}
|
||||
return null
|
||||
|
||||
@@ -5,4 +5,5 @@ $primary-shallow-bg: rgb(245, 245, 245);
|
||||
$input-placeholder-color: rgba(60, 60, 67, 0.6);
|
||||
$textarea-placeholder-color: rgba(60, 60, 67, 0.3);
|
||||
$primary-background-color: rgba(0, 0, 0, 0.06);
|
||||
$primary-border-color: rgba(0, 0, 0, 0.16);
|
||||
$primary-border-color: rgba(0, 0, 0, 0.16);
|
||||
$primary-border-light-color: rgba(22, 24, 35, 0.12);
|
||||
@@ -4,7 +4,7 @@ import type { ApiResponse } from './httpService'
|
||||
// 用户接口
|
||||
export interface PublishBallData {
|
||||
title: string // 球局标题
|
||||
image_list: Array<string>[] // 球局封面
|
||||
image_list: string[] // 球局封面
|
||||
start_time: string,
|
||||
end_time: string
|
||||
play_type: string // 玩法类型
|
||||
@@ -16,15 +16,15 @@ export interface PublishBallData {
|
||||
longitude?: string // 经度
|
||||
court_type?: string // 场地类型 1: 室内 2: 室外
|
||||
court_surface?: string // 场地表面 1: 硬地 2: 红土 3: 草地
|
||||
venue_description_tag?: Array<string>[] // 场地描述标签
|
||||
venue_description_tag?: string[] // 场地描述标签
|
||||
venue_description?: string // 场地描述
|
||||
venue_image_list?: Array<string>[] // 场地图片
|
||||
venue_image_list?: string[] // 场地图片
|
||||
max_players: number // 人数要求
|
||||
current_players: number // 人数要求
|
||||
skill_level_min: number // 水平要求(NTRP)
|
||||
skill_level_max: number // 水平要求(NTRP)
|
||||
description: string // 备注
|
||||
description_tag: Array<string>[] // 备注标签
|
||||
description_tag: string[] // 备注标签
|
||||
is_substitute_supported: boolean // 是否支持替补
|
||||
is_wechat_contact: boolean // 是否需要微信联系
|
||||
wechat_contact?: string // 微信联系
|
||||
@@ -66,6 +66,13 @@ class PublishService {
|
||||
return httpService.post('/venues/list', data, {
|
||||
showLoading: false })
|
||||
}
|
||||
// 畅打发布
|
||||
async create_play_pmoothly(data: PublishBallData): Promise<ApiResponse<Response>> {
|
||||
return httpService.post('/games/create_play_pmoothly', data, {
|
||||
showLoading: true,
|
||||
loadingText: '发布中...'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 导出认证服务实例
|
||||
|
||||
6
src/static/publishBall/icon-group.svg
Normal file
6
src/static/publishBall/icon-group.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M32.0934 7.947C29.0388 5.08557 24.9323 3.3335 20.4166 3.3335C10.9818 3.3335 3.33328 10.982 3.33328 20.4168C3.33328 29.8517 10.9818 37.5002 20.4166 37.5002C25.0956 37.5002 29.3353 35.6191 32.4206 32.5719L19.9999 20.0002L32.0934 7.947Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M33.3333 23.3332C35.1742 23.3332 36.6667 21.8408 36.6667 19.9998C36.6667 18.1589 35.1742 16.6665 33.3333 16.6665C31.4924 16.6665 30 18.1589 30 19.9998C30 21.8408 31.4924 23.3332 33.3333 23.3332Z" stroke="black" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M14.1667 10.8335V17.5002" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10.8333 14.1665H17.4999" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 900 B |
5
src/static/publishBall/icon-person.svg
Normal file
5
src/static/publishBall/icon-person.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.9999 36.6663C29.2047 36.6663 36.6666 29.2044 36.6666 19.9997C36.6666 10.7949 29.2047 3.33301 19.9999 3.33301C10.7952 3.33301 3.33325 10.7949 3.33325 19.9997C3.33325 29.2044 10.7952 36.6663 19.9999 36.6663Z" stroke="black" stroke-width="2"/>
|
||||
<path d="M19.9999 3.33301C19.9158 8.88984 18.5515 13.0577 15.9069 15.8367C13.2623 18.6156 9.071 20.0056 3.33325 20.0066" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M36.6402 20.8374C31.2131 20.4651 27.0662 21.6251 24.1996 24.3174C21.3328 27.0096 19.9338 31.1259 20.0024 36.6663" stroke="black" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 739 B |
17
src/static/publishBall/icon-plus.svg
Normal file
17
src/static/publishBall/icon-plus.svg
Normal file
@@ -0,0 +1,17 @@
|
||||
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g filter="url(#filter0_d_3058_12408)">
|
||||
<path d="M30.8727 9.19494C31.6525 9.97702 31.6505 11.2434 30.8685 12.0234L24.8526 18.0241L30.8388 24.0103C31.6199 24.7914 31.6199 26.0577 30.8388 26.8388C30.0578 27.6198 28.7915 27.6198 28.0104 26.8388L22.02 20.8484L16 26.8533C15.2179 27.6331 13.9515 27.6311 13.1715 26.8491C12.392 26.067 12.3938 24.8006 13.1757 24.0207L19.1916 18.02L13.1612 11.9895C12.3801 11.2085 12.3801 9.94215 13.1612 9.1611C13.9422 8.38005 15.2085 8.38005 15.9896 9.1611L22.0242 15.1957L28.0442 9.19079C28.8263 8.41107 30.0927 8.41318 30.8727 9.19494Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_3058_12408" x="0.575378" y="0.575195" width="42.8809" height="42.8616" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="4"/>
|
||||
<feGaussianBlur stdDeviation="6"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_3058_12408"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_3058_12408" result="shape"/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/publishBall/icon-publish.png
Normal file
BIN
src/static/publishBall/icon-publish.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
3
src/static/publishBall/icon-right-max.svg
Normal file
3
src/static/publishBall/icon-right-max.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="11" height="18" viewBox="0 0 11 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.7598 8.94922C10.7598 9.11849 10.7272 9.27474 10.6621 9.41797C10.597 9.5612 10.4993 9.69792 10.3691 9.82812L2.76172 17.2695C2.54688 17.4909 2.27995 17.6016 1.96094 17.6016C1.7526 17.6016 1.56055 17.5495 1.38477 17.4453C1.20898 17.3477 1.06901 17.2109 0.964844 17.0352C0.867188 16.8659 0.818359 16.6771 0.818359 16.4688C0.818359 16.1562 0.935547 15.8796 1.16992 15.6387L8.02539 8.94922L1.16992 2.25977C0.935547 2.01888 0.818359 1.74544 0.818359 1.43945C0.818359 1.22461 0.867188 1.03255 0.964844 0.863281C1.06901 0.69401 1.20898 0.560547 1.38477 0.462891C1.56055 0.358724 1.7526 0.306641 1.96094 0.306641C2.27995 0.306641 2.54688 0.414062 2.76172 0.628906L10.3691 8.07031C10.4993 8.20052 10.597 8.33724 10.6621 8.48047C10.7272 8.6237 10.7598 8.77995 10.7598 8.94922Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 897 B |
@@ -21,6 +21,9 @@ export const getEndTime = (startTime: string): string => {
|
||||
return endDateTime.format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
export const getDateStr = (date: Date): string => {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm')
|
||||
}
|
||||
|
||||
export const getDate = (date: string): string => {
|
||||
return dayjs(date).format('YYYY年MM月DD日')
|
||||
@@ -32,10 +35,10 @@ export const getTime = (time: string): string => {
|
||||
const minute = timeObj.minute()
|
||||
|
||||
// 判断是上午还是下午
|
||||
const period = hour < 12 ? 'AM' : 'PM'
|
||||
const period = hour <= 12 ? 'AM' : 'PM'
|
||||
|
||||
// 转换为12小时制
|
||||
const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour
|
||||
const hour12 = hour === 0 ? 0 : hour > 12 ? hour - 12 : hour
|
||||
|
||||
// 格式化分钟,保证两位数
|
||||
const minuteStr = minute.toString().padStart(2, '0')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
export interface PublishBallFormData {
|
||||
title: string // 球局标题
|
||||
image_list: Array<string>[] // 球局封面
|
||||
image_list: string[] // 球局封面
|
||||
timeRange: {
|
||||
start_time: string,
|
||||
end_time: string
|
||||
@@ -16,15 +16,15 @@ export interface PublishBallFormData {
|
||||
longitude?: string // 经度
|
||||
court_type?: string // 场地类型 1: 室内 2: 室外
|
||||
court_surface?: string // 场地表面 1: 硬地 2: 红土 3: 草地
|
||||
venue_description_tag?: Array<string>[] // 场地描述标签
|
||||
venue_description_tag?: string[] // 场地描述标签
|
||||
venue_description?: string // 场地描述
|
||||
venue_image_list?: Array<string>[] // 场地图片
|
||||
venue_image_list?: string[] // 场地图片
|
||||
}
|
||||
players: [number, number] // 人数要求
|
||||
skill_level: [number, number] // 水平要求(NTRP)
|
||||
descriptionInfo: {
|
||||
description: string // 备注
|
||||
description_tag: Array<string>[] // 备注标签
|
||||
description_tag: string[] // 备注标签
|
||||
}
|
||||
is_substitute_supported: boolean // 是否支持替补
|
||||
is_wechat_contact: boolean // 是否需要微信联系
|
||||
|
||||
@@ -8293,11 +8293,6 @@ punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.1:
|
||||
resolved "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
qqmap-wx-jssdk@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.npmmirror.com/qqmap-wx-jssdk/-/qqmap-wx-jssdk-1.0.0.tgz#8ab4b0d3aa900458217d6fbe52af832bb6c63c73"
|
||||
integrity sha512-wuaNetsA9/OKEQGgK1CNPsX6pppWpY10cQwQu1OHJplGMyMIMzK2bliMkNXjtry99qXYCsvDAWPqw2DI+/foJg==
|
||||
|
||||
qs@6.13.0:
|
||||
version "6.13.0"
|
||||
resolved "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906"
|
||||
|
||||
Reference in New Issue
Block a user