Merge branch master into feature/juguohong/20250816

This commit is contained in:
李瑞
2025-09-30 15:58:59 +08:00
190 changed files with 14267 additions and 2085 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ node_modules/
.DS_Store
.swc
src/config/env.ts
.vscode
*.http

View File

@@ -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", // 提现
],
},
// {

View File

@@ -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<AppProps> {
componentDidMount() {
// 初始化字典数据
this.initDictionaryData();
this.getNavBarHeight();
// this.getLocation()
}
@@ -26,15 +24,7 @@ class App extends Component<AppProps> {
componentDidHide() {}
// 初始化字典数据
private async initDictionaryData() {
try {
const { fetchDictionary } = useDictionaryStore.getState();
await fetchDictionary();
} catch (error) {
console.error("初始化字典数据失败:", error);
}
}
// 获取导航高度
getNavBarHeight = () => {

View File

@@ -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<P extends object>(
WrappedComponent: React.ComponentType<P>,
) {

View File

@@ -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;
}
}

View File

@@ -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<CommentInputReplyParamsType>
) => void;
}
// 2⃣ 定义通过 ref 暴露出去的方法类型
interface CommentInputRef {
show: (params?: CommentInputReplyParamsType) => void;
}
interface CommentInputReplyParamsType {
parent_id: number;
reply_to_user_id: number;
nickname: string;
}
const CommentInput = forwardRef<CommentInputRef, CommentInputProps>(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 (
<CommonPopup
visible={visible}
showHeader={false}
hideFooter
zIndex={1002}
onClose={onClose}
style={{ height: "60px!important", minHeight: "unset" }}
enableDragToClose={false}
>
<View className={styles.inputContainer}>
<View className={styles.inputWrapper}>
<Input
ref={inputDomRef}
className={styles.input}
value={value}
onInput={(e) => setValue(e.detail.value)}
placeholder={
params?.reply_to_user_id ? `回复 @${params.nickname}` : "写评论"
}
focus
maxlength={100}
/>
</View>
<View className={styles.sendIcon} onClick={handleSend}>
<Image className={styles.sendImage} src={sendImg} />
</View>
</View>
</CommonPopup>
);
});
function isReplyComment(item: BaseComment<any>): 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 (
<View className={styles.commentItem} key={comment.id}>
<View style={{ width: level === 1 ? "36px" : "28px" }}>
<Image
className={styles.avatar}
src={comment.user.avatar_url}
mode="aspectFill"
style={
level === 1
? { width: "36px", height: "36px" }
: { width: "28px", height: "28px" }
}
/>
</View>
<View className={styles.contents}>
<View className={styles.main}>
<View className={styles.publisherInfo}>
<View className={styles.nickname}>
<Text>{comment.user.nickname}</Text>
</View>
{isGamePublisher && (
<View className={styles.role}>
<Text></Text>
</View>
)}
</View>
<View className={styles.content}>
<Text className={styles.atPeople}>
{isReplyComment(comment) && comment.reply_to_user
? `@${comment.reply_to_user.nickname} `
: ""}
</Text>
<Text>{comment.content}</Text>
</View>
<View className={styles.addons}>
<View className={styles.time}>
<Text>{getRelativeDay(comment.create_time)}</Text>
</View>
<View className={styles.location}>
<Text></Text>
</View>
<View
className={styles.reply}
onClick={() =>
handleReply({
parent_id: comment.parent_id || comment.id,
reply_to_user_id: comment.user.id,
nickname: comment.user.nickname,
})
}
>
<Text></Text>
</View>
{isGamePublisher || isCommentPublisher}
<View
className={styles.delete}
onClick={() =>
handleDelete({
parent_id: comment.parent_id,
id: comment.id,
})
}
>
<Text></Text>
</View>
</View>
</View>
{!isReplyComment(comment) &&
comment.replies.map((item: ReplyComment) => (
<CommentItem
key={comment.id}
publisher_id={publisher_id}
comment={item}
level={2}
loadMore={handleLoadMore}
handleReply={handleReply}
handleDelete={handleDelete}
/>
))}
{!isReplyComment(comment) &&
comment.replies.length !== comment.reply_count && (
<View
className={styles.viewMore}
onClick={() => handleLoadMore(comment)}
>
</View>
)}
</View>
</View>
);
}
export default forwardRef(function Comments(
props: { game_id: number; publisher_id: number },
ref
) {
const { game_id, publisher_id } = props;
const [comments, setComments] = useState<Comment[]>([]);
const inputRef = useRef<CommentInputRef>(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 (
<View className={styles.container}>
<View className={styles.header}>
<View className={styles.commentCount}>
{comments.length > 0 ? `${comments.length}` : ""}
</View>
<View className={styles.addComment} onClick={() => handleReply()}>
<Image className={styles.addCommentImage} src={addComment} />
<Text className={styles.addCommentText}></Text>
</View>
</View>
{comments.length > 0 ? (
<View className={styles.list}>
{comments.map((comment) => {
return (
<CommentItem
key={comment.id}
publisher_id={publisher_id}
level={1}
comment={comment}
loadMore={getReplies}
handleReply={handleReply}
handleDelete={deleteComment}
/>
);
})}
</View>
) : (
<View className={styles.empty}>
<Image className={styles.emptyImage} src={emptyComment} />
<Text className={styles.emptyTip}></Text>
</View>
)}
<CommentInput ref={inputRef} onConfirm={onSend} />
</View>
);
});

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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<FollowUserCardProps> = ({ 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 (
<View className="follow_user_card">
<View className="user_info" onClick={handle_user_click}>
<Image
className="avatar"
src={user.avatar_url || require('@/static/userInfo/default_avatar.svg')}
/>
<View className="user_details">
<Text className="nickname">{user.nickname}</Text>
<Text className="signature">
{user.personal_profile?.replace(/\n/g, ' ') || '签名写在这里'}
</Text>
</View>
</View>
<View
className={`action_button ${button_config.className} ${isProcessing ? 'processing' : ''}`}
onClick={handle_follow_action}
>
<Text className="button_text">
{isProcessing ? '处理中...' : button_config.text}
</Text>
</View>
{
tabKey === 'recommend' && (
<View className='delete_button' onClick={add_to_blacklist}></View>
)
}
</View>
);
};
export default FollowUserCard;

View File

@@ -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;
}
}
}
}

View File

@@ -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 (
<>
<CommonPopup
visible={visible}
showHeader={false}
hideFooter
zIndex={1002}
enableDragToClose={false}
onClose={onClose}
position="center"
style={{
width: hasOtherJoin ? "360px" : "300px",
borderRadius: "16px",
}}
>
<View className={styles.centerContainer}>
<View className={styles.title}></View>
<View className={styles.content}>
<Text className={styles.tips}>
{hasOtherJoin
? "已有球友报名,取消后将为他们自动退款"
: "有100+球友正在浏览您的球局哦~"}
</Text>
{hasOtherJoin && (
<View className={styles.cancelReason}>
<Input
ref={inputRef}
className={styles.input}
placeholder="请输入取消理由"
focus
value={cancelReason}
onInput={(e) => setCancelReason(e.detail.value)}
maxlength={100}
/>
</View>
)}
</View>
<View className={styles.actions}>
<View className={styles.confirm} onClick={handleConfirm}>
</View>
<View className={styles.cancel} onClick={onClose}>
</View>
</View>
</View>
</CommonPopup>
</>
);
});
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 (
<>
<CommonPopup
visible={visible}
showHeader={false}
hideFooter
zIndex={1001}
enableDragToClose={false}
onClose={onClose}
>
<View className={styles.container}>
<View className={styles.button} onClick={handleEditGame}>
</View>
{finished && (
<View className={styles.button} onClick={handleRepubGame}>
</View>
)}
{!finished && (
<View className={styles.button} onClick={handleCancelGame}>
</View>
)}
{hasJoin && (
<View className={styles.button} onClick={handleQuitGame}>
退
</View>
)}
<View className={styles.button} onClick={onClose}>
</View>
</View>
</CommonPopup>
<CancelPopup ref={cancelRef} detail={detail} />
</>
);
});

View File

@@ -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;
}

View File

@@ -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<GeneralNavbarProps> = ({
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 (
<Image
src={img.ICON_LIST_SEARCH_BACK}
className={styles.backIcon}
onClick={handleBack}
/>
)
}
return null
}
const renderTitle = () => {
if (!title) {
return null
}
return (
<Text
className={`${styles.title} ${titleClassName}`}
style={titleStyle}
>
{title}
</Text>
)
}
return (
<View
className={`${styles.customNavbar} ${className}`}
style={{
height: `${navBarHeight}px`,
paddingTop: `${statusBarHeight}px`,
backgroundColor
}}
>
<View className={styles.navbarContent}>
<View className={styles.leftSection}>
{renderLeftContent()}
</View>
<View className={styles.centerSection}>
{renderTitle()}
</View>
<View className={styles.rightSection}>
{/* 右侧占位,保持标题居中 */}
</View>
</View>
</View>
)
}
export default GeneralNavbar

View File

@@ -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;
}
}
}

View File

@@ -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<undefined | string>();
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 (
<>
<CommonPopup
title="NTRP评估"
visible={visible}
onClose={() => setVisible(false)}
position="center"
onClose={handleClose}
showHeader={false}
hideFooter
enableDragToClose={false}
>
<View className={style.container}>
{/* TODO: 直接修改NTRP水平 */}
<Text></Text>
<Text>NTRP评估</Text>
<Button onClick={handleEvaluate}></Button>
{guideShow ? (
<NtrpPopupGuide
close={handleClose}
skipGuide={() => {
setGuideShow(false);
}}
/>
) : (
<View className={styles.container}>
<View className={styles.header}>
<Text> NTRP </Text>
<View className={styles.closeBtn} onClick={handleClose}>
<Image className={styles.closeIcon} src={CloseIcon} />
</View>
</View>
<View className={styles.entryCard}>
<NTRPTestEntryCard />
</View>
</View>
)}
</CommonPopup>
{showEntry && props.children}
{showEntry ? props.children : ""}
</>
);
};
export default forwardRef(NTRPEvaluatePopup);
export default memo(forwardRef(NTRPEvaluatePopup));

View File

@@ -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);
}

View File

@@ -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 (
<View className={styles.container}>
<View className={styles.header}>
<View className={styles.closeBtn} onClick={close}>
<Image className={styles.closeIcon} src={CloseIcon} />
</View>
</View>
<View className={styles.top}>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo.avatar_url}
mode="aspectFit"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image
className={styles.docImage}
src={DocCopy}
mode="aspectFill"
/>
</View>
</View>
<View className={styles.guideMsg}>
<View className={styles.title}>
<Text></Text>
</View>
<View className={styles.title}>
<Text></Text>
<Text className={styles.colorTip}> (</Text>
<Text className={styles.strongTip}>NTRP</Text>
<Text className={styles.colorTip}>) </Text>
<Text>?</Text>
</View>
</View>
<View className={styles.desc}>
<Text> NTRP </Text>
</View>
</View>
<View className={styles.bottom}>
<View className={styles.jump}>
<Button
className={classnames(styles.button, styles.primary)}
onClick={handleTest}
>
<Text></Text>
<Image className={styles.jumpIcon} src={ArrwoRight} />
</Button>
</View>
<View className={styles.direct}>
<Button className={classnames(styles.button)} onClick={skipGuide}>
<Text></Text>
</Button>
</View>
</View>
</View>
);
}
export default NtrpPopupGuide;

View File

@@ -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);
}

View File

@@ -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" ? (
<View className={styles.higher}>
<View className={styles.desc}>
<View>
<View className={styles.title}>
<Text></Text>
</View>
<View className={styles.title}>
<Text></Text>
<Text className={styles.colorTip}> (</Text>
<Text className={styles.strongTip}>NTRP</Text>
<Text className={styles.colorTip}>) </Text>
<Text>?</Text>
</View>
</View>
<View className={styles.entry}>
<Text></Text>
<Image className={styles.entryIcon} src={ArrowRight} />
</View>
</View>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo.avatar_url}
mode="aspectFit"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image className={styles.docImage} src={DocCopy} mode="aspectFill" />
</View>
</View>
</View>
) : (
<View className={styles.lower}>
<View className={styles.desc}>
<View className={styles.title}>
<Text></Text>
<Text className={styles.colorTip}> (</Text>
<Text className={styles.strongTip}>NTRP</Text>
<Text className={styles.colorTip}>) </Text>
<Text>?</Text>
</View>
<View className={styles.entry}>
<Text></Text>
<Image className={styles.entryIcon} src={ArrowRight} />
</View>
</View>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo.avatar_url}
mode="aspectFit"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image className={styles.docImage} src={DocCopy} mode="aspectFill" />
</View>
</View>
</View>
);
}
export default NTRPTestEntryCard;

View File

@@ -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;

View File

@@ -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<NumberIntervalProps> = ({
min,
max
}) => {
const [minParticipants, maxParticipants] = value || [1, 1]
const [organizer_joined, setOrganizerJoined] = useState(true);
const [minParticipants, setMinParticipants] = useState(1);
const [maxParticipants, setMaxParticipants] = useState(1);
const handleChange = (value: [number | string, number | string]) => {
const newMin = Number(value[0])
const newMax = Number(value[1])
useEffect(() => {
if (value) {
setOrganizerJoined(value.organizer_joined);
setMinParticipants(value.min);
setMaxParticipants(value.max);
}
console.log(value, 'valuevaluevaluevaluevaluevalue');
}, [value]);
// 确保最少人数不能大于最多人数
if (newMin > newMax) {
return
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 })
}
onChange([newMin, newMax])
}
return (
<View className='participants-control-section'>
<View className='participant-control'>
<Text className='control-label'></Text>
<Text className='control-label'></Text>
<View className='control-buttons'>
<InputNumber
className="format-width"
defaultValue={minParticipants}
value={minParticipants}
min={min}
max={maxParticipants}
onChange={(value) => handleChange([value, maxParticipants])}
onChange={(value) => handleChange({ min: value, max: maxParticipants, organizer_joined: organizer_joined })}
formatter={(value) => `${value}`}
/>
</View>
</View>
<View className='participant-control'>
<Text className='control-label'></Text>
<Text className='control-label'></Text>
<View className='control-buttons'>
<InputNumber
className="format-width"
defaultValue={maxParticipants}
onChange={(value) => handleChange([minParticipants, value])}
value={maxParticipants}
onChange={(value) => handleChange({ min: minParticipants, max: value, organizer_joined: organizer_joined })}
min={minParticipants}
max={max}
formatter={(value) => `${value}`}
/>
</View>
</View>
<View className='participant-control'>
<View className='participant-control-checkbox-wrapper'>
<Checkbox
className='participant-control-checkbox'
checked={organizer_joined}
onChange={(checked) => handleChange({ min: minParticipants, max: maxParticipants, organizer_joined: checked })}
/>
</View>
</View>
</View>
)
}

View File

@@ -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<DialogCalendarCardProps> = ({
visible,
searchType,
onClose,
title,
value,
onChange,
}) => {
const [selected, setSelected] = useState<Date>(value || new Date())
const [selected, setSelected] = useState<Date | Date[]>(value || new Date());
const [selectedBackup, setSelectedBackup] = useState<Date[]>(
Array.isArray(value) ? [...(value as Date[])] : [value as Date]
);
const [current, setCurrent] = useState<Date>(new Date());
const [delta, setDelta] = useState(0);
const calendarRef = useRef<CalendarUIRef>(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<PickerCommonRef>(null);
const hourMinutePickerRef = useRef<PickerCommonRef>(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;
}
setType('year')
} else if (type === 'time') {
setSelected(new Date(year, month - 1, 1));
}
} 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)) {
setSelected(d[0])
if (d.length === 2) {
return;
} else if (d.length === 1) {
if (selectedBackup.length === 0 || selectedBackup.length === 2) {
setSelected([...d]);
setSelectedBackup([...d]);
} else {
setSelected(d)
setSelected(
[...selectedBackup, d[0]].sort(
(a, b) => a.getTime() - b.getTime()
)
);
setSelectedBackup([]);
}
}
return;
}
}
if (Array.isArray(d)) {
setSelected(d[0]);
} else {
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 (
<CommonPopup
@@ -112,43 +180,45 @@ const DialogCalendarCard: React.FC<DialogCalendarCardProps> = ({
showHeader={!!title}
title={title}
hideFooter={false}
cancelText='取消'
cancelText="取消"
confirmText={getConfirmText()}
onConfirm={handleConfirm}
position='bottom'
position="bottom"
round
zIndex={1000}
>
{
type === 'year' &&
<View className={styles['calendar-container']}>
{type === "year" && (
<View className={styles["calendar-container"]}>
<CalendarUI
ref={calendarRef}
type={searchType}
value={selected}
onChange={handleChange}
showQuickActions={false}
onHeaderClick={onHeaderClick}
/></View>
}
{
type === 'month' && <PickerCommon
/>
</View>
)}
{type === "month" && (
<PickerCommon
ref={pickerRef}
onChange={handleDateTimePickerChange}
type="month"
value={[selected.getFullYear(), selected.getMonth() + 1]}
value={[
(selected as Date).getFullYear(),
(selected as Date).getMonth() + 1,
]}
/>
}
{
type === 'time' && <PickerCommon
)}
{type === "time" && (
<PickerCommon
ref={hourMinutePickerRef}
type="hour"
value={[selectedHour, selectedMinute]}
/>
}
)}
</CommonPopup>
)
}
);
};
export default DialogCalendarCard
export default DialogCalendarCard;

View File

@@ -1,191 +1,264 @@
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<CalendarUIRef, NutUICalendarProps>(({
type = 'single',
const NutUICalendar = React.forwardRef<CalendarUIRef, NutUICalendarProps>(
(
{
type = "single",
value,
onChange,
isBorder = false,
showQuickActions = true,
onHeaderClick
}, ref) => {
onHeaderClick,
},
ref
) => {
// 根据类型初始化选中值
// const getInitialValue = (): Date | Date[] => {
// console.log(value,defaultValue,'today')
// 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<Date | Date[]>()
const [current, setCurrent] = useState<Date>(startOfMonth(new Date()))
const calendarRef = useRef<any>(null)
const [visible, setvisible] = useState(false)
console.log('current', current)
const [selectedValue, setSelectedValue] = useState<Date | Date[]>();
const [current, setCurrent] = useState<Date>(startOfMonth(new Date()));
const calendarRef = useRef<any>(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]))
setSelectedValue(value.map((item) => new Date(item)));
setCurrent(new Date(value[0] as Date));
}
if ((typeof value === 'string' || value instanceof Date) && value) {
setSelectedValue(new Date(value))
setCurrent(new Date(value))
if ((typeof value === "string" || value instanceof Date) && value) {
setSelectedValue(new Date(value));
setCurrent(new Date(value));
}
}, [value])
}, [value]);
useImperativeHandle(ref, () => ({
jumpTo: (year: number, month: number) => {
calendarRef.current?.jumpTo(year, month)
}
}))
calendarRef.current?.jumpTo(year, month);
},
gotoMonth,
}));
const handleDateChange = (newValue: any) => {
setSelectedValue(newValue)
onChange?.(newValue as any)
}
const formatHeader = (date: Date) => `${getMonth(date)}`
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)
}
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)
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)
calendarRef.current?.jump?.(delta);
} catch (e) {
console.warn('CalendarCardRef jump 调用失败', e)
}
handlePageChange({ year: next.getFullYear(), month: next.getMonth() + 1 })
console.warn("CalendarCardRef jump 调用失败", e);
}
handlePageChange({
year: next.getFullYear(),
month: next.getMonth() + 1,
});
};
const handleHeaderClick = () => {
onHeaderClick && onHeaderClick(current)
setvisible(true)
}
onHeaderClick && onHeaderClick(current);
setvisible(true);
};
const syncMonthTo = (anchor: Date) => {
// 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta)
const monthsDelta = (anchor.getFullYear() - current.getFullYear()) * 12 + (anchor.getMonth() - current.getMonth())
const monthsDelta =
(anchor.getFullYear() - current.getFullYear()) * 12 +
(anchor.getMonth() - current.getMonth());
if (monthsDelta !== 0) {
gotoMonth(monthsDelta)
}
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 (
<View class="day-container">
{date}
</View>
)
}
return date
const { date, month, year } = day;
const today = new Date();
if (
date === today.getDate() &&
month === today.getMonth() + 1 &&
year === today.getFullYear()
) {
return <View className="day-container">{date}</View>;
}
return date;
};
const selectWeekend = () => {
const [start, end] = getWeekend()
setSelectedValue([start, end])
syncMonthTo(start)
onChange?.([start, end])
}
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 dayList = getWeekendOfCurrentWeek(7);
setSelectedValue(dayList);
syncMonthTo(dayList[0]);
onChange?.(dayList);
};
const selectMonth = () => {
const dayList = getWeekendOfCurrentWeek(30)
setSelectedValue(dayList)
syncMonthTo(dayList[0])
onChange?.(dayList)
}
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)
}
calendarRef.current?.jumpTo(year, month);
};
return (
<View>
{/* 快速操作行 */}
{
showQuickActions &&
<View className={styles['quick-actions']}>
<View className={styles['quick-action']} onClick={selectWeekend}></View>
<View className={styles['quick-action']} onClick={selectWeek}></View>
<View className={styles['quick-action']} onClick={selectMonth}></View>
{showQuickActions && (
<View className={styles["quick-actions"]}>
<View className={styles["quick-action"]} onClick={selectWeekend}>
</View>
}
<View className={`${styles['calendar-card']} ${isBorder ? styles['border'] : ''}`}>
<View className={styles["quick-action"]} onClick={selectWeek}>
</View>
<View className={styles["quick-action"]} onClick={selectMonth}>
</View>
</View>
)}
<View
className={`${styles["calendar-card"]} ${
isBorder ? styles["border"] : ""
}`}
>
{type === "range" && (
<View className={styles["date-range-container"]}>
<Text
className={`${styles["date-text-placeholder"]} ${
(value as Date[]).length === 2 ? styles["date-text"] : ""
}`}
>
{(value as Date[]).length === 2
? dayjs(value?.[0] as Date).format("YYYY-MM-DD")
: "起始时间"}
</Text>
<Text></Text>
<Text
className={`${styles["date-text-placeholder"]} ${
(value as Date[]).length === 2 ? styles["date-text"] : ""
}`}
>
{(value as Date[]).length === 2
? dayjs(value?.[1] as Date).format("YYYY-MM-DD")
: "结束时间"}
</Text>
</View>
)}
{/* 自定义头部显示周一到周日 */}
<View className={styles['header']}>
<View className={styles['header-left']} onClick={handleHeaderClick}>
<Text className={styles['header-text']}>{formatHeader(current as Date)}</Text>
<Image src={images.ICON_RIGHT_MAX} className={`${styles['month-arrow']}`} onClick={() => gotoMonth(1)} />
<View className={styles["header"]}>
<View className={styles["header-left"]} onClick={handleHeaderClick}>
<Text className={styles["header-text"]}>
{formatHeader(current as Date)}
</Text>
<Image
src={images.ICON_RIGHT_MAX}
className={`${styles["month-arrow"]}`}
onClick={() => gotoMonth(1)}
/>
</View>
<View className={styles['header-actions']}>
<View className={styles['arrow-left-container']} onClick={() => gotoMonth(-1)}>
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']} ${styles['left']}`} />
<View className={styles["header-actions"]}>
<View
className={styles["arrow-left-container"]}
onClick={() => gotoMonth(-1)}
>
<Image
src={images.ICON_RIGHT_MAX}
className={`${styles["arrow"]} ${styles["left"]}`}
/>
</View>
<View className={styles['arrow-right-container']} onClick={() => gotoMonth(1)}>
<Image src={images.ICON_RIGHT_MAX} className={`${styles['arrow']}`} />
<View
className={styles["arrow-right-container"]}
onClick={() => gotoMonth(1)}
>
<Image
src={images.ICON_RIGHT_MAX}
className={`${styles["arrow"]}`}
/>
</View>
</View>
</View>
<View className={styles['week-header']}>
{[ '周日', '周一', '周二', '周三', '周四', '周五', '周六'].map((day) => (
<Text key={day} className={styles['week-day']}>
<View className={styles["week-header"]}>
{["周日", "周一", "周二", "周三", "周四", "周五", "周六"].map(
(day) => (
<Text key={day} className={styles["week-day"]}>
{day}
</Text>
))}
)
)}
</View>
{/* NutUI CalendarCard 组件 */}
@@ -196,16 +269,21 @@ const NutUICalendar = React.forwardRef<CalendarUIRef, NutUICalendarProps>(({
renderDay={renderDay}
onChange={handleDateChange}
onPageChange={handlePageChange}
onDayClick={handleDayClick}
/>
</View>
{ visible && <PopupPicker
{visible && (
<PopupPicker
visible={visible}
setvisible={setvisible}
value={[current.getFullYear(), current.getMonth() + 1]}
type="month"
onChange={(value) => handleMonthChange(value)}/> }
onChange={(value) => handleMonthChange(value)}
/>
)}
</View>
)
})
);
}
);
export default NutUICalendar
export default NutUICalendar;

View File

@@ -1,35 +1,63 @@
.calendar-card {
background: #fff;
border-radius: 16px;
&.border{
&.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;
}
.header-left {
}
.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 {
}
.header-text {
font-size: 17px;
font-weight: 600;
color: #000;
}
.header-actions {
}
.header-actions {
display: flex;
width: 60px;
.arrow-left-container {
display: flex;
align-items: center;
@@ -37,6 +65,7 @@
width: 50%;
flex: 1;
}
.arrow-right-container {
display: flex;
align-items: center;
@@ -44,134 +73,145 @@
width: 50%;
flex: 1;
}
}
.month-arrow{
}
.month-arrow {
width: 8px;
height: 24px;
}
.arrow {
}
.arrow {
width: 10px;
height: 24px;
position: relative;
}
.arrow.left {
}
.arrow.left {
left: 9px;
transform: rotate(-180deg);
}
}
.week-row {
.week-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 0 0 4px 0;
}
.week-item {
}
.week-item {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 13px;
}
}
// 新增的周一到周日头部样式
.week-header {
// 新增的周一到周日头部样式
.week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 8px 0;
}
.week-day {
}
.week-day {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 14px;
font-weight: 500;
}
}
.grid {
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px 0;
padding: 4px 0 16px;
}
.cell {
}
.cell {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
}
.cell.empty {
}
.cell.empty {
opacity: 0;
}
.cell.disabled {
color: rgba(0,0,0,0.2);
}
.cell-text.selected {
}
.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);
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
// 时间段选择样式
.cell-text.range-start {
// 时间段选择样式
.cell-text.range-start {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.9);
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
.cell-text.range-end {
.cell-text.range-end {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.9);
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
}
.cell-text.in-range {
.cell-text.in-range {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.1);
background: rgba(0, 0, 0, 0.1);
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
}
.footer {
.footer {
display: flex;
gap: 12px;
}
.btn {
}
.btn {
flex: 1;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.06);
background: rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.btn.primary {
}
.btn.primary {
background: #000;
color: #fff;
}
}
.hm-placeholder {
.hm-placeholder {
height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
}
// CalendarRange 组件样式
.calendar-range {
@@ -241,43 +281,56 @@
.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%;
}
}

View File

@@ -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 [

View File

@@ -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 = [],
img,
onConfirm,
onChange,
options = [],
type = null
type = null,
}: PickerProps) => {
const [defaultValue, setDefaultValue] = useState<(string | number)[]>([])
const [defaultOptions, setDefaultOptions] = useState<PickerOption[][]>([])
const [defaultValue, setDefaultValue] = useState<(string | number)[]>([]);
const [defaultOptions, setDefaultOptions] = useState<PickerOption[][]>([]);
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])
}, [type]);
// useEffect(() => {
// if (value.length > 0 && defaultOptions.length > 0) {
// setDefaultValue([...value])
// }
// }, [value, defaultOptions])
// useEffect(() => {
// if (value.length > 0 && defaultOptions.length > 0) {
// setDefaultValue([...value])
// }
// }, [value, defaultOptions])
return (
<>
<CommonPopup
@@ -69,13 +98,34 @@ const PopupPicker = ({
showHeader={false}
title={null}
hideFooter={false}
cancelText='取消'
confirmText='完成'
cancelText="取消"
confirmText="完成"
onConfirm={handleConfirm}
position='bottom'
position="bottom"
round
zIndex={1000}
>
{type === "ntrp" && (
<View className={`${styles["examination-btn"]}}`}>
<View className={`${styles["text-container"]}}`}>
<View className={`${styles["text-title"]}}`}>
<Text>NTRP</Text>
</View>
<View className={`${styles["text-btn"]}}`}>
<Text></Text>
<Image src={imgs.ICON_ARROW_GREEN} className={`${styles["icon-arrow"]}`}></Image>
</View>
</View>
<View className={`${styles["img-container"]}}`}>
<View className={`${styles["img-box"]}`}>
<Image src={img!}></Image>
</View>
<View className={`${styles["img-box"]}`}>
<Image src={imgs.ICON_EXAMINATION}></Image>
</View>
</View>
</View>
)}
<Picker
visible={visible}
options={defaultOptions}
@@ -84,7 +134,7 @@ const PopupPicker = ({
/>
</CommonPopup>
</>
)
}
);
};
export default PopupPicker
export default PopupPicker;

View File

@@ -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%;
}
}
}
}
}

View File

@@ -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<PublishMenuProps> = () => {
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');
Taro.navigateTo({
url: `/publish_pages/publishBall/index?type=${type}`
})
setIsVisible(false)
console.log(type, "type");
if (type === "ai") {
setAiImportVisible(true);
setIsVisible(false);
return;
}
Taro.navigateTo({
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 (
<View className={styles.publishMenu}>
{/* 蒙层 */}
{isVisible && (
<View className={styles.overlay} onClick={handleOverlayClick} />
)}
{/* 菜单选项 */}
{isVisible && (
<View className={styles.menuCard}>
<View
className={styles.menuItem}
onClick={() => handleMenuItemClick('individual')}
onClick={() => handleMenuItemClick("individual")}
>
<View className={styles.menuContent}>
<View className={styles.menuTitle}>
<View className={styles.menuArrow}>
<Image
src={images.ICON_ARROW_RIGHT_BLACK}
className={styles.img}
/>
</View>
</View>
<Text className={styles.menuDesc}>
</Text>
</View>
<View className={styles.menuIcon}>
<Image src={images.ICON_PERSON} />
</View>
<View className={styles.menuContent}>
<Text className={styles.menuTitle}></Text>
<Text className={styles.menuDesc}></Text>
</View>
<View className={styles.menuArrow}>
<Image src={images.ICON_ARROW_RIGHT} className={styles.img} />
</View>
</View>
<View
className={styles.menuItem}
onClick={() => handleMenuItemClick('group')}
onClick={() => handleMenuItemClick("group")}
>
<View className={styles.menuContent}>
<View className={styles.menuTitle}>
<View className={styles.menuArrow}>
<Image
src={images.ICON_ARROW_RIGHT_BLACK}
className={styles.img}
/>
</View>
</View>
<Text className={styles.menuDesc}></Text>
</View>
<View className={styles.menuIcon}>
<Image src={images.ICON_GROUP} />
</View>
<View className={styles.menuContent}>
<Text className={styles.menuTitle}></Text>
<Text className={styles.menuDesc}></Text>
</View>
<View
className={`${styles.menuItem} ${styles.aiItem}`}
onClick={() => handleMenuItemClick("ai")}
>
<View className={styles.menuContent}>
<View className={styles.menuTitle}>
<View className={styles.menuArrow}>
<Image src={images.ICON_ARROW_RIGHT} className={styles.img} />
<Image
src={images.ICON_ARROW_RIGHT_WHITE}
className={styles.img}
/>
</View>
</View>
<Text className={styles.menuDesc}>
/
</Text>
</View>
<View className={styles.menuIcon}>
<Image src={images.ICON_IMPORTANT_BTN} />
</View>
</View>
</View>
@@ -69,13 +124,20 @@ const PublishMenu: React.FC<PublishMenuProps> = () => {
{/* 绿色圆形按钮 */}
<View
className={`${styles.greenButton} ${isVisible ? styles.rotated : ''}`}
className={`${styles.greenButton} ${isVisible ? styles.rotated : ""}`}
onClick={handleIconClick}
>
<Image src={images.ICON_PUBLISH} className={styles.closeIcon} />
</View>
</View>
)
}
export default PublishMenu
{/* AI导入弹窗 */}
<AiImportPopup
visible={aiImportVisible}
onClose={handleAiImportClose}
onManualPublish={handleManualPublish}
/>
</View>
);
};
export default PublishMenu;

View File

@@ -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);
}
}

View File

@@ -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 (
<View>
<Canvas
type="2d"
id="radarCanvas"
style={{ width: "320px", height: "320px", background: "transparent" }}
/>
{/* <Button onClick={saveImage}>保存为图片</Button> */}
</View>
);
});
export default RadarChart;

View File

@@ -66,8 +66,11 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
<View className='time-content' onClick={() => openPicker('start')}>
<Text className='time-label'></Text>
<view className='time-text-wrapper'>
{value.start_time && (<>
<Text className='time-text'>{getDate(value.start_time)}</Text>
<Text className='time-text time-am'>{getTime(value.start_time)}</Text>
</>)}
{!value.start_time && (<Text className='time-text'></Text>)}
</view>
</View>
</View>
@@ -80,8 +83,9 @@ const TimeSelector: React.FC<TimeSelectorProps> = ({
<View className='time-content' onClick={() => openPicker('end')}>
<Text className='time-label'></Text>
<view className='time-text-wrapper'>
{showEndTime && (<Text className='time-text'>{getDate(value.end_time)}</Text>)}
<Text className='time-text time-am'>{getTime(value.end_time)}</Text>
{value.end_time && (<>{showEndTime && (<Text className='time-text'>{getDate(value.end_time)}</Text>)}
<Text className='time-text time-am'>{getTime(value.end_time)}</Text></>)}
{!value.end_time && (<Text className='time-text'></Text>)}
</view>
</View>
</View>

View File

@@ -126,7 +126,7 @@ export default function UploadCover(props: UploadCoverProps) {
value.map((item) => {
return (
<View className="cover-image-item" key={item.id}>
<Image className="cover-image-item-image" src={item.url} />
<Image className="cover-image-item-image" src={item.url} mode="aspectFill" />
<Image className="cover-image-item-delete" src={img.ICON_REMOVE} onClick={() => onDelete(item)} />
</View>
)

View File

@@ -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;

View File

@@ -121,6 +121,7 @@ export default forwardRef(function UploadImage(props: UploadImageProps, ref) {
<ScrollView
scrollY
className="upload-popup-scroll-view"
// style={{ height: images.length / 3 * }}
>
{images.length > 0 ? (
<View className="upload-popup-image-list">
@@ -128,7 +129,7 @@ export default forwardRef(function UploadImage(props: UploadImageProps, ref) {
const isSelected = checkImageSelected(selectedImages, item)
return (
<View className={`upload-popup-image-item ${outOfMax ? 'disabled' : ''} ${isSelected ? 'selected' : ''}`} onClick={() => handleImageClick(item)}>
<Image className="upload-popup-image-item-image" src={item.url} />
<Image className="upload-popup-image-item-image" src={item.url} mode="aspectFill" />
<View className={`upload-popup-image-item-select ${isSelected ? 'selected' : ''}`}>
{isSelected ? (
<Image className="select-image-icon" src={img.ICON_CIRCLE_SELECT_ARROW} />

View File

@@ -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,6 +269,50 @@
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;
}
}
}
.personal_profile {
.personal_profile_edit {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
.edit_icon {
width: 16px;
height: 16px;
}
}
.bio_text {
@@ -250,6 +323,8 @@
color: rgba(0, 0, 0, 0.65);
white-space: pre-line;
}
}
}
}

View File

@@ -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<UserInfoCardProps> = ({
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<string>("");
const [gender_picker_visible, setGenderPickerVisible] = useState(false);
const [location_picker_visible, setLocationPickerVisible] = useState(false);
const [ntrp_picker_visible, setNtrpPickerVisible] = useState(false);
const [occupation_picker_visible, setOccupationPickerVisible] =
useState(false);
// 表单状态
const [form_data, setFormData] = useState<UserInfo>({ ...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 (
<View className="user_info_card">
{/* 头像和基本信息 */}
@@ -64,21 +237,30 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
<Text className="nickname">{user_info.nickname}</Text>
<Text className="join_date">{user_info.join_date}</Text>
</View>
<View className='tag_item' onClick={on_edit}>
{is_current_user && (
<View className="tag_item" onClick={on_edit}>
<Image
className="tag_icon"
src={require('../../static/userInfo/edit.svg')}
/> </View>
src={require("../../static/userInfo/edit.svg")}
/>
</View>
)}
</View>
{/* 统计数据 */}
<View className="stats_section">
<View className="stats_container">
<View className="stat_item">
<View
className="stat_item clickable"
onClick={() => handle_stats_click("following")}
>
<Text className="stat_number">{user_info.stats.following}</Text>
<Text className="stat_label"></Text>
</View>
<View className="stat_item">
<View
className="stat_item clickable"
onClick={() => handle_stats_click("friends")}
>
<Text className="stat_number">{user_info.stats.friends}</Text>
<Text className="stat_label"></Text>
</View>
@@ -95,27 +277,31 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
{/* 只有非当前用户才显示关注按钮 */}
{!is_current_user && on_follow && (
<Button
className={`follow_button ${is_following ? 'following' : ''}`}
className={`follow_button ${is_following ? "following" : ""}`}
onClick={on_follow}
>
<Image
className="button_icon"
src={require('../../static/userInfo/plus.svg')}
src={require(is_following
? "@/static/userInfo/following.svg"
: "@/static/userInfo/unfollow.svg")}
/>
<Text className="button_text">
{is_following ? '已关注' : '关注'}
<Text
className={`button_text ${is_following ? "following" : ""}`}
>
{is_following ? "已关注" : "关注"}
</Text>
</Button>
)}
{/* 只有非当前用户才显示消息按钮 */}
{!is_current_user && on_message && (
{/* {!is_current_user && on_message && (
<Button className="message_button" onClick={on_message}>
<Image
className="button_icon"
src={require('../../static/userInfo/message.svg')}
src={require("@/static/userInfo/chat.svg")}
/>
</Button>
)}
)} */}
{/* 只有当前用户才显示分享按钮 */}
{is_current_user && on_share && (
@@ -129,38 +315,155 @@ export const UserInfoCard: React.FC<UserInfoCardProps> = ({
{/* 标签和简介 */}
<View className="tags_bio_section">
<View className="tags_container">
{user_info.gender ? (
<View className="tag_item">
{user_info.gender === "0" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/male.svg')}
src={require("../../static/userInfo/male.svg")}
/>
)}
{user_info.gender === "1" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/female.svg')}
src={require("../../static/userInfo/female.svg")}
/>
)}
</View>
) : is_current_user ? (
<View className="button_edit" onClick={() => { handle_open_edit_modal('gender') }}>
<Text></Text>
</View>
) : null}
{user_info.ntrp_level ? (
<View className="tag_item">
<Text className="tag_text">{user_info.ntrp_level || '未设置'}</Text>
<Text className="tag_text">{`NTRP ${user_info.ntrp_level}`}</Text>
</View>
) : is_current_user ? (
<View className="button_edit" onClick={() => { handle_open_edit_modal('ntrp_level') }}>
<Text>NTRP水平</Text>
</View>
) : null}
{user_info.occupation ? (
<View className="tag_item">
<Text className="tag_text">{user_info.occupation || '未设置'}</Text>
<Text className="tag_text">{user_info.occupation.split(" ")[1]}</Text>
</View>
) : is_current_user ? (
<View className="button_edit" onClick={() => { handle_open_edit_modal('occupation') }}>
<Text></Text>
</View>
) : null}
{user_info.country || user_info.province || user_info.city ? (
<View className="tag_item">
<Image
className="tag_icon"
src={require('../../static/userInfo/location.svg')}
/>
<Text className="tag_text">{user_info.location || '未设置'}</Text>
<Text className="tag_text">{`${user_info.province}${user_info.city}`}</Text>
</View>
) : is_current_user ? (
<View className="button_edit" onClick={() => handle_open_edit_modal('location')}>
<Text></Text>
</View>
) : null}
</View>
<View className="personal_profile">
{user_info.personal_profile ? (
<Text className="bio_text">{user_info.personal_profile}</Text>
) : is_current_user ? (
<View
className="personal_profile_edit"
onClick={() => handle_open_edit_modal("personal_profile")}
>
<Image
className="edit_icon"
src={require("../../static/userInfo/info_edit.svg")}
/>
<Text className="bio_text"></Text>
</View>
) : null}
</View>
</View>
{/* 编辑个人简介弹窗 */}
<EditModal
visible={edit_modal_visible}
type={editing_field}
title="编辑简介"
placeholder="介绍一下你的喜好,或者训练习惯"
initialValue={form_data["personal_profile"] || ""}
maxLength={100}
onSave={handle_edit_modal_save}
onCancel={handle_edit_modal_cancel}
validationMessage="请填写 2-100 个字符"
/>
{/* 性别选择弹窗 */}
{gender_picker_visible && (
<PopupPicker
options={[
[
{ text: "男", value: "0" },
{ text: "女", value: "1" },
{ text: "保密", value: "2" },
],
]}
visible={gender_picker_visible}
setvisible={setGenderPickerVisible}
value={[form_data.gender]}
onChange={handle_gender_change}
/>
)}
{/* 地区选择弹窗 */}
{location_picker_visible && (
<PopupPicker
options={[
[{ text: "中国", value: "中国" }],
[{ text: "上海", value: "上海" }],
[
{ text: "浦东新区", value: "浦东新区" },
{ text: "静安区", value: "静安区" },
],
]}
visible={location_picker_visible}
setvisible={setLocationPickerVisible}
value={[form_data.country, form_data.province, form_data.city]}
onChange={handle_location_change}
/>
)}
{/* NTRP水平选择弹窗 */}
{ntrp_picker_visible && (
<PopupPicker
options={[
[
{ text: "1.5", value: "1.5" },
{ text: "2.0", value: "2.0" },
{ text: "2.5", value: "2.5" },
{ text: "3.0", value: "3.0" },
{ text: "3.5", value: "3.5" },
{ text: "4.0", value: "4.0" },
{ text: "4.5", value: "4.5" },
],
]}
type="ntrp"
img={user_info.avatar}
visible={ntrp_picker_visible}
setvisible={setNtrpPickerVisible}
value={[form_data.ntrp_level]}
onChange={handle_ntrp_level_change}
/>
)}
{/* 职业选择弹窗 */}
{occupation_picker_visible && (
<PopupPicker
options={[
[{ text: "时尚", value: "时尚" }],
[
{ text: "美妆博主", value: "美妆博主" },
{ text: "设计师", value: "设计师" },
],
]}
visible={occupation_picker_visible}
setvisible={setOccupationPickerVisible}
value={[...form_data.occupation.split(" ")]}
onChange={handle_occupation_change}
/>
)}
</View>
);
};
@@ -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<GameCardProps> = ({
game,
on_click,
on_participant_click
on_participant_click,
}) => {
return (
<View
className="game_card"
onClick={() => on_click(game.id)}
>
<View className="game_card" onClick={() => on_click(game.id)}>
{/* 球局标题和类型 */}
<View className="game_header">
<Text className="game_title">{game.title}</Text>
<View className="game_type_icon">
<Image
className="type_icon"
src={require('../../static/userInfo/tennis.svg')}
src={require("../../static/userInfo/tennis.svg")}
/>
</View>
</View>
@@ -233,12 +535,8 @@ export const GameCard: React.FC<GameCardProps> = ({
{/* 球局图片 */}
<View className="game_images">
{game.images.map((image, index) => (
<Image
key={index}
className="game_image"
src={image}
/>
{game.image_list.map((image, index) => (
<Image key={index} className="game_image" src={image} />
))}
</View>
@@ -246,7 +544,7 @@ export const GameCard: React.FC<GameCardProps> = ({
<View className="game_tags">
<View className="participants_info">
<View className="avatars">
{game.participants.map((participant, index) => (
{game.participants?.map((participant, index) => (
<Image
key={index}
className="participant_avatar"
@@ -279,8 +577,8 @@ export const GameCard: React.FC<GameCardProps> = ({
// 球局标签页组件属性
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,18 +586,25 @@ interface GameTabsProps {
export const GameTabs: React.FC<GameTabsProps> = ({
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 (
<View className="game_tabs_section">
<View className="tab_container">
<View className={`tab_item ${active_tab === 'hosted' ? 'active' : ''}`} onClick={() => on_tab_change('hosted')}>
<View
className={`tab_item ${active_tab === "hosted" ? "active" : ""}`}
onClick={() => on_tab_change("hosted")}
>
<Text className="tab_text">{hosted_text}</Text>
</View>
<View className={`tab_item ${active_tab === 'participated' ? 'active' : ''}`} onClick={() => on_tab_change('participated')}>
<View
className={`tab_item ${active_tab === "participated" ? "active" : ""
}`}
onClick={() => on_tab_change("participated")}
>
<Text className="tab_text">{participated_text}</Text>
</View>
</View>

View File

@@ -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,
};

View File

@@ -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;
}
}

View File

@@ -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 (
<View className={styles.refundPolicy}>
{/* <View className={styles.moduleTitle}>
<Text>退款政策</Text>
</View> */}
{<View className={styles.specTips}>{notice}</View>}
{/* 订单信息摘要 */}
<View className={styles.policyList}>
{policyList.map((item, index) => (
<View key={index} className={styles.policyItem}>
<View className={styles.time}>{item.time}</View>
<View className={styles.rule}>{item.rule}</View>
</View>
))}
</View>
</View>
);
}
export type RefundRef = {
show: (item: any, callback: (result: boolean) => void) => void
}
export default forwardRef<RefundRef>(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 (
<CommonPopup
visible={visible}
onClose={onClose}
// title="退出活动"
enableDragToClose={false}
zIndex={1001}
hideFooter
>
<View className={styles.container}>
<View className={styles.header}>
<Text className={styles.title}>退</Text>
<Image className={styles.closeIcon} src={closeIcon} onClick={onClose} />
</View>
{renderCancelContent(checkOrderInfo)}
<Button className={styles.action} onClick={handleConfirmQuit}>退</Button>
</View>
</CommonPopup>
)
})

View File

@@ -8,10 +8,11 @@ export const API_CONFIG = {
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',
},
// 文件上传接口
@@ -28,7 +29,9 @@ export const API_CONFIG = {
CREATE: '/game/create',
JOIN: '/game/join',
LEAVE: '/game/leave'
}
},
PROFESSIONS: '/professions/tree',
CITIS: '/cities/tree'
};
// 请求拦截器配置

View File

@@ -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<EnvType, EnvConfig> = {
// 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<EnvType, EnvConfig> = {
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' : '未知'
}
}

View File

@@ -171,7 +171,7 @@ export const publishBallFormSchema: FormFieldConfig[] = [
}
},
{
prop: 'is_wechat_contact',
prop: 'wechat',
label: '',
type: FieldType.WECHATCONTACT,
required: true,

View File

@@ -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'),
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'),
}

View File

@@ -5,7 +5,7 @@
flex-direction: column;
gap: 5px;
padding-bottom: 34px;
min-height: 100vh;
// min-height: 100vh;
.recommendTextWrapper {
display: flex;

View File

@@ -1,4 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '球局详情',
navigationStyle: 'custom',
enableShareAppMessage: true,
})

View File

@@ -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,19 +1122,34 @@
}
}
&-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;
&.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";
@@ -1140,37 +1160,20 @@
color: #000;
}
}
}
}
}
.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 {
.game_manage {
width: 100px;
height: 64px;
border-radius: 12px;
margin-left: auto;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
& > image {
width: 24px;
height: 24px;
justify-content: center;
background: #000;
color: #fff;
pointer-events: all;
}
}
& > text {
color: rgba(0, 0, 0, 0.85);
}
}
}

View File

@@ -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 */}
<Image
className="detail-page-content-avatar-tags-avatar-image"
mode="aspectFill"
src={avatar_url}
onClick={handleViewUserInfo.bind(null, id)}
/>
</View>
<View className="detail-page-content-avatar-tags-tags">
@@ -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 (
<CommonPopup
@@ -203,12 +218,28 @@ const SharePopup = forwardRef(
hideFooter
style={{ minHeight: "100px" }}
>
<View catchMove className="share-popup-content">
<View className={styles.shareContainer}>
<View catchMove className={styles.title}>
</View>
<View className={styles.shareItems}>
<Button
className={classnames(styles.button, styles.share)}
openType="share"
>
</Button>
<Button
className={classnames(styles.button, styles.save)}
onClick={handleSaveToLocal}
>
</Button>
</View>
</View>
</CommonPopup>
);
},
}
);
function navto(url) {
@@ -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: () => <Text>¥{displayPrice} </Text>,
action: () => toast("已加入"),
};
} else if (is_substituting) {
return {
text: "候补中,查看其他球局",
action: navto.bind(null, "/game_pages/list/index"),
text: () => <Text>¥{displayPrice} </Text>,
action: () => toast("已加入候补"),
};
} else if (can_pay) {
return {
text: "继续支付",
text: () => <Text>¥{price} </Text>,
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: () => <Text>¥{displayPrice} </Text>,
action: handleJoinGame,
};
} else if (can_join) {
return {
text: () => {
return (
<>
<Text>🎾</Text>
<Text></Text>
<View className="game-price">
<Text>¥ {price}</Text>
</View>
</>
);
return <Text>¥{displayPrice} </Text>;
},
action: handleJoinGame,
};
@@ -312,8 +391,9 @@ function StickyButton(props) {
types={[EvaluateType.EDIT, EvaluateType.EVALUATE]}
scene={SceneType.DETAIL}
displayCondition={DisplayConditionType.AUTO}
showGuide={false}
>
<Text>NTRP自评</Text>
<Text>¥{displayPrice} </Text>
</NTRPEvaluatePopup>
),
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,9 +423,8 @@ function StickyButton(props) {
};
}
// const role = Number(publisher_id) === id ? "ownner" : "visitor";
return (
<>
<View className="sticky-bottom-bar">
<View className="sticky-bottom-bar-share-and-comment">
<View className="sticky-bottom-bar-share" onClick={handleShare}>
@@ -355,20 +438,46 @@ function StickyButton(props) {
<View
className="sticky-bottom-bar-comment"
onClick={() => {
Taro.showToast({ title: "To be continued", icon: "none" });
// Taro.showToast({ title: "To be continued", icon: "none" });
handleAddComment();
}}
>
<Image
className="sticky-bottom-bar-comment-icon"
src={img.ICON_DETAIL_COMMENT_DARK}
/>
<Text className="sticky-bottom-bar-comment-text">32</Text>
<Text className="sticky-bottom-bar-comment-text">
{commentCount > 0 ? commentCount : "评论"}
</Text>
</View>
</View>
<View className="sticky-bottom-bar-join-game" onClick={action}>
<View
className={classnames(
"detail-main-action",
available ? "" : "disabled"
)}
>
<View
style={is_organizer ? {} : { margin: "auto" }}
className="sticky-bottom-bar-join-game"
onClick={action}
>
<ActionText />
</View>
{is_organizer && (
<View
className="game_manage"
onClick={() => {
gameManageRef.current.show(detail, onStatusChange);
}}
>
</View>
)}
</View>
</View>
<GameManagePopup ref={gameManageRef} />
</>
);
}
@@ -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,7 +698,8 @@ function VenueInfo(props) {
<View className="venue-screenshot-title"></View>
<ScrollView scrollY className="venue-screenshot-scroll-view">
<View className="venue-screenshot-image-list">
{venue_image_list.map((item) => {
{venue_image_list?.length > 0 &&
venue_image_list.map((item) => {
return (
<View
className="venue-screenshot-image-item"
@@ -596,6 +707,7 @@ function VenueInfo(props) {
>
<Image
className="venue-screenshot-image-item-image"
mode="aspectFill"
src={item.url}
/>
</View>
@@ -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 (
<View className="detail-page-content-participants">
<View className="participants-title">
@@ -691,7 +802,6 @@ function Participants(props) {
className="participants-list-application"
onClick={() => {
handleJoinGame();
// Taro.showToast({ title: "To be continued", icon: "none" });
}}
>
<Image
@@ -708,11 +818,14 @@ function Participants(props) {
<View
className="participants-list-scroll-content"
style={{
width: `${participants.length * 103 + (participants.length - 1) * 8}px`,
width: `${
participants.length * 103 + (participants.length - 1) * 8
}px`,
}}
>
{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 (
<View key={participant.id} className="participants-list-item">
<Avatar
<Image
className="participants-list-item-avatar"
mode="aspectFill"
src={avatar_url}
onClick={handleViewUserInfo.bind(
null,
participant_user_id
)}
/>
<Text className="participants-list-item-name">
{nickname || "未知"}
@@ -750,7 +867,7 @@ function Participants(props) {
function SupplementalNotes(props) {
const {
detail: { description, description_tag = [] },
detail: { description, description_tag },
} = props;
return (
<View className="detail-page-content-supplemental-notes">
@@ -760,7 +877,7 @@ function SupplementalNotes(props) {
<View className="supplemental-notes-content">
{/* supplemental notes tags */}
<View className="supplemental-notes-content-tags">
{insertDotInTags(description_tag).map((tag, index) => (
{insertDotInTags(description_tag || []).map((tag, index) => (
<View key={index} className="supplemental-notes-content-tags-tag">
<Text>{tag}</Text>
</View>
@@ -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 (
<View className="detail-page-content-organizer-recommend-games">
{/* orgnizer title */}
@@ -863,7 +991,12 @@ function OrganizerInfo(props) {
</View>
{/* organizer avatar and name */}
<View className="organizer-avatar-name">
<Avatar className="organizer-avatar-name-avatar" src={avatar_url} />
<Image
className="organizer-avatar-name-avatar"
src={avatar_url}
mode="aspectFill"
onClick={handleViewUserInfo.bind(null, id)}
/>
<View className="organizer-avatar-name-message">
<Text className="organizer-avatar-name-message-name">{nickname}</Text>
<View className="organizer-avatar-name-message-stats">
@@ -893,7 +1026,10 @@ function OrganizerInfo(props) {
)}
</View>
)}
<View className="organizer-actions-comment">
<View
className="organizer-actions-comment"
onClick={() => handleAddComment()}
>
<Image
className="organizer-actions-comment-icon"
src={img.ICON_DETAIL_COMMENT}
@@ -902,8 +1038,12 @@ function OrganizerInfo(props) {
</View>
</View>
{/* recommend games by organizer */}
{recommendGames.length > 0 && (
<View className="organizer-recommend-games">
<View className="organizer-recommend-games-title" onClick={() => {}}>
<View
className="organizer-recommend-games-title"
onClick={handleViewUserInfo.bind(null, id)}
>
<Text>TA的更多活动</Text>
<Image
className="organizer-recommend-games-title-arrow"
@@ -913,7 +1053,11 @@ function OrganizerInfo(props) {
<ScrollView className="recommend-games-list" scrollX>
<View className="recommend-games-list-content">
{recommendGames.map((game, index) => (
<View key={index} className="recommend-games-list-item">
<View
key={index}
className="recommend-games-list-item"
onClick={handleViewGame.bind(null, game.id)}
>
{/* game title */}
<View className="recommend-games-list-item-title">
<Text>{game.title}</Text>
@@ -937,14 +1081,20 @@ function OrganizerInfo(props) {
</View>
{/* organizer avatar、applications、level requirements、play type */}
<View className="recommend-games-list-item-addon">
<Avatar
<Image
className="recommend-games-list-item-addon-avatar"
mode="aspectFill"
src={game.avatar}
onClick={(e) => {
e.stopPropagation();
handleViewUserInfo(id);
}}
/>
<View className="recommend-games-list-item-addon-message">
<View className="recommend-games-list-item-addon-message-applications">
<Text>
{game.checkedApplications}/{game.applications}
{game.checkedApplications}/
{game.applications}
</Text>
</View>
<View className="recommend-games-list-item-addon-message-level-requirements">
@@ -960,6 +1110,7 @@ function OrganizerInfo(props) {
</View>
</ScrollView>
</View>
)}
</View>
);
}
@@ -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<any>(null);
const commentRef = useRef();
useDidShow(async () => {
await updateLocation();
@@ -1003,11 +1158,17 @@ function Index() {
const fetchDetail = async () => {
if (!id) return;
try {
const res = await DetailService.getDetail(Number(id));
if (res.code === 0) {
setDetail(res.data);
fetchUserInfoById(res.data.publisher_id);
}
} catch (e) {
if (e.message === "球局不存在") {
handleBack();
}
}
};
const onUpdateUserInfo = () => {
@@ -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 */}
<View className="detail-page-content">
{/* avatar and tags */}
<GameTags detail={detail} userInfo={userInfo} />
<GameTags
detail={detail}
userInfo={userInfo}
handleViewUserInfo={handleViewUserInfo}
/>
{/* title */}
<View className="detail-page-content-title">
<Text className="detail-page-content-title-text">{detail.title}</Text>
@@ -1085,7 +1264,11 @@ function Index() {
{/* gameplay requirements */}
<GamePlayAndRequirement detail={detail} />
{/* participants */}
<Participants detail={detail} handleJoinGame={handleJoinGame} />
<Participants
detail={detail}
handleJoinGame={handleJoinGame}
handleViewUserInfo={handleViewUserInfo}
/>
{/* supplemental notes */}
<SupplementalNotes detail={detail} />
{/* 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();
}}
/>
<Comments
ref={commentRef}
game_id={Number(id)}
publisher_id={Number(detail.publisher_id)}
/>
{/* sticky bottom action bar */}
<StickyButton
handleShare={handleShare}
handleJoinGame={handleJoinGame}
detail={detail}
onStatusChange={onStatusChange}
handleAddComment={() => {
commentRef.current && commentRef.current.addComment();
}}
getCommentCount={
commentRef.current && commentRef.current.getCommentCount
}
/>
{/* share popup */}
<SharePopup

View File

@@ -0,0 +1,24 @@
.shareContainer {
.title {
padding: 20px;
color: #000;
text-align: center;
font-family: "PingFang SC";
font-size: 18px;
font-style: normal;
font-weight: 600;
line-height: 28px;
}
.shareItems {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 60px;
.button {
width: 140px;
height: 40px;
}
}
}

View File

@@ -13,6 +13,7 @@ import DistanceQuickFilter from "@/components/DistanceQuickFilter";
import { withAuth } from "@/components";
import { updateUserLocation } from "@/services/userService";
// import ShareCardCanvas from "@/components/ShareCardCanvas";
import { useDictionaryStore } from "@/store/dictionaryStore";
const ListPage = () => {
@@ -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 (
<>

View File

@@ -10,7 +10,6 @@ const HomePage: React.FC = () => {
useEffect(() => {
const handleLoginRedirect = async () => {
const login_status = check_login_status();
if (login_status) {
try {
// 先获取用户信息

View File

@@ -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;
}

View File

@@ -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: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
),
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: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
),
onConfirm,
onCancel,
});
}
function handleQuit(item) {
if (refundRef.current) {
refundRef.current.show(item, (result) => {
if (result) {
init();
}
});
}
}
return (
<View className={styles.gameInfoContainer}>
{Boolean(order_status) && order_status !== OrderStatus.PENDING && (
<>
<View className={styles.paidInfo}> ¥ 90</View>
<View className={styles.gameStatus}>
<Text className={styles.statusText}></Text>
<Text>2</Text>
{["refund", "progress", "expired"].includes(orderStatus) && (
<View className={styles.paidInfo}>
{refundTextMap.get(refund_status)} ¥ {amount}
</View>
)}
{["progress", "expired"].includes(orderStatus) && (
<View className={styles.gameStatus}>
<Text className={styles.statusText}>{gameNotice.title}</Text>
{gameNotice.content && <Text>{gameNotice.content}</Text>}
</View>
</>
)}
<View className={styles.gameInfo}>
{/* Date and Weather */}
@@ -81,6 +249,21 @@ function GameInfo(props) {
</View>
</View>
</View>
<View className={styles.weather}>
{/* Weather icon */}
<View className={styles.weatherIcon}>
{/*<Image className="weather-icon" src={img.ICON_WEATHER_SUN} />*/}
<i className={`qi-${iconDay}`}></i>
</View>
{/* Weather text and temperature */}
<View className={styles.temperature}>
{tempMin && tempMax && (
<Text>
{tempMin} - {tempMax}
</Text>
)}
</View>
</View>
</View>
{/* Place */}
<View className={styles.gameInfoPlace}>
@@ -122,13 +305,42 @@ function GameInfo(props) {
</View>
</View>
{/* Action bar */}
<View className={styles.gameInfoActions}></View>
<View className={styles.gameInfoActions}>
{orderDetail.order_id
? generateOrderActions(
orderDetail,
{
handleDeleteOrder,
handleCancelOrder,
handleQuit,
handlePayNow: () => {},
handleViewGame,
},
"detail"
)?.map((obj) => (
<Button
className={classnames(styles.button, styles[obj.className])}
onClick={obj.action}
>
{obj.text}
</Button>
))
: ""}
</View>
<Dialog id="detailCancelOrder" />
<RefundPopup ref={refundRef} />
</View>
);
}
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: (
<View className={styles.orderNo}>
<Text>{order_no}</Text>
<Text
className={styles.copy}
onClick={handleCopy.bind(null, order_no)}
>
</Text>
</View>
),
},
]
: []),
];
return (
<View className={styles.orderSummary}>
@@ -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<GameOrderRes | {}>({});
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 = () => {
</View>
);
}
const { order_status, cancel_type } = orderDetail;
return (
<View className={styles.container}>
{/* Game Date and Address */}
@@ -339,16 +594,23 @@ const OrderCheck = () => {
detail={detail}
orderDetail={orderDetail}
currentLocation={location}
init={init}
/>
{/* Order message */}
<OrderMsg detail={detail} checkOrderInfo={checkOrderInfo} />
<OrderMsg
detail={detail}
orderDetail={orderDetail}
checkOrderInfo={checkOrderInfo}
/>
{/* Refund policy */}
<RefundPolicy checkOrderInfo={checkOrderInfo} />
{/* Disclaimer */}
<Disclaimer />
{(!id || orderDetail.order_status === OrderStatus.PENDING) && (
{(!id ||
(order_status === OrderStatus.PENDING &&
cancel_type === CancelType.NONE)) && (
<Button className={styles.payButton} onClick={handlePay}>
{orderDetail.order_status === OrderStatus.PENDING ? "继续" : "确认"}
{order_status === OrderStatus.PENDING ? "继续" : "确认"}
</Button>
)}

View File

@@ -1,42 +1,34 @@
@use "~@/scss/images.scss" as img;
.container {
padding: 12px;
padding: 12px 12px 40px;
background-color: #fafafa;
min-height: 100vh;
.orderItem {
height: 100vh;
width: 100%;
height: 222px;
box-sizing: border-box;
.list {
height: 100%;
width: 100%;
position: relative;
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);
// .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: space-between;
.userInfo {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
.avatar {
width: 16px;
height: 16px;
}
.nickName {
display: contents;
.nickNameText {
color: #000;
justify-content: center;
color: rgba(0, 0, 0, 0.8);
font-feature-settings:
"liga" off,
"clig" off;
@@ -45,37 +37,122 @@
font-style: normal;
font-weight: 500;
line-height: 18px;
}
.arrowRight {
width: 8px;
height: 8px;
}
background-color: #f9f9f9;
}
}
.paidInfo {
.orderItem {
width: 100%;
// 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;
// .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-end;
gap: 8px;
justify-content: space-between;
padding: 12px 15px 0;
.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 {
.title {
overflow: hidden;
color: #000;
}
font-feature-settings: 'liga' off, 'clig' off;
text-overflow: ellipsis;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: 24px; /* 150% */
}
.payNum {
@@ -96,14 +173,96 @@
}
}
}
.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% */
}
.gameInfo {
height: 122px;
.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 {
.dialogFooter {
// width: 100%;
width: calc(100% + 1px);
height: 44px;
box-sizing: border-box;
display: flex;
padding: 15px 0 8px;
justify-content: space-between;
align-items: center;
align-self: stretch;
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;
&:last-child {
background: #000;
color: #fff;
}
}
.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);
& > .cancel {
border-radius: 0;
}
.policyItem {
display: flex;
justify-content: space-around;
align-items: center;
color: #000;
& > .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-feature-settings: 'liga' off, 'clig' off;
font-family: "PingFang SC";
font-size: 12px;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 20px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
line-height: 24px;
}
&:nth-child(1) {
color: #000;
text-align: center;
font-feature-settings:
"liga" off,
"clig" off;
.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: 600;
line-height: 20px;
border: none;
}
.time,
.rule {
width: 50%;
padding: 10px 12px;
}
.rule {
border-left: 1px solid rgba(0, 0, 0, 0.06);
}
}
font-weight: 500;
line-height: 24px;
}
}

View File

@@ -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 (
<>
<Text>
{diffDay <= 2 && diffDay >= 0
? diffDayMap.get(diffDay)
: startTime.format("YYYY-MM-DD")}
</Text>
<Text>({DayOfWeekMap.get(dayofWeek)})</Text>
<Text>{startTime.format("ah")}</Text>
<Text>{gameLength}</Text>
</>
);
}
const OrderList = () => {
const [list, setList] = useState([]);
const [list, setList] = useState<any[][]>([]);
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,51 +116,72 @@ 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,
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");
};
}),
];
return (
<View className={styles.refundPolicy}>
<View className={styles.moduleTitle}>
<Text>退</Text>
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: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
{/* 订单信息摘要 */}
<View className={styles.policyList}>
{policyList.map((item, index) => (
<View key={index} className={styles.policyItem}>
<View className={styles.time}>{item.time}</View>
<View className={styles.rule}>{item.rule}</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
))}
</View>
</View>
);
),
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 () => {
const { order_no } = item;
const onCancel = () => {
Dialog.close("cancelOrder");
};
const onConfirm = async () => {
try {
const cancelRes = await orderService.cancelUnpaidOrder({
order_no,
@@ -96,7 +190,11 @@ const OrderList = () => {
if (cancelRes.code !== 0) {
throw new Error(cancelRes.message);
}
getOrders();
getOrders(item.page, false);
Taro.showToast({
title: "取消成功",
icon: "none",
});
} catch (e) {
Taro.showToast({
title: e.message,
@@ -105,42 +203,38 @@ const OrderList = () => {
} finally {
Dialog.close("cancelOrder");
}
},
onCancel: () => {
Dialog.close("cancelOrder");
},
});
return;
}
const res = await orderService.getCheckOrderInfo(game_info.id);
};
Dialog.open("cancelOrder", {
title: "确定取消订单吗?",
content: renderCancelContent(res.data),
onConfirm: async () => {
try {
const refundRes = await orderService.applicateRefund({
order_no,
refund_amount: amount,
refund_reason: "用户主动退款",
content: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
),
onConfirm,
onCancel,
});
if (refundRes.code !== 0) {
throw new Error(refundRes.message);
}
getOrders();
} catch (e) {
Taro.showToast({
title: e.message,
icon: "error",
});
} finally {
Dialog.close("cancelOrder");
function handleQuit(item) {
if (refundRef.current) {
refundRef.current.show(item, (result) => {
if (result) {
getOrders(item.page);
}
},
onCancel: () => {
Dialog.close("cancelOrder");
},
});
}
}
function handleViewOrderDetail(orderId) {
Taro.navigateTo({
@@ -148,92 +242,164 @@ const OrderList = () => {
});
}
const flatList = list.flat()
return (
<View className={styles.container}>
{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 &&
<ScrollView
scrollY
scrollWithAnimation
lowerThreshold={20}
onScrollToLower={handleFetchNext}
enhanced
showScrollbar={false}
className={styles.list}
>
{/* <View className={styles.bg} /> */}
{flatList.map((item) => {
const unPay =
item.order_status === OrderStatus.PENDING &&
item.cancel_type === CancelType.NONE;
const { game_info } = item;
const {
skill_level_max,
skill_level_min,
play_type,
participants,
location_name,
current_players,
max_players,
court_type,
} = game_info || {};
return (
<View key={item.id} className={styles.orderItem}>
<View className={styles.orderTitle}>
<View className={styles.userInfo}>
<Avatar
className={styles.avatar}
src="https://img.yzcdn.cn/vant/cat.jpeg"
/>
<View className={styles.nickName}>
<Text className={styles.nickNameText}>Light</Text>
<Image
className={styles.arrowRight}
src={orderListArrowRight}
/>
</View>
</View>
{expired ? (
""
) : (
<View className={styles.paidInfo}>
<Text
className={classnames(
styles.payTime,
styles[unPay ? "pending" : "paid"],
)}
>
{unPay
? `请在 ${expiredTime} 前支付`
: dayjs(item.pay_time).format("YYYY-MM-DD HH:mm:ss")}
</Text>
<Text
className={classnames(
styles.payNum,
styles[unPay ? "pending" : "paid"],
)}
>
{unPay ? "待支付" : "已支付"} ¥ {item.amount}
</Text>
</View>
)}
</View>
<View
className={styles.gameInfo}
onClick={handleViewOrderDetail.bind(null, item.id)}
>
{item?.game_info?.title}
<View className={styles.gameTitle}>
<View className={styles.title}>{item?.game_info?.title}</View>
<View
className={classnames(
styles.payNum,
styles[unPay ? "pending" : "paid"]
)}
>
<Text>{unPay ? "待支付" : refundTextMap.get(item.refund_status)}</Text> ¥{" "}
<Text>{item.amount}</Text>
</View>
</View>
<View className={styles.gameTime}>
{generateTimeMsg(item.game_info || {})}
</View>
<View className={styles.address}>
{insertDotInTags([location_name, court_type, "3.5km"]).map(
(text, index) => (
<Text key={index}>{text}</Text>
)
)}
</View>
<View className={styles.gameOtherInfo}>
{participants?.length >= 0 ? (
<View className={styles.avatarCards}>
{
/* 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 (
<Image
className={styles.avatar}
mode="aspectFill"
key={id}
src={avatar_url}
/>
);
})
}
</View>
) : (
""
)}
<View className={styles.participantProgress}>
<Text className={styles.current}>
{current_players}
</Text>
<Text>/</Text>
<Text>{max_players}</Text>
</View>
<View className={styles.levelReq}>
{skill_level_max !== skill_level_min
? `${skill_level_min || "-"}${skill_level_max || "-"}`
: skill_level_min === 1
? "无要求"
: `${skill_level_min} 以上`}
</View>
<View className={styles.playType}>{play_type}</View>
</View>
</View>
<View className={styles.orderActions}>
<View className={styles.extraActions}></View>
<View className={styles.mainActions}>
{showCancel && (
{generateOrderActions(
item,
{
handleDeleteOrder,
handleCancelOrder,
handleQuit,
handlePayNow,
handleViewGame,
},
"list"
)?.map((obj) => (
<Button
className={classnames(styles.button, styles.cancelOrder)}
onClick={handleCancelOrder.bind(null, item)}
>
</Button>
className={classnames(
styles.button,
styles[obj.className]
)}
{unPay && !expired && (
<Button
className={classnames(styles.button, styles.payNow)}
onClick={handlePayNow.bind(null, item.game_info.id)}
onClick={obj.action}
>
{obj.text}
</Button>
)}
))}
</View>
</View>
</View>
);
})}
{flatList.length > 0 && end && (
<View className={styles.endTips}></View>
)}
{flatList.length === 0 && (
<View className={styles.emptyNotice}>
<Image src={emptyContent} />
<Text className={styles.emptyTip}></Text>
</View>
)}
</ScrollView>
<Dialog id="cancelOrder" />
<RefundPopup ref={refundRef} />
</View>
);
};

View File

@@ -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;
}

View File

@@ -112,7 +112,7 @@ const Message = () => {
</View>
{/* 消息列表 */}
<ScrollView scrollY className="message-list">
<ScrollView scrollY className="message-list" scrollWithAnimation enhanced showScrollbar={false} >
{filteredMessages.length > 0 ? (
<View className="message-list-content">
{filteredMessages.map(renderMessageItem)}

View File

@@ -1,5 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "NTRP 评测",
// navigationBarTitleText: "NTRP 评测",
// navigationBarBackgroundColor: '#FAFAFA',
// navigationStyle: 'custom',
navigationStyle: 'custom',
enableShareAppMessage: true,
});

View File

@@ -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;
}
}
}
}
}
}

View File

@@ -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 (
<View className={styles.header}>
<View className={styles.closeIcon} onClick={handleClose}>
<Image className={styles.closeImg} src={CloseIcon} />
</View>
<View className={styles.title}>
<Text>{title}</Text>
</View>
</View>
);
}
function Intro(props) {
const { redirect } = props;
const [ntrpData, setNtrpData] = useState<LastTimeTestResult>();
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 (
<View className={styles.introContainer}>
<CommonGuideBar />
{ntrpData?.has_ntrp_level ? (
<View className={styles.result}>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo.avatar_url}
mode="aspectFit"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image
className={styles.docImage}
src={DocCopy}
mode="aspectFill"
/>
</View>
</View>
{/* tip */}
<View className={styles.tip}>
<Image
className={styles.tipImage}
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
mode="aspectFit"
/>
</View>
<View className={styles.lastResult}>
<View className={styles.tipAndTime}>
<Text></Text>
<Text>{lastTestTime}</Text>
</View>
<View className={styles.levelWrap}>
<Text>NTRP</Text>
<Text className={styles.level}>{ntrp_level}</Text>
</View>
<View className={styles.slogan}>
<Text>线+</Text>
</View>
</View>
<View className={styles.actions}>
<View className={styles.buttonWrap}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
</View>
<View className={styles.buttonWrap}>
<Button
className={styles.button}
onClick={() => handleNext(StageType.RESULT)}
>
<Text>使</Text>
</Button>
</View>
</View>
</View>
) : (
<View className={styles.guide}>
{/* tip */}
<View className={styles.tip}>
<Image
className={styles.tipImage}
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
mode="aspectFit"
/>
</View>
{/* radar */}
<View className={styles.radar}>
<Image
className={styles.radarImage}
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/a2e1b639-82a9-4ab8-b767-8605556eafcb.png"
mode="aspectFit"
/>
</View>
<View className={styles.desc}>
<Text>
NTRPNational Tennis Rating
Program
</Text>
</View>
<View className={styles.actions}>
<View className={styles.buttonWrap}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
</View>
</View>
</View>
)}
</View>
);
}
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<number>(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 (
<View className={styles.testContainer}>
<CommonGuideBar confirm title={`${index + 1} / ${questions.length}`} />
<View className={styles.bar}>
<View
className={styles.progressBar}
style={{ width: `${100 * ((index + 1) / questions.length)}%` }}
/>
</View>
<View className={styles.notice}>
<Text>3</Text>
</View>
<View className={styles.question}>
<View className={styles.content}>{question.question_content}</View>
<View className={styles.options}>
{question.options.map((item, i) => {
const checked = question.choosen === i;
return (
<View
key={i}
className={styles.optionItem}
onClick={() => handleSelect(i)}
>
<View className={styles.optionText}>{item.text}</View>
<View className={styles.optionIcon}>
<Image
className={styles.icon}
src={checked ? CircleChecked : CircleUnChecked}
/>
</View>
</View>
);
})}
</View>
</View>
<View className={styles.actions}>
<View
className={classnames(styles.next, disabled ? styles.disabled : "")}
onClick={() => handIndexChange(1)}
>
<Button className={styles.nextBtn} type="primary">
{index === questions.length - 1 ? "完成测试" : "继续"}
</Button>
</View>
{index !== 0 && (
<View className={styles.prev} onClick={() => handIndexChange(-1)}>
<Image className={styles.backIcon} src={ArrowBack} />
<Text></Text>
</View>
)}
</View>
</View>
);
}
function Result(props) {
const { params } = useRouter();
const { id, type, redirect } = params;
const userInfo = useUserInfo();
const { fetchUserInfo } = useUserActions();
const radarRef = useRef();
const [result, setResult] = useState<TestResultData>();
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 (
<View className={styles.resultContainer}>
<CommonGuideBar />
<View className={styles.card}>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo.avatar_url}
mode="aspectFit"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image
className={styles.docImage}
src={DocCopy}
mode="aspectFill"
/>
</View>
</View>
<View className={styles.desc}>
<View className={styles.tip}>
<Text> NTRP </Text>
</View>
<View className={styles.levelWrap}>
<Text>NTRP</Text>
<Text className={styles.level}>{result?.ntrp_level}</Text>
</View>
<View className={styles.slogan}>
<Text>线+</Text>
</View>
</View>
<View>
<RadarChart ref={radarRef} data={radarData} />
</View>
<View className={styles.retest} onClick={handleReTest}>
<Image className={styles.re_actIcon} src={ReTestIcon} />
<Text></Text>
</View>
</View>
{userInfo.id ? (
<View className={styles.updateTip}>
<Text> NTRP {result?.ntrp_level} </Text>
<Text className={styles.grayTip}>()</Text>
</View>
) : (
<View className={styles.updateTip}>
<Text></Text>
</View>
)}
<View className={styles.actions}>
<View className={styles.viewGame} onClick={handleGoon}>
<Button className={styles.viewGameBtn}>{sourceTypeToTextMap.get(type) || '去看看球局'}</Button>
</View>
<View className={styles.otherActions}>
<View className={styles.share}>
<Button className={styles.shareBtn} openType={userInfo.id ? 'share' : undefined} onClick={handleAuth}>
<Image className={styles.wechatIcon} src={WechatIcon} />
<Text></Text>
</Button>
</View>
<View className={styles.saveImage} onClick={handleSaveImage}>
<Button className={styles.saveImageBtn}>
<Image className={styles.downloadIcon} src={DownloadIcon} />
<Text></Text>
</Button>
</View>
</View>
</View>
<Canvas
type="2d"
id="exportCanvas"
style={{
width: "0px",
height: "0px",
position: "absolute",
left: "-9999px",
}}
/>
</View>
);
}
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 (
<View className={styles.container}>
<View className={styles.title}>NTRP评分</View>
<View className={styles.content}>
<Image
className={styles.image}
src="https://img.yzcdn.cn/vant/cat.jpeg"
/>
<Text className={styles.description}>NTRP评分是 4.0 </Text>
</View>
<Button className={styles.button} onClick={handleUpdateNtrp}>
</Button>
</View>
);
const Component = ComponentsMap[stage];
return <Component redirect={redirect} />;
}
export default withAuth(NtrpEvaluate);

View File

@@ -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<AiImportPopupProps> = ({
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 (
<Popup
visible={visible}
position="bottom"
round={true}
closeable={false}
onClose={onClose}
className={styles.aiImportPopup}
style={{ paddingBottom: isKeyboardVisible ? `${keyboardHeight}px` : undefined }}
>
<View className={styles.popupContent}>
{/* 头部 */}
<View className={styles.header}>
<View className={styles.titleContainer}>
<Image src={images.ICON_IMPORTANT_BLACK} className={styles.lightningIcon} />
<Text className={styles.title}></Text>
</View>
<View className={styles.closeButton} onClick={onClose}>
<Image src={images.ICON_CLOSE} className={styles.lightningIcon} />
</View>
</View>
{/* 文本域 */}
<View className={styles.textAreaContainer}>
<Textarea
className={styles.textArea}
value={text}
onInput={handleTextChange}
onFocus={() => {}}
onBlur={() => {}}
placeholder="在此「粘贴识别」或输入文本,智能拆分球局时间、费用、地点和其他信息,并帮你智能生成球局标题"
maxlength={100}
showConfirmBar={false}
placeholderClass={styles.textArea_placeholder}
autoHeight
// 关闭系统自动上推,改为手动根据键盘高度加内边距
adjustPosition={false}
/>
<View className={styles.charCount}>
<Text className={styles.charCountText}>{text.length}/100</Text>
</View>
</View>
{/* 图片识别按钮 */}
<View className={styles.imageRecognitionContainer}>
<View className={`${styles.imageRecognitionButton} ${uploadLoading ? styles.uploadLoadingContainer : ''}`} onClick={handleImageRecognition}>
{
uploadLoading ? (<Image src={images.ICON_UPLOAD_SUCCESS} className={styles.cameraIcon} />) : (<Image src={images.ICON_UPLOAD_IMG} className={styles.cameraIcon} />)
}
<Text className={styles.imageRecognitionText}></Text>
<Text className={styles.imageRecognitionDesc}>{uploadLoading ? '已上传 1 张图片' : '支持订场截图/小红书笔记截图等图片'}</Text>
</View>
</View>
{/* 底部按钮 */}
<View className={styles.bottomButtons}>
{showManualButton && (
<View className={styles.manualButton} onClick={handleManualPublish}>
<Text className={styles.manualButtonText}></Text>
</View>
)}
<View className={styles.pasteButton} onClick={handlePasteAndRecognize}>
{
loading ? (
<View className={styles.loadingContainer}>
<ConfigProvider theme={{ nutuiLoadingIconColor: '#fff', nutuiLoadingIconSize: '20px' }}>
<Loading type="circular" />
</ConfigProvider>
<Text className={styles.pasteButtonText}></Text>
</View>
) : (
<>
<Image src={images.ICON_COPY} className={styles.clipboardIcon} />
<Text className={styles.pasteButtonText}></Text>
</>
)
}
</View>
</View>
</View>
<Toast id="toast" />
</Popup>
)
}
export default AiImportPopup

View File

@@ -0,0 +1,201 @@
@use '~@/scss/themeColor.scss' as theme;
.aiImportPopup {
.popupContent {
width: 100%;
background: #fff;
border-radius: 16px 16px 0 0;
padding: 0;
box-sizing: border-box;
max-height: 80vh;
overflow-y: auto;
background: rgba(0, 0, 0, 0.06);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
.titleContainer {
display: flex;
align-items: center;
gap: 8px;
.lightningIcon {
width: 24px;
height: 24px;
}
.title {
font-size: 22px;
font-weight: 600;
color: #1f2329;
}
}
.closeButton {
align-items: center;
justify-content: center;
display: flex;
width: 40px;
height: 40px;
box-sizing: border-box;
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);
.lightningIcon{
width: 24px;
height: 24px;
}
}
}
.textAreaContainer {
position: relative;
padding: 0 16px 12px 16px;
.textArea {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #e5e6eb;
border-radius: 8px;
font-size: 14px;
color: #1f2329;
background: #fff;
box-sizing: border-box;
resize: none;
.textArea_placeholder{
color: rgba(60, 60, 67, 0.30);
font-size: 14px;
line-height: 24px;
}
}
.charCount {
position: absolute;
bottom: 20px;
right: 32px;
.charCountText {
font-size: 12px;
color: #8a8a8a;
}
}
}
.imageRecognitionContainer {
padding: 0 20px;
.imageRecognitionButton {
display: flex;
height: 40px;
padding: 2px 16px;
justify-content: center;
align-items: center;
gap: 6px;
align-self: stretch;
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.16);
background: #FFF;
.cameraIcon {
width: 16px;
height: 16px;
}
.imageRecognitionText {
font-size: 14px;
color: #1f2329;
font-weight: 500;
}
.imageRecognitionDesc {
font-size: 12px;
color: rgba(0, 0, 0, 0.35);
}
}
.uploadLoadingContainer{
border-radius: 999px;
border: 0.5px solid rgba(0, 0, 0, 0.16);
background: rgba(52, 199, 89, 0.10);
display: flex;
}
}
.bottomButtons {
padding: 28px 16px 16px 16px;
display: flex;
gap: 12px;
padding-bottom: calc(20px + env(safe-area-inset-bottom));
.manualButton {
flex: 1;
height: 44px;
background: #f5f6f7;
border: 1px solid #e5e6eb;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
&:active {
background: #e9ecef;
}
.manualButtonText {
font-size: 14px;
color: #1f2329;
font-weight: 500;
}
}
.pasteButton {
flex: 1;
height: 44px;
background: #000;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
transition: all 0.2s;
.loadingContainer{
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
&:active {
background: #333;
}
.clipboardIcon {
width: 20px;
height: 20px;
}
.pasteButtonText {
font-size: 16px;
color: #fff;
font-weight: 600;
}
}
}
}
:global {
.nut-toast-inner{
max-width: 95%;
width: auto;
white-space: nowrap;
display: inline-block;
}
}

View File

@@ -0,0 +1,2 @@
export { default } from './AiImportPopup'
export type { AiImportPopupProps } from './AiImportPopup'

View File

@@ -50,9 +50,9 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
// 处理场馆选择
const handleStadiumSelect = (stadium: Stadium | null) => {
console.log(stadium,'stadiumstadium');
const { address, name, latitude, longitude, court_type, court_surface, description, description_tag, venue_image_list} = stadium || {};
const { address, name, venue_id, latitude, longitude, court_type, court_surface, description, description_tag, venue_image_list} = stadium || {};
onChange({...value,
venue_id: stadium?.id,
venue_id,
location_name: name,
location: address,
latitude,
@@ -96,8 +96,8 @@ const FormBasicInfo: React.FC<FormBasicInfoProps> = ({
onChange({...value, [key]: ''});
return;
}
if (numValue < 0) {
onChange({...value, [key]: '0'});
if (numValue <= 0) {
onChange({...value, [key]: '1'});
return;
}
if (numValue > 9999.99) {

View File

@@ -3,7 +3,7 @@ import { View, Text, Input, ScrollView, Image } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Loading } from '@nutui/nutui-react-taro'
import StadiumDetail, { StadiumDetailRef } from './StadiumDetail'
import { CommonPopup } from '@/components'
import { CommonPopup } from '../../../../components'
import { getLocation } from '@/utils/locationUtils'
import PublishService from '@/services/publishService'
import images from '@/config/images'
@@ -135,6 +135,12 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
setSearchValue('')
}
const handleDetailCancel = () => {
setShowDetail(false)
setSelectedStadium(null)
setSearchValue('')
}
const handleItemLocation = (stadium: Stadium) => {
if (stadium.latitude && stadium.longitude) {
Taro.openLocation({
@@ -169,7 +175,7 @@ const SelectStadium: React.FC<SelectStadiumProps> = ({
cancelText="返回"
confirmText="确认"
className="select-stadium-popup"
onCancel={handleCancel}
onCancel={handleDetailCancel}
onConfirm={handleConfirm}
position="bottom"
round

View File

@@ -10,6 +10,7 @@ import './StadiumDetail.scss'
export interface Stadium {
id?: string
venue_id?: string
name: string
address?: string
longitude?: number
@@ -108,7 +109,8 @@ const StadiumDetail = forwardRef<StadiumDetailRef, StadiumDetailProps>(({
description:{
description: '',
description_tag: []
}
},
venue_id: stadium.id
})
// 暴露方法给父组件

View File

@@ -1,50 +1,74 @@
import React, { useCallback, useState } from 'react'
import React, { useCallback, useState, useEffect } from 'react'
import { View, Text, Input } from '@tarojs/components'
import { Checkbox } from '@nutui/nutui-react-taro'
import styles from './index.module.scss'
interface FormSwitchProps {
value: boolean
onChange: (checked: boolean) => void
subTitle: string
wechatId?: string
type WechatContactValue = {
is_wechat_contact: boolean
wechat_contact: string
default_wechat_contact: string
}
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wechatId }) => {
interface FormSwitchProps {
value: WechatContactValue
wechatId: string
onChange: (val: WechatContactValue) => void
subTitle: string
}
const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle }) => {
const [editWechat, setEditWechat] = useState(false)
const [wechatIdValue, setWechatIdValue] = useState('')
const [wechat, setWechat] = useState(wechatId)
const [defaultWechat, setDefaultWechat] = useState('')
const [isWechatContact, setIsWechatContact] = useState(false)
const editWechatId = () => {
setEditWechat(true)
}
const setWechatId = useCallback((e: any) => {
const value = e.target.value
onChange && onChange(value)
setWechatIdValue(value)
}, [onChange])
const valueStr = e.target.value
onChange && onChange({ is_wechat_contact: isWechatContact, wechat_contact: valueStr, default_wechat_contact: defaultWechat })
setWechatIdValue(valueStr)
}, [onChange, isWechatContact])
const fillWithPhone = () => {
if (wechat) {
setWechatIdValue(wechat)
if (defaultWechat) {
setWechatIdValue(defaultWechat)
}
}
const handleChange = (checked: boolean) => {
setIsWechatContact(checked)
onChange({ is_wechat_contact: checked, wechat_contact: wechatIdValue, default_wechat_contact: defaultWechat })
}
useEffect(() => {
const { is_wechat_contact, default_wechat_contact } = value || {} as any
if (is_wechat_contact) {
setIsWechatContact(is_wechat_contact)
}
if (default_wechat_contact) {
setDefaultWechat(default_wechat_contact)
}
}, [value])
return (
<>
<View className={styles['wechat-contact-section']}>
<View className={styles['wechat-contact-item']}>
<Checkbox
className={styles['wechat-contact-checkbox']}
checked={value}
onChange={onChange}
checked={isWechatContact}
onChange={(checked) => handleChange(checked)}
/>
<View className={styles['wechat-contact-content']}>
<Text className={styles['wechat-contact-text']}>{subTitle}</Text>
</View>
</View>
{
!editWechat && wechatId && (
!editWechat && (
<View className={styles['wechat-contact-id']}>
<Text className={styles['wechat-contact-text']}>: {wechatId.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}</Text>
<Text className={styles['wechat-contact-text']}>: {defaultWechat.replace(/(\d{3})(\d{4})(\d{4})/, '$1 $2 $3')}</Text>
<View className={styles['wechat-contact-edit']} onClick={editWechatId}></View>
</View>
)
@@ -55,7 +79,9 @@ const FormSwitch: React.FC<FormSwitchProps> = ({ value, onChange, subTitle, wech
<View className={styles['wechat-contact-edit-input']}>
<Input value={wechatIdValue} onInput={setWechatId} placeholder='请输入正确微信号' />
</View>
<View className={styles['wechat-contact-edit']} onClick={fillWithPhone}>{wechat}</View>
{defaultWechat && (
<View className={styles['wechat-contact-edit']} onClick={fillWithPhone}>{defaultWechat}</View>
)}
</View>
)
}

View File

@@ -0,0 +1,8 @@
export default definePageConfig({
navigationBarTitleText: '约球规则',
navigationBarBackgroundColor: '#ffffff',
navigationBarTextStyle: 'black',
backgroundColor: '#f5f5f5',
enablePullDownRefresh: false,
disableScroll: false
})

View File

@@ -0,0 +1,245 @@
// 条款页面样式
.terms_page {
width: 100%;
height: 100vh;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
background: #FAFAFA;
box-sizing: border-box;
}
// 状态栏样式
.status_bar {
position: absolute;
top: 21px;
left: 0;
right: 0;
height: 33px;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 16px;
z-index: 10;
.time {
color: #000000;
font-family: 'SF Pro';
font-weight: 590;
font-size: 17px;
line-height: 22px;
}
.status_icons {
display: flex;
align-items: center;
gap: 7px;
.signal_icon,
.wifi_icon,
.battery_icon {
width: 20px;
height: 12px;
background: #000000;
border-radius: 2px;
opacity: 0.8;
}
.signal_icon {
width: 19px;
height: 12px;
}
.wifi_icon {
width: 17px;
height: 12px;
}
.battery_icon {
width: 27px;
height: 13px;
border: 1px solid rgba(0, 0, 0, 0.35);
background: #000000;
border-radius: 4px;
position: relative;
&::after {
content: '';
position: absolute;
right: -3px;
top: 4px;
width: 1px;
height: 4px;
background: rgba(0, 0, 0, 0.4);
border-radius: 0 1px 1px 0;
}
}
}
}
// 导航栏样式
.navigation_bar {
position: absolute;
top: 54px;
left: 0;
right: 0;
height: 44px;
background: #FFFFFF;
border-radius: 44px 44px 0 0;
z-index: 10;
display: flex;
align-items: center;
padding: 0 10px;
.nav_content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
.back_button {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
.back_icon {
width: 8px;
height: 16px;
background: #000000;
position: relative;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #000000;
transform: translateY(-50%) rotate(45deg);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #000000;
transform: translateY(-50%) rotate(-45deg);
}
}
}
.page_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 18px;
color: #000000;
text-align: center;
}
.nav_placeholder {
width: 32px;
}
}
}
// 主要内容区域
.main_content {
position: relative;
z-index: 5;
flex: 1;
box-sizing: border-box;
overflow-y: auto;
// 条款标题
.terms_title {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 20px;
line-height: 1.6em;
text-align: center;
color: #000000;
margin-bottom: 24px;
}
// 条款简介
.terms_intro {
font-family: 'PingFang SC';
font-weight: 600;
font-size: 14px;
line-height: 1.43em;
color: #000000;
margin-bottom: 24px;
}
// 条款详细内容
.terms_content {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.43em;
color: #000000;
margin-bottom: 40px;
white-space: pre-line;
padding: 0px 24px;
.terms_first_line,
span.terms_first_line {
font-weight: 500;
display: block;
margin-bottom: 16px;
}
}
// 底部按钮
.bottom_actions {
margin-bottom: 40px;
.agree_button {
width: 100%;
height: 52px;
background: #07C160;
border: none;
border-radius: 16px;
color: #FFFFFF;
font-size: 16px;
font-weight: 600;
font-family: 'PingFang SC';
cursor: pointer;
transition: all 0.3s ease;
&::after {
border: none;
}
&:active {
opacity: 0.8;
}
}
}
}
// 底部指示器
.home_indicator {
position: absolute;
bottom: 21px;
left: 50%;
transform: translateX(-50%);
width: 140px;
height: 5px;
background: #000000;
border-radius: 2.5px;
z-index: 10;
}

View File

@@ -0,0 +1,40 @@
import React, { useEffect } from 'react';
import { View, ScrollView } from '@tarojs/components';
import './index.scss';
const footballRules: React.FC = () => {
// 获取页面参数
const [termsContent, setTermsContent] = React.useState('');
useEffect(() => {
setTermsContent(`<span class="terms_first_line">欢迎使用本平台(以下简称"本平台")的微信绑定服务。为保障您的权益,请您务必仔细阅读并理解以下协议内容。</span>
一、绑定服务说明
1. 本平台提供微信账号绑定服务,用户可通过微信快捷登录方式使用平台功能。
2. 绑定微信账号后,用户可使用微信登录、微信支付、微信分享等功能。
3. 本平台承诺保护用户微信账号信息安全,不会泄露给第三方。`)
}, [])
return (
<View className="terms_page">
{/* 主要内容 */}
<ScrollView className="main_content" scrollY>
{/* 条款标题 */}
<View className="terms_title">
</View>
{/* 条款详细内容 */}
<View
className="terms_content"
dangerouslySetInnerHTML={{ __html: termsContent }}
/>
</ScrollView>
</View>
);
};
export default footballRules;

View File

@@ -1,4 +1,3 @@
export default definePageConfig({
navigationBarTitleText: '发布',
navigationBarBackgroundColor: '#FAFAFA'
navigationStyle: 'custom'
})

View File

@@ -1,14 +1,25 @@
@use '~@/scss/themeColor.scss' as theme;
.publish-ball-container{
position: relative;
&.publish-ball-container-keyboard{
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 9999;
}
}
.publish-ball {
padding-top: 0;
min-height: 100vh;
background: theme.$page-background-color;
box-sizing: border-box;
position: relative;
&__scroll {
height: calc(100vh - 120px);
overflow: auto;
padding: 4px 16px 72px 16px;
padding: 4px 16px 20px 16px;
box-sizing: border-box;
}
@@ -166,12 +177,8 @@
// 提交区域
.submit-section {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px;
padding: 16px;
.submit-btn {
width: 100%;
color: white;
@@ -207,6 +214,8 @@
width: 11px;
height: 11px;
:global(.nut-icon-Checked){
width: 11px;
height: 11px;
background: rgba(22, 24, 35, 0.75)!important;
}
}
@@ -255,3 +264,12 @@
transform: rotate(360deg);
}
}
.publish-ball-navbar{
position: fixed !important;
top: 0 !important;
left: 0 !important;
z-index: 9999 !important;
width: 100% !important;
box-shadow: none!important;
}

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'
import React, { useState, useEffect, useRef } from 'react'
import { View, Text, Button, Image } from '@tarojs/components'
import { Checkbox } from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import Taro from '@tarojs/taro'
import { type ActivityType } from '../../components/ActivityTypeSwitch'
import CommonDialog from '../../components/CommonDialog'
@@ -10,9 +11,14 @@ import { FormFieldConfig, publishBallFormSchema } from '../../config/formSchema/
import { PublishBallFormData } from '../../../types/publishBall';
import PublishService from '@/services/publishService';
import { getNextHourTime, getEndTime, delay } from '@/utils';
import { useGlobalState } from "@/store/global"
import GeneralNavbar from "@/components/GeneralNavbar"
import images from '@/config/images'
import { useUserInfo } from '@/store/userStore'
import styles from './index.module.scss'
import dayjs from 'dayjs'
import { usePublishBallData } from '@/store/publishBallStore'
import { useKeyboardHeight } from '@/store/keyboardStore'
import DetailService from "@/services/detailService";
const defaultFormData: PublishBallFormData = {
title: '',
@@ -35,26 +41,41 @@ const defaultFormData: PublishBallFormData = {
venue_description: '',
venue_image_list: [],
},
players: [1, 1],
players: {
min: 1,
max: 1,
organizer_joined: true
},
skill_level: [1.0, 5.0],
descriptionInfo: {
description: '',
description_tag: [],
},
is_substitute_supported: true,
wechat: {
is_wechat_contact: true,
wechat_contact: '14223332214'
wechat_contact: '',
default_wechat_contact: ''
}
}
const PublishBall: React.FC = () => {
const [activityType, setActivityType] = useState<ActivityType>('individual')
const [isSubmitDisabled, setIsSubmitDisabled] = useState(false)
const userInfo = useUserInfo();
const publishAiData = usePublishBallData()
const { statusNavbarHeightInfo } = useGlobalState();
// 使用全局键盘状态
const { keyboardHeight, isKeyboardVisible, addListener, initializeKeyboardListener } = useKeyboardHeight()
// 获取页面参数并设置导航标题
const [optionsConfig, setOptionsConfig] = useState<FormFieldConfig[]>(publishBallFormSchema)
const [formData, setFormData] = useState<PublishBallFormData[]>([
defaultFormData
])
console.log(userInfo, 'userInfo');
const [formData, setFormData] = useState<PublishBallFormData[]>([defaultFormData])
const [checked, setChecked] = useState(true)
const [titleBar, setTitleBar] = useState('发布')
const scrollViewRef = useRef<any>(null)
// 删除确认弹窗状态
const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -77,14 +98,6 @@ const PublishBall: React.FC = () => {
}
// 处理活动类型变化
const handleActivityTypeChange = (type: ActivityType) => {
if (type === 'group') {
setFormData([defaultFormData])
} else {
setFormData([defaultFormData])
}
}
// 检查相邻两组数据是否相同
const checkAdjacentDataSame = (formDataArray: PublishBallFormData[]) => {
@@ -165,9 +178,11 @@ const PublishBall: React.FC = () => {
}
const validateFormData = (formData: PublishBallFormData, isOnSubmit: boolean = false) => {
const { activityInfo, image_list, title, timeRange } = formData;
const { activityInfo, title, timeRange, image_list, players, current_players } = formData;
const { play_type, price, location_name } = activityInfo;
if (!image_list?.length) {
const { max } = players;
if (!image_list?.length && activityType === 'group') {
if (!isOnSubmit) {
Taro.showToast({
title: `请上传活动封面`,
@@ -216,6 +231,7 @@ const PublishBall: React.FC = () => {
if (timeRange?.start_time && timeRange?.end_time) {
const start = dayjs(timeRange.start_time)
const end = dayjs(timeRange.end_time)
const currentTime = dayjs()
if (!end.isAfter(start)) {
if (!isOnSubmit) {
Taro.showToast({
@@ -234,6 +250,33 @@ const PublishBall: React.FC = () => {
}
return false
}
if (start.isBefore(currentTime)) {
if (!isOnSubmit) {
Taro.showToast({
title: `开始时间需晚于当前时间`,
icon: 'none'
})
}
return false
}
if (end.isBefore(currentTime)) {
if (!isOnSubmit) {
Taro.showToast({
title: `结束时间需晚于当前时间`,
icon: 'none'
})
}
return false
}
}
if (current_players && (current_players > max)) {
if (!isOnSubmit) {
Taro.showToast({
title: `最大人数不能小于当前参与人数${current_players}`,
icon: 'none'
})
}
return false
}
return true
@@ -246,39 +289,54 @@ const PublishBall: React.FC = () => {
return true
}
const getParams = () => {
const currentInstance = Taro.getCurrentInstance()
const params = currentInstance.router?.params
return params
}
// 提交表单
const handleSubmit = async () => {
// 基础验证
console.log(formData, 'formData');
const params = getParams()
const { republish } = params || {};
if (activityType === 'individual') {
const isValid = validateFormData(formData[0])
if (!isValid) {
return
}
const { activityInfo, descriptionInfo, timeRange, players, skill_level,image_list, ...rest } = formData[0];
const { activityInfo, descriptionInfo,is_substitute_supported, timeRange, players, skill_level, image_list, wechat, id, ...rest } = formData[0];
const { min, max, organizer_joined } = players;
const options = {
...rest,
...activityInfo,
...descriptionInfo,
...timeRange,
max_players: players[1],
current_players: players[0],
max_players: max,
min_players: min,
organizer_joined: organizer_joined === true ? 1 : 0,
skill_level_min: skill_level[0],
skill_level_max: skill_level[1],
image_list: image_list.map(item => item.url)
image_list: image_list.map(item => item.url),
is_wechat_contact: wechat.is_wechat_contact ? 1 : 0,
wechat_contact: wechat.wechat_contact || wechat.default_wechat_contact,
is_substitute_supported: is_substitute_supported ? '1' : '0',
...(republish === '0' ? { id } : {}),
}
const res = await PublishService.createPersonal(options);
const res = republish === '0' ? await PublishService.gamesUpdate(options) : await PublishService.createPersonal(options);
const successText = republish === '0' ? '更新成功' : '发布成功';
if (res.code === 0 && res.data) {
Taro.showToast({
title: '发布成功',
title: successText,
icon: 'success'
})
delay(1000)
// 如果是个人球局,则跳转到详情页,并自动分享
// 如果是畅打,则跳转第一个球局详情页,并自动分享 @刘杰
const id = (res as any).data?.id;
Taro.navigateTo({
// @ts-expect-error: id
url: `/pages/detail/index?id=${res.data.id || 1}&from=publish&autoShare=1`
url: `/game_pages/detail/index?id=${id || 1}&from=publish&autoShare=1`
})
} else {
Taro.showToast({
@@ -300,31 +358,37 @@ const PublishBall: React.FC = () => {
return
}
const options = formData.map((item) => {
const { activityInfo, descriptionInfo, timeRange, players, skill_level, ...rest } = item;
const { activityInfo, descriptionInfo, timeRange, players, skill_level, is_substitute_supported, id, ...rest } = item;
const { min, max, organizer_joined } = players;
return {
...rest,
...activityInfo,
...descriptionInfo,
...timeRange,
max_players: players[1],
current_players: players[0],
max_players: max,
min_players: min,
organizer_joined: organizer_joined === true ? 1 : 0,
skill_level_min: skill_level[0],
skill_level_max: skill_level[1],
image_list: item.image_list.map(img => img.url)
is_substitute_supported: is_substitute_supported ? '1' : '0',
image_list: item.image_list.map(img => img.url),
...(republish === '0' ? { id } : {}),
}
})
const res = await PublishService.create_play_pmoothlys({rows: options});
const successText = republish === '0' ? '更新成功' : '发布成功';
const res = republish === '0' ? await PublishService.gamesUpdate(options[0]) : await PublishService.create_play_pmoothlys({rows: options});
if (res.code === 0 && res.data) {
Taro.showToast({
title: '发布成功',
title: successText,
icon: 'success'
})
delay(1000)
// 如果是个人球局,则跳转到详情页,并自动分享
// 如果是畅打,则跳转第一个球局详情页,并自动分享 @刘杰
const id = republish === '0' ? (res as any).data?.id : (res as any).data?.[0]?.id;
Taro.navigateTo({
// @ts-expect-error: id
url: `/pages/detail/index?id=${res.data?.[0].id || 1}&from=publish&autoShare=1`
url: `/game_pages/detail/index?id=${id || 1}&from=publish&autoShare=1`
})
} else {
Taro.showToast({
@@ -335,16 +399,77 @@ const PublishBall: React.FC = () => {
}
}
const initFormData = () => {
const currentInstance = Taro.getCurrentInstance()
const params = currentInstance.router?.params
if (params?.type) {
const type = params.type as ActivityType
if (type === 'individual' || type === 'group') {
setActivityType(type)
if (type === 'group') {
const mergeWithDefault = (data: any, isDetail: boolean = false): PublishBallFormData => {
// ai导入与详情数据处理
const { start_time, end_time, play_type, price, venue_id, location_name, location, latitude,
longitude, court_type, court_surface, venue_description_tag, venue_description, venue_image_list,
description, description_tag, max_players, min_players, skill_level_max, skill_level_min,
venueDtl, wechat_contact, image_list, id: publish_id, is_wechat_contact,
is_substitute_supported, title, current_players, organizer_joined
} = data;
const level_max = skill_level_max ? Number(skill_level_max) : 5.0;
const level_min = skill_level_min ? Number(skill_level_min) : 1.0;
const userPhone = wechat_contact || (userInfo as any)?.phone || ''
let activityInfo = {};
if (venueDtl) {
const { latitude, longitude,venue_type, surface_type, facilities, name, id } = venueDtl;
activityInfo = {
latitude,
longitude,
court_type: venue_type,
court_surface: surface_type,
venue_description: facilities,
location_name: name,
venue_id: id
}
}
if (isDetail) {
activityInfo = {
venue_id,
location_name,
location,
latitude,
longitude,
court_type,
court_surface,
venue_description_tag,
venue_description,
venue_image_list
}
}
return {
...defaultFormData,
title,
...(is_substitute_supported === '0' ? { is_substitute_supported: false } : {}),
...(publish_id ? { id: publish_id } : {}),
timeRange: {
...defaultFormData.timeRange,
start_time,
end_time,
},
activityInfo: {
...defaultFormData.activityInfo,
...(play_type ? { play_type } : {}),
...((price) ? { price } : {}),
...activityInfo
},
descriptionInfo: {
...defaultFormData.descriptionInfo,
...(description ? { description } : {}),
...(description_tag ? { description_tag } : {}),
},
...(level_max && level_min ? { skill_level: [level_min, level_max] } : {}),
...(max_players && min_players ? { players: { min: min_players, max: max_players, organizer_joined: organizer_joined === 0 ? false : true } } : {}),
wechat: { ...defaultFormData.wechat, default_wechat_contact: userPhone, is_wechat_contact: is_wechat_contact === 0 ? false : true},
image_list: image_list?.map(item => ({ url: item, id: item })) || [],
...(current_players ? { current_players } : {}),
}
}
const formatConfig = () => {
const newFormSchema = publishBallFormSchema.reduce((acc, item) => {
if (item.prop === 'is_wechat_contact') {
if (item.prop === 'wechat') {
return acc
}
if (item.prop === 'image_list') {
@@ -361,22 +486,75 @@ const PublishBall: React.FC = () => {
return acc
}, [] as FormFieldConfig[])
setOptionsConfig(newFormSchema)
setFormData([defaultFormData])
}
// 根据type设置导航标题
const initFormData = () => {
const params = getParams()
const userPhone = (userInfo as any)?.phone || ''
if (params?.type) {
const type = params.type as ActivityType
if (type === 'individual' || type === 'group') {
setActivityType(type)
if (type === 'group') {
Taro.setNavigationBarTitle({
title: '发布畅打活动'
})
formatConfig()
setFormData([defaultFormData])
setTitleBar('发布畅打活动')
} else {
Taro.setNavigationBarTitle({
title: '发布'
setTitleBar('发布')
setFormData([{...defaultFormData, wechat: { ...defaultFormData.wechat, default_wechat_contact: userPhone } }])
}
} else if (type === 'ai') {
// 从 Store 注入 AI 生成的表单 JSON
if (publishAiData && Array.isArray(publishAiData) && publishAiData.length > 0) {
Taro.showToast({
title: '智能识别成功,请完善剩余信息',
icon: 'none'
})
const merged = publishAiData.map(item => mergeWithDefault(item))
setFormData(merged.length ? merged : [defaultFormData])
if (merged.length === 1) {
setTitleBar('发布')
setActivityType('individual')
} else {
formatConfig()
setTitleBar('发布畅打活动')
setActivityType('group')
}
} else {
setFormData([defaultFormData])
setTitleBar('发布')
setActivityType('individual')
}
}
}
if (params?.gameId) {
getGameDetail(params.gameId)
}
}
const getGameDetail = async (gameId) => {
if (!gameId) return;
try {
const res = await DetailService.getDetail(Number(gameId));
if (res.code === 0) {
const merged = mergeWithDefault(res.data, true)
setFormData([merged])
if (res.data.game_type === '个人球局') {
setTitleBar('发布')
setActivityType('individual')
} else {
setTitleBar('发布畅打活动')
setActivityType('group')
}
}
} catch (e) {
Taro.showToast({
title: e.message,
icon: 'none'
})
}
}
handleActivityTypeChange(type)
}
}
};
const onCheckedChange = (checked: boolean) => {
setChecked(checked)
}
@@ -394,8 +572,28 @@ const PublishBall: React.FC = () => {
initFormData()
}, [])
// 使用全局键盘状态监听
useEffect(() => {
// 初始化全局键盘监听器
initializeKeyboardListener()
// 添加本地监听器
const removeListener = addListener((height, visible) => {
console.log('PublishBall 收到键盘变化:', height, visible)
})
return () => {
removeListener()
}
}, [initializeKeyboardListener, addListener])
console.log(isKeyboardVisible, 'isKeyboardVisible');
console.log(keyboardHeight, 'keyboardHeight');
return (
<View className={styles['publish-ball']}>
<View className={`${styles['publish-ball-container']} ${isKeyboardVisible ? styles['publish-ball-container-keyboard'] : ''}`} style={{ bottom: isKeyboardVisible ? `${keyboardHeight - 124}px` : 0 }}>
<GeneralNavbar title={titleBar} backgroundColor="#FAFAFA" className={styles['publish-ball-navbar']} />
<View className={styles['publish-ball']} style={{ paddingTop: `${statusNavbarHeightInfo.totalHeight}px` }}>
{/* 活动类型切换 */}
<View className={styles['activity-type-switch']}>
{/* <ActivityTypeSwitch
@@ -404,7 +602,7 @@ const PublishBall: React.FC = () => {
/> */}
</View>
<View className={styles['publish-ball__scroll']}>
<View className={styles['publish-ball__scroll']} style={{ height: `calc(100vh - ${statusNavbarHeightInfo.totalHeight+120}px)`, overflow: 'auto' }}>
{
formData.map((item, index) => (
<View key={index}>
@@ -471,7 +669,7 @@ const PublishBall: React.FC = () => {
activityType === 'individual' && (
<Text className={styles['submit-tip']}>
<Text className={styles['link']}></Text>
<Text className={styles['link']} onClick={() => Taro.navigateTo({url: '/publish_pages/publishBall/footballRules/index'})}></Text>
</Text>
)
}
@@ -489,6 +687,7 @@ const PublishBall: React.FC = () => {
}
</View>
</View>
</View>
)
}

View File

@@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react'
import { View, Text } from '@tarojs/components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch, UploadCover } from '@/components'
import { ImageUpload, Range, TimeSelector, TextareaTag, NumberInterval, TitleTextarea, FormSwitch, UploadCover } from '../../components'
import FormBasicInfo from './components/FormBasicInfo'
import { type CoverImage } from '../../components/index.types'
import { FormFieldConfig, FieldType } from '../../config/formSchema/publishBallFormSchema'
import { PublishBallFormData } from '../../../types/publishBall';
import WechatSwitch from './components/WechatSwitch/WechatSwitch'
import styles from './index.module.scss'
import { useDictionaryActions } from '@/store/dictionaryStore'
import { useDictionaryActions } from '../../store/dictionaryStore'
// 组件映射器
const componentMap = {
@@ -128,14 +128,14 @@ const PublishForm: React.FC<{
return '';
}
const getPlayersText = (players: [number, number] | any) => {
const getPlayersText = (players: { min: number, max: number, organizer_joined: boolean } | any) => {
// 检查 players 是否为数组
if (!Array.isArray(players) || players.length !== 2) {
if (!players.min || !players.max) {
console.warn('getPlayersText: players 不是有效的数组格式:', players);
return '未设置';
}
const [min, max] = players;
const { min, max } = players;
// 检查 min 和 max 是否为有效数字
if (typeof min !== 'number' || typeof max !== 'number') {
@@ -174,7 +174,7 @@ const PublishForm: React.FC<{
...item.props,
...(item.type === FieldType.TEXTAREATAG ? { options: item.options } : {}),
...(item.props?.className ? { className: styles[item.props.className] } : {}),
...(item.type === FieldType.WECHATCONTACT ? { wechatId: formData.wechat_contact } : {})
// ...(item.type === FieldType.WECHATCONTACT ? { wechatId: formData.wechat.wechat_contact } : {})
}
if (item.type === FieldType.UPLOADIMAGE) {
/* 活动封面 */
@@ -188,8 +188,7 @@ const PublishForm: React.FC<{
return <>
<View className={styles['activity-description']}>
<Text className={styles['description-text']}>
2
2
</Text>
</View>

View File

@@ -0,0 +1,85 @@
import httpService from "./httpService";
import type { ApiResponse } from "./httpService";
export interface CommentResponse {
rows: Comment[]
count: number
}
export interface UserInfo {
id: number
nickname: string
avatar_url: string
}
export type BaseComment<T = {}> = {
create_time: string
last_modify_time: string
id: number
game_id: number
user_id: number
parent_id: number | null
reply_to_user_id: number | null
content: string
like_count: number
reply_count: number
user: UserInfo
is_liked?: boolean
} & T
export type ReplyComment = BaseComment<{
parent_id: number
reply_to_user_id: number
reply_to_user: UserInfo
}>
export type Comment = BaseComment<{
replies: ReplyComment[]
}>
// 接口响应
export interface ReplyCommentResponse {
count: number
rows: ReplyComment[]
}
export interface ToggleLikeType {
is_liked: boolean,
like_count: number,
message: string
}
// 评论管理类
class CommentService {
// 查询评论列表
async getComments(req: { game_id: number, page: number, pageSize: number }): Promise<ApiResponse<CommentResponse>> {
return httpService.post("/comments/list", req, { showLoading: true });
}
// 发表评论
async createComment(req: { game_id: number, content: string }): Promise<ApiResponse<BaseComment>> {
return httpService.post("/comments/create", req, { showLoading: true });
}
// 回复评论
async replyComment(req: { parent_id: number, reply_to_user_id: number, content: string }): Promise<ApiResponse<ReplyComment>> {
return httpService.post("/comments/reply", req, { showLoading: true });
}
// 点赞取消点赞评论
async toggleLike(req: { comment_id: number }): Promise<ApiResponse<ToggleLikeType>> {
return httpService.post("/comments/like", req, { showLoading: true });
}
// 删除评论
async deleteComment(req: { comment_id: number }): Promise<ApiResponse<any>> {
return httpService.post("/comments/delete", req, { showLoading: true });
}
// 获取评论的所有回复
async getReplies(req: { comment_id: number, page: number, pageSize: number }): Promise<ApiResponse<ReplyCommentResponse>> {
return httpService.post("/comments/replies", req, { showLoading: true });
}
}
export default new CommentService();

View File

@@ -70,7 +70,7 @@ class CommonApiService {
// 获取字典数据
async getDictionaryManyKey(keys: string): Promise<ApiResponse<any>> {
return httpService.get('/parameter/many_key', { keys }, {
return httpService.post('/parameter/many_key', { keys }, {
showLoading: false,
})
}

View File

@@ -1,134 +0,0 @@
import httpService from './httpService'
import type { ApiResponse } from './httpService'
// 用户信息接口
export interface UserProfile {
id: string
nickname: string
avatar?: string
age?: number
gender: 'male' | 'female'
interests: string[]
acceptNotification: boolean
}
// 反馈评价接口
export interface Feedback {
id: string
matchId: string
userId: string
photos: string[]
rating: number
recommend: 'yes' | 'no' | 'neutral'
aspects: string[]
comments: string
createdAt: string
}
// DynamicFormDemo页面API服务类
class DemoApiService {
// ==================== 用户信息相关接口 ====================
// 获取用户信息
async getUserProfile(): Promise<ApiResponse<UserProfile>> {
return httpService.get('/user/profile')
}
// 更新用户信息
async updateUserProfile(data: Partial<UserProfile>): Promise<ApiResponse<UserProfile>> {
return httpService.put('/user/profile', data, {
showLoading: true,
loadingText: '保存中...'
})
}
// 上传头像
async uploadAvatar(filePath: string): Promise<ApiResponse<{ url: string }>> {
return httpService.post('/user/avatar', { filePath }, {
showLoading: true,
loadingText: '上传头像中...'
})
}
// ==================== 反馈评价相关接口 ====================
// 提交活动评价
async submitFeedback(data: {
matchId: string
photos?: string[]
rating: number
recommend: 'yes' | 'no' | 'neutral'
aspects: string[]
comments: string
}): Promise<ApiResponse<Feedback>> {
return httpService.post('/feedback', data, {
showLoading: true,
loadingText: '提交评价中...'
})
}
// 获取我的评价列表
async getMyFeedbacks(params?: {
page?: number
limit?: number
}): Promise<ApiResponse<{ list: Feedback[]; total: number }>> {
return httpService.get('/feedback/my', params)
}
// 获取活动的所有评价
async getMatchFeedbacks(matchId: string, params?: {
page?: number
limit?: number
}): Promise<ApiResponse<{ list: Feedback[]; total: number }>> {
return httpService.get(`/feedback/match/${matchId}`, params)
}
// ==================== 通用表单提交接口 ====================
// 提交表单数据(通用接口)
async submitForm(formType: string, formData: any[]): Promise<ApiResponse<any>> {
return httpService.post('/forms/submit', {
type: formType,
data: formData
}, {
showLoading: true,
loadingText: '提交中...'
})
}
// 保存表单草稿
async saveFormDraft(formType: string, formData: any[]): Promise<ApiResponse<{ id: string }>> {
return httpService.post('/forms/draft', {
type: formType,
data: formData
}, {
showLoading: true,
loadingText: '保存中...'
})
}
// 获取表单草稿
async getFormDrafts(formType: string): Promise<ApiResponse<{ id: string; data: any[]; createdAt: string }[]>> {
return httpService.get('/forms/drafts', { type: formType })
}
// 删除表单草稿
async deleteFormDraft(id: string): Promise<ApiResponse<{ success: boolean }>> {
return httpService.delete(`/forms/draft/${id}`)
}
// ==================== 兴趣爱好相关接口 ====================
// 获取兴趣爱好选项
async getInterestOptions(): Promise<ApiResponse<{ label: string; value: string }[]>> {
return httpService.get('/interests')
}
// 获取推荐的兴趣爱好
async getRecommendedInterests(): Promise<ApiResponse<string[]>> {
return httpService.get('/interests/recommended')
}
}
// 导出API服务实例
export default new DemoApiService()

View File

@@ -82,6 +82,13 @@ export enum MATCH_STATUS {
NOT_STARTED = 0, // 未开始
IN_PROGRESS = 1, //进行中
FINISHED = 2, //已结束
CANCELED = 3, // 已取消
}
// 是否支持候补
export enum IsSubstituteSupported {
SUPPORT = '0', // 支持
NOTSUPPORT = '1', // 不支持
}
export interface UpdateLocationRes {
@@ -92,10 +99,8 @@ export interface UpdateLocationRes {
city: string;
district: string;
}
// 发布球局类
class GameDetailService {
// 用户登录
// 查询球局详情
async getDetail(id: number): Promise<ApiResponse<GameData>> {
return httpService.post(
"/games/detail",
@@ -114,6 +119,27 @@ class GameDetailService {
showLoading: true,
});
}
// 组织者加入球局
async organizerJoin(game_id: number): Promise<ApiResponse<any>> {
return httpService.post("/games/organizer_join", { game_id }, {
showLoading: true,
});
}
// 组织者退出球局
async organizerQuit(req: { game_id: number, quit_reason: string }): Promise<ApiResponse<any>> {
return httpService.post("/games/organizer_quit", req, {
showLoading: true,
});
}
// 组织者解散球局
async disbandGame(req: { game_id: number, settle_reason: string }): Promise<ApiResponse<any>> {
return httpService.post("/games/settle_game", req, {
showLoading: true,
});
}
}
// 导出认证服务实例

View File

@@ -1,71 +1,123 @@
import httpService from "./httpService";
import type { ApiResponse } from "./httpService";
export interface AnswerResItem {
question_id: number;
answer_index: number;
// 单个选项类型
interface Option {
text: string;
score: number;
}
// 单个问题类型
export interface Question {
id: number;
question_title: string;
question_content: string;
options: Option[];
radar_mapping: string[];
}
// 单项能力分数
interface AbilityScore {
current_score: number;
max_score: number;
percentage: number;
}
// 雷达图数据
interface RadarData {
abilities: Record<string, AbilityScore>; // key 是能力名称,如 "正手球质"
summary: {
total_questions: number;
calculation_time: string; // ISO 字符串
};
}
// 单题答案
interface Answer {
question_id: number;
question_title: string;
answer_index: number;
selected_option: string;
score: number;
}
export type AnswerItem = Pick<AnswerResItem, "question_id" | "answer_index">;
export interface Answers {
answers: AnswerItem[];
test_duration: number;
}
export interface QuestionItem {
id: number;
question_title: string;
question_content: string;
options: string[];
scores: number[];
}
export interface SubmitAnswerRes {
// 提交测试结果 对象类型
export interface TestResultData {
record_id: number;
total_score: number;
ntrp_level: string;
is_coverage: boolean;
old_ntrp_level: string;
level_description: string;
answers: AnswerResItem[];
radar_data: RadarData;
answers: Answer[];
}
// 单条测试记录
interface TestRecord {
id: number;
total_score: number;
ntrp_level: string;
level_description: string;
test_duration: number; // 单位:秒
create_time: string; // 时间字符串
}
// 测试历史对象类型
export interface TestResultList {
count: number;
rows: TestRecord[];
}
// 单次测试结果
interface TestResult {
id: number;
total_score: number;
ntrp_level: string;
level_description: string;
radar_data: RadarData;
test_duration: number; // 单位秒
create_time: string; // 时间字符串
}
// data 对象
// 上一次测试结果
export interface LastTimeTestResult {
has_test_record: boolean;
has_ntrp_level: boolean;
user_ntrp_level: string;
last_test_result: TestResult;
}
// 发布球局类
class EvaluateService {
async getEvaluateQuestions(): Promise<ApiResponse<QuestionItem[]>> {
return httpService.post("/ntrp/questions", {
showLoading: true,
});
// 获取测试题目
async getQuestions(): Promise<ApiResponse<Question[]>> {
return httpService.post("/ntrp/questions", {}, { showLoading: true });
}
async submitEvaluateAnswers({
answers,
}: Answers): Promise<ApiResponse<SubmitAnswerRes>> {
return httpService.post(
"/ntrp/submit",
{ answers },
{
showLoading: true,
},
);
// 提交答案
async submit(req: { answers: { question_id: number, answer_index: number }[], test_duration: number }): Promise<ApiResponse<TestResultData>> {
return httpService.post("/ntrp/submit", req, { showLoading: true });
}
async getHistoryNtrp(): Promise<ApiResponse<any>> {
return httpService.post("/ntrp/history", {
showLoading: true,
});
// 获取测试历史
async getResultList(): Promise<ApiResponse<TestResultList>> {
return httpService.post("/ntrp/history", {}, { showLoading: true });
}
async getNtrpDetail(record_id: number): Promise<ApiResponse<any>> {
return httpService.post(
"/ntrp/detail",
{ record_id },
{
showLoading: true,
},
);
// 获取测试详情
async getTestResult(req: { record_id: number }): Promise<ApiResponse<TestResultData>> {
return httpService.post("/ntrp/detail", req, { showLoading: true });
}
// 获取最后一次(最新)测试结果
async getLastResult(): Promise<ApiResponse<LastTimeTestResult>> {
return httpService.post("/ntrp/last_result", {}, { showLoading: true });
}
// 更新NTRP等级
async updateNtrp(req: { record_id: number, ntrp_level: string, update_type: string }): Promise<ApiResponse<any>> {
return httpService.post("/ntrp/update_user_level", req, { showLoading: true });
}
}

View File

@@ -0,0 +1,232 @@
import { API_CONFIG } from '@/config/api';
import httpService, { ApiResponse } from './httpService';
// 球友信息接口
export interface FollowUser {
id: number;
nickname: string;
avatar_url: string;
personal_profile?: string;
gender?: string;
city?: string;
ntrp_level?: number;
// 关注状态mutual_follow=互相关注, following=已关注, follower=粉丝, recommend=推荐
follow_status: 'mutual_follow' | 'following' | 'follower' | 'recommend';
follow_time?: string; // 关注时间
is_mutual?: boolean; // 是否互关
is_following?: boolean; // 是否已关注
common_games_count?: number; // 共同参与球局数量
}
// 关注列表响应接口
export interface FollowListResponse {
list: FollowUser[];
total: number;
current_city?: string; // 当前用户城市(推荐接口返回)
}
// 球友关注服务类
export class FollowService {
// 获取互关用户列表
static async get_mutual_follow_list(
page: number = 1,
page_size: number = 20
): Promise<FollowListResponse> {
try {
const response = await httpService.post<FollowListResponse>(
'/user_follow/mutual_follow_list',
{ page, page_size },
{ showLoading: false }
);
if (response.code === 0) {
// 为数据添加 follow_status 标识
const list = response.data.list.map(user => ({
...user,
follow_status: 'mutual_follow' as const
}));
return { ...response.data, list };
} else {
throw new Error(response.message || '获取互关列表失败');
}
} catch (error) {
console.error('获取互关列表失败:', error);
return { list: [], total: 0 };
}
}
// 获取我的粉丝列表
static async get_fans_list(
page: number = 1,
page_size: number = 20
): Promise<FollowListResponse> {
try {
const response = await httpService.post<FollowListResponse>(
'/user_follow/my_fans_list',
{ page, page_size },
{ showLoading: false }
);
if (response.code === 0) {
// 为数据添加 follow_status 标识
const list = response.data.list.map(user => ({
...user,
follow_status: user.is_mutual ? 'mutual_follow' as const : 'follower' as const
}));
return { ...response.data, list };
} else {
throw new Error(response.message || '获取粉丝列表失败');
}
} catch (error) {
console.error('获取粉丝列表失败:', error);
return { list: [], total: 0 };
}
}
// 获取我的关注列表
static async get_following_list(
page: number = 1,
page_size: number = 20
): Promise<FollowListResponse> {
try {
const response = await httpService.post<FollowListResponse>(
'/user_follow/my_following_list',
{ page, page_size },
{ showLoading: false }
);
if (response.code === 0) {
// 为数据添加 follow_status 标识
const list = response.data.list.map(user => ({
...user,
follow_status: user.is_mutual ? 'mutual_follow' as const : 'following' as const
}));
return { ...response.data, list };
} else {
throw new Error(response.message || '获取关注列表失败');
}
} catch (error) {
console.error('获取关注列表失败:', error);
return { list: [], total: 0 };
}
}
// 获取关注列表(统一入口)
static async get_follow_list(
type: 'mutual_follow' | 'following' | 'follower' | 'recommend',
page: number = 1,
page_size: number = 20
): Promise<FollowListResponse> {
switch (type) {
case 'mutual_follow':
return this.get_mutual_follow_list(page, page_size);
case 'following':
return this.get_following_list(page, page_size);
case 'follower':
return this.get_fans_list(page, page_size);
case 'recommend':
return this.get_recommend_users(page, page_size);
default:
return { list: [], total: 0 };
}
}
// 关注用户
static async follow_user(user_id: number): Promise<boolean> {
try {
const response = await httpService.post<any>(
API_CONFIG.USER.FOLLOW,
{ following_id: user_id },
{
showLoading: false,
loadingText: '关注中...'
}
);
if (response.code === 0) {
return true;
} else {
throw new Error(response.message || '关注失败');
}
} catch (error) {
console.error('关注失败:', error);
throw error;
}
}
// 取消关注用户
static async unfollow_user(user_id: number): Promise<boolean> {
try {
const response = await httpService.post<any>(
API_CONFIG.USER.UNFOLLOW,
{ following_id: user_id },
{
showLoading: false,
loadingText: '取消关注中...'
}
);
if (response.code === 0) {
return true;
} else {
throw new Error(response.message || '取消关注失败');
}
} catch (error) {
console.error('取消关注失败:', error);
throw error;
}
}
// 回关用户(关注粉丝)
static async follow_back(user_id: number): Promise<boolean> {
return this.follow_user(user_id);
}
// 获取同城推荐用户
static async get_recommend_users(page: number = 1, page_size: number = 10): Promise<FollowListResponse> {
try {
const response = await httpService.post<FollowListResponse>(
'/user_follow/recommend_same_city',
{ page, page_size },
{ showLoading: false }
);
if (response.code === 0) {
// 为数据添加 follow_status 标识
const list = response.data.list.map(user => ({
...user,
follow_status: 'recommend' as const
}));
return { ...response.data, list };
} else {
throw new Error(response.message || '获取推荐用户失败');
}
} catch (error) {
console.error('获取推荐用户失败:', error);
return { list: [], total: 0 };
}
}
// 检查关注状态
static async check_follow_status(user_id: number): Promise<'mutual_follow' | 'following' | 'follower' | 'none'> {
try {
const response = await httpService.post<{ status: string }>(
'/wch_users/follow_status',
{ user_id },
{ showLoading: false }
);
if (response.code === 0) {
return response.data.status as 'mutual_follow' | 'following' | 'follower' | 'none';
} else {
return 'none';
}
} catch (error) {
console.error('检查关注状态失败:', error);
return 'none';
}
}
}
export default FollowService;

View File

@@ -105,7 +105,7 @@ class HttpService {
}
// 处理响应
private handleResponse<T>(response: any): Promise<ApiResponse<T>> {
private handleResponse<T>(response: any, showToast: boolean): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const { statusCode, data } = response
@@ -121,8 +121,12 @@ class HttpService {
// 业务状态码检查
if (data && typeof data === 'object') {
if (data.success === false || (data.code && data.code !== 0 && data.code !== 200)) {
if (showToast) {
this.handleBusinessError(data)
reject(new Error(data.message || '请求失败'))
} else {
reject(response.data)
}
return
}
}
@@ -191,7 +195,8 @@ class HttpService {
data,
params,
showLoading = false,
loadingText = '请求中...'
loadingText = '请求中...',
showToast = true,
} = config
let fullUrl = this.buildUrl(url, method === 'GET' ? params : undefined)
@@ -238,11 +243,12 @@ class HttpService {
method: method,
data: method !== 'GET' ? data : undefined,
header: reqHeader,
timeout: this.timeout
timeout: this.timeout,
showToast,
}
const response = await Taro.request(requestConfig)
return this.handleResponse<T>(response)
return this.handleResponse<T>(response, showToast)
} catch (error) {
this.log('error', '请求失败', error)
@@ -304,7 +310,7 @@ class HttpService {
}
uploadFile(){
uploadFile() {
}

View File

@@ -25,7 +25,7 @@ export const getGamesList = async (params?: Record<string, any>) => {
*/
// const isIntegrate = params?.order === '0';
// const fetchApi = isIntegrate ? '/games/integrate_list' : '/games/list'
return httpService.post('/games/list', params, { showLoading: true })
return httpService.post('/games/list', params, { showLoading: false })
} catch (error) {
console.error("列表数据获取失败:", error);
throw error;
@@ -39,7 +39,7 @@ export const getGamesList = async (params?: Record<string, any>) => {
*/
export const getGamesIntegrateList = async (params?: Record<string, any>) => {
try {
return httpService.post('/games/integrate_list', params, { showLoading: true })
return httpService.post('/games/integrate_list', params, { showLoading: false })
} catch (error) {
console.error("列表数据获取失败:", error);
throw error;
@@ -53,7 +53,7 @@ export const getGamesIntegrateList = async (params?: Record<string, any>) => {
*/
export const getGamesCount = async (params?: Record<string, any>) => {
try {
return httpService.post('/games/count', params, { showLoading: true })
return httpService.post('/games/count', params, { showLoading: false })
} catch (error) {
console.error("列表数量获取失败:", error);
throw error;
@@ -68,7 +68,7 @@ export const getGamesCount = async (params?: Record<string, any>) => {
export const getSearchHistory = async (params) => {
try {
// 调用HTTP服务获取搜索历史记录
return httpService.post('/games/search_history', params, { showLoading: true })
return httpService.post('/games/search_history', params, { showLoading: false })
} catch (error) {
// 捕获并打印错误信息
console.error("历史记录获取失败:", error);
@@ -84,7 +84,7 @@ export const getSearchHistory = async (params) => {
export const clearHistory = async (params) => {
try {
// 调用HTTP服务清除搜索历史记录
return httpService.post('/games/search_history/delete_all', params, { showLoading: true })
return httpService.post('/games/search_history/delete_all', params, { showLoading: false })
} catch (error) {
// 捕获并打印错误信息
console.error("清除历史记录失败:", error);

View File

@@ -40,23 +40,23 @@ export interface UnreadMountResponse {
class NoticeService {
// 获取用户消息通知列表
async getNotificationList({ notification_type, is_read }: NoticeListParams): Promise<ApiResponse<NoticeListResponse>> {
return httpService.post("/notifications/list", { notification_type, is_read }, { showLoading: true });
return httpService.post("/notifications/list", { notification_type, is_read }, { showLoading: false });
}
// 获取消息通知详情
async getNotificationDetail(notification_id: number): Promise<ApiResponse<Notice>> {
return httpService.post("/notifications/detail", { notification_id }, { showLoading: true });
return httpService.post("/notifications/detail", { notification_id }, { showLoading: false });
}
// 标记消息为已读
async markNotificationRead({ notification_ids, mark_all }: MarkReadParams): Promise<ApiResponse<{ marked_count: number }>> {
return httpService.post("/notifications/mark_read", { notification_ids, mark_all }, { showLoading: true });
return httpService.post("/notifications/mark_read", { notification_ids, mark_all }, { showLoading: false });
}
// 删除消息通知
async delNotification({ notification_ids, delete_all }: DeleteParams): Promise<ApiResponse<{ deleted_count: number }>> {
return httpService.post("/notifications/delete", { notification_ids, delete_all }, { showLoading: true });
return httpService.post("/notifications/delete", { notification_ids, delete_all }, { showLoading: false });
}
// 获取未读消息数量
async getNotificationUnreadCount(): Promise<ApiResponse<UnreadMountResponse>> {
return httpService.post("/notifications/unread_count", {}, { showLoading: true });
return httpService.post("/notifications/unread_count", {}, { showLoading: false });
}
}

View File

@@ -22,6 +22,18 @@ export enum CancelType {
TIMEOUT, // 超时
}
export enum RefundStatus {
NONE = 0, // 无退款
PENDING, // 退款中
SUCCESS, // 已退款
}
export const refundTextMap = new Map([
[RefundStatus.NONE, "已支付"],
[RefundStatus.PENDING, "退款中"],
[RefundStatus.SUCCESS, "已退款"],
]);
export interface PayMentParams {
order_id: number;
order_no: string;
@@ -78,8 +90,8 @@ export interface GameOrderRes {
// 发布球局类
class OrderService {
// 查询订单列表
async getOrderList() {
return httpService.post("/user/orders", {}, { showLoading: true });
async getOrderList(pagination: { page: number, pageSize: number }) {
return httpService.post("/user/orders", pagination, { showLoading: true });
}
// 获取订单详情
@@ -161,6 +173,21 @@ class OrderService {
},
);
}
// 删除订单
async deleteOrder({
order_id,
}: {
order_id: number;
}): Promise<ApiResponse<any>> {
return httpService.post(
"/payment/delete_order",
{ order_id },
{
showLoading: true,
},
);
}
}
// 导出认证服务实例

View File

@@ -20,7 +20,7 @@ export interface PublishBallData {
venue_description?: string // 场地描述
venue_image_list?: string[] // 场地图片
max_players: number // 人数要求
current_players: number // 人数要求
min_players: number // 人数要求
skill_level_min: number // 水平要求(NTRP)
skill_level_max: number // 水平要求(NTRP)
description: string // 备注
@@ -142,6 +142,24 @@ class PublishService {
showToast: false,
})
}
async extract_tennis_activity(req: {text: string}): Promise<getPicturesRes> {
return httpService.post('/ai/extract_tennis_activity', req, {
showLoading: false,
showToast: false,
})
}
async extract_tennis_activity_from_image(req: {image_url: string}): Promise<getPicturesRes> {
return httpService.post('/ai/extract_tennis_activity_from_image', req, {
showLoading: false,
showToast: false,
})
}
async gamesUpdate(data: PublishBallData): Promise<ApiResponse<createGameData>> {
return httpService.post('/games/update', data, {
showLoading: true,
loadingText: '发布中...'
})
}
async getPictures(req) {
const { type, tag, otherReq = {} } = req
if (type === 'history') {

View File

@@ -3,6 +3,7 @@ import { API_CONFIG } from '@/config/api';
import httpService, { ApiResponse } from './httpService';
import uploadFiles from './uploadFiles';
import Taro from '@tarojs/taro';
import getCurrentConfig from '@/config/env';
// 用户详情接口
@@ -18,7 +19,7 @@ interface UserDetailData {
country: string;
province: string;
city: string;
district:string;
district: string;
language: string;
phone: string;
is_subscribed: string;
@@ -28,6 +29,10 @@ interface UserDetailData {
last_login_time: string;
create_time: string;
last_modify_time: string;
personal_profile: string;
occupation: string;
birthday: string;
ntrp_level: string,
stats: {
followers_count: number;
following_count: number;
@@ -36,6 +41,17 @@ interface UserDetailData {
};
}
export interface PickerOption {
text: string | number;
value: string | number;
children?: PickerOption[];
}
export interface Profession {
name: string;
children: Profession[] | [];
}
// 用户详细信息接口(从 loginService 移过来)
export interface UserInfoType {
id: number
@@ -48,7 +64,7 @@ export interface UserInfoType {
country: string
province: string
city: string
district:string
district: string
language: string
phone: string
is_subscribed: string
@@ -108,6 +124,26 @@ interface BackendGameData {
venue_type: string;
surface_type: string;
};
participants: {
user: {
avatar_url: string;
};
}[];
}
const formatOptions = (data: Profession[]): PickerOption[] => {
return data.map((item: Profession) => {
const { name: text, children } = item;
const itm: PickerOption = {
text,
value: text,
children: children ? formatOptions(children) : []
}
if (!itm.children!.length) {
delete itm.children
}
return itm
})
}
// 用户服务类
@@ -116,7 +152,7 @@ export class UserService {
private static transform_game_data(backend_data: BackendGameData[]): any[] {
return backend_data.map(game => {
// 处理时间格式
const start_time = new Date(game.start_time);
const start_time = new Date(game.start_time.replace(/\s/, 'T'));
const date_time = this.format_date_time(start_time);
// 处理图片数组 - 兼容两种数据格式
@@ -153,6 +189,8 @@ export class UserService {
id: game.id,
title: game.title || '未命名球局',
start_time: date_time,
original_start_time: game.start_time,
end_time: game.end_time || '',
location: location,
distance_km: parseFloat(distance.replace('km', '')) || 0,
current_players: registered_count,
@@ -163,30 +201,42 @@ export class UserService {
image_list: images,
court_type: game.court_type || '未知',
matchType: game.play_type || '不限',
shinei: game.court_type || '未知'
shinei: game.court_type || '未知',
participants: game.participants || [],
};
});
}
private static is_date_in_this_week(date: Date): boolean {
const today = new Date();
const firstDayOfWeek = new Date(today.setDate(today.getDate() - today.getDay()));
const lastDayOfWeek = new Date(firstDayOfWeek.setDate(firstDayOfWeek.getDate() + 6));
return date >= firstDayOfWeek && date <= lastDayOfWeek;
}
// 格式化时间显示
private static format_date_time(start_time: Date): string {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const today = new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
const tomorrow = new Date(today.getTime() + 24 * 60 * 60 * 1000);
const day_after_tomorrow = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000);
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
const start_date = new Date(start_time.getFullYear(), start_time.getMonth(), start_time.getDate());
const start_date = new Date(start_time.getFullYear(), start_time.getMonth() + 1, start_time.getDate());
const weekday = weekdays[start_time.getDay()];
let date_str = '';
if (start_date.getTime() === today.getTime()) {
date_str = '今天';
} else if (start_date.getTime() === tomorrow.getTime()) {
date_str = '明天';
date_str = `明天(${weekday})`;
} else if (start_date.getTime() === day_after_tomorrow.getTime()) {
date_str = '后天';
date_str = `后天(${weekday})`;
} else if (this.is_date_in_this_week(start_time)) {
date_str = weekday;
} else {
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
date_str = weekdays[start_time.getDay()];
date_str = `${start_time.getFullYear()}-${(start_time.getMonth() + 1).toString().padStart(2, '0')}-${start_time.getDate().toString().padStart(2, '0')}(${weekday})`;
}
const time_str = `${start_time.getHours().toString().padStart(2, '0')}:${start_time.getMinutes().toString().padStart(2, '0')}`;
@@ -215,7 +265,7 @@ export class UserService {
if (response.code === 0) {
const userData = response.data;
return {
id: userData.user_code || user_id || '',
id: userData.id || '',
nickname: userData.nickname || '',
avatar: userData.avatar_url || '',
join_date: userData.subscribe_time ? `${new Date(userData.subscribe_time).getFullYear()}${new Date(userData.subscribe_time).getMonth() + 1}月加入` : '',
@@ -226,12 +276,15 @@ export class UserService {
participated: userData.stats?.participated_games_count || 0
},
personal_profile: '',
location: userData.city + userData.district|| '',
occupation: '',
ntrp_level: '',
personal_profile: userData.personal_profile || '',
occupation: userData.occupation || '',
ntrp_level: userData.ntrp_level || '',
phone: userData.phone || '',
gender: userData.gender || ''
gender: userData.gender || '',
birthday: userData.birthday || '',
country: userData.country || '',
province: userData.province || '',
city: userData.city || '',
};
} else {
throw new Error(response.message || '获取用户信息失败');
@@ -260,7 +313,6 @@ export class UserService {
}
}
});
// 如果没有需要更新的字段,直接返回
if (Object.keys(filtered_data).length === 0) {
console.log('没有需要更新的字段');
@@ -281,10 +333,10 @@ export class UserService {
}
// 获取用户主办的球局
static async get_hosted_games(user_id: string): Promise<any[]> {
static async get_hosted_games(userId: string | number): Promise<any[]> {
try {
const response = await httpService.post<any>(API_CONFIG.USER.HOSTED_GAMES, {
user_id
userId
}, {
showLoading: false
@@ -304,10 +356,10 @@ export class UserService {
}
// 获取用户参与的球局
static async get_participated_games(user_id: string): Promise<any[]> {
static async get_participated_games(userId: string | number): Promise<any[]> {
try {
const response = await httpService.post<any>(API_CONFIG.USER.PARTICIPATED_GAMES, {
user_id
userId
}, {
showLoading: false
@@ -328,7 +380,7 @@ export class UserService {
}
// 获取用户球局记录(兼容旧方法)
static async get_user_games(user_id: string, type: 'hosted' | 'participated'): Promise<any[]> {
static async get_user_games(user_id: string | number, type: 'hosted' | 'participated'): Promise<any[]> {
if (type === 'hosted') {
return this.get_hosted_games(user_id);
} else {
@@ -337,10 +389,10 @@ export class UserService {
}
// 关注/取消关注用户
static async toggle_follow(user_id: string, is_following: boolean): Promise<boolean> {
static async toggle_follow(following_id: string | number, is_following: boolean): Promise<boolean> {
try {
const endpoint = is_following ? API_CONFIG.USER.UNFOLLOW : API_CONFIG.USER.FOLLOW;
const response = await httpService.post<any>(endpoint, { user_id }, {
const response = await httpService.post<any>(endpoint, { following_id }, {
showLoading: true,
loadingText: is_following ? '取消关注中...' : '关注中...'
@@ -369,8 +421,8 @@ export class UserService {
latitude: 'latitude',
longitude: 'longitude',
province: 'province',
country:"country",
city:"city",
country: "country",
city: "city",
personal_profile: 'personal_profile',
occupation: 'occupation',
ntrp_level: 'ntrp_level'
@@ -449,6 +501,57 @@ export class UserService {
return require('../static/userInfo/default_avatar.svg');
}
}
// 解析用户手机号
static async parse_phone(phone_code: string): Promise<string> {
try {
const response = await httpService.post<{ phone: string }>(API_CONFIG.USER.PARSE_PHONE, { phone_code }, {
showLoading: true,
loadingText: '获取手机号中...'
});
if (response.code === 0) {
return response.data.phone || '';
} else {
throw new Error(response.message || '获取手机号失败');
}
} catch (error) {
console.error('获取手机号失败:', error);
return '';
}
}
// 获取职业树
static async getProfessions(): Promise<[] | PickerOption[]> {
try {
const response = await httpService.post<any>(API_CONFIG.PROFESSIONS);
const { code, data, message } = response;
if (code === 0) {
return formatOptions(data || []);
} else {
throw new Error(message || '获取职业树失败');
}
} catch (error) {
console.error('获取职业树失败:', error);
return [];
}
}
// 获取城市树
static async getCities(): Promise<[] | PickerOption[]> {
try {
const response = await httpService.post<any>(API_CONFIG.CITIS);
const { code, data, message } = response;
if (code === 0) {
return formatOptions(data || []);
} else {
throw new Error(message || '获取城市树失败');
}
} catch (error) {
console.error('获取职业树失败:', error);
return [];
}
}
}
// 从 loginService 移过来的用户相关方法
@@ -501,3 +604,86 @@ export const get_user_info = (): any | null => {
return null;
}
};
// 客服中心处理函数
export const handleCustomerService = async (): Promise<void> => {
try {
// 获取当前环境的客服配置
const config = getCurrentConfig;
const { customerService } = config;
console.log('打开客服中心,配置信息:', customerService);
// 使用微信官方客服能力
await Taro.openCustomerServiceChat({
extInfo: {
url: customerService.serviceUrl
},
corpId: customerService.corpId,
success: (res) => {
console.log('打开客服成功:', res);
},
fail: (error) => {
console.error('打开客服失败:', error);
// 如果官方客服不可用,显示备用联系方式
showCustomerServiceFallback(customerService);
}
});
} catch (error) {
console.error('客服功能异常:', error);
// 备用方案:显示联系信息
showCustomerServiceFallback();
}
};
// 客服备用方案
const showCustomerServiceFallback = (customerInfo?: any) => {
const options = ['拨打客服电话', '复制邮箱地址'];
// 如果没有客服信息,只显示通用提示
if (!customerInfo?.phoneNumber && !customerInfo?.email) {
Taro.showModal({
title: '联系客服',
content: '如需帮助,请通过其他方式联系我们',
showCancel: false
});
return;
}
Taro.showActionSheet({
itemList: options,
success: async (res) => {
if (res.tapIndex === 0 && customerInfo?.phoneNumber) {
// 拨打客服电话
try {
await Taro.makePhoneCall({
phoneNumber: customerInfo.phoneNumber
});
} catch (error) {
console.error('拨打电话失败:', error);
Taro.showToast({
title: '拨打电话失败',
icon: 'none'
});
}
} else if (res.tapIndex === 1 && customerInfo?.email) {
// 复制邮箱地址
try {
await Taro.setClipboardData({
data: customerInfo.email
});
Taro.showToast({
title: '邮箱地址已复制',
icon: 'success'
});
} catch (error) {
console.error('复制邮箱失败:', error);
Taro.showToast({
title: '复制失败',
icon: 'none'
});
}
}
}
});
};

View File

@@ -0,0 +1,115 @@
import httpService from "./httpService";
// 钱包信息接口
export interface WalletInfo {
balance: number;
total_income: number;
total_withdraw: number;
}
// 交易记录接口
export interface TransactionRecord {
id: string;
type: "withdraw" | "income" | "refund";
amount: number;
description: string;
created_time: string;
status: "pending" | "success" | "failed";
}
// 提现请求接口
export interface WithdrawRequest {
amount: number;
bank_id: string;
}
export class WalletService {
/**
* 获取钱包信息
*/
static async get_wallet_info(): Promise<WalletInfo> {
try {
const response = await httpService.post("/api/wallet/get_info", {});
if (response.code === 200) {
return response.data;
} else {
throw new Error(response.message || "获取钱包信息失败");
}
} catch (error) {
console.error("获取钱包信息失败:", error);
// 返回模拟数据
return {
balance: 1588.80,
total_income: 3200.00,
total_withdraw: 1611.20,
};
}
}
/**
* 获取交易记录
*/
static async get_transactions(): Promise<TransactionRecord[]> {
try {
const response = await httpService.post("/api/wallet/get_transactions", {});
if (response.code === 200) {
return response.data;
} else {
throw new Error(response.message || "获取交易记录失败");
}
} catch (error) {
console.error("获取交易记录失败:", error);
// 返回模拟数据
return [
{
id: "1",
type: "income",
amount: 150.00,
description: "球局收入",
created_time: "2024-12-20 14:30:00",
status: "success",
},
{
id: "2",
type: "withdraw",
amount: 200.00,
description: "提现",
created_time: "2024-12-19 10:15:00",
status: "success",
},
{
id: "3",
type: "refund",
amount: 80.00,
description: "球局退款",
created_time: "2024-12-18 16:45:00",
status: "success",
},
];
}
}
/**
* 提交提现申请
*/
static async submit_withdraw(request: WithdrawRequest): Promise<void> {
try {
const response = await httpService.post("/wallet/withdraw", {
amount: request.amount,
transfer_remark: "用户申请提现"
});
if (response.code !== 200) {
throw new Error(response.message || "提现申请提交失败");
}
} catch (error) {
console.error("提现申请提交失败:", error);
throw error;
}
}
}

View File

@@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.0042 6.05029V18" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6 12L12 6L18 12" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 322 B

View File

@@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.625 15.75H16.125" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.125 10.02V12.75H6.86895L14.625 4.99054L11.8857 2.25L4.125 10.02Z" stroke="white" stroke-width="1.5" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.50002 10L17.5 10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 15L2.5 10L7.5 5" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H2.5" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5 5L17.5 10L12.5 15" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="13" viewBox="0 0 12 13" fill="none">
<path d="M10.5 6.5H1.5" stroke="#5CA693" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 3.5L10.5 6.5L7.5 9.5" stroke="#5CA693" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.36695 10.343C5.75135 9.94642 6.38444 9.93658 6.78099 10.321L8.84309 12.3199L13.1831 6.17329C13.5016 5.72214 14.1256 5.61464 14.5768 5.93319C15.0279 6.25174 15.1354 6.87571 14.8169 7.32687L9.80293 14.428C9.6344 14.6667 9.3699 14.8197 9.07898 14.8469C8.78805 14.8741 8.4998 14.7726 8.29001 14.5692L5.38894 11.757C4.99239 11.3726 4.98254 10.7395 5.36695 10.343Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9.25" stroke="#161823" stroke-opacity="0.34" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33325 9.33334L22.6666 22.6667" stroke="black" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33325 22.6667L22.6666 9.33334" stroke="black" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1,12 @@
<svg width="55" height="55" viewBox="0 0 55 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4962_28921)">
<path d="M14.462 7.25007C14.6157 6.15625 15.6271 5.39415 16.7209 5.54788L36.5263 8.33134L45.0372 19.6258L41.1404 47.3533C40.9866 48.4471 39.9753 49.2092 38.8815 49.0554L11.154 45.1586C10.0601 45.0049 9.29805 43.9936 9.45178 42.8997L14.462 7.25007Z" stroke="#00E6AD" stroke-width="4" stroke-linejoin="round"/>
<path d="M20.4364 22.2275L36.2807 24.4543" stroke="#00E6AD" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.3226 30.1494L35.1669 32.3762" stroke="#00E6AD" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_4962_28921">
<rect width="48" height="48" fill="white" transform="translate(7.37561 0.195312) rotate(8)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 862 B

View File

@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.5 10.0035V17.5H17.5V10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M13.75 9.58301L10 13.333L6.25 9.58301" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.99658 2.5V13.3333" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 479 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M7 7L17 17" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 17L17 7" stroke="black" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 313 B

View File

@@ -0,0 +1,4 @@
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.18198 9.18198C8.36765 9.99633 7.24265 10.5 6 10.5C3.51472 10.5 1.5 8.48528 1.5 6C1.5 3.51472 3.51472 1.5 6 1.5C7.24265 1.5 8.36765 2.00368 9.18198 2.81803C9.59648 3.23253 10.5 4.25 10.5 4.25" stroke="black" stroke-opacity="0.85" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.5 2V4.25H8.25" stroke="black" stroke-opacity="0.85" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,22 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.52734 11.4374C1.52734 13.7338 2.74202 15.8003 4.6428 17.1972C4.79619 17.3071 4.89569 17.4895 4.89569 17.6946C4.89569 17.761 4.88118 17.8252 4.86459 17.8895C4.71329 18.4636 4.47075 19.3838 4.45831 19.4253C4.43967 19.4979 4.41063 19.5725 4.41063 19.6492C4.41063 19.817 4.54539 19.9538 4.7112 19.9538C4.77753 19.9538 4.82936 19.9289 4.88531 19.8979L6.85865 18.7413C7.00789 18.6543 7.16542 18.6004 7.33747 18.6004C7.43074 18.6004 7.51782 18.6149 7.60278 18.6398C8.52312 18.9092 9.516 19.0584 10.5462 19.0584C15.5251 19.0584 19.563 15.6469 19.563 11.4395C19.563 7.22993 15.5251 3.81836 10.5462 3.81836C5.56314 3.81629 1.52734 7.22784 1.52734 11.4374Z" fill="white"/>
<path d="M1.52734 11.4374C1.52734 13.7338 2.74202 15.8003 4.6428 17.1972C4.79619 17.3071 4.89569 17.4895 4.89569 17.6946C4.89569 17.761 4.88118 17.8252 4.86459 17.8895C4.71329 18.4636 4.47075 19.3838 4.45831 19.4253C4.43967 19.4979 4.41063 19.5725 4.41063 19.6492C4.41063 19.817 4.54539 19.9538 4.7112 19.9538C4.77753 19.9538 4.82936 19.9289 4.88531 19.8979L6.85865 18.7413C7.00789 18.6543 7.16542 18.6004 7.33747 18.6004C7.43074 18.6004 7.51782 18.6149 7.60278 18.6398C8.52312 18.9092 9.516 19.0584 10.5462 19.0584C15.5251 19.0584 19.563 15.6469 19.563 11.4395C19.563 7.22993 15.5251 3.81836 10.5462 3.81836C5.5652 3.81629 1.52734 7.22784 1.52734 11.4374Z" fill="url(#paint0_linear_4962_26946)"/>
<path d="M11.4062 17.0935C11.4062 20.5983 14.7684 23.4378 18.914 23.4378C19.7701 23.4378 20.5971 23.3134 21.3641 23.0896C21.4346 23.0689 21.5071 23.0565 21.5838 23.0565C21.7268 23.0565 21.8595 23.1 21.9818 23.1725L23.6256 24.1342C23.6711 24.1612 23.7147 24.1819 23.7706 24.1819C23.9095 24.1819 24.0215 24.0679 24.0215 23.929C24.0215 23.8669 23.9966 23.8026 23.9821 23.7425C23.9717 23.7073 23.7706 22.9404 23.6442 22.4616C23.6297 22.4077 23.6173 22.3559 23.6173 22.2999C23.6173 22.13 23.7002 21.9787 23.8266 21.8875C25.4102 20.7247 26.4218 19.0024 26.4218 17.0914C26.4218 13.5866 23.0597 10.7471 18.914 10.7471C14.7663 10.7492 11.4062 13.5887 11.4062 17.0935Z" fill="url(#paint1_linear_4962_26946)"/>
<path d="M20.4937 15.1574C20.4937 15.7212 20.9434 16.1772 21.4989 16.1772C22.0543 16.1772 22.5041 15.7212 22.5041 15.1574C22.5041 14.5937 22.0543 14.1377 21.4989 14.1377C20.9434 14.1377 20.4937 14.5937 20.4937 15.1574Z" fill="#919191"/>
<path d="M15.4692 15.1574C15.4692 15.7212 15.919 16.1772 16.4745 16.1772C17.0299 16.1772 17.4797 15.7212 17.4797 15.1574C17.4797 14.5937 17.0299 14.1377 16.4745 14.1377C15.919 14.1377 15.4692 14.5937 15.4692 15.1574Z" fill="#919191"/>
<path d="M8.73456 8.98751C8.73456 9.6632 8.19561 10.2104 7.52817 10.2104C6.86279 10.2104 6.32178 9.6632 6.32178 8.98751C6.32178 8.31185 6.86073 7.76465 7.52817 7.76465C8.19561 7.76465 8.73456 8.31185 8.73456 8.98751Z" fill="#168743"/>
<path d="M14.7639 8.98751C14.7639 9.6632 14.2228 10.2104 13.5575 10.2104C12.8921 10.2104 12.3511 9.6632 12.3511 8.98751C12.3511 8.31185 12.8921 7.76465 13.5575 7.76465C14.2228 7.76465 14.7639 8.31185 14.7639 8.98751Z" fill="#168743"/>
<defs>
<linearGradient id="paint0_linear_4962_26946" x1="10.5439" y1="19.9517" x2="10.5439" y2="3.81689" gradientUnits="userSpaceOnUse">
<stop offset="0.0602" stop-color="#05CD66"/>
<stop offset="0.2202" stop-color="#0ED169"/>
<stop offset="0.4805" stop-color="#26DB6F"/>
<stop offset="0.8069" stop-color="#4DEB7A"/>
<stop offset="0.9517" stop-color="#61F380"/>
</linearGradient>
<linearGradient id="paint1_linear_4962_26946" x1="18.9141" y1="24.1841" x2="18.9141" y2="10.7484" gradientUnits="userSpaceOnUse">
<stop offset="0.081" stop-color="#D9D9D9"/>
<stop offset="1" stop-color="#F0F0F0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83325 5.83325L14.1666 14.1666" stroke="#A0A0A0" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M5.83325 14.1666L14.1666 5.83325" stroke="#A0A0A0" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 367 B

Some files were not shown because too many files have changed in this diff Show More