671 lines
20 KiB
TypeScript
671 lines
20 KiB
TypeScript
import { useState, useEffect, useRef, useId } from "react";
|
||
import { View, Text, Image, Button, Canvas } from "@tarojs/components";
|
||
import Taro, { useRouter, useShareAppMessage } from "@tarojs/taro";
|
||
import dayjs from "dayjs";
|
||
import classnames from "classnames";
|
||
import { withAuth, RadarChart } from "@/components";
|
||
import evaluateService, {
|
||
LastTimeTestResult,
|
||
Question,
|
||
TestResultData,
|
||
} from "@/services/evaluateService";
|
||
import { useUserInfo, useUserActions } from "@/store/userStore";
|
||
import { useEvaluate, EvaluateScene } from "@/store/evaluateStore";
|
||
import { delay, getCurrentFullPath } from "@/utils";
|
||
import CloseIcon from "@/static/ntrp/ntrp_close_icon.svg";
|
||
import DocCopy from "@/static/ntrp/ntrp_doc_copy.svg";
|
||
import ArrowRight from "@/static/ntrp/ntrp_arrow_right.svg";
|
||
import ArrowBack from "@/static/ntrp/ntrp_arrow_back.svg";
|
||
import CircleChecked from "@/static/ntrp/ntrp_circle_checked.svg";
|
||
import CircleUnChecked from "@/static/ntrp/ntrp_circle_unchecked.svg";
|
||
import WechatIcon from "@/static/ntrp/ntrp_wechat.svg";
|
||
import DownloadIcon from "@/static/ntrp/ntrp_download.svg";
|
||
import ReTestIcon from "@/static/ntrp/ntrp_re-action.svg";
|
||
import styles from "./index.module.scss";
|
||
|
||
enum StageType {
|
||
INTRO = "intro",
|
||
TEST = "test",
|
||
RESULT = "result",
|
||
}
|
||
|
||
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 isEmptyArrowFunction(fn) {
|
||
return (
|
||
typeof fn === "function" &&
|
||
!fn.hasOwnProperty("prototype") && // 排除普通函数
|
||
fn.toString().replace(/\s+/g, "") === "()=>{}"
|
||
);
|
||
}
|
||
|
||
function CommonGuideBar(props) {
|
||
const { title, confirm } = props;
|
||
const { onCancel } = useEvaluate();
|
||
// const userInfo = useUserInfo()
|
||
|
||
function handleClose() {
|
||
//TODO: 二次确认
|
||
if (confirm) {
|
||
}
|
||
try {
|
||
if (isEmptyArrowFunction(onCancel)) {
|
||
Taro.redirectTo({ url: "/game_pages/list/index" });
|
||
}
|
||
onCancel();
|
||
} catch {
|
||
Taro.redirectTo({ url: "/game_pages/list/index" });
|
||
}
|
||
}
|
||
|
||
return (
|
||
<View className={styles.header}>
|
||
<View className={styles.closeIcon} onClick={handleClose}>
|
||
<Image className={styles.closeImg} src={CloseIcon} />
|
||
</View>
|
||
<View className={styles.title}>
|
||
<Text>{title}</Text>
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Intro() {
|
||
const [ntrpData, setNtrpData] = useState<LastTimeTestResult>();
|
||
const userInfo = useUserInfo();
|
||
const { fetchUserInfo } = useUserActions();
|
||
const [ready, setReady] = useState(false);
|
||
const { setCallback } = useEvaluate();
|
||
|
||
const { last_test_result: { ntrp_level, create_time, id } = {} } =
|
||
ntrpData || {};
|
||
const lastTestTime = dayjs(create_time).format("YYYY年M月D日");
|
||
|
||
useEffect(() => {
|
||
getLastResult();
|
||
}, []);
|
||
|
||
async function getLastResult() {
|
||
const res = await evaluateService.getLastResult();
|
||
if (res.code === 0) {
|
||
setNtrpData(res.data);
|
||
if (res.data.has_ntrp_level) {
|
||
fetchUserInfo();
|
||
}
|
||
setReady(true);
|
||
}
|
||
}
|
||
if (!ready) {
|
||
return "";
|
||
}
|
||
|
||
function handleNext(type) {
|
||
setCallback({
|
||
type: EvaluateScene.share,
|
||
next: () => {
|
||
Taro.redirectTo({ url: "/game_pages/list/index" });
|
||
},
|
||
onCancel: () => {
|
||
Taro.redirectTo({ url: "/game_pages/list/index" });
|
||
// if (userInfo.id) {
|
||
// Taro.redirectTo({ url: "/game_pages/list/index" });
|
||
// } else {
|
||
// Taro.exitMiniProgram();
|
||
// }
|
||
},
|
||
});
|
||
Taro.redirectTo({
|
||
url: `/other_pages/ntrp-evaluate/index?stage=${type}${
|
||
type === StageType.RESULT ? `&id=${id}` : ""
|
||
}`,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<View className={styles.introContainer}>
|
||
<CommonGuideBar />
|
||
{ntrpData?.has_ntrp_level ? (
|
||
<View className={styles.result}>
|
||
<View className={styles.avatarWrap}>
|
||
<View className={styles.avatar}>
|
||
<Image
|
||
className={styles.avatarUrl}
|
||
src={userInfo.avatar_url}
|
||
mode="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="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
|
||
mode="aspectFit"
|
||
/>
|
||
</View>
|
||
<View className={styles.lastResult}>
|
||
<View className={styles.tipAndTime}>
|
||
<Text>上次测试结果</Text>
|
||
<Text>{lastTestTime}</Text>
|
||
</View>
|
||
<View className={styles.levelWrap}>
|
||
<Text>NTRP</Text>
|
||
<Text className={styles.level}>{ntrp_level}</Text>
|
||
</View>
|
||
<View className={styles.slogan}>
|
||
<Text>变线+网前,下一步就是赢比赛!</Text>
|
||
</View>
|
||
</View>
|
||
<View className={styles.actions}>
|
||
<View className={styles.buttonWrap}>
|
||
<Button
|
||
className={classnames(styles.button, styles.primary)}
|
||
type="primary"
|
||
onClick={() => handleNext(StageType.TEST)}
|
||
>
|
||
<Text>再次测试</Text>
|
||
<Image className={styles.arrowImage} src={ArrowRight} />
|
||
</Button>
|
||
</View>
|
||
<View className={styles.buttonWrap}>
|
||
<Button
|
||
className={styles.button}
|
||
onClick={() => handleNext(StageType.RESULT)}
|
||
>
|
||
<Text>继续使用上次测试结果</Text>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
) : (
|
||
<View className={styles.guide}>
|
||
{/* tip */}
|
||
<View className={styles.tip}>
|
||
<Image
|
||
className={styles.tipImage}
|
||
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/b7cb47aa-b609-4112-899f-3fde02ed2431.png"
|
||
mode="aspectFit"
|
||
/>
|
||
</View>
|
||
{/* radar */}
|
||
<View className={styles.radar}>
|
||
<Image
|
||
className={styles.radarImage}
|
||
src="http://bimwe.oss-cn-shanghai.aliyuncs.com/front/ball/images/a2e1b639-82a9-4ab8-b767-8605556eafcb.png"
|
||
mode="aspectFit"
|
||
/>
|
||
</View>
|
||
<View className={styles.desc}>
|
||
<Text>
|
||
NTRP(National 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]);
|
||
|
||
async function getQUestions() {
|
||
const res = await evaluateService.getQuestions();
|
||
if (res.code === 0) {
|
||
setQuestions(res.data.map((item) => ({ ...item, choosen: 3 })));
|
||
}
|
||
}
|
||
|
||
function handleSelect(i) {
|
||
setQuestions((prev) =>
|
||
prev.map((item, pIndex) => ({
|
||
...item,
|
||
...(pIndex === index ? { choosen: i } : {}),
|
||
}))
|
||
);
|
||
}
|
||
|
||
async function handleSubmit() {
|
||
setDisabled(true);
|
||
try {
|
||
const res = await evaluateService.submit({
|
||
answers: questions.map((item) => ({
|
||
question_id: item.id,
|
||
answer_index: item.choosen,
|
||
})),
|
||
test_duration: (Date.now() - startTimeRef.current) / 1000,
|
||
});
|
||
if (res.code === 0) {
|
||
Taro.redirectTo({
|
||
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.RESULT}&id=${res.data.record_id}`,
|
||
});
|
||
}
|
||
} catch (e) {
|
||
Taro.showToast({ title: e.message, icon: "error" });
|
||
} finally {
|
||
setDisabled(false);
|
||
}
|
||
}
|
||
|
||
function handIndexChange(direction) {
|
||
console.log(disabled, direction);
|
||
if (disabled && direction > 0) {
|
||
return;
|
||
}
|
||
if (index === questions.length - 1 && direction > 0) {
|
||
handleSubmit();
|
||
return;
|
||
}
|
||
setIndex((prev) => prev + direction);
|
||
}
|
||
|
||
const question = questions[index];
|
||
if (!question) {
|
||
return "";
|
||
}
|
||
return (
|
||
<View className={styles.testContainer}>
|
||
<CommonGuideBar confirm title={`${index + 1} / ${questions.length}`} />
|
||
<View className={styles.bar}>
|
||
<View
|
||
className={styles.progressBar}
|
||
style={{ width: `${100 * ((index + 1) / questions.length)}%` }}
|
||
/>
|
||
</View>
|
||
<View className={styles.notice}>
|
||
<Text>根据近3个月实际表现勾选最符合项</Text>
|
||
</View>
|
||
<View className={styles.question}>
|
||
<View className={styles.content}>{question.question_content}</View>
|
||
<View className={styles.options}>
|
||
{question.options.map((item, i) => {
|
||
const checked = question.choosen === i;
|
||
return (
|
||
<View
|
||
key={i}
|
||
className={styles.optionItem}
|
||
onClick={() => handleSelect(i)}
|
||
>
|
||
<View className={styles.optionText}>{item.text}</View>
|
||
<View className={styles.optionIcon}>
|
||
<Image
|
||
className={styles.icon}
|
||
src={checked ? CircleChecked : CircleUnChecked}
|
||
/>
|
||
</View>
|
||
</View>
|
||
);
|
||
})}
|
||
</View>
|
||
</View>
|
||
<View className={styles.actions}>
|
||
<View
|
||
className={classnames(styles.next, disabled ? styles.disabled : "")}
|
||
onClick={() => handIndexChange(1)}
|
||
>
|
||
<Button className={styles.nextBtn} type="primary">
|
||
{index === questions.length - 1 ? "完成测试" : "继续"}
|
||
</Button>
|
||
</View>
|
||
{index !== 0 && (
|
||
<View className={styles.prev} onClick={() => handIndexChange(-1)}>
|
||
<Image className={styles.backIcon} src={ArrowBack} />
|
||
<Text>返回</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
function Result() {
|
||
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(() => {
|
||
getResultById();
|
||
fetchUserInfo();
|
||
}, []);
|
||
|
||
async function getResultById() {
|
||
const res = await evaluateService.getTestResult({ record_id: Number(id) });
|
||
if (res.code === 0) {
|
||
setResult(res.data);
|
||
setRadarData(
|
||
adjustRadarLabels(
|
||
Object.entries(res.data.radar_data.abilities).map(([key, value]) => [
|
||
key,
|
||
value.current_score,
|
||
])
|
||
)
|
||
);
|
||
updateUserLevel(res.data.record_id, res.data.ntrp_level);
|
||
}
|
||
}
|
||
|
||
function updateUserLevel(record_id, ntrp_level) {
|
||
try {
|
||
evaluateService.updateNtrp({
|
||
record_id,
|
||
ntrp_level,
|
||
update_type: "test_result",
|
||
});
|
||
} catch (e) {
|
||
Taro.showToast({ title: e.message, icon: "none" });
|
||
}
|
||
}
|
||
|
||
function handleReTest() {
|
||
Taro.redirectTo({
|
||
url: `/other_pages/ntrp-evaluate/index?stage=${StageType.TEST}`,
|
||
});
|
||
}
|
||
|
||
function handleViewGames() {
|
||
Taro.redirectTo({
|
||
url: "/game_pages/list/index",
|
||
});
|
||
}
|
||
|
||
async function handleGoon() {
|
||
if (type) {
|
||
next();
|
||
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.getSystemInfoSync().pixelRatio;
|
||
const width = 300;
|
||
const height = 400;
|
||
canvas.width = width * dpr;
|
||
canvas.height = height * dpr;
|
||
ctx.scale(dpr, dpr);
|
||
|
||
// 背景
|
||
ctx.fillStyle = "#e9fdf8";
|
||
ctx.fillRect(0, 0, width, height);
|
||
|
||
// 标题文字
|
||
ctx.fillStyle = "#000";
|
||
ctx.font = "16px sans-serif";
|
||
ctx.fillText("你的 NTRP 测试结果为", 20, 40);
|
||
ctx.fillStyle = "#00E5AD";
|
||
ctx.font = "bold 22px sans-serif";
|
||
ctx.fillText(`NTRP ${result?.ntrp_level}`, 20, 70);
|
||
|
||
// 绘制雷达图
|
||
const img = canvas.createImage();
|
||
img.src = url;
|
||
img.onload = () => {
|
||
ctx.drawImage(img, 20, 100, 260, 260);
|
||
|
||
// 第三步:导出最终卡片
|
||
Taro.canvasToTempFilePath({
|
||
canvas,
|
||
success: (res3) => {
|
||
console.log("导出成功:", res3.tempFilePath);
|
||
resolve(res3.tempFilePath);
|
||
},
|
||
});
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
async function handleSaveImage() {
|
||
if (!userInfo.id) {
|
||
return;
|
||
}
|
||
const url = await genCardImage();
|
||
Taro.saveImageToPhotosAlbum({ filePath: url });
|
||
}
|
||
|
||
useShareAppMessage(async (res) => {
|
||
const url = await genCardImage();
|
||
console.log(res, "res");
|
||
return {
|
||
title: "分享",
|
||
imageUrl: url,
|
||
path: `/other_pages/ntrp-evaluate/index?stage=${StageType.INTRO}`,
|
||
};
|
||
});
|
||
|
||
function handleAuth() {
|
||
if (userInfo.id) {
|
||
return true;
|
||
}
|
||
const currentPage = getCurrentFullPath();
|
||
Taro.redirectTo({
|
||
url: `/login_pages/index/index${
|
||
currentPage ? `?redirect=${encodeURIComponent(currentPage)}` : ""
|
||
}`,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<View className={styles.resultContainer}>
|
||
<CommonGuideBar />
|
||
<View className={styles.card}>
|
||
<View className={styles.avatarWrap}>
|
||
<View className={styles.avatar}>
|
||
<Image
|
||
className={styles.avatarUrl}
|
||
src={userInfo.avatar_url}
|
||
mode="aspectFit"
|
||
/>
|
||
</View>
|
||
{/* avatar side */}
|
||
<View className={styles.addonImage}>
|
||
<Image
|
||
className={styles.docImage}
|
||
src={DocCopy}
|
||
mode="aspectFill"
|
||
/>
|
||
</View>
|
||
</View>
|
||
<View className={styles.desc}>
|
||
<View className={styles.tip}>
|
||
<Text>你的 NTRP 测试结果为</Text>
|
||
</View>
|
||
<View className={styles.levelWrap}>
|
||
<Text>NTRP</Text>
|
||
<Text className={styles.level}>{result?.ntrp_level}</Text>
|
||
</View>
|
||
<View className={styles.slogan}>
|
||
<Text>变线+网前,下一步就是赢比赛!</Text>
|
||
</View>
|
||
</View>
|
||
<View>
|
||
<RadarChart ref={radarRef} data={radarData} />
|
||
</View>
|
||
<View className={styles.retest} onClick={handleReTest}>
|
||
<Image className={styles.re_actIcon} src={ReTestIcon} />
|
||
<Text>重新测试</Text>
|
||
</View>
|
||
</View>
|
||
{userInfo.id ? (
|
||
<View className={styles.updateTip}>
|
||
<Text>你的 NTRP 水平已更新为 {result?.ntrp_level} </Text>
|
||
<Text className={styles.grayTip}>(可在个人信息中修改)</Text>
|
||
</View>
|
||
) : (
|
||
<View className={styles.updateTip}>
|
||
<Text>登录「有场」小程序,查看匹配你的球局</Text>
|
||
</View>
|
||
)}
|
||
<View className={styles.actions}>
|
||
<View className={styles.viewGame} onClick={handleGoon}>
|
||
<Button className={styles.viewGameBtn}>
|
||
{sourceTypeToTextMap.get(type) || "去看看球局"}
|
||
</Button>
|
||
</View>
|
||
<View className={styles.otherActions}>
|
||
<View className={styles.share}>
|
||
<Button
|
||
className={styles.shareBtn}
|
||
openType={userInfo.id ? "share" : undefined}
|
||
onClick={handleAuth}
|
||
>
|
||
<Image className={styles.wechatIcon} src={WechatIcon} />
|
||
<Text>邀请好友测试</Text>
|
||
</Button>
|
||
</View>
|
||
<View className={styles.saveImage} onClick={handleSaveImage}>
|
||
<Button className={styles.saveImageBtn}>
|
||
<Image className={styles.downloadIcon} src={DownloadIcon} />
|
||
<Text>保存图片</Text>
|
||
</Button>
|
||
</View>
|
||
</View>
|
||
</View>
|
||
<Canvas
|
||
type="2d"
|
||
id="exportCanvas"
|
||
style={{
|
||
width: "0px",
|
||
height: "0px",
|
||
position: "absolute",
|
||
left: "-9999px",
|
||
}}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const ComponentsMap = {
|
||
[StageType.INTRO]: Intro,
|
||
[StageType.TEST]: Test,
|
||
[StageType.RESULT]: Result,
|
||
};
|
||
|
||
function NtrpEvaluate() {
|
||
// const { updateUserInfo } = useUserActions();
|
||
const { params } = useRouter();
|
||
// const { redirect } = params;
|
||
|
||
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);
|