Files
mini-programs/src/game_pages/detail/components/Participants/index.tsx
张成 8abf6e6f2b 1
2026-02-01 18:46:30 +08:00

498 lines
16 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, { useRef } from "react";
import Taro from "@tarojs/taro";
import { View, Text, Image, ScrollView } from "@tarojs/components";
import dayjs from "dayjs";
import classnames from "classnames";
import img from "@/config/images";
import { useUserInfo } from "@/store/userStore";
import { formatNtrpDisplay, toast, navto } from "@/utils/helper";
// import RMB_ICON from "@/static/detail/rmb.svg";
import { MATCH_STATUS, IsSubstituteSupported } from "@/services/detailService";
import OrderService from "@/services/orderService";
import styles from "./index.module.scss";
import NTRPEvaluatePopup from "@/components/NTRPEvaluatePopup";
import { EvaluateCallback, EvaluateScene } from "@/store/evaluateStore";
function isFull(counts) {
const {
max_players,
current_players,
max_substitute_players,
current_substitute_count,
is_substitute_supported,
} = counts;
if (
max_players === current_players &&
is_substitute_supported === IsSubstituteSupported.NOTSUPPORT
) {
return true;
} else if (
max_players === current_players &&
is_substitute_supported === IsSubstituteSupported.SUPPORT
) {
return max_substitute_players === current_substitute_count;
}
return false;
}
function matchNtrpRequestment(
target?: string,
min?: string,
max?: string
): boolean {
// 目标值为空或 undefined
if (!target?.trim()) return true;
// 提取目标值中的第一个数字
const match = target.match(/-?\d+(\.\d+)?/);
if (!match) return true;
const value = parseFloat(match[0]);
const minNum = min !== undefined ? parseFloat(min) : undefined;
const maxNum = max !== undefined ? parseFloat(max) : undefined;
// min 和 max 都未定义 → 直接通过
if (minNum === undefined && maxNum === undefined) return true;
// min = max 或只有一边 undefined → 参考值判断,包含端点
if (minNum === undefined || maxNum === undefined || minNum === maxNum) {
return value >= (minNum ?? maxNum!);
}
// 正常区间判断,包含端点
return value >= minNum && value <= maxNum;
}
// 参与者
export default function Participants(props) {
const { detail = {}, handleJoinGame, handleViewUserInfo } = props;
const ntrpRef = useRef<{
show: (evaluateCallback: EvaluateCallback) => void;
}>({ show: () => {} });
const userInfo = useUserInfo();
const participants = detail.participants || [];
const substitute_members = detail.substitute_members || [];
// const participants = Array(10)
// .fill(0)
// .map((_, index) => ({
// id: 337 + index,
// join_time: "2025-12-06 11:06:24",
// status: "joined",
// is_organizer: true,
// user: {
// id: 18,
// nickname: "小猫开刀削面店往猫毛里面下面条",
// avatar_url:
// "https://youchang2026.oss-cn-shanghai.aliyuncs.com/front/ball/images/d284060f-248b-4d58-a153-4d37c0ca77c8.jpg",
// phone: "18513125687",
// ntrp_level: "1.5",
// },
// }));
const {
participant_count,
max_participants,
substitute_count,
max_substitute_players,
user_action_status = {},
start_time,
price,
ntrp_level,
skill_level_min,
skill_level_max,
is_organizer,
match_status,
end_time,
id,
} = 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 &&
dayjs(start_time).isAfter(dayjs());
// 检查手机号绑定的包装函数
const checkPhoneAndExecute = (action: () => void) => {
return () => {
if (!userInfo?.phone) {
Taro.showModal({
title: "提示",
content: "该功能需要绑定手机号",
confirmText: "去绑定",
cancelText: "取消",
success: (res) => {
if (res.confirm) {
const currentPath = Taro.getCurrentInstance().router?.path || "";
const currentParams =
Taro.getCurrentInstance().router?.params || {};
const queryString = Object.keys(currentParams)
.map((key) => `${key}=${currentParams[key]}`)
.join("&");
const fullPath = queryString
? `${currentPath}?${queryString}`
: currentPath;
Taro.navigateTo({
url: `/login_pages/index/index?redirect=${encodeURIComponent(
fullPath
)}`,
});
}
},
});
return;
}
action();
};
};
const matchNtrpReq = matchNtrpRequestment(
userInfo?.ntrp_level,
skill_level_min,
skill_level_max
);
function handleSelfEvaluate() {
ntrpRef?.current?.show({
type: EvaluateScene.detail,
next: ({ flag, score }) => {
if (!matchNtrpRequestment(score, skill_level_min, skill_level_max)) {
toast("您当前不符合此球局NTRP水平要求去看看其他活动吧");
return;
}
if (flag) {
Taro.navigateTo({
url: `/order_pages/orderDetail/index?gameId=${id}`,
});
return;
}
Taro.redirectTo({ url: `/order_pages/orderDetail/index?gameId=${id}` });
},
onCancel: () => {
// Taro.redirectTo({ url: `/game_pages/detail/index?id=${id}` });
Taro.navigateBack();
},
});
}
function generateTextAndAction(
user_action_status: null | { [key: string]: boolean }
):
| undefined
| { text: string | React.FC; action?: () => void; available?: boolean } {
if (!user_action_status) {
return;
}
// const priceStrArr = price?.toString().split(".") ?? [];
// const displayPrice = is_organizer ? (
// <>
// <Text className={styles.integer}>0</Text>
// {/* <Text className={styles.decimalPart}>.00</Text> */}
// </>
// ) : (
// <>
// <Text className={styles.integer}>{priceStrArr[0]}</Text>
// <Text className={styles.decimalPart}>.{priceStrArr[1]}</Text>
// </>
// );
// 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 (MATCH_STATUS.FINISHED === 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: "已加入",
action: () => toast("您已参与了本次活动"),
};
} else if (is_substituting) {
return {
text: "已加入候补",
action: () => toast("您已加入候补,候补失败会全额退款~"),
};
} else if (can_pay) {
return {
text: "继续支付",
action: checkPhoneAndExecute(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 (!matchNtrpReq) {
return {
text: "立即加入1",
available: false,
action: () =>
toast("您当前不符合此球局NTRP水平要求去看看其他活动吧"),
};
} else if (can_substitute) {
return {
text: "我要候补",
action: checkPhoneAndExecute(handleJoinGame),
};
} else if (can_join) {
return {
text: "立即加入2",
action: checkPhoneAndExecute(handleJoinGame),
};
} else if (can_assess) {
return {
text: "立即加入3",
action: checkPhoneAndExecute(handleSelfEvaluate),
};
}
return {
text: "球局无法加入",
available: false,
};
}
const { action = () => {} } = generateTextAndAction(user_action_status)!;
const leftCount = max_participants - participant_count;
const leftSubstituteCount = (max_substitute_players || 0) - (substitute_count || 0);
const showSubstituteApplicationEntry =
[can_pay, can_join, is_substituting, waiting_start].every(
(item) => !item
) &&
can_substitute &&
dayjs(start_time).isAfter(dayjs());
return (
<>
<View className={styles["detail-page-content-participants"]}>
<View className={styles["participants-title"]}>
<Text></Text>
<Text>·</Text>
<Text>{leftCount > 0 ? `剩余空位 ${leftCount}` : "已满员"}</Text>
</View>
{participant_count > 0 || showApplicationEntry ? (
<View className={styles["participants-list"]}>
{/* application */}
{showApplicationEntry && (
<View
className={styles["participants-list-application"]}
onClick={() => {
action?.();
}}
>
<Image
className={styles["participants-list-application-icon"]}
src={img.ICON_DETAIL_APPLICATION_ADD}
/>
<Text className={styles["participants-list-application-text"]}>
</Text>
</View>
)}
{/* participants list */}
<ScrollView
refresherBackground="#FAFAFA"
className={classnames(
styles["participants-list-scroll"],
showApplicationEntry ? styles.withApplication : ""
)}
scrollX
>
<View
className={styles["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,
ntrp_level,
id: participant_user_id,
},
} = participant;
const role = is_organizer ? "组织者" : "参与者";
// 优先使用 ntrp_level如果没有则使用 level
const ntrpValue = ntrp_level || level;
// 格式化显示 NTRP如果没有值则显示"初学者"
const displayNtrp = ntrpValue
? formatNtrpDisplay(ntrpValue)
: "初学者";
return (
<View
key={participant.id}
className={styles["participants-list-item"]}
>
<Image
className={styles["participants-list-item-avatar"]}
mode="aspectFill"
src={avatar_url}
onClick={handleViewUserInfo.bind(
null,
participant_user_id
)}
/>
<Text className={styles["participants-list-item-name"]}>
{nickname || "未知"}
</Text>
<Text className={styles["participants-list-item-level"]}>
{displayNtrp}
</Text>
<Text className={styles["participants-list-item-role"]}>
{role}
</Text>
</View>
);
})}
</View>
</ScrollView>
</View>
) : (
""
)}
</View>
{/* 候补区域 */}
{max_substitute_players > 0 && (substitute_count > 0 || showSubstituteApplicationEntry) && (
<View className={styles["detail-page-content-participants"]}>
<View className={styles["participants-title"]}>
<Text></Text>
<Text>·</Text>
<Text>{leftSubstituteCount > 0 ? `剩余空位 ${leftSubstituteCount}` : "已满员"}</Text>
</View>
<View className={styles["participants-list"]}>
{/* 候补申请入口 */}
{showSubstituteApplicationEntry && (
<View
className={styles["participants-list-application"]}
onClick={() => {
action?.();
}}
>
<Image
className={styles["participants-list-application-icon"]}
src={img.ICON_DETAIL_APPLICATION_ADD}
/>
<Text className={styles["participants-list-application-text"]}>
</Text>
</View>
)}
{/* 候补成员列表 */}
<ScrollView
refresherBackground="#FAFAFA"
className={classnames(
styles["participants-list-scroll"],
showSubstituteApplicationEntry ? styles.withApplication : ""
)}
scrollX
>
<View
className={styles["participants-list-scroll-content"]}
style={{
width: `${
Math.max(substitute_members.length, 1) * 103 + (Math.max(substitute_members.length, 1) - 1) * 8
}px`,
}}
>
{substitute_members.map((substitute) => {
const {
is_organizer,
user: {
avatar_url,
nickname,
level,
ntrp_level,
id: substitute_user_id,
},
} = substitute;
const role = is_organizer ? "组织者" : "参与者";
// 优先使用 ntrp_level如果没有则使用 level
const ntrpValue = ntrp_level || level;
// 格式化显示 NTRP如果没有值则显示"初学者"
const displayNtrp = ntrpValue
? formatNtrpDisplay(ntrpValue)
: "初学者";
return (
<View
key={substitute.id}
className={styles["participants-list-item"]}
>
<Image
className={styles["participants-list-item-avatar"]}
mode="aspectFill"
src={avatar_url}
onClick={handleViewUserInfo.bind(
null,
substitute_user_id
)}
/>
<Text className={styles["participants-list-item-name"]}>
{nickname || "未知"}
</Text>
<Text className={styles["participants-list-item-level"]}>
{displayNtrp}
</Text>
<Text className={styles["participants-list-item-role"]}>
{role}
</Text>
</View>
);
})}
</View>
</ScrollView>
</View>
</View>
)}
<NTRPEvaluatePopup type={EvaluateScene.detail} ref={ntrpRef} showGuide />
</>
);
}