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

671 lines
20 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 } 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>
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]);
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);