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

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);