feat: 问卷调查

This commit is contained in:
2025-09-26 15:31:24 +08:00
parent 16c7be700b
commit 3cc2a24be5
13 changed files with 1197 additions and 73 deletions

1
.gitignore vendored
View File

@@ -7,3 +7,4 @@ node_modules/
.swc
src/config/env.ts
.vscode
*.http

View File

@@ -0,0 +1,183 @@
import Taro, { useReady } from "@tarojs/taro";
import { View, Canvas, Button } from "@tarojs/components";
import { useEffect } from "react";
const RadarChart: React.FC = () => {
const labels = [
"正手球质",
"正手控制",
"反手球质",
"反手控制",
"底线相持",
"场地覆盖",
"发球接发",
"接随机球",
"战术设计",
];
const values = [50, 75, 60, 20, 40, 70, 65, 35, 75];
const maxValue = 100;
const levels = 4;
const radius = 100;
const center = { x: 160, y: 160 };
useReady(() => {
const query = Taro.createSelectorQuery();
query
.select("#radarCanvas")
.fields({ node: true, size: true })
.exec((res) => {
const canvas = res[0].node as HTMLCanvasElement;
const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
// 设置像素比,保证清晰
const dpr = Taro.getSystemInfoSync().pixelRatio;
canvas.width = res[0].width * dpr;
canvas.height = res[0].height * dpr;
ctx.scale(dpr, dpr);
// 绘制网格
ctx.strokeStyle = "#ddd";
// for (let i = 1; i <= levels; i++) {
// const r = (radius / levels) * i;
// ctx.beginPath();
// labels.forEach((_, j) => {
// const angle = ((Math.PI * 2) / labels.length) * j - Math.PI / 2;
// const x = center.x + r * Math.cos(angle);
// const y = center.y + r * Math.sin(angle);
// if (j === 0) ctx.moveTo(x, y);
// else ctx.lineTo(x, y);
// });
// ctx.closePath();
// ctx.stroke();
// }
for (let i = 1; i <= levels; i++) {
const r = (radius / levels) * i;
ctx.beginPath();
labels.forEach((_, j) => {
const angle = ((Math.PI * 2) / labels.length) * j - Math.PI / 2;
const x = center.x + r * Math.cos(angle);
const y = center.y + r * Math.sin(angle);
if (j === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
// === 偶数环填充颜色 ===
if (i % 2 === 0) {
ctx.fillStyle = "rgba(0, 150, 200, 0.1)"; // 浅色填充,透明度可调
ctx.fill();
}
// 保留线条
ctx.strokeStyle = "#bbb"; // 边框颜色
ctx.stroke();
}
// 坐标轴 & 标签
ctx.fillStyle = "#333";
ctx.font = "12px sans-serif";
// labels.forEach((label, i) => {
// const angle = ((Math.PI * 2) / labels.length) * i - Math.PI / 2;
// const x = center.x + radius * Math.cos(angle);
// const y = center.y + radius * Math.sin(angle);
// ctx.beginPath();
// ctx.moveTo(center.x, center.y);
// ctx.lineTo(x, y);
// ctx.strokeStyle = "#bbb";
// ctx.stroke();
// ctx.fillText(
// label,
// x + Math.cos(angle) * 20,
// y + Math.sin(angle) * 20
// );
// });
labels.forEach((label, i) => {
const angle = ((Math.PI * 2) / labels.length) * i - Math.PI / 2;
// 线条终点(半径)
const x = center.x + radius * Math.cos(angle);
const y = center.y + radius * Math.sin(angle);
// 画坐标轴线
ctx.beginPath();
ctx.moveTo(center.x, center.y);
ctx.lineTo(x, y);
ctx.strokeStyle = "#bbb";
ctx.stroke();
// === 改造后的文字位置 ===
const offset = 10; // 标签离图形的距离
const textX = center.x + (radius + offset) * Math.cos(angle);
const textY = center.y + (radius + offset) * Math.sin(angle);
ctx.font = "12px sans-serif";
ctx.fillStyle = "#333";
ctx.textBaseline = "middle";
console.log(label, angle)
// 根据角度调整文字对齐方式
if (Math.abs(angle) < 0.01 || Math.abs(Math.abs(angle) - Math.PI) < 0.01) {
// 顶部或底部
ctx.textAlign = "center";
} else if (angle > -Math.PI / 2 && angle < Math.PI / 2) {
// 右侧
ctx.textAlign = "left";
} else {
// 左侧
ctx.textAlign = "right";
}
ctx.fillText(label, textX, textY);
});
// 数据区域
ctx.beginPath();
values.forEach((val, i) => {
const angle = ((Math.PI * 2) / labels.length) * i - Math.PI / 2;
const r = (val / maxValue) * radius;
const x = center.x + r * Math.cos(angle);
const y = center.y + r * Math.sin(angle);
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.fillStyle = "rgba(0,200,180,0.3)";
ctx.fill();
ctx.strokeStyle = "#00c8b4";
ctx.lineWidth = 4;
ctx.stroke();
});
});
// 保存为图片
const saveImage = () => {
Taro.canvasToTempFilePath({
canvasId: "radarCanvas",
success: (res) => {
Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath,
success: () => Taro.showToast({ title: "保存成功" }),
});
},
});
};
return (
<View>
<Canvas
type="2d"
id="radarCanvas"
style={{ width: "320px", height: "320px", background: "transparent" }}
/>
{/* <Button onClick={saveImage}>保存为图片</Button> */}
</View>
);
};
export default RadarChart;

View File

@@ -21,6 +21,7 @@ import GameManagePopup from './GameManagePopup';
import FollowUserCard from './FollowUserCard/index';
import Comments from "./Comments";
import GeneralNavbar from "./GeneralNavbar";
import RadarChart from './Radar'
export {
ActivityTypeSwitch,
@@ -47,4 +48,5 @@ export {
FollowUserCard,
Comments,
GeneralNavbar,
RadarChart,
};

View File

@@ -1,5 +1,6 @@
export default definePageConfig({
navigationBarTitleText: "NTRP 评测",
// navigationBarTitleText: "NTRP 评测",
// navigationBarBackgroundColor: '#FAFAFA',
// navigationStyle: 'custom',
navigationStyle: 'custom',
enableShareAppMessage: true,
});

View File

@@ -62,3 +62,503 @@
}
}
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
height: 44px;
padding: 46px 42px 0 10px;
.closeIcon {
width: 32px;
height: 32px;
margin-right: auto;
.closeImg {
width: 100%;
height: 100%;
}
}
.title {
flex: 1;
margin: auto;
display: flex;
align-items: center;
justify-content: center;
}
}
@mixin commonAvatarStyle($multiple: 1) {
.avatar {
flex: 0 0 auto;
width: calc(100px * $multiple);
height: calc(100px * $multiple);
display: flex;
align-items: center;
justify-content: center;
background-color: #fff;
border-radius: 50%;
border: 1px solid #efefef;
overflow: hidden;
box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.20), 0 8px 20px 0 rgba(0, 0, 0, 0.12);
.avatarUrl {
width: calc(90px * $multiple);
height: calc(90px * $multiple);
border-radius: 50%;
border: 1px solid #efefef;
}
}
.addonImage {
flex: 0 0 auto;
width: calc(88px * $multiple);
height: calc(88px * $multiple);
transform: rotate(8deg);
flex-shrink: 0;
aspect-ratio: 1/1;
border-radius: calc(20px * $multiple);
border: 4px solid #FFF;
background: linear-gradient(0deg, rgba(89, 255, 214, 0.20) 0%, rgba(89, 255, 214, 0.20) 100%), #FFF;
box-shadow: 0 4px 36px 0 rgba(0, 0, 0, 0.12);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
margin-left: calc(-1 * 20px * $multiple);
.docImage {
width: calc(48px * $multiple);
height: calc(48px * $multiple);
transform: rotate(-7deg);
flex-shrink: 0;
}
}
}
.introContainer {
width: 100vw;
height: 100vh;
background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA;
.result {
.avatarWrap {
width: 200px;
height: 100px;
padding: 30px 0 0 30px;
display: flex;
align-items: center;
justify-content: flex-start;
@include commonAvatarStyle(1);
}
.tip {
padding: 0 30px;
.tipImage {
width: 100%;
}
}
.lastResult {
margin: 40px 22px;
display: flex;
padding: 16px 20px 20px 20px;
flex-direction: column;
justify-content: center;
align-items: flex-start;
gap: 8px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: #FFF;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
.tipAndTime {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
color: rgba(0, 0, 0, 0.65);
font-feature-settings: 'liga' off, 'clig' off;
font-family: "Noto Sans SC";
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.levelWrap {
color: #000;
font-feature-settings: 'liga' off, 'clig' off;
text-overflow: ellipsis;
font-family: "Noto Sans SC";
font-size: 32px;
font-style: normal;
font-weight: 900;
line-height: 36px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
.level {
color: #00E5AD;
}
}
.slogan {
color: #000;
font-family: "Noto Sans SC";
font-size: 16px;
font-style: normal;
font-weight: 700;
line-height: 22px;
}
}
.actions {
margin: 0 22px;
display: flex;
flex-direction: column;
gap: 10px;
.buttonWrap {
width: 100%;
height: 52px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
overflow: hidden;
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
font-feature-settings: 'liga' off, 'clig' off;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: normal;
&.primary {
color: #fff;
background: #000;
.arrowImage {
width: 20px;
height: 20px;
}
}
}
}
}
}
.guide {
.tip {
padding: 0 30px;
.tipImage {
width: 100%;
}
}
.radar {
display: flex;
align-items: center;
justify-content: center;
.radarImage {
width: 320px;
transform: scale(1.8);
}
}
.desc {
padding: 0 30px;
color: rgba(0, 0, 0, 0.85);
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 300;
line-height: 24px;
}
.actions {
margin: 74px 22px 0;
display: flex;
flex-direction: column;
gap: 10px;
.buttonWrap {
width: 100%;
height: 52px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
overflow: hidden;
.button {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
height: 100%;
font-feature-settings: 'liga' off, 'clig' off;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: normal;
&.primary {
color: #fff;
background: #000;
.arrowImage {
width: 20px;
height: 20px;
}
}
}
}
}
}
}
.testContainer {
width: 100vw;
height: 100vh;
background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA;
.bar {
margin: 12px 20px 36px;
height: 8px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.06);
position: relative;
.progressBar {
height: 8px;
position: absolute;
left: 0;
top: 0;
border-radius: 999px;
background-color: #000;
}
}
.notice {
padding: 0 20px 20px;
color: #000;
font-family: "PingFang SC";
font-size: 18px;
font-style: normal;
font-weight: 300;
line-height: normal;
}
.question {
padding: 0 20px 48px;
box-sizing: border-box;
height: 502px;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
.content {
color: #000;
font-family: "PingFang SC";
font-size: 36px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
.options {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 12px;
width: 100%;
.optionItem {
display: flex;
align-items: center;
justify-content: space-between;
display: flex;
padding: 14px 20px;
align-items: center;
gap: 12px;
border-radius: 16px;
border: 0.5px solid rgba(0, 0, 0, 0.12);
background: #fff;
width: 100%;
box-sizing: border-box;
.optionText {
color: #000;
font-feature-settings: 'liga' off, 'clig' off;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 24px;
}
.optionIcon {
display: flex;
align-items: center;
.icon {
width: 20px;
height: 20px;
}
}
}
}
}
.actions {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 0 22px;
gap: 24px;
.next {
width: 100%;
height: 52px;
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.20);
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
overflow: hidden;
.nextBtn {
width: 100%;
height: 100%;
background-color: #000;
color: #fff;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
}
&.disabled {
border-radius: 16px;
border: 1px solid rgba(0, 0, 0, 0.06);
background: rgba(0, 0, 0, 0.20);
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
.nextBtn {
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.20);
color: #fff;
border-radius: 16px;
}
}
}
.prev {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #000;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 600;
line-height: normal;
.backIcon {
width: 20px;
height: 20px;
}
}
}
}
.resultContainer {
width: 100vw;
height: 100vh;
background: radial-gradient(227.15% 100% at 50% 0%, #BFFFEF 0%, #FFF 36.58%), #FAFAFA;
.card {
margin: 10px 20px 0;
padding: 24px 28px 0;
position: relative;
display: flex;
// height: px;
flex-direction: column;
justify-content: space-between;
align-items: center;
align-self: stretch;
border-radius: 26px;
border: 4px solid #FFF;
background: linear-gradient(180deg, #BFFFEF 0%, #F2FFFC 100%), #FFF;
box-shadow: 0 8px 64px 0 rgba(0, 0, 0, 0.10);
.avatarWrap {
padding-bottom: 20px;
display: flex;
align-items: center;
justify-content: flex-start;
@include commonAvatarStyle(0.5);
}
.desc {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
.tip {
color: #000;
font-family: "PingFang SC";
font-size: 14px;
font-style: normal;
font-weight: 300;
line-height: normal;
}
.levelWrap {
color: #000;
font-feature-settings: 'liga' off, 'clig' off;
text-overflow: ellipsis;
font-family: "Noto Sans SC";
font-size: 36px;
font-style: normal;
font-weight: 900;
line-height: 44px;
.level {
color: #00E5AD;
}
}
.slogan {
color: #000;
font-family: "PingFang SC";
font-size: 16px;
font-style: normal;
font-weight: 600;
line-height: normal;
}
}
}
}

View File

@@ -1,22 +1,388 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { View, Text, Image, Button } from "@tarojs/components";
import Taro, { useRouter } from "@tarojs/taro";
import { withAuth } from "@/components";
import evaluateService from "@/services/evaluateService";
import { useUserActions } from "@/store/userStore";
import dayjs from "dayjs";
import classnames from "classnames";
import { withAuth, RadarChart } from "@/components";
import evaluateService, {
LastTimeTestResult,
Question,
} from "@/services/evaluateService";
import { useUserInfo, useUserActions } from "@/store/userStore";
import { delay } 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 styles from "./index.module.scss";
enum StageType {
INTRO = "intro",
TEST = "test",
RESULT = "result",
}
function CommonGuideBar(props) {
const { title, confirm } = props;
const { params } = useRouter();
const { redirect } = params;
function handleClose() {
//TODO: 二次确认
if (confirm) {
}
Taro.redirectTo({
url: redirect ? redirect : "/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(props) {
const { redirect } = props;
const [ntrpData, setNtrpData] = useState<LastTimeTestResult>();
const userInfo = useUserInfo();
const { fetchUserInfo } = useUserActions();
const [ready, setReady] = useState(false);
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) {
Taro.redirectTo({
url: `/other_pages/ntrp-evaluate/index?stage=${type}${
type === StageType.RESULT ? `&id=${id}` : ""
}${redirect ? `&redirect=${redirect}` : ""}`,
});
}
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="aspectFit"
/>
</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(props) {
const { redirect } = props;
const [disabled, setDisabled] = useState(false);
const [index, setIndex] = useState(9);
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
}${redirect ? `&redirect=${redirect}` : ""}`,
});
}
} 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 userInfo = useUserInfo();
const { fetchUserInfo } = useUserActions();
useEffect(() => {
fetchUserInfo()
}, [])
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}>{1.1}</Text>
</View>
<View className={styles.slogan}>
<Text>线+</Text>
</View>
</View>
<View>
<RadarChart />
</View>
</View>
</View>
);
}
const ComponentsMap = {
[StageType.INTRO]: Intro,
[StageType.TEST]: Test,
[StageType.RESULT]: Result,
};
function NtrpEvaluate() {
const { updateUserInfo } = useUserActions();
const { params } = useRouter();
const { redirect } = params;
useEffect(() => {
evaluateService.getEvaluateQuestions().then((data) => {
console.log(data);
});
}, []);
const stage = params.stage as StageType;
async function handleUpdateNtrp() {
await updateUserInfo({
@@ -33,21 +399,9 @@ function NtrpEvaluate() {
}
}
return (
<View className={styles.container}>
<View className={styles.title}>NTRP评分</View>
<View className={styles.content}>
<Image
className={styles.image}
src="https://img.yzcdn.cn/vant/cat.jpeg"
/>
<Text className={styles.description}>NTRP评分是 4.0 </Text>
</View>
<Button className={styles.button} onClick={handleUpdateNtrp}>
</Button>
</View>
);
const Component = ComponentsMap[stage];
return <Component redirect={redirect} />;
}
export default withAuth(NtrpEvaluate);

View File

@@ -1,71 +1,123 @@
import httpService from "./httpService";
import type { ApiResponse } from "./httpService";
export interface AnswerResItem {
question_id: number;
answer_index: number;
// 单个选项类型
interface Option {
text: string;
score: number;
}
// 单个问题类型
export interface Question {
id: number;
question_title: string;
question_content: string;
options: Option[];
radar_mapping: string[];
}
// 单项能力分数
interface AbilityScore {
current_score: number;
max_score: number;
percentage: number;
}
// 雷达图数据
interface RadarData {
abilities: Record<string, AbilityScore>; // key 是能力名称,如 "正手球质"
summary: {
total_questions: number;
calculation_time: string; // ISO 字符串
};
}
// 单题答案
interface Answer {
question_id: number;
question_title: string;
answer_index: number;
selected_option: string;
score: number;
}
export type AnswerItem = Pick<AnswerResItem, "question_id" | "answer_index">;
export interface Answers {
answers: AnswerItem[];
test_duration: number;
}
export interface QuestionItem {
id: number;
question_title: string;
question_content: string;
options: string[];
scores: number[];
}
export interface SubmitAnswerRes {
// 提交测试结果 对象类型
export interface TestResultData {
record_id: number;
total_score: number;
ntrp_level: string;
is_coverage: boolean;
old_ntrp_level: string;
level_description: string;
answers: AnswerResItem[];
radar_data: RadarData;
answers: Answer[];
}
// 单条测试记录
interface TestRecord {
id: number;
total_score: number;
ntrp_level: string;
level_description: string;
test_duration: number; // 单位:秒
create_time: string; // 时间字符串
}
// 测试历史对象类型
export interface TestResultList {
count: number;
rows: TestRecord[];
}
// 单次测试结果
interface TestResult {
id: number;
total_score: number;
ntrp_level: string;
level_description: string;
radar_data: RadarData;
test_duration: number; // 单位秒
create_time: string; // 时间字符串
}
// data 对象
// 上一次测试结果
export interface LastTimeTestResult {
has_test_record: boolean;
has_ntrp_level: boolean;
user_ntrp_level: string;
last_test_result: TestResult;
}
// 发布球局类
class EvaluateService {
async getEvaluateQuestions(): Promise<ApiResponse<QuestionItem[]>> {
return httpService.post("/ntrp/questions", {
showLoading: true,
});
// 获取测试题目
async getQuestions(): Promise<ApiResponse<Question[]>> {
return httpService.post("/ntrp/questions", {}, { showLoading: true });
}
async submitEvaluateAnswers({
answers,
}: Answers): Promise<ApiResponse<SubmitAnswerRes>> {
return httpService.post(
"/ntrp/submit",
{ answers },
{
showLoading: true,
},
);
// 提交答案
async submit(req: { answers: { question_id: number, answer_index: number }[], test_duration: number }): Promise<ApiResponse<TestResultData>> {
return httpService.post("/ntrp/submit", req, { showLoading: true });
}
async getHistoryNtrp(): Promise<ApiResponse<any>> {
return httpService.post("/ntrp/history", {
showLoading: true,
});
// 获取测试历史
async getResultList(): Promise<ApiResponse<TestResultList>> {
return httpService.post("/ntrp/history", {}, { showLoading: true });
}
async getNtrpDetail(record_id: number): Promise<ApiResponse<any>> {
return httpService.post(
"/ntrp/detail",
{ record_id },
{
showLoading: true,
},
);
// 获取测试详情
async getTestResult(req: { record_id: number }): Promise<ApiResponse<TestResultData>> {
return httpService.post("/ntrp/detail", req, { showLoading: true });
}
// 获取最后一次(最新)测试结果
async getLastResult(): Promise<ApiResponse<LastTimeTestResult>> {
return httpService.post("/ntrp/last_result", {}, { showLoading: true });
}
// 更新NTRP等级
async updateNtrp(req: { record_id: number, ntrp_level: string, update_type: string }): Promise<ApiResponse<any>> {
return httpService.post("/ntrp/update_user_level", req, { showLoading: true });
}
}

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.50002 10L17.5 10" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 15L2.5 10L7.5 5" stroke="black" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 338 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17.5 10H2.5" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12.5 5L17.5 10L12.5 15" stroke="white" stroke-width="1.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 334 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="10" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.36695 10.343C5.75135 9.94642 6.38444 9.93658 6.78099 10.321L8.84309 12.3199L13.1831 6.17329C13.5016 5.72214 14.1256 5.61464 14.5768 5.93319C15.0279 6.25174 15.1354 6.87571 14.8169 7.32687L9.80293 14.428C9.6344 14.6667 9.3699 14.8197 9.07898 14.8469C8.78805 14.8741 8.4998 14.7726 8.29001 14.5692L5.38894 11.757C4.99239 11.3726 4.98254 10.7395 5.36695 10.343Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="10" cy="10" r="9.25" stroke="#161823" stroke-opacity="0.34" stroke-width="1.5"/>
</svg>

After

Width:  |  Height:  |  Size: 196 B

View File

@@ -0,0 +1,4 @@
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.33325 9.33334L22.6666 22.6667" stroke="black" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.33325 22.6667L22.6666 9.33334" stroke="black" stroke-width="2.66667" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1,12 @@
<svg width="55" height="55" viewBox="0 0 55 55" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_4962_28921)">
<path d="M14.462 7.25007C14.6157 6.15625 15.6271 5.39415 16.7209 5.54788L36.5263 8.33134L45.0372 19.6258L41.1404 47.3533C40.9866 48.4471 39.9753 49.2092 38.8815 49.0554L11.154 45.1586C10.0601 45.0049 9.29805 43.9936 9.45178 42.8997L14.462 7.25007Z" stroke="#00E6AD" stroke-width="4" stroke-linejoin="round"/>
<path d="M20.4364 22.2275L36.2807 24.4543" stroke="#00E6AD" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M19.3226 30.1494L35.1669 32.3762" stroke="#00E6AD" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_4962_28921">
<rect width="48" height="48" fill="white" transform="translate(7.37561 0.195312) rotate(8)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 862 B