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

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>
</View>
{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 handleChange = (value: [number | string, number | string]) => {
const newMin = Number(value[0])
const newMax = Number(value[1])
// 确保最少人数不能大于最多人数
if (newMin > newMax) {
return
const [organizer_joined, setOrganizerJoined] = useState(true);
const [minParticipants, setMinParticipants] = useState(1);
const [maxParticipants, setMaxParticipants] = useState(1);
useEffect(() => {
if (value) {
setOrganizerJoined(value.organizer_joined);
setMinParticipants(value.min);
setMaxParticipants(value.max);
}
onChange([newMin, newMax])
console.log(value, 'valuevaluevaluevaluevaluevalue');
}, [value]);
const handleChange = (value: { min: number | string, max: number | string, organizer_joined: boolean }) => {
const toNumber = (v: number | string): number => typeof v === 'string' ? Number(v) : v
const { min, max, organizer_joined } = value;
onChange({ min: toNumber(min), max: toNumber(max), organizer_joined })
}
return (
<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;
}
setSelected(new Date(year, month - 1, 1));
}
setType('year')
} else if (type === 'time') {
} else if (type === "time") {
// 时间选择完成后调用onNext回调
const value = hourMinutePickerRef.current?.getValue()
const value = hourMinutePickerRef.current?.getValue();
if (value) {
const hour = value[0] as number
const minute = value[1] as number
setSelectedHour(hour)
setSelectedMinute(minute)
const hours = hour.toString().padStart(2, '0')
const minutes = minute.toString().padStart(2, '0')
const finalDate = new Date(dayjs(selected).format('YYYY-MM-DD') + ' ' + hours + ':' + minutes)
if (onChange) onChange(finalDate)
const hour = value[0] as number;
const minute = value[1] as number;
setSelectedHour(hour);
setSelectedMinute(minute);
const hours = hour.toString().padStart(2, "0");
const minutes = minute.toString().padStart(2, "0");
const finalDate = new Date(
dayjs(selected as Date).format("YYYY-MM-DD") +
" " +
hours +
":" +
minutes
);
if (onChange) onChange(finalDate);
}
onClose()
onClose();
}
}
};
const calculateMonthDifference = (date1, date2) => {
if (!(date1 instanceof Date) || !(date2 instanceof Date)) {
throw new Error("Both arguments must be Date objects");
}
setCurrent(date1)
let months = (date2.getFullYear() - date1.getFullYear()) * 12;
months -= date1.getMonth();
months += date2.getMonth();
setDelta(months);
};
const handleChange = (d: Date | Date[]) => {
if (searchType === "range") {
if (Array.isArray(d)) {
if (d.length === 2) {
return;
} else if (d.length === 1) {
if (selectedBackup.length === 0 || selectedBackup.length === 2) {
setSelected([...d]);
setSelectedBackup([...d]);
} else {
setSelected(
[...selectedBackup, d[0]].sort(
(a, b) => a.getTime() - b.getTime()
)
);
setSelectedBackup([]);
}
}
return;
}
}
if (Array.isArray(d)) {
setSelected(d[0])
setSelected(d[0]);
} else {
setSelected(d)
setSelected(d);
}
}
};
const onHeaderClick = (date: Date) => {
setSelected(date)
setType('month')
}
console.log("onHeaderClick", date);
setSelected(date);
setType("month");
};
const getConfirmText = () => {
if (type === 'time' || type === 'month') return '完成'
return '下一步'
}
if (type === "time" || type === "month" || searchType === "range")
return "完成";
return "下一步";
};
const handleDateTimePickerChange = (value: (string | number)[]) => {
const year = value[0] as number
const month = value[1] as number
setSelected(new Date(year, month - 1, 1))
}
const year = value[0] as number;
const month = value[1] as number;
setSelected(new Date(year, month - 1, 1));
};
const dialogClose = () => {
if (type === 'month') {
setType('year')
} else if (type === 'time') {
setType('year')
if (type === "month") {
setType("year");
} else if (type === "time") {
setType("year");
} else {
onClose()
onClose();
}
}
};
useEffect(() => {
calendarRef.current?.gotoMonth(delta);
}, [delta])
useEffect(() => {
if (visible && value) {
setSelected(value || new Date())
setSelectedHour(value ? dayjs(value).hour() : 8)
setSelectedMinute(value ? dayjs(value).minute() : 0)
setSelected(value || new Date());
setSelectedHour(value ? dayjs(value as Date).hour() : 8);
setSelectedMinute(value ? dayjs(value as Date).minute() : 0);
}
}, [value, visible])
}, [value, visible]);
useEffect(() => {
if (type === 'year' && pendingJump && calendarRef.current) {
calendarRef.current.jumpTo(pendingJump.year, pendingJump.month)
setPendingJump(null)
if (type === "year" && pendingJump && calendarRef.current) {
calendarRef.current.jumpTo(pendingJump.year, pendingJump.month);
setPendingJump(null);
}
}, [type, pendingJump])
}, [type, pendingJump]);
console.log([selectedHour, selectedMinute], 'selectedHour, selectedMinute');
console.log([selectedHour, selectedMinute], "selectedHour, selectedMinute");
return (
<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']}>
<CalendarUI
ref={calendarRef}
value={selected}
onChange={handleChange}
showQuickActions={false}
onHeaderClick={onHeaderClick}
/></View>
}
{
type === 'month' && <PickerCommon
{type === "year" && (
<View className={styles["calendar-container"]}>
<CalendarUI
ref={calendarRef}
type={searchType}
value={selected}
onChange={handleChange}
showQuickActions={false}
onHeaderClick={onHeaderClick}
/>
</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
ref={hourMinutePickerRef}
type="hour"
value={[selectedHour, selectedMinute]}
/>
}
)}
{type === "time" && (
<PickerCommon
ref={hourMinutePickerRef}
type="hour"
value={[selectedHour, selectedMinute]}
/>
)}
</CommonPopup>
)
}
);
};
export default DialogCalendarCard
export default DialogCalendarCard;

View File

@@ -1,211 +1,289 @@
import React, { useState, useEffect, useRef, useImperativeHandle } from 'react'
import { CalendarCard } from '@nutui/nutui-react-taro'
import { View, Text, Image } from '@tarojs/components'
import images from '@/config/images'
import styles from './index.module.scss'
import { getMonth, getWeekend, getWeekendOfCurrentWeek } from '@/utils/timeUtils'
import { PopupPicker } from '@/components/Picker/index'
import React, { useState, useEffect, useRef, useImperativeHandle } from "react";
import { CalendarCard } from "@nutui/nutui-react-taro";
import { View, Text, Image } from "@tarojs/components";
import images from "@/config/images";
import styles from "./index.module.scss";
import {
getMonth,
getWeekend,
getWeekendOfCurrentWeek,
} from "@/utils/timeUtils";
import { PopupPicker } from "@/components/Picker/index";
import dayjs from "dayjs";
interface NutUICalendarProps {
type?: 'single' | 'range' | 'multiple'
value?: string | Date | String[] | Date[]
defaultValue?: string | string[]
onChange?: (value: Date | Date[]) => void,
isBorder?: boolean
showQuickActions?: boolean,
onHeaderClick?: (date: Date) => void
type?: "single" | "range" | "multiple";
value?: string | Date | String[] | Date[];
defaultValue?: string | string[];
onChange?: (value: Date | Date[]) => void;
isBorder?: boolean;
showQuickActions?: boolean;
onHeaderClick?: (date: Date) => void;
}
export interface CalendarUIRef {
jumpTo: (year: number, month: number) => void
jumpTo: (year: number, month: number) => void;
gotoMonth: (delta: number) => void;
}
const NutUICalendar = React.forwardRef<CalendarUIRef, NutUICalendarProps>(({
type = 'single',
value,
onChange,
isBorder = false,
showQuickActions = true,
onHeaderClick
}, ref) => {
// 根据类型初始化选中值
// const getInitialValue = (): Date | Date[] => {
// console.log(value,defaultValue,'today')
const NutUICalendar = React.forwardRef<CalendarUIRef, NutUICalendarProps>(
(
{
type = "single",
value,
onChange,
isBorder = false,
showQuickActions = true,
onHeaderClick,
},
ref
) => {
// 根据类型初始化选中值
// const getInitialValue = (): Date | Date[] => {
// console.log(value,defaultValue,'today')
// if (typeof value === 'string' && value) {
// return new Date(value)
// }
// if (Array.isArray(value) && value.length > 0) {
// return value.map(item => new Date(item))
// }
// if (typeof defaultValue === 'string' && defaultValue) {
// return new Date(defaultValue)
// }
// if (Array.isArray(defaultValue) && defaultValue.length > 0) {
// return defaultValue.map(item => new Date(item))
// }
// const today = new Date();
// if (type === 'multiple') {
// return [today]
// }
// return today
// }
const startOfMonth = (date: Date) => new Date(date.getFullYear(), date.getMonth(), 1)
// if (typeof value === 'string' && value) {
// return new Date(value)
// }
// if (Array.isArray(value) && value.length > 0) {
// return value.map(item => new Date(item))
// }
// if (typeof defaultValue === 'string' && defaultValue) {
// return new Date(defaultValue)
// }
// if (Array.isArray(defaultValue) && defaultValue.length > 0) {
// return defaultValue.map(item => new Date(item))
// }
// const today = new Date();
// if (type === 'multiple') {
// return [today]
// }
// return today
// }
const startOfMonth = (date: Date) =>
new Date(date.getFullYear(), date.getMonth(), 1);
const [selectedValue, setSelectedValue] = useState<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]))
}
if ((typeof value === 'string' || value instanceof Date) && value) {
setSelectedValue(new Date(value))
setCurrent(new Date(value))
}
}, [value])
useImperativeHandle(ref, () => ({
jumpTo: (year: number, month: number) => {
calendarRef.current?.jumpTo(year, month)
}
}))
const handleDateChange = (newValue: any) => {
setSelectedValue(newValue)
onChange?.(newValue as any)
}
const formatHeader = (date: Date) => `${getMonth(date)}`
const handlePageChange = (data: { year: number; month: number }) => {
// 月份切换时的处理逻辑,如果需要的话
console.log('月份切换:', data)
}
const gotoMonth = (delta: number) => {
const base = current instanceof Date ? new Date(current) : new Date()
base.setMonth(base.getMonth() + delta)
const next = startOfMonth(base)
setCurrent(next)
// 同步底部 CalendarCard 的月份
try {
calendarRef.current?.jump?.(delta)
} catch (e) {
console.warn('CalendarCardRef jump 调用失败', e)
}
handlePageChange({ year: next.getFullYear(), month: next.getMonth() + 1 })
}
const handleHeaderClick = () => {
onHeaderClick && onHeaderClick(current)
setvisible(true)
}
const syncMonthTo = (anchor: Date) => {
// 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta)
const monthsDelta = (anchor.getFullYear() - current.getFullYear()) * 12 + (anchor.getMonth() - current.getMonth())
if (monthsDelta !== 0) {
gotoMonth(monthsDelta)
}
}
const renderDay = (day: any) => {
const { date, month, year} = day;
const today = new Date()
if (date === today.getDate() && month === today.getMonth() + 1 && year === today.getFullYear()) {
return (
<View class="day-container">
{date}
</View>
)
}
return date
}
const selectWeekend = () => {
const [start, end] = getWeekend()
setSelectedValue([start, end])
syncMonthTo(start)
onChange?.([start, end])
}
const selectWeek = () => {
const dayList = getWeekendOfCurrentWeek(7)
setSelectedValue(dayList)
syncMonthTo(dayList[0])
onChange?.(dayList)
}
const selectMonth = () => {
const dayList = getWeekendOfCurrentWeek(30)
setSelectedValue(dayList)
syncMonthTo(dayList[0])
onChange?.(dayList)
}
const handleMonthChange = (value: any) => {
const [year, month] = value;
const newDate = new Date(year, month - 1, 1);
setCurrent(newDate);
calendarRef.current?.jumpTo(year, month)
}
return (
<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>
</View>
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] as Date));
}
<View className={`${styles['calendar-card']} ${isBorder ? styles['border'] : ''}`}>
{/* 自定义头部显示周一到周日 */}
<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>
<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']}>
{day}
</Text>
))}
</View>
{/* NutUI CalendarCard 组件 */}
<CalendarCard
ref={calendarRef}
type={type}
value={selectedValue}
renderDay={renderDay}
onChange={handleDateChange}
onPageChange={handlePageChange}
/>
</View>
{ visible && <PopupPicker
visible={visible}
setvisible={setvisible}
value={[current.getFullYear(), current.getMonth() + 1]}
type="month"
onChange={(value) => handleMonthChange(value)}/> }
</View>
)
})
if ((typeof value === "string" || value instanceof Date) && value) {
setSelectedValue(new Date(value));
setCurrent(new Date(value));
}
}, [value]);
export default NutUICalendar
useImperativeHandle(ref, () => ({
jumpTo: (year: number, month: number) => {
calendarRef.current?.jumpTo(year, month);
},
gotoMonth,
}));
const handleDateChange = (newValue: any) => {
if (type === "range") return;
setSelectedValue(newValue);
onChange?.(newValue as any);
};
const formatHeader = (date: Date) => `${getMonth(date)}`;
const handlePageChange = (data: { year: number; month: number }) => {
// 月份切换时的处理逻辑,如果需要的话
console.log("月份切换:", data);
};
const handleDayClick = (day: any) => {
const { type, year, month, date } = day;
if (type === "next") return;
onChange?.([new Date(year, month - 1, date)]);
};
const gotoMonth = (delta: number) => {
const base = current instanceof Date ? new Date(current) : new Date();
base.setMonth(base.getMonth() + delta);
const next = startOfMonth(base);
setCurrent(next);
// 同步底部 CalendarCard 的月份
try {
calendarRef.current?.jump?.(delta);
} catch (e) {
console.warn("CalendarCardRef jump 调用失败", e);
}
handlePageChange({
year: next.getFullYear(),
month: next.getMonth() + 1,
});
};
const handleHeaderClick = () => {
onHeaderClick && onHeaderClick(current);
setvisible(true);
};
const syncMonthTo = (anchor: Date) => {
// 计算从 current 到目标 anchor 所在月份的偏移,调用 jump(delta)
const monthsDelta =
(anchor.getFullYear() - current.getFullYear()) * 12 +
(anchor.getMonth() - current.getMonth());
if (monthsDelta !== 0) {
gotoMonth(monthsDelta);
}
};
const renderDay = (day: any) => {
const { date, month, year } = day;
const today = new Date();
if (
date === today.getDate() &&
month === today.getMonth() + 1 &&
year === today.getFullYear()
) {
return <View className="day-container">{date}</View>;
}
return date;
};
const selectWeekend = () => {
const [start, end] = getWeekend();
setSelectedValue([start, end]);
syncMonthTo(start);
onChange?.([start, end]);
};
const selectWeek = () => {
const dayList = getWeekendOfCurrentWeek(7);
setSelectedValue(dayList);
syncMonthTo(dayList[0]);
onChange?.(dayList);
};
const selectMonth = () => {
const dayList = getWeekendOfCurrentWeek(30);
setSelectedValue(dayList);
syncMonthTo(dayList[0]);
onChange?.(dayList);
};
const handleMonthChange = (value: any) => {
const [year, month] = value;
const newDate = new Date(year, month - 1, 1);
setCurrent(newDate);
calendarRef.current?.jumpTo(year, month);
};
return (
<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>
</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>
<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>
</View>
</View>
<View className={styles["week-header"]}>
{["周日", "周一", "周二", "周三", "周四", "周五", "周六"].map(
(day) => (
<Text key={day} className={styles["week-day"]}>
{day}
</Text>
)
)}
</View>
{/* NutUI CalendarCard 组件 */}
<CalendarCard
ref={calendarRef}
type={type}
value={selectedValue}
renderDay={renderDay}
onChange={handleDateChange}
onPageChange={handlePageChange}
onDayClick={handleDayClick}
/>
</View>
{visible && (
<PopupPicker
visible={visible}
setvisible={setvisible}
value={[current.getFullYear(), current.getMonth() + 1]}
type="month"
onChange={(value) => handleMonthChange(value)}
/>
)}
</View>
);
}
);
export default NutUICalendar;

View File

@@ -1,177 +1,217 @@
.calendar-card {
background: #fff;
border-radius: 16px;
&.border{
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
margin-bottom: 6px;
padding: 12px 12px 8px;
background: #fff;
border-radius: 16px;
&.border {
border-radius: 12px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
margin-bottom: 6px;
padding: 12px 12px 8px;
}
}
.header {
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 4px 11px 4px;
height: 24px;
}
.date-range-container {
height: 55px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
color: #000;
padding: 0 4px;
font-size: 17.68px;
}
.date-text-placeholder {
font-family: PingFang SC;
font-weight: 600;
font-style: Semibold;
font-size: 17.68px;
color: #3C3C4399;
}
.date-text {
color: #000;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
}
.header-text {
font-size: 17px;
font-weight: 600;
color: #000;
}
.header-actions {
display: flex;
width: 60px;
.arrow-left-container {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 4px 11px 4px;
height: 24px;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
}
.header-text {
font-size: 17px;
font-weight: 600;
color: #000;
}
.header-actions {
display: flex;
width: 60px;
.arrow-left-container {
display: flex;
align-items: center;
justify-content: flex-start;
width: 50%;
flex: 1;
}
.arrow-right-container {
display: flex;
align-items: center;
justify-content: flex-end;
width: 50%;
flex: 1;
}
}
.month-arrow{
width: 8px;
height: 24px;
}
.arrow {
width: 10px;
height: 24px;
position: relative;
}
.arrow.left {
left: 9px;
transform: rotate(-180deg);
}
.week-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 0 0 4px 0;
}
.week-item {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 13px;
}
// 新增的周一到周日头部样式
.week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 8px 0;
}
.week-day {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 14px;
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px 0;
padding: 4px 0 16px;
}
.cell {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
}
.cell.empty {
opacity: 0;
}
.cell.disabled {
color: rgba(0,0,0,0.2);
}
.cell-text.selected {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
// 时间段选择样式
.cell-text.range-start {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.cell-text.range-end {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.cell-text.in-range {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.1);
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
display: flex;
gap: 12px;
}
.btn {
justify-content: flex-start;
width: 50%;
flex: 1;
height: 44px;
border-radius: 22px;
background: rgba(0,0,0,0.06);
}
.arrow-right-container {
display: flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background: #000;
color: #fff;
}
.hm-placeholder {
height: 240px;
display: flex;
align-items: center;
justify-content: center;
justify-content: flex-end;
width: 50%;
flex: 1;
}
}
.month-arrow {
width: 8px;
height: 24px;
}
.arrow {
width: 10px;
height: 24px;
position: relative;
}
.arrow.left {
left: 9px;
transform: rotate(-180deg);
}
.week-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 0 0 4px 0;
}
.week-item {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 13px;
}
// 新增的周一到周日头部样式
.week-header {
display: grid;
grid-template-columns: repeat(7, 1fr);
padding: 8px 0;
}
.week-day {
text-align: center;
color: rgba(60, 60, 67, 0.30);
font-size: 14px;
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 8px 0;
padding: 4px 0 16px;
}
.cell {
height: 44px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
position: relative;
}
.cell.empty {
opacity: 0;
}
.cell.disabled {
color: rgba(0, 0, 0, 0.2);
}
.cell-text.selected {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
// 时间段选择样式
.cell-text.range-start {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.cell-text.range-end {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.9);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
}
.cell-text.in-range {
width: 44px;
height: 44px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.1);
color: #000;
display: flex;
align-items: center;
justify-content: center;
}
.footer {
display: flex;
gap: 12px;
}
.btn {
flex: 1;
height: 44px;
border-radius: 22px;
background: rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
}
.btn.primary {
background: #000;
color: #fff;
}
.hm-placeholder {
height: 240px;
display: flex;
align-items: center;
justify-content: center;
}
// CalendarRange 组件样式
.calendar-range {
@@ -234,50 +274,63 @@
flex: 1;
}
// 隐藏 CalendarCard 默认头部
:global {
.nut-calendarcard {
.nut-calendarcard-header {
display: none !important;
}
.nut-calendarcard-content{
.nut-calendarcard-days{
&:first-child{
.nut-calendarcard-content {
.nut-calendarcard-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
justify-items: center;
&:first-child {
display: none !important;
}
}
}
}
.nut-calendarcard-day{
margin-bottom:0px!important;
.nut-calendarcard-day {
margin-bottom: 0px !important;
height: 44px;
width: 44px!important;
&.active{
background-color: #000!important;
color: #fff!important;
width: 44px !important;
&.active {
background-color: #000 !important;
color: #fff !important;
height: 44px;
border-radius: 22px!important;
border-radius: 22px !important;
display: flex;
align-items: center;
justify-content: center;
width: 44px!important;
font-size: 24px!important;
.day-container{
background-color: transparent!important;
width: 44px !important;
font-size: 24px !important;
.day-container {
background-color: transparent !important;
}
}
&.weekend{
color: rgb(0,0,0)!important;
&.active{
color: #fff!important;
&.weekend {
color: rgb(0, 0, 0) !important;
&.active {
color: #fff !important;
}
}
}
.nut-calendarcard-day-inner{
.nut-calendarcard-day-inner {
font-size: 20px;
.day-container{
.day-container {
background-color: #f5f5f5;
border-radius: 22px;
width: 44px;
@@ -288,5 +341,22 @@
font-size: 24px;
}
}
}
.nut-calendarcard-day.start,
.nut-calendarcard-day.end {
background-color: #000;
border-radius: 50%;
color: #fff !important;
}
.nut-calendarcard-day-inner .day-container {
background-color: unset;
color: unset;
}
.nut-calendarcard-day.mid {
background-color: rgba(0, 0, 0, 0.12);
color: #000;
border-radius: 50%;
}
}

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 = [],
onConfirm,
const PopupPicker = ({
visible,
setvisible,
value = [],
img,
onConfirm,
onChange,
options = [],
type = null
type = null,
}: PickerProps) => {
const [defaultValue, setDefaultValue] = useState<(string | number)[]>([])
const [defaultOptions, setDefaultOptions] = useState<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])
// useEffect(() => {
// if (value.length > 0 && defaultOptions.length > 0) {
// setDefaultValue([...value])
// }
// }, [value, defaultOptions])
}, [type]);
// useEffect(() => {
// if (value.length > 0 && defaultOptions.length > 0) {
// setDefaultValue([...value])
// }
// }, [value, defaultOptions])
return (
<>
<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');
console.log(type, "type");
if (type === "ai") {
setAiImportVisible(true);
setIsVisible(false);
return;
}
Taro.navigateTo({
url: `/publish_pages/publishBall/index?type=${type}`
})
setIsVisible(false)
}
url: `/publish_pages/publishBall/index?type=${type}`,
});
setIsVisible(false);
};
const handleAiImportClose = () => {
setAiImportVisible(false);
};
const handleManualPublish = () => {
Taro.navigateTo({
url: "/publish_pages/publishBall/index?type=individual",
});
};
return (
<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>
<View
className={`${styles.menuItem} ${styles.aiItem}`}
onClick={() => handleMenuItemClick("ai")}
>
<View className={styles.menuContent}>
<Text className={styles.menuTitle}></Text>
<Text className={styles.menuDesc}></Text>
<View className={styles.menuTitle}>
<View className={styles.menuArrow}>
<Image
src={images.ICON_ARROW_RIGHT_WHITE}
className={styles.img}
/>
</View>
</View>
<Text className={styles.menuDesc}>
/
</Text>
</View>
<View className={styles.menuArrow}>
<Image src={images.ICON_ARROW_RIGHT} className={styles.img} />
<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'>
<Text className='time-text'>{getDate(value.start_time)}</Text>
{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,15 +269,61 @@
color: #000000;
}
}
.button_edit {
font-family: 'PingFang SC';
font-weight: 500;
font-size: 11px;
line-height: 1.8em;
letter-spacing: -2.1%;
color: rgba(60, 60, 67, 0.6);
display: flex;
align-items: center;
position: relative;
padding-right: 20px;
&::before,
&::after {
content: '';
width: 6px;
height: 1px;
display: inline-block;
background-color: rgba(60, 60, 67, 0.6);
position: absolute;
right: 12px;
transform: rotate(45deg);
margin-left: 4px;
}
&::after {
transform: rotate(-45deg);
translate: 4.2px 0;
}
}
}
.bio_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.571em;
color: rgba(0, 0, 0, 0.65);
white-space: pre-line;
.personal_profile {
.personal_profile_edit {
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
.edit_icon {
width: 16px;
height: 16px;
}
}
.bio_text {
font-family: 'PingFang SC';
font-weight: 400;
font-size: 14px;
line-height: 1.571em;
color: rgba(0, 0, 0, 0.65);
white-space: pre-line;
}
}
}
}

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}>
<Image
className="tag_icon"
src={require('../../static/userInfo/edit.svg')}
/> </View>
{is_current_user && (
<View className="tag_item" onClick={on_edit}>
<Image
className="tag_icon"
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">
<View className="tag_item">
{user_info.gender === "0" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/male.svg')}
/>
)}
{user_info.gender === "1" && (
<Image
className="tag_icon"
src={require('../../static/userInfo/female.svg')}
/>
)}
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.ntrp_level || '未设置'}</Text>
</View>
<View className="tag_item">
<Text className="tag_text">{user_info.occupation || '未设置'}</Text>
</View>
<View className="tag_item">
<Image
className="tag_icon"
src={require('../../static/userInfo/location.svg')}
/>
<Text className="tag_text">{user_info.location || '未设置'}</Text>
</View>
{user_info.gender ? (
<View className="tag_item">
{user_info.gender === "0" && (
<Image
className="tag_icon"
src={require("../../static/userInfo/male.svg")}
/>
)}
{user_info.gender === "1" && (
<Image
className="tag_icon"
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">{`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.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">
<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>
<Text className="bio_text">{user_info.personal_profile}</Text>
</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,21 +586,28 @@ 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>
);
};
};

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