Files
mini-programs/src/order_pages/orderDetail/index.tsx
2026-02-07 18:07:33 +08:00

732 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef } from "react";
import { View, Text, Button, Image } from "@tarojs/components";
import { Dialog } from "@nutui/nutui-react-taro";
import Taro, { useDidShow, useRouter } from "@tarojs/taro";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import classnames from "classnames";
import orderService, {
CancelType,
GameOrderRes,
OrderStatus,
refundTextMap,
} from "@/services/orderService";
import { debounce } from "@tarojs/runtime";
import {
payOrder,
delay,
calculateDistance,
getCurrentLocation,
getOrderStatus,
generateOrderActions,
isPhoneNumber,
} from "@/utils";
import { getStorage, setStorage } from "@/store/storage";
import { useGlobalStore } from "@/store/global";
import { useOrder } from "@/store/orderStore";
import detailService, { GameData } from "@/services/detailService";
import { withAuth, RefundPopup, GeneralNavbar } from "@/components";
import { OSS_BASE } from "@/config/api";
import img from "@/config/images";
import CustomerIcon from "@/static/order/customer.svg";
import { handleCustomerService } from "@/services/userService";
import { requireLoginWithPhone } from "@/utils/helper";
import { DECLAIMER } from "./config";
import styles from "./index.module.scss";
dayjs.locale("zh-cn");
const gameNoticeMap = new Map([
[
"pending",
{ title: "球局暂未开始", content: "球局开始前2小时我们将通过短信通知你" },
],
[
"pendinging",
{
title: "球局即将开始,请按时抵达球局",
content: "球局开始前2小时我们将通过短信通知你",
},
],
["progress", { title: "球局已开始", content: "友谊第一,比赛第二" }],
["finish", { title: "球局已结束", content: "" }],
]);
function genGameNotice(order_status, start_time) {
const startTime = dayjs(start_time);
let key = "";
if (order_status === OrderStatus.PENDING) {
return {};
}
if (order_status === OrderStatus.FINISHED) {
key = "finish";
}
const leftHour = startTime.diff(dayjs(), "hour");
const start = startTime.isBefore(dayjs());
if (start) {
key = "progress";
} else if (leftHour > 2) {
key = "pending";
} else if (leftHour < 2) {
key = "pendinging";
}
return gameNoticeMap.get(key) || {};
}
function GameInfo(props) {
const { detail, currentLocation, orderDetail, init } = props;
const { order_status, refund_status, amount } = orderDetail;
const {
latitude,
longitude,
location,
location_name,
start_time,
end_time,
weather,
title,
} = detail || {};
const [{ iconDay, tempMax, tempMin }] = weather || [{}];
const refundRef = useRef(null);
const openMap = () => {
Taro.openLocation({
latitude, // 纬度(必填)
longitude, // 经度(必填)
name: location_name, // 位置名(可选)
address: location, // 地址详情(可选)
scale: 15, // 地图缩放级别1-28
});
};
const [c_latitude, c_longitude] = currentLocation;
const distance =
c_latitude + c_longitude === 0
? 0
: calculateDistance(c_latitude, c_longitude, latitude, longitude) / 1000;
const startTime = dayjs(start_time);
const endTime = dayjs(end_time);
const game_length = Number(
(endTime.diff(startTime, "minutes") / 60).toFixed()
);
const startMonth = startTime.format("M");
const startDay = startTime.format("D");
const theDayOfWeek = startTime.format("dddd");
const startDate = `${startMonth}${startDay}${theDayOfWeek}`;
const gameRange = `${startTime.format("HH:mm")} - ${endTime.format("HH:mm")}`;
const orderStatus = getOrderStatus(orderDetail);
const gameNotice = genGameNotice(order_status, start_time);
function handleViewGame(gameId) {
Taro.navigateTo({
url: `/game_pages/detail/index?id=${gameId}&from=orderList`,
});
}
async function handleDeleteOrder(item) {
const { order_id } = item;
const onCancel = () => {
Dialog.close("detailCancelOrder");
};
const onConfirm = async () => {
try {
const deleteRes = await orderService.deleteOrder({
order_id,
});
if (deleteRes.code !== 0) {
throw new Error(deleteRes.message);
}
Taro.showToast({
title: "删除成功",
icon: "none",
});
delay(2000);
Taro.redirectTo({ url: "/order_pages/orderList/index" });
} catch (e) {
Taro.showToast({
title: e.message,
icon: "error",
});
} finally {
Dialog.close("detailCancelOrder");
}
};
Dialog.open("detailCancelOrder", {
title: "确定删除订单吗?",
content: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
),
onConfirm,
onCancel,
});
}
async function handleCancelOrder(item) {
const { order_no } = item;
const onCancel = () => {
Dialog.close("detailCancelOrder");
};
const onConfirm = async () => {
try {
const cancelRes = await orderService.cancelUnpaidOrder({
order_no,
cancel_reason: "用户主动取消",
});
if (cancelRes.code !== 0) {
throw new Error(cancelRes.message);
}
init();
Taro.showToast({
title: "取消成功",
icon: "none",
});
} catch (e) {
Taro.showToast({
title: e.message,
icon: "error",
});
} finally {
Dialog.close("detailCancelOrder");
}
};
Dialog.open("detailCancelOrder", {
title: "确定取消订单吗?",
content: (
<View className={styles.cancelTip}>
<Text></Text>
</View>
),
footer: (
<View className={styles.dialogFooter}>
<Button className={styles.cancel} onClick={onCancel}>
</Button>
<Button className={styles.confirm} type="primary" onClick={onConfirm}>
</Button>
</View>
),
onConfirm,
onCancel,
});
}
function handleQuit(item) {
if (refundRef.current) {
refundRef.current.show(item, (result) => {
if (result) {
init();
}
});
}
}
return (
<View className={styles.gameInfoContainer}>
{["refund", "progress", "expired"].includes(orderStatus) && (
<View className={styles.paidInfo}>
{refundTextMap.get(refund_status)} ¥ {amount}
</View>
)}
{["progress", "expired"].includes(orderStatus) &&
order_status !== OrderStatus.PENDING && (
<View className={styles.gameStatus}>
<Text className={styles.statusText}>{gameNotice.title}</Text>
{gameNotice.content && <Text>{gameNotice.content}</Text>}
</View>
)}
{!orderDetail.order_id && (
<View className={styles.gameStatus}>
<Text className={styles.statusText}>{title}</Text>
</View>
)}
<View className={styles.gameInfo}>
{/* Date and Weather */}
<View className={styles.gameInfoDateWeather}>
{/* Calendar and Date time */}
<View className={styles.gameInfoDateWeatherCalendarDate}>
{/* Calendar */}
<View className={styles.gameInfoDateWeatherCalendarDateCalendar}>
<View className={styles.month}>{startMonth}</View>
<View className={styles.day}>{startDay}</View>
</View>
{/* Date time */}
<View className={styles.gameInfoDateWeatherCalendarDateDate}>
<View className={styles.date}>{startDate}</View>
<View className={styles.venueTime}>
{gameRange} {game_length}
</View>
</View>
</View>
<View className={styles.weather}>
{/* Weather icon */}
<View className={styles.weatherIcon}>
{/*<Image className="weather-icon" src={img.ICON_WEATHER_SUN} />*/}
<i className={`qi-${iconDay}`}></i>
</View>
{/* Weather text and temperature */}
<View className={styles.temperature}>
{tempMin && tempMax && (
<Text>
{tempMin} - {tempMax}
</Text>
)}
</View>
</View>
</View>
{/* Place */}
<View className={styles.gameInfoPlace}>
{/* venue location message */}
<View className={styles.locationMessage}>
{/* location icon */}
<View className={styles.locationMessageIcon}>
<Image
className={styles.locationMessageIconImage}
src={`${OSS_BASE}/front/ball/images/3ee5c89c-fe58-4a56-9471-1295da09c743.png`}
/>
</View>
{/* location message */}
<View className={styles.locationMessageText}>
{/* venue name and distance */}
<View
className={styles.locationMessageTextNameDistance}
onClick={openMap}
>
<Text>{location_name || "-"}</Text>
{distance ? (
<>
<Text>·</Text>
<Text>{distance.toFixed(1)}km</Text>
</>
) : null}
<Image
className={styles.locationMessageTextNameDistanceArrow}
src={img.ICON_DETAIL_ARROW_RIGHT}
/>
</View>
{/* venue address */}
<View className={styles.locationMessageTextAddress}>
<Text>{location || "-"}</Text>
</View>
</View>
</View>
</View>
</View>
{/* Action bar */}
{orderDetail.order_id && (
<View className={styles.gameInfoActions}>
{generateOrderActions(
orderDetail,
{
handleDeleteOrder,
handleCancelOrder,
handleQuit,
handlePayNow: () => {},
handleViewGame,
},
"detail"
)?.map((obj) => (
<View className={classnames(styles.button, styles[obj.className])}>
<Text className={styles.buttonText}>{obj.text}</Text>
<Button className={styles.transparentButton} onClick={obj.action}>
{obj.text}
</Button>
</View>
))}
<View className={styles.customer} onClick={handleCustomerService}>
<Image className={styles.customerIcon} src={CustomerIcon} />
<Text></Text>
</View>
</View>
)}
<Dialog id="detailCancelOrder" />
<RefundPopup ref={refundRef} />
</View>
);
}
function handleCopy(msg) {
Taro.setClipboardData({
data: msg,
});
}
function OrderMsg(props) {
const { detail, orderDetail, checkOrderInfo } = props;
const {
start_time,
end_time,
location,
location_name,
wechat_contact,
price,
} = detail;
const { order_no, registrant_phone: registrant_phone_from_order } =
orderDetail;
const {
order_info: { registrant_phone: registrant_phone_from_check_order } = {},
} = checkOrderInfo || {};
const registrant_phone =
registrant_phone_from_order || registrant_phone_from_check_order;
const startTime = dayjs(start_time);
const endTime = dayjs(end_time);
const startDate = startTime.format("YYYY年M月D日");
const gameRange = `${startTime.format("HH:mm")} - ${endTime.format("HH:mm")}`;
const summary = [
{
title: "时间",
content: `${startDate} ${gameRange}`,
},
{
title: "地址",
content: (
<View className={styles.location}>
<Text>{location}</Text>
<Text>{location_name}</Text>
</View>
),
},
{
title: "报名人电话",
content: registrant_phone ? (
<Text
selectable={true} // 支持长按复制
style={{
color: "#007AFF",
cursor: "pointer",
}}
onClick={() => {
Taro.makePhoneCall({ phoneNumber: registrant_phone });
}}
>
{registrant_phone}
</Text>
) : (
"-"
),
},
{
title: "组织人微信号",
content: wechat_contact || "-",
},
{
title: "组织人电话",
content:
wechat_contact && isPhoneNumber(wechat_contact) ? (
<Text
selectable={true} // 支持长按复制
style={{
color: "#007AFF",
// textDecoration: "underline",
cursor: "pointer",
}}
onClick={() => {
Taro.makePhoneCall({ phoneNumber: wechat_contact });
}}
>
{wechat_contact}
</Text>
) : (
"-"
),
},
{
title: "费用",
content: `${price} 元 / 人`,
},
...(order_no
? [
{
title: "订单号",
content: (
<View className={styles.orderNo}>
<Text>{order_no}</Text>
<Text
className={styles.copy}
onClick={handleCopy.bind(null, order_no)}
>
</Text>
</View>
),
},
]
: []),
];
return (
<View className={styles.orderSummary}>
<View className={styles.moduleTitle}>
<Text></Text>
</View>
{/* 订单信息摘要 */}
<View className={styles.summaryList}>
{summary.map((item, index) => (
<View key={index} className={styles.summaryItem}>
<Text className={styles.title}>{item.title}</Text>
<View className={styles.content}>{item.content}</View>
</View>
))}
</View>
</View>
);
}
function RefundPolicy(props) {
const { refund_policy = [] } = props;
const current = dayjs();
const policyList = [
{
time: "申请退款时间",
rule: "退款规则",
},
...refund_policy.map((item, index) => {
const isLast = index === refund_policy.length - 1;
const theTimeObj = dayjs(
isLast
? refund_policy.at(-2).deadline_formatted
: item.deadline_formatted
);
const year = theTimeObj.format("YYYY");
const month = theTimeObj.format("M");
const day = theTimeObj.format("D");
const time = theTimeObj.format("HH:mm");
return {
time: `${year}${month}${day}${time} ${isLast ? "后" : "前"}`,
rule: item.refund_rule,
beforeCurrent: isLast ? true : current.isBefore(theTimeObj),
};
}),
];
const targetIndex = policyList.findIndex((item) => item.beforeCurrent);
return (
<View className={styles.refundPolicy}>
<View className={styles.moduleTitle}>
<Text>退</Text>
</View>
{/* 订单信息摘要 */}
<View className={styles.policyList}>
{policyList.map((item, index) => (
<View
key={index}
className={classnames(
styles.policyItem,
targetIndex > index && index !== 0 ? styles.pastItem : "",
targetIndex === index ? styles.currentItem : ""
)}
>
<View className={styles.time}>
{targetIndex === index && (
<View className={styles.currentTag}>
<Text></Text>
</View>
)}
<Text>{item.time}</Text>
</View>
<View className={styles.rule}>{item.rule}</View>
</View>
))}
</View>
</View>
);
}
function Disclaimer() {
return (
<View className={styles.declaimer}>
<Text className={styles.title}></Text>
<Text className={styles.content}>{DECLAIMER}</Text>
</View>
);
}
const OrderCheck = () => {
const { params } = useRouter();
const { id: stringId, gameId: stringGameId } = params;
const [id, gameId] = [Number(stringId), Number(stringGameId)];
const [detail, setDetail] = useState<GameData | {}>({});
const [location, setLocation] = useState<number[]>([0, 0]);
const [checkOrderInfo, setCheckOrderInfo] = useState<GameOrderRes>();
const [orderDetail, setOrderDetail] = useState({});
const { paying, setPaying } = useOrder();
useDidShow(() => {
init();
});
async function init() {
let gameDetail = {};
if (id) {
const res = await orderService.getOrderDetail(id);
if (res.code === 0) {
setOrderDetail(res.data);
gameDetail = res.data.game_info;
}
} else if (gameId) {
const res = await detailService.getDetail(gameId);
if (res.code === 0) {
gameDetail = res.data;
}
checkOrder(gameId);
}
setDetail(gameDetail);
const location = await getCurrentLocation();
setLocation([location.latitude, location.longitude]);
}
async function checkOrder(gid) {
const orderRes = await orderService.getCheckOrderInfo(gid);
setCheckOrderInfo(orderRes.data);
}
async function getPaymentParams() {
// 检查登录状态和手机号(创建订单前检查)
if (!requireLoginWithPhone()) {
throw new Error("请先绑定手机号");
}
const unPaidRes = await orderService.getUnpaidOrder(detail.id);
if (unPaidRes.code === 0 && unPaidRes.data.has_unpaid_order) {
return unPaidRes.data.payment_params;
}
const createOrderRes = await orderService.createOrder(detail.id);
if (createOrderRes.code === 0) {
return createOrderRes.data.payment_params;
}
throw new Error("支付调用失败");
}
//TODO: get order msg from id
const handlePay = debounce(async () => {
// 检查登录状态和手机号
if (!requireLoginWithPhone()) {
return; // 未登录或未绑定手机号,已跳转到登录页
}
setPaying(true);
let payment_params = {};
try {
payment_params = await getPaymentParams();
if (!id) {
setStorage("backFlag", "1");
Taro.redirectTo({
url: `/order_pages/orderDetail/index?id=${payment_params.order_id}`,
});
}
await payOrder(payment_params);
Taro.showToast({
title: "支付成功",
icon: "success",
});
const backFlag = getStorage("backFlag");
if (backFlag === "1") {
setStorage("backFlag", "0");
Taro.navigateBack();
}
// Taro.navigateBack({
// delta: 1,
// });
} catch (error) {
Taro.showToast({
title: error.message,
icon: "none",
});
} finally {
setStorage("backFlag", "0");
init();
setPaying(false);
}
}, 300);
if (!id && !gameId) {
return (
<View className={styles.errorTip}>
<Text></Text>
<Button
type="warn"
onClick={() => {
Taro.redirectTo({ url: "/main_pages/index" });
}}
>
</Button>
</View>
);
}
const { order_status, cancel_type } = orderDetail;
const { statusNavbarHeightInfo } = useGlobalStore();
const { totalHeight } = statusNavbarHeightInfo;
return (
<View
className={styles.container}
style={{ paddingTop: `${totalHeight + 8}px` }}
>
<GeneralNavbar
title={id ? "订单详情" : "加入活动"}
titleClassName={styles.titleClassName}
className={styles.navbar}
backgroundColor="#fafafa"
/>
{/* Game Date and Address */}
<GameInfo
detail={detail}
orderDetail={orderDetail}
currentLocation={location}
init={init}
/>
{/* Order message */}
<OrderMsg
detail={detail}
orderDetail={orderDetail}
checkOrderInfo={checkOrderInfo}
/>
{/* Refund policy */}
<RefundPolicy
refund_policy={
checkOrderInfo?.refund_policy || orderDetail?.refund_policy || []
}
/>
{/* Disclaimer */}
<Disclaimer />
{(!id ||
(order_status === OrderStatus.PENDING &&
cancel_type === CancelType.NONE)) && (
<Button
className={styles.payButton}
disabled={paying}
onClick={handlePay}
loading={paying}
>
{paying
? "支付中..."
: `${order_status === OrderStatus.PENDING ? "继续" : "确认"}支付`}
</Button>
)}
</View>
);
};
export default withAuth(OrderCheck);