1315 lines
38 KiB
TypeScript
1315 lines
38 KiB
TypeScript
import React, {
|
||
useState,
|
||
useEffect,
|
||
useRef,
|
||
useImperativeHandle,
|
||
forwardRef,
|
||
} from "react";
|
||
import { View, Text, Image, Map, ScrollView, Button } from "@tarojs/components";
|
||
// import { Avatar } from "@nutui/nutui-react-taro";
|
||
import Taro, {
|
||
useRouter,
|
||
useShareAppMessage,
|
||
useShareTimeline,
|
||
useDidShow,
|
||
} from "@tarojs/taro";
|
||
import classnames from "classnames";
|
||
import dayjs from "dayjs";
|
||
import "dayjs/locale/zh-cn";
|
||
// 导入API服务
|
||
import {
|
||
CommonPopup,
|
||
withAuth,
|
||
NTRPEvaluatePopup,
|
||
GameManagePopup,
|
||
Comments,
|
||
} from "@/components";
|
||
import {
|
||
EvaluateType,
|
||
SceneType,
|
||
DisplayConditionType,
|
||
} from "@/components/NTRPEvaluatePopup";
|
||
import DetailService, {
|
||
MATCH_STATUS,
|
||
IsSubstituteSupported,
|
||
} from "@/services/detailService";
|
||
import * as LoginService from "@/services/loginService";
|
||
import OrderService from "@/services/orderService";
|
||
import { getCurrentLocation, calculateDistance } from "@/utils/locationUtils";
|
||
import { useUserInfo, useUserActions } from "@/store/userStore";
|
||
import img from "@/config/images";
|
||
import styles from "./style.module.scss";
|
||
import "./index.scss";
|
||
|
||
dayjs.locale("zh-cn");
|
||
|
||
// 将·作为连接符插入到标签文本之间
|
||
function insertDotInTags(tags: string[]) {
|
||
if (!tags) return [];
|
||
return tags.join("-·-").split("-");
|
||
}
|
||
|
||
function GameTags(props) {
|
||
const { userInfo, handleViewUserInfo } = props;
|
||
const { avatar_url, id } = userInfo;
|
||
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"
|
||
mode="aspectFill"
|
||
src={avatar_url}
|
||
onClick={handleViewUserInfo.bind(null, id)}
|
||
/>
|
||
</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();
|
||
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);
|
||
},
|
||
}));
|
||
|
||
useShareAppMessage((res) => {
|
||
console.log(res, "res");
|
||
return {
|
||
title: "分享",
|
||
imageUrl: "https://img.yzcdn.cn/vant/cat.jpeg",
|
||
path: `/game_pages/detail/index?id=${id}&from=share`,
|
||
};
|
||
});
|
||
|
||
// function handleShareToWechatMoments() {
|
||
// useShareTimeline(() => {
|
||
// return {
|
||
// title: '分享',
|
||
// path: `/game_pages/detail/index?id=${id}&from=share`,
|
||
// }
|
||
// })
|
||
// }
|
||
|
||
function handleSaveToLocal() {
|
||
Taro.showToast({ title: "not yet", icon: "error" });
|
||
return;
|
||
Taro.saveImageToPhotosAlbum({
|
||
filePath: "",
|
||
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 className={styles.shareContainer}>
|
||
<View catchMove className={styles.title}>
|
||
分享卡片
|
||
</View>
|
||
<View className={styles.shareItems}>
|
||
<Button
|
||
className={classnames(styles.button, styles.share)}
|
||
openType="share"
|
||
>
|
||
分享到聊天
|
||
</Button>
|
||
<Button
|
||
className={classnames(styles.button, styles.save)}
|
||
onClick={handleSaveToLocal}
|
||
>
|
||
保存到本地
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
</CommonPopup>
|
||
);
|
||
}
|
||
);
|
||
|
||
function navto(url) {
|
||
Taro.navigateTo({
|
||
url: url,
|
||
});
|
||
}
|
||
|
||
function toast(message) {
|
||
Taro.showToast({ title: message, icon: "none" });
|
||
}
|
||
|
||
function isFull(counts) {
|
||
const {
|
||
max_players,
|
||
current_players,
|
||
max_substitute_players,
|
||
current_substitute_count,
|
||
is_substitute_supported,
|
||
} = counts;
|
||
if (max_players === current_players) {
|
||
return true;
|
||
} else if (is_substitute_supported === IsSubstituteSupported.SUPPORT) {
|
||
return max_substitute_players === current_substitute_count;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
// 底部操作栏
|
||
function StickyButton(props) {
|
||
const {
|
||
handleShare,
|
||
handleJoinGame,
|
||
detail,
|
||
onStatusChange,
|
||
handleAddComment,
|
||
getCommentCount,
|
||
} = props;
|
||
const [commentCount, setCommentCount] = useState(0);
|
||
const ntrpRef = useRef(null);
|
||
const {
|
||
id,
|
||
price,
|
||
user_action_status,
|
||
match_status,
|
||
start_time,
|
||
end_time,
|
||
is_organizer,
|
||
} = detail || {};
|
||
|
||
const gameManageRef = useRef();
|
||
|
||
function handleSelfEvaluate() {
|
||
// TODO: 打开自评弹窗
|
||
ntrpRef?.current?.show();
|
||
}
|
||
|
||
useEffect(() => {
|
||
getCommentCount?.((count) => {
|
||
setCommentCount(count);
|
||
});
|
||
}, [getCommentCount]);
|
||
|
||
function generateTextAndAction(
|
||
user_action_status: null | { [key: string]: boolean }
|
||
):
|
||
| undefined
|
||
| { text: string | React.FC; action?: () => void; available?: boolean } {
|
||
if (!user_action_status) {
|
||
return;
|
||
}
|
||
const displayPrice = is_organizer ? 0 : price;
|
||
user_action_status.can_assess = true;
|
||
user_action_status.can_join = false;
|
||
// console.log(user_action_status, "user_action");
|
||
const {
|
||
can_assess,
|
||
can_join,
|
||
can_substitute,
|
||
can_pay,
|
||
is_substituting,
|
||
waiting_start,
|
||
} = user_action_status || {};
|
||
if (MATCH_STATUS.CANCELED === match_status) {
|
||
return {
|
||
text: "活动已取消",
|
||
available: false,
|
||
// action: () => toast("活动已取消"),
|
||
};
|
||
} else if (dayjs(end_time).isBefore(dayjs())) {
|
||
return {
|
||
text: "活动已结束",
|
||
available: false,
|
||
// action: () => toast("活动已结束"),
|
||
};
|
||
} else if (dayjs(start_time).isBefore(dayjs())) {
|
||
return {
|
||
text: "活动已开始",
|
||
available: false,
|
||
// action: () => toast("活动已开始"),
|
||
};
|
||
} else if (isFull(detail)) {
|
||
return {
|
||
text: "活动已满员",
|
||
available: false,
|
||
// action: () => toast("活动已满员"),
|
||
};
|
||
}
|
||
if (waiting_start) {
|
||
return {
|
||
text: () => <Text>¥{displayPrice} 已加入</Text>,
|
||
action: () => toast("已加入"),
|
||
};
|
||
} else if (is_substituting) {
|
||
return {
|
||
text: () => <Text>¥{displayPrice} 已加入候补</Text>,
|
||
action: () => toast("已加入候补"),
|
||
};
|
||
} else if (can_pay) {
|
||
return {
|
||
text: () => <Text>¥{price} 继续支付</Text>,
|
||
action: async () => {
|
||
const res = await OrderService.getUnpaidOrder(id);
|
||
if (res.code === 0) {
|
||
navto(
|
||
`/order_pages/orderDetail/index?id=${res.data.order_info.order_id}`
|
||
);
|
||
}
|
||
},
|
||
};
|
||
} else if (can_substitute) {
|
||
return {
|
||
text: () => <Text>¥{displayPrice} 我要候补</Text>,
|
||
action: handleJoinGame,
|
||
};
|
||
} else if (can_join) {
|
||
return {
|
||
text: () => {
|
||
return <Text>¥{displayPrice} 立即加入</Text>;
|
||
},
|
||
action: handleJoinGame,
|
||
};
|
||
} else if (can_assess) {
|
||
return {
|
||
text: () => (
|
||
<NTRPEvaluatePopup
|
||
ref={ntrpRef}
|
||
types={[EvaluateType.EDIT, EvaluateType.EVALUATE]}
|
||
scene={SceneType.DETAIL}
|
||
displayCondition={DisplayConditionType.AUTO}
|
||
showGuide={false}
|
||
>
|
||
<Text>¥{displayPrice} 立即加入</Text>
|
||
</NTRPEvaluatePopup>
|
||
),
|
||
action: handleSelfEvaluate,
|
||
};
|
||
}
|
||
return {
|
||
text: "球局无法加入",
|
||
available: false,
|
||
};
|
||
}
|
||
|
||
if (!user_action_status) {
|
||
return "";
|
||
}
|
||
|
||
const {
|
||
text,
|
||
available = true,
|
||
action = () => {},
|
||
} = generateTextAndAction(user_action_status)!;
|
||
|
||
let ActionText: React.FC | string = text;
|
||
|
||
if (typeof ActionText === "string") {
|
||
ActionText = () => {
|
||
return <Text>{text as string}</Text>;
|
||
};
|
||
}
|
||
|
||
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" });
|
||
handleAddComment();
|
||
}}
|
||
>
|
||
<Image
|
||
className="sticky-bottom-bar-comment-icon"
|
||
src={img.ICON_DETAIL_COMMENT_DARK}
|
||
/>
|
||
<Text className="sticky-bottom-bar-comment-text">
|
||
{commentCount > 0 ? commentCount : "评论"}
|
||
</Text>
|
||
</View>
|
||
</View>
|
||
<View
|
||
className={classnames(
|
||
"detail-main-action",
|
||
available ? "" : "disabled"
|
||
)}
|
||
>
|
||
<View
|
||
style={is_organizer ? {} : { margin: "auto" }}
|
||
className="sticky-bottom-bar-join-game"
|
||
onClick={action}
|
||
>
|
||
<ActionText />
|
||
</View>
|
||
{is_organizer && (
|
||
<View
|
||
className="game_manage"
|
||
onClick={() => {
|
||
gameManageRef.current.show(detail, onStatusChange);
|
||
}}
|
||
>
|
||
管理
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
<GameManagePopup ref={gameManageRef} />
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 球局信息
|
||
function GameInfo(props) {
|
||
const { detail, currentLocation } = props;
|
||
const {
|
||
latitude,
|
||
longitude,
|
||
location,
|
||
location_name,
|
||
start_time,
|
||
end_time,
|
||
weather,
|
||
} = detail || {};
|
||
|
||
const [{ iconDay, tempMax, tempMin }] = weather || [{}];
|
||
|
||
const openMap = () => {
|
||
Taro.openLocation({
|
||
latitude, // 纬度(必填)
|
||
longitude, // 经度(必填)
|
||
name: location_name, // 位置名(可选)
|
||
address: location, // 地址详情(可选)
|
||
scale: 15, // 地图缩放级别(1-28)
|
||
});
|
||
};
|
||
|
||
const [c_latitude, c_longitude] = currentLocation;
|
||
const distance =
|
||
latitude && longitude
|
||
? calculateDistance(c_latitude, c_longitude, latitude, longitude) / 1000
|
||
: 0;
|
||
|
||
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} />*/}
|
||
<i className={`qi-${iconDay}`}></i>
|
||
</View>
|
||
{/* Weather text and temperature */}
|
||
<View className="detail-page-content-game-info-date-weather-weather-text-temperature">
|
||
{tempMin && tempMax && (
|
||
<Text>
|
||
{tempMin}℃ - {tempMax}℃
|
||
</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 */}
|
||
{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={c_longitude}
|
||
latitude={c_latitude}
|
||
markers={[
|
||
{
|
||
id: 1,
|
||
latitude,
|
||
longitude,
|
||
iconPath: require("@/static/detail/icon-stark.svg"),
|
||
width: 16,
|
||
height: 16,
|
||
},
|
||
]}
|
||
includePoints={[
|
||
{ latitude, longitude },
|
||
{ latitude: c_latitude, longitude: c_longitude },
|
||
]}
|
||
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?.length > 0 ? 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?.length > 0 &&
|
||
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"
|
||
mode="aspectFill"
|
||
src={item.url}
|
||
/>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</ScrollView>
|
||
</CommonPopup>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function genNTRPRequirementText(min, max) {
|
||
if (min && max && min !== max) {
|
||
return `${min} - ${max} 之间`;
|
||
} else if (max === "1") {
|
||
return "无要求";
|
||
} 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 = {}, handleJoinGame, handleViewUserInfo } = props;
|
||
const participants = detail.participants || [];
|
||
const {
|
||
participant_count,
|
||
max_participants,
|
||
user_action_status = {},
|
||
} = detail;
|
||
const { can_join, can_pay, can_substitute, is_substituting, waiting_start } =
|
||
user_action_status;
|
||
const showApplicationEntry =
|
||
[can_pay, can_substitute, is_substituting, waiting_start].every(
|
||
(item) => !item
|
||
) && can_join;
|
||
const leftCount = max_participants - participant_count;
|
||
return (
|
||
<View className="detail-page-content-participants">
|
||
<View className="participants-title">
|
||
<Text>参与者</Text>
|
||
<Text>·</Text>
|
||
<Text>{leftCount > 0 ? `剩余空位 ${leftCount}` : "已满员"}</Text>
|
||
</View>
|
||
{participant_count > 0 || showApplicationEntry ? (
|
||
<View className="participants-list">
|
||
{/* application */}
|
||
{showApplicationEntry && (
|
||
<View
|
||
className="participants-list-application"
|
||
onClick={() => {
|
||
handleJoinGame();
|
||
}}
|
||
>
|
||
<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 {
|
||
is_organizer,
|
||
user: {
|
||
avatar_url,
|
||
nickname,
|
||
level,
|
||
id: participant_user_id,
|
||
},
|
||
} = participant;
|
||
const role = is_organizer ? "组织者" : "参与者";
|
||
return (
|
||
<View key={participant.id} className="participants-list-item">
|
||
<Image
|
||
className="participants-list-item-avatar"
|
||
mode="aspectFill"
|
||
src={avatar_url}
|
||
onClick={handleViewUserInfo.bind(
|
||
null,
|
||
participant_user_id
|
||
)}
|
||
/>
|
||
<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 genRecommendGames(games, location, avatar) {
|
||
return games.map((item) => {
|
||
const {
|
||
id,
|
||
title,
|
||
start_time,
|
||
end_time,
|
||
court_type,
|
||
location_name,
|
||
current_players,
|
||
max_players,
|
||
latitude,
|
||
longitude,
|
||
skill_level_max,
|
||
skill_level_min,
|
||
play_type,
|
||
} = item;
|
||
const [c_latitude, c_longitude] = location;
|
||
const distance =
|
||
calculateDistance(c_latitude, c_longitude, latitude, longitude) / 1000;
|
||
const startTime = dayjs(start_time);
|
||
const endTime = dayjs(end_time);
|
||
return {
|
||
id,
|
||
title,
|
||
time: startTime.format("YYYY-MM-DD HH:MM"),
|
||
timeLength: endTime.diff(startTime, "hour"),
|
||
venue: location_name,
|
||
venueType: court_type,
|
||
distance: `${distance.toFixed(2)}km`,
|
||
avatar,
|
||
applications: max_players,
|
||
checkedApplications: current_players,
|
||
levelRequirements:
|
||
skill_level_max !== skill_level_min
|
||
? `${skill_level_min || "-"}至${skill_level_max || "-"}`
|
||
: skill_level_min === "1"
|
||
? "无要求"
|
||
: `${skill_level_min}以上`,
|
||
playType: play_type,
|
||
};
|
||
});
|
||
}
|
||
|
||
function OrganizerInfo(props) {
|
||
const {
|
||
userInfo,
|
||
currentLocation: location,
|
||
onUpdateUserInfo = () => {},
|
||
handleViewUserInfo,
|
||
handleAddComment,
|
||
} = props;
|
||
const {
|
||
id,
|
||
nickname,
|
||
avatar_url,
|
||
is_following,
|
||
ntrp_level,
|
||
stats: { hosted_games_count } = {},
|
||
ongoing_games = [],
|
||
} = userInfo;
|
||
|
||
const myInfo = useUserInfo();
|
||
const { id: my_id } = myInfo as LoginService.UserInfoType;
|
||
|
||
const recommendGames = genRecommendGames(ongoing_games, location, avatar_url);
|
||
|
||
const toggleFollow = async (follow) => {
|
||
try {
|
||
if (follow) {
|
||
await LoginService.unFollowUser(id);
|
||
} else {
|
||
await LoginService.followUser(id);
|
||
}
|
||
onUpdateUserInfo();
|
||
Taro.showToast({
|
||
title: `${nickname} ${follow ? "已取消关注" : "已关注"}`,
|
||
icon: "success",
|
||
});
|
||
} catch (e) {
|
||
Taro.showToast({
|
||
title: `${nickname} ${follow ? "取消关注失败" : "关注失败"}`,
|
||
icon: "error",
|
||
});
|
||
}
|
||
};
|
||
|
||
function handleViewGame(gameId) {
|
||
navto(`/game_pages/detail/index?id=${gameId}&from=current`);
|
||
}
|
||
|
||
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">
|
||
<Image
|
||
className="organizer-avatar-name-avatar"
|
||
src={avatar_url}
|
||
mode="aspectFill"
|
||
onClick={handleViewUserInfo.bind(null, id)}
|
||
/>
|
||
<View className="organizer-avatar-name-message">
|
||
<Text className="organizer-avatar-name-message-name">{nickname}</Text>
|
||
<View className="organizer-avatar-name-message-stats">
|
||
<Text>已组织 {hosted_games_count} 次</Text>
|
||
<View className="organizer-avatar-name-message-stats-separator" />
|
||
<Text>NTRP {ntrp_level || "初学者"}</Text>
|
||
</View>
|
||
</View>
|
||
<View className="organizer-actions">
|
||
{my_id === id ? (
|
||
""
|
||
) : (
|
||
<View
|
||
className="organizer-actions-follow"
|
||
onClick={toggleFollow.bind(null, is_following)}
|
||
>
|
||
{is_following ? (
|
||
<Text className="organizer-actions-follow-text">取消关注</Text>
|
||
) : (
|
||
<>
|
||
<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"
|
||
onClick={() => handleAddComment()}
|
||
>
|
||
<Image
|
||
className="organizer-actions-comment-icon"
|
||
src={img.ICON_DETAIL_COMMENT}
|
||
/>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
{/* recommend games by organizer */}
|
||
{recommendGames.length > 0 && (
|
||
<View className="organizer-recommend-games">
|
||
<View
|
||
className="organizer-recommend-games-title"
|
||
onClick={handleViewUserInfo.bind(null, id)}
|
||
>
|
||
<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"
|
||
onClick={handleViewGame.bind(null, game.id)}
|
||
>
|
||
{/* 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.venueType}</Text>
|
||
<Text>·</Text>
|
||
<Text>{game.distance}</Text>
|
||
</View>
|
||
{/* organizer avatar、applications、level requirements、play type */}
|
||
<View className="recommend-games-list-item-addon">
|
||
<Image
|
||
className="recommend-games-list-item-addon-avatar"
|
||
mode="aspectFill"
|
||
src={game.avatar}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleViewUserInfo(id);
|
||
}}
|
||
/>
|
||
<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 [userInfo, setUserInfo] = useState({}); // 组织者的userInfo
|
||
const { fetchUserInfo } = useUserActions(); // 获取登录用户的userInfo
|
||
const myInfo = useUserInfo();
|
||
|
||
const isMyOwn = userInfo.id === myInfo.id;
|
||
|
||
const sharePopupRef = useRef<any>(null);
|
||
const commentRef = useRef();
|
||
|
||
useDidShow(async () => {
|
||
await updateLocation();
|
||
await fetchUserInfo();
|
||
// await fetchDetail();
|
||
});
|
||
|
||
const updateLocation = async () => {
|
||
try {
|
||
const { address, ...location } = await getCurrentLocation();
|
||
setCurrentLocation([location.latitude, location.longitude]);
|
||
|
||
// 使用 userStore 中的统一位置更新方法
|
||
// await updateUserInfo({ latitude: location.latitude, longitude: location.longitude })
|
||
await DetailService.updateLocation({
|
||
latitude: Number(location.latitude),
|
||
longitude: Number(location.longitude),
|
||
});
|
||
|
||
// 位置更新后,重新获取详情页数据(因为距离等信息可能发生变化)
|
||
await fetchDetail();
|
||
} catch (error) {
|
||
console.error("用户位置更新失败", error);
|
||
}
|
||
};
|
||
|
||
const fetchDetail = async () => {
|
||
if (!id) return;
|
||
try {
|
||
const res = await DetailService.getDetail(Number(id));
|
||
if (res.code === 0) {
|
||
setDetail(res.data);
|
||
fetchUserInfoById(res.data.publisher_id);
|
||
}
|
||
} catch (e) {
|
||
if (e.message === "球局不存在") {
|
||
handleBack();
|
||
}
|
||
}
|
||
};
|
||
|
||
const onUpdateUserInfo = () => {
|
||
fetchUserInfoById(detail.publisher_id);
|
||
};
|
||
|
||
async function fetchUserInfoById(user_id) {
|
||
const userDetailInfo = await LoginService.getUserInfoById(user_id);
|
||
if (userDetailInfo.code === 0) {
|
||
setUserInfo(userDetailInfo.data);
|
||
}
|
||
}
|
||
|
||
function handleShare() {
|
||
sharePopupRef.current.show();
|
||
}
|
||
|
||
const handleJoinGame = async () => {
|
||
if (isMyOwn) {
|
||
const res = await DetailService.organizerJoin(Number(id));
|
||
if (res.code === 0) {
|
||
toast("加入成功");
|
||
fetchDetail();
|
||
}
|
||
return;
|
||
}
|
||
navto(`/order_pages/orderDetail/index?gameId=${id}`);
|
||
};
|
||
|
||
function onStatusChange(result) {
|
||
if (result) {
|
||
fetchDetail();
|
||
}
|
||
}
|
||
|
||
function handleBack() {
|
||
const pages = Taro.getCurrentPages();
|
||
if (pages.length <= 1) {
|
||
Taro.redirectTo({
|
||
url: "/game_pages/list/index",
|
||
});
|
||
} else {
|
||
Taro.navigateBack();
|
||
}
|
||
}
|
||
|
||
function handleViewUserInfo(userId) {
|
||
navto(`/user_pages/other/index?userid=${userId}`);
|
||
}
|
||
|
||
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}
|
||
userInfo={userInfo}
|
||
handleViewUserInfo={handleViewUserInfo}
|
||
/>
|
||
{/* 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}
|
||
handleJoinGame={handleJoinGame}
|
||
handleViewUserInfo={handleViewUserInfo}
|
||
/>
|
||
{/* supplemental notes */}
|
||
<SupplementalNotes detail={detail} />
|
||
{/* organizer and recommend games by organizer */}
|
||
<OrganizerInfo
|
||
detail={detail}
|
||
userInfo={userInfo}
|
||
currentLocation={currentLocation}
|
||
onUpdateUserInfo={onUpdateUserInfo}
|
||
handleViewUserInfo={handleViewUserInfo}
|
||
handleAddComment={() => {
|
||
commentRef.current && commentRef.current.addComment();
|
||
}}
|
||
/>
|
||
<Comments
|
||
ref={commentRef}
|
||
game_id={Number(id)}
|
||
publisher_id={Number(detail.publisher_id)}
|
||
/>
|
||
{/* sticky bottom action bar */}
|
||
<StickyButton
|
||
handleShare={handleShare}
|
||
handleJoinGame={handleJoinGame}
|
||
detail={detail}
|
||
onStatusChange={onStatusChange}
|
||
handleAddComment={() => {
|
||
commentRef.current && commentRef.current.addComment();
|
||
}}
|
||
getCommentCount={
|
||
commentRef.current && commentRef.current.getCommentCount
|
||
}
|
||
/>
|
||
{/* share popup */}
|
||
<SharePopup
|
||
ref={sharePopupRef}
|
||
id={id as string}
|
||
from={from as string}
|
||
/>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
export default withAuth(Index);
|