Files
mini-programs/src/components/UserInfo/index.tsx
2026-02-14 12:59:21 +08:00

886 lines
27 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 } from "react";
import Taro, { useDidShow } from "@tarojs/taro";
import { View, Text, Image, Button } from "@tarojs/components";
import "./index.scss";
import { EditModal } from "@/components";
import { UserService, PickerOption } from "@/services/userService";
import { PopupPicker } from "@/components/Picker/index";
import {
useUserActions,
useNicknameChangeStatus,
useLastTestResult,
useUserInfo,
} from "@/store/userStore";
import { UserInfoType } from "@/services/userService";
import {
useCities,
useProfessions,
useNtrpLevels,
} from "@/store/pickerOptionsStore";
import { formatNtrpDisplay } from "@/utils/helper";
import { useGlobalState } from "@/store/global";
// 用户信息接口
// export interface UserInfo {
// id: string | number;
// nickname: string;
// avatar: string;
// join_date: string;
// stats: {
// following: number;
// friends: number;
// hosted: number;
// participated: number;
// };
// personal_profile: string;
// occupation: string;
// ntrp_level: string;
// phone?: string;
// gender: string;
// bio?: string;
// latitude?: string;
// longitude?: string;
// birthday?: string;
// is_following?: boolean;
// tags?: string[];
// ongoing_games?: string[];
// country: string;
// province: string;
// city: string;
// }
// 用户信息卡片组件属性
interface UserInfoCardProps {
editable: boolean;
user_info: Partial<UserInfoType>;
is_current_user: boolean;
is_following?: boolean;
collapseProfile?: boolean;
setMarginBottom?: boolean;
on_follow?: () => void;
on_message?: () => void;
on_share?: () => void;
set_user_info?: (info: Partial<UserInfoType>) => void;
onTab?: (tab) => void;
}
// 处理编辑用户信息
const on_edit = () => {
Taro.navigateTo({
url: "/user_pages/edit/index",
});
};
// 用户信息卡片组件
const UserInfoCardComponent: React.FC<UserInfoCardProps> = ({
editable = true,
user_info: user_info_prop,
is_current_user,
is_following = false,
collapseProfile,
setMarginBottom = true,
on_follow,
on_message,
on_share,
set_user_info,
onTab,
}) => {
const global_user_info = useUserInfo();
// 查看别人页面时用传入的 user_info个人页用全局 store
const user_info = is_current_user ? global_user_info : (user_info_prop ?? global_user_info);
const nickname_change_status = useNicknameChangeStatus();
const { setShowGuideBar } = useGlobalState();
const { updateUserInfo, updateNickname, fetchLastTestResult } =
useUserActions();
const ntrpLevels = useNtrpLevels();
// 使用全局状态中的测试结果,避免重复调用接口
const lastTestResult = useLastTestResult();
// 使用 useRef 记录上一次的 user_info只在真正变化时打印
const prevUserInfoRef = useRef<Partial<UserInfoType>>();
useEffect(() => {
const prevStr = JSON.stringify(prevUserInfoRef.current);
const currentStr = JSON.stringify(user_info);
if (prevStr !== currentStr) {
prevUserInfoRef.current = user_info;
}
// 仅当前用户才拉取 NTRP 测试结果
if (is_current_user && !lastTestResult && user_info?.id) {
fetchLastTestResult();
}
}, [user_info?.id, lastTestResult, fetchLastTestResult, is_current_user]);
// 从全局状态中获取测试状态
const ntrpTested = lastTestResult?.has_test_in_last_month || false;
// 编辑个人简介弹窗状态
const [edit_modal_visible, setEditModalVisible] = useState(false);
const [editing_field, setEditingField] = useState<string>("");
const [gender_picker_visible, setGenderPickerVisible] = useState(false);
const [location_picker_visible, setLocationPickerVisible] = useState(false);
const [ntrp_picker_visible, setNtrpPickerVisible] = useState(false);
const [occupation_picker_visible, setOccupationPickerVisible] =
useState(false);
// 表单状态
const [form_data, set_form_data] = useState<Partial<UserInfoType>>({ ...user_info });
// useDidShow(() => {
// set_form_data({ ...user_info });
// });
useEffect(() => {
set_form_data({ ...user_info });
}, [user_info])
useEffect(() => {
const visibles = [
gender_picker_visible,
location_picker_visible,
ntrp_picker_visible,
occupation_picker_visible,
edit_modal_visible,
];
const allPickersClosed = visibles.every((item) => !item);
// 所有选择器都关闭时,显示 GuideBar否则隐藏
setShowGuideBar(allPickersClosed);
}, [
gender_picker_visible,
location_picker_visible,
ntrp_picker_visible,
occupation_picker_visible,
edit_modal_visible,
]);
// 职业数据
const professions = useProfessions();
// 城市数据
const cities = useCities();
// 页面加载时初始化数据
// useEffect(() => {
// getProfessions();
// getCities();
// }, []);
// const getProfessions = async () => {
// try {
// const res = await UserService.getProfessions();
// setProfessions(res);
// } catch (e) {
// console.log("获取职业失败:", e);
// }
// };
// const getCities = async () => {
// try {
// const res = await UserService.getCities();
// setCities(res);
// } catch (e) {
// console.log("获取职业失败:", e);
// }
// };
// 处理编辑弹窗
const handle_open_edit_modal = (field: string) => {
// 打开编辑弹窗时隐藏 GuideBar
setShowGuideBar(false);
if (field === "gender") {
setGenderPickerVisible(true);
return;
}
if (field === "location") {
setLocationPickerVisible(true);
return;
}
if (field === "ntrp_level") {
setNtrpPickerVisible(true);
return;
}
if (field === "occupation") {
setOccupationPickerVisible(true);
return;
}
if (field === "nickname") {
if (!is_current_user) return;
if (!nickname_change_status.can_change) {
return Taro.showToast({
title: `30天内仅可修改4次昵称${nickname_change_status.next_period_start_date}后可修改`,
icon: "none",
duration: 2000,
});
}
// 手动输入
setShowGuideBar(false);
setEditingField(field);
setEditModalVisible(true);
} else {
if (!is_current_user) return;
setShowGuideBar(false);
setEditingField(field);
setEditModalVisible(true);
}
};
const handle_edit_modal_save = async (value: string) => {
try {
// 调用更新用户信息接口,只传递修改的字段
const update_data = { [editing_field]: value };
// await UserService.update_user_info(update_data);
editing_field === "nickname"
? await updateNickname(value)
: await updateUserInfo(update_data);
set_form_data((prev) => {
return { ...prev, ...update_data };
});
// 更新本地状态
// setFormData((prev) => {
// const updated = { ...prev, [editing_field]: value };
// typeof set_user_info === "function" && set_user_info(updated);
// return updated;
// });
// 关闭弹窗
setEditModalVisible(false);
setEditingField("");
// 显示成功提示
Taro.showToast({
title: "保存成功",
icon: "success",
});
} catch (error) {
console.warn("保存失败:", error);
Taro.showToast({
title: "保存失败",
icon: "error",
});
}
};
// 处理字段编辑
const handle_field_edit = async (
field: string | { [key: string]: string },
value?: string
) => {
try {
if (
typeof field === "object" &&
field !== null &&
!Array.isArray(field)
) {
await updateUserInfo({ ...field });
set_form_data((prev) => {
return { ...prev, ...field };
});
// 更新本地状态
// setFormData((prev) => ({ ...prev, ...field }));
// setUserInfo((prev) => ({ ...prev, ...field }));
} else {
// 调用更新用户信息接口,只传递修改的字段
const update_data = { [field as string]: value };
await updateUserInfo(update_data);
set_form_data((prev) => {
return { ...prev, ...update_data };
});
// 更新本地状态
// setFormData((prev) => ({ ...prev, [field as string]: value }));
// setUserInfo((prev) => ({ ...prev, [field as string]: value }));
}
// 显示成功提示
Taro.showToast({
title: "保存成功",
icon: "success",
});
} catch (error) {
console.warn("保存失败:", error);
Taro.showToast({
title: "保存失败",
icon: "error",
});
}
};
// 处理性别选择
const handle_gender_change = (e: any) => {
const gender_value = e[0];
handle_field_edit("gender", gender_value);
};
// 处理地区选择
const handle_location_change = (e: any) => {
const [province, city, district] = e;
handle_field_edit({ province, city, district });
};
// 处理NTRP水平选择
const handle_ntrp_level_change = (e: any) => {
const ntrp_level_value = e[0];
handle_field_edit("ntrp_level", ntrp_level_value);
};
// 处理职业选择
const handle_occupation_change = (e: any) => {
const [firstVal, secondVal, thirdVal] = e;
handle_field_edit("occupation", `${firstVal} ${secondVal} ${thirdVal}`);
};
const handle_edit_modal_cancel = () => {
// 关闭编辑弹窗时显示 GuideBar
setShowGuideBar(true);
setEditModalVisible(false);
setEditingField("");
};
// 处理统计项点击
const handle_stats_click = (
type: "following" | "friends" | "hosted" | "participated"
) => {
// 只有当前用户才能查看关注相关页面
if (!is_current_user) {
Taro.showToast({
title: "暂不支持查看他人关注信息",
icon: "none",
});
return;
}
if (type === "following") {
// 跳转到关注列表页面
Taro.navigateTo({
url: "/user_pages/follow/index?tab=following",
});
} else if (type === "friends") {
// 跳转到球友(粉丝)页面,显示粉丝标签
Taro.navigateTo({
url: "/user_pages/follow/index?tab=follower",
});
}
// 主办和参加暂时不处理,可以后续扩展
};
const getDefaultOption = (options) => {
if (!Array.isArray(options) || options.length === 0) {
return [];
}
const defaultOptions: string[] = [];
let current = options[0];
while (current) {
defaultOptions.push(current.text);
current = current.children?.[0];
}
return defaultOptions;
};
const previewAvatar = (url) => {
wx.previewImage({
urls: [url],
});
};
return (
<View className="user_info_card">
{/* 头像和基本信息 */}
<View className="basic_info">
<View className="avatar_container">
<Image
className="avatar"
src={user_info.avatar_url || ""}
mode="aspectFill"
onClick={() => {
previewAvatar(user_info.avatar_url || "");
}}
/>
</View>
<View className="info_container">
<Text
className="nickname"
onClick={() => {
handle_open_edit_modal("nickname");
}}
>
{user_info.nickname || ""}
</Text>
<Text className="join_date">{user_info.join_date || ""}</Text>
</View>
{is_current_user && (
<View className="tag_item" onClick={on_edit}>
<Image
className="tag_icon"
style="gap: 0;"
src={require("../../static/userInfo/edit.svg")}
/>
</View>
)}
</View>
{/* 统计数据 */}
<View className="stats_section">
<View
className="stats_container"
// style={{
// marginBottom: `${
// collapseProfile && setMarginBottom ? "16px" : "unset"
// }`,
// }}
>
<View
className="stat_item clickable"
onClick={() => handle_stats_click("following")}
>
<Text className="stat_number">
{user_info.stats?.following_count || 0}
</Text>
<Text className="stat_label"></Text>
</View>
<View
className="stat_item clickable"
onClick={() => handle_stats_click("friends")}
>
<Text className="stat_number">
{user_info.stats?.followers_count || 0}
</Text>
<Text className="stat_label"></Text>
</View>
<View
className="stat_item clickable"
onClick={() => onTab?.("hosted")}
>
<Text className="stat_number">
{user_info.stats?.hosted_games_count || 0}
</Text>
<Text className="stat_label"></Text>
</View>
<View
className="stat_item clickable"
onClick={() => onTab?.("participated")}
>
<Text className="stat_number">
{user_info.stats?.participated_games_count || 0}
</Text>
<Text className="stat_label"></Text>
</View>
</View>
<View className="action_buttons">
{/* 只有非当前用户才显示关注按钮 */}
{!is_current_user && on_follow && (
<Button
className={`follow_button ${is_following ? "following" : ""}`}
onClick={on_follow}
>
<Image
className="button_icon"
src={require(is_following
? "@/static/userInfo/following.svg"
: "@/static/userInfo/unfollow.svg")}
/>
<Text
className={`button_text ${is_following ? "following" : ""}`}
>
{is_following ? "已关注" : "关注"}
</Text>
</Button>
)}
{/* 只有非当前用户才显示消息按钮 */}
{/* {!is_current_user && on_message && (
<Button className="message_button" onClick={on_message}>
<Image
className="button_icon"
src={require("@/static/userInfo/chat.svg")}
/>
</Button>
)} */}
{/* 只有当前用户才显示分享按钮 */}
{is_current_user && on_share && (
<Button className="share_button" onClick={on_share}>
<Text className="button_text"></Text>
</Button>
)}
</View>
</View>
{/* 标签和简介 */}
{!collapseProfile ? (
<View className="tags_bio_section">
<View className="tags_container">
{user_info.gender && user_info.gender !== "2" ? (
<View className="tag_item">
{user_info.gender === "0" && (
<Image
className="tag_icon"
src={require("../../static/userInfo/male.svg")}
onClick={() => {
editable && handle_open_edit_modal("gender");
}}
/>
)}
{user_info.gender === "1" && (
<Image
className="tag_icon"
src={require("../../static/userInfo/female.svg")}
onClick={() => {
editable && handle_open_edit_modal("gender");
}}
/>
)}
</View>
) : is_current_user && user_info.gender !== "2" ? (
<View
className="button_edit"
onClick={() => {
handle_open_edit_modal("gender");
}}
>
<Text></Text>
</View>
) : null}
{user_info.ntrp_level !== "" ? (
<View
className="tag_item"
onClick={() => {
editable && handle_open_edit_modal("ntrp_level");
}}
>
<Text className="tag_text">{`NTRP ${formatNtrpDisplay(
user_info.ntrp_level
)}`}</Text>
</View>
) : is_current_user ? (
<View
className="button_edit"
onClick={() => {
handle_open_edit_modal("ntrp_level");
}}
>
<Text>NTRP水平</Text>
</View>
) : null}
{user_info.occupation ? (
<View
className="tag_item"
onClick={() => {
editable && handle_open_edit_modal("occupation");
}}
>
<Text className="tag_text">
{user_info.occupation.split(" ")[2]}
</Text>
</View>
) : is_current_user ? (
<View
className="button_edit"
onClick={() => {
handle_open_edit_modal("occupation");
}}
>
<Text></Text>
</View>
) : null}
{user_info.province || user_info.city || user_info.district ? (
<View
className="tag_item"
onClick={() => editable && handle_open_edit_modal("location")}
>
<Text className="tag_text">{`${user_info.city}${user_info.district}`}</Text>
</View>
) : is_current_user ? (
<View
className="button_edit"
onClick={() => handle_open_edit_modal("location")}
>
<Text></Text>
</View>
) : null}
</View>
<View
className="personal_profile"
onClick={() => handle_open_edit_modal("personal_profile")}
>
{!collapseProfile ? (
user_info.personal_profile ? (
<Text className="bio_text">{user_info.personal_profile}</Text>
) : is_current_user ? (
<View className="personal_profile_edit">
<Image
className="edit_icon"
src={require("../../static/userInfo/info_edit.svg")}
/>
<Text className="bio_text"></Text>
</View>
) : null
) : null}
</View>
</View>
) : null}
{/* 编辑个人简介弹窗 */}
<EditModal
visible={edit_modal_visible}
type={editing_field}
title={editing_field === "nickname" ? "编辑名字" : "编辑简介"}
placeholder={
editing_field === "nickname"
? "请输入您的名字"
: "介绍一下你的喜好,或者训练习惯"
}
initialValue={form_data[editing_field as keyof typeof form_data] || ""}
maxLength={editing_field === "nickname" ? 20 : 100}
invalidCharacters={
editing_field === "nickname"
? /^[\u4e00-\u9fa5a-zA-Z0-9_\-\.\(\)\s]*$/
: null
}
onSave={handle_edit_modal_save}
onCancel={handle_edit_modal_cancel}
validationMessage={
editing_field === "nickname"
? `请填写 2-24 个字符,不包括 @<>/等无效字符。30 天内可修改 4 次昵称,${nickname_change_status.next_period_start_date} 前还可修改 ${nickname_change_status.remaining_count} 次。`
: "请填写 2-100 个字符"
}
/>
{/* <EditModal
visible={edit_modal_visible}
type={editing_field}
title="编辑简介"
placeholder="介绍一下你的喜好,或者训练习惯"
initialValue={form_data["personal_profile"] || ""}
maxLength={100}
onSave={handle_edit_modal_save}
onCancel={handle_edit_modal_cancel}
validationMessage="请填写 2-100 个字符"
/> */}
{/* 性别选择弹窗 */}
{gender_picker_visible && (
<PopupPicker
showHeader={true}
title="选择性别"
options={
[
{ text: "男", value: "0" },
{ text: "女", value: "1" },
{ text: "保密", value: "2" },
]
}
visible={gender_picker_visible}
setvisible={setGenderPickerVisible}
value={!form_data.gender ? ["0"] : [form_data.gender]}
onChange={handle_gender_change}
/>
)}
{/* 地区选择弹窗 */}
{location_picker_visible && (
<PopupPicker
showHeader={true}
title="选择地区"
options={cities}
visible={location_picker_visible}
setvisible={setLocationPickerVisible}
value={
form_data.province
? [form_data.province, form_data.city, form_data.district]
: getDefaultOption(cities)
}
onChange={handle_location_change}
/>
)}
{/* NTRP水平选择弹窗 */}
{ntrp_picker_visible && (
<PopupPicker
showHeader={true}
title="选择 NTRP 自评水平"
ntrpTested={ntrpTested}
options={ntrpLevels}
type="ntrp"
img={user_info.avatar_url || ""}
visible={ntrp_picker_visible}
setvisible={setNtrpPickerVisible}
value={!form_data.ntrp_level ? ["2.5"] : [form_data.ntrp_level]}
onChange={handle_ntrp_level_change}
/>
)}
{/* 职业选择弹窗 */}
{occupation_picker_visible && (
<PopupPicker
showHeader={true}
title="选择职业"
options={professions}
visible={occupation_picker_visible}
setvisible={setOccupationPickerVisible}
value={
form_data.occupation
? [...form_data.occupation.split(" ")]
: getDefaultOption(professions)
}
onChange={handle_occupation_change}
/>
)}
</View>
);
};
// 自定义比较函数:只在关键 props 变化时重新渲染
const arePropsEqual = (
prevProps: UserInfoCardProps,
nextProps: UserInfoCardProps
) => {
// 使用 JSON.stringify 进行深度比较(注意:对于复杂对象可能有性能问题)
const prevUserInfoStr = JSON.stringify(prevProps.user_info);
const nextUserInfoStr = JSON.stringify(nextProps.user_info);
return (
prevUserInfoStr === nextUserInfoStr &&
prevProps.editable === nextProps.editable &&
prevProps.is_current_user === nextProps.is_current_user &&
prevProps.is_following === nextProps.is_following &&
prevProps.collapseProfile === nextProps.collapseProfile
);
};
// 使用 React.memo 优化组件,减少不必要的重新渲染
// export const UserInfoCard = React.memo(UserInfoCardComponent, arePropsEqual);
export const UserInfoCard = UserInfoCardComponent;
// 球局记录接口
export interface GameRecord {
id: string;
title: string;
date: string;
time: string;
duration: string;
location: string;
type: string;
distance: string;
participants: {
avatar: string;
nickname: string;
}[];
max_participants: number;
current_participants: number;
level_range: string;
game_type: string;
image_list: string[];
deadline_hours: number;
end_time: string;
}
// 球局卡片组件属性
interface GameCardProps {
game: GameRecord;
on_click: (game_id: string) => void;
on_participant_click?: (participant_id: string) => void;
}
// 球局卡片组件
export const GameCard: React.FC<GameCardProps> = ({
game,
on_click,
on_participant_click,
}) => {
return (
<View className="game_card" onClick={() => on_click(game.id)}>
{/* 球局标题和类型 */}
<View className="game_header">
<Text className="game_title">{game.title}</Text>
<View className="game_type_icon">
<Image
className="type_icon"
src={require("../../static/userInfo/tennis.svg")}
/>
</View>
</View>
{/* 球局时间 */}
<View className="game_time">
<Text className="time_text">
{game.date} {game.time} {game.duration}
</Text>
</View>
{/* 球局地点和类型 */}
<View className="game_location">
<Text className="location_text">{game.location}</Text>
<Text className="separator">·</Text>
<Text className="type_text">{game.type}</Text>
<Text className="separator">·</Text>
<Text className="distance_text">{game.distance}</Text>
</View>
{/* 球局图片 */}
<View className="game_images">
{game.image_list.map((image, index) => (
<Image key={index} className="game_image" src={image} />
))}
</View>
{/* 球局信息标签 */}
<View className="game_tags">
<View className="participants_info">
<View className="avatars">
{game.participants?.map((participant, index) => (
<Image
key={index}
className="participant_avatar"
src={participant.avatar}
onClick={(e) => {
e.stopPropagation();
on_participant_click?.(participant.nickname);
}}
/>
))}
</View>
<View className="participants_count">
<Text className="count_text">
{game.current_participants}/{game.max_participants}
</Text>
</View>
</View>
<View className="game_info_tags">
<View className="info_tag">
<Text className="tag_text">{game.level_range}</Text>
</View>
<View className="info_tag">
<Text className="tag_text">{game.game_type}</Text>
</View>
</View>
</View>
</View>
);
};
// 球局标签页组件属性
interface GameTabsProps {
active_tab: "hosted" | "participated";
on_tab_change: (tab: "hosted" | "participated") => void;
is_current_user: boolean;
}
// 球局标签页组件
export const GameTabs: React.FC<GameTabsProps> = ({
active_tab,
on_tab_change,
is_current_user,
}) => {
const hosted_text = is_current_user ? "我主办的" : "主办球局";
const participated_text = is_current_user ? "我参与的" : "参与球局";
return (
<View className="game_tabs_section">
<View className="tab_container">
<View
className={`tab_item ${active_tab === "hosted" ? "active" : ""}`}
onClick={() => on_tab_change("hosted")}
>
<Text className="tab_text">{hosted_text}</Text>
</View>
<View
className={`tab_item ${active_tab === "participated" ? "active" : ""
}`}
onClick={() => on_tab_change("participated")}
>
<Text className="tab_text">{participated_text}</Text>
</View>
</View>
</View>
);
};