Files
mini-programs/src/other_pages/ntrp-evaluate/index.tsx

874 lines
27 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 { useState, useEffect, useRef, useId, useMemo } from "react";
import { View, Text, Image, Button, Canvas } from "@tarojs/components";
import Taro, { useRouter, useShareAppMessage } from "@tarojs/taro";
import dayjs from "dayjs";
import classnames from "classnames";
import { withAuth, RadarChart, RadarChartV2 } from "@/components";
import evaluateService, {
LastTimeTestResult,
Question,
TestResultData,
StageType,
} from "@/services/evaluateService";
import { useUserInfo, useUserActions } from "@/store/userStore";
import { useEvaluate, EvaluateScene } from "@/store/evaluateStore";
import { useGlobalState } from "@/store/global";
import { delay, getCurrentFullPath } from "@/utils";
import { formatNtrpDisplay } from "@/utils/helper";
import { waitForAuthInit } from "@/utils/authInit";
// import httpService from "@/services/httpService";
import DetailService from "@/services/detailService";
import { OSS_BASE } from "@/config/api";
import CloseIcon from "@/static/ntrp/ntrp_close_icon.svg";
import DocCopy from "@/static/ntrp/ntrp_doc_copy.svg";
import ArrowRight from "@/static/ntrp/ntrp_arrow_right.svg";
import ArrowBack from "@/static/ntrp/ntrp_arrow_back.svg";
import CircleChecked from "@/static/ntrp/ntrp_circle_checked.svg";
import CircleUnChecked from "@/static/ntrp/ntrp_circle_unchecked.svg";
import WechatIcon from "@/static/ntrp/ntrp_wechat.svg";
import DownloadIcon from "@/static/ntrp/ntrp_download.svg";
import ReTestIcon from "@/static/ntrp/ntrp_re-action.svg";
import styles from "./index.module.scss";
const sourceTypeToTextMap = new Map([
[EvaluateScene.detail, "继续加入球局"],
[EvaluateScene.publish, "继续发布球局"],
]);
function adjustRadarLabels(
source: [string, number][],
topK: number = 4, // 默认挑前4个最长的标签保护
): [string, number][] {
if (source.length === 0) return source;
// 复制并按长度排序(降序)
let sorted = [...source].sort((a, b) => b[0].length - a[0].length);
// 取出前 K 个最长标签
let protectedLabels = sorted.slice(0, topK);
// 其他标签(保持原始顺序,但排除掉 protected
let protectedSet = new Set(protectedLabels.map(([l]) => l));
let others = source.filter(([l]) => !protectedSet.has(l));
let n = source.length;
let result: ([string, number] | undefined)[] = new Array(n);
// 放首尾
result[0] = protectedLabels.shift() || others.shift();
result[n - 1] = protectedLabels.shift() || others.shift();
// 放中间(支持偶数两个位置)
if (n % 2 === 0) {
let mid1 = n / 2 - 1;
let mid2 = n / 2;
result[mid1] = protectedLabels.shift() || others.shift();
result[mid2] = protectedLabels.shift() || others.shift();
} else {
let mid = Math.floor(n / 2);
result[mid] = protectedLabels.shift() || others.shift();
}
// 把剩余标签按顺序塞进空位
let pool = [...protectedLabels, ...others];
for (let i = 0; i < n; i++) {
if (!result[i]) result[i] = pool.shift();
}
return result as [string, number][];
}
function isOnCancelEmpty(onCancelFunc) {
if (typeof onCancelFunc !== "function") {
console.log("onCancel 不是函数");
return false;
}
try {
const funcString = onCancelFunc.toString().trim();
// 常见空函数模式
const emptyFunctionPatterns = [
"functiononCancel(){}",
"function onCancel() {}",
"onCancel(){}",
"()=>{}",
"functiononCancel(){ }",
"() => {}",
];
const normalized = funcString.replace(/\s/g, "");
return emptyFunctionPatterns.includes(normalized);
} catch (error) {
console.error("检查 onCancel 函数时出错:", error);
return false;
}
}
function CommonGuideBar(props) {
const { title, confirm } = props;
const { onCancel } = useEvaluate();
const { statusNavbarHeightInfo } = useGlobalState();
const { statusBarHeight, navBarHeight } = statusNavbarHeightInfo;
// const userInfo = useUserInfo()
// 判断是否从分享链接进入页面栈只有1个页面时说明是从分享直接进入的
const isFromShare = () => {
try {
const pages = (Taro as any).getCurrentPages();
return pages && pages.length === 1;
} catch {
return false;
}
};
function handleClose() {
// 如果是从分享链接进入,直接跳转首页
if (isFromShare()) {
(Taro as any).redirectTo({ url: "/main_pages/index" });
return;
}
// 否则执行原来的逻辑(跳转球局或执行 onCancel
//TODO: 二次确认
if (confirm) {
}
try {
console.log(onCancel, isOnCancelEmpty(onCancel));
if (isOnCancelEmpty(onCancel)) {
(Taro as any).redirectTo({ url: "/main_pages/index" });
}
onCancel();
} catch {
(Taro as any).redirectTo({ url: "/main_pages/index" });
}
}
return (
<View
className={styles.header}
style={{
height: navBarHeight + "px",
paddingTop: statusBarHeight + "px",
}}
>
<View className={styles.closeIcon} onClick={handleClose}>
<Image className={styles.closeImg} src={CloseIcon} />
</View>
<View className={styles.title}>
<Text>{title}</Text>
</View>
</View>
);
}
function Intro() {
const [ntrpData, setNtrpData] = useState<LastTimeTestResult>();
const userInfo = useUserInfo();
const { fetchUserInfo } = useUserActions();
const [ready, setReady] = useState(false);
const { setCallback } = useEvaluate();
const { last_test_result = null } = ntrpData || {};
const { ntrp_level, create_time, id, level_description } =
last_test_result || {};
const lastTestTime = create_time
? dayjs(create_time).format("YYYY年M月D日")
: "";
useEffect(() => {
const init = async () => {
// 先等待静默登录完成
await waitForAuthInit();
// 然后再调用接口
await getLastResult();
};
init();
}, []);
async function getLastResult() {
const res = await evaluateService.getLastResult();
if (res.code === 0) {
setNtrpData(res.data);
if (res.data.has_ntrp_level) {
// 确保用户信息已加载
if (!userInfo || Object.keys(userInfo).length === 0) {
await fetchUserInfo();
}
}
setReady(true);
}
}
if (!ready) {
return "";
}
function handleNext(type) {
if (!id) {
setCallback({
type: EvaluateScene.share,
next: () => {
Taro.redirectTo({ url: "/main_pages/index" });
},
onCancel: () => {
Taro.redirectTo({ url: "/main_pages/index" });
},
});
}
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${type}${
type === StageType.RESULT ? `&id=${id}` : ""
}`,
});
}
return (
<View
className={styles.introContainer}
style={{
backgroundImage: `url(${OSS_BASE}/front/ball/images/215f1ce1-be52-4a92-8250-5a4a69e7f2b3.png)`,
}}
>
<CommonGuideBar />
{ntrpData?.has_test_record ? (
<View className={styles.result}>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo?.avatar_url || ""}
mode="aspectFill"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image
className={styles.docImage}
src={DocCopy}
mode="aspectFill"
/>
</View>
</View>
{/* tip */}
<View className={styles.tip}>
<Image
className={styles.tipImage}
src={`${OSS_BASE}/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png`}
mode="aspectFit"
/>
</View>
<View
className={styles.lastResult}
onClick={() => handleNext(StageType.RESULT)}
>
<View className={styles.tipAndTime}>
<Text></Text>
<Text>{lastTestTime}</Text>
</View>
<View className={styles.levelWrap}>
<Text>NTRP</Text>
<Text className={styles.level}>
{formatNtrpDisplay(ntrp_level || "")}
</Text>
</View>
<View className={styles.slogan}>
<Text>{level_description}</Text>
</View>
</View>
<View className={styles.actions}>
<View className={classnames(styles.buttonWrap, styles.customBtn)}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
<View
className={classnames(styles.customBtnCover, styles.primary)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</View>
</View>
<View className={classnames(styles.buttonWrap, styles.customBtn)}>
<Button
className={styles.button}
onClick={() => handleNext(StageType.RESULT)}
>
<Text>使</Text>
</Button>
<View className={styles.customBtnCover}>
<Text>使</Text>
</View>
</View>
</View>
</View>
) : (
<View className={styles.guide}>
{/* tip */}
<View className={styles.tip}>
<Image
className={styles.tipImage}
src={`${OSS_BASE}/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png`}
mode="aspectFit"
/>
</View>
{/* radar */}
<View className={styles.radar}>
<Image
className={styles.radarImage}
src={`${OSS_BASE}/front/ball/images/a2e1b639-82a9-4ab8-b767-8605556eafcb.png`}
mode="aspectFit"
/>
</View>
<View className={styles.desc}>
<Text>
NTRPNational Tennis Rating
Program
</Text>
</View>
<View className={styles.actions}>
<View className={styles.buttonWrap}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
</View>
</View>
</View>
)}
</View>
);
}
function Test() {
const [disabled, setDisabled] = useState(false);
const [index, setIndex] = useState(0);
const [questions, setQuestions] = useState<
(Question & { choosen: number })[]
>([]);
const startTimeRef = useRef<number>(0);
useEffect(() => {
startTimeRef.current = Date.now();
getQUestions();
}, []);
useEffect(() => {
setDisabled(questions[index]?.choosen === -1);
}, [index, questions]);
const doneFlag = useMemo(() => {
const [q1, q2, q3] = questions;
return [q1, q2, q3].every((q) => q?.choosen === 0);
}, [questions]);
async function getQUestions() {
const res = await evaluateService.getQuestions();
if (res.code === 0) {
setQuestions(res.data.map((item) => ({ ...item, choosen: -1 })));
}
}
function handleSelect(i) {
setQuestions((prev) =>
prev.map((item, pIndex) => ({
...item,
...(pIndex === index ? { choosen: i } : {}),
})),
);
}
async function handleSubmit() {
setDisabled(true);
try {
const res = await evaluateService.submit({
answers: questions
.filter((item) => item.choosen >= 0)
.map((item) => ({
question_id: item.id,
answer_index: item.choosen,
})),
test_duration: (Date.now() - startTimeRef.current) / 1000,
});
if (res.code === 0) {
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.RESULT}&id=${res.data.record_id}`,
});
}
} catch (e) {
Taro.showToast({ title: e.message, icon: "error" });
} finally {
setDisabled(false);
}
}
function handIndexChange(direction) {
if (disabled && direction > 0) {
return;
}
if ((index === questions.length - 1 || doneFlag) && direction > 0) {
handleSubmit();
return;
}
setIndex((prev) => prev + direction);
}
const question = questions[index];
if (!question) {
return "";
}
return (
<View
className={styles.testContainer}
style={{
backgroundImage: `url(${OSS_BASE}/front/ball/images/215f1ce1-be52-4a92-8250-5a4a69e7f2b3.png)`,
}}
>
<CommonGuideBar confirm title={`${index + 1} / ${questions.length}`} />
<View className={styles.bar}>
<View
className={styles.progressBar}
style={{ width: `${100 * ((index + 1) / questions.length)}%` }}
/>
</View>
<View className={styles.notice}>
<Text>3</Text>
</View>
<View className={styles.question}>
<View className={styles.content}>{question.question_content}</View>
<View className={styles.options}>
{question.options.map((item, i) => {
const checked = question.choosen === i;
return (
<View
key={i}
className={styles.optionItem}
onClick={() => handleSelect(i)}
>
<View className={styles.optionText}>{item.text}</View>
<View className={styles.optionIcon}>
<Image
className={styles.icon}
src={checked ? CircleChecked : CircleUnChecked}
/>
</View>
</View>
);
})}
</View>
</View>
<View className={styles.actions}>
<View
className={classnames(styles.next, disabled ? styles.disabled : "")}
onClick={() => handIndexChange(1)}
>
<Button className={styles.nextBtn} type="primary">
{index === questions.length - 1 || doneFlag ? "完成测试" : "继续"}
</Button>
</View>
{index !== 0 && (
<View className={styles.prev} onClick={() => handIndexChange(-1)}>
<Image className={styles.backIcon} src={ArrowBack} />
<Text></Text>
</View>
)}
</View>
</View>
);
}
function Result() {
const { params } = useRouter();
const { id } = params;
const userInfo = useUserInfo();
const { fetchUserInfo, updateUserInfo } = useUserActions();
const { type, next, clear } = useEvaluate();
const radarRef = useRef<any>();
const radarV2Ref = useRef<any>();
const [result, setResult] = useState<TestResultData>();
const [radarData, setRadarData] = useState<
[propName: string, prop: number][]
>([]);
const [qrCodeUrl, setQrCodeUrl] = useState<string>("");
useEffect(() => {
const init = async () => {
// 先等待静默登录完成
await waitForAuthInit();
// 然后再调用接口
await getResultById();
// 确保用户信息已加载
if (!userInfo || Object.keys(userInfo).length === 0) {
await fetchUserInfo();
}
// 获取二维码
await fetchQRCode();
};
init();
}, [id]);
// 获取二维码 - 调用接口生成分享二维码
async function fetchQRCode() {
try {
// 调用接口生成二维码,分享当前页面
const qrCodeUrlRes = await DetailService.getQrCodeUrl({
page: "other_pages/ntrp-evaluate/index",
scene: `stage=${StageType.INTRO}`,
});
setQrCodeUrl(qrCodeUrlRes.data.ossPath);
// if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) {
// // 将 base64 转换为临时文件路径
// const tempFilePath = await base64ToTempFilePath(
// qrCodeUrlRes.data.qr_code_base64
// );
// setQrCodeUrl(tempFilePath);
// }
} catch (error) {
console.error("获取二维码失败:", error);
}
}
async function getResultById() {
const res = await evaluateService.getTestResult({ record_id: Number(id) });
if (res.code === 0) {
setResult(res.data);
const sortOrder = res.data.sort || [];
const abilities = res.data.radar_data.abilities;
const sortedKeys = sortOrder.filter((k) => k in abilities);
const remainingKeys = Object.keys(abilities).filter(
(k) => !sortOrder.includes(k),
);
const allKeys = [...sortedKeys, ...remainingKeys];
let radarData: [string, number][] = allKeys.map((key) => [
key,
Math.min(
100,
Math.floor(
(abilities[key].current_score / abilities[key].max_score) * 100,
),
),
]);
// 直接使用接口 sort 顺序,不经过 adjustRadarLabels 重新排序
setRadarData(radarData);
updateUserLevel(res.data.record_id, res.data.ntrp_level);
}
}
function updateUserLevel(record_id, ntrp_level) {
try {
evaluateService.updateNtrp({
record_id,
ntrp_level,
update_type: "test_result",
});
updateUserInfo({ ntrp_level: ntrp_level });
} catch (e) {
Taro.showToast({ title: e.message, icon: "none" });
}
}
function handleReTest() {
if (!userInfo.phone) {
handleAuth();
return false;
}
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.TEST}`,
});
}
function handleViewGames() {
Taro.redirectTo({
url: "/main_pages/index",
});
}
async function handleGoon() {
if (!userInfo?.phone) {
Taro.redirectTo({
url: `/login_pages/index/index?redirect=${encodeURIComponent(
`/main_pages/index`,
)}`,
});
clear();
return;
}
if (type) {
next({ flag: false, score: result?.ntrp_level as string });
await delay(1500);
clear();
} else {
handleViewGames();
}
}
async function genCardImage() {
try {
// 确保二维码已获取,如果没有则重新获取
let finalQrCodeUrl = qrCodeUrl;
if (!finalQrCodeUrl) {
// 直接调用接口获取二维码
const qrCodeUrlRes = await DetailService.getQrCodeUrl({
page: "other_pages/ntrp-evaluate/index",
scene: `stage=${StageType.INTRO}`,
});
finalQrCodeUrl = qrCodeUrlRes.data.ossPath;
// if (qrCodeUrlRes.code === 0 && qrCodeUrlRes.data?.qr_code_base64) {
// finalQrCodeUrl = await base64ToTempFilePath(
// qrCodeUrlRes.data.qr_code_base64
// );
// }
}
// 使用 RadarV2 的 generateFullImage 方法生成完整图片
const userNickname = (userInfo as any)?.nickname;
const titleText = userNickname
? `${userNickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为";
const imageUrl = await radarV2Ref.current?.generateFullImage({
title: titleText,
ntrpLevel: result?.ntrp_level,
levelDescription: result?.level_description,
avatarUrl: (userInfo as any)?.avatar_url,
qrCodeUrl: finalQrCodeUrl,
bottomText: "长按识别二维码,快来加入,有你就有场!",
width: 750, // 设计稿宽度
height: 1334, // 设计稿高度
});
return imageUrl;
} catch (error) {
console.error("生成图片失败:", error);
throw error;
}
}
async function handleSaveImage() {
console.log(userInfo);
if (!userInfo?.phone) {
handleAuth();
return;
}
Taro.getSetting().then(async (res) => {
if (!res.authSetting["scope.writePhotosAlbum"]) {
Taro.authorize({
scope: "scope.writePhotosAlbum",
success: async () => {
try {
const url = await genCardImage();
Taro.saveImageToPhotosAlbum({ filePath: url });
Taro.showToast({ title: "保存成功" });
} catch (e) {
Taro.showToast({ title: "图片保存失败", icon: "none" });
}
},
fail: () => {
Taro.showModal({
title: "提示",
content: "需要开启相册权限才能保存图片",
success: (r) => {
if (r.confirm) Taro.openSetting();
},
});
},
});
} else {
try {
const url = await genCardImage();
Taro.saveImageToPhotosAlbum({ filePath: url });
Taro.showToast({ title: "保存成功" });
} catch (e) {
Taro.showToast({ title: "图片保存失败", icon: "none" });
}
}
});
}
useShareAppMessage(async (res) => {
console.log("res", result);
return {
title: "来测一测你的NTRP等级吧",
imageUrl: result?.level_img || undefined,
path: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`,
};
});
function handleAuth() {
if (userInfo?.phone) {
return true;
}
const currentPage = getCurrentFullPath();
Taro.redirectTo({
url: `/login_pages/index/index${
currentPage ? `?redirect=${encodeURIComponent(currentPage)}` : ""
}`,
});
}
function handleGo() {}
return (
<View className={styles.resultContainer}>
<CommonGuideBar />
<View
className={styles.card}
style={{
backgroundImage: `url(${OSS_BASE}/front/ball/images/f5b45cea-5015-41d6-aaf4-83b2e76678e1.png)`,
}}
>
<View className={styles.avatarWrap}>
<View className={styles.avatar}>
<Image
className={styles.avatarUrl}
src={userInfo?.avatar_url || ""}
mode="aspectFill"
/>
</View>
{/* avatar side */}
<View className={styles.addonImage}>
<Image
className={styles.docImage}
src={DocCopy}
mode="aspectFill"
/>
</View>
</View>
<View className={styles.desc}>
<View className={styles.tip}>
<Text>
{(userInfo as any)?.nickname
? `${(userInfo as any).nickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为"}
</Text>
</View>
<View className={styles.levelWrap}>
<Text>NTRP</Text>
<Text className={styles.level}>
{formatNtrpDisplay(result?.ntrp_level)}
</Text>
</View>
<View className={styles.slogan}>
{/* <Text>变线+网前,下一步就是赢比赛!</Text> */}
<Text>{result?.level_description}</Text>
</View>
</View>
<View>
<RadarChart ref={radarRef} data={radarData} />
</View>
<View className={styles.retest} onClick={handleReTest}>
<Image className={styles.re_actIcon} src={ReTestIcon} />
<Text></Text>
</View>
</View>
{userInfo?.phone ? (
<View className={styles.updateTip}>
<Text>
NTRP {" "}
{formatNtrpDisplay(result?.ntrp_level || "")}{" "}
</Text>
<Text className={styles.grayTip}>()</Text>
</View>
) : (
<View className={styles.updateTip}>
<Text></Text>
</View>
)}
<View className={styles.actions}>
<View className={styles.viewGame} onClick={handleGoon}>
<Button className={styles.viewGameBtn}>
{sourceTypeToTextMap.get(type) || "去看看适合你的球局"}
</Button>
</View>
<View className={styles.otherActions}>
<View className={styles.share}>
<Button
className={styles.shareBtn}
openType={userInfo?.phone ? "share" : undefined}
onClick={handleAuth}
></Button>
<View className={styles.shareBtnCover}>
<Image className={styles.wechatIcon} src={WechatIcon} />
<Text></Text>
</View>
</View>
<View className={styles.saveImage} onClick={handleSaveImage}>
<Button className={styles.saveImageBtn}></Button>
<View className={styles.saveImageBtnCover}>
<Image className={styles.downloadIcon} src={DownloadIcon} />
<Text></Text>
</View>
</View>
</View>
</View>
{/* 隐藏的 RadarV2 用于生成完整图片,不显示在界面上 */}
<View
style={{
position: "absolute",
top: "-9999px",
left: "-9999px",
width: "0px",
height: "0px",
overflow: "hidden",
}}
>
<RadarChartV2
ref={radarV2Ref}
data={radarData}
title={
(userInfo as any)?.nickname
? `${(userInfo as any).nickname}的 NTRP 测试结果为`
: "你的 NTRP 测试结果为"
}
ntrpLevel={result?.ntrp_level}
levelDescription={result?.level_description}
avatarUrl={(userInfo as any)?.avatar_url}
qrCodeUrl={qrCodeUrl}
bottomText="长按识别二维码,快来加入,有你就有场!"
/>
</View>
</View>
);
}
const ComponentsMap = {
[StageType.INTRO]: Intro,
[StageType.TEST]: Test,
[StageType.RESULT]: Result,
};
function NtrpEvaluate() {
// const { updateUserInfo } = useUserActions();
const { params } = useRouter();
// const { redirect } = params;
const stage = params.stage as StageType;
// async function handleUpdateNtrp() {
// await updateUserInfo({
// ntrp_level: "4.0",
// });
// Taro.showToast({
// title: "更新成功",
// icon: "success",
// duration: 2000,
// });
// await delay(2000);
// if (redirect) {
// Taro.redirectTo({ url: decodeURIComponent(redirect) });
// }
// }
const Component = ComponentsMap[stage];
return <Component />;
}
export default withAuth(NtrpEvaluate);