feat: 生成分享图

This commit is contained in:
2025-10-15 11:20:36 +08:00
parent 56fd71f266
commit 77e50731a3
30 changed files with 2756 additions and 2641 deletions

View File

@@ -9,6 +9,7 @@ export default defineAppConfig({
"game_pages/search/index", // 搜索页
"game_pages/searchResult/index", // 搜索结果页面
"game_pages/detail/index", // 球局详情页
"game_pages/sharePoster/index",
],
subPackages: [

View File

@@ -0,0 +1,34 @@
.detail-swiper-container {
height: 270px;
width: 100%;
padding: 15px 15px 0;
box-sizing: border-box;
overflow-x: scroll;
&::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
color: transparent; /* 透明色 */
}
.detail-swiper-scroll-container {
display: flex;
height: 250px;
width: auto;
align-items: center;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 12px;
.detail-swiper-item {
flex: 0 0 auto;
height: 250px;
display: flex;
align-items: center;
&-image {
border-radius: 12px;
transition: transform 0.5s;
}
}
}
}

View File

@@ -0,0 +1,84 @@
import Taro from "@tarojs/taro";
import { useEffect, useState } from "react";
import { View, Image } from "@tarojs/components";
import styles from "./index.module.scss";
type CarouselItemType = {
url: string;
width: number;
height: number;
};
export default function Carousel(props) {
const { detail } = props;
const [list, setList] = useState<CarouselItemType[]>([]);
const [listWidth, setListWidth] = useState(0);
const { image_list } = detail;
async function getImagesMsg(imageList) {
const latest_list: CarouselItemType[] = [];
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]);
function previewImage(current_url) {
Taro.previewImage({
current: current_url,
urls: list?.length > 0 ? list.map((c) => c.url) : [],
});
}
return (
<View className={styles["detail-swiper-container"]}>
<View
className={styles["detail-swiper-scroll-container"]}
style={{ width: listWidth + "px" }}
>
{list.map((item, index) => {
return (
<View
className={styles["detail-swiper-item"]}
key={index}
onClick={() => previewImage(item.url)}
>
<Image
src={item.url}
mode="aspectFill"
className={styles["detail-swiper-item-image"]}
style={{ width: item.width + "px", height: item.height + "px" }}
/>
</View>
);
})}
</View>
</View>
);
}

View File

@@ -0,0 +1,201 @@
.detail-page-content-game-info {
&-date-weather {
padding: 20px 20px 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
// gap: 12px;
&-calendar-date {
width: 60%;
display: flex;
align-items: center;
gap: 16px;
&-calendar {
display: flex;
width: 48px;
height: 48px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
gap: 4px;
border-radius: 12px;
// border: 0.5px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.25);
overflow: hidden;
color: #fff;
background: #536272;
.month {
width: 100%;
height: 18px;
font-size: 10px;
display: flex;
padding: 1px auto;
box-sizing: border-box;
justify-content: center;
align-items: center;
// border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background: #7b828b;
}
.day {
display: flex;
width: 48px;
height: 30px;
// padding-bottom: 6px;
box-sizing: border-box;
flex-direction: column;
align-items: center;
// border: 0.5px solid rgba(255, 255, 255, 0.08);
// background: rgba(255, 255, 255, 0.25);
// background-color: #536272;
}
}
&-date {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-evenly;
gap: 4px;
align-self: stretch;
color: #fff;
.date {
color: #fff;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
}
.venue-time {
color: rgba(255, 255, 255, 0.8);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
}
}
&-weather {
display: flex;
align-items: flex-end;
flex-direction: column;
gap: 4px;
&-icon {
width: 20px;
height: 20px;
color: rgba(255, 255, 255, 0.8);
}
&-text-temperature {
display: flex;
align-items: center;
gap: 12px;
color: rgba(255, 255, 255, 0.8);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
}
}
&-place {
.location-message {
padding: 20px 20px 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 12px;
&-icon {
width: 48px;
height: 48px;
border-radius: 12px;
padding: 14px;
box-sizing: border-box;
background: #4d5865;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
// border: 0.5px solid rgba(255, 255, 255, 0.08);
// background: rgba(255, 255, 255, 0.25);
&-image {
width: 20px;
height: 20px;
}
}
&-text {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-evenly;
gap: 4px;
align-self: stretch;
&-name-distance {
display: flex;
align-items: center;
gap: 4px;
color: #fff;
text-align: center;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
&-arrow {
width: 16px;
height: 16px;
}
}
&-address {
color: rgba(255, 255, 255, 0.8);
text-align: center;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
}
}
.location-map {
width: 100%;
padding: 20px 20px 0;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
border-radius: 12px;
&-map {
width: 100%;
height: 95px;
border-radius: 12px;
}
}
}
}

View File

@@ -0,0 +1,188 @@
import Taro from "@tarojs/taro";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import { calculateDistance } from "@/utils";
import { View, Image, Text, Map } from "@tarojs/components";
import img from "@/config/images";
import styles from "./index.module.scss";
dayjs.locale("zh-cn");
// 球局信息
export default 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={styles["detail-page-content-game-info"]}>
{/* Date and Weather */}
<View className={styles["detail-page-content-game-info-date-weather"]}>
{/* Calendar and Date time */}
<View
className={
styles["detail-page-content-game-info-date-weather-calendar-date"]
}
>
{/* Calendar */}
<View
className={
styles[
"detail-page-content-game-info-date-weather-calendar-date-calendar"
]
}
>
<View className={styles.month}>{startMonth}</View>
<View className={styles.day}>{startDay}</View>
</View>
{/* Date time */}
<View
className={
styles[
"detail-page-content-game-info-date-weather-calendar-date-date"
]
}
>
<View className={styles.date}>{startDate}</View>
<View className={styles["venue-time"]}>
{gameRange} {game_length}
</View>
</View>
</View>
{/* Weather */}
<View
className={
styles["detail-page-content-game-info-date-weather-weather"]
}
>
{/* Weather icon */}
<View
className={
styles["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={
styles[
"detail-page-content-game-info-date-weather-weather-text-temperature"
]
}
>
{tempMin && tempMax && (
<Text>
{tempMin} - {tempMax}
</Text>
)}
</View>
</View>
</View>
{/* Place */}
<View className={styles["detail-page-content-game-info-place"]}>
{/* venue location message */}
<View className={styles["location-message"]}>
{/* location icon */}
<View className={styles["location-message-icon"]}>
<Image
className={styles["location-message-icon-image"]}
src={img.ICON_DETAIL_MAP}
/>
</View>
{/* location message */}
<View className={styles["location-message-text"]}>
{/* venue name and distance */}
{distance ? (
<View
className={styles["location-message-text-name-distance"]}
onClick={openMap}
>
<Text>{location_name || "-"}</Text>
<Text>·</Text>
<Text>{distance.toFixed(1)}km</Text>
<Image
className={
styles["location-message-text-name-distance-arrow"]
}
src={img.ICON_DETAIL_ARROW_RIGHT}
/>
</View>
) : (
""
)}
{/* venue address */}
<View className={styles["location-message-text-address"]}>
<Text>{location || "-"}</Text>
</View>
</View>
</View>
{/* venue map */}
<View className={styles["location-map"]}>
{longitude && latitude && (
<Map
className={styles["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>
);
}

View File

@@ -0,0 +1,54 @@
.detail-page-content-gameplay-requirements {
padding: 24px 15px 0;
box-sizing: border-box;
.gameplay-requirements-title {
overflow: hidden;
color: #fff;
height: 24px;
padding-bottom: 6px;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.gameplay-requirements {
padding: 12px 0 0;
display: flex;
flex-direction: column;
gap: 12px;
&-item {
display: flex;
justify-content: space-between;
align-items: center;
gap: 2px;
align-self: stretch;
&-title {
color: rgba(255, 255, 255, 0.8);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 171.429% */
}
&-desc {
color: #fff;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 160% */
}
}
}
}

View File

@@ -0,0 +1,46 @@
import { View, Text } from "@tarojs/components";
import { genNTRPRequirementText } from "../../utils/helper";
import styles from "./index.module.scss";
// 玩法要求
export default 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={styles["detail-page-content-gameplay-requirements"]}>
{/* title */}
<View className={styles["gameplay-requirements-title"]}>
<Text></Text>
</View>
{/* requirements */}
<View className={styles["gameplay-requirements"]}>
{requirements.map((item, index) => (
<View key={index} className={styles["gameplay-requirements-item"]}>
<Text className={styles["gameplay-requirements-item-title"]}>
{item.title}
</Text>
<Text className={styles["gameplay-requirements-item-desc"]}>
{item.desc}
</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,44 @@
.detail-page-content-avatar-tags {
padding: 20px 20px 0;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 6px;
&-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
overflow: hidden;
&-image {
width: 28px;
height: 28px;
}
}
&-tags {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
&-tag {
display: flex;
height: 28px;
padding: 6px 12px;
box-sizing: border-box;
align-items: center;
gap: 4px;
border-radius: 999px;
border: 0.5px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.25);
color: #fff;
font-size: 13px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.23px;
text-align: center;
}
}
}

View File

@@ -0,0 +1,49 @@
import { View, Text, Image } from "@tarojs/components";
import styles from "./index.module.scss";
export default 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={styles["detail-page-content-avatar-tags"]}>
<View className={styles["detail-page-content-avatar-tags-avatar"]}>
{/* network image mock */}
<Image
className={styles["detail-page-content-avatar-tags-avatar-image"]}
mode="aspectFill"
src={avatar_url}
onClick={handleViewUserInfo.bind(null, id)}
/>
</View>
<View className={styles["detail-page-content-avatar-tags-tags"]}>
{tags.map((tag, index) => (
<View
key={index}
className={styles["detail-page-content-avatar-tags-tags-tag"]}
>
{tag.icon && <Image src={tag.icon} />}
<Text>{tag.name}</Text>
</View>
))}
</View>
</View>
);
}

View File

@@ -0,0 +1,239 @@
.detail-page-content-organizer-recommend-games {
padding: 24px 15px 10px;
.organizer-title {
overflow: hidden;
padding-bottom: 6px;
height: 24px;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.organizer-avatar-name {
display: flex;
align-items: center;
padding: 16px 0 0;
align-items: center;
gap: 8px;
justify-content: flex-start;
&-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
}
&-message {
display: flex;
flex-direction: column;
gap: 4px;
&-name {
color: rgba(255, 255, 255, 0.85);
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 184.615% */
}
&-stats {
display: flex;
align-items: center;
gap: 5px;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
letter-spacing: 0.06px;
&-separator {
width: 1px;
height: 10px;
color: rgba(255, 255, 255, 0.2);
}
}
}
.organizer-actions {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
.organizer-actions-follow,
.organizer-actions-comment {
display: flex;
height: 32px;
box-sizing: border-box;
align-items: center;
gap: 4px;
border-radius: 999px;
// border: 0.5px solid rgba(255, 255, 255, 0.10);
background: rgba(255, 255, 255, 0.16);
box-shadow: 0 4px 48px 0 rgba(0, 0, 0, 0.08);
& > image {
width: 16px;
height: 16px;
}
}
.organizer-actions-follow {
padding: 8px 12px 8px;
&-text {
color: #fff;
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 153.846% */
letter-spacing: -0.23px;
}
}
.organizer-actions-comment {
padding: 8px 10px;
}
}
}
.organizer-recommend-games {
padding-top: 20px;
.organizer-recommend-games-title {
overflow: hidden;
color: rgba(255, 255, 255, 0.65);
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 160% */
display: flex;
align-items: center;
gap: 2px;
align-self: stretch;
&-arrow {
width: 12px;
height: 12px;
}
}
.recommend-games-list {
padding: 10px 0;
display: flex;
gap: 8px;
flex-wrap: nowrap;
&-content {
display: flex;
gap: 8px;
flex-wrap: nowrap;
.recommend-games-list-item {
width: 246px;
height: 122px;
display: flex;
flex-direction: column;
gap: 6px;
flex: 0 0 auto;
border-radius: 20px;
border: 1px solid rgba(33, 178, 0, 0.2);
background: rgba(255, 255, 255, 0.16);
padding: 12px 0 12px 15px;
box-sizing: border-box;
&-title {
display: flex;
align-items: center;
height: 24px;
gap: 2px;
overflow: hidden;
color: rgba(255, 255, 255, 0.85);
text-overflow: ellipsis;
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
&-arrow {
width: 12px;
height: 12px;
}
}
&-time-range {
overflow: hidden;
color: rgba(255, 255, 255, 0.45);
text-overflow: ellipsis;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
&-location-venue-distance {
display: flex;
align-items: center;
gap: 2px;
overflow: hidden;
color: rgba(255, 255, 255, 0.45);
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 150% */
}
&-addon {
display: flex;
align-items: center;
gap: 4px;
&-avatar {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
&-message {
display: flex;
gap: 4px;
&-applications,
&-level-requirements,
&-play-type {
color: rgba(255, 255, 255, 0.85);
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 181.818% */
letter-spacing: -0.23px;
display: flex;
height: 20px;
padding: 6px 8px;
box-sizing: border-box;
align-items: center;
gap: 4px;
border-radius: 999px;
// border: 0.5px solid rgba(0, 0, 0, 0.16);
background: rgba(255, 255, 255, 0.12);
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,276 @@
import Taro from "@tarojs/taro";
import dayjs from "dayjs";
import { View, Text, Image, ScrollView } from "@tarojs/components";
import { calculateDistance } from "@/utils";
import { useUserInfo } from "@/store/userStore";
import * as LoginService from "@/services/loginService";
import img from "@/config/images";
import { navto } from "../../utils/helper";
import styles from "./index.module.scss";
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,
};
});
}
export default 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={styles["detail-page-content-organizer-recommend-games"]}>
{/* orgnizer title */}
<View className={styles["organizer-title"]}>
<Text></Text>
</View>
{/* organizer avatar and name */}
<View className={styles["organizer-avatar-name"]}>
<Image
className={styles["organizer-avatar-name-avatar"]}
src={avatar_url}
mode="aspectFill"
onClick={handleViewUserInfo.bind(null, id)}
/>
<View className={styles["organizer-avatar-name-message"]}>
<Text className={styles["organizer-avatar-name-message-name"]}>
{nickname}
</Text>
<View className={styles["organizer-avatar-name-message-stats"]}>
<Text> {hosted_games_count} </Text>
<View
className={
styles["organizer-avatar-name-message-stats-separator"]
}
/>
<Text>NTRP {ntrp_level || "初学者"}</Text>
</View>
</View>
<View className={styles["organizer-actions"]}>
{my_id === id ? (
""
) : (
<View
className={styles["organizer-actions-follow"]}
onClick={toggleFollow.bind(null, is_following)}
>
{is_following ? (
<Text className={styles["organizer-actions-follow-text"]}>
</Text>
) : (
<>
<Image
className={styles["organizer-actions-follow-icon"]}
src={img.ICON_DETAIL_APPLICATION_ADD}
/>
<Text className={styles["organizer-actions-follow-text"]}>
</Text>
</>
)}
</View>
)}
<View
className={styles["organizer-actions-comment"]}
onClick={() => handleAddComment()}
>
<Image
className={styles["organizer-actions-comment-icon"]}
src={img.ICON_DETAIL_COMMENT}
/>
</View>
</View>
</View>
{/* recommend games by organizer */}
{recommendGames.length > 0 && (
<View className={styles["organizer-recommend-games"]}>
<View
className={styles["organizer-recommend-games-title"]}
onClick={handleViewUserInfo.bind(null, id)}
>
<Text>TA的更多活动</Text>
<Image
className={styles["organizer-recommend-games-title-arrow"]}
src={img.ICON_DETAIL_ARROW_RIGHT}
/>
</View>
<ScrollView className={styles["recommend-games-list"]} scrollX>
<View className={styles["recommend-games-list-content"]}>
{recommendGames.map((game, index) => (
<View
key={index}
className={styles["recommend-games-list-item"]}
onClick={handleViewGame.bind(null, game.id)}
>
{/* game title */}
<View className={styles["recommend-games-list-item-title"]}>
<Text>{game.title}</Text>
<Image
className={
styles["recommend-games-list-item-title-arrow"]
}
src={img.ICON_DETAIL_ARROW_RIGHT}
/>
</View>
{/* game time and range */}
<View
className={styles["recommend-games-list-item-time-range"]}
>
<Text>{game.time}</Text>
<Text>{game.timeLength}</Text>
</View>
{/* game location、vunue、distance */}
<View
className={
styles[
"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={styles["recommend-games-list-item-addon"]}>
<Image
className={
styles["recommend-games-list-item-addon-avatar"]
}
mode="aspectFill"
src={game.avatar}
onClick={(e) => {
e.stopPropagation();
handleViewUserInfo(id);
}}
/>
<View
className={
styles["recommend-games-list-item-addon-message"]
}
>
<View
className={
styles[
"recommend-games-list-item-addon-message-applications"
]
}
>
<Text>
{game.checkedApplications}/
{game.applications}
</Text>
</View>
<View
className={
styles[
"recommend-games-list-item-addon-message-level-requirements"
]
}
>
<Text>{game.levelRequirements}</Text>
</View>
<View
className={
styles[
"recommend-games-list-item-addon-message-play-type"
]
}
>
<Text>{game.playType}</Text>
</View>
</View>
</View>
</View>
))}
</View>
</ScrollView>
</View>
)}
</View>
);
}

View File

@@ -0,0 +1,132 @@
.detail-page-content-participants {
padding: 24px 15px 0;
box-sizing: border-box;
.participants-title {
display: flex;
padding-bottom: 6px;
align-items: center;
overflow: hidden;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.participants-list {
padding: 10px 0 0;
height: 162px;
display: flex;
flex-direction: row;
gap: 8px;
&-application {
display: flex;
width: 108px;
height: 162px;
padding: 16px 12px 10px 12px;
box-sizing: border-box;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 8px;
align-self: stretch;
border-radius: 20px;
border: 1px dashed rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.16);
flex: 0 0 auto;
&-icon {
width: 28px;
height: 28px;
}
&-text {
color: rgba(255, 255, 255, 0.6);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
&-scroll {
flex: 0 0 auto;
width: calc(100% - 116px);
&-content {
display: flex;
flex-direction: row;
gap: 8px;
height: 162px;
flex-wrap: nowrap;
.participants-list-item {
display: flex;
width: 108px;
padding: 16px 4px 10px 4px;
box-sizing: border-box;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 4px;
border-radius: 20px;
border: 0.5px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.16);
flex: 0 0 auto;
.participants-list-item-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
overflow: hidden;
}
&-name {
width: 100%;
color: rgba(255, 255, 255, 0.85);
text-align: center;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 13px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 184.615% */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&-level {
color: rgba(255, 255, 255, 0.45);
text-align: center;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 166.667% */
}
&-role {
color: #fff;
text-align: center;
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 166.667% */
}
}
}
}
}
}

View File

@@ -0,0 +1,106 @@
import { View, Text, Image, ScrollView } from "@tarojs/components";
import dayjs from "dayjs";
import img from "@/config/images";
import styles from "./index.module.scss";
// 参与者
export default function Participants(props) {
const { detail = {}, handleJoinGame, handleViewUserInfo } = props;
const participants = detail.participants || [];
const {
participant_count,
max_participants,
user_action_status = {},
start_time,
} = 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 leftCount = max_participants - participant_count;
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={() => {
handleJoinGame();
}}
>
<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 className={styles["participants-list-scroll"]} 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,
id: participant_user_id,
},
} = participant;
const role = is_organizer ? "组织者" : "参与者";
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"]}>
{level || "未知"}
</Text>
<Text className={styles["participants-list-item-role"]}>
{role}
</Text>
</View>
);
})}
</View>
</ScrollView>
</View>
) : (
""
)}
</View>
);
}

View File

@@ -119,80 +119,3 @@
}
}
}
.posterContainer {
background: linear-gradient(180deg, #fff 0%, #fafafa 100%), #fff;
padding: 20px;
}
.posterWrap {
border-radius: 19.067px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: linear-gradient(180deg, #bfffef 0%, #f2fffc 100%), #fff;
box-shadow: 0 6.933px 55.467px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
box-sizing: border-box;
}
.sharePoster {
margin-top: 40px;
display: flex;
align-items: center;
justify-content: space-around;
.shareItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(0, 0, 0, 0.85);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: normal;
background-color: #fff;
border: none;
padding: 0;
margin: 0;
line-height: normal;
font-size: inherit;
color: inherit;
&:after {
border: none;
background: transparent;
}
.icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.1);
&.wechatIcon {
background-color: #07c160;
}
.download {
width: 28px;
height: 28px;
}
.wechat {
width: 36px;
height: 30px;
}
.timeline {
width: 32px;
height: 32px;
}
}
}
}

View File

@@ -0,0 +1,165 @@
import { forwardRef, useState, useEffect, useImperativeHandle } from "react";
import { View, Button, Image, Text } from "@tarojs/components";
import Taro, { useShareAppMessage } from "@tarojs/taro";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import classnames from "classnames";
import { generateShareImage } from "@/utils";
import DetailService from "@/services/detailService";
import { CommonPopup } from "@/components";
import DownloadIcon from "@/static/detail/download_icon.svg";
import WechatLogo from "@/static/detail/wechat_icon.svg";
// import WechatTimeline from "@/static/detail/wechat_timeline.svg";
import LinkIcon from "@/static/detail/link.svg";
import CrossIcon from "@/static/detail/cross.svg";
import { genNTRPRequirementText, navto } from "../../utils/helper";
import { DayOfWeekMap } from "../../config";
import styles from "./index.module.scss";
dayjs.locale("zh-cn");
// 分享弹窗
export default forwardRef(({ id, from, detail, userInfo }, ref) => {
const [visible, setVisible] = useState(false);
const [publishFlag, setPublishFlag] = useState(false);
// const posterRef = useRef();
const { max_participants, participant_count } = detail || {};
useEffect(() => {
if (id) {
changeMessageType();
}
}, [id]);
async function changeMessageType() {
try {
const res = await DetailService.getActivityId({
business_id: id,
business_type: "game",
is_private: false,
});
if (res.code === 0) {
Taro.updateShareMenu({
withShareTicket: false, // 是否需要返回 shareTicket
isUpdatableMessage: true, // 是否是动态消息(需要服务端配置过模版)
activityId: res.data.activity_id, // 动态消息的活动 id
});
}
} catch (e) {
Taro.showToast({ title: e.message, icon: "none" });
}
}
useImperativeHandle(ref, () => ({
show: (publish_flag = false) => {
setPublishFlag(publish_flag);
setVisible(true);
},
}));
useShareAppMessage(async (res) => {
const {
play_type,
skill_level_max,
skill_level_min,
start_time,
end_time,
location_name,
venue_image_list,
} = detail || {};
const startTime = dayjs(start_time);
const endTime = dayjs(end_time);
const dayofWeek = DayOfWeekMap.get(startTime.day());
const gameLength = `${endTime.diff(startTime, "hour")}小时`;
const url = await generateShareImage({
userAvatar: userInfo.avatar_url,
userNickname: userInfo.nickname,
gameType: play_type,
skillLevel: `NTRP ${genNTRPRequirementText(
skill_level_min,
skill_level_max
)}`,
gameDate: `${startTime.format("M月D日")} (${dayofWeek})`,
gameTime: `${startTime.format("ah")}${gameLength}`,
venueName: location_name,
venueImages: venue_image_list ? venue_image_list.map((c) => c.url) : [],
});
// console.log(res, "res");
return {
title: detail.title,
imageUrl: url || "https://img.yzcdn.cn/vant/cat.jpeg",
path: `/game_pages/detail/index?id=${id}&from=share`,
};
});
async function handlePost() {
navto(`/game_pages/sharePoster/index?id=${detail.id}`);
setVisible(false);
}
function onClose() {
setVisible(false);
setPublishFlag(false);
}
return (
<>
<CommonPopup
title="分享至"
visible={visible}
onClose={onClose}
showHeader={false}
hideFooter
enableDragToClose={false}
style={{ minHeight: "100px" }}
zIndex={1000}
>
<View className={styles.shareContainer}>
<View catchMove className={styles.title}>
{publishFlag ? (
<Text className={styles.publishText}> 🎉</Text>
) : (
<Text></Text>
)}
<View className={styles.closeIconWrap} onClick={onClose}>
<Image className={styles.closeIcon} src={CrossIcon} />
</View>
</View>
{publishFlag && (
<View className={styles.shareTip}>
<Text>
<Text className={styles.specialCount}>
{" "}
{max_participants - participant_count}{" "}
</Text>
</Text>
</View>
)}
<View className={styles.shareItems}>
<Button className={styles.button} openType="share">
<View className={classnames(styles.icon, styles.wechatIcon)}>
<Image className={styles.wechat} src={WechatLogo} />
</View>
<Text></Text>
</Button>
<Button className={styles.button} onClick={handlePost}>
<View className={styles.icon}>
<Image className={styles.download} src={DownloadIcon} />
</View>
<Text></Text>
</Button>
<Button className={styles.button}>
<View className={styles.icon}>
<Image className={styles.linkIcon} src={LinkIcon} />
</View>
<Text></Text>
</Button>
</View>
</View>
</CommonPopup>
{/* <PosterPopup ref={posterRef} id={detail.id} /> */}
</>
);
});

View File

@@ -0,0 +1,124 @@
.sticky-bottom-bar {
position: sticky;
bottom: 0;
padding: 10px 10px 32px;
box-sizing: border-box;
width: 100%;
height: 92px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
&-share-and-comment {
display: flex;
align-items: center;
height: 52px;
width: 120px;
box-sizing: border-box;
padding: 2px 20px;
justify-content: center;
gap: 16px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #fff;
.sticky-bottom-bar-share {
display: flex;
align-items: center;
flex-direction: column;
gap: 4px;
&-icon {
width: 16px;
height: 16px;
}
&-text {
color: rgba(0, 0, 0, 0.85);
font-size: 10px;
font-style: normal;
font-weight: 500;
line-height: 16px; /* 160% */
}
}
&-separator {
width: 1px;
height: 24px;
background: rgba(0, 0, 0, 0.1);
}
.sticky-bottom-bar-comment {
display: flex;
align-items: center;
flex-direction: column;
gap: 4px;
&-icon {
width: 16px;
height: 16px;
}
&-text {
color: rgba(0, 0, 0, 0.85);
font-size: 10px;
font-style: normal;
font-weight: 500;
line-height: 16px; /* 160% */
}
}
}
.detail-main-action {
display: flex;
align-items: center;
height: 52px;
width: auto;
// padding: 2px 6px;
box-sizing: border-box;
justify-content: center;
// gap: 12px;
flex: 1 0 0;
border-radius: 16px;
// border: 1px solid rgba(0, 0, 0, 0.06);
background: #fff;
overflow: hidden;
&.disabled {
background-color: #b4b4b4;
color: rgba(60, 60, 67, 0.6);
pointer-events: none;
}
.sticky-bottom-bar-join-game {
margin-left: auto;
// width: 151px;
display: flex;
align-items: center;
justify-content: center;
flex: 1;
&-price {
font-family: "PoetsenOne";
font-size: 28px;
font-weight: 400;
line-height: 24px; /* 114.286% */
letter-spacing: -0.56px;
color: #000;
}
}
.game_manage {
width: 100px;
margin-left: auto;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
pointer-events: all;
}
}
}

View File

@@ -0,0 +1,253 @@
import { useEffect, useRef, useState } from "react";
import Taro from "@tarojs/taro";
import classnames from "classnames";
import dayjs from "dayjs";
import { Text, View, Image } from "@tarojs/components";
import OrderService from "@/services/orderService";
import { EvaluateCallback, EvaluateScene } from "@/store/evaluateStore";
import { MATCH_STATUS, IsSubstituteSupported } from "@/services/detailService";
import { GameManagePopup, NTRPEvaluatePopup } from "@/components";
import img from "@/config/images";
import { toast, navto } from "../../utils/helper";
import styles from "./index.module.scss";
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;
}
// 底部操作栏
export default function StickyButton(props) {
const {
handleShare,
handleJoinGame,
detail,
onStatusChange,
handleAddComment,
getCommentCount,
} = props;
const [commentCount, setCommentCount] = useState(0);
const ntrpRef = useRef<{
show: (evaluateCallback: EvaluateCallback) => void;
}>({ show: () => {} });
const {
id,
price,
user_action_status,
match_status,
start_time,
end_time,
is_organizer,
} = detail || {};
const gameManageRef = useRef();
function handleSelfEvaluate() {
ntrpRef?.current?.show({
type: EvaluateScene.detail,
next: (flag) => {
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();
},
});
}
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: () => <Text>¥{displayPrice} </Text>,
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={styles["sticky-bottom-bar"]}>
<View className={styles["sticky-bottom-bar-share-and-comment"]}>
<View
className={styles["sticky-bottom-bar-share"]}
onClick={() => handleShare()}
>
<Image
className={styles["sticky-bottom-bar-share-icon"]}
src={img.ICON_DETAIL_SHARE}
/>
<Text className={styles["sticky-bottom-bar-share-text"]}></Text>
</View>
<View
className={styles["sticky-bottom-bar-share-and-comment-separator"]}
/>
<View
className={styles["sticky-bottom-bar-comment"]}
onClick={() => {
// Taro.showToast({ title: "To be continued", icon: "none" });
handleAddComment();
}}
>
<Image
className={styles["sticky-bottom-bar-comment-icon"]}
src={img.ICON_DETAIL_COMMENT_DARK}
/>
<Text className={styles["sticky-bottom-bar-comment-text"]}>
{commentCount > 0 ? commentCount : "评论"}
</Text>
</View>
</View>
<View
className={classnames(
styles["detail-main-action"],
available ? "" : styles.disabled
)}
>
<View
style={is_organizer ? {} : { margin: "auto" }}
className={styles["sticky-bottom-bar-join-game"]}
onClick={action}
>
<ActionText />
</View>
{is_organizer && (
<View
className={styles.game_manage}
onClick={() => {
gameManageRef.current.show(detail, onStatusChange);
}}
>
</View>
)}
</View>
</View>
<GameManagePopup ref={gameManageRef} />
<NTRPEvaluatePopup type={EvaluateScene.detail} ref={ntrpRef} showGuide />
</>
);
}

View File

@@ -0,0 +1,55 @@
.detail-page-content-supplemental-notes {
padding: 24px 15px 0;
.supplemental-notes-title {
overflow: hidden;
padding-bottom: 7px;
height: 24px;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.supplemental-notes-content {
padding: 12px 0 0;
display: flex;
flex-direction: column;
gap: 6px;
&-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
&-tag {
overflow: hidden;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 160% */
}
}
&-text {
overflow: hidden;
color: rgba(255, 255, 255, 0.65);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 160% */
}
}
}

View File

@@ -0,0 +1,33 @@
import { Text, View } from "@tarojs/components";
import styles from "./index.module.scss";
import { insertDotInTags } from "../../utils/helper";
export default function SupplementalNotes(props) {
const {
detail: { description, description_tag },
} = props;
return (
<View className={styles["detail-page-content-supplemental-notes"]}>
<View className={styles["supplemental-notes-title"]}>
<Text></Text>
</View>
<View className={styles["supplemental-notes-content"]}>
{/* supplemental notes tags */}
<View className={styles["supplemental-notes-content-tags"]}>
{insertDotInTags(description_tag || []).map((tag, index) => (
<View
key={index}
className={styles["supplemental-notes-content-tags-tag"]}
>
<Text>{tag}</Text>
</View>
))}
</View>
{/* supplemental notes content */}
<View className={styles["supplemental-notes-content-text"]}>
<Text>{description}</Text>
</View>
</View>
</View>
);
}

View File

@@ -0,0 +1,115 @@
.detail-page-content-venue {
padding: 24px 15px 0;
box-sizing: border-box;
.venue-detail-title {
display: flex;
height: 31px;
align-items: center;
justify-content: flex-start;
gap: 8px;
padding-bottom: 6px;
color: #fff;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
.venue-reserve-status {
display: inline-flex;
justify-content: flex-start;
align-items: center;
gap: 4px;
.venue-reserve-screenshot {
width: 16px;
height: 16px;
}
}
}
.venue-detail-content {
padding: 16px 0 0;
box-sizing: border-box;
&-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
&-tag {
overflow: hidden;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 160% */
}
}
&-remarks {
overflow: hidden;
color: rgba(255, 255, 255, 0.65);
// font-feature-settings: 'liga' off, 'clig' off;
// text-overflow: ellipsis;
// white-space: nowrap;
font-family: "PingFang SC";
font-size: 15px;
font-style: normal;
font-weight: 400;
line-height: 24px; /* 160% */
}
}
.venue-screenshot-title {
display: flex;
padding: 18px 20px 10px 20px;
align-items: center;
align-self: stretch;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.23px;
}
.venue-screenshot-scroll-view {
max-height: calc(100vh - 260px);
overflow-y: auto;
padding-bottom: 40px;
.venue-screenshot-image-list {
width: 100%;
padding: 0 16px;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px 10px;
.venue-screenshot-image-item {
aspect-ratio: 1/1;
border-radius: 9px;
border: 1px solid rgba(0, 0, 0, 0.12);
box-sizing: border-box;
background: rgba(0, 0, 0, 0.06);
margin: 0;
position: relative;
.venue-screenshot-image-item-image {
width: 100%;
height: 100%;
border-radius: 9px;
margin: 0;
object-fit: cover;
}
}
}
}
}

View File

@@ -0,0 +1,105 @@
import { useState } from "react";
import { View, Text, Image, ScrollView } from "@tarojs/components";
import Taro from "@tarojs/taro";
import { CommonPopup } from "@/components";
import img from "@/config/images";
import styles from "./index.module.scss";
import { insertDotInTags } from "../../utils/helper";
// 场馆信息
export default 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={styles["detail-page-content-venue"]}>
{/* venue detail title and venue ordered status */}
<View className={styles["venue-detail-title"]}>
<Text></Text>
{venue_image_list?.length > 0 ? (
<>
<Text>·</Text>
<View
className={styles["venue-reserve-status"]}
onClick={showScreenShot}
>
<Text></Text>
<Image
className={styles["venue-reserve-screenshot"]}
src={img.ICON_DETAIL_ARROW_RIGHT}
/>
</View>
</>
) : (
""
)}
</View>
{/* venue detail content */}
<View className={styles["venue-detail-content"]}>
{/* venue detail tags */}
<View className={styles["venue-detail-content-tags"]}>
{insertDotInTags(venue_description_tag).map((tag, index) => (
<View
key={index}
className={styles["venue-detail-content-tags-tag"]}
>
<Text>{tag}</Text>
</View>
))}
</View>
{/* venue remarks */}
<View className={styles["venue-detail-content-remarks"]}>
<Text>{venue_description}</Text>
</View>
</View>
<CommonPopup
visible={visible}
onClose={onClose}
round
hideFooter
position="bottom"
zIndex={1001}
>
<View className={styles["venue-screenshot-title"]}></View>
<ScrollView scrollY className={styles["venue-screenshot-scroll-view"]}>
<View className={styles["venue-screenshot-image-list"]}>
{venue_image_list?.length > 0 &&
venue_image_list.map((item) => {
return (
<View
className={styles["venue-screenshot-image-item"]}
onClick={previewImage.bind(null, item.url)}
>
<Image
className={styles["venue-screenshot-image-item-image"]}
mode="aspectFill"
src={item.url}
/>
</View>
);
})}
</View>
</ScrollView>
</CommonPopup>
</View>
);
}

View File

@@ -0,0 +1,111 @@
.detail-page {
width: 100%;
height: 100%;
// padding-bottom: env(safe-area-inset-bottom);
.custom-navbar {
height: 56px; /* 通常与原生导航栏高度一致 */
display: flex;
align-items: center;
justify-content: center;
// background-color: #fff;
color: #000;
padding-top: 44px; /* 适配状态栏 */
position: sticky;
top: 0;
z-index: 100;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.2);
}
.detail-navigator {
height: 30px;
width: 80px;
border-radius: 15px;
position: absolute;
left: 12px;
border: 1px solid #888;
box-sizing: border-box;
color: #fff;
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.1);
.detail-navigator-back {
border-right: 1px solid #444;
}
.detail-navigator-back,
.detail-navigator-icon {
height: 20px;
width: 50%;
display: flex;
justify-content: center;
& > .detail-navigator-back-icon {
width: 20px;
height: 20px;
color: #fff;
}
& > .detail-navigator-logo-icon {
width: 20px;
height: 20px;
color: #fff;
}
}
}
.detail-page-bg {
width: 100%;
height: 100%;
position: fixed;
left: 0;
top: 0;
background-size: cover;
// filter: blur(40px);
// transform: scale(1.5);
z-index: -2;
// width: calc(100% + 20px);
// height: calc(100% + 20px);
// margin: -10px;
width: 100vw;
height: 100vh;
&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: -1;
background: linear-gradient(
180deg,
rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0.4) 100%
);
backdrop-filter: blur(100px);
}
}
.detail-page-content {
&-title {
padding: 20px 20px 0;
box-sizing: border-box;
&-text {
overflow: hidden;
color: #fff;
font-feature-settings: "liga" off, "clig" off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 22px;
font-style: normal;
font-weight: 600;
line-height: 32px; /* 145.455% */
}
}
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,56 @@
import Taro, {
useLoad,
} from "@tarojs/taro";
export function navto(url) {
Taro.navigateTo({
url: url,
});
}
export function toast(message) {
Taro.showToast({ title: message, icon: "none" });
}
// 将·作为连接符插入到标签文本之间
export function insertDotInTags(tags: string[]) {
if (!tags) return [];
return tags.join("-·-").split("-");
}
export const useSceneRedirect = (defaultPage: string) => {
useLoad((options) => {
if (options.scene) {
try {
const decoded = decodeURIComponent(options.scene || "");
const params: Record<string, string> = {};
decoded.split("&").forEach((pair) => {
const [key, value] = pair.split("=");
if (key) params[key] = value ? decodeURIComponent(value) : "";
});
// 拼接成 URL query
const query = Object.entries(params)
.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)
.join("&");
Taro.redirectTo({
url: query ? `/${defaultPage}?${query}` : `/${defaultPage}`,
});
} catch (err) {
console.error("scene 解析失败:", err);
}
}
});
};
export function genNTRPRequirementText(min, max) {
if (min && max && min !== max) {
return `${min} - ${max} 之间`;
} else if (max === "1") {
return "无要求";
} else if (max) {
return `${max} 以上`;
}
return "-";
}

View File

@@ -0,0 +1,6 @@
export default definePageConfig({
navigationBarTitleText: '生成分享图',
navigationStyle: 'custom',
enableShareAppMessage: true,
enableShareTimeline: true,
})

View File

@@ -0,0 +1,101 @@
.navbar {
box-shadow: none;
}
.posterContainer {
width: 100vw;
height: 100vh;
background: linear-gradient(180deg, #fff 0%, #fafafa 100%), #fff;
padding: 100px 20px 40px;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
// gap: 20px;
}
.posterWrap {
width: 100%;
border-radius: 19.067px;
// border: 1px solid rgba(0, 0, 0, 0.06);
// background: linear-gradient(180deg, #bfffef 0%, #f2fffc 100%), #fff;
// box-shadow: 0 6.933px 55.467px 0 rgba(0, 0, 0, 0.1);
// overflow: hidden;
box-sizing: border-box;
.imageContainer {
width: 100%;
padding: 0 20px;
box-sizing: border-box;
.poster {
border-radius: 12px;
box-shadow: 0 6.933px 55.467px 0 rgba(0, 0, 0, 0.1);
width: 100%;
}
}
}
.sharePoster {
width: 100%;
margin-top: 40px;
display: flex;
align-items: center;
justify-content: space-around;
.shareItem {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: 12px;
color: rgba(0, 0, 0, 0.85);
font-feature-settings: "liga" off, "clig" off;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: normal;
background-color: #fff;
border: none;
padding: 0;
margin: 0;
line-height: normal;
font-size: inherit;
color: inherit;
&:after {
border: none;
background: transparent;
}
.icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: #fff;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.06);
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.1);
&.wechatIcon {
background-color: #07c160;
}
.download {
width: 28px;
height: 28px;
}
.wechat {
width: 36px;
height: 30px;
}
.timeline {
width: 32px;
height: 32px;
}
}
}
}

View File

@@ -0,0 +1,134 @@
// import React from "react";
import { useState, useEffect } from "react";
import { View, Image, Text, Button } from "@tarojs/components";
import Taro, { useRouter } from "@tarojs/taro";
import classnames from "classnames";
import dayjs from "dayjs";
import { generatePosterImage, base64ToTempFilePath, delay } from "@/utils";
import { withAuth } from "@/components";
import GeneralNavbar from "@/components/GeneralNavbar";
import DetailService from "@/services/detailService";
import DownloadIcon from "@/static/detail/download_icon.svg";
import WechatLogo from "@/static/detail/wechat_icon.svg";
import WechatTimeline from "@/static/detail/wechat_timeline.svg";
import { useUserActions } from "@/store/userStore";
import { DayOfWeekMap } from "../detail/config";
import { genNTRPRequirementText } from "@/game_pages/detail/utils/helper";
import styles from "./index.module.scss";
function SharePoster(props) {
const [url, setUrl] = useState("");
const { fetchUserInfo } = useUserActions();
const { id } = useRouter().params;
useEffect(() => {
fetchDetail();
}, []);
async function fetchDetail() {
const res = await DetailService.getDetail(Number(id));
handleGenPoster(res.data);
}
async function handleGenPoster(detail) {
const {
id,
play_type,
skill_level_max,
skill_level_min,
start_time,
end_time,
location_name,
image_list,
title,
} = detail || {};
const userInfo = await fetchUserInfo();
const { avatar_url, nickname } = userInfo;
const startTime = dayjs(start_time);
const endTime = dayjs(end_time);
const dayofWeek = DayOfWeekMap.get(startTime.day());
const gameLength = `${endTime.diff(startTime, "hour")}小时`;
Taro.showLoading({ title: "生成中..." });
const qrCodeUrlRes = await DetailService.getQrCodeUrl({
page: "game_pages/detail/index",
scene: `id=${id}`,
});
const qrCodeUrl = await base64ToTempFilePath(
qrCodeUrlRes.data.qr_code_base64
);
await delay(100);
const url = await generatePosterImage({
playType: play_type,
ntrp: `NTRP ${genNTRPRequirementText(skill_level_min, skill_level_max)}`,
mainCoursal:
image_list[0] ||
"https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/0621b8cf-f7d6-43ad-b852-7dc39f29a782.png",
nickname,
avatarUrl: avatar_url,
title,
locationName: location_name,
date: `${startTime.format("M月D日")} (${dayofWeek})`,
time: `${startTime.format("ah")}${gameLength}`,
qrCodeUrl,
});
Taro.hideLoading();
setUrl(url);
}
function handleShare() {
Taro.showShareImageMenu({
path: url,
});
}
return (
<>
<GeneralNavbar
title="生成分享图"
backgroundColor="transparent"
className={styles.navbar}
/>
{url && (
<View className={styles.posterContainer}>
<View className={styles.posterWrap}>
<View className={styles.imageContainer}>
<Image className={styles.poster} src={url} mode="widthFix" />
</View>
</View>
<View className={styles.sharePoster}>
<Button
className={styles.shareItem}
plain={true}
onClick={handleShare}
>
<View className={styles.icon}>
<Image className={styles.download} src={DownloadIcon} />
</View>
<Text></Text>
</Button>
<Button
className={styles.shareItem}
plain={true}
onClick={handleShare}
>
<View className={classnames(styles.icon, styles.wechatIcon)}>
<Image className={styles.wechat} src={WechatLogo} />
</View>
<Text></Text>
</Button>
<Button
className={styles.shareItem}
plain={true}
onClick={handleShare}
>
<View className={styles.icon}>
<Image className={styles.timeline} src={WechatTimeline} />
</View>
<Text></Text>
</Button>
</View>
</View>
)}
</>
);
}
export default withAuth(SharePoster);

View File

@@ -16,17 +16,13 @@ import { withAuth, RefundPopup } from "@/components";
import { payOrder, generateOrderActions } from "@/utils";
import emptyContent from "@/static/emptyStatus/publish-empty.png";
import CustomerIcon from "@/static/order/customer.svg";
import { insertDotInTags } from "@/game_pages/detail/utils/helper";
import styles from "./index.module.scss";
dayjs.locale("zh-cn");
const PAGESIZE = 100;
// 将·作为连接符插入到标签文本之间
function insertDotInTags(tags: string[]) {
return tags.join("-·-").split("-");
}
const diffDayMap = new Map([
[0, "今天"],
[1, "明天"],

View File

@@ -7,7 +7,7 @@ import {
export interface UserState {
user: UserInfoType | {};
fetchUserInfo: () => Promise<void>;
fetchUserInfo: () => Promise<UserInfoType | undefined>;
updateUserInfo: (userInfo: Partial<UserInfoType>) => void;
}
@@ -16,9 +16,9 @@ export const useUser = create<UserState>()((set) => ({
fetchUserInfo: async () => {
try {
const res = await fetchUserProfile();
console.log(res);
set({ user: res.data });
} catch {}
return res.data
} catch { }
},
updateUserInfo: async (userInfo: Partial<UserInfoType>) => {
const res = await updateUserProfile(userInfo);