diff --git a/.gitignore b/.gitignore index fdc3298..d0a811b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ node_modules/ .DS_Store .swc src/config/env.ts +.vscode +*.http diff --git a/src/app.config.ts b/src/app.config.ts index 17fe620..5ae88f1 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -1,5 +1,6 @@ export default defineAppConfig({ pages: [ + "home_pages/index", //中转页 "login_pages/index/index", "login_pages/verification/index", @@ -21,7 +22,17 @@ export default defineAppConfig({ root: "user_pages", pages: [ "myself/index", // 个人中心 - "edit/index", // 个人中心 + "edit/index", // 编辑个人中心 + "other/index", // 他人个人主页 + "follow/index", // 球友关注页 + "wallet/index", // 钱包页 + "queryTransactions/index", // 查询交易 + "downloadBill/index", // 下载账单 + "downloadBillRecords/index", // 下载账单记录 + "billDetail/index", // 账单详情 + "setTransactionPassword/index", // 设置交易密码 + "validPhone/index", // 验证手机号 + "withdrawal/index", // 提现 ], }, // { diff --git a/src/app.ts b/src/app.ts index c4bcd9a..565dd5f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,6 @@ import { Component, ReactNode } from "react"; import "./nutui-theme.scss"; import "./app.scss"; import "qweather-icons/font/qweather-icons.css"; -import { useDictionaryStore } from "./store/dictionaryStore"; import { useGlobalStore } from "./store/global"; interface AppProps { @@ -17,7 +16,6 @@ class App extends Component { componentDidMount() { // 初始化字典数据 - this.initDictionaryData(); this.getNavBarHeight(); // this.getLocation() } @@ -26,15 +24,7 @@ class App extends Component { componentDidHide() {} - // 初始化字典数据 - private async initDictionaryData() { - try { - const { fetchDictionary } = useDictionaryStore.getState(); - await fetchDictionary(); - } catch (error) { - console.error("初始化字典数据失败:", error); - } - } + // 获取导航高度 getNavBarHeight = () => { diff --git a/src/components/Auth/index.tsx b/src/components/Auth/index.tsx index 9044f41..6cbf848 100644 --- a/src/components/Auth/index.tsx +++ b/src/components/Auth/index.tsx @@ -1,26 +1,9 @@ import React, { useEffect, useState } from "react"; import { View } from "@tarojs/components"; import Taro from "@tarojs/taro"; +import { getCurrentFullPath } from '@/utils'; import { check_login_status } from "@/services/loginService"; -export function getCurrentFullPath(): string { - const pages = Taro.getCurrentPages(); - const currentPage = pages.at(-1); - - if (currentPage) { - console.log(currentPage, "currentPage get"); - const route = currentPage.route; - const options = currentPage.options || {}; - - const query = Object.keys(options) - .map((key) => `${key}=${options[key]}`) - .join("&"); - - return query ? `/${route}?${query}` : `/${route}`; - } - return ""; -} - export default function withAuth

( WrappedComponent: React.ComponentType

, ) { diff --git a/src/components/Comments/index.module.scss b/src/components/Comments/index.module.scss new file mode 100644 index 0000000..62fc1ff --- /dev/null +++ b/src/components/Comments/index.module.scss @@ -0,0 +1,214 @@ +.container { + .header { + padding: 20px 20px 0; + + .commentCount { + border-bottom: 1px solid rgba(255, 255, 255, 0.10); + padding-bottom: 8px; + 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; + } + + .addComment { + height: 40px; + padding: 0 12px; + margin: 12px 0; + display: flex; + justify-content: flex-start; + align-items: center; + gap: 6px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.20); + + .addCommentImage { + width: 18px; + height: 18px; + } + + .addCommentText { + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + } + } + .list { + padding: 12px 20px; + + & > .commentItem { + padding-bottom: 12px; + } + + .commentItem { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + + .avatar { + border-radius: 50%; + } + + .contents { + flex: 1; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 12px; + + .main { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 4px; + + .publisherInfo { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + + .nickname { + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 18px; + } + + .role { + padding: 0 4px; + height: 18px; + border-radius: 3px; + background: rgba(255, 255, 255, 0.10); + color: rgba(255, 255, 255, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 10px; + font-style: normal; + font-weight: 500; + line-height: 18px; + } + } + + .content { + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + + .atPeople { + color: rgba(255, 255, 255, 0.45); + } + } + + .addons { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + + .time, .location, .reply, .delete { + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + line-height: 18px; + + &.time, &.location { + color: rgba(255, 255, 255, 0.45); + font-weight: 400; + } + + &.reply, &.delete { + color: rgba(255, 255, 255, 0.85); + font-weight: 600; + } + } + } + } + + .viewMore { + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + } + } + } +} + +.inputContainer { + height: 36px; + padding: 12px 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + + .inputWrapper { + flex: 1; + display: flex; + justify-content: flex-start; + align-items: center; + + & > .input { + width: 100%; + height: 36px; + } + } + + .sendIcon { + width: 36px; + height: 36px; + border-radius: 50%; + background-color: #000; + border: 0.5px solid rgba(0, 0, 0, 0.06); + + .sendImage { + width: 36px; + height: 36px; + } + } +} + +.empty { + height: 40vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + + .emptyTip { + color: rgba(255, 255, 255, 0.85); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 24px; + } +} \ No newline at end of file diff --git a/src/components/Comments/index.tsx b/src/components/Comments/index.tsx new file mode 100644 index 0000000..e6706f5 --- /dev/null +++ b/src/components/Comments/index.tsx @@ -0,0 +1,423 @@ +import React, { + useState, + useEffect, + forwardRef, + useRef, + useImperativeHandle, +} from "react"; +import { View, Text, Image, Input } from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import dayjs from "dayjs"; +import CommentServices from "@/services/commentServices"; +import type { + BaseComment, + Comment, + ReplyComment, +} from "@/services/commentServices"; +import { useUserInfo } from "@/store/userStore"; +import sendImg from "@/static/detail/icon-sendup.svg"; +import addComment from "@/static/detail/icon-write.svg"; +import emptyComment from "@/static/emptyStatus/comment-empty.png"; +import CommonPopup from "../CommonPopup"; +import styles from "./index.module.scss"; + +// const PAGESIZE = 4; +const PAGESIZE = 1000; + +function toast(msg) { + Taro.showToast({ title: msg, icon: "none" }); +} + +interface CommentInputProps { + onConfirm?: ( + value: { content: string } & Partial + ) => void; +} + +// 2️⃣ 定义通过 ref 暴露出去的方法类型 +interface CommentInputRef { + show: (params?: CommentInputReplyParamsType) => void; +} + +interface CommentInputReplyParamsType { + parent_id: number; + reply_to_user_id: number; + nickname: string; +} + +const CommentInput = forwardRef(function ( + props, + ref +) { + const { onConfirm } = props; + const [visible, setVisible] = useState(false); + const [value, setValue] = useState(""); + const [params, setParams] = useState< + CommentInputReplyParamsType | undefined + >(); + + const inputDomRef = useRef(null); + + useImperativeHandle(ref, () => ({ + show: (_params: CommentInputReplyParamsType | undefined) => { + setVisible(true); + setTimeout(() => { + inputDomRef.current && inputDomRef.current?.focus(); + }, 100); + setParams(_params); + }, + })); + + function handleSend() { + if (!value) { + toast("评论内容不得为空"); + return; + } + onConfirm?.({ content: value, ...params }); + onClose(); + } + + function onClose() { + setVisible(false); + setValue(""); + inputDomRef.current && inputDomRef.current?.blur(); + } + return ( + + + + setValue(e.detail.value)} + placeholder={ + params?.reply_to_user_id ? `回复 @${params.nickname}` : "写评论" + } + focus + maxlength={100} + /> + + + + + + + ); +}); + +function isReplyComment(item: BaseComment): item is ReplyComment { + return "reply_to_user" in item; +} + +function getRelativeDay(time) { + const theTime = dayjs(time); + const isThisYear = dayjs().isSame(theTime, "year"); + const diffDay = dayjs().startOf("day").diff(theTime, "day"); + return diffDay <= 3 + ? diffDay >= 1 + ? `${diffDay}天前` + : theTime.format("HH:mm:ss") + : theTime.format(isThisYear ? "MM-DD HH:mm:ss" : "YYYY-MM-DD HH:mm:ss"); +} + +function CommentItem(props: { + level: number; + publisher_id: number; + comment: Comment | ReplyComment; + loadMore: (c: Comment) => void; + handleReply: (options: CommentInputReplyParamsType) => void; + handleDelete: (options: { parent_id: number | null; id: number }) => void; +}) { + const { + level, + publisher_id, + comment, + loadMore: handleLoadMore, + handleReply, + handleDelete, + } = props; + const currentUserInfo = useUserInfo(); + const isGamePublisher = publisher_id === comment.user.id; + const isCommentPublisher = currentUserInfo.id === comment.user.id; + return ( + + + + + + + + + {comment.user.nickname} + + {isGamePublisher && ( + + 组织者 + + )} + + + + {isReplyComment(comment) && comment.reply_to_user + ? `@${comment.reply_to_user.nickname} ` + : ""} + + {comment.content} + + + + {getRelativeDay(comment.create_time)} + + + 上海 + + + handleReply({ + parent_id: comment.parent_id || comment.id, + reply_to_user_id: comment.user.id, + nickname: comment.user.nickname, + }) + } + > + 回复 + + {isGamePublisher || isCommentPublisher} + + handleDelete({ + parent_id: comment.parent_id, + id: comment.id, + }) + } + > + 删除 + + + + {!isReplyComment(comment) && + comment.replies.map((item: ReplyComment) => ( + + ))} + {!isReplyComment(comment) && + comment.replies.length !== comment.reply_count && ( + handleLoadMore(comment)} + > + 展开更多评论 + + )} + + + ); +} + +export default forwardRef(function Comments( + props: { game_id: number; publisher_id: number }, + ref +) { + const { game_id, publisher_id } = props; + const [comments, setComments] = useState([]); + const inputRef = useRef(null); + + const commentCountUpdateRef = useRef() + + useEffect(() => { + getComments(1); + }, [game_id]); + + useImperativeHandle(ref, () => ({ + addComment: handleReply, + getCommentCount: (onUpdate) => { + commentCountUpdateRef.current = onUpdate + onUpdate(comments.length) + }, + })); + + async function getComments(page) { + if (!game_id) return; + const res = await CommentServices.getComments({ + page, + pageSize: PAGESIZE, + game_id, + }); + if (res.code === 0) { + const newComments: Comment[] = res.data.rows; + setComments((prev) => { + const res = [...prev]; + res.splice(page * PAGESIZE - 1, newComments.length, ...newComments); + commentCountUpdateRef.current?.(res.length) + return res; + }); + } + } + + async function getReplies(c: Comment) { + const { replies, id: comment_id } = c; + const page = replies.length < PAGESIZE ? 1 : replies.length / PAGESIZE + 1; + const res = await CommentServices.getReplies({ + comment_id, + page, + pageSize: PAGESIZE, + }); + if (res.code === 0) { + const newReplies = res.data.rows; + setComments((prev) => { + const newComments = [...prev]; + newComments.forEach((item) => { + if (item.id === comment_id) { + item.replies.splice( + page === 1 ? 0 : page * PAGESIZE - 1, + newReplies.length, + ...newReplies + ); + item.reply_count = res.data.count; + } + }); + commentCountUpdateRef.current?.(newComments.length) + return newComments; + }); + } + } + + function handleReply(options?: CommentInputReplyParamsType) { + inputRef.current?.show(options); + } + + function onSend({ content, parent_id, reply_to_user_id }) { + if (!parent_id) { + createComment(content); + return; + } + replyComment({ content, parent_id, reply_to_user_id }); + } + + async function createComment(val: string) { + const res = await CommentServices.createComment({ game_id, content: val }); + if (res.code === 0) { + setComments((prev) => { + commentCountUpdateRef.current?.(prev.length + 1) + return [{ ...res.data, replies: [] }, ...prev]; + }); + toast("发布成功"); + } + } + + async function replyComment({ parent_id, reply_to_user_id, content }) { + const res = await CommentServices.replyComment({ + parent_id, + reply_to_user_id, + content, + }); + if (res.code === 0) { + setComments((prev) => { + return prev.map((item) => { + if (item.id === parent_id) { + return { + ...item, + replies: [res.data, ...item.replies], + reply_count: item.reply_count + 1, + }; + } + return item; + }); + }); + toast("回复成功"); + } + } + + async function deleteComment({ parent_id, id }) { + const res = await CommentServices.deleteComment({ comment_id: id }); + if (res.code === 0) { + if (parent_id) { + setComments((prev) => { + return prev.map((item) => { + if (item.id === parent_id) { + return { + ...item, + replies: item.replies.filter( + (replyItem) => replyItem.id !== id + ), + reply_count: item.reply_count - 1, + }; + } + return item; + }); + }); + } else { + setComments((prev) => { + commentCountUpdateRef.current?.(prev.length - 1) + return prev.filter((item) => item.id !== id); + }); + } + toast("评论已删除"); + } + } + + return ( + + + + {comments.length > 0 ? `${comments.length} 条` : ""}评论 + + handleReply()}> + + 写评论 + + + {comments.length > 0 ? ( + + {comments.map((comment) => { + return ( + + ); + })} + + ) : ( + + + 快来发表第一条评论 + + )} + + + + ); +}); diff --git a/src/components/CommonPopup/index.module.scss b/src/components/CommonPopup/index.module.scss index 7210f10..d6df636 100644 --- a/src/components/CommonPopup/index.module.scss +++ b/src/components/CommonPopup/index.module.scss @@ -63,7 +63,7 @@ border: none; width: 154px; height: 44px; - border-radius: 12px; + border-radius: 12px!important; border: 0.5px solid rgba(0, 0, 0, 0.06); background: #fff; padding: 4px 10px; diff --git a/src/components/FollowUserCard/index.scss b/src/components/FollowUserCard/index.scss new file mode 100644 index 0000000..6f18048 --- /dev/null +++ b/src/components/FollowUserCard/index.scss @@ -0,0 +1,125 @@ +// 球友卡片样式 +.follow_user_card { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 20px; + background: #ffffff; + height: 56px; + margin-top: 12px; + box-sizing: border-box; + + .user_info { + display: flex; + align-items: center; + flex: 1; + gap: 12px; + + .avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + } + + .user_details { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + max-width: 200px; + + .nickname { + font-family: PingFang SC; + font-weight: 600; + font-size: 14px; + line-height: 16px; + color: rgba(0, 0, 0, 0.8); + } + + .signature { + font-family: PingFang SC; + font-weight: 400; + font-size: 12px; + line-height: 16px; + color: rgba(60, 60, 67, 0.6); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; + } + } + } + + .action_button { + display: flex; + justify-content: center; + align-items: center; + padding: 6px 16px; + border-radius: 20px; + white-space: nowrap; + + .button_text { + font-family: PingFang SC; + font-weight: 400; + font-size: 12px; + line-height: 16px; + } + + &.follow_button { + border: 0.5px solid #000000 !important; + background: transparent !important; + + .button_text { + color: #000000 !important; + } + } + + &.following_button { + border: 0.5px solid rgba(120, 120, 128, 0.12) !important; + background: transparent !important; + + .button_text { + color: rgba(0, 0, 0, 0.8) !important; + } + } + + &.mutual_button { + border: 0.5px solid rgba(120, 120, 128, 0.12) !important; + background: transparent !important; + + .button_text { + color: rgba(0, 0, 0, 0.8) !important; + } + } + + &.processing { + opacity: 0.6; + pointer-events: none; + } + } + + .delete_button { + width: 20px; + height: 20px; + margin-left: 4px; + display: flex; + justify-content: center; + align-items: center; + &::before, &::after { + content: ""; + display: block; + width: 13px; + height: 2px; + border-radius: 2px; + background: #8c8c8c; + position: absolute; + } + &::before { + transform: rotate(45deg); + } + &::after { + transform: rotate(-45deg); + } + } +} \ No newline at end of file diff --git a/src/components/FollowUserCard/index.tsx b/src/components/FollowUserCard/index.tsx new file mode 100644 index 0000000..cf4c077 --- /dev/null +++ b/src/components/FollowUserCard/index.tsx @@ -0,0 +1,144 @@ +import React, { useState } from 'react'; +import { View, Text, Image } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { FollowUser } from '@/services/followService'; +import './index.scss'; + + +// 标签页类型 +type TabType = 'mutual_follow' | 'following' | 'follower' | 'recommend'; + +interface FollowUserCardProps { + user: FollowUser; + tabKey: TabType; + onFollowChange?: (userId: number, isFollowing: boolean) => void; +} + +const FollowUserCard: React.FC = ({ user, tabKey, onFollowChange }) => { + const [isProcessing, setIsProcessing] = useState(false); + + // 防御性检查 + if (!user || !user.id) { + return null; + } + + // 处理关注操作 + const handle_follow_action = async () => { + if (isProcessing) return; + + try { + setIsProcessing(true); + + // 根据当前状态决定操作 + let new_status = false; + if (user.follow_status === 'follower' || user.follow_status === 'recommend') { + // 粉丝或推荐用户,执行关注操作 + new_status = true; + } else if (user.follow_status === 'following' || user.follow_status === 'mutual_follow') { + // 已关注或互相关注,执行取消关注操作 + new_status = false; + } + + onFollowChange?.(user.id, new_status); + } catch (error) { + console.error('关注操作失败:', error); + Taro.showToast({ + title: '操作失败', + icon: 'none' + }); + } finally { + setIsProcessing(false); + } + }; + + // 加入黑名单 + const add_to_blacklist = () => { + if (isProcessing) return; + + try { + setIsProcessing(true); + // TODO: 加入黑名单逻辑 + Taro.showToast({ + title: '不会再为您推荐该用户', + icon: 'none' + }); + } catch (error) { + console.error('删除推荐人员失败:', error); + Taro.showToast({ + title: '操作失败', + icon: 'none' + }); + } finally { + setIsProcessing(false); + } + }; + + // 处理用户点击 + const handle_user_click = () => { + Taro.navigateTo({ + url: `/user_pages/other/index?userid=${user.id}` + }); + }; + + // 获取按钮文本和样式 + const get_button_config = () => { + switch (user.follow_status) { + case 'follower': + case 'recommend': + return { + text: '关注', + className: 'follow_button' + }; + case 'following': + return { + text: '已关注', + className: 'following_button' + }; + case 'mutual_follow': + return { + text: '互相关注', + className: 'mutual_button' + }; + default: + return { + text: '关注', + className: 'follow_button' + }; + } + }; + + const button_config = get_button_config(); + + return ( + + + + + {user.nickname} + + {user.personal_profile?.replace(/\n/g, ' ') || '签名写在这里'} + + + + + + + {isProcessing ? '处理中...' : button_config.text} + + + { + tabKey === 'recommend' && ( + + ) + } + + ); +}; + +export default FollowUserCard; \ No newline at end of file diff --git a/src/components/GameManagePopup/index.module.scss b/src/components/GameManagePopup/index.module.scss new file mode 100644 index 0000000..eefab2e --- /dev/null +++ b/src/components/GameManagePopup/index.module.scss @@ -0,0 +1,112 @@ +.container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + padding-bottom: 40px; + + .button { + width: 100%; + padding: 20px 0; + display: flex; + justify-content: center; + align-items: center; + color: #000; + 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: 20px; + + &:last-child { + border-top: 8px solid #f5f5f5; + } + } +} + +.centerContainer { + overflow: hidden; + .title { + padding-top: 24px; + color: #000; + 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: 20px; + } + + .content { + padding: 24px 20px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + + .tips { + color: rgba(60, 60, 67, 0.60); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + + .cancelReason { + width: 100%; + margin-top: 8px; + padding: 8px; + border-radius: 4px; + background: #F0F0F0; + + .input { + width: 100%; + &:placeholder-shown { + color: rgba(60, 60, 67, 0.30); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 24px; + } + } + } + } + + .actions { + display: flex; + justify-content: space-between; + align-items: center; + height: 44px; + border-top: 0.5px solid #CECECE; + background: #FFF; + margin-top: 2px; + + .confirm, .cancel { + width: 50%; + height: 44px; + display: flex; + justify-content: center; + align-items: center; + color: #000; + 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: 20px; + + &.cancel { + background-color: #000; + color: #fff; + } + } + } +} \ No newline at end of file diff --git a/src/components/GameManagePopup/index.tsx b/src/components/GameManagePopup/index.tsx new file mode 100644 index 0000000..2737115 --- /dev/null +++ b/src/components/GameManagePopup/index.tsx @@ -0,0 +1,225 @@ +import React, { + useState, + forwardRef, + useImperativeHandle, + useRef, +} from "react"; +import Taro from "@tarojs/taro"; +import { View, Text, Input } from "@tarojs/components"; +import CommonPopup from "../CommonPopup"; +import styles from "./index.module.scss"; +import detailService, { MATCH_STATUS } from "@/services/detailService"; +import { useUserInfo } from "@/store/userStore"; + +const CancelPopup = forwardRef((props, ref) => { + const { detail } = props; + const [visible, setVisible] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + const onFinish = useRef(null); + const inputRef = useRef(null); + + const { current_players, participants = [], publisher_id } = detail; + const realParticipants = participants + .filter((item) => item.status === "joined") + .map((item) => item.user.id); + const hasOtherJoin = + current_players > 1 || + realParticipants.some((id) => id !== Number(publisher_id)); + // const hasOtherJoin = true; + + useImperativeHandle(ref, () => ({ + show: (onAct) => { + onFinish.current = onAct; + setVisible(true); + setTimeout(() => { + inputRef.current && inputRef.current.focus(); + }, 0); + }, + })); + + function onClose() { + setVisible(false); + setCancelReason(""); + } + + async function handleConfirm() { + if (!cancelReason) { + Taro.showToast({ title: "请输入取消原因", icon: "none" }); + return; + } + try { + await onFinish.current(cancelReason); + onClose(); + } catch (e) { + console.log(e, 1221); + } + } + + return ( + <> + + + 确定要取消活动吗? + + + {hasOtherJoin + ? "已有球友报名,取消后将为他们自动退款" + : "有100+球友正在浏览您的球局哦~"} + + {hasOtherJoin && ( + + setCancelReason(e.detail.value)} + maxlength={100} + /> + + )} + + + + 确认取消 + + + 再想想 + + + + + + ); +}); + +export default forwardRef(function GameManagePopup(props, ref) { + const [visible, setVisible] = useState(false); + const [detail, setDetail] = useState({}); + const onStatusChange = useRef(null); + const cancelRef = useRef(null); + const userInfo = useUserInfo(); + + useImperativeHandle(ref, () => ({ + show: (gameDetail, onChange) => { + onStatusChange.current = onChange; + setDetail(gameDetail); + setVisible(true); + }, + })); + + function handleEditGame() { + Taro.navigateTo({ + url: `/publish_pages/publishBall/index?gameId=${detail.id}&republish=0`, + }); + onClose(); + } + + function handleRepubGame() { + Taro.navigateTo({ + url: `/publish_pages/publishBall/index?gameId=${detail.id}&republish=1`, + }); + onClose(); + } + + async function handleCancelGame() { + cancelRef.current.show(async (result) => { + if (result) { + try { + const res = await detailService.disbandGame({ + game_id: detail.id, + settle_reason: result, + }); + if (res.code === 0) { + Taro.showToast({ title: "活动取消成功" }); + onStatusChange.current?.(true); + } + } catch (e) { + Taro.showToast({ title: e.message, icon: "error" }); + return e; + } + } + }); + onClose(); + } + + async function handleQuitGame() { + try { + const res = await detailService.organizerQuit({ + game_id: detail.id, + quit_reason: "组织者主动退出", + }); + if (res.code === 0) { + Taro.showToast({ title: "活动退出成功" }); + onStatusChange.current?.(true); + } + } catch (e) { + Taro.showToast({ title: e.message, icon: "error" }); + } finally { + onClose(); + } + } + + function onClose() { + setVisible(false); + } + + const hasJoin = (detail.participants || []) + .filter((item) => item.status === "joined") + .some((item) => item.user.id === userInfo.id); + + const finished = [MATCH_STATUS.FINISHED, MATCH_STATUS.CANCELED].includes( + detail.match_status + ); + + return ( + <> + + + + 编辑活动 + + {finished && ( + + 重新发布 + + )} + {!finished && ( + + 取消活动 + + )} + {hasJoin && ( + + 退出活动 + + )} + + 取消 + + + + + + ); +}); diff --git a/src/components/GeneralNavbar/index.module.scss b/src/components/GeneralNavbar/index.module.scss new file mode 100644 index 0000000..f1f1cdb --- /dev/null +++ b/src/components/GeneralNavbar/index.module.scss @@ -0,0 +1,49 @@ +.customNavbar { + position: fixed; + top: 0; + left: 0; + z-index: 9; + width: 100%; + background-color: #FAFAFA; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); +} + +.navbarContent { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; + padding: 0 16px; +} + +.leftSection { + display: flex; + align-items: center; + min-width: 60px; +} + +.centerSection { + flex: 1; + display: flex; + justify-content: center; + align-items: center; +} + +.rightSection { + display: flex; + align-items: center; + min-width: 60px; +} + +.title { + font-size: 18px; + font-weight: 600; + color: #333; + text-align: center; +} + +.backIcon { + width: 24px; + height: 24px; + cursor: pointer; +} diff --git a/src/components/GeneralNavbar/index.tsx b/src/components/GeneralNavbar/index.tsx new file mode 100644 index 0000000..17c6e52 --- /dev/null +++ b/src/components/GeneralNavbar/index.tsx @@ -0,0 +1,105 @@ +import React from 'react' +import { View, Text, Image } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { useGlobalState } from '@/store/global' +import styles from './index.module.scss' +import img from '@/config/images' + +interface GeneralNavbarProps { + title?: string + titleStyle?: React.CSSProperties + titleClassName?: string + leftContent?: React.ReactNode + backgroundColor?: string + showBack?: boolean + showLeft?: boolean + onBack?: () => void + className?: string +} + +const GeneralNavbar: React.FC = ({ + title = '', + titleStyle, + titleClassName = '', + leftContent, + backgroundColor = '#FAFAFA', + showBack = true, + showLeft = true, + onBack, + className = '' +}) => { + const { statusNavbarHeightInfo } = useGlobalState() + const { statusBarHeight, navBarHeight } = statusNavbarHeightInfo + + const handleBack = () => { + if (onBack) { + onBack() + } else { + Taro.navigateBack() + } + } + + const renderLeftContent = () => { + if (!showLeft) { + return null + } + + if (leftContent) { + return leftContent + } + + if (showBack) { + return ( + + ) + } + + return null + } + + const renderTitle = () => { + if (!title) { + return null + } + + return ( + + {title} + + ) + } + + return ( + + + + {renderLeftContent()} + + + + {renderTitle()} + + + + {/* 右侧占位,保持标题居中 */} + + + + ) +} + +export default GeneralNavbar diff --git a/src/components/NTRPEvaluatePopup/index.module.scss b/src/components/NTRPEvaluatePopup/index.module.scss index 5b618c0..c574c29 100644 --- a/src/components/NTRPEvaluatePopup/index.module.scss +++ b/src/components/NTRPEvaluatePopup/index.module.scss @@ -1,12 +1,55 @@ @use "~@/scss/images.scss" as img; .container { - width: calc(100vw - 40px); - height: 400px; + width: 100%; + // height: 400px; display: flex; flex-direction: column; align-items: center; justify-content: space-between; - padding: 20px; + // padding: 20px; box-sizing: border-box; + + .entryCard { + width: 100%; + box-sizing: border-box; + padding: 0 20px; + } +} + +.header { + height: 72px; + width: 100%; + padding: 16px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: space-between; + color: #000; + text-align: center; + font-family: "PingFang SC"; + font-size: 22px; + font-style: normal; + font-weight: 600; + line-height: 28px; + + .closeBtn { + display: flex; + width: 40px; + height: 40px; + justify-content: center; + align-items: center; + gap: 6px; + flex-shrink: 0; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: #fff; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06); + + .closeIcon { + width: 24px; + height: 24px; + flex-shrink: 0; + } + } } diff --git a/src/components/NTRPEvaluatePopup/index.tsx b/src/components/NTRPEvaluatePopup/index.tsx index 724f678..5bcf54c 100644 --- a/src/components/NTRPEvaluatePopup/index.tsx +++ b/src/components/NTRPEvaluatePopup/index.tsx @@ -3,13 +3,17 @@ import React, { useImperativeHandle, useEffect, forwardRef, + memo, } from "react"; -import { Button, Input, View, Text } from "@tarojs/components"; +import { Button, Input, View, Text, Image } from "@tarojs/components"; import Taro from "@tarojs/taro"; import CommonPopup from "../CommonPopup"; -import { getCurrentFullPath } from "@/components/Auth"; -import { useUserInfo, useUserActions } from "@/store/userStore"; -import style from "./index.module.scss"; +import { getCurrentFullPath } from "@/utils"; +import evaluateService from "@/services/evaluateService"; +import NTRPTestEntryCard from "../NTRPTestEntryCard"; +import NtrpPopupGuide from "../NTRPPopupGuide"; +import CloseIcon from "@/static/ntrp/ntrp_popup_close.svg"; +import styles from "./index.module.scss"; export enum EvaluateType { EDIT = "edit", @@ -32,6 +36,7 @@ interface NTRPEvaluatePopupProps { types: EvaluateType[]; displayCondition: DisplayConditionType; scene: SceneType; + showGuide: boolean; children: React.ReactNode; } @@ -40,7 +45,7 @@ function showCondition(scene, ntrp) { // TODO: 显示频率 return Math.random() < 0.1 && [0, undefined].includes(ntrp); } - return [0, undefined].includes(ntrp); + return ntrp === "0"; } const NTRPEvaluatePopup = (props: NTRPEvaluatePopupProps, ref) => { @@ -48,10 +53,11 @@ const NTRPEvaluatePopup = (props: NTRPEvaluatePopupProps, ref) => { types = ["edit", "evaluate"], displayCondition = "auto", scene = "list", + showGuide = false, } = props; - const [visible, setVisible] = useState(false); - const { ntrp } = useUserInfo(); - const { fetchUserInfo } = useUserActions(); + const [visible, setVisible] = useState(true); + const [ntrp, setNtrp] = useState(); + const [guideShow, setGuideShow] = useState(() => props.showGuide); useImperativeHandle(ref, () => ({ show: () => setVisible(true), @@ -61,39 +67,69 @@ const NTRPEvaluatePopup = (props: NTRPEvaluatePopupProps, ref) => { setVisible(false); // TODO: 实现NTRP评估逻辑 Taro.navigateTo({ - url: `/other_pages/ntrp-evaluate/index?redirect=${encodeURIComponent(getCurrentFullPath())}`, + url: `/other_pages/ntrp-evaluate/index?redirect=${encodeURIComponent( + getCurrentFullPath() + )}`, }); } useEffect(() => { - // fetchUserInfo(); + getNtrp(); }, []); + async function getNtrp() { + const res = await evaluateService.getLastResult(); + if (res.code === 0 && res.data.has_ntrp_level) { + // setNtrp(res.data.user_ntrp_level) + setNtrp("0"); + } else { + setNtrp("0"); + } + } + const showEntry = displayCondition === "auto" ? showCondition(scene, ntrp) : displayCondition === "always"; + function handleClose() { + setVisible(false); + } + return ( <> setVisible(false)} - position="center" + onClose={handleClose} + showHeader={false} hideFooter enableDragToClose={false} > - - {/* TODO: 直接修改NTRP水平 */} - 您还未测评。。。 - 请先进行NTRP评估 - - + {guideShow ? ( + { + setGuideShow(false); + }} + /> + ) : ( + + + 选择 NTRP 自评水平 + + + + + + + + + )} - {showEntry && props.children} + {showEntry ? props.children : ""} ); }; -export default forwardRef(NTRPEvaluatePopup); +export default memo(forwardRef(NTRPEvaluatePopup)); diff --git a/src/components/NTRPPopupGuide/index.module.scss b/src/components/NTRPPopupGuide/index.module.scss new file mode 100644 index 0000000..a4c8401 --- /dev/null +++ b/src/components/NTRPPopupGuide/index.module.scss @@ -0,0 +1,202 @@ +.container { + width: 100%; + height: 540px; + border-radius: 20px 20px 0 0; + background: linear-gradient(180deg, #bfffef 0%, #f2fffc 100%), #fafafa; + display: flex; + flex-direction: column; + align-items: center; + justify-content: space-between; + + .top { + width: 100%; + padding: 0 24px; + box-sizing: border-box; + margin-bottom: auto; + } + + .bottom { + width: 100%; + padding: 0 10px 40px; + box-sizing: border-box; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + + .jump, + .direct { + width: 100%; + height: 50px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + overflow: hidden; + + .button { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + background: #fff; + color: #000; + font-feature-settings: "liga" off, "clig" off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + + &.primary { + color: #fff; + background: #000; + + .jumpIcon { + width: 20px; + height: 20px; + } + } + } + } + } +} + +.header { + height: 72px; + width: 100%; + padding: 16px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-end; + + .closeBtn { + display: flex; + width: 40px; + height: 40px; + justify-content: center; + align-items: center; + gap: 6px; + flex-shrink: 0; + border-radius: 999px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: #fff; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06); + + .closeIcon { + width: 24px; + height: 24px; + flex-shrink: 0; + } + } +} + +.guideMsg { + padding-bottom: 20px; + + .title { + color: #2a4d44; + font-family: "Noto Sans SC"; + font-size: 32px; + font-style: normal; + font-weight: 900; + line-height: 48px; + + .colorTip { + color: #00e5ad; + font-family: "Noto Sans SC"; + font-size: 32px; + font-style: normal; + font-weight: 900; + line-height: 48px; + } + + .strongTip { + color: #00e5ad; + font-family: "Noto Sans SC"; + font-size: 32px; + font-style: normal; + font-weight: 900; + line-height: 48px; + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-skip-ink: auto; + text-decoration-thickness: auto; + text-underline-offset: auto; + text-underline-position: from-font; + } + } +} + +.desc { + color: #2a4d44; + font-family: "Noto Sans SC"; + font-size: 16px; + font-style: normal; + font-weight: 900; + line-height: 28px; +} + +@mixin commonAvatarStyle($multiple: 1) { + .avatar { + flex: 0 0 auto; + width: calc(100px * $multiple); + height: calc(100px * $multiple); + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + border-radius: 50%; + border: 1px solid #efefef; + overflow: hidden; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.2), 0 8px 20px 0 rgba(0, 0, 0, 0.12); + + .avatarUrl { + width: calc(90px * $multiple); + height: calc(90px * $multiple); + border-radius: 50%; + border: 1px solid #efefef; + } + } + + .addonImage { + flex: 0 0 auto; + width: calc(88px * $multiple); + height: calc(88px * $multiple); + transform: rotate(8deg); + flex-shrink: 0; + aspect-ratio: 1/1; + border-radius: calc(20px * $multiple); + border: 4px solid #fff; + background: linear-gradient( + 0deg, + rgba(89, 255, 214, 0.2) 0%, + rgba(89, 255, 214, 0.2) 100% + ), + #fff; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin-left: calc(-1 * 20px * $multiple); + + .docImage { + width: calc(48px * $multiple); + height: calc(48px * $multiple); + transform: rotate(-7deg); + flex-shrink: 0; + } + } +} + +.avatarWrap { + width: 100%; + padding-bottom: 20px; + display: flex; + align-items: center; + justify-content: flex-start; + @include commonAvatarStyle(0.5); +} diff --git a/src/components/NTRPPopupGuide/index.tsx b/src/components/NTRPPopupGuide/index.tsx new file mode 100644 index 0000000..7623915 --- /dev/null +++ b/src/components/NTRPPopupGuide/index.tsx @@ -0,0 +1,81 @@ +import React from "react"; +import { View, Text, Button, Image } from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import classnames from "classnames"; +import { useUserInfo } from "@/store/userStore"; +import ArrwoRight from "@/static/ntrp/ntrp_arrow_right.svg"; +import CloseIcon from "@/static/ntrp/ntrp_popup_close.svg"; +import DocCopy from "@/static/ntrp/ntrp_doc_copy.svg"; +import styles from "./index.module.scss"; + +function NtrpPopupGuide(props: { close: () => void; skipGuide: () => void }) { + const { close, skipGuide } = props; + const userInfo = useUserInfo(); + + function handleTest() { + Taro.redirectTo({ + url: `/other_pages/ntrp-evaluate/index`, + }); + } + return ( + + + + + + + + + + + + {/* avatar side */} + + + + + + + 快速测一测✏️ + + + 你的 + ( + NTRP + ) + 水平? + + + + 首次发布球局前,需完善 NTRP 水平信息 + + + + + + + + + + + + ); +} + +export default NtrpPopupGuide; diff --git a/src/components/NTRPTestEntryCard/index.module.scss b/src/components/NTRPTestEntryCard/index.module.scss new file mode 100644 index 0000000..fe70d63 --- /dev/null +++ b/src/components/NTRPTestEntryCard/index.module.scss @@ -0,0 +1,138 @@ +@mixin commonCardStyle { + width: 100%; + padding: 16px; + box-sizing: border-box; + border-radius: 20px; + border: 0.5px solid rgba(0, 0, 0, 0.08); + background: linear-gradient(180deg, #BFFFEF 0%, #F2FFFC 100%), var(--Backgrounds-Primary, #FFF); + display: flex; + align-items: center; + justify-content: space-between; +} + +.higher { + height: 100px; + @include commonCardStyle(); +} + +.lower { + height: 80px; + @include commonCardStyle(); + + +} + +.desc { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + gap: 7px; + + .title { + color: #2A4D44; + font-family: "Noto Sans SC"; + font-size: 16px; + font-style: normal; + font-weight: 900; + line-height: 24px; + + .colorTip { + color: #00E5AD; + font-family: "Noto Sans SC"; + font-size: 16px; + font-style: normal; + font-weight: 900; + line-height: 24px; + } + + .strongTip { + color: #00E5AD; + font-family: "Noto Sans SC"; + font-size: 16px; + font-style: normal; + font-weight: 900; + line-height: 24px; + text-decoration-line: underline; + text-decoration-style: solid; + text-decoration-skip-ink: auto; + text-decoration-thickness: auto; + text-underline-offset: auto; + text-underline-position: from-font; + } + } +} + +.entry { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + color: #5CA693; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: normal; + + .entryIcon { + width: 12px; + height: 12px; + } +} + +@mixin commonAvatarStyle($multiple: 1) { + .avatar { + flex: 0 0 auto; + width: calc(100px * $multiple); + height: calc(100px * $multiple); + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + border-radius: 50%; + border: 1px solid #efefef; + overflow: hidden; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.20), 0 8px 20px 0 rgba(0, 0, 0, 0.12); + + .avatarUrl { + width: calc(90px * $multiple); + height: calc(90px * $multiple); + border-radius: 50%; + border: 1px solid #efefef; + } + } + + .addonImage { + flex: 0 0 auto; + width: calc(88px * $multiple); + height: calc(88px * $multiple); + transform: rotate(8deg); + flex-shrink: 0; + aspect-ratio: 1/1; + border-radius: calc(20px * $multiple); + border: 4px solid #FFF; + background: linear-gradient(0deg, rgba(89, 255, 214, 0.20) 0%, rgba(89, 255, 214, 0.20) 100%), #FFF; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin-left: calc(-1 * 20px * $multiple); + + .docImage { + width: calc(48px * $multiple); + height: calc(48px * $multiple); + transform: rotate(-7deg); + flex-shrink: 0; + } + } +} + +.avatarWrap { + display: flex; + align-items: center; + justify-content: flex-start; + @include commonAvatarStyle(0.5); +} \ No newline at end of file diff --git a/src/components/NTRPTestEntryCard/index.tsx b/src/components/NTRPTestEntryCard/index.tsx new file mode 100644 index 0000000..c1875dc --- /dev/null +++ b/src/components/NTRPTestEntryCard/index.tsx @@ -0,0 +1,82 @@ +import React, { useEffect } from "react"; +import { View, Image, Text } from "@tarojs/components"; +import { useUserInfo, useUserActions } from "@/store/userStore"; +import DocCopy from "@/static/ntrp/ntrp_doc_copy.svg"; +import ArrowRight from "@/static/ntrp/ntrp_arrow_right_color.svg"; +import styles from "./index.module.scss"; + +function NTRPTestEntryCard(props) { + const userInfo = useUserInfo(); + // const { fetchUserInfo } = useUserActions() + + // useEffect(() => { + // fetchUserInfo() + // }, []) + const { type } = props; + return type === "list" ? ( + + + + + 快速测一测✏️ + + + 你的 + ( + NTRP + ) + 水平? + + + + 快速测试 + + + + + + + + {/* avatar side */} + + + + + + ) : ( + + + + 不知道自己的 + ( + NTRP + ) + 水平? + + + 快速测试 + + + + + + + + {/* avatar side */} + + + + + + ); +} + +export default NTRPTestEntryCard; diff --git a/src/components/NumberInterval/NumberInterval.scss b/src/components/NumberInterval/NumberInterval.scss index 8aeddad..bce28cc 100644 --- a/src/components/NumberInterval/NumberInterval.scss +++ b/src/components/NumberInterval/NumberInterval.scss @@ -6,26 +6,16 @@ width: 100%; padding: 9px 12px; display: flex; - justify-content: space-between; - height: 48px; + flex-direction: column; box-sizing: border-box; .participant-control { display: flex; align-items: center; position: relative; - &:first-child{ - width: 50%; - &::after{ - content: ''; - display: block; - width: 1px; - height: 16px; - background: #E5E5E5; - position: absolute; - right: 0; - top: 50%; - transform: translateY(-50%); - } + justify-content: space-between; + padding-bottom: 12px; + &:last-child{ + padding-bottom: 0; } .control-label { font-size: 13px; @@ -33,6 +23,17 @@ white-space: nowrap; padding-right: 10px; } + .participant-control-checkbox-wrapper{ + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 12px; + .participant-control-checkbox { + width: 18px; + height: 18px; + } + } .control-buttons { display: flex; diff --git a/src/components/NumberInterval/NumberInterval.tsx b/src/components/NumberInterval/NumberInterval.tsx index d3b8ed6..80cbbad 100644 --- a/src/components/NumberInterval/NumberInterval.tsx +++ b/src/components/NumberInterval/NumberInterval.tsx @@ -1,11 +1,12 @@ -import React from 'react' -import { View, Text, Button } from '@tarojs/components' -import './NumberInterval.scss' +import React, { useState, useEffect } from 'react' +import { View, Text } from '@tarojs/components' import { InputNumber } from '@nutui/nutui-react-taro' +import { Checkbox } from '@nutui/nutui-react-taro' +import './NumberInterval.scss' interface NumberIntervalProps { - value: [number, number] - onChange: (value: [number, number]) => void + value: { min: number, max: number, organizer_joined: boolean } + onChange: (value: { min: number, max: number, organizer_joined: boolean }) => void min: number max: number } @@ -16,48 +17,64 @@ const NumberInterval: React.FC = ({ min, max }) => { - const [minParticipants, maxParticipants] = value || [1, 1] - - const handleChange = (value: [number | string, number | string]) => { - const newMin = Number(value[0]) - const newMax = Number(value[1]) - - // 确保最少人数不能大于最多人数 - if (newMin > newMax) { - return + const [organizer_joined, setOrganizerJoined] = useState(true); + const [minParticipants, setMinParticipants] = useState(1); + const [maxParticipants, setMaxParticipants] = useState(1); + + useEffect(() => { + if (value) { + setOrganizerJoined(value.organizer_joined); + setMinParticipants(value.min); + setMaxParticipants(value.max); } - - onChange([newMin, newMax]) + console.log(value, 'valuevaluevaluevaluevaluevalue'); + }, [value]); + + const handleChange = (value: { min: number | string, max: number | string, organizer_joined: boolean }) => { + const toNumber = (v: number | string): number => typeof v === 'string' ? Number(v) : v + const { min, max, organizer_joined } = value; + onChange({ min: toNumber(min), max: toNumber(max), organizer_joined }) } + return ( - 最少 + 最小成局数 handleChange([value, maxParticipants])} + onChange={(value) => handleChange({ min: value, max: maxParticipants, organizer_joined: organizer_joined })} formatter={(value) => `${value}人`} /> - 最多 + 球局总人数 handleChange([minParticipants, value])} + value={maxParticipants} + onChange={(value) => handleChange({ min: minParticipants, max: value, organizer_joined: organizer_joined })} min={minParticipants} max={max} formatter={(value) => `${value}人`} /> + + + handleChange({ min: minParticipants, max: maxParticipants, organizer_joined: checked })} + /> + 我也参与此球局 + + ) } diff --git a/src/components/Picker/CalendarDialog/DialogCalendarCard.tsx b/src/components/Picker/CalendarDialog/DialogCalendarCard.tsx index 688b432..daff3c1 100644 --- a/src/components/Picker/CalendarDialog/DialogCalendarCard.tsx +++ b/src/components/Picker/CalendarDialog/DialogCalendarCard.tsx @@ -1,109 +1,177 @@ -import React, { useState, useEffect, useRef } from 'react' -import CommonPopup from '@/components/CommonPopup' -import { View } from '@tarojs/components' -import CalendarUI, { CalendarUIRef } from '@/components/Picker/CalendarUI/CalendarUI' -import { PickerCommon, PickerCommonRef } from '@/components/Picker' -import dayjs from 'dayjs' -import styles from './index.module.scss' +import React, { useState, useEffect, useRef } from "react"; +import CommonPopup from "@/components/CommonPopup"; +import { View } from "@tarojs/components"; +import CalendarUI, { + CalendarUIRef, +} from "@/components/Picker/CalendarUI/CalendarUI"; +import { PickerCommon, PickerCommonRef } from "@/components/Picker"; +import dayjs from "dayjs"; +import styles from "./index.module.scss"; export interface DialogCalendarCardProps { - value?: Date - onChange?: (date: Date) => void - visible: boolean - onClose: () => void - title?: React.ReactNode + value?: Date | Date[]; + searchType?: "single" | "range" | "multiple"; + onChange?: (date: Date | Date[]) => void; + visible: boolean; + onClose: () => void; + title?: React.ReactNode; } const DialogCalendarCard: React.FC = ({ visible, + searchType, onClose, title, value, onChange, }) => { - const [selected, setSelected] = useState(value || new Date()) + const [selected, setSelected] = useState(value || new Date()); + const [selectedBackup, setSelectedBackup] = useState( + Array.isArray(value) ? [...(value as Date[])] : [value as Date] + ); + const [current, setCurrent] = useState(new Date()); + const [delta, setDelta] = useState(0); const calendarRef = useRef(null); - const [type, setType] = useState<'year' | 'month' | 'time'>('year'); - const [selectedHour, setSelectedHour] = useState(8) - const [selectedMinute, setSelectedMinute] = useState(0) + const [type, setType] = useState<"year" | "month" | "time">("year"); + const [selectedHour, setSelectedHour] = useState(8); + const [selectedMinute, setSelectedMinute] = useState(0); const pickerRef = useRef(null); const hourMinutePickerRef = useRef(null); - const [pendingJump, setPendingJump] = useState<{ year: number; month: number } | null>(null) + const [pendingJump, setPendingJump] = useState<{ + year: number; + month: number; + } | null>(null); const handleConfirm = () => { - if (type === 'year') { + if (type === "year") { + if (searchType === "range") { + if (onChange) onChange(selected); + onClose(); + return; + } // 年份选择完成后,进入月份选择 - setType('time') - } else if (type === 'month') { + setType("time"); + } else if (type === "month") { // 月份选择完成后,进入时间选择 - const value = pickerRef.current?.getValue() + const value = pickerRef.current?.getValue(); if (value) { - const year = value[0] as number - const month = value[1] as number - setSelected(new Date(year, month - 1, 1)) - setPendingJump({ year, month }) + const year = value[0] as number; + const month = value[1] as number; + setPendingJump({ year, month }); + setType("year"); + if (searchType === "range") { + calculateMonthDifference( + current, + new Date(year, month - 1, 1) + ); + + return; + } + setSelected(new Date(year, month - 1, 1)); } - setType('year') - } else if (type === 'time') { + } else if (type === "time") { // 时间选择完成后,调用onNext回调 - const value = hourMinutePickerRef.current?.getValue() + const value = hourMinutePickerRef.current?.getValue(); if (value) { - const hour = value[0] as number - const minute = value[1] as number - setSelectedHour(hour) - setSelectedMinute(minute) - const hours = hour.toString().padStart(2, '0') - const minutes = minute.toString().padStart(2, '0') - const finalDate = new Date(dayjs(selected).format('YYYY-MM-DD') + ' ' + hours + ':' + minutes) - if (onChange) onChange(finalDate) + const hour = value[0] as number; + const minute = value[1] as number; + setSelectedHour(hour); + setSelectedMinute(minute); + const hours = hour.toString().padStart(2, "0"); + const minutes = minute.toString().padStart(2, "0"); + const finalDate = new Date( + dayjs(selected as Date).format("YYYY-MM-DD") + + " " + + hours + + ":" + + minutes + ); + if (onChange) onChange(finalDate); } - onClose() + onClose(); } - } + }; + + const calculateMonthDifference = (date1, date2) => { + if (!(date1 instanceof Date) || !(date2 instanceof Date)) { + throw new Error("Both arguments must be Date objects"); + } + setCurrent(date1) + let months = (date2.getFullYear() - date1.getFullYear()) * 12; + months -= date1.getMonth(); + months += date2.getMonth(); + setDelta(months); + }; const handleChange = (d: Date | Date[]) => { + if (searchType === "range") { + if (Array.isArray(d)) { + if (d.length === 2) { + return; + } else if (d.length === 1) { + if (selectedBackup.length === 0 || selectedBackup.length === 2) { + setSelected([...d]); + setSelectedBackup([...d]); + } else { + setSelected( + [...selectedBackup, d[0]].sort( + (a, b) => a.getTime() - b.getTime() + ) + ); + setSelectedBackup([]); + } + } + return; + } + } if (Array.isArray(d)) { - setSelected(d[0]) + setSelected(d[0]); } else { - setSelected(d) + setSelected(d); } - } + }; const onHeaderClick = (date: Date) => { - setSelected(date) - setType('month') - } + console.log("onHeaderClick", date); + setSelected(date); + setType("month"); + }; const getConfirmText = () => { - if (type === 'time' || type === 'month') return '完成' - return '下一步' - } + if (type === "time" || type === "month" || searchType === "range") + return "完成"; + return "下一步"; + }; const handleDateTimePickerChange = (value: (string | number)[]) => { - const year = value[0] as number - const month = value[1] as number - setSelected(new Date(year, month - 1, 1)) - } + const year = value[0] as number; + const month = value[1] as number; + setSelected(new Date(year, month - 1, 1)); + }; const dialogClose = () => { - if (type === 'month') { - setType('year') - } else if (type === 'time') { - setType('year') + if (type === "month") { + setType("year"); + } else if (type === "time") { + setType("year"); } else { - onClose() + onClose(); } - } + }; + useEffect(() => { + calendarRef.current?.gotoMonth(delta); + }, [delta]) + useEffect(() => { if (visible && value) { - setSelected(value || new Date()) - setSelectedHour(value ? dayjs(value).hour() : 8) - setSelectedMinute(value ? dayjs(value).minute() : 0) + setSelected(value || new Date()); + setSelectedHour(value ? dayjs(value as Date).hour() : 8); + setSelectedMinute(value ? dayjs(value as Date).minute() : 0); } - }, [value, visible]) + }, [value, visible]); useEffect(() => { - if (type === 'year' && pendingJump && calendarRef.current) { - calendarRef.current.jumpTo(pendingJump.year, pendingJump.month) - setPendingJump(null) + if (type === "year" && pendingJump && calendarRef.current) { + calendarRef.current.jumpTo(pendingJump.year, pendingJump.month); + setPendingJump(null); } - }, [type, pendingJump]) + }, [type, pendingJump]); - console.log([selectedHour, selectedMinute], 'selectedHour, selectedMinute'); + console.log([selectedHour, selectedMinute], "selectedHour, selectedMinute"); return ( = ({ showHeader={!!title} title={title} hideFooter={false} - cancelText='取消' + cancelText="取消" confirmText={getConfirmText()} onConfirm={handleConfirm} - position='bottom' + position="bottom" round zIndex={1000} > - { - type === 'year' && - - - } - { - type === 'month' && + + + )} + {type === "month" && ( + - - } - { - type === 'time' && - - } + )} + {type === "time" && ( + + )} - ) -} + ); +}; -export default DialogCalendarCard +export default DialogCalendarCard; diff --git a/src/components/Picker/CalendarUI/CalendarUI.tsx b/src/components/Picker/CalendarUI/CalendarUI.tsx index 16fa745..f58f469 100644 --- a/src/components/Picker/CalendarUI/CalendarUI.tsx +++ b/src/components/Picker/CalendarUI/CalendarUI.tsx @@ -1,211 +1,289 @@ -import React, { useState, useEffect, useRef, useImperativeHandle } from 'react' -import { CalendarCard } from '@nutui/nutui-react-taro' -import { View, Text, Image } from '@tarojs/components' -import images from '@/config/images' -import styles from './index.module.scss' -import { getMonth, getWeekend, getWeekendOfCurrentWeek } from '@/utils/timeUtils' -import { PopupPicker } from '@/components/Picker/index' +import React, { useState, useEffect, useRef, useImperativeHandle } from "react"; +import { CalendarCard } from "@nutui/nutui-react-taro"; +import { View, Text, Image } from "@tarojs/components"; +import images from "@/config/images"; +import styles from "./index.module.scss"; +import { + getMonth, + getWeekend, + getWeekendOfCurrentWeek, +} from "@/utils/timeUtils"; +import { PopupPicker } from "@/components/Picker/index"; +import dayjs from "dayjs"; interface NutUICalendarProps { - type?: 'single' | 'range' | 'multiple' - value?: string | Date | String[] | Date[] - defaultValue?: string | string[] - onChange?: (value: Date | Date[]) => void, - isBorder?: boolean - showQuickActions?: boolean, - onHeaderClick?: (date: Date) => void + type?: "single" | "range" | "multiple"; + value?: string | Date | String[] | Date[]; + defaultValue?: string | string[]; + onChange?: (value: Date | Date[]) => void; + isBorder?: boolean; + showQuickActions?: boolean; + onHeaderClick?: (date: Date) => void; } export interface CalendarUIRef { - jumpTo: (year: number, month: number) => void + jumpTo: (year: number, month: number) => void; + gotoMonth: (delta: number) => void; } -const NutUICalendar = React.forwardRef(({ - type = 'single', - value, - onChange, - isBorder = false, - showQuickActions = true, - onHeaderClick -}, ref) => { - // 根据类型初始化选中值 -// const getInitialValue = (): Date | Date[] => { -// console.log(value,defaultValue,'today') +const NutUICalendar = React.forwardRef( + ( + { + type = "single", + value, + onChange, + isBorder = false, + showQuickActions = true, + onHeaderClick, + }, + ref + ) => { + // 根据类型初始化选中值 + // const getInitialValue = (): Date | Date[] => { + // console.log(value,defaultValue,'today') -// if (typeof value === 'string' && value) { -// return new Date(value) -// } -// if (Array.isArray(value) && value.length > 0) { -// return value.map(item => new Date(item)) -// } -// if (typeof defaultValue === 'string' && defaultValue) { -// return new Date(defaultValue) -// } -// if (Array.isArray(defaultValue) && defaultValue.length > 0) { -// return defaultValue.map(item => new Date(item)) -// } -// const today = new Date(); -// if (type === 'multiple') { -// return [today] -// } -// return today -// } - const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1) + // if (typeof value === 'string' && value) { + // return new Date(value) + // } + // if (Array.isArray(value) && value.length > 0) { + // return value.map(item => new Date(item)) + // } + // if (typeof defaultValue === 'string' && defaultValue) { + // return new Date(defaultValue) + // } + // if (Array.isArray(defaultValue) && defaultValue.length > 0) { + // return defaultValue.map(item => new Date(item)) + // } + // const today = new Date(); + // if (type === 'multiple') { + // return [today] + // } + // return today + // } + const startOfMonth = (date: Date) => + new Date(date.getFullYear(), date.getMonth(), 1); - const [selectedValue, setSelectedValue] = useState() - const [current, setCurrent] = useState(startOfMonth(new Date())) - const calendarRef = useRef(null) - const [visible, setvisible] = useState(false) - console.log('current', current) - // 当外部 value 变化时更新内部状态 - useEffect(() => { - if (Array.isArray(value) && value.length > 0) { - setSelectedValue(value.map(item => new Date(item))) - setCurrent(new Date(value[0])) - } - if ((typeof value === 'string' || value instanceof Date) && value) { - setSelectedValue(new Date(value)) - setCurrent(new Date(value)) - } - }, [value]) - - useImperativeHandle(ref, () => ({ - jumpTo: (year: number, month: number) => { - calendarRef.current?.jumpTo(year, month) - } - })) - - const handleDateChange = (newValue: any) => { - setSelectedValue(newValue) - onChange?.(newValue as any) - } - const formatHeader = (date: Date) => `${getMonth(date)}` - - const handlePageChange = (data: { year: number; month: number }) => { - // 月份切换时的处理逻辑,如果需要的话 - console.log('月份切换:', data) - } - - const gotoMonth = (delta: number) => { - const base = current instanceof Date ? new Date(current) : new Date() - base.setMonth(base.getMonth() + delta) - const next = startOfMonth(base) - setCurrent(next) - // 同步底部 CalendarCard 的月份 - try { - calendarRef.current?.jump?.(delta) - } catch (e) { - console.warn('CalendarCardRef jump 调用失败', e) - } - handlePageChange({ year: next.getFullYear(), month: next.getMonth() + 1 }) - } - - const handleHeaderClick = () => { - onHeaderClick && onHeaderClick(current) - setvisible(true) - } - - - - const syncMonthTo = (anchor: Date) => { - // 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta) - const monthsDelta = (anchor.getFullYear() - current.getFullYear()) * 12 + (anchor.getMonth() - current.getMonth()) - if (monthsDelta !== 0) { - gotoMonth(monthsDelta) - } - } - const renderDay = (day: any) => { - const { date, month, year} = day; - const today = new Date() - if (date === today.getDate() && month === today.getMonth() + 1 && year === today.getFullYear()) { - return ( - - {date} - - ) - } - return date - } - - const selectWeekend = () => { - const [start, end] = getWeekend() - setSelectedValue([start, end]) - syncMonthTo(start) - onChange?.([start, end]) - } - const selectWeek = () => { - const dayList = getWeekendOfCurrentWeek(7) - setSelectedValue(dayList) - syncMonthTo(dayList[0]) - onChange?.(dayList) - } - const selectMonth = () => { - const dayList = getWeekendOfCurrentWeek(30) - setSelectedValue(dayList) - syncMonthTo(dayList[0]) - onChange?.(dayList) - } - - const handleMonthChange = (value: any) => { - const [year, month] = value; - const newDate = new Date(year, month - 1, 1); - setCurrent(newDate); - calendarRef.current?.jumpTo(year, month) - } - - - return ( - - {/* 快速操作行 */} - { - showQuickActions && - - 本周末 - 一周内 - 一个月 - + const [selectedValue, setSelectedValue] = useState(); + const [current, setCurrent] = useState(startOfMonth(new Date())); + const calendarRef = useRef(null); + const [visible, setvisible] = useState(false); + console.log("current", current); + // 当外部 value 变化时更新内部状态 + useEffect(() => { + if (Array.isArray(value) && value.length > 0) { + setSelectedValue(value.map((item) => new Date(item))); + setCurrent(new Date(value[0] as Date)); } - - {/* 自定义头部显示周一到周日 */} - - - {formatHeader(current as Date)} - gotoMonth(1)} /> - - - gotoMonth(-1)}> - - - gotoMonth(1)}> - - - - - - {[ '周日', '周一', '周二', '周三', '周四', '周五', '周六'].map((day) => ( - - {day} - - ))} - - - {/* NutUI CalendarCard 组件 */} - - - { visible && handleMonthChange(value)}/> } - - ) -}) + if ((typeof value === "string" || value instanceof Date) && value) { + setSelectedValue(new Date(value)); + setCurrent(new Date(value)); + } + }, [value]); -export default NutUICalendar \ No newline at end of file + useImperativeHandle(ref, () => ({ + jumpTo: (year: number, month: number) => { + calendarRef.current?.jumpTo(year, month); + }, + gotoMonth, + })); + + const handleDateChange = (newValue: any) => { + if (type === "range") return; + setSelectedValue(newValue); + onChange?.(newValue as any); + }; + const formatHeader = (date: Date) => `${getMonth(date)}`; + + const handlePageChange = (data: { year: number; month: number }) => { + // 月份切换时的处理逻辑,如果需要的话 + console.log("月份切换:", data); + }; + + const handleDayClick = (day: any) => { + const { type, year, month, date } = day; + if (type === "next") return; + onChange?.([new Date(year, month - 1, date)]); + }; + + const gotoMonth = (delta: number) => { + const base = current instanceof Date ? new Date(current) : new Date(); + base.setMonth(base.getMonth() + delta); + const next = startOfMonth(base); + setCurrent(next); + // 同步底部 CalendarCard 的月份 + try { + calendarRef.current?.jump?.(delta); + } catch (e) { + console.warn("CalendarCardRef jump 调用失败", e); + } + handlePageChange({ + year: next.getFullYear(), + month: next.getMonth() + 1, + }); + }; + + const handleHeaderClick = () => { + onHeaderClick && onHeaderClick(current); + setvisible(true); + }; + + const syncMonthTo = (anchor: Date) => { + // 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta) + const monthsDelta = + (anchor.getFullYear() - current.getFullYear()) * 12 + + (anchor.getMonth() - current.getMonth()); + if (monthsDelta !== 0) { + gotoMonth(monthsDelta); + } + }; + const renderDay = (day: any) => { + const { date, month, year } = day; + const today = new Date(); + if ( + date === today.getDate() && + month === today.getMonth() + 1 && + year === today.getFullYear() + ) { + return {date}; + } + return date; + }; + + const selectWeekend = () => { + const [start, end] = getWeekend(); + setSelectedValue([start, end]); + syncMonthTo(start); + onChange?.([start, end]); + }; + const selectWeek = () => { + const dayList = getWeekendOfCurrentWeek(7); + setSelectedValue(dayList); + syncMonthTo(dayList[0]); + onChange?.(dayList); + }; + const selectMonth = () => { + const dayList = getWeekendOfCurrentWeek(30); + setSelectedValue(dayList); + syncMonthTo(dayList[0]); + onChange?.(dayList); + }; + + const handleMonthChange = (value: any) => { + const [year, month] = value; + const newDate = new Date(year, month - 1, 1); + setCurrent(newDate); + calendarRef.current?.jumpTo(year, month); + }; + + return ( + + {/* 快速操作行 */} + {showQuickActions && ( + + + 本周末 + + + 一周内 + + + 一个月 + + + )} + + {type === "range" && ( + + + {(value as Date[]).length === 2 + ? dayjs(value?.[0] as Date).format("YYYY-MM-DD") + : "起始时间"} + + + + {(value as Date[]).length === 2 + ? dayjs(value?.[1] as Date).format("YYYY-MM-DD") + : "结束时间"} + + + )} + {/* 自定义头部显示周一到周日 */} + + + + {formatHeader(current as Date)} + + gotoMonth(1)} + /> + + + gotoMonth(-1)} + > + + + gotoMonth(1)} + > + + + + + + {["周日", "周一", "周二", "周三", "周四", "周五", "周六"].map( + (day) => ( + + {day} + + ) + )} + + + {/* NutUI CalendarCard 组件 */} + + + {visible && ( + handleMonthChange(value)} + /> + )} + + ); + } +); + +export default NutUICalendar; diff --git a/src/components/Picker/CalendarUI/index.module.scss b/src/components/Picker/CalendarUI/index.module.scss index ce79dc7..ec0717a 100644 --- a/src/components/Picker/CalendarUI/index.module.scss +++ b/src/components/Picker/CalendarUI/index.module.scss @@ -1,177 +1,217 @@ .calendar-card { - background: #fff; - border-radius: 16px; - &.border{ - border-radius: 12px; - border: 0.5px solid rgba(0, 0, 0, 0.12); - margin-bottom: 6px; - padding: 12px 12px 8px; + background: #fff; + border-radius: 16px; + + &.border { + border-radius: 12px; + border: 0.5px solid rgba(0, 0, 0, 0.12); + margin-bottom: 6px; + padding: 12px 12px 8px; - } } - - .header { +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 9px 4px 11px 4px; + height: 24px; +} + +.date-range-container { + height: 55px; + display: flex; + justify-content: space-between; + align-items: center; + border-bottom: 1px solid rgba(0, 0, 0, 0.05); + color: #000; + padding: 0 4px; + font-size: 17.68px; +} + +.date-text-placeholder { + font-family: PingFang SC; + font-weight: 600; + font-style: Semibold; + font-size: 17.68px; + color: #3C3C4399; +} + +.date-text { + color: #000; +} + +.header-left { + display: flex; + align-items: center; + gap: 6px; +} + +.header-text { + font-size: 17px; + font-weight: 600; + color: #000; +} + +.header-actions { + display: flex; + width: 60px; + + .arrow-left-container { display: flex; align-items: center; - justify-content: space-between; - padding: 9px 4px 11px 4px; - height: 24px; - } - .header-left { - display: flex; - align-items: center; - gap: 6px; - } - .header-text { - font-size: 17px; - font-weight: 600; - color: #000; - } - .header-actions { - display: flex; - width: 60px; - .arrow-left-container { - display: flex; - align-items: center; - justify-content: flex-start; - width: 50%; - flex: 1; - } - .arrow-right-container { - display: flex; - align-items: center; - justify-content: flex-end; - width: 50%; - flex: 1; - } - } - .month-arrow{ - width: 8px; - height: 24px; - } - .arrow { - width: 10px; - height: 24px; - position: relative; - } - .arrow.left { - left: 9px; - transform: rotate(-180deg); - } - - .week-row { - display: grid; - grid-template-columns: repeat(7, 1fr); - padding: 0 0 4px 0; - } - .week-item { - text-align: center; - color: rgba(60, 60, 67, 0.30); - font-size: 13px; - } - - // 新增的周一到周日头部样式 - .week-header { - display: grid; - grid-template-columns: repeat(7, 1fr); - padding: 8px 0; - } - .week-day { - text-align: center; - color: rgba(60, 60, 67, 0.30); - font-size: 14px; - font-weight: 500; - } - - .grid { - display: grid; - grid-template-columns: repeat(7, 1fr); - gap: 8px 0; - padding: 4px 0 16px; - } - .cell { - height: 44px; - display: flex; - align-items: center; - justify-content: center; - font-size: 20px; - position: relative; - } - .cell.empty { - opacity: 0; - } - .cell.disabled { - color: rgba(0,0,0,0.2); - } - .cell-text.selected { - width: 44px; - height: 44px; - border-radius: 22px; - background: rgba(0,0,0,0.9); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - } - - // 时间段选择样式 - .cell-text.range-start { - width: 44px; - height: 44px; - border-radius: 22px; - background: rgba(0,0,0,0.9); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - } - - .cell-text.range-end { - width: 44px; - height: 44px; - border-radius: 22px; - background: rgba(0,0,0,0.9); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - } - - .cell-text.in-range { - width: 44px; - height: 44px; - border-radius: 22px; - background: rgba(0,0,0,0.1); - color: #000; - display: flex; - align-items: center; - justify-content: center; - } - - .footer { - display: flex; - gap: 12px; - } - .btn { + justify-content: flex-start; + width: 50%; flex: 1; - height: 44px; - border-radius: 22px; - background: rgba(0,0,0,0.06); + } + + .arrow-right-container { display: flex; align-items: center; - justify-content: center; - } - .btn.primary { - background: #000; - color: #fff; - } - - .hm-placeholder { - height: 240px; - display: flex; - align-items: center; - justify-content: center; + justify-content: flex-end; + width: 50%; + flex: 1; } +} + +.month-arrow { + width: 8px; + height: 24px; +} + +.arrow { + width: 10px; + height: 24px; + position: relative; +} + +.arrow.left { + left: 9px; + transform: rotate(-180deg); +} + +.week-row { + display: grid; + grid-template-columns: repeat(7, 1fr); + padding: 0 0 4px 0; +} + +.week-item { + text-align: center; + color: rgba(60, 60, 67, 0.30); + font-size: 13px; +} + +// 新增的周一到周日头部样式 +.week-header { + display: grid; + grid-template-columns: repeat(7, 1fr); + padding: 8px 0; +} + +.week-day { + text-align: center; + color: rgba(60, 60, 67, 0.30); + font-size: 14px; + font-weight: 500; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px 0; + padding: 4px 0 16px; +} + +.cell { + height: 44px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + position: relative; +} + +.cell.empty { + opacity: 0; +} + +.cell.disabled { + color: rgba(0, 0, 0, 0.2); +} + +.cell-text.selected { + width: 44px; + height: 44px; + border-radius: 22px; + background: rgba(0, 0, 0, 0.9); + color: #fff; + display: flex; + align-items: center; + justify-content: center; +} + +// 时间段选择样式 +.cell-text.range-start { + width: 44px; + height: 44px; + border-radius: 22px; + background: rgba(0, 0, 0, 0.9); + color: #fff; + display: flex; + align-items: center; + justify-content: center; +} + +.cell-text.range-end { + width: 44px; + height: 44px; + border-radius: 22px; + background: rgba(0, 0, 0, 0.9); + color: #fff; + display: flex; + align-items: center; + justify-content: center; +} + +.cell-text.in-range { + width: 44px; + height: 44px; + border-radius: 22px; + background: rgba(0, 0, 0, 0.1); + color: #000; + display: flex; + align-items: center; + justify-content: center; +} + +.footer { + display: flex; + gap: 12px; +} + +.btn { + flex: 1; + height: 44px; + border-radius: 22px; + background: rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; + justify-content: center; +} + +.btn.primary { + background: #000; + color: #fff; +} + +.hm-placeholder { + height: 240px; + display: flex; + align-items: center; + justify-content: center; +} // CalendarRange 组件样式 .calendar-range { @@ -234,50 +274,63 @@ flex: 1; } - + // 隐藏 CalendarCard 默认头部 :global { .nut-calendarcard { .nut-calendarcard-header { display: none !important; } - .nut-calendarcard-content{ - .nut-calendarcard-days{ - &:first-child{ + + .nut-calendarcard-content { + .nut-calendarcard-days { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 4px; + justify-items: center; + + &:first-child { display: none !important; } } } - + } - .nut-calendarcard-day{ - margin-bottom:0px!important; + + .nut-calendarcard-day { + margin-bottom: 0px !important; height: 44px; - width: 44px!important; - &.active{ - background-color: #000!important; - color: #fff!important; + width: 44px !important; + + &.active { + background-color: #000 !important; + color: #fff !important; height: 44px; - border-radius: 22px!important; + border-radius: 22px !important; display: flex; align-items: center; justify-content: center; - width: 44px!important; - font-size: 24px!important; - .day-container{ - background-color: transparent!important; + width: 44px !important; + font-size: 24px !important; + + .day-container { + background-color: transparent !important; } } - &.weekend{ - color: rgb(0,0,0)!important; - &.active{ - color: #fff!important; + + &.weekend { + color: rgb(0, 0, 0) !important; + + &.active { + color: #fff !important; } } } - .nut-calendarcard-day-inner{ + + .nut-calendarcard-day-inner { font-size: 20px; - .day-container{ + + .day-container { background-color: #f5f5f5; border-radius: 22px; width: 44px; @@ -288,5 +341,22 @@ font-size: 24px; } } -} + .nut-calendarcard-day.start, + .nut-calendarcard-day.end { + background-color: #000; + border-radius: 50%; + color: #fff !important; + } + + .nut-calendarcard-day-inner .day-container { + background-color: unset; + color: unset; + } + + .nut-calendarcard-day.mid { + background-color: rgba(0, 0, 0, 0.12); + color: #000; + border-radius: 50%; + } +} \ No newline at end of file diff --git a/src/components/Picker/PickerData.js b/src/components/Picker/PickerData.js index c145e8d..cc5c2e2 100644 --- a/src/components/Picker/PickerData.js +++ b/src/components/Picker/PickerData.js @@ -13,6 +13,26 @@ export const renderYearMonth = (minYear = 2020, maxYear = 2099) => { ] } +export const renderYearMonthDay = (minYear = 2020, maxYear = 2099) => { + return [ + // 年份列 + Array.from({ length: maxYear - minYear + 1 }, (_, index) => ({ + text: `${minYear + index}年`, + value: minYear + index + })), + // 月份列 + Array.from({ length: 12 }, (_, index) => ({ + text: `${index + 1}月`, + value: index + 1 + })), + // 日期列 (默认31天,具体天数需在onChange时动态调整) + Array.from({ length: 31 }, (_, index) => ({ + text: `${index + 1}日`, + value: index + 1 + })) + ] +} + export const renderHourMinute = (minHour = 0, maxHour = 23) => { // 生成小时和分钟的选项数据 return [ diff --git a/src/components/Picker/PopupPicker.tsx b/src/components/Picker/PopupPicker.tsx index 2f2292d..75de996 100644 --- a/src/components/Picker/PopupPicker.tsx +++ b/src/components/Picker/PopupPicker.tsx @@ -1,66 +1,95 @@ -import React, { useState, useEffect, useCallback } from 'react' -import CommonPopup from '@/components/CommonPopup' -import Picker from './Picker' -import { renderYearMonth, renderHourMinute } from './PickerData' +import React, { useState, useEffect, useCallback } from "react"; +import CommonPopup from "@/components/CommonPopup"; +import { View, Text, Image } from "@tarojs/components"; +import Picker from "./Picker"; +import { + renderYearMonth, + renderYearMonthDay, + renderHourMinute, +} from "./PickerData"; +import imgs from "@/config/images"; +import styles from "./index.module.scss"; interface PickerOption { - text: string | number - value: string | number + text: string | number; + value: string | number; } interface PickerProps { - visible: boolean - setvisible: (visible: boolean) => void - options?: PickerOption[][] - value?: (string | number)[] - type?: 'month' | 'hour' | null - onConfirm?: (options: PickerOption[], values: (string | number)[]) => void - onChange?: ( value: (string | number)[] ) => void + visible: boolean; + setvisible: (visible: boolean) => void; + options?: PickerOption[][] | PickerOption[]; + value?: (string | number)[]; + type?: "month" | "day" | "hour" | "ntrp" | null; + img?: string; + onConfirm?: (options: PickerOption[], values: (string | number)[]) => void; + onChange?: (value: (string | number)[]) => void; } -const PopupPicker = ({ - visible, - setvisible, - value = [], - onConfirm, +const PopupPicker = ({ + visible, + setvisible, + value = [], + img, + onConfirm, onChange, options = [], - type = null + type = null, }: PickerProps) => { - - const [defaultValue, setDefaultValue] = useState<(string | number)[]>([]) - const [defaultOptions, setDefaultOptions] = useState([]) + const [defaultValue, setDefaultValue] = useState<(string | number)[]>([]); + const [defaultOptions, setDefaultOptions] = useState([]); const changePicker = (options: any[], values: any, columnIndex: number) => { if (onChange) { - console.log('picker onChange', columnIndex, values, options) + console.log("picker onChange", columnIndex, values, options); - setDefaultValue(values) + if ( + type === "day" && + JSON.stringify(defaultValue) !== JSON.stringify(values) + ) { + const [year, month] = values; + const daysInMonth = new Date(Number(year), Number(month), 0).getDate(); + const dayOptions = Array.from({ length: daysInMonth }, (_, i) => ({ + text: i + 1 + "日", + value: i + 1, + })); + const newOptions = [...defaultOptions]; + if (JSON.stringify(newOptions[2]) !== JSON.stringify(dayOptions)) { + newOptions[2] = dayOptions; + setDefaultOptions(newOptions); + } + } + + if (JSON.stringify(defaultValue) !== JSON.stringify(values)) { + setDefaultValue(values); + } } - } + }; const handleConfirm = () => { - console.log(defaultValue,'defaultValue'); - onChange(defaultValue) - setvisible(false) - } - + console.log(defaultValue, "defaultValue"); + onChange(defaultValue); + setvisible(false); + }; + const dialogClose = () => { - setvisible(false) - } + setvisible(false); + }; useEffect(() => { - if (type === 'month') { - setDefaultOptions(renderYearMonth()) - } else if (type === 'hour') { - setDefaultOptions(renderHourMinute()) + if (type === "month") { + setDefaultOptions(renderYearMonth()); + } else if (type === "day") { + setDefaultOptions(renderYearMonthDay()); + } else if (type === "hour") { + setDefaultOptions(renderHourMinute()); } else { - setDefaultOptions(options) + setDefaultOptions(options); } - }, [type]) - -// useEffect(() => { -// if (value.length > 0 && defaultOptions.length > 0) { -// setDefaultValue([...value]) -// } -// }, [value, defaultOptions]) + }, [type]); + + // useEffect(() => { + // if (value.length > 0 && defaultOptions.length > 0) { + // setDefaultValue([...value]) + // } + // }, [value, defaultOptions]) return ( <> + {type === "ntrp" && ( + + + + 不知道自己的(NTRP)水平 + + + 快速测试 + + + + + + + + + + + + + )} - ) -} + ); +}; -export default PopupPicker +export default PopupPicker; diff --git a/src/components/Picker/index.module.scss b/src/components/Picker/index.module.scss index 841cdee..3e1a7e1 100644 --- a/src/components/Picker/index.module.scss +++ b/src/components/Picker/index.module.scss @@ -1,17 +1,19 @@ .picker-container { - :global{ - .nut-popup-round{ - position: relative!important; + :global { + .nut-popup-round { + position: relative !important; + .nut-picker-control { - display: none!important; + display: none !important; } - .nut-picker{ - &::after{ - content: ''; + + .nut-picker { + &::after { + content: ""; position: absolute; top: 50%; left: 16px; - right: 16px!important; + right: 16px !important; width: calc(100% - 32px); height: 48px; background: rgba(22, 24, 35, 0.05); @@ -23,3 +25,91 @@ } } } + +.examination-btn { + padding: 8px 16px; + margin: 16px; + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + display: flex; + justify-content: space-between; + align-items: center; + box-sizing: border-box; + background: linear-gradient(to bottom, + #CCFFF2, + /* 开始颜色 */ + #F7FFFD + /* 结束颜色 */ + ), + repeating-linear-gradient(90deg, + /* 垂直方向 */ + rgba(0, 0, 0, 1), + /* 条纹的开始颜色 */ + rgba(0, 0, 0, 0.01) 1px, + /* 条纹的结束颜色及宽度 */ + #CCFFF2 8px, + /* 条纹之间的开始颜色 */ + #F7FFFD 10px + /* 条纹之间的结束颜色及宽度 */ + ); + background-blend-mode: luminosity; + /* 将两个渐变层叠在一起 */ + + .text-container { + .text-title { + font-family: Noto Sans SC; + font-weight: 900; + color: #2a4d44; + font-size: 16px; + margin-bottom: 4px; + + Text { + color: #00e5ad; + } + } + + .text-btn { + font-size: 12px; + color: #5ca693; + display: flex; + align-items: center; + gap: 6px; + + .icon-arrow { + width: 12px; + height: 12px; + } + } + } + + .img-container { + display: flex; + + .img-box { + width: 47px; + height: 47px; + border: 3px solid #fff; + border-radius: 50%; + overflow: hidden; + display: flex; + justify-content: center; + align-items: center; + + Image { + width: 100%; + height: 100%; + } + + &:nth-child(2) { + border-radius: 8px; + background-color: #ccfff2; + transform: scale(0.88) rotate(15deg) translateX(-10px); + + Image { + width: 66%; + height: 66%; + } + } + } + } +} \ No newline at end of file diff --git a/src/components/PublishMenu/PublishMenu.tsx b/src/components/PublishMenu/PublishMenu.tsx index e4a1cb0..7ad6105 100644 --- a/src/components/PublishMenu/PublishMenu.tsx +++ b/src/components/PublishMenu/PublishMenu.tsx @@ -1,67 +1,122 @@ -import React, { useState } from 'react' -import { View, Text, Image } from '@tarojs/components' -import Taro from '@tarojs/taro' -import styles from './index.module.scss' -import images from '@/config/images' +import React, { useState } from "react"; +import { View, Text, Image } from "@tarojs/components"; +import Taro from "@tarojs/taro"; +import styles from "./index.module.scss"; +import images from "@/config/images"; +import AiImportPopup from "@/publish_pages/publishBall/components/AiImportPopup"; export interface PublishMenuProps { - onPersonalPublish?: () => void - onActivityPublish?: () => void + onPersonalPublish?: () => void; + onActivityPublish?: () => void; } const PublishMenu: React.FC = () => { - const [isVisible, setIsVisible] = useState(false) + const [isVisible, setIsVisible] = useState(false); + const [aiImportVisible, setAiImportVisible] = useState(false); const handleIconClick = () => { - setIsVisible(!isVisible) - } - - const handleMenuItemClick = (type: 'individual' | 'group') => { + setIsVisible(!isVisible); + }; + const handleOverlayClick = () => { + setIsVisible(false); + }; + const handleMenuItemClick = (type: "individual" | "group" | "ai") => { // 跳转到publishBall页面并传递type参数 - console.log(type, 'type'); + console.log(type, "type"); + if (type === "ai") { + setAiImportVisible(true); + setIsVisible(false); + return; + } Taro.navigateTo({ - url: `/publish_pages/publishBall/index?type=${type}` - }) - setIsVisible(false) - } + url: `/publish_pages/publishBall/index?type=${type}`, + }); + setIsVisible(false); + }; + const handleAiImportClose = () => { + setAiImportVisible(false); + }; + const handleManualPublish = () => { + Taro.navigateTo({ + url: "/publish_pages/publishBall/index?type=individual", + }); + }; return ( - + {/* 蒙层 */} + {isVisible && ( + + )} {/* 菜单选项 */} {isVisible && ( handleMenuItemClick('individual')} + onClick={() => handleMenuItemClick("individual")} > + + + 发布个人约球 + + + + + + 已订场,找球友;未订场,找搭子 + + - - 发布个人约球 - 已订场,找球友;未订场,找搭子 - - - - handleMenuItemClick('group')} + onClick={() => handleMenuItemClick("group")} > + + + 发布畅打活动 + + + + + 认证球场官方组织 + + + handleMenuItemClick("ai")} + > - 发布畅打活动 - 认证球场官方组织 + + 智能发布球局 + + + + + + 识别文本/图片,快速导入球局信息 + - - + + + @@ -69,13 +124,20 @@ const PublishMenu: React.FC = () => { {/* 绿色圆形按钮 */} - - ) -} -export default PublishMenu + {/* AI导入弹窗 */} + + + ); +}; + +export default PublishMenu; diff --git a/src/components/PublishMenu/index.module.scss b/src/components/PublishMenu/index.module.scss index f09ec51..6a1f9a9 100644 --- a/src/components/PublishMenu/index.module.scss +++ b/src/components/PublishMenu/index.module.scss @@ -3,48 +3,53 @@ z-index: 1000; } - +.overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 999; +} .menuCard { position: absolute; bottom: 80px; right: 0; - width: 302px; - background: white; - border-radius: 16px; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); - padding: 12px; + width: 278px; animation: slideIn 0.3s ease-out; box-sizing: border-box; display: flex; flex-direction: column; gap: 8px; - /* 小三角指示器 */ - &::after { - content: ''; - position: absolute; - bottom: -8px; - right: 20px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 8px solid white; - /* 移除阴影,避免连接处的黑色 */ - } + z-index: 1001; + // /* 小三角指示器 */ + // &::after { + // content: ''; + // position: absolute; + // bottom: -8px; + // right: 20px; + // width: 0; + // height: 0; + // border-left: 8px solid transparent; + // border-right: 8px solid transparent; + // border-top: 8px solid white; + // /* 移除阴影,避免连接处的黑色 */ + // } - /* 为小三角添加单独的阴影效果 */ - &::before { - content: ''; - position: absolute; - bottom: -9px; - right: 20px; - width: 0; - height: 0; - border-left: 8px solid transparent; - border-right: 8px solid transparent; - border-top: 8px solid rgba(0, 0, 0, 0.1); - z-index: -1; - } + // /* 为小三角添加单独的阴影效果 */ + // &::before { + // content: ''; + // position: absolute; + // bottom: -9px; + // right: 20px; + // width: 0; + // height: 0; + // border-left: 8px solid transparent; + // border-right: 8px solid transparent; + // border-top: 8px solid rgba(0, 0, 0, 0.1); + // z-index: -1; + // } } @keyframes slideIn { @@ -71,12 +76,20 @@ } .menuIcon { - width: 40px; - height: 40px; display: flex; - align-items: center; + width: 48px; + height: 48px; + padding: 10px; justify-content: center; - margin-right: 12px; + align-items: center; + border-radius: 12px; +border: 1px solid rgba(0, 0, 0, 0.06); +background: rgba(0, 0, 0, 0.03); +box-sizing: border-box; + image{ + width: 28px; + height: 28px; + } } .ballIcon { @@ -143,6 +156,7 @@ flex: 1; display: flex; flex-direction: column; + padding-left: 8px; } .menuTitle { @@ -151,6 +165,8 @@ color: #000; margin-bottom: 2px; line-height: 24px; /* 150% */ + display: flex; + align-items: center; } .menuDesc { @@ -162,7 +178,7 @@ .menuArrow { font-size: 16px; color: #ccc; - margin-left: 8px; + margin-left: 4px; .img{ width: 16px; height: 16px; @@ -180,6 +196,8 @@ justify-content: center; flex-shrink: 0; overflow: hidden; + z-index: 1001; + position: relative; &.rotated { transform: rotate(-90deg); } @@ -193,3 +211,20 @@ font-weight: bold; line-height: 1; } + +.aiItem{ + border-radius: 20px; + border: 0.5px solid rgba(0, 0, 0, 0.08); + background: #000; + .menuTitle{ + color: #FFF; + } + .menuDesc{ + color: rgba(255, 255, 255, 0.60); + } + .menuIcon{ + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(255, 255, 255, 0.20); + } +} \ No newline at end of file diff --git a/src/components/Radar/index.tsx b/src/components/Radar/index.tsx new file mode 100644 index 0000000..00d2556 --- /dev/null +++ b/src/components/Radar/index.tsx @@ -0,0 +1,164 @@ +import Taro, { useReady } from "@tarojs/taro"; +import { View, Canvas, Button } from "@tarojs/components"; +import { useEffect, useRef, forwardRef, useImperativeHandle } from "react"; + +const RadarChart: React.FC = forwardRef((props, ref) => { + const { data } = props + + const renderFnRef = useRef() + // const labels = [ + // "正手球质", + // "正手控制", + // "反手球质", + // "反手控制", + // "底线相持", + // "场地覆盖", + // "发球接发", + // "接随机球", + // "战术设计", + // ]; + // const values = [50, 75, 60, 20, 40, 70, 65, 35, 75]; + const maxValue = 100; + const levels = 4; + const radius = 100; + const center = { x: 160, y: 160 }; + + useEffect(() => { + if (data.length > 0) { + const {texts, vals} = data.reduce((res, item) => { + const [text, val] = item + return { + texts: [...res.texts, text], + vals: [...res.vals, val] + } + }, { texts: [], vals: [] }) + renderFnRef.current && renderFnRef.current(texts, vals) + } + }, [data]) + + useReady(() => { + renderFnRef.current = renderCanvas + }); + + function renderCanvas (labels, values) { + const query = Taro.createSelectorQuery(); + query + .select("#radarCanvas") + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node as HTMLCanvasElement; + const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; + const dpr = Taro.getSystemInfoSync().pixelRatio; + canvas.width = res[0].width * dpr; + canvas.height = res[0].height * dpr; + ctx.scale(dpr, dpr); + + // === 绘制圆形网格 === + for (let i = 1; i <= levels; i++) { + const r = (radius / levels) * i; + ctx.beginPath(); + ctx.arc(center.x, center.y, r, 0, Math.PI * 2); + if (i % 2 === 0) { + ctx.fillStyle = "rgba(0, 150, 200, 0.1)"; + ctx.fill(); + } + ctx.strokeStyle = "#bbb"; + ctx.stroke(); + } + + // === 坐标轴 & 标签 === + labels.forEach((label, i) => { + const angle = ((Math.PI * 2) / labels.length) * i - Math.PI / 2; + const x = center.x + radius * Math.cos(angle); + const y = center.y + radius * Math.sin(angle); + + // 坐标轴 + ctx.beginPath(); + ctx.moveTo(center.x, center.y); + ctx.lineTo(x, y); + ctx.strokeStyle = "#bbb"; + ctx.stroke(); + + // 标签 + const offset = 10; + const textX = center.x + (radius + offset) * Math.cos(angle); + const textY = center.y + (radius + offset) * Math.sin(angle); + + ctx.font = "12px sans-serif"; + ctx.fillStyle = "#333"; + ctx.textBaseline = "middle"; + + if (Math.abs(angle) < 0.01 || Math.abs(Math.abs(angle) - Math.PI) < 0.01) { + ctx.textAlign = "center"; + } else if (angle > -Math.PI / 2 && angle < Math.PI / 2) { + ctx.textAlign = "left"; + } else { + ctx.textAlign = "right"; + } + + ctx.fillText(label, textX, textY); + }); + + // === 数据区域 === + ctx.beginPath(); + values.forEach((val, i) => { + const angle = ((Math.PI * 2) / labels.length) * i - Math.PI / 2; + const r = (val / maxValue) * radius; + const x = center.x + r * Math.cos(angle); + const y = center.y + r * Math.sin(angle); + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + }); + ctx.closePath(); + ctx.fillStyle = "rgba(0,200,180,0.3)"; + ctx.fill(); + ctx.strokeStyle = "#00c8b4"; + ctx.lineWidth = 3; + ctx.stroke(); + }); + } + + useImperativeHandle(ref, () => ({ + generateImage: () => new Promise((resolve, reject) => { + const query = Taro.createSelectorQuery() + query.select("#radarCanvas") + .fields({ node: true, size: true }) + .exec((res) => { + const canvas = res[0].node + // ⚠️ 关键:传 canvas,而不是 canvasId + Taro.canvasToTempFilePath({ + canvas, + success: (res) => resolve(res.tempFilePath), + fail: (err) => reject(err), + }) + }) + }) + })) + + + // 保存为图片 + const saveImage = () => { + Taro.canvasToTempFilePath({ + canvasId: "radarCanvas", + success: (res) => { + Taro.saveImageToPhotosAlbum({ + filePath: res.tempFilePath, + success: () => Taro.showToast({ title: "保存成功" }), + }); + }, + }); + }; + + return ( + + + {/* */} + + ); +}); + +export default RadarChart; diff --git a/src/components/TimeSelector/TimeSelector.tsx b/src/components/TimeSelector/TimeSelector.tsx index dbe7fe8..86cc9d9 100644 --- a/src/components/TimeSelector/TimeSelector.tsx +++ b/src/components/TimeSelector/TimeSelector.tsx @@ -66,8 +66,11 @@ const TimeSelector: React.FC = ({ openPicker('start')}> 开始时间 - {getDate(value.start_time)} + {value.start_time && (<> + {getDate(value.start_time)} {getTime(value.start_time)} + )} + {!value.start_time && (请选择开始时间)} @@ -80,8 +83,9 @@ const TimeSelector: React.FC = ({ openPicker('end')}> 结束时间 - {showEndTime && ({getDate(value.end_time)})} - {getTime(value.end_time)} + {value.end_time && (<>{showEndTime && ({getDate(value.end_time)})} + {getTime(value.end_time)})} + {!value.end_time && (请选择结束时间)} diff --git a/src/components/UploadCover/index.tsx b/src/components/UploadCover/index.tsx index eaf9d51..07b4ff0 100644 --- a/src/components/UploadCover/index.tsx +++ b/src/components/UploadCover/index.tsx @@ -126,7 +126,7 @@ export default function UploadCover(props: UploadCoverProps) { value.map((item) => { return ( - + onDelete(item)} /> ) diff --git a/src/components/UploadCover/upload-source-popup.scss b/src/components/UploadCover/upload-source-popup.scss index 999c934..bb8fbcd 100644 --- a/src/components/UploadCover/upload-source-popup.scss +++ b/src/components/UploadCover/upload-source-popup.scss @@ -31,7 +31,8 @@ } .upload-popup-scroll-view { - max-height: calc(100vh - 260px); + // max-height: calc(100vh - 260px); + height: 440px; overflow-y: auto; .upload-popup-image-list { @@ -124,7 +125,7 @@ display: flex; width: 100%; height: 62px; - padding: 8px 10px 10px 10px; + padding: 8px 10px 50px 10px; box-sizing: border-box; justify-content: center; align-items: flex-start; diff --git a/src/components/UploadCover/upload-source-popup.tsx b/src/components/UploadCover/upload-source-popup.tsx index 6858b44..8a832c0 100644 --- a/src/components/UploadCover/upload-source-popup.tsx +++ b/src/components/UploadCover/upload-source-popup.tsx @@ -121,6 +121,7 @@ export default forwardRef(function UploadImage(props: UploadImageProps, ref) { {images.length > 0 ? ( @@ -128,7 +129,7 @@ export default forwardRef(function UploadImage(props: UploadImageProps, ref) { const isSelected = checkImageSelected(selectedImages, item) return ( handleImageClick(item)}> - + {isSelected ? ( diff --git a/src/components/UserInfo/index.scss b/src/components/UserInfo/index.scss index dfe55a1..91a350e 100644 --- a/src/components/UserInfo/index.scss +++ b/src/components/UserInfo/index.scss @@ -91,6 +91,31 @@ letter-spacing: 3.2%; color: rgba(0, 0, 0, 0.35); } + + // 可点击的统计项样式 + &.clickable { + // cursor: pointer; + transition: all 0.2s ease; + // padding: 4px 8px; + // border-radius: 8px; + + // &:hover { + // background-color: rgba(0, 0, 0, 0.05); + // } + + // &:active { + // background-color: rgba(0, 0, 0, 0.1); + // transform: scale(0.98); + // } + + .stat_number { + color: rgba(0, 0, 0, 0.9); + } + + .stat_label { + color: rgba(0, 0, 0, 0.5); + } + } } } @@ -98,6 +123,8 @@ display: flex; align-items: center; gap: 12px; + border: 1px solid rgba(0, 0, 0, 0.2); + border-radius: 20px; .follow_button { display: flex; @@ -106,14 +133,22 @@ padding: 12px 16px 12px 12px; height: 40px; background: #000000; - border: 0.5px solid rgba(0, 0, 0, 0.06); - border-radius: 999px; + border: none; + outline: none; cursor: pointer; transition: all 0.3s ease; &.following { background: #FFFFFF; color: #000000; + + .button_text { + color: #000000 !important; + } + } + + &:after { + border: none; } .button_icon { @@ -127,19 +162,16 @@ font-size: 14px; line-height: 1.4em; color: #FFFFFF; - - .following & { - color: #000000; - } } } .message_button { width: 40px; height: 40px; + padding: unset; background: #FFFFFF; border: 0.5px solid rgba(0, 0, 0, 0.12); - border-radius: 999px; + border-radius: 50%; display: flex; align-items: center; justify-content: center; @@ -147,8 +179,8 @@ transition: all 0.3s ease; .button_icon { - width: 18px; - height: 18px; + width: 20px; + height: 20px; } } @@ -210,7 +242,8 @@ gap: 8px; flex-wrap: wrap; - .tag_item { + .tag_item, + .button_edit { display: flex; align-items: center; gap: 4px; @@ -224,11 +257,7 @@ .tag_icon { width: 12px; height: 12px; - /* Frame 1912054928 */ - - - } .tag_text { @@ -240,15 +269,61 @@ color: #000000; } } + + .button_edit { + font-family: 'PingFang SC'; + font-weight: 500; + font-size: 11px; + line-height: 1.8em; + letter-spacing: -2.1%; + color: rgba(60, 60, 67, 0.6); + display: flex; + align-items: center; + position: relative; + padding-right: 20px; + + &::before, + &::after { + content: ''; + width: 6px; + height: 1px; + display: inline-block; + background-color: rgba(60, 60, 67, 0.6); + position: absolute; + right: 12px; + transform: rotate(45deg); + margin-left: 4px; + } + + &::after { + transform: rotate(-45deg); + translate: 4.2px 0; + } + } } - .bio_text { - font-family: 'PingFang SC'; - font-weight: 400; - font-size: 14px; - line-height: 1.571em; - color: rgba(0, 0, 0, 0.65); - white-space: pre-line; + .personal_profile { + .personal_profile_edit { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + + .edit_icon { + width: 16px; + height: 16px; + } + } + + .bio_text { + font-family: 'PingFang SC'; + font-weight: 400; + font-size: 14px; + line-height: 1.571em; + color: rgba(0, 0, 0, 0.65); + white-space: pre-line; + } + } } } diff --git a/src/components/UserInfo/index.tsx b/src/components/UserInfo/index.tsx index 2cc71f1..8c968e6 100644 --- a/src/components/UserInfo/index.tsx +++ b/src/components/UserInfo/index.tsx @@ -1,11 +1,15 @@ -import React from 'react'; -import Taro from '@tarojs/taro'; -import { View, Text, Image, Button } from '@tarojs/components'; -import './index.scss'; +import React, { useState } from "react"; +import Taro from "@tarojs/taro"; +import { View, Text, Image, Button } from "@tarojs/components"; +import "./index.scss"; + +import { EditModal } from "@/components"; +import { UserService } from "@/services/userService"; +import { PopupPicker } from "@/components/Picker/index"; // 用户信息接口 export interface UserInfo { - id: string; + id: string | number; nickname: string; avatar: string; join_date: string; @@ -16,17 +20,22 @@ export interface UserInfo { participated: number; }; personal_profile: string; - location: string; occupation: string; ntrp_level: string; phone?: string; - gender?: string; - - latitude?: string, - longitude?: 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 { user_info: UserInfo; @@ -35,13 +44,13 @@ interface UserInfoCardProps { on_follow?: () => void; on_message?: () => void; on_share?: () => void; + set_user_info?: (info: UserInfo) => void; } - // 处理编辑用户信息 const on_edit = () => { Taro.navigateTo({ - url: '/user_pages/edit/index' + url: "/user_pages/edit/index", }); }; // 用户信息卡片组件 @@ -51,8 +60,172 @@ export const UserInfoCard: React.FC = ({ is_following = false, on_follow, on_message, - on_share + on_share, + set_user_info, }) => { + console.log("UserInfoCard 用户信息:", user_info); + // 编辑个人简介弹窗状态 + const [edit_modal_visible, setEditModalVisible] = useState(false); + const [editing_field, setEditingField] = useState(""); + 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, setFormData] = useState({ ...user_info }); + + // 处理编辑弹窗 + const handle_open_edit_modal = (field: string) => { + 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") { + // 手动输入 + setEditingField(field); + setEditModalVisible(true); + } else { + 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); + + // 更新本地状态 + 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.error("保存失败:", 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 UserService.update_user_info({ ...field }); + // 更新本地状态 + setFormData((prev) => ({ ...prev, ...field })); + // setUserInfo((prev) => ({ ...prev, ...field })); + } else { + // 调用更新用户信息接口,只传递修改的字段 + const update_data = { [field as string]: value }; + await UserService.update_user_info(update_data); + + // 更新本地状态 + setFormData((prev) => ({ ...prev, [field as string]: value })); + // setUserInfo((prev) => ({ ...prev, [field as string]: value })); + } + + // 显示成功提示 + Taro.showToast({ + title: "保存成功", + icon: "success", + }); + } catch (error) { + console.error("保存失败:", 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 [country, province, city] = e; + handle_field_edit({ country, province, city }); + }; + + // 处理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 [country, province] = e; + handle_field_edit("occupation", `${country} ${province}`); + }; + const handle_edit_modal_cancel = () => { + 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", + }); + } + // 主办和参加暂时不处理,可以后续扩展 + }; + return ( {/* 头像和基本信息 */} @@ -64,21 +237,30 @@ export const UserInfoCard: React.FC = ({ {user_info.nickname} {user_info.join_date} - - + {is_current_user && ( + + + + )} {/* 统计数据 */} - + handle_stats_click("following")} + > {user_info.stats.following} 关注 - + handle_stats_click("friends")} + > {user_info.stats.friends} 球友 @@ -95,27 +277,31 @@ export const UserInfoCard: React.FC = ({ {/* 只有非当前用户才显示关注按钮 */} {!is_current_user && on_follow && ( )} {/* 只有非当前用户才显示消息按钮 */} - {!is_current_user && on_message && ( + {/* {!is_current_user && on_message && ( - )} + )} */} {/* 只有当前用户才显示分享按钮 */} {is_current_user && on_share && ( @@ -129,38 +315,155 @@ export const UserInfoCard: React.FC = ({ {/* 标签和简介 */} - - - {user_info.gender === "0" && ( - - )} - {user_info.gender === "1" && ( - - )} - - - - {user_info.ntrp_level || '未设置'} - - - {user_info.occupation || '未设置'} - - - - {user_info.location || '未设置'} - + {user_info.gender ? ( + + {user_info.gender === "0" && ( + + )} + {user_info.gender === "1" && ( + + )} + + ) : is_current_user ? ( + { handle_open_edit_modal('gender') }}> + 选择性别 + + ) : null} + {user_info.ntrp_level ? ( + + {`NTRP ${user_info.ntrp_level}`} + + ) : is_current_user ? ( + { handle_open_edit_modal('ntrp_level') }}> + 测测你的NTRP水平 + + ) : null} + {user_info.occupation ? ( + + {user_info.occupation.split(" ")[1]} + + ) : is_current_user ? ( + { handle_open_edit_modal('occupation') }}> + 选择职业 + + ) : null} + {user_info.country || user_info.province || user_info.city ? ( + + {`${user_info.province}${user_info.city}`} + + ) : is_current_user ? ( + handle_open_edit_modal('location')}> + 选择地区 + + ) : null} + + + {user_info.personal_profile ? ( + {user_info.personal_profile} + ) : is_current_user ? ( + handle_open_edit_modal("personal_profile")} + > + + 点击添加简介,让更多人了解你 + + ) : null} - {user_info.personal_profile} + + {/* 编辑个人简介弹窗 */} + + {/* 性别选择弹窗 */} + {gender_picker_visible && ( + + )} + {/* 地区选择弹窗 */} + {location_picker_visible && ( + + )} + {/* NTRP水平选择弹窗 */} + {ntrp_picker_visible && ( + + )} + {/* 职业选择弹窗 */} + {occupation_picker_visible && ( + + )} ); }; @@ -183,7 +486,9 @@ export interface GameRecord { current_participants: number; level_range: string; game_type: string; - images: string[]; + image_list: string[]; + deadline_hours: number; + end_time: string; } // 球局卡片组件属性 @@ -197,20 +502,17 @@ interface GameCardProps { export const GameCard: React.FC = ({ game, on_click, - on_participant_click + on_participant_click, }) => { return ( - on_click(game.id)} - > + on_click(game.id)}> {/* 球局标题和类型 */} {game.title} @@ -233,12 +535,8 @@ export const GameCard: React.FC = ({ {/* 球局图片 */} - {game.images.map((image, index) => ( - + {game.image_list.map((image, index) => ( + ))} @@ -246,7 +544,7 @@ export const GameCard: React.FC = ({ - {game.participants.map((participant, index) => ( + {game.participants?.map((participant, index) => ( = ({ // 球局标签页组件属性 interface GameTabsProps { - active_tab: 'hosted' | 'participated'; - on_tab_change: (tab: 'hosted' | 'participated') => void; + active_tab: "hosted" | "participated"; + on_tab_change: (tab: "hosted" | "participated") => void; is_current_user: boolean; } @@ -288,21 +586,28 @@ interface GameTabsProps { export const GameTabs: React.FC = ({ active_tab, on_tab_change, - is_current_user + is_current_user, }) => { - const hosted_text = is_current_user ? '我主办的' : '他主办的'; - const participated_text = is_current_user ? '我参与的' : '他参与的'; + const hosted_text = is_current_user ? "我主办的" : "主办球局"; + const participated_text = is_current_user ? "我参与的" : "参与球局"; return ( - on_tab_change('hosted')}> + on_tab_change("hosted")} + > {hosted_text} - on_tab_change('participated')}> + on_tab_change("participated")} + > {participated_text} ); -}; \ No newline at end of file +}; diff --git a/src/components/index.ts b/src/components/index.ts index e8526bb..be082a9 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -17,6 +17,12 @@ import withAuth from "./Auth"; import { CustomPicker, PopupPicker } from "./Picker"; import NTRPEvaluatePopup from "./NTRPEvaluatePopup"; import ShareCardCanvas from "./ShareCardCanvas"; +import RefundPopup from "./refundPopup"; +import GameManagePopup from './GameManagePopup'; +import FollowUserCard from './FollowUserCard/index'; +import Comments from "./Comments"; +import GeneralNavbar from "./GeneralNavbar"; +import RadarChart from './Radar' export { ActivityTypeSwitch, @@ -39,4 +45,10 @@ export { PopupPicker, NTRPEvaluatePopup, ShareCardCanvas, + RefundPopup, + GameManagePopup, + FollowUserCard, + Comments, + GeneralNavbar, + RadarChart, }; diff --git a/src/components/refundPopup/index.module.scss b/src/components/refundPopup/index.module.scss new file mode 100644 index 0000000..adbfc5a --- /dev/null +++ b/src/components/refundPopup/index.module.scss @@ -0,0 +1,132 @@ +.refundPolicy { + padding-top: 20px; + // .moduleTitle { + // display: flex; + // padding: 15px 0 8px; + // justify-content: space-between; + // align-items: center; + // align-self: stretch; + // color: #000; + // font-feature-settings: + // "liga" off, + // "clig" off; + // font-family: "PingFang SC"; + // font-size: 14px; + // font-style: normal; + // font-weight: 600; + // line-height: 20px; + // letter-spacing: -0.23px; + // } + + .specTips { + padding-bottom: 20px; + color: rgba(60, 60, 67, 0.60); + text-align: center; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 18px; + } + + .policyList { + border-radius: 12px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: #fff; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06); + + .policyItem { + display: flex; + justify-content: space-around; + align-items: center; + color: #000; + 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; + border-top: 1px solid rgba(0, 0, 0, 0.06); + + &:nth-child(1) { + color: #000; + text-align: center; + font-feature-settings: + "liga" off, + "clig" off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: 20px; + border: none; + } + + .time, + .rule { + width: 50%; + padding: 10px 12px; + } + + .rule { + border-left: 1px solid rgba(0, 0, 0, 0.06); + } + } + } +} + +.container { + padding: 0 15px 40px; + + .header { + padding: 24px 15px 0; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + + .title { + color: #000; + 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: 20px; + margin-left: auto; + } + + .closeIcon { + margin-left: auto; + width: 20px; + height: 20px; + } + } + + .action { + display: flex; + align-items: center; + justify-content: center; + margin-top: 20px; + padding: 2px 6px; + height: 52px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + backdrop-filter: blur(16px); + color: #fff; + background-color: #000; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 20px; + letter-spacing: -0.23px; + } +} \ No newline at end of file diff --git a/src/components/refundPopup/index.tsx b/src/components/refundPopup/index.tsx new file mode 100644 index 0000000..b7354d9 --- /dev/null +++ b/src/components/refundPopup/index.tsx @@ -0,0 +1,139 @@ +import React, { useState, useRef, forwardRef, useImperativeHandle } from 'react'; +import { View, Text, Button, Image } from '@tarojs/components' +import Taro from '@tarojs/taro'; +import dayjs from 'dayjs' +import { CommonPopup } from '@/components'; +import orderService from '@/services/orderService'; +import styles from './index.module.scss' +import closeIcon from '@/static/order/orderListClose.svg' + +function genRefundNotice (refund_policy) { + if (refund_policy.length === 0) { + return {} + } + const now = dayjs() + const deadlines = refund_policy.map(item => dayjs(item.deadline_formatted)) + let matchPolicyIndex = deadlines.findIndex(d => d.isAfter(now)) + if (matchPolicyIndex === -1) { + matchPolicyIndex = refund_policy.length - 1 + } + const { deadline_formatted, price, refund_rate } = refund_policy[matchPolicyIndex] + if (refund_rate === 1) { + return { refundPrice: price, notice: `本次可全额退款, ¥${price} 将原路退回,请查收` } + } else if (refund_rate === 0) { + return { refundPrice: 0, notice: `当前退出不可退款,后续流程未明确,@麻真瑜` } + } + const refundPrice = price * refund_rate + const leftHours = dayjs(deadline_formatted).diff(dayjs(), 'hour') + return { refundPrice, notice: `距活动开始已不足${leftHours}h,当前退出您需扣除${price - refundPrice}元` } +} + +function renderCancelContent(checkOrderInfo) { + const { refund_policy = [] } = checkOrderInfo; + const policyList = [ + { + time: "申请退款时间", + rule: "退款规则", + }, + ...refund_policy.map((item) => { + return { + time: item.application_time, + rule: item.refund_rule, + }; + }), + ]; + const { notice } = genRefundNotice(refund_policy) + return ( + + {/* + 退款政策 + */} + {{notice}} + {/* 订单信息摘要 */} + + {policyList.map((item, index) => ( + + {item.time} + {item.rule} + + ))} + + + ); +} + +export type RefundRef = { + show: (item: any, callback: (result: boolean) => void) => void +} + +export default forwardRef(function RefundPopup(_props, ref) { + const [visible, setVisible] = useState(false) + const [checkOrderInfo, setCheckOrderInfo] = useState({}) + const [orderData, setOrderData] = useState({}) + const onDown = useRef<((result: boolean) => void) | null>(null) + + useImperativeHandle(ref, () => ({ + show: onShow, + })) + + async function onShow (orderItem, onFinish: (result: boolean) => void) { + const { + game_info, + } = orderItem + onDown.current = onFinish + setOrderData(orderItem) + const res = await orderService.getCheckOrderInfo(game_info.id); + setCheckOrderInfo(res.data); + setVisible(true) + } + + function onClose () { + setVisible(false) + onDown.current?.(false) + } + + async function handleConfirmQuit () { + const { order_no, amount } = orderData + try { + const refundRes = await orderService.applicateRefund({ + order_no, + refund_amount: amount, + refund_reason: "用户主动退款", + }); + if (refundRes.code !== 0) { + throw new Error(refundRes.message); + } + Taro.showToast({ + title: "退出成功", + icon: "none", + }) + onDown.current?.(true) + } catch (e) { + Taro.showToast({ + title: e.message, + icon: "error", + }); + } finally { + onClose() + } + } + return ( + + + + 退出活动 + + + {renderCancelContent(checkOrderInfo)} + + + + ) +}) \ No newline at end of file diff --git a/src/config/api.ts b/src/config/api.ts index 0ab74ab..763f263 100644 --- a/src/config/api.ts +++ b/src/config/api.ts @@ -3,24 +3,25 @@ import envConfig from './env'// API配置 export const API_CONFIG = { // 基础URL BASE_URL: envConfig.apiBaseURL, - + // 用户相关接口 USER: { DETAIL: '/user/detail', UPDATE: '/user/update', - FOLLOW: '/user/follow', - UNFOLLOW: '/user/unfollow', + FOLLOW: '/wch_users/follow', + UNFOLLOW: '/wch_users/unfollow', HOSTED_GAMES: '/user/games', - PARTICIPATED_GAMES: '/user/participated' + PARTICIPATED_GAMES: '/user/participated', + PARSE_PHONE: '/user/parse_phone', }, - + // 文件上传接口 UPLOAD: { AVATAR: '/gallery/upload', IMAGE: '/gallery/upload', OSS_IMG: '/gallery/upload_oss_img' }, - + // 球局相关接口 GAME: { LIST: '/game/list', @@ -28,7 +29,9 @@ export const API_CONFIG = { CREATE: '/game/create', JOIN: '/game/join', LEAVE: '/game/leave' - } + }, + PROFESSIONS: '/professions/tree', + CITIS: '/cities/tree' }; // 请求拦截器配置 @@ -37,4 +40,4 @@ export const REQUEST_CONFIG = { header: { 'Content-Type': 'application/json' } -}; \ No newline at end of file +}; diff --git a/src/config/env.ts b/src/config/env.ts index 98f161d..00d5ebd 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -10,6 +10,13 @@ export interface EnvConfig { timeout: number enableLog: boolean enableMock: boolean + // 客服配置 + customerService: { + corpId: string + serviceUrl: string + phoneNumber?: string + email?: string + } } // 各环境配置 @@ -21,7 +28,14 @@ const envConfigs: Record = { // apiBaseURL: 'http://localhost:9098', timeout: 15000, enableLog: true, - enableMock: true + enableMock: true, + // 客服配置 + customerService: { + corpId: 'ww51fc969e8b76af82', // 企业ID + serviceUrl: 'https://work.weixin.qq.com/kfid/kfc64085b93243c5c91', + phoneNumber: '400-888-8888', + email: 'service@light120.com' + } }, @@ -31,7 +45,14 @@ const envConfigs: Record = { apiBaseURL: 'https://sit.light120.com', timeout: 10000, enableLog: false, - enableMock: false + enableMock: false, + // 客服配置 + customerService: { + corpId: 'ww51fc969e8b76af82', // 企业ID + serviceUrl: 'https://work.weixin.qq.com/kfid/kfc64085b93243c5c91', + phoneNumber: '400-888-8888', + email: 'service@light120.com' + } } } @@ -86,7 +107,7 @@ export const getEnvInfo = () => { config, taroEnv: Taro.getEnv(), platform: Taro.getEnv() === Taro.ENV_TYPE.WEAPP ? '微信小程序' : - Taro.getEnv() === Taro.ENV_TYPE.H5 ? 'H5' : + Taro.getEnv() === Taro.ENV_TYPE.WEB ? 'Web' : Taro.getEnv() === Taro.ENV_TYPE.RN ? 'React Native' : '未知' } } diff --git a/src/config/formSchema/publishBallFormSchema.ts b/src/config/formSchema/publishBallFormSchema.ts index d130a71..7904952 100644 --- a/src/config/formSchema/publishBallFormSchema.ts +++ b/src/config/formSchema/publishBallFormSchema.ts @@ -171,7 +171,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [ } }, { - prop: 'is_wechat_contact', + prop: 'wechat', label: '', type: FieldType.WECHATCONTACT, required: true, diff --git a/src/config/images.js b/src/config/images.js index 8009e5f..920240f 100644 --- a/src/config/images.js +++ b/src/config/images.js @@ -56,4 +56,14 @@ export default { ICON_LIST_SEARCH_CLEAR_HISTORY: require('@/static/search/icon-clear-history.svg'), ICON_LIST_SEARCH_SUGGESTION: require('@/static/search/icon-search-suggestion.svg'), ICON_LIST_INPUT_LOGO: require('@/static/list/icon-input-logo.svg'), - } \ No newline at end of file + ICON_IMPORTANT_BTN: require('@/static/publishBall/icon-important-btn.svg'), + ICON_IMPORTANT_BLACK: require('@/static/publishBall/icon-important-black.svg'), + ICON_ARROW_RIGHT_WHITE: require('@/static/publishBall/icon-arrow-right-white.svg'), + ICON_ARROW_RIGHT_BLACK: require('@/static/publishBall/icon-arrow-right-black.svg'), + ICON_EXAMINATION: require('@/static/userInfo/examination.svg'), + ICON_ARROW_GREEN: require('@/static/userInfo/arrow-green.svg'), + ICON_COPY: require('@/static/publishBall/icon-copy.svg'), + ICON_UPLOAD_IMG: require('@/static/publishBall/icon-upload-img.svg'), + ICON_UPLOAD_SUCCESS: require('@/static/publishBall/icon-upload-success.svg'), + ICON_CLOSE: require('@/static/publishBall/icon-close.svg'), + } diff --git a/src/container/listContainer/index.scss b/src/container/listContainer/index.scss index af26e6f..04bf2f4 100644 --- a/src/container/listContainer/index.scss +++ b/src/container/listContainer/index.scss @@ -5,7 +5,7 @@ flex-direction: column; gap: 5px; padding-bottom: 34px; - min-height: 100vh; + // min-height: 100vh; .recommendTextWrapper { display: flex; diff --git a/src/game_pages/detail/index.config.ts b/src/game_pages/detail/index.config.ts index eaeff9d..3275f5e 100644 --- a/src/game_pages/detail/index.config.ts +++ b/src/game_pages/detail/index.config.ts @@ -1,4 +1,5 @@ export default definePageConfig({ navigationBarTitleText: '球局详情', navigationStyle: 'custom', + enableShareAppMessage: true, }) diff --git a/src/game_pages/detail/index.scss b/src/game_pages/detail/index.scss index c8996da..9cdd85e 100644 --- a/src/game_pages/detail/index.scss +++ b/src/game_pages/detail/index.scss @@ -162,7 +162,6 @@ &-image { width: 28px; height: 28px; - border-radius: 50%; } } @@ -690,9 +689,11 @@ background: rgba(255, 255, 255, 0.16); flex: 0 0 auto; - &-avatar { + .participants-list-item-avatar { width: 60px; height: 60px; + border-radius: 50%; + overflow: hidden; } &-name { @@ -806,7 +807,7 @@ } &-organizer-recommend-games { - padding: 24px 15px 0; + padding: 24px 15px 10px; .organizer-title { overflow: hidden; @@ -836,6 +837,8 @@ &-avatar { width: 40px; height: 40px; + border-radius: 50%; + object-fit: cover; } &-message { @@ -1012,6 +1015,8 @@ &-avatar { width: 20px; height: 20px; + border-radius: 50%; + object-fit: cover; } &-message { @@ -1061,7 +1066,7 @@ display: flex; align-items: center; height: 52px; - width: 113px; + width: 120px; box-sizing: border-box; padding: 2px 20px; justify-content: center; @@ -1117,60 +1122,58 @@ } } - &-join-game { + .detail-main-action { display: flex; align-items: center; height: 52px; width: auto; - padding: 2px 6px; + // padding: 2px 6px; box-sizing: border-box; justify-content: center; - gap: 12px; + // gap: 12px; flex: 1 0 0; border-radius: 16px; - border: 1px solid rgba(0, 0, 0, 0.06); + // border: 1px solid rgba(0, 0, 0, 0.06); background: #fff; + overflow: hidden; - &-price { - font-family: "PoetsenOne"; - font-size: 28px; - font-weight: 400; - line-height: 24px; /* 114.286% */ - letter-spacing: -0.56px; - color: #000; + &.disabled { + background-color: #B4B4B4; + color: rgba(60, 60, 67, 0.60); + 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; } } } } } -.share-popup-content { - width: 100%; - height: 100%; - padding: 20px 16px env(safe-area-inset-bottom); - box-sizing: border-box; - // padding-bottom: env(safe-area-inset-bottom); - box-sizing: border-box; - display: flex; - justify-content: space-around; - align-items: center; - - & > view { - width: 100px; - height: 64px; - border-radius: 12px; - display: flex; - flex-direction: column; - align-items: center; - gap: 12px; - - & > image { - width: 24px; - height: 24px; - } - - & > text { - color: rgba(0, 0, 0, 0.85); - } - } -} diff --git a/src/game_pages/detail/index.tsx b/src/game_pages/detail/index.tsx index e3c929f..10e3d60 100644 --- a/src/game_pages/detail/index.tsx +++ b/src/game_pages/detail/index.tsx @@ -5,41 +5,53 @@ import React, { useImperativeHandle, forwardRef, } from "react"; -import { View, Text, Image, Map, ScrollView } from "@tarojs/components"; -import { Avatar } from "@nutui/nutui-react-taro"; +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 } from "@/components"; +import { + CommonPopup, + withAuth, + NTRPEvaluatePopup, + GameManagePopup, + Comments, +} from "@/components"; import { EvaluateType, SceneType, DisplayConditionType, } from "@/components/NTRPEvaluatePopup"; -import DetailService, { MATCH_STATUS } from "@/services/detailService"; +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 } = props; - const { avatar_url } = userInfo; + const { userInfo, handleViewUserInfo } = props; + const { avatar_url, id } = userInfo; const tags = [ { name: "🕙 急招", @@ -64,7 +76,9 @@ function GameTags(props) { {/* network image mock */} @@ -96,7 +110,6 @@ function Coursel(props) { async function getImagesMsg(imageList) { const latest_list: CourselItemType[] = []; const sys_info = await Taro.getSystemInfo(); - console.log(sys_info, "info"); const max_width = sys_info.screenWidth - 30; const max_height = 240; const current_aspect_ratio = max_width / max_height; @@ -163,14 +176,14 @@ const SharePopup = forwardRef( }, })); - // function handleShareToWechat() { - // useShareAppMessage(() => { - // return { - // title: '分享', - // path: `/game_pages/detail/index?id=${id}&from=share`, - // } - // }) - // } + 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(() => { @@ -181,17 +194,19 @@ const SharePopup = forwardRef( // }) // } - // function handleSaveToLocal() { - // Taro.saveImageToPhotosAlbum({ - // filePath: images[0], - // success: () => { - // Taro.showToast({ title: '保存成功', icon: 'success' }) - // }, - // fail: () => { - // Taro.showToast({ title: '保存失败', icon: 'none' }) - // }, - // }) - // } + 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 ( - - 分享卡片 + + + 分享卡片 + + + + + ); - }, + } ); function navto(url) { @@ -217,34 +248,74 @@ function navto(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 } = props; + const { + handleShare, + handleJoinGame, + detail, + onStatusChange, + handleAddComment, + getCommentCount, + } = props; + const [commentCount, setCommentCount] = useState(0); const ntrpRef = useRef(null); - // const userInfo = useUserInfo(); - // const { id } = userInfo; const { id, - publisher_id, - match_status, 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 } { + 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 = true; + // user_action_status.can_join = false; + // console.log(user_action_status, "user_action"); const { can_assess, can_join, @@ -253,54 +324,62 @@ function StickyButton(props) { is_substituting, waiting_start, } = user_action_status || {}; - if ( - Object.values(user_action_status).every((value) => !value) && - dayjs(end_time).isBefore(dayjs()) - ) { + if (MATCH_STATUS.CANCELED === match_status) { return { - text: "球局已结束,查看其他球局", - action: navto.bind(null, "/game_pages/list/index"), + 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: navto.bind(null, "/game_pages/list/index"), + text: () => ¥{displayPrice} 已加入, + action: () => toast("已加入"), }; } else if (is_substituting) { return { - text: "候补中,查看其他球局", - action: navto.bind(null, "/game_pages/list/index"), + text: () => ¥{displayPrice} 已加入候补, + action: () => toast("已加入候补"), }; } else if (can_pay) { return { - text: "继续支付", + text: () => ¥{price} 继续支付, action: async () => { const res = await OrderService.getUnpaidOrder(id); if (res.code === 0) { - Taro.navigateTo({ - url: `/order_pages/orderDetail/index?id=${res.data.order_info.order_id}`, - }); + navto( + `/order_pages/orderDetail/index?id=${res.data.order_info.order_id}` + ); } }, }; } else if (can_substitute) { return { - text: "立即候补", + text: () => ¥{displayPrice} 我要候补, action: handleJoinGame, }; } else if (can_join) { return { text: () => { - return ( - <> - 🎾 - 立即加入 - - ¥ {price} - - - ); + return ¥{displayPrice} 立即加入; }, action: handleJoinGame, }; @@ -312,8 +391,9 @@ function StickyButton(props) { types={[EvaluateType.EDIT, EvaluateType.EVALUATE]} scene={SceneType.DETAIL} displayCondition={DisplayConditionType.AUTO} + showGuide={false} > - NTRP自评 + ¥{displayPrice} 立即加入 ), action: handleSelfEvaluate, @@ -321,7 +401,7 @@ function StickyButton(props) { } return { text: "球局无法加入", - action: () => {}, + available: false, }; } @@ -329,7 +409,11 @@ function StickyButton(props) { return ""; } - const { text, action } = generateTextAndAction(user_action_status)!; + const { + text, + available = true, + action = () => {}, + } = generateTextAndAction(user_action_status)!; let ActionText: React.FC | string = text; @@ -339,36 +423,61 @@ function StickyButton(props) { }; } - // const role = Number(publisher_id) === id ? "ownner" : "visitor"; - return ( - - - - - 分享 + <> + + + + + 分享 + + + { + // Taro.showToast({ title: "To be continued", icon: "none" }); + handleAddComment(); + }} + > + + + {commentCount > 0 ? commentCount : "评论"} + + - { - Taro.showToast({ title: "To be continued", icon: "none" }); - }} + className={classnames( + "detail-main-action", + available ? "" : "disabled" + )} > - - 32 + + + + {is_organizer && ( + { + gameManageRef.current.show(detail, onStatusChange); + }} + > + 管理 + + )} - - - - + + ); } @@ -382,10 +491,10 @@ function GameInfo(props) { location_name, start_time, end_time, - weather = [{}], + weather, } = detail || {}; - const [{ iconDay, tempMax, tempMin }] = weather; + const [{ iconDay, tempMax, tempMin }] = weather || [{}]; const openMap = () => { Taro.openLocation({ @@ -539,7 +648,8 @@ function VenueInfo(props) { function previewImage(current_url) { Taro.previewImage({ current: current_url, - urls: venue_image_list.map((c) => c.url), + urls: + venue_image_list?.length > 0 ? venue_image_list.map((c) => c.url) : [], }); } return ( @@ -588,19 +698,21 @@ function VenueInfo(props) { 预定截图 - {venue_image_list.map((item) => { - return ( - - - - ); - })} + {venue_image_list?.length > 0 && + venue_image_list.map((item) => { + return ( + + + + ); + })} @@ -611,8 +723,8 @@ function VenueInfo(props) { function genNTRPRequirementText(min, max) { if (min && max && min !== max) { return `${min} - ${max} 之间`; - } else if (max === 1) { - return "没有要求"; + } else if (max === "1") { + return "无要求"; } else if (max) { return `${max} 以上`; } @@ -661,7 +773,7 @@ function GamePlayAndRequirement(props) { // 参与者 function Participants(props) { - const { detail = {}, handleJoinGame } = props; + const { detail = {}, handleJoinGame, handleViewUserInfo } = props; const participants = detail.participants || []; const { participant_count, @@ -672,10 +784,9 @@ function Participants(props) { user_action_status; const showApplicationEntry = [can_pay, can_substitute, is_substituting, waiting_start].every( - (item) => !item, + (item) => !item ) && can_join; const leftCount = max_participants - participant_count; - const organizer_id = Number(detail.publisher_id); return ( @@ -691,7 +802,6 @@ function Participants(props) { className="participants-list-application" onClick={() => { handleJoinGame(); - // Taro.showToast({ title: "To be continued", icon: "none" }); }} > {participants.map((participant) => { const { + is_organizer, user: { avatar_url, nickname, @@ -720,13 +833,17 @@ function Participants(props) { id: participant_user_id, }, } = participant; - const role = - participant_user_id === organizer_id ? "组织者" : "参与者"; + const role = is_organizer ? "组织者" : "参与者"; return ( - {nickname || "未知"} @@ -750,7 +867,7 @@ function Participants(props) { function SupplementalNotes(props) { const { - detail: { description, description_tag = [] }, + detail: { description, description_tag }, } = props; return ( @@ -760,7 +877,7 @@ function SupplementalNotes(props) { {/* supplemental notes tags */} - {insertDotInTags(description_tag).map((tag, index) => ( + {insertDotInTags(description_tag || []).map((tag, index) => ( {tag} @@ -808,7 +925,12 @@ function genRecommendGames(games, location, avatar) { avatar, applications: max_players, checkedApplications: current_players, - levelRequirements: `NTRP ${genNTRPRequirementText(skill_level_min, skill_level_max)}`, + levelRequirements: + skill_level_max !== skill_level_min + ? `${skill_level_min || "-"}至${skill_level_max || "-"}` + : skill_level_min === "1" + ? "无要求" + : `${skill_level_min}以上`, playType: play_type, }; }); @@ -819,6 +941,8 @@ function OrganizerInfo(props) { userInfo, currentLocation: location, onUpdateUserInfo = () => {}, + handleViewUserInfo, + handleAddComment, } = props; const { id, @@ -855,6 +979,10 @@ function OrganizerInfo(props) { } }; + function handleViewGame(gameId) { + navto(`/game_pages/detail/index?id=${gameId}&from=current`); + } + return ( {/* orgnizer title */} @@ -863,7 +991,12 @@ function OrganizerInfo(props) { {/* organizer avatar and name */} - + {nickname} @@ -893,7 +1026,10 @@ function OrganizerInfo(props) { )} )} - + handleAddComment()} + > {/* recommend games by organizer */} - - {}}> - TA的更多活动 - - - - - {recommendGames.map((game, index) => ( - - {/* game title */} - - {game.title} - - - {/* game time and range */} - - {game.time} - {game.timeLength} - - {/* game location、vunue、distance */} - - {game.venue} - · - {game.venueType} - · - {game.distance} - - {/* organizer avatar、applications、level requirements、play type */} - - - - - - 报名人数 {game.checkedApplications}/{game.applications} - - - - {game.levelRequirements} - - - {game.playType} + {recommendGames.length > 0 && ( + + + TA的更多活动 + + + + + {recommendGames.map((game, index) => ( + + {/* game title */} + + {game.title} + + + {/* game time and range */} + + {game.time} + {game.timeLength} + + {/* game location、vunue、distance */} + + {game.venue} + · + {game.venueType} + · + {game.distance} + + {/* organizer avatar、applications、level requirements、play type */} + + { + e.stopPropagation(); + handleViewUserInfo(id); + }} + /> + + + + 报名人数 {game.checkedApplications}/ + {game.applications} + + + + {game.levelRequirements} + + + {game.playType} + - - ))} - - - + ))} + + + + )} ); } @@ -973,8 +1124,12 @@ function Index() { 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(null); + const commentRef = useRef(); useDidShow(async () => { await updateLocation(); @@ -1003,10 +1158,16 @@ function Index() { const fetchDetail = async () => { if (!id) return; - const res = await DetailService.getDetail(Number(id)); - if (res.code === 0) { - setDetail(res.data); - fetchUserInfoById(res.data.publisher_id); + 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(); + } } }; @@ -1017,7 +1178,6 @@ function Index() { async function fetchUserInfoById(user_id) { const userDetailInfo = await LoginService.getUserInfoById(user_id); if (userDetailInfo.code === 0) { - // console.log(userDetailInfo.data); setUserInfo(userDetailInfo.data); } } @@ -1026,12 +1186,24 @@ function Index() { sharePopupRef.current.show(); } - const handleJoinGame = () => { - Taro.navigateTo({ - url: `/order_pages/orderDetail/index?gameId=${id}`, - }); + 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) { @@ -1043,7 +1215,10 @@ function Index() { } } - console.log("detail", detail); + function handleViewUserInfo(userId) { + navto(`/user_pages/other/index?userid=${userId}`); + } + const backgroundImage = detail?.image_list?.[0] ? { backgroundImage: `url(${detail?.image_list?.[0]})` } : {}; @@ -1073,7 +1248,11 @@ function Index() { {/* content */} {/* avatar and tags */} - + {/* title */} {detail.title} @@ -1085,7 +1264,11 @@ function Index() { {/* gameplay requirements */} {/* participants */} - + {/* supplemental notes */} {/* organizer and recommend games by organizer */} @@ -1094,12 +1277,28 @@ function Index() { userInfo={userInfo} currentLocation={currentLocation} onUpdateUserInfo={onUpdateUserInfo} + handleViewUserInfo={handleViewUserInfo} + handleAddComment={() => { + commentRef.current && commentRef.current.addComment(); + }} + /> + {/* sticky bottom action bar */} { + commentRef.current && commentRef.current.addComment(); + }} + getCommentCount={ + commentRef.current && commentRef.current.getCommentCount + } /> {/* share popup */} { @@ -163,11 +164,11 @@ const ListPage = () => { Taro.stopPullDownRefresh(); // 显示刷新成功提示 - Taro.showToast({ - title: "刷新成功", - icon: "success", - duration: 1000, - }); + // Taro.showToast({ + // title: "刷新成功", + // icon: "success", + // duration: 1000, + // }); } catch (error) { // 刷新失败时也停止动画 Taro.stopPullDownRefresh(); @@ -269,6 +270,19 @@ const ListPage = () => { // imageUrl: shareImagePath || '' // } // }) + // 初始化字典数据 + const initDictionaryData = async () => { + try { + const { fetchDictionary } = useDictionaryStore.getState(); + await fetchDictionary(); + } catch (error) { + console.error("初始化字典数据失败:", error); + } + } + + useEffect(() => { + initDictionaryData() + }, []); return ( <> diff --git a/src/home_pages/index.tsx b/src/home_pages/index.tsx index 29b5fe7..dfdc15e 100644 --- a/src/home_pages/index.tsx +++ b/src/home_pages/index.tsx @@ -10,7 +10,6 @@ const HomePage: React.FC = () => { useEffect(() => { const handleLoginRedirect = async () => { const login_status = check_login_status(); - if (login_status) { try { // 先获取用户信息 @@ -32,7 +31,7 @@ const HomePage: React.FC = () => { return ( - + ); } diff --git a/src/order_pages/orderDetail/index.module.scss b/src/order_pages/orderDetail/index.module.scss index d7aed40..136ae97 100644 --- a/src/order_pages/orderDetail/index.module.scss +++ b/src/order_pages/orderDetail/index.module.scss @@ -159,6 +159,34 @@ } } } + + .weather { + display: flex; + align-items: flex-end; + flex-direction: column; + gap: 4px; + + .weatherIcon { + width: 20px; + height: 20px; + color: rgba(0, 0, 0, 0.8); + } + + .temperature { + display: flex; + align-items: center; + gap: 12px; + color: rgba(0, 0, 0, 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; + } + } } &Place { @@ -229,7 +257,34 @@ .gameInfoActions { min-height: 12px; + padding: 0 12px; border-top: 0.5px solid rgba(0, 0, 0, 0.06); + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + + & > .button { + margin: 12px 0; + padding: 4px 10px; + height: 28px; + border-radius: 999px; + border: 0.5px solid rgba(0, 0, 0, 0.06); + color: #000; + font-size: 12px; + font-style: normal; + font-weight: 600; + line-height: 20px; + letter-spacing: -0.23px; + + &:first-child { + background: #000; + color: #fff; + &.payNow { + background-color: #ff3b30; + } + } + } } } @@ -299,6 +354,17 @@ justify-content: center; gap: 8px; } + + .orderNo { + display: flex; + justify-content: flex-end; + align-items: center; + gap: 8px; + + .copy { + color: #007AFF; + } + } } } } @@ -432,3 +498,59 @@ font-weight: 600; line-height: normal; } + +.dialogFooter { + // width: 100%; + width: calc(100% + 1px); + height: 44px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-end; + position: absolute; + // margin: 0 -24px -24px; + bottom: 0; + left: 0; + border-top: 1px solid rgba(0, 0, 0, 0.06); + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + overflow: hidden; + + & > .cancel, & > .confirm { + padding: 12px 10px; + height: 44px; + width: 50%; + text-align: center; + // border: 0.5px solid rgba(0, 0, 0, 0.06); + color: #000; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 20px; + + &:last-child { + background: #000; + color: #fff; + } + } + + & > .cancel { + border-radius: 0; + } + + & > .confirm { + border-radius: 0; + } +} + +.cancelTip { + padding: 12px 15px; + color: rgba(60, 60, 67, 0.60); + text-align: center; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; +} diff --git a/src/order_pages/orderDetail/index.tsx b/src/order_pages/orderDetail/index.tsx index 340b3ec..63c1d94 100644 --- a/src/order_pages/orderDetail/index.tsx +++ b/src/order_pages/orderDetail/index.tsx @@ -1,31 +1,77 @@ -import React, { useState } from "react"; +import React, { useState, useRef } from "react"; import { View, Text, Button, Image } from "@tarojs/components"; +import { Dialog } from "@nutui/nutui-react-taro"; import Taro, { useDidShow, useRouter } from "@tarojs/taro"; import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; +import classnames from "classnames"; import orderService, { + CancelType, GameOrderRes, OrderStatus, + refundTextMap, } from "@/services/orderService"; import { payOrder, delay, calculateDistance, getCurrentLocation, + getOrderStatus, + generateOrderActions, } from "@/utils"; import detailService, { GameData } from "@/services/detailService"; -import { withAuth } from "@/components"; +import { withAuth, RefundPopup } from "@/components"; import img from "@/config/images"; import { DECLAIMER } from "./config"; import styles from "./index.module.scss"; dayjs.locale("zh-cn"); +const gameNoticeMap = new Map([ + [ + "pending", + { title: "球局暂未开始", content: "球局开始前2小时,我们将通过短信通知你" }, + ], + [ + "pendinging", + { + title: "球局即将开始,请按时抵达球局", + content: "球局开始前2小时,我们将通过短信通知你", + }, + ], + ["progress", { title: "球局已开始", content: "友谊第一,比赛第二" }], + ["finish", { title: "球局已结束", content: "" }], +]); + +function genGameNotice(order_status, start_time) { + const startTime = dayjs(start_time); + let key = ""; + if (order_status === OrderStatus.FINISHED) { + key = "finish"; + } + const leftHour = startTime.diff(dayjs(), "hour"); + const start = startTime.isBefore(dayjs()); + if (start) { + key = "progress"; + } else if (leftHour > 2) { + key = "pending"; + } else if (leftHour < 2) { + key = "pendinging"; + } + + return gameNoticeMap.get(key) || {}; +} + function GameInfo(props) { - const { detail, currentLocation, orderDetail } = props; - const { order_status } = orderDetail; - const { latitude, longitude, location, location_name, start_time, end_time } = + const { detail, currentLocation, orderDetail, init } = props; + const { order_status, refund_status, amount } = orderDetail; + const { latitude, longitude, location, location_name, start_time, end_time, weather } = detail || {}; + const [{ iconDay, tempMax, tempMin }] = weather || [{}]; + + const refundRef = useRef(null); + const openMap = () => { Taro.openLocation({ latitude, // 纬度(必填) @@ -52,16 +98,138 @@ function GameInfo(props) { const startDate = `${startMonth}月${startDay}日 ${theDayOfWeek}`; const gameRange = `${startTime.format("HH:mm")} - ${endTime.format("HH:mm")}`; + const orderStatus = getOrderStatus(orderDetail); + + const gameNotice = genGameNotice(order_status, start_time); + + function handleViewGame(gameId) { + Taro.navigateTo({ + url: `/game_pages/detail/index?id=${gameId}&from=orderList`, + }); + } + + async function handleDeleteOrder(item) { + const { order_id } = item; + const onCancel = () => { + Dialog.close("detailCancelOrder"); + }; + const onConfirm = async () => { + try { + const deleteRes = await orderService.deleteOrder({ + order_id, + }); + if (deleteRes.code !== 0) { + throw new Error(deleteRes.message); + } + Taro.showToast({ + title: "删除成功", + icon: "none", + }); + delay(2000); + Taro.redirectTo({ url: "/order_pages/orderList/index" }); + } catch (e) { + Taro.showToast({ + title: e.message, + icon: "error", + }); + } finally { + Dialog.close("detailCancelOrder"); + } + }; + Dialog.open("detailCancelOrder", { + title: "确定删除订单吗?", + content: ( + + 删除订单后,您将无法恢复订单。请确认是否继续取消? + + ), + footer: ( + + + + + ), + onConfirm, + onCancel, + }); + } + + async function handleCancelOrder(item) { + const { order_no } = item; + const onCancel = () => { + Dialog.close("detailCancelOrder"); + }; + const onConfirm = async () => { + try { + const cancelRes = await orderService.cancelUnpaidOrder({ + order_no, + cancel_reason: "用户主动取消", + }); + if (cancelRes.code !== 0) { + throw new Error(cancelRes.message); + } + init(); + Taro.showToast({ + title: "取消成功", + icon: "none", + }); + } catch (e) { + Taro.showToast({ + title: e.message, + icon: "error", + }); + } finally { + Dialog.close("detailCancelOrder"); + } + }; + Dialog.open("detailCancelOrder", { + title: "确定取消订单吗?", + content: ( + + 取消订单后,您将无法恢复订单。请确认是否继续取消? + + ), + footer: ( + + + + + ), + onConfirm, + onCancel, + }); + } + + function handleQuit(item) { + if (refundRef.current) { + refundRef.current.show(item, (result) => { + if (result) { + init(); + } + }); + } + } + return ( - {Boolean(order_status) && order_status !== OrderStatus.PENDING && ( - <> - 已支付 ¥ 90 - - 球局暂未开始 - 球局开始前2小时,我们将通过短信通知你 - - + {["refund", "progress", "expired"].includes(orderStatus) && ( + + {refundTextMap.get(refund_status)} ¥ {amount} + + )} + {["progress", "expired"].includes(orderStatus) && ( + + {gameNotice.title} + {gameNotice.content && {gameNotice.content}} + )} {/* Date and Weather */} @@ -81,6 +249,21 @@ function GameInfo(props) { + + {/* Weather icon */} + + {/**/} + + + {/* Weather text and temperature */} + + {tempMin && tempMax && ( + + {tempMin}℃ - {tempMax}℃ + + )} + + {/* Place */} @@ -122,13 +305,42 @@ function GameInfo(props) { {/* Action bar */} - + + {orderDetail.order_id + ? generateOrderActions( + orderDetail, + { + handleDeleteOrder, + handleCancelOrder, + handleQuit, + handlePayNow: () => {}, + handleViewGame, + }, + "detail" + )?.map((obj) => ( + + )) + : ""} + +

+ ); } +function handleCopy(msg) { + Taro.setClipboardData({ + data: msg, + }); +} + function OrderMsg(props) { - const { detail, checkOrderInfo } = props; + const { detail, orderDetail, checkOrderInfo } = props; const { start_time, end_time, @@ -137,7 +349,8 @@ function OrderMsg(props) { wechat_contact, price, } = detail; - const { order_info: { registrant_nickname } = {} } = checkOrderInfo; + const { order_no } = orderDetail; + const { order_info: { registrant_phone } = {} } = checkOrderInfo; const startTime = dayjs(start_time); const endTime = dayjs(end_time); const startYear = startTime.format("YYYY"); @@ -160,17 +373,39 @@ function OrderMsg(props) { ), }, { - title: "组织者昵称", - content: registrant_nickname, + title: "报名人电话", + content: registrant_phone, }, { - title: "组织者电话", + title: "组织人微信号", + content: wechat_contact, + }, + { + title: "组织人电话", content: wechat_contact, }, { title: "费用", content: `${price} 元 / 人`, }, + ...(order_no + ? [ + { + title: "订单号", + content: ( + + {order_no} + + 复制 + + + ), + }, + ] + : []), ]; return ( @@ -199,14 +434,18 @@ function RefundPolicy(props) { rule: "退款规则", }, ...refund_policy.map((item, index) => { - const [, theTime] = item.application_time.split("undefined "); - const theTimeObj = dayjs(theTime); + const isLast = index === refund_policy.length - 1; + const theTimeObj = dayjs( + isLast + ? refund_policy.at(-2).deadline_formatted + : item.deadline_formatted + ); const year = theTimeObj.format("YYYY"); const month = theTimeObj.format("M"); const day = theTimeObj.format("D"); - const time = theTimeObj.format("HH:MM"); + const time = theTimeObj.format("HH:mm"); return { - time: `${year}年${month}月${day}日${time}${index === 0 ? "前" : "后"}`, + time: `${year}年${month}月${day}日${time} ${isLast ? "后" : "前"}`, rule: item.refund_rule, }; }), @@ -247,7 +486,11 @@ const OrderCheck = () => { const [checkOrderInfo, setCheckOrderInfo] = useState({}); const [orderDetail, setOrderDetail] = useState({}); - useDidShow(async () => { + useDidShow(() => { + init() + }); + + async function init() { let gameDetail = {}; if (id) { const res = await orderService.getOrderDetail(id); @@ -265,7 +508,7 @@ const OrderCheck = () => { setDetail(gameDetail); onInit(gameDetail.id); } - }); + } async function checkOrder(gid) { const orderRes = await orderService.getCheckOrderInfo(gid); @@ -297,24 +540,33 @@ const OrderCheck = () => { mask: true, }); + let payment_params = {} try { - const payment_params = await getPaymentParams(); + payment_params = await getPaymentParams(); await payOrder(payment_params); Taro.hideLoading(); Taro.showToast({ title: "支付成功", icon: "success", }); - await delay(1000); - Taro.navigateBack({ - delta: 1, - }); + // Taro.navigateBack({ + // delta: 1, + // }); } catch (error) { Taro.hideLoading(); Taro.showToast({ title: error.message, icon: "none", }); + } finally { + await delay(1000); + if (!id) { + Taro.redirectTo({ + url: `/order_pages/orderDetail/index?id=${payment_params.order_id}`, + }); + } else { + init() + } } }; if (!id && !gameId) { @@ -332,6 +584,9 @@ const OrderCheck = () => { ); } + + const { order_status, cancel_type } = orderDetail; + return ( {/* Game Date and Address */} @@ -339,16 +594,23 @@ const OrderCheck = () => { detail={detail} orderDetail={orderDetail} currentLocation={location} + init={init} /> {/* Order message */} - + {/* Refund policy */} {/* Disclaimer */} - {(!id || orderDetail.order_status === OrderStatus.PENDING) && ( + {(!id || + (order_status === OrderStatus.PENDING && + cancel_type === CancelType.NONE)) && ( )} diff --git a/src/order_pages/orderList/index.module.scss b/src/order_pages/orderList/index.module.scss index e42fc43..a863198 100644 --- a/src/order_pages/orderList/index.module.scss +++ b/src/order_pages/orderList/index.module.scss @@ -1,81 +1,158 @@ @use "~@/scss/images.scss" as img; .container { - padding: 12px; + padding: 12px 12px 40px; background-color: #fafafa; - min-height: 100vh; + height: 100vh; + width: 100%; + box-sizing: border-box; + + .list { + height: 100%; + width: 100%; + position: relative; + background-color: #fff; + + // .bg { + // position: absolute; + // left: 0; + // top: 0; + // width: 100%; + // height: 100%; + // background-color: #fafafa; + // z-index: -1; + // } + + .endTips { + height: 40px; + display: flex; + align-items: center; + justify-content: center; + color: rgba(0, 0, 0, 0.8); + font-feature-settings: + "liga" off, + "clig" off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 18px; + background-color: #f9f9f9; + } + } .orderItem { width: 100%; - height: 222px; + // height: 222px; background-color: #fff; border-radius: 12px; border: 1px solid rgba(0, 0, 0, 0.06); box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06); margin-bottom: 12px; - .orderTitle { - height: 18px; - padding: 15px 15px 12px; - border-bottom: 1px solid rgba(0, 0, 0, 0.06); - display: flex; - align-items: center; - justify-content: space-between; + // .orderTitle { + // height: 18px; + // padding: 15px 15px 12px; + // border-bottom: 1px solid rgba(0, 0, 0, 0.06); + // display: flex; + // align-items: center; + // justify-content: space-between; - .userInfo { + // .userInfo { + // display: flex; + // align-items: center; + // justify-content: flex-start; + // gap: 6px; + + // .avatar { + // width: 16px; + // height: 16px; + // } + + // .nickName { + // display: contents; + // .nickNameText { + // color: #000; + // font-feature-settings: + // "liga" off, + // "clig" off; + // font-family: "PingFang SC"; + // font-size: 12px; + // font-style: normal; + // font-weight: 500; + // line-height: 18px; + // } + // .arrowRight { + // width: 8px; + // height: 8px; + // } + // } + // } + + // .paidInfo { + // display: flex; + // align-items: center; + // justify-content: flex-end; + // gap: 8px; + + // .payTime { + // font-feature-settings: + // "liga" off, + // "clig" off; + // font-family: "PingFang SC"; + // font-size: 12px; + // font-style: normal; + // font-weight: 400; + // line-height: 18px; + + // &.paid { + // color: rgba(60, 60, 67, 0.6); + // } + + // &.pending { + // color: #000; + // } + // } + + // .payNum { + // font-feature-settings: + // "liga" off, + // "clig" off; + // font-family: "PingFang SC"; + // font-size: 12px; + // font-style: normal; + // font-weight: 600; + // line-height: 18px; + // &.paid { + // color: #000; + // } + + // &.pending { + // color: #ff3b30; + // } + // } + // } + // } + + .gameInfo { + height: 122px; + + .gameTitle { display: flex; align-items: center; - justify-content: flex-start; - gap: 6px; + justify-content: space-between; + padding: 12px 15px 0; - .avatar { - width: 16px; - height: 16px; - } - - .nickName { - display: contents; - .nickNameText { - color: #000; - font-feature-settings: - "liga" off, - "clig" off; - font-family: "PingFang SC"; - font-size: 12px; - font-style: normal; - font-weight: 500; - line-height: 18px; - } - .arrowRight { - width: 8px; - height: 8px; - } - } - } - - .paidInfo { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 8px; - - .payTime { - font-feature-settings: - "liga" off, - "clig" off; + .title { + overflow: hidden; + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; font-family: "PingFang SC"; - font-size: 12px; + font-size: 16px; font-style: normal; - font-weight: 400; - line-height: 18px; - - &.paid { - color: rgba(60, 60, 67, 0.6); - } - - &.pending { - color: #000; - } + font-weight: 600; + line-height: 24px; /* 150% */ } .payNum { @@ -96,14 +173,96 @@ } } } - } - .gameInfo { - height: 122px; + .gameTime { + padding: 6px 0 0 15px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; + color: rgba(60, 60, 67, 0.60); + 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% */ + } + + .address { + padding: 6px 0 0 15px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + + color: rgba(60, 60, 67, 0.60); + 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% */ + } + + .gameOtherInfo { + padding: 8px 0 12px 15px; + height: 20px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 4px; + + .avatarCards { + display: flex; + align-items: center; + justify-content: flex-start; + height: 20px; + + .avatar { + width: 20px; + height: 20px; + border-radius: 50%; + border: 1px solid rgba(0, 0, 0, 0.06); + &+.avatar { + margin-left: -10px; + } + } + } + + .participantProgress, .levelReq, .playType { + display: flex; + height: 20px; + padding: 0px 8px; + align-items: center; + gap: 4px; + border-radius: 999px; + border: 0.5px solid rgba(0, 0, 0, 0.16); + background: #FFF; + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 11px; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 181.818% */ + letter-spacing: -0.23px; + } + + .participantProgress { + color: #c4c4c7; + + .current { + color: #000; + } + } + } } .orderActions { - height: 28px; + min-height: 28px; padding: 12px 12px 15px; border-top: 1px solid rgba(0, 0, 0, 0.06); @@ -135,6 +294,9 @@ &:last-child { background: #000; color: #fff; + &.payNow { + background-color: #ff3b30; + } } } @@ -142,76 +304,84 @@ } .payNow { + background-color: #ff3b30; } } } } } -.refundPolicy { - .moduleTitle { - display: flex; - padding: 15px 0 8px; - justify-content: space-between; - align-items: center; - align-self: stretch; +.dialogFooter { + // width: 100%; + width: calc(100% + 1px); + height: 44px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: flex-end; + position: absolute; + // margin: 0 -24px -24px; + bottom: 0; + left: 0; + border-top: 1px solid rgba(0, 0, 0, 0.06); + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + overflow: hidden; + + & > .cancel, & > .confirm { + padding: 12px 10px; + height: 44px; + width: 50%; + text-align: center; + // border: 0.5px solid rgba(0, 0, 0, 0.06); color: #000; - font-feature-settings: - "liga" off, - "clig" off; - font-family: "PingFang SC"; - font-size: 14px; + font-size: 16px; font-style: normal; font-weight: 600; line-height: 20px; - letter-spacing: -0.23px; - } - .policyList { - border-radius: 12px; - border: 1px solid rgba(0, 0, 0, 0.06); - background: #fff; - box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.06); - - .policyItem { - display: flex; - justify-content: space-around; - align-items: center; - color: #000; - 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; - border-top: 1px solid rgba(0, 0, 0, 0.06); - - &:nth-child(1) { - color: #000; - text-align: center; - font-feature-settings: - "liga" off, - "clig" off; - font-family: "PingFang SC"; - font-size: 14px; - font-style: normal; - font-weight: 600; - line-height: 20px; - border: none; - } - - .time, - .rule { - width: 50%; - padding: 10px 12px; - } - - .rule { - border-left: 1px solid rgba(0, 0, 0, 0.06); - } + &:last-child { + background: #000; + color: #fff; } } + + & > .cancel { + border-radius: 0; + } + + & > .confirm { + border-radius: 0; + } +} + +.cancelTip { + padding: 12px 15px; + color: rgba(60, 60, 67, 0.60); + text-align: center; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: 24px; +} + +.emptyNotice { + height: 40vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + + .emptyTip { + 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: 500; + line-height: 24px; + } } diff --git a/src/order_pages/orderList/index.tsx b/src/order_pages/orderList/index.tsx index 070b52b..df326c9 100644 --- a/src/order_pages/orderList/index.tsx +++ b/src/order_pages/orderList/index.tsx @@ -1,36 +1,109 @@ -import React, { useState } from "react"; -import { View, Text, Button, Image } from "@tarojs/components"; +import React, { useState, useEffect, useRef } from "react"; +import { View, Text, Button, Image, ScrollView } from "@tarojs/components"; import Taro, { useDidShow } from "@tarojs/taro"; import { Avatar, Dialog } from "@nutui/nutui-react-taro"; import dayjs from "dayjs"; +import "dayjs/locale/zh-cn"; import classnames from "classnames"; -import orderService, { OrderStatus, CancelType } from "@/services/orderService"; -import { withAuth } from "@/components"; -import { payOrder } from "@/utils"; +import orderService, { OrderStatus, CancelType, refundTextMap } from "@/services/orderService"; +import { withAuth, RefundPopup } from "@/components"; +import { payOrder, generateOrderActions } from "@/utils"; +import emptyContent from "@/static/emptyStatus/publish-empty.png"; import styles from "./index.module.scss"; -import orderListArrowRight from "@/static/order/orderListArrowRight.svg"; + +dayjs.locale("zh-cn"); + +const PAGESIZE = 100; + +// 将·作为连接符插入到标签文本之间 +function insertDotInTags(tags: string[]) { + return tags.join("-·-").split("-"); +} + +const diffDayMap = new Map([ + [0, "今天"], + [1, "明天"], + [2, "后天"], +]); + +const DayOfWeekMap = new Map([ + [0, "周日"], + [1, "周一"], + [2, "周二"], + [3, "周三"], + [4, "周四"], + [5, "周五"], + [6, "周六"], +]); + +function generateTimeMsg(game_info) { + const { start_time, end_time } = game_info; + const startTime = dayjs(start_time); + const endTime = dayjs(end_time); + const diffDay = startTime.startOf("day").diff(dayjs().startOf("day"), "day"); + + const dayofWeek = startTime.day(); + const gameLength = `${endTime.diff(startTime, "hour")}小时`; + return ( + <> + + {diffDay <= 2 && diffDay >= 0 + ? diffDayMap.get(diffDay) + : startTime.format("YYYY-MM-DD")} + + ({DayOfWeekMap.get(dayofWeek)}) + {startTime.format("ah")}点 + {gameLength} + + ); +} const OrderList = () => { - const [list, setList] = useState([]); + const [list, setList] = useState([]); + const [total, setTotal] = useState(0); + const refundRef = useRef(null); - useDidShow(() => { - getOrders(); - }); + const end = list.length * PAGESIZE >= total; - async function getOrders() { - const res = await orderService.getOrderList(); - console.log(res); + useEffect(() => { + getOrders(1); + }, []); + + function addPageInfo(arr, page) { + return arr.map((item) => ({ ...item, page })); + } + + // clear 是否清除当前页后面的数据(如果有的话,没有也不影响) + async function getOrders(page, clear = true) { + const res = await orderService.getOrderList({ page, pageSize: PAGESIZE }); if (res.code === 0) { - setList(res.data.rows); + setTotal(res.data.count); + setList((prev) => { + const newList = [...prev]; + const index = page - 1; + newList.splice( + index, + clear ? newList.length - index : 1, + addPageInfo(res.data.rows, page) + ); + return newList; + }); } } - async function handlePayNow(gameId) { + function handleFetchNext() { + console.log("scroll"); + if (!end) { + getOrders(list.length + 1); + } + } + + async function handlePayNow(item) { try { - const unPaidRes = await orderService.getUnpaidOrder(gameId); + const unPaidRes = await orderService.getUnpaidOrder(item.game_info?.id); if (unPaidRes.code === 0 && unPaidRes.data.has_unpaid_order) { await payOrder(unPaidRes.data.payment_params); - getOrders(); + getOrders(item.page, false); } else { throw new Error("支付调用失败"); } @@ -43,197 +116,290 @@ const OrderList = () => { } } - function renderCancelContent(checkOrderInfo) { - const { refund_policy = [] } = checkOrderInfo; - const policyList = [ - { - time: "申请退款时间", - rule: "退款规则", - }, - ...refund_policy.map((item, index) => { - const [, theTime] = item.application_time.split("undefined "); - const theTimeObj = dayjs(theTime); - const year = theTimeObj.format("YYYY"); - const month = theTimeObj.format("M"); - const day = theTimeObj.format("D"); - const time = theTimeObj.format("HH:MM"); - return { - time: `${year}年${month}月${day}日${time}${index === 0 ? "前" : "后"}`, - rule: item.refund_rule, - }; - }), - ]; - return ( - - - 退款政策 + function handleViewGame(gameId) { + if (!gameId) { + Taro.showToast({ title: "球局未找到", icon: "error" }); + return; + } + Taro.navigateTo({ + url: `/game_pages/detail/index?id=${gameId}&from=orderList`, + }); + } + + async function handleDeleteOrder(item) { + const { id: order_id } = item; + // TODO:删除订单,刷新这一页,然后后面的全清除掉 + const onCancel = () => { + Dialog.close("cancelOrder"); + }; + const onConfirm = async () => { + try { + const deleteRes = await orderService.deleteOrder({ + order_id, + }); + if (deleteRes.code !== 0) { + throw new Error(deleteRes.message); + } + getOrders(item.page); + Taro.showToast({ + title: "删除成功", + icon: "none", + }); + } catch (e) { + Taro.showToast({ + title: e.message, + icon: "error", + }); + } finally { + Dialog.close("cancelOrder"); + } + }; + Dialog.open("cancelOrder", { + title: "确定删除订单吗?", + content: ( + + 删除订单后,您将无法恢复订单。请确认是否继续取消? - {/* 订单信息摘要 */} - - {policyList.map((item, index) => ( - - {item.time} - {item.rule} - - ))} + ), + footer: ( + + + - - ); + ), + onConfirm, + onCancel, + }); } async function handleCancelOrder(item) { - const { order_no, order_status, game_info, amount } = item; - if (order_status === OrderStatus.PENDING) { - Dialog.open("cancelOrder", { - title: "确定取消订单吗?", - content: "取消订单后,您将无法恢复订单。请确认是否继续取消?", - onConfirm: async () => { - try { - const cancelRes = await orderService.cancelUnpaidOrder({ - order_no, - cancel_reason: "用户主动取消", - }); - if (cancelRes.code !== 0) { - throw new Error(cancelRes.message); - } - getOrders(); - } catch (e) { - Taro.showToast({ - title: e.message, - icon: "error", - }); - } finally { - Dialog.close("cancelOrder"); - } - }, - onCancel: () => { - Dialog.close("cancelOrder"); - }, - }); - return; - } - const res = await orderService.getCheckOrderInfo(game_info.id); + const { order_no } = item; + const onCancel = () => { + Dialog.close("cancelOrder"); + }; + const onConfirm = async () => { + try { + const cancelRes = await orderService.cancelUnpaidOrder({ + order_no, + cancel_reason: "用户主动取消", + }); + if (cancelRes.code !== 0) { + throw new Error(cancelRes.message); + } + getOrders(item.page, false); + Taro.showToast({ + title: "取消成功", + icon: "none", + }); + } catch (e) { + Taro.showToast({ + title: e.message, + icon: "error", + }); + } finally { + Dialog.close("cancelOrder"); + } + }; Dialog.open("cancelOrder", { title: "确定取消订单吗?", - content: renderCancelContent(res.data), - onConfirm: async () => { - try { - const refundRes = await orderService.applicateRefund({ - order_no, - refund_amount: amount, - refund_reason: "用户主动退款", - }); - if (refundRes.code !== 0) { - throw new Error(refundRes.message); - } - getOrders(); - } catch (e) { - Taro.showToast({ - title: e.message, - icon: "error", - }); - } finally { - Dialog.close("cancelOrder"); - } - }, - onCancel: () => { - Dialog.close("cancelOrder"); - }, + content: ( + + 取消订单后,您将无法恢复订单。请确认是否继续取消? + + ), + footer: ( + + + + + ), + onConfirm, + onCancel, }); } + function handleQuit(item) { + if (refundRef.current) { + refundRef.current.show(item, (result) => { + if (result) { + getOrders(item.page); + } + }); + } + } + function handleViewOrderDetail(orderId) { Taro.navigateTo({ url: `/order_pages/orderDetail/index?id=${orderId}`, }); } + const flatList = list.flat() + return ( - {list.map((item) => { - const unPay = item.order_status === OrderStatus.PENDING; - const expired = - item.order_status === OrderStatus.FINISHED || - [CancelType.TIMEOUT, CancelType.USER].includes(item.cancel_type); - const expiredTime = dayjs(item.expire_time).isSame(dayjs(), "day") - ? dayjs(item.expire_time).format("HH:mm:ss") - : dayjs(item.expire_time).format("YYYY-MM-DD HH:mm:ss"); - const showCancel = - item.order_status !== OrderStatus.FINISHED && - item.cancel_type === CancelType.NONE; + + {/* */} + {flatList.map((item) => { + const unPay = + item.order_status === OrderStatus.PENDING && + item.cancel_type === CancelType.NONE; + const { game_info } = item; - return ( - - - - - - Light - - - - {expired ? ( - "" - ) : ( - - - {unPay - ? `请在 ${expiredTime} 前支付` - : dayjs(item.pay_time).format("YYYY-MM-DD HH:mm:ss")} - - + + + {item?.game_info?.title} + - {unPay ? "待支付" : "已支付"} ¥ {item.amount} - + {unPay ? "待支付" : refundTextMap.get(item.refund_status)} ¥{" "} + {item.amount} + + + + {generateTimeMsg(item.game_info || {})} + + + {insertDotInTags([location_name, court_type, "3.5km"]).map( + (text, index) => ( + {text} + ) + )} + + + {participants?.length >= 0 ? ( + + { + /* participants */ [ + { + user: { + avatar_url: "https://img.yzcdn.cn/vant/cat.jpeg", + id: 1, + }, + }, + { + user: { + avatar_url: "https://img.yzcdn.cn/vant/cat.jpeg", + id: 2, + }, + }, + { + user: { + avatar_url: "https://img.yzcdn.cn/vant/cat.jpeg", + id: 3, + }, + }, + ].map((participant) => { + const { + user: { avatar_url, id }, + } = participant; + return ( + + ); + }) + } + + ) : ( + "" + )} + + + 报名人数 {current_players} + + / + {max_players} + + + {skill_level_max !== skill_level_min + ? `${skill_level_min || "-"} 至 ${skill_level_max || "-"}` + : skill_level_min === 1 + ? "无要求" + : `${skill_level_min} 以上`} + + {play_type} + + + + + + {generateOrderActions( + item, + { + handleDeleteOrder, + handleCancelOrder, + handleQuit, + handlePayNow, + handleViewGame, + }, + "list" + )?.map((obj) => ( + + ))} - )} - - - {item?.game_info?.title} - - - - - {showCancel && ( - - )} - {unPay && !expired && ( - - )} + ); + })} + {flatList.length > 0 && end && ( + 已经到底了~ + )} + {flatList.length === 0 && ( + + + 暂时没有订单 - ); - })} + )} + + ); }; diff --git a/src/other_pages/message/index.scss b/src/other_pages/message/index.scss index 637ed05..d2730dc 100644 --- a/src/other_pages/message/index.scss +++ b/src/other_pages/message/index.scss @@ -9,12 +9,13 @@ // 导航栏 .navbar { - height: 100px; + height: 56px; background: #FFFFFF; padding-top: 44px; position: sticky; top: 0; z-index: 100; + box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.06); .navbar-content { height: 56px; @@ -50,13 +51,13 @@ flex: 1; overflow: hidden; box-sizing: border-box; - margin-bottom:100px; + // margin-bottom:100px; background-color: none !important; .message-list-content { display: flex; flex-direction: column; - padding: 0 12px; + padding: 12px 12px 112px; gap: 8px; } diff --git a/src/other_pages/message/index.tsx b/src/other_pages/message/index.tsx index b48b9dc..e4a6653 100644 --- a/src/other_pages/message/index.tsx +++ b/src/other_pages/message/index.tsx @@ -112,7 +112,7 @@ const Message = () => { {/* 消息列表 */} - + {filteredMessages.length > 0 ? ( {filteredMessages.map(renderMessageItem)} diff --git a/src/other_pages/ntrp-evaluate/index.config.ts b/src/other_pages/ntrp-evaluate/index.config.ts index 99e9ac1..f001986 100644 --- a/src/other_pages/ntrp-evaluate/index.config.ts +++ b/src/other_pages/ntrp-evaluate/index.config.ts @@ -1,5 +1,6 @@ export default definePageConfig({ - navigationBarTitleText: "NTRP 评测", + // navigationBarTitleText: "NTRP 评测", // navigationBarBackgroundColor: '#FAFAFA', - // navigationStyle: 'custom', + navigationStyle: 'custom', + enableShareAppMessage: true, }); diff --git a/src/other_pages/ntrp-evaluate/index.module.scss b/src/other_pages/ntrp-evaluate/index.module.scss index f66c2da..122d07a 100644 --- a/src/other_pages/ntrp-evaluate/index.module.scss +++ b/src/other_pages/ntrp-evaluate/index.module.scss @@ -62,3 +62,625 @@ } } } + +.header { + display: flex; + justify-content: space-between; + align-items: center; + height: 44px; + padding: 46px 42px 0 10px; + + .closeIcon { + width: 32px; + height: 32px; + margin-right: auto; + + .closeImg { + width: 100%; + height: 100%; + } + } + + .title { + flex: 1; + margin: auto; + display: flex; + align-items: center; + justify-content: center; + } +} + +@mixin commonAvatarStyle($multiple: 1) { + .avatar { + flex: 0 0 auto; + width: calc(100px * $multiple); + height: calc(100px * $multiple); + display: flex; + align-items: center; + justify-content: center; + background-color: #fff; + border-radius: 50%; + border: 1px solid #efefef; + overflow: hidden; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.20), 0 8px 20px 0 rgba(0, 0, 0, 0.12); + + .avatarUrl { + width: calc(90px * $multiple); + height: calc(90px * $multiple); + border-radius: 50%; + border: 1px solid #efefef; + } + } + + .addonImage { + flex: 0 0 auto; + width: calc(88px * $multiple); + height: calc(88px * $multiple); + transform: rotate(8deg); + flex-shrink: 0; + aspect-ratio: 1/1; + border-radius: calc(20px * $multiple); + border: 4px solid #FFF; + background: linear-gradient(0deg, rgba(89, 255, 214, 0.20) 0%, rgba(89, 255, 214, 0.20) 100%), #FFF; + box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.12); + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; + margin-left: calc(-1 * 20px * $multiple); + + .docImage { + width: calc(48px * $multiple); + height: calc(48px * $multiple); + transform: rotate(-7deg); + flex-shrink: 0; + } + } +} + +.introContainer { + width: 100vw; + height: 100vh; + background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA; + + .result { + + .avatarWrap { + width: 200px; + height: 100px; + padding: 30px 0 0 30px; + display: flex; + align-items: center; + justify-content: flex-start; + @include commonAvatarStyle(1); + } + + .tip { + padding: 0 30px; + + .tipImage { + width: 100%; + } + } + + .lastResult { + margin: 40px 22px; + display: flex; + padding: 16px 20px 20px 20px; + flex-direction: column; + justify-content: center; + align-items: flex-start; + gap: 8px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: #FFF; + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + + .tipAndTime { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + color: rgba(0, 0, 0, 0.65); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "Noto Sans SC"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 24px; + } + + .levelWrap { + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; + font-family: "Noto Sans SC"; + font-size: 32px; + font-style: normal; + font-weight: 900; + line-height: 36px; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 10px; + + .level { + color: #00E5AD; + } + } + + .slogan { + color: #000; + font-family: "Noto Sans SC"; + font-size: 16px; + font-style: normal; + font-weight: 700; + line-height: 22px; + } + } + + .actions { + margin: 0 22px; + display: flex; + flex-direction: column; + gap: 10px; + + .buttonWrap { + width: 100%; + height: 52px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + overflow: hidden; + + .button { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + height: 100%; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + + &.primary { + color: #fff; + background: #000; + + .arrowImage { + width: 20px; + height: 20px; + } + } + } + } + } + } + + .guide { + .tip { + padding: 0 30px; + + .tipImage { + width: 100%; + } + } + + .radar { + display: flex; + align-items: center; + justify-content: center; + .radarImage { + width: 320px; + transform: scale(1.8); + } + } + + .desc { + padding: 0 30px; + color: rgba(0, 0, 0, 0.85); + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 300; + line-height: 24px; + } + + .actions { + margin: 74px 22px 0; + display: flex; + flex-direction: column; + gap: 10px; + + .buttonWrap { + width: 100%; + height: 52px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + overflow: hidden; + + .button { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + height: 100%; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + + &.primary { + color: #fff; + background: #000; + + .arrowImage { + width: 20px; + height: 20px; + } + } + } + } + } + } +} + +.testContainer { + width: 100vw; + height: 100vh; + background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA; + + .bar { + margin: 12px 20px 36px; + height: 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.06); + position: relative; + + .progressBar { + height: 8px; + position: absolute; + left: 0; + top: 0; + border-radius: 999px; + background-color: #000; + } + } + + .notice { + padding: 0 20px 20px; + color: #000; + font-family: "PingFang SC"; + font-size: 18px; + font-style: normal; + font-weight: 300; + line-height: normal; + } + + .question { + padding: 0 20px 48px; + box-sizing: border-box; + height: 502px; + + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: space-between; + + .content { + color: #000; + font-family: "PingFang SC"; + font-size: 36px; + font-style: normal; + font-weight: 600; + line-height: normal; + } + + .options { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-end; + gap: 12px; + width: 100%; + + .optionItem { + display: flex; + align-items: center; + justify-content: space-between; + display: flex; + padding: 14px 20px; + align-items: center; + gap: 12px; + border-radius: 16px; + border: 0.5px solid rgba(0, 0, 0, 0.12); + background: #fff; + width: 100%; + box-sizing: border-box; + + .optionText { + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 24px; + } + + .optionIcon { + display: flex; + align-items: center; + + .icon { + width: 20px; + height: 20px; + } + } + } + } + } + + .actions { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0 22px; + gap: 24px; + + .next { + width: 100%; + height: 52px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.20); + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + overflow: hidden; + + .nextBtn { + width: 100%; + height: 100%; + background-color: #000; + color: #fff; + border-radius: 16px; + display: flex; + align-items: center; + justify-content: center; + } + + &.disabled { + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.20); + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + + .nextBtn { + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.20); + color: #fff; + border-radius: 16px; + } + } + } + + .prev { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #000; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 600; + line-height: normal; + + .backIcon { + width: 20px; + height: 20px; + } + } + } +} + +.resultContainer { + width: 100vw; + height: 100vh; + background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA; + + .card { + margin: 10px 20px 0; + + padding: 24px 28px 0; + position: relative; + display: flex; + // height: px; + flex-direction: column; + justify-content: space-between; + align-items: center; + align-self: stretch; + border-radius: 26px; + border: 4px solid #FFF; + background: linear-gradient(180deg, #BFFFEF 0%, #F2FFFC 100%), #FFF; + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + + .avatarWrap { + padding-bottom: 20px; + display: flex; + align-items: center; + justify-content: flex-start; + @include commonAvatarStyle(0.5); + } + + .desc { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + + .tip { + color: #000; + font-family: "PingFang SC"; + font-size: 14px; + font-style: normal; + font-weight: 300; + line-height: normal; + } + + .levelWrap { + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + text-overflow: ellipsis; + font-family: "Noto Sans SC"; + font-size: 36px; + font-style: normal; + font-weight: 900; + line-height: 44px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + + .level { + color: #00E5AD; + } + } + + .slogan { + color: #000; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + } + } + + .retest { + position: absolute; + right: 12px; + top: 12px; + display: flex; + padding: 6px 10px; + justify-content: center; + align-items: center; + gap: 6px; + border-radius: 12px; + border: 0.5px solid rgba(0, 0, 0, 0.12); + background: #FFF; + box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10); + color: rgba(0, 0, 0, 0.85); + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 500; + line-height: 20px; + + .re_actIcon { + width: 12px; + height: 12px; + } + } + } + + .updateTip { + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 24px; + + text-align: center; + padding: 24px 0; + + .grayTip { + color: rgba(60, 60, 67, 0.60); + } + } + + .actions { + padding: 0 28px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + gap: 10px; + + .viewGame { + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + width: 100%; + overflow: hidden; + + .viewGameBtn { + width: 100%; + height: 50px; + background: #000; + color: #FFF; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + display: flex; + align-items: center; + justify-content: center; + } + } + + .otherActions { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + + .share, .saveImage { + width: 50%; + height: 50px; + border-radius: 16px; + border: 1px solid rgba(0, 0, 0, 0.06); + overflow: hidden; + + .shareBtn, .saveImageBtn { + background: #FFF; + width: 100%; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + color: #000; + font-feature-settings: 'liga' off, 'clig' off; + font-family: "PingFang SC"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: normal; + + .downloadIcon { + width: 20px; + height: 20px; + } + + .wechatIcon { + width: 24px; + height: 24px; + } + } + } + } + } +} \ No newline at end of file diff --git a/src/other_pages/ntrp-evaluate/index.tsx b/src/other_pages/ntrp-evaluate/index.tsx index fcf5348..f323b09 100644 --- a/src/other_pages/ntrp-evaluate/index.tsx +++ b/src/other_pages/ntrp-evaluate/index.tsx @@ -1,22 +1,624 @@ -import { useState, useEffect } from "react"; -import { View, Text, Image, Button } from "@tarojs/components"; -import Taro, { useRouter } from "@tarojs/taro"; -import { withAuth } from "@/components"; -import evaluateService from "@/services/evaluateService"; -import { useUserActions } from "@/store/userStore"; -import { delay } from "@/utils"; +import { useState, useEffect, useRef, useId } from "react"; +import { View, Text, Image, Button, Canvas } from "@tarojs/components"; +import Taro, { useRouter, useShareAppMessage } from "@tarojs/taro"; +import dayjs from "dayjs"; +import classnames from "classnames"; +import { withAuth, RadarChart } from "@/components"; +import evaluateService, { + LastTimeTestResult, + Question, + TestResultData, +} from "@/services/evaluateService"; +import { useUserInfo, useUserActions } from "@/store/userStore"; +import { delay, getCurrentFullPath } from "@/utils"; +import CloseIcon from "@/static/ntrp/ntrp_close_icon.svg"; +import DocCopy from "@/static/ntrp/ntrp_doc_copy.svg"; +import ArrowRight from "@/static/ntrp/ntrp_arrow_right.svg"; +import ArrowBack from "@/static/ntrp/ntrp_arrow_back.svg"; +import CircleChecked from "@/static/ntrp/ntrp_circle_checked.svg"; +import CircleUnChecked from "@/static/ntrp/ntrp_circle_unchecked.svg"; +import WechatIcon from "@/static/ntrp/ntrp_wechat.svg"; +import DownloadIcon from "@/static/ntrp/ntrp_download.svg"; +import ReTestIcon from "@/static/ntrp/ntrp_re-action.svg"; import styles from "./index.module.scss"; +enum StageType { + INTRO = "intro", + TEST = "test", + RESULT = "result", +} + +enum SourceType { + DETAIL = 'detail', + PUBLISH = 'publish', +} + +const sourceTypeToTextMap = new Map([ + [SourceType.DETAIL, '继续加入球局'], + [SourceType.PUBLISH, '继续发布球局'], +]) + +function adjustRadarLabels( + source: [string, number][], + topK: number = 4 // 默认挑前4个最长的标签保护 +): [string, number][] { + if (source.length === 0) return source; + + // 复制并按长度排序(降序) + let sorted = [...source].sort((a, b) => b[0].length - a[0].length); + + // 取出前 K 个最长标签 + let protectedLabels = sorted.slice(0, topK); + // 其他标签(保持原始顺序,但排除掉 protected) + let protectedSet = new Set(protectedLabels.map(([l]) => l)); + let others = source.filter(([l]) => !protectedSet.has(l)); + + let n = source.length; + let result: ([string, number] | undefined)[] = new Array(n); + + // 放首尾 + result[0] = protectedLabels.shift() || others.shift(); + result[n - 1] = protectedLabels.shift() || others.shift(); + + // 放中间(支持偶数两个位置) + if (n % 2 === 0) { + let mid1 = n / 2 - 1; + let mid2 = n / 2; + result[mid1] = protectedLabels.shift() || others.shift(); + result[mid2] = protectedLabels.shift() || others.shift(); + } else { + let mid = Math.floor(n / 2); + result[mid] = protectedLabels.shift() || others.shift(); + } + + // 把剩余标签按顺序塞进空位 + let pool = [...protectedLabels, ...others]; + for (let i = 0; i < n; i++) { + if (!result[i]) result[i] = pool.shift(); + } + + return result as [string, number][]; +} + +function CommonGuideBar(props) { + const { title, confirm } = props; + const { params } = useRouter(); + const { redirect } = params; + + function handleClose() { + //TODO: 二次确认 + if (confirm) { + } + Taro.redirectTo({ + url: redirect ? redirect : "/game_pages/list/index", + }); + } + + return ( + + + + + + {title} + + + ); +} + +function Intro(props) { + const { redirect } = props; + const [ntrpData, setNtrpData] = useState(); + const userInfo = useUserInfo(); + const { fetchUserInfo } = useUserActions(); + const [ready, setReady] = useState(false); + + const { last_test_result: { ntrp_level, create_time, id } = {} } = + ntrpData || {}; + const lastTestTime = dayjs(create_time).format("YYYY年M月D日"); + + useEffect(() => { + getLastResult(); + }, []); + + async function getLastResult() { + const res = await evaluateService.getLastResult(); + if (res.code === 0) { + setNtrpData(res.data); + if (res.data.has_ntrp_level) { + fetchUserInfo(); + } + setReady(true); + } + } + if (!ready) { + return ""; + } + + function handleNext(type) { + Taro.redirectTo({ + url: `/other_pages/ntrp-evaluate/index?stage=${type}${ + type === StageType.RESULT ? `&id=${id}` : "" + }${redirect ? `&redirect=${redirect}` : ""}`, + }); + } + + return ( + + + {ntrpData?.has_ntrp_level ? ( + + + + + + {/* avatar side */} + + + + + {/* tip */} + + + + + + 上次测试结果 + {lastTestTime} + + + NTRP + {ntrp_level} + + + 变线+网前,下一步就是赢比赛! + + + + + + + + + + + + ) : ( + + {/* tip */} + + + + {/* radar */} + + + + + + NTRP(National Tennis Rating + Program)是一种常用的网球水平分级系统,这不是绝对精准的“分数”,而是一个参考标准,能够帮助你更清晰地了解自己的网球水平,从而在训练、比赛或娱乐活动中找到「难度合适」的球友,避免过度碾压或被碾压。 + + + + + + + + + )} + + ); +} + +function Test(props) { + const { redirect } = props; + const [disabled, setDisabled] = useState(false); + const [index, setIndex] = useState(0); + const [questions, setQuestions] = useState< + (Question & { choosen: number })[] + >([]); + const startTimeRef = useRef(0); + + useEffect(() => { + startTimeRef.current = Date.now(); + getQUestions(); + }, []); + + useEffect(() => { + setDisabled(questions[index]?.choosen === -1); + }, [index, questions]); + + async function getQUestions() { + const res = await evaluateService.getQuestions(); + if (res.code === 0) { + setQuestions(res.data.map((item) => ({ ...item, choosen: 3 }))); + } + } + + function handleSelect(i) { + setQuestions((prev) => + prev.map((item, pIndex) => ({ + ...item, + ...(pIndex === index ? { choosen: i } : {}), + })) + ); + } + + async function handleSubmit() { + setDisabled(true); + try { + const res = await evaluateService.submit({ + answers: questions.map((item) => ({ + question_id: item.id, + answer_index: item.choosen, + })), + test_duration: (Date.now() - startTimeRef.current) / 1000, + }); + if (res.code === 0) { + Taro.redirectTo({ + url: `/other_pages/ntrp-evaluate/index?stage=${StageType.RESULT}&id=${ + res.data.record_id + }${redirect ? `&redirect=${redirect}` : ""}`, + }); + } + } catch (e) { + Taro.showToast({ title: e.message, icon: "error" }); + } finally { + setDisabled(false); + } + } + + function handIndexChange(direction) { + console.log(disabled, direction); + if (disabled && direction > 0) { + return; + } + if (index === questions.length - 1 && direction > 0) { + handleSubmit(); + return; + } + setIndex((prev) => prev + direction); + } + + const question = questions[index]; + if (!question) { + return ""; + } + return ( + + + + + + + 根据近3个月实际表现勾选最符合项 + + + {question.question_content} + + {question.options.map((item, i) => { + const checked = question.choosen === i; + return ( + handleSelect(i)} + > + {item.text} + + + + + ); + })} + + + + handIndexChange(1)} + > + + + {index !== 0 && ( + handIndexChange(-1)}> + + 返回 + + )} + + + ); +} + +function Result(props) { + const { params } = useRouter(); + const { id, type, redirect } = params; + const userInfo = useUserInfo(); + const { fetchUserInfo } = useUserActions(); + const radarRef = useRef(); + + const [result, setResult] = useState(); + const [radarData, setRadarData] = useState< + [propName: string, prop: number][] + >([]); + + useEffect(() => { + getResultById(); + fetchUserInfo(); + }, []); + + async function getResultById() { + const res = await evaluateService.getTestResult({ record_id: Number(id) }); + if (res.code === 0) { + setResult(res.data); + setRadarData( + adjustRadarLabels( + Object.entries(res.data.radar_data.abilities).map(([key, value]) => [ + key, + value.current_score, + ]) + ) + ); + updateUserLevel(res.data.record_id, res.data.ntrp_level); + } + } + + function updateUserLevel(record_id, ntrp_level) { + try { + evaluateService.updateNtrp({ + record_id, + ntrp_level, + update_type: "test_result", + }); + } catch (e) { + Taro.showToast({ title: e.message, icon: "none" }); + } + } + + function handleReTest() { + Taro.redirectTo({ + url: `/other_pages/ntrp-evaluate/index?stage=${StageType.TEST}${ + redirect ? `&redirect=${redirect}` : "" + }`, + }); + } + + function handleViewGames() { + Taro.redirectTo({ + url: "/game_pages/list/index", + }); + } + + function handleGoon () { + if (type) { + Taro.redirectTo({ url: redirect }) + } else { + handleViewGames() + } + } + + async function genCardImage() { + return new Promise(async (resolve, reject) => { + const url = await radarRef.current.generateImage(); + const query = Taro.createSelectorQuery(); + query + .select("#exportCanvas") + .fields({ node: true, size: true }) + .exec((res2) => { + const canvas = res2[0].node; + const ctx = canvas.getContext("2d"); + const dpr = Taro.getSystemInfoSync().pixelRatio; + const width = 300; + const height = 400; + canvas.width = width * dpr; + canvas.height = height * dpr; + ctx.scale(dpr, dpr); + + // 背景 + ctx.fillStyle = "#e9fdf8"; + ctx.fillRect(0, 0, width, height); + + // 标题文字 + ctx.fillStyle = "#000"; + ctx.font = "16px sans-serif"; + ctx.fillText("你的 NTRP 测试结果为", 20, 40); + ctx.fillStyle = "#00E5AD"; + ctx.font = "bold 22px sans-serif"; + ctx.fillText(`NTRP ${result?.ntrp_level}`, 20, 70); + + // 绘制雷达图 + const img = canvas.createImage(); + img.src = url; + img.onload = () => { + ctx.drawImage(img, 20, 100, 260, 260); + + // 第三步:导出最终卡片 + Taro.canvasToTempFilePath({ + canvas, + success: (res3) => { + console.log("导出成功:", res3.tempFilePath); + resolve(res3.tempFilePath); + }, + }); + }; + }); + }); + } + + async function handleSaveImage() { + if (!userInfo.id) { + return + } + const url = await genCardImage(); + Taro.saveImageToPhotosAlbum({ filePath: url }); + } + + useShareAppMessage(async (res) => { + const url = await genCardImage(); + console.log(res, "res"); + return { + title: "分享", + imageUrl: url, + path: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`, + }; + }); + + function handleAuth () { + if (userInfo.id) { + return true + } + const currentPage = getCurrentFullPath() + Taro.redirectTo({ + url: `/login_pages/index/index${ + currentPage ? `?redirect=${encodeURIComponent(currentPage)}` : "" + }`, + }); + } + + return ( + + + + + + + + {/* avatar side */} + + + + + + + 你的 NTRP 测试结果为 + + + NTRP + {result?.ntrp_level} + + + 变线+网前,下一步就是赢比赛! + + + + + + + + 重新测试 + + + {userInfo.id ? ( + + 你的 NTRP 水平已更新为 {result?.ntrp_level} + (可在个人信息中修改) + + ) : ( + + 登录「有场」小程序,查看匹配你的球局 + + )} + + + + + + + + + + + + + + + + ); +} + +const ComponentsMap = { + [StageType.INTRO]: Intro, + [StageType.TEST]: Test, + [StageType.RESULT]: Result, +}; + function NtrpEvaluate() { const { updateUserInfo } = useUserActions(); const { params } = useRouter(); const { redirect } = params; - useEffect(() => { - evaluateService.getEvaluateQuestions().then((data) => { - console.log(data); - }); - }, []); + const stage = params.stage as StageType; async function handleUpdateNtrp() { await updateUserInfo({ @@ -33,21 +635,9 @@ function NtrpEvaluate() { } } - return ( - - NTRP评分 - - - 您的NTRP评分是 4.0 分。 - - - - ); + const Component = ComponentsMap[stage]; + + return ; } export default withAuth(NtrpEvaluate); diff --git a/src/publish_pages/publishBall/components/AiImportPopup/AiImportPopup.tsx b/src/publish_pages/publishBall/components/AiImportPopup/AiImportPopup.tsx new file mode 100644 index 0000000..a4bf51c --- /dev/null +++ b/src/publish_pages/publishBall/components/AiImportPopup/AiImportPopup.tsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect } from 'react' +import { View, Text, Textarea, Image } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { ConfigProvider, Loading, Popup, Toast } from '@nutui/nutui-react-taro' +import styles from './index.module.scss' +import uploadFiles from '@/services/uploadFiles' +import publishService from '@/services/publishService' +import { usePublishBallActions } from '@/store/publishBallStore' +import { useKeyboardHeight } from '@/store/keyboardStore' +import images from '@/config/images' + +export interface AiImportPopupProps { + visible: boolean + onClose: () => void + onManualPublish?: () => void +} + +const AiImportPopup: React.FC = ({ + visible, + onClose, + onManualPublish, +}) => { + const [text, setText] = useState('') + const [uploadFailCount, setUploadFailCount] = useState(0) + const [loading, setLoading] = useState(false) + const [uploadLoading, setUploadLoading] = useState(false) + const maxFailCount = 3 + + // 获取 actions(在组件顶层调用 Hook) + const { setPublishData } = usePublishBallActions() + + // 使用全局键盘状态 + const { keyboardHeight, isKeyboardVisible, addListener, initializeKeyboardListener } = useKeyboardHeight() + + const textIdentification = async (text: string) => { + setLoading(true) + const res = await publishService.extract_tennis_activity({text}) + const { data } = res + if (data && data?.length > 0) { + navigateToPublishBall(data) + } else { + Taro.showToast({ + title: '未识别到球局信息', + icon: 'error' + }) + setUploadFailCount(prev => prev + 1) + } + setLoading(false) + } + + const initAiPopup = () => { + setText('') + setUploadFailCount(0) + setLoading(false) + setUploadLoading(false) + } + const handlePasteAndRecognize = async () => { + if (text) { + textIdentification(text) + } else { + getClipboardData() + } + } + const getClipboardData = async () => { + try { + const res = await Taro.getClipboardData() + if (res.data && res.data.trim()) { + setText(res.data) + Toast.show('toast', { + content: '有场读取了你的剪切板信息', + duration: 2, + wordBreak:'break-word' + }) + textIdentification(res.data) + // Taro.showToast({ + // title: '已读取你的剪切板信息', + // icon: 'success', + // duration: 2000 + // }) + } else { + Taro.showToast({ + title: '剪切板为空,请手动输入', + icon: 'none', + duration: 2 + }) + } + } catch (error) { + console.error('获取剪切板失败:', error) + Taro.showToast({ + title: '读取剪切板失败,请手动输入', + icon: 'error', + duration: 2 + }) + } + } + + const navigateToPublishBall = (data: any) => { + if (Array.isArray(data) && data.length > 0) { + setPublishData(data) + initAiPopup() + onClose() + Taro.navigateTo({ + url: '/publish_pages/publishBall/index?type=ai' + }) + } + } + + const handleTextChange = (e: any) => { + setText(e.detail.value) + } + + // 使用全局键盘状态监听 + useEffect(() => { + // 初始化全局键盘监听器 + initializeKeyboardListener() + + // 添加本地监听器 + const removeListener = addListener((height, visible) => { + console.log('AiImportPopup 收到键盘变化:', height, visible) + }) + + return () => { + removeListener() + } + }, [initializeKeyboardListener, addListener]) + + const handleImageRecognition = async () => { + try { + const res = await Taro.chooseMedia({ + count: 1, + mediaType: ['image'], + sourceType: ['album', 'camera'], + camera: 'back' + }) + + if (res.tempFiles && res.tempFiles.length > 0) { + // 这里可以调用图片识别API + setUploadLoading(false) + setLoading(true) + const res_upload = await uploadFiles.upload_oss_img(res.tempFiles[0].tempFilePath) + const {ossPath} = res_upload; + if (ossPath) { + setUploadLoading(true) + const publishData = await publishService.extract_tennis_activity_from_image({image_url: ossPath}) + const { data } = publishData + if (data && data?.length > 0) { + navigateToPublishBall(data) + } else { + Taro.showToast({ + title: '未识别到球局信息', + icon: 'error' + }) + setUploadFailCount(prev => prev + 1) + setUploadLoading(false) + } + setLoading(false) + } + } + } catch (error) { + console.error('选择图片失败:', error) + if (!(typeof error === 'object' && error.errMsg && error.errMsg.includes('fail cancel'))) { + setUploadFailCount(prev => prev + 1) + Taro.showToast({ + title: '上传失败', + icon: 'error' + }) + } + } + } + + + const handleManualPublish = () => { + if (onManualPublish) { + onManualPublish() + } + onClose() + } + + const showManualButton = uploadFailCount >= maxFailCount + + return ( + + + {/* 头部 */} + + + + 智能导入球局信息 + + + + + + + {/* 文本域 */} + +