Files
mini-programs/src/other_pages/ntrp-evaluate/index.tsx
2025-11-22 15:46:23 +08:00

778 lines
23 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 } 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 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()
function handleClose() {
//TODO: 二次确认
if (confirm) {
}
try {
console.log(onCancel, isOnCancelEmpty(onCancel));
if (isOnCancelEmpty(onCancel)) {
Taro.redirectTo({ url: "/main_pages/index" });
}
onCancel();
} catch {
Taro.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 } = 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}>
<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="https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
mode="aspectFit"
/>
</View>
<View className={styles.lastResult}>
<View className={styles.tipAndTime}>
<Text></Text>
<Text>{lastTestTime}</Text>
</View>
<View className={styles.levelWrap}>
<Text>NTRP</Text>
<Text className={styles.level}>
{formatNtrpDisplay(ntrp_level || "")}
</Text>
</View>
<View className={styles.slogan}>
<Text>线+</Text>
</View>
</View>
<View className={styles.actions}>
<View className={styles.buttonWrap}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
</View>
<View className={styles.buttonWrap}>
<Button
className={styles.button}
onClick={() => handleNext(StageType.RESULT)}
>
<Text>使</Text>
</Button>
</View>
</View>
</View>
) : (
<View className={styles.guide}>
{/* tip */}
<View className={styles.tip}>
<Image
className={styles.tipImage}
src="https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
mode="aspectFit"
/>
</View>
{/* radar */}
<View className={styles.radar}>
<Image
className={styles.radarImage}
src="https://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/a2e1b639-82a9-4ab8-b767-8605556eafcb.png"
mode="aspectFit"
/>
</View>
<View className={styles.desc}>
<Text>
NTRPNational Tennis Rating
Program
</Text>
</View>
<View className={styles.actions}>
<View className={styles.buttonWrap}>
<Button
className={classnames(styles.button, styles.primary)}
type="primary"
onClick={() => handleNext(StageType.TEST)}
>
<Text></Text>
<Image className={styles.arrowImage} src={ArrowRight} />
</Button>
</View>
</View>
</View>
)}
</View>
);
}
function Test() {
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}>
<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 } = useUserActions();
const { type, next, clear } = useEvaluate();
const radarRef = useRef();
const [result, setResult] = useState<TestResultData>();
const [radarData, setRadarData] = useState<
[propName: string, prop: number][]
>([]);
useEffect(() => {
const init = async () => {
// 先等待静默登录完成
await waitForAuthInit();
// 然后再调用接口
await getResultById();
// 确保用户信息已加载
if (!userInfo || Object.keys(userInfo).length === 0) {
await fetchUserInfo();
}
};
init();
}, [id]);
async function getResultById() {
const res = await evaluateService.getTestResult({ record_id: Number(id) });
if (res.code === 0) {
setResult(res.data);
// delay(1000);
setRadarData(
adjustRadarLabels(
Object.entries(res.data.radar_data.abilities).map(([key, value]) => [
key,
Math.min(
100,
Math.floor((value.current_score / value.max_score) * 100)
),
])
)
);
updateUserLevel(res.data.record_id, res.data.ntrp_level);
}
}
function updateUserLevel(record_id, ntrp_level) {
try {
evaluateService.updateNtrp({
record_id,
ntrp_level,
update_type: "test_result",
});
} catch (e) {
Taro.showToast({ title: e.message, icon: "none" });
}
}
function handleReTest() {
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() {
return new Promise(async (resolve, reject) => {
const url = await radarRef.current.generateImage();
const query = Taro.createSelectorQuery();
query
.select("#exportCanvas")
.fields({ node: true, size: true })
.exec((res2) => {
const canvas = res2[0].node;
const ctx = canvas.getContext("2d");
const dpr = Taro.getWindowInfo().pixelRatio;
const width = 300;
const height = 400;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
// 背景
ctx.fillStyle = "#e9fdf8";
ctx.fillRect(0, 0, width, height);
// 标题文字
ctx.fillStyle = "#000";
ctx.font = "16px sans-serif";
ctx.fillText("你的 NTRP 测试结果为", 20, 40);
ctx.fillStyle = "#00E5AD";
ctx.font = "bold 22px sans-serif";
ctx.fillText(`NTRP ${formatNtrpDisplay(result?.ntrp_level)}`, 20, 70);
// 绘制雷达图
const img = canvas.createImage();
img.src = url;
img.onload = () => {
ctx.drawImage(img, 20, 100, 260, 260);
// 第三步:导出最终卡片
Taro.canvasToTempFilePath({
canvas,
success: (res3) => {
console.log("导出成功:", res3.tempFilePath);
resolve(res3.tempFilePath);
},
});
};
});
});
}
async function handleSaveImage() {
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) => {
// const url = await genCardImage();
console.log(res, "res");
return {
title: "来测一测你的NTRP等级吧",
// imageUrl: url,
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}>
<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> 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>
</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>
<Canvas
type="2d"
id="exportCanvas"
style={{
width: "0px",
height: "0px",
position: "absolute",
left: "-9999px",
}}
/>
</View>
);
}
const ComponentsMap = {
[StageType.INTRO]: Intro,
[StageType.TEST]: Test,
[StageType.RESULT]: Result,
};
function NtrpEvaluate() {
// const { updateUserInfo } = useUserActions();
const { params } = useRouter();
// const { redirect } = params;
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);