Files
mini-programs/src/pages/detail/index.tsx

728 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react'
import { View, Text, Image, Map, ScrollView } from '@tarojs/components'
import { Avatar, Popover, ImagePreview } from '@nutui/nutui-react-taro'
import Taro, { useRouter, useShareAppMessage, useShareTimeline, useDidShow } from '@tarojs/taro'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
// 导入API服务
import { CommonPopup, withAuth } from '@/components'
import DetailService, { MATCH_STATUS} from '@/services/detailService'
import { getCurrentLocation, calculateDistance } from '@/utils/locationUtils'
import {
useUserInfo,
useUserActions,
} from '@/store/userStore'
import img from '@/config/images'
// import { getTextColorOnImage } from '../../utils'
import './index.scss'
dayjs.locale('zh-cn')
// 将·作为连接符插入到标签文本之间
function insertDotInTags(tags: string[]) {
return tags.join('-·-').split('-')
}
function GameTags(props) {
const { detail } = props
const tags = [{
name: '🕙 急招',
icon: '',
}, {
name: '🔥 本周热门',
icon: '',
}, {
name: '🎉 新活动',
icon: '',
}, {
name: '官方组织',
icon: '',
}]
return (
<View className='detail-page-content-avatar-tags'>
<View className='detail-page-content-avatar-tags-avatar'>
{/* network image mock */}
<Image className='detail-page-content-avatar-tags-avatar-image' src="https://img.yzcdn.cn/vant/cat.jpeg" />
</View>
<View className='detail-page-content-avatar-tags-tags'>
{tags.map((tag, index) => (
<View key={index} className='detail-page-content-avatar-tags-tags-tag'>
{tag.icon && <Image src={tag.icon} />}
<Text>{tag.name}</Text>
</View>
))}
</View>
</View>
)
}
type CourselItemType = {
url: string
width: number
height: number
}
function Coursel(props) {
const { detail } = props
const [list, setList] = useState<CourselItemType[]>([])
const [listWidth, setListWidth] = useState(0)
const { image_list } = detail
async function getImagesMsg (imageList) {
const latest_list: CourselItemType[] = []
const sys_info = await Taro.getSystemInfo()
console.log(sys_info, 'info')
const max_width = sys_info.screenWidth - 30
const max_height = 240
const current_aspect_ratio = max_width / max_height
let container_width = 0
for (const imageUrl of imageList) {
const { width, height } = await Taro.getImageInfo({ src: imageUrl })
if (width && height) {
const aspect_ratio = width / height
const latest_w_h = { width, height }
if (aspect_ratio < current_aspect_ratio) {
latest_w_h.width = max_height * aspect_ratio
latest_w_h.height = max_height
} else {
latest_w_h.width = max_width
latest_w_h.height = max_width / aspect_ratio
}
container_width += latest_w_h.width + 12
latest_list.push({
url: imageUrl,
width: latest_w_h.width,
height: latest_w_h.height,
})
}
}
setList(latest_list)
setListWidth(container_width)
}
useEffect(() => { getImagesMsg(image_list || []) }, [image_list])
return (
<View className="detail-swiper-container">
<View className="detail-swiper-scroll-container" style={{ width: listWidth + 'px' }}>
{
list.map((item, index) => {
return (
<View className='detail-swiper-item' key={index}>
<Image
src={item.url}
mode="aspectFill"
className='detail-swiper-item-image'
style={{ width: item.width + 'px', height: item.height + 'px' }}
/>
</View>
)
})
}
</View>
</View>
)
}
// 分享弹窗
const SharePopup = forwardRef(({ id, from }: { id: string, from: string }, ref) => {
const [visible, setVisible] = useState(false)
useImperativeHandle(ref, () => ({
show: () => {
setVisible(true)
}
}))
// function handleShareToWechat() {
// useShareAppMessage(() => {
// return {
// title: '分享',
// path: `/pages/detail/index?id=${id}&from=share`,
// }
// })
// }
// function handleShareToWechatMoments() {
// useShareTimeline(() => {
// return {
// title: '分享',
// path: `/pages/detail/index?id=${id}&from=share`,
// }
// })
// }
// function handleSaveToLocal() {
// Taro.saveImageToPhotosAlbum({
// filePath: images[0],
// success: () => {
// Taro.showToast({ title: '保存成功', icon: 'success' })
// },
// fail: () => {
// Taro.showToast({ title: '保存失败', icon: 'none' })
// },
// })
// }
return (
<CommonPopup
title="分享"
visible={visible}
onClose={() => { setVisible(false) }}
hideFooter
style={{ minHeight: '100px' }}
>
<View catchMove className='share-popup-content'>
</View>
</CommonPopup>
)
})
// 底部操作栏
function StickyButton(props) {
const { handleShare, handleJoinGame, detail } = props
const userInfo = useUserInfo()
const { id } = userInfo
const { publisher_id, match_status, price } = detail || {}
const role = Number(publisher_id) === id ? 'ownner' : 'visitor'
return (
<View className="sticky-bottom-bar">
<View className="sticky-bottom-bar-share-and-comment">
<View className='sticky-bottom-bar-share' onClick={handleShare}>
<Image className='sticky-bottom-bar-share-icon' src={img.ICON_DETAIL_SHARE} />
<Text className='sticky-bottom-bar-share-text'></Text>
</View>
<View className='sticky-bottom-bar-share-and-comment-separator' />
<View className='sticky-bottom-bar-comment' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
<Image className='sticky-bottom-bar-comment-icon' src={img.ICON_DETAIL_COMMENT_DARK} />
<Text className='sticky-bottom-bar-comment-text'>32</Text>
</View>
</View>
<View className="sticky-bottom-bar-join-game" onClick={handleJoinGame}>
<Text>🎾</Text>
<Text></Text>
<View className='game-price'>
<Text>¥ {price}</Text>
</View>
</View>
</View>
)
}
// 球局信息
function GameInfo(props) {
const { detail, currentLocation } = props
const { latitude, longitude, location, location_name, start_time, end_time } = detail || {}
const openMap = () => {
Taro.openLocation({
latitude, // 纬度(必填)
longitude, // 经度(必填)
name: location_name, // 位置名(可选)
address: location, // 地址详情(可选)
scale: 15, // 地图缩放级别1-28
})
}
const [c_latitude, c_longitude] = currentLocation
const distance = calculateDistance(c_latitude, c_longitude, latitude, longitude) / 1000
const startTime = dayjs(start_time)
const endTime = dayjs(end_time)
const game_length = endTime.diff(startTime, 'minutes') / 60
const startMonth = startTime.format('M')
const startDay = startTime.format('D')
const theDayOfWeek = startTime.format('dddd')
const startDate = `${startMonth}${startDay}${theDayOfWeek}`
const gameRange = `${startTime.format('HH:mm')} - ${endTime.format('HH:mm')}`
return (
<View className='detail-page-content-game-info'>
{/* Date and Weather */}
<View className='detail-page-content-game-info-date-weather'>
{/* Calendar and Date time */}
<View className='detail-page-content-game-info-date-weather-calendar-date'>
{/* Calendar */}
<View className='detail-page-content-game-info-date-weather-calendar-date-calendar'>
<View className="month">{startMonth}</View>
<View className="day">{startDay}</View>
</View>
{/* Date time */}
<View className='detail-page-content-game-info-date-weather-calendar-date-date'>
<View className="date">{startDate}</View>
<View className="venue-time">{gameRange} {game_length}</View>
</View>
</View>
{/* Weather */}
<View className='detail-page-content-game-info-date-weather-weather'>
{/* Weather icon */}
<View className='detail-page-content-game-info-date-weather-weather-icon'>
<Image className="weather-icon" src={img.ICON_WEATHER_SUN} />
</View>
{/* Weather text and temperature */}
<View className='detail-page-content-game-info-date-weather-weather-text-temperature'>
<Text>28 - 32</Text>
</View>
</View>
</View>
{/* Place */}
<View className='detail-page-content-game-info-place'>
{/* venue location message */}
<View className='location-message'>
{/* location icon */}
<View className='location-message-icon'>
<Image className='location-message-icon-image' src={img.ICON_DETAIL_MAP} />
</View>
{/* location message */}
<View className='location-message-text'>
{/* venue name and distance */}
<View className='location-message-text-name-distance' onClick={openMap}>
<Text>{location_name || '-'}</Text>
<Text>·</Text>
<Text>{distance.toFixed(1)}km</Text>
<Image className='location-message-text-name-distance-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
{/* venue address */}
<View className='location-message-text-address'>
<Text>{location || '-'}</Text>
</View>
</View>
</View>
{/* venue map */}
<View className='location-map'>
{longitude && latitude && (
<Map
className='location-map-map'
longitude={latitude}
latitude={longitude}
markers={[{ id: 1, latitude: longitude, longitude: latitude, iconPath: require('@/static/detail/icon-stark.svg'), width: 16, height: 16 }]}
includePoints={[{ latitude: longitude, longitude: latitude }, { latitude: currentLocation[0], longitude: currentLocation[1] }]}
includePadding={{ left: 50, right: 50, top: 50, bottom: 50 }}
onError={() => {}}
// hide business msg
showLocation
theme='dark'
/>
)}
</View>
</View>
</View>
)
}
// 场馆信息
function VenueInfo(props) {
const { detail } = props
const [visible, setVisible] = useState(false)
const { venue_description, venue_description_tag = [], venue_image_list = [] } = detail
function showScreenShot() {
setVisible(true)
}
function onClose() {
setVisible(false)
}
function previewImage(current_url) {
Taro.previewImage({
current: current_url,
urls: venue_image_list.map(c => c.url),
})
}
return (
<View className='detail-page-content-venue'>
{/* venue detail title and venue ordered status */}
<View className='venue-detail-title'>
<Text></Text>
{venue_image_list?.length > 0 ?
<>
<Text>·</Text>
<View className="venue-reserve-status" onClick={showScreenShot}>
<Text></Text>
<Image className="venue-reserve-screenshot" src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
</>
:
''
}
</View>
{/* venue detail content */}
<View className='venue-detail-content'>
{/* venue detail tags */}
<View className='venue-detail-content-tags'>
{insertDotInTags(venue_description_tag).map((tag, index) => (
<View key={index} className='venue-detail-content-tags-tag'>
<Text>{tag}</Text>
</View>
))}
</View>
{/* venue remarks */}
<View className='venue-detail-content-remarks'>
<Text>{venue_description}</Text>
</View>
</View>
<CommonPopup
visible={visible}
onClose={onClose}
round
hideFooter
position='bottom'
zIndex={1001}
>
<View className="venue-screenshot-title"></View>
<ScrollView
scrollY
className="venue-screenshot-scroll-view"
>
<View className="venue-screenshot-image-list">
{venue_image_list.map(item => {
return (
<View className="venue-screenshot-image-item" onClick={previewImage.bind(null, item.url)}>
<Image className="venue-screenshot-image-item-image" src={item.url} />
</View>
)
})}
</View>
</ScrollView>
</CommonPopup>
</View>
)
}
function genNTRPRequirementText(min, max) {
if (min && max) {
return `${min} - ${max} 之间`
} else if (max) {
return `${max} 以上`
}
return '没有要求'
}
// 玩法要求
function GamePlayAndRequirement(props) {
const { detail: { skill_level_min, skill_level_max, play_type, game_type } } = props
const requirements = [
{
title: 'NTRP水平要求',
desc: genNTRPRequirementText(skill_level_min, skill_level_max),
},
{
title: '活动玩法',
desc: play_type || '-',
},
{
title: '人员构成',
desc: game_type || '-',
}
]
return (
<View className='detail-page-content-gameplay-requirements'>
{/* title */}
<View className="gameplay-requirements-title">
<Text></Text>
</View>
{/* requirements */}
<View className='gameplay-requirements'>
{requirements.map((item, index) => (
<View key={index} className='gameplay-requirements-item'>
<Text className='gameplay-requirements-item-title'>{item.title}</Text>
<Text className='gameplay-requirements-item-desc'>{item.desc}</Text>
</View>
))}
</View>
</View>
)
}
// 参与者
function Participants(props) {
const { detail = {} } = props
const participants = detail.participants || []
const organizer_id = Number(detail.publisher_id)
return (
<View className='detail-page-content-participants'>
<View className='participants-title'>
<Text></Text>
<Text>·</Text>
<Text> 3</Text>
</View>
<View className='participants-list'>
{/* application */}
<View className='participants-list-application' onClick={() => { Taro.showToast({ title: 'To be continued', icon: 'none' }) }}>
<Image className='participants-list-application-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
<Text className='participants-list-application-text'></Text>
</View>
{/* participants list */}
<ScrollView className='participants-list-scroll' scrollX>
<View className='participants-list-scroll-content' style={{ width: `${participants.length * 103 + (participants.length - 1) * 8}px` }}>
{participants.map((participant) => {
const { user: { avatar_url, nickname, level, id: participant_user_id } } = participant
const role = participant_user_id === organizer_id ? '组织者' : '参与者'
return (
<View key={participant.id} className='participants-list-item'>
<Avatar className='participants-list-item-avatar' src={avatar_url} />
<Text className='participants-list-item-name'>{nickname || '未知'}</Text>
<Text className='participants-list-item-level'>{level || '未知'}</Text>
<Text className='participants-list-item-role'>{role}</Text>
</View>
)
})}
</View>
</ScrollView>
</View>
</View>
)
}
function SupplementalNotes(props) {
const { detail: { description, description_tag = [] } } = props
return (
<View className='detail-page-content-supplemental-notes'>
<View className='supplemental-notes-title'>
<Text></Text>
</View>
<View className='supplemental-notes-content'>
{/* supplemental notes tags */}
<View className='supplemental-notes-content-tags'>
{insertDotInTags(description_tag).map((tag, index) => (
<View key={index} className='supplemental-notes-content-tags-tag'>
<Text>{tag}</Text>
</View>
))}
</View>
{/* supplemental notes content */}
<View className='supplemental-notes-content-text'>
<Text>{description}</Text>
</View>
</View>
</View>
)
}
function OrganizerInfo(props) {
const recommendGames = [
{
title: '黄浦日场对拉',
time: '2025-08-25 9:00',
timeLength: '2小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
{
title: '黄浦夜场对拉',
time: '2025-08-25 19:00',
timeLength: '2小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
{
title: '黄浦全天对拉',
time: '2025-08-25 9:00',
timeLength: '12小时',
venue: '上海体育场',
veuneType: '室外',
distance: '1.2km',
avatar: 'https://img.yzcdn.cn/vant/cat.jpeg',
applications: 10,
checkedApplications: 3,
levelRequirements: 'NTRP 3.5',
playType: '双打',
},
]
return (
<View className='detail-page-content-organizer-recommend-games'>
{/* orgnizer title */}
<View className='organizer-title'>
<Text></Text>
</View>
{/* organizer avatar and name */}
<View className='organizer-avatar-name'>
<Avatar className='organizer-avatar-name-avatar' src="https://img.yzcdn.cn/vant/cat.jpeg" />
<View className='organizer-avatar-name-message'>
<Text className='organizer-avatar-name-message-name'>Light</Text>
<View className='organizer-avatar-name-message-stats'>
<Text> 8 </Text>
<View className='organizer-avatar-name-message-stats-separator' />
<Text>NTRP 3.5</Text>
</View>
</View>
<View className="organizer-actions">
<View className="organizer-actions-follow">
<Image className='organizer-actions-follow-icon' src={img.ICON_DETAIL_APPLICATION_ADD} />
<Text className='organizer-actions-follow-text'></Text>
</View>
<View className="organizer-actions-comment">
<Image className='organizer-actions-comment-icon' src={img.ICON_DETAIL_COMMENT} />
</View>
</View>
</View>
{/* recommend games by organizer */}
<View className='organizer-recommend-games'>
<View className='organizer-recommend-games-title'>
<Text>TA的更多活动</Text>
<Image className='organizer-recommend-games-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
<ScrollView className='recommend-games-list' scrollX>
<View className='recommend-games-list-content'>
{recommendGames.map((game, index) => (
<View key={index} className='recommend-games-list-item'>
{/* game title */}
<View className='recommend-games-list-item-title'>
<Text>{game.title}</Text>
<Image className='recommend-games-list-item-title-arrow' src={img.ICON_DETAIL_ARROW_RIGHT} />
</View>
{/* game time and range */}
<View className='recommend-games-list-item-time-range'>
<Text>{game.time}</Text>
<Text>{game.timeLength}</Text>
</View>
{/* game location、vunue、distance */}
<View className='recommend-games-list-item-location-venue-distance'>
<Text>{game.venue}</Text>
<Text>·</Text>
<Text>{game.veuneType}</Text>
<Text>·</Text>
<Text>{game.distance}</Text>
</View>
{/* organizer avatar、applications、level requirements、play type */}
<View className='recommend-games-list-item-addon'>
<Avatar className='recommend-games-list-item-addon-avatar' src={game.avatar} />
<View className='recommend-games-list-item-addon-message'>
<View className='recommend-games-list-item-addon-message-applications'>
<Text> {game.checkedApplications}/{game.applications}</Text>
</View>
<View className='recommend-games-list-item-addon-message-level-requirements'>
<Text>{game.levelRequirements}</Text>
</View>
<View className='recommend-games-list-item-addon-message-play-type'>
<Text>{game.playType}</Text>
</View>
</View>
</View>
</View>
))}
</View>
</ScrollView>
</View>
</View>
)
}
function Index() {
const [detail, setDetail] = useState<any>({})
const { params } = useRouter()
const [currentLocation, setCurrentLocation] = useState<[number, number]>([0, 0])
const { id, from } = params
const { fetchUserInfo, updateUserInfo } = useUserActions()
const sharePopupRef = useRef<any>(null)
useDidShow(async () => {
await updateLocation()
await fetchUserInfo()
await fetchDetail()
})
const updateLocation = async () => {
try {
const location = await getCurrentLocation()
setCurrentLocation([location.latitude, location.longitude])
await updateUserInfo({ latitude: location.latitude, longitude: location.longitude })
} catch (error) {
console.error('用户位置更新失败', error)
}
}
const fetchDetail = async () => {
const res = await DetailService.getDetail(Number(id))
if (res.code === 0) {
setDetail(res.data)
}
}
function handleShare() {
sharePopupRef.current.show()
}
const handleJoinGame = () => {
Taro.navigateTo({
url: `/pages/orderCheck/index?gameId=${id}`,
})
}
function handleBack() {
const pages = Taro.getCurrentPages()
if (pages.length <= 1) {
Taro.redirectTo({
url: '/pages/list/index',
})
} else {
Taro.navigateBack()
}
}
console.log('detail', detail)
const backgroundImage = detail?.image_list?.[0] ? { backgroundImage: `url(${detail?.image_list?.[0]})` } : {}
return (
<View className='detail-page'>
{/* custom navbar */}
<view className="custom-navbar">
<View className='detail-navigator'>
<View className='detail-navigator-back' onClick={handleBack}>
<Image className='detail-navigator-back-icon' src={img.ICON_ARROW_LEFT} />
</View>
<View className='detail-navigator-icon'>
<Image className='detail-navigator-logo-icon' src={img.ICON_LOGO_GO} />
</View>
</View>
</view>
<View className='detail-page-bg' style={backgroundImage} />
{/* swiper */}
<Coursel detail={detail} />
{/* content */}
<View className='detail-page-content'>
{/* avatar and tags */}
<GameTags detail={detail} />
{/* title */}
<View className='detail-page-content-title'>
<Text className='detail-page-content-title-text'>{detail.title}</Text>
</View>
{/* Date and Place and weather */}
<GameInfo detail={detail} currentLocation={currentLocation} />
{/* detail */}
<VenueInfo detail={detail} />
{/* gameplay requirements */}
<GamePlayAndRequirement detail={detail} />
{/* participants */}
<Participants detail={detail} />
{/* supplemental notes */}
<SupplementalNotes detail={detail} />
{/* organizer and recommend games by organizer */}
<OrganizerInfo detail={detail} />
{/* sticky bottom action bar */}
<StickyButton handleShare={handleShare} handleJoinGame={handleJoinGame} detail={detail} />
{/* share popup */}
<SharePopup ref={sharePopupRef} id={id as string} from={from as string} />
</View>
</View>
)
}
export default withAuth(Index)