Quellcode durchsuchen

feat(cognitive): 新增5个认知测试页面 + 自测提交API

- 新增 /api/cognitive/self-test 接口,自动创建测评档案
- CognitiveService.submitSelfTestScore() 支持无档案时自动建表
- 前端新增 5 个认知测试页:逻辑推理/感知觉/记忆力/空间思维/加工速度
- 训练中心入口新增 5 个测试卡片
- 健康页面 tab 顺序调整为:健康/认知/财富/社会性/心理
Sisyphus vor 1 Woche
Ursprung
Commit
797491fd2c

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/assessment/CognitiveController.java

@@ -213,4 +213,37 @@ public class CognitiveController {
 
         return Result.success(trend);
     }
+
+    @Operation(summary = "提交认知自测结果(写入六维认知档案,自动创建档案)")
+    @PostMapping("/self-test")
+    public Result<String> submitSelfTest(
+            @RequestBody Map<String, Object> body,
+            @RequestAttribute(value = "currentMemberId", required = false) Long currentMemberId) {
+        Long memberId = ParamUtils.getLong(body.get("memberId"), currentMemberId);
+        String dimension = body.get("dimension") != null
+                ? body.get("dimension").toString()
+                : null;
+        Integer score = body.get("score") != null
+                ? Integer.valueOf(body.get("score").toString())
+                : null;
+
+        if (memberId == null) {
+            return Result.error("memberId不能为空");
+        }
+        if (dimension == null || dimension.isEmpty()) {
+            return Result.error("dimension不能为空");
+        }
+        if (score == null) {
+            return Result.error("score不能为空");
+        }
+        if (score < 0 || score > 100) {
+            return Result.error("score必须在0-100之间");
+        }
+
+        boolean ok = cognitiveService.submitSelfTestScore(memberId, dimension, score);
+        if (!ok) {
+            return Result.error("提交失败,请重试");
+        }
+        return Result.success("提交成功");
+    }
 }

+ 69 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/CognitiveService.java

@@ -67,19 +67,16 @@ public class CognitiveService {
      * @return Map<childId, DanAssessmentResult> 孩子ID与最新测评结果的映射
      */
     public Map<Long, DanAssessmentResult> getFamilyLatestResults(Long userId) {
-        // 1. 查找用户所属家庭
         User user = userMapper.selectById(userId);
         if (user == null || user.getFamilyId() == null) {
             return Collections.emptyMap();
         }
 
-        // 2. 查找该家庭下所有孩子
         List<FamilyMember> children = familyMemberMapper.selectList(
                 new LambdaQueryWrapper<FamilyMember>()
                         .eq(FamilyMember::getFamilyId, user.getFamilyId())
         );
 
-        // 3. 获取每个孩子的最新测评结果
         Map<Long, DanAssessmentResult> resultMap = new LinkedHashMap<>();
         for (FamilyMember child : children) {
             DanAssessmentResult latest = getFamilyMemberLatestResult(child.getId());
@@ -90,6 +87,75 @@ public class CognitiveService {
         return resultMap;
     }
 
+    /**
+     * 提交认知自测分数(自动创建测评结果行,适用于孩子尚无DAN测评档案的情况)
+     * 自动计算综合分(7个认知维度非空值的平均值)
+     */
+    public boolean submitSelfTestScore(Long memberId, String dimension, Integer score) {
+        DanAssessmentResult latest = getFamilyMemberLatestResult(memberId);
+        if (latest == null) {
+            FamilyMember member = familyMemberMapper.selectById(memberId);
+            if (member == null) {
+                log.warn("未找到家庭成员 {}, 无法创建自测结果", memberId);
+                return false;
+            }
+            latest = new DanAssessmentResult();
+            latest.setChildId(memberId);
+            latest.setFamilyMemberId(memberId);
+            latest.setFamilyMemberName(member.getNickname());
+            latest.setFamilyMemberBirthday(member.getBirthday());
+            latest.setFamilyMemberGender(member.getGender());
+            latest.setFamilyMemberAge(member.getAge());
+            latest.setTeacherId(0L);
+            latest.setStatus("completed");
+            latest.setAssessmentDate(new Date());
+            latest.setSource("self_test");
+            latest.setCreatedAt(new Date());
+            latest.setUpdatedAt(new Date());
+        }
+
+        switch (dimension) {
+            case "memoryScore":
+                latest.setMemoryScore(score);
+                break;
+            case "logicScore":
+                latest.setLogicScore(score);
+                break;
+            case "perceptionScore":
+                latest.setPerceptionScore(score);
+                break;
+            case "spatialScore":
+                latest.setSpatialScore(score);
+                break;
+            case "processingSpeedScore":
+                latest.setProcessingSpeedScore(score);
+                break;
+            default:
+                log.warn("未知的认知自测维度: {}", dimension);
+                return false;
+        }
+
+        int validCount = 0, total = 0;
+        for (Integer s : Arrays.asList(
+                latest.getAttentionScore(), latest.getFocusScore(),
+                latest.getMemoryScore(), latest.getLogicScore(),
+                latest.getPerceptionScore(), latest.getSpatialScore(),
+                latest.getProcessingSpeedScore())) {
+            if (s != null && s > 0) { total += s; validCount++; }
+        }
+        if (validCount > 0) {
+            latest.setOverallScore(Math.round(total / (float) validCount));
+        }
+
+        if (latest.getId() == null) {
+            danAssessmentResultMapper.insert(latest);
+        } else {
+            danAssessmentResultMapper.updateById(latest);
+        }
+        log.info("已更新孩子 {} 的自测维度 {} 得分为 {} (id={})", memberId, dimension, score, latest.getId());
+        return true;
+    }
+
     /**
      * 更新指定孩子的认知维度得分
      *

+ 35 - 0
cfc-frontend/pages.json

@@ -337,6 +337,41 @@
         }
       ]
     },
+    {
+      "root": "pages/cognitive-test",
+      "pages": [
+        {
+          "path": "logic-test",
+          "style": {
+            "navigationBarTitleText": "逻辑推理测试"
+          }
+        },
+        {
+          "path": "perception-test",
+          "style": {
+            "navigationBarTitleText": "感知觉测试"
+          }
+        },
+        {
+          "path": "memory-test",
+          "style": {
+            "navigationBarTitleText": "记忆力测试"
+          }
+        },
+        {
+          "path": "spatial-test",
+          "style": {
+            "navigationBarTitleText": "空间思维测试"
+          }
+        },
+        {
+          "path": "speed-test",
+          "style": {
+            "navigationBarTitleText": "加工速度测试"
+          }
+        }
+      ]
+    },
     {
       "root": "pages/assessment",
       "pages": [

+ 513 - 0
cfc-frontend/pages/cognitive-test/logic-test.vue

@@ -0,0 +1,513 @@
+<template>
+  <view class="container">
+    <!-- 引导页 -->
+    <view class="step-intro" v-if="step === 'intro'">
+      <view class="intro-icon">
+        <text class="intro-icon-text">🧠</text>
+      </view>
+      <text class="intro-title">逻辑推理测试</text>
+      <text class="intro-desc">观察数列规律,从选项中选出缺失的数字</text>
+      <view class="intro-info">
+        <view class="info-row">
+          <text class="info-label">题目数量</text>
+          <text class="info-value">8 题</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">每题限时</text>
+          <text class="info-value">15 秒</text>
+        </view>
+        <view class="info-row">
+          <text class="info-label">测评维度</text>
+          <text class="info-value">逻辑思维</text>
+        </view>
+      </view>
+      <view class="btn btn--primary" @tap="startTest">开始测试</view>
+    </view>
+
+    <!-- 答题页 -->
+    <view class="step-playing" v-if="step === 'playing'">
+      <view class="quiz-header">
+        <text class="quiz-progress">第 {{ currentQ + 1 }}/{{ questions.length }} 题</text>
+        <text class="quiz-timer">{{ timerSeconds }}s</text>
+      </view>
+      <view class="timer-track">
+        <view class="timer-fill" :style="'width:' + timerPct + '%'"></view>
+      </view>
+      <view class="question-card" v-if="currentQuestion">
+        <text class="q-text">{{ currentQuestion.seq }}</text>
+        <view class="q-options">
+          <view
+            class="q-option"
+            v-for="(opt, oi) in currentQuestion.options"
+            :key="oi"
+            :class="{ 'q-option--selected': selectedIndex === oi }"
+            @tap="selectOption(oi)"
+          >
+            <text class="q-option-text">{{ opt }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 结果页 -->
+    <view class="step-result" v-if="step === 'result'">
+      <view class="score-circle">
+        <text class="score-value">{{ score }}</text>
+      </view>
+      <text class="score-label">{{ scoreText }}</text>
+      <view class="result-info">
+        <view class="result-row">
+          <text class="result-label">答对题数</text>
+          <text class="result-value">{{ correctCount }}/{{ questions.length }}</text>
+        </view>
+        <view class="result-row">
+          <text class="result-label">测评维度</text>
+          <view class="dim-tag">
+            <text class="dim-tag-text">逻辑思维</text>
+          </view>
+        </view>
+        <view class="result-row">
+          <text class="result-label">得分</text>
+          <text class="result-value">{{ score }} 分</text>
+        </view>
+      </view>
+      <view class="btn btn--primary" @tap="submitScore">提交成绩</view>
+      <view class="btn btn--outline" @tap="restartTest">再测一次</view>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+var TIMER_SECONDS = 15
+var ROUND_COUNT = 8
+
+var QUESTION_BANK = [
+  { seq: '2, 4, 6, 8, ?', options: ['9', '10', '11', '12'], answer: '10' },
+  { seq: '21, 18, 15, 12, ?', options: ['10', '9', '8', '7'], answer: '9' },
+  { seq: '1, 2, 4, 8, ?', options: ['12', '14', '16', '20'], answer: '16' },
+  { seq: '1, 3, 9, 27, ?', options: ['54', '72', '81', '90'], answer: '81' },
+  { seq: '1, 1, 2, 3, 5, 8, ?', options: ['10', '12', '13', '15'], answer: '13' },
+  { seq: '2, 3, 5, 8, 13, ?', options: ['18', '20', '21', '24'], answer: '21' },
+  { seq: '1, 4, 9, 16, 25, ?', options: ['30', '32', '36', '49'], answer: '36' },
+  { seq: '1, 8, 27, 64, ?', options: ['100', '121', '125', '144'], answer: '125' },
+  { seq: '5, 7, 6, 8, 7, ?', options: ['8', '9', '10', '11'], answer: '9' },
+  { seq: '2, 5, 6, 9, 10, ?', options: ['11', '12', '13', '14'], answer: '13' },
+  { seq: '1, 3, 5, 7, 9, ?', options: ['10', '11', '12', '13'], answer: '11' },
+  { seq: '2, 5, 11, 23, ?', options: ['35', '41', '46', '47'], answer: '47' },
+  { seq: '1, 3, 7, 13, 21, ?', options: ['27', '29', '31', '33'], answer: '31' },
+  { seq: '3, 8, 6, 11, 9, ?', options: ['12', '13', '14', '15'], answer: '14' },
+  { seq: '2, 3, 5, 7, 11, ?', options: ['12', '13', '15', '17'], answer: '13' },
+  { seq: '64, 32, 16, 8, ?', options: ['2', '4', '6', '8'], answer: '4' },
+  { seq: '4, 7, 10, 13, 16, ?', options: ['17', '18', '19', '21'], answer: '19' },
+  { seq: '50, 45, 40, 35, ?', options: ['25', '28', '30', '32'], answer: '30' },
+  { seq: '1, 5, 9, 13, 17, ?', options: ['19', '20', '21', '23'], answer: '21' },
+  { seq: '3, 5, 9, 17, 33, ?', options: ['49', '57', '63', '65'], answer: '65' },
+  { seq: '4, 8, 7, 14, 13, ?', options: ['20', '24', '25', '26'], answer: '26' },
+  { seq: '1, 3, 6, 10, 15, ?', options: ['18', '20', '21', '24'], answer: '21' },
+  { seq: '30, 28, 24, 18, 10, ?', options: ['0', '2', '4', '6'], answer: '0' },
+  { seq: '7, 14, 28, 56, ?', options: ['84', '96', '108', '112'], answer: '112' }
+]
+
+export default {
+  data() {
+    return {
+      step: 'intro',
+      questions: [],
+      currentQ: 0,
+      selectedIndex: -1,
+      correctCount: 0,
+      timeLeft: TIMER_SECONDS,
+      timer: null,
+      answered: false,
+      score: 0,
+      memberId: ''
+    }
+  },
+  computed: {
+    currentQuestion: function() {
+      if (this.questions.length === 0) {
+        return null
+      }
+      return this.questions[this.currentQ]
+    },
+    timerSeconds: function() {
+      return Math.ceil(this.timeLeft)
+    },
+    timerPct: function() {
+      var pct = (this.timeLeft / TIMER_SECONDS) * 100
+      if (pct < 0) {
+        pct = 0
+      }
+      return pct
+    },
+    scoreText: function() {
+      var s = this.score
+      if (s >= 90) return '太棒了,逻辑满分!'
+      if (s >= 70) return '逻辑思维很出色!'
+      if (s >= 50) return '表现不错,继续加油!'
+      return '多练习数列规律会更好!'
+    }
+  },
+  onLoad(options) {
+    if (options && options.memberId) {
+      this.memberId = options.memberId
+    }
+    this.memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+  },
+  onUnload() {
+    this.stopTimer()
+  },
+  methods: {
+    shuffle: function(arr) {
+      var a = arr.slice()
+      for (var i = a.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = a[i]
+        a[i] = a[j]
+        a[j] = tmp
+      }
+      return a
+    },
+    startTest: function() {
+      var shuffled = this.shuffle(QUESTION_BANK)
+      this.questions = shuffled.slice(0, ROUND_COUNT)
+      this.currentQ = 0
+      this.correctCount = 0
+      this.score = 0
+      this.answered = false
+      this.selectedIndex = -1
+      this.step = 'playing'
+      this.startTimer()
+    },
+    startTimer: function() {
+      this.stopTimer()
+      this.timeLeft = TIMER_SECONDS
+      var self = this
+      this.timer = setInterval(function() {
+        self.timeLeft -= 0.1
+        if (self.timeLeft <= 0.01) {
+          self.timeLeft = 0
+          self.onTimeout()
+        }
+      }, 100)
+    },
+    stopTimer: function() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    selectOption: function(idx) {
+      if (this.answered) return
+      this.answered = true
+      this.selectedIndex = idx
+      this.stopTimer()
+      var q = this.currentQuestion
+      if (q && q.options[idx] === q.answer) {
+        this.correctCount++
+      }
+      var self = this
+      setTimeout(function() {
+        self.advance()
+      }, 400)
+    },
+    onTimeout: function() {
+      if (this.answered) return
+      this.answered = true
+      this.stopTimer()
+      this.selectedIndex = -1
+      var self = this
+      setTimeout(function() {
+        self.advance()
+      }, 400)
+    },
+    advance: function() {
+      if (this.currentQ < this.questions.length - 1) {
+        this.currentQ++
+        this.answered = false
+        this.selectedIndex = -1
+        this.startTimer()
+      } else {
+        this.finishTest()
+      }
+    },
+    finishTest: function() {
+      this.stopTimer()
+      this.score = Math.round((this.correctCount / ROUND_COUNT) * 100)
+      this.step = 'result'
+    },
+    restartTest: function() {
+      this.stopTimer()
+      this.startTest()
+    },
+    submitScore: function() {
+      if (!this.memberId) {
+        uni.showToast({ title: '未获取到成员信息', icon: 'none' })
+        return
+      }
+      submitCognitiveSelfTest(this.memberId, 'logicScore', this.score)
+        .then((res) => {
+          if (res && res.code === 200) {
+            uni.showToast({ title: '提交成功', icon: 'success' })
+            var self = this
+            setTimeout(function() {
+              uni.navigateBack()
+            }, 800)
+          } else {
+            uni.showToast({ title: '提交失败', icon: 'none' })
+          }
+        })
+        .catch(function() {
+          uni.showToast({ title: '提交失败', icon: 'none' })
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+  box-sizing: border-box;
+}
+
+/* ===== 引导页 ===== */
+.step-intro {
+  padding-top: 80rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.intro-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(255, 215, 0, 0.15);
+  border: 3rpx solid #FFD700;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+.intro-icon-text {
+  font-size: 72rpx;
+}
+.intro-title {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #FFD700;
+  margin-bottom: 16rpx;
+  display: block;
+}
+.intro-desc {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+  margin-bottom: 50rpx;
+  display: block;
+}
+.intro-info {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 20rpx 30rpx;
+  margin-bottom: 60rpx;
+  box-sizing: border-box;
+}
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.info-row + .info-row {
+  border-top: 1rpx solid rgba(255, 255, 255, 0.08);
+}
+.info-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+}
+.info-value {
+  font-size: 26rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+
+/* ===== 答题页 ===== */
+.quiz-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.quiz-progress {
+  font-size: 30rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.quiz-timer {
+  font-size: 30rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+.timer-track {
+  height: 12rpx;
+  background: rgba(255, 255, 255, 0.12);
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 50rpx;
+}
+.timer-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #FFD700, #FFA500);
+  border-radius: 6rpx;
+  transition: width 0.1s linear;
+}
+.question-card {
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 40rpx 30rpx;
+  box-sizing: border-box;
+}
+.q-text {
+  font-size: 44rpx;
+  color: #fff;
+  font-weight: bold;
+  text-align: center;
+  display: block;
+  margin-bottom: 50rpx;
+  letter-spacing: 4rpx;
+}
+.q-options {
+  display: flex;
+  flex-direction: column;
+}
+.q-option {
+  padding: 26rpx 0;
+  border-radius: 16rpx;
+  border: 3rpx solid rgba(255, 255, 255, 0.2);
+  background: #24243e;
+  text-align: center;
+  margin-bottom: 20rpx;
+  transition: all 0.2s;
+}
+.q-option:last-child {
+  margin-bottom: 0;
+}
+.q-option--selected {
+  border-color: #FFD700;
+  background: rgba(255, 215, 0, 0.12);
+}
+.q-option:active {
+  transform: scale(0.98);
+}
+.q-option-text {
+  font-size: 34rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.q-option--selected .q-option-text {
+  color: #FFD700;
+}
+
+/* ===== 结果页 ===== */
+.step-result {
+  padding-top: 100rpx;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.score-circle {
+  width: 240rpx;
+  height: 240rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 8rpx 40rpx rgba(255, 215, 0, 0.35);
+  margin-bottom: 30rpx;
+}
+.score-value {
+  font-size: 80rpx;
+  font-weight: bold;
+  color: #1a1a2e;
+}
+.score-label {
+  font-size: 30rpx;
+  color: #FFD700;
+  margin-bottom: 50rpx;
+  display: block;
+}
+.result-info {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 20rpx 30rpx;
+  margin-bottom: 60rpx;
+  box-sizing: border-box;
+}
+.result-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 16rpx 0;
+}
+.result-row + .result-row {
+  border-top: 1rpx solid rgba(255, 255, 255, 0.08);
+}
+.result-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.7);
+}
+.result-value {
+  font-size: 28rpx;
+  color: #fff;
+  font-weight: bold;
+}
+.dim-tag {
+  padding: 6rpx 24rpx;
+  border-radius: 24rpx;
+  border: 3rpx solid #FFD700;
+  background: rgba(255, 215, 0, 0.12);
+}
+.dim-tag-text {
+  font-size: 24rpx;
+  color: #FFD700;
+  font-weight: bold;
+}
+
+/* ===== 按钮 ===== */
+.btn {
+  width: 100%;
+  padding: 26rpx 0;
+  border-radius: 50rpx;
+  text-align: center;
+  font-size: 30rpx;
+  font-weight: bold;
+  box-sizing: border-box;
+  margin-bottom: 24rpx;
+}
+.btn:last-child {
+  margin-bottom: 0;
+}
+.btn--primary {
+  background: linear-gradient(135deg, #FFD700, #FFA500);
+  color: #1a1a2e;
+}
+.btn--primary:active {
+  opacity: 0.9;
+  transform: scale(0.98);
+}
+.btn--outline {
+  border: 3rpx solid #FFD700;
+  color: #FFD700;
+  background: transparent;
+}
+.btn--outline:active {
+  background: rgba(255, 215, 0, 0.12);
+}
+</style>

+ 675 - 0
cfc-frontend/pages/cognitive-test/memory-test.vue

@@ -0,0 +1,675 @@
+<template>
+  <view class="page">
+
+    <!-- ============ 1. 说明页 ============ -->
+    <view class="intro" v-if="step === 'intro'">
+      <view class="intro-icon">
+        <text class="intro-icon-text">🧠</text>
+      </view>
+      <text class="intro-title">记忆力测试</text>
+      <text class="intro-desc">屏幕上会快速闪现一串数字,数字消失后,用下方数字键盘把它重新输入出来。每答对一关,数字就会增加一位,看看你能记住多少位!</text>
+      <view class="intro-rule">
+        <text class="rule-item">起始位数:3 位</text>
+        <text class="rule-item">最高位数:8 位</text>
+        <text class="rule-item">共 6 关</text>
+      </view>
+      <button class="primary-btn" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- ============ 2. 测试中 ============ -->
+    <view class="playing" v-if="step === 'playing'">
+      <view class="progress-header">
+        <text class="progress-level">第 {{ levelIndex }} 关</text>
+        <text class="progress-digit">{{ digitCount }} 位数</text>
+      </view>
+      <view class="progress-track">
+        <view class="progress-fill" :style="{ width: progressPct + '%' }"></view>
+      </view>
+
+      <!-- 展示阶段:数字闪现 + 计时条 -->
+      <view class="show-area" v-if="phase === 'show'">
+        <text class="show-hint">记住这串数字</text>
+        <view class="digit-row">
+          <view class="show-digit" v-for="(d, i) in showDigits" :key="getKey(d, i)">
+            <text>{{ d }}</text>
+          </view>
+        </view>
+        <view class="timer-bar-wrap">
+          <view class="timer-bar" :style="{ width: timerProgress * 100 + '%' }"></view>
+        </view>
+      </view>
+
+      <!-- 输入阶段:数字键盘 -->
+      <view class="input-area" v-if="phase === 'input'">
+        <text class="input-hint">请输入刚才的数字</text>
+        <view class="input-display">
+          <view
+            class="input-cell"
+            v-for="(c, i) in inputCells"
+            :key="getCellKey(i)"
+            :class="{ filled: i < userInput.length }"
+          ></view>
+        </view>
+        <view class="pad">
+          <view class="pad-row" v-for="(row, ri) in padRows" :key="getRowKey(ri)">
+            <view
+              class="pad-key"
+              :class="{ 'pad-key-ctrl': key.ctrl }"
+              v-for="(key, ci) in row"
+              :key="getPadKey(key, ri, ci)"
+              @click="onPadPress(key)"
+            >
+              <text>{{ key.v }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+
+      <!-- 反馈动画 -->
+      <view class="feedback feedback-correct" v-if="feedback === 'correct'">
+        <text class="feedback-text">回答正确</text>
+      </view>
+      <view class="feedback feedback-wrong" v-if="feedback === 'wrong'">
+        <text class="feedback-text">回答错误</text>
+      </view>
+    </view>
+
+    <!-- ============ 3. 结果页 ============ -->
+    <view class="result" v-if="step === 'result'">
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+        <text class="score-unit">分</text>
+      </view>
+      <view class="max-level">
+        <text class="max-level-label">最高记忆</text>
+        <text class="max-level-value">{{ maxLevel === 0 ? 3 : maxLevel }}位数</text>
+      </view>
+      <view class="dimension-tag">
+        <text>记忆力</text>
+      </view>
+      <button class="primary-btn submit-btn" :disabled="submitting" @click="submitScore">
+        {{ submitting ? '提交中...' : '提交成绩' }}
+      </button>
+      <button class="ghost-btn" @click="startTest">再测一次</button>
+    </view>
+
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      step: 'intro',          // intro | playing | result
+      phase: 'show',          // show | input
+      digitCount: 3,          // 当前关卡位数 3-8
+      currentNumber: '',      // 需要记忆的数字串
+      userInput: [],          // 用户已输入的数字
+      inputLocked: false,     // 反馈期间锁定键盘
+      feedback: '',           // '' | correct | wrong
+      showStart: 0,           // 数字展示开始时间戳
+      showDuration: 0,        // 数字展示时长(ms)
+      timerProgress: 1,       // 计时条剩余比例 0-1
+      showTimer: null,
+      correctTimer: null,
+      wrongTimer: null,
+      submitTimer: null,
+      maxLevel: 0,            // 成功记住的最高位数
+      score: 0,
+      submitting: false,
+      memberId: '',
+      padRows: [
+        [{ v: 1 }, { v: 2 }, { v: 3 }],
+        [{ v: 4 }, { v: 5 }, { v: 6 }],
+        [{ v: 7 }, { v: 8 }, { v: 9 }],
+        [{ v: '清除', ctrl: true }, { v: 0 }, { v: '确认', ctrl: true }]
+      ]
+    }
+  },
+  computed: {
+    levelIndex() {
+      return this.digitCount - 2
+    },
+    progressPct() {
+      return (this.levelIndex / 6) * 100
+    },
+    showDigits() {
+      return (this.currentNumber || '').split('')
+    },
+    inputCells() {
+      var arr = []
+      for (var i = 0; i < this.digitCount; i++) {
+        arr.push(i)
+      }
+      return arr
+    }
+  },
+  onLoad(options) {
+    var id = (options && options.memberId) || uni.getStorageSync('currentChildId') || ''
+    this.memberId = id
+  },
+  onUnload() {
+    this.clearAllTimers()
+  },
+  methods: {
+    // key 一律用方法调用,禁止模板内表达式
+    getKey(d, i) {
+      return 'digit-' + i
+    },
+    getCellKey(i) {
+      return 'cell-' + i
+    },
+    getRowKey(ri) {
+      return 'row-' + ri
+    },
+    getPadKey(key, ri, ci) {
+      return 'pad-' + ri + '-' + ci
+    },
+    clearAllTimers() {
+      if (this.showTimer) {
+        clearInterval(this.showTimer)
+        this.showTimer = null
+      }
+      if (this.correctTimer) {
+        clearTimeout(this.correctTimer)
+        this.correctTimer = null
+      }
+      if (this.wrongTimer) {
+        clearTimeout(this.wrongTimer)
+        this.wrongTimer = null
+      }
+      if (this.submitTimer) {
+        clearTimeout(this.submitTimer)
+        this.submitTimer = null
+      }
+    },
+    startTest() {
+      this.clearAllTimers()
+      this.digitCount = 3
+      this.maxLevel = 0
+      this.score = 0
+      this.userInput = []
+      this.inputLocked = false
+      this.feedback = ''
+      this.step = 'playing'
+      this.startRound()
+    },
+    startRound() {
+      this.phase = 'show'
+      this.userInput = []
+      this.inputLocked = false
+      this.feedback = ''
+      // 生成随机数字串
+      var num = ''
+      for (var i = 0; i < this.digitCount; i++) {
+        num += Math.floor(Math.random() * 10)
+      }
+      this.currentNumber = num
+      this.showStart = Date.now()
+      this.showDuration = Math.max(1.5, this.digitCount * 0.5) * 1000
+      this.timerProgress = 1
+      if (this.showTimer) {
+        clearInterval(this.showTimer)
+        this.showTimer = null
+      }
+      this.showTimer = setInterval(() => {
+        var remaining = this.showDuration - (Date.now() - this.showStart)
+        if (remaining <= 0) {
+          if (this.showTimer) {
+            clearInterval(this.showTimer)
+            this.showTimer = null
+          }
+          this.timerProgress = 0
+          this.phase = 'input'
+          return
+        }
+        this.timerProgress = remaining / this.showDuration
+      }, 50)
+    },
+    onPadPress(key) {
+      if (this.feedback || this.inputLocked) return
+      var v = key.v
+      if (typeof v === 'number') {
+        if (this.userInput.length < this.digitCount) {
+          this.userInput.push(v)
+        }
+      } else if (v === '清除') {
+        this.userInput.pop()
+      } else if (v === '确认') {
+        this.confirmInput()
+      }
+    },
+    confirmInput() {
+      if (this.userInput.length < this.digitCount) return
+      this.inputLocked = true
+      var entered = this.userInput.join('')
+      if (entered === this.currentNumber) {
+        this.feedback = 'correct'
+        this.correctTimer = setTimeout(() => {
+          this.correctTimer = null
+          this.feedback = ''
+          this.maxLevel = this.digitCount
+          if (this.digitCount >= 8) {
+            // 最高关卡已通过,测试结束
+            this.endGame()
+          } else {
+            this.digitCount++
+            this.startRound()
+          }
+        }, 700)
+      } else {
+        this.feedback = 'wrong'
+        this.wrongTimer = setTimeout(() => {
+          this.wrongTimer = null
+          this.feedback = ''
+          this.endGame()
+        }, 900)
+      }
+    },
+    endGame() {
+      this.clearAllTimers()
+      // 按最高成功记住的位数映射得分
+      var score = 100
+      if (this.maxLevel === 0) {
+        score = 40
+      } else if (this.maxLevel === 3) {
+        score = 55
+      } else if (this.maxLevel === 4) {
+        score = 70
+      } else if (this.maxLevel === 5) {
+        score = 82
+      } else if (this.maxLevel === 6) {
+        score = 92
+      }
+      this.score = score
+      this.step = 'result'
+    },
+    submitScore() {
+      if (this.submitting) return
+      var memberId = this.memberId
+      if (!memberId) {
+        uni.showToast({ title: '未找到测评成员', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      submitCognitiveSelfTest(memberId, 'memoryScore', this.score)
+        .then(() => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          this.submitTimer = setTimeout(() => {
+            var pages = getCurrentPages()
+            if (pages.length > 1) {
+              uni.navigateBack()
+            } else {
+              uni.reLaunch({ url: '/pages/index/index' })
+            }
+          }, 800)
+        })
+        .catch(() => {
+          // request() 已自动弹出失败提示,这里只需恢复按钮状态
+          this.submitting = false
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 60rpx 40rpx 80rpx;
+  box-sizing: border-box;
+}
+
+/* ---------- 通用按钮 ---------- */
+.primary-btn {
+  margin-top: 60rpx;
+  width: 480rpx;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+  color: #ffffff;
+  font-size: 34rpx;
+  font-weight: 600;
+  border: none;
+  box-shadow: 0 8rpx 24rpx rgba(99, 102, 241, 0.35);
+}
+.primary-btn::after {
+  border: none;
+}
+.primary-btn[disabled] {
+  opacity: 0.6;
+  color: #ffffff;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+}
+
+.ghost-btn {
+  margin-top: 30rpx;
+  width: 480rpx;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  background: transparent;
+  color: #a5b4fc;
+  font-size: 32rpx;
+  border: 2rpx solid rgba(99, 102, 241, 0.5);
+}
+.ghost-btn::after {
+  border: none;
+}
+
+/* ---------- 说明页 ---------- */
+.intro {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 80rpx;
+}
+.intro-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(99, 102, 241, 0.15);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 40rpx;
+}
+.intro-icon-text {
+  font-size: 80rpx;
+}
+.intro-title {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 30rpx;
+}
+.intro-desc {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.75);
+  line-height: 1.7;
+  text-align: center;
+  padding: 0 20rpx;
+}
+.intro-rule {
+  display: flex;
+  flex-direction: row;
+  flex-wrap: wrap;
+  justify-content: center;
+  margin-top: 40rpx;
+}
+.rule-item {
+  font-size: 24rpx;
+  color: #a5b4fc;
+  background: rgba(99, 102, 241, 0.12);
+  border-radius: 24rpx;
+  padding: 10rpx 24rpx;
+  margin: 8rpx 12rpx;
+}
+
+/* ---------- 测试中 ---------- */
+.playing {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.progress-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 20rpx;
+}
+.progress-level {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.progress-digit {
+  font-size: 26rpx;
+  color: #a5b4fc;
+  margin-top: 8rpx;
+}
+.progress-track {
+  width: 480rpx;
+  height: 8rpx;
+  border-radius: 4rpx;
+  background: rgba(255, 255, 255, 0.1);
+  margin-top: 24rpx;
+  overflow: hidden;
+}
+.progress-fill {
+  height: 100%;
+  border-radius: 4rpx;
+  background: linear-gradient(90deg, #6366f1, #818cf8);
+  transition: width 0.3s ease;
+}
+
+/* 展示阶段:数字闪现 */
+.show-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 120rpx;
+}
+.show-hint {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 60rpx;
+}
+.digit-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  align-items: center;
+}
+.show-digit {
+  width: 76rpx;
+  height: 140rpx;
+  margin: 0 4rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 16rpx;
+}
+.show-digit text {
+  font-size: 120rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.timer-bar-wrap {
+  width: 480rpx;
+  height: 12rpx;
+  border-radius: 6rpx;
+  background: rgba(255, 255, 255, 0.1);
+  overflow: hidden;
+  margin-top: 60rpx;
+}
+.timer-bar {
+  height: 100%;
+  border-radius: 6rpx;
+  background: linear-gradient(90deg, #6366f1, #818cf8);
+  transition: width 0.05s linear;
+}
+
+/* 输入阶段:数字键盘 */
+.input-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 60rpx;
+}
+.input-hint {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 50rpx;
+}
+.input-display {
+  display: flex;
+  flex-direction: row;
+  justify-content: center;
+  min-height: 100rpx;
+  margin-bottom: 40rpx;
+}
+.input-cell {
+  width: 76rpx;
+  height: 100rpx;
+  margin: 0 4rpx;
+  border-radius: 16rpx;
+  background: rgba(255, 255, 255, 0.04);
+  border: 2rpx solid rgba(255, 255, 255, 0.15);
+  box-sizing: border-box;
+}
+.input-cell.filled {
+  background: rgba(99, 102, 241, 0.5);
+  border-color: #6366f1;
+}
+.pad {
+  width: 100%;
+  max-width: 620rpx;
+}
+.pad-row {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+  margin-bottom: 20rpx;
+}
+.pad-key {
+  width: 180rpx;
+  height: 108rpx;
+  border-radius: 20rpx;
+  background: rgba(255, 255, 255, 0.06);
+  border: 2rpx solid rgba(255, 255, 255, 0.08);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  box-sizing: border-box;
+}
+.pad-key text {
+  font-size: 44rpx;
+  color: #ffffff;
+  font-weight: 500;
+}
+.pad-key:active {
+  border-color: #6366f1;
+  background: rgba(99, 102, 241, 0.2);
+}
+.pad-key-ctrl {
+  background: rgba(255, 255, 255, 0.03);
+}
+.pad-key-ctrl text {
+  font-size: 32rpx;
+  color: #a5b4fc;
+}
+
+/* 反馈动画 */
+.feedback {
+  position: fixed;
+  left: 0;
+  right: 0;
+  top: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 99;
+  animation: feedbackFade 0.7s ease both;
+}
+.feedback-correct {
+  background: rgba(16, 185, 129, 0.3);
+}
+.feedback-wrong {
+  background: rgba(239, 68, 68, 0.3);
+}
+.feedback-text {
+  font-size: 56rpx;
+  font-weight: bold;
+  color: #ffffff;
+  padding: 24rpx 60rpx;
+  border-radius: 24rpx;
+  background: rgba(0, 0, 0, 0.35);
+}
+@keyframes feedbackFade {
+  0% {
+    opacity: 0;
+  }
+  20% {
+    opacity: 1;
+  }
+  80% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+
+/* ---------- 结果页 ---------- */
+.result {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+  padding-top: 100rpx;
+}
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #6366f1 0%, #818cf8 100%);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  box-shadow: 0 12rpx 40rpx rgba(99, 102, 241, 0.45);
+}
+.score-num {
+  font-size: 96rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1;
+}
+.score-unit {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-top: 8rpx;
+}
+.max-level {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+  margin-top: 60rpx;
+}
+.max-level-label {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.6);
+  margin-right: 16rpx;
+}
+.max-level-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+.dimension-tag {
+  margin-top: 24rpx;
+  font-size: 26rpx;
+  color: #a5b4fc;
+  background: rgba(99, 102, 241, 0.15);
+  border: 2rpx solid rgba(99, 102, 241, 0.4);
+  border-radius: 24rpx;
+  padding: 8rpx 28rpx;
+}
+.submit-btn {
+  margin-top: 80rpx;
+}
+</style>

+ 457 - 0
cfc-frontend/pages/cognitive-test/perception-test.vue

@@ -0,0 +1,457 @@
+<template>
+  <view class="test-container">
+
+    <!-- 步骤一:测试说明 -->
+    <view class="step-panel" v-if="step === 'instruction'">
+      <view class="title-icon">👁️</view>
+      <text class="test-title">感知觉测试</text>
+      <text class="test-desc">屏幕上会出现 6 个相似的图案,其中有一个与众不同。</text>
+      <text class="test-desc">请快速找出"不一样"的那个,点击它即可!</text>
+      <text class="test-meta">共 8 题 · 每题限时 8 秒 · 超时自动进入下一题</text>
+      <button class="btn-primary" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- 步骤二:答题中 -->
+    <view class="step-panel" v-else-if="step === 'playing'">
+      <view class="progress-row">
+        <text class="progress-text">第 {{ currentIndex + 1 }}/8 题</text>
+        <text class="progress-text">答对 {{ correctCount }} 题</text>
+      </view>
+
+      <view class="timer-bar">
+        <view class="timer-fill" :style="timerBarStyle"></view>
+      </view>
+      <text class="timer-text">{{ timerLeft }}秒</text>
+
+      <text class="question-hint" v-if="currentQuestion">{{ currentQuestion.hint }}</text>
+
+      <view class="grid-wrap">
+        <view
+          class="grid-cell"
+          v-for="(item, index) in currentQuestion.items"
+          :key="getItemKey(item, index)"
+          :class="{
+            'cell-correct': lockInput && index === currentQuestion.oddIndex,
+            'cell-wrong': tappedIndex === index && index !== currentQuestion.oddIndex
+          }"
+          @click="handleTap(index)"
+        >
+          <text class="cell-emoji">{{ item }}</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- 步骤三:结果 -->
+    <view class="step-panel" v-else>
+      <text class="result-title">测试完成</text>
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+      </view>
+      <view class="score-row">
+        <text class="score-label">答对 {{ correctCount }}/8 题</text>
+        <text class="dimension-tag">感知觉</text>
+      </view>
+      <view class="result-tip" v-if="score >= 75">
+        <text class="result-tip-text">感官敏锐,找不同又快又准!</text>
+      </view>
+      <view class="result-tip" v-else-if="score >= 50">
+        <text class="result-tip-text">表现不错,再细心一点会更好!</text>
+      </view>
+      <view class="result-tip" v-else>
+        <text class="result-tip-text">别灰心,多练几次会越来越快!</text>
+      </view>
+      <button class="btn-primary" @click="submitResult">提交成绩</button>
+      <button class="btn-secondary" @click="retryTest">再测一次</button>
+    </view>
+
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+// 题库:6 个相似图案,其中一个是"不同"的(颜色/形状变体)
+var QUESTION_BANK = [
+  { items: ['🟦','🟦','🟦','🟦','🟦','🟥'], oddIndex: 5, hint: '找出不同的颜色' },
+  { items: ['🟨','🟨','🟨','🟨','🟨','🟩'], oddIndex: 3, hint: '找出不同的颜色' },
+  { items: ['🟪','🟪','🟪','🟪','🟪','🟦'], oddIndex: 4, hint: '找出不同的颜色' },
+  { items: ['🟩','🟩','🟩','🟩','🟩','🟨'], oddIndex: 1, hint: '找出不同的颜色' },
+  { items: ['🟥','🟥','🟥','🟥','🟥','🟪'], oddIndex: 2, hint: '找出不同的颜色' },
+  { items: ['🟡','🟡','🟡','🟡','🟡','🟢'], oddIndex: 0, hint: '找出不同的颜色' },
+  { items: ['⬛','⬛','⬛','⬛','⬛','⬜'], oddIndex: 5, hint: '找出不同的颜色' },
+  { items: ['🔴','🔴','🔴','🔴','🔴','🟡'], oddIndex: 2, hint: '找出不同的颜色' },
+  { items: ['🟨','🟨','🟨','🟨','🟨','🟥'], oddIndex: 0, hint: '找出不同的颜色' },
+  { items: ['🟩','🟩','🟩','🟩','🟩','🟦'], oddIndex: 4, hint: '找出不同的颜色' },
+  { items: ['🟪','🟪','🟪','🟪','🟪','⬛'], oddIndex: 1, hint: '找出不同的图案' },
+  { items: ['⬜','⬜','⬜','⬜','⬜','⬛'], oddIndex: 3, hint: '找出不同的图案' },
+  { items: ['🔴','🔴','🔴','🔴','🔴','🟥'], oddIndex: 5, hint: '找出不同的图案' },
+  { items: ['🟥','🟥','🟥','🟥','🟥','🔴'], oddIndex: 2, hint: '找出不同的图案' },
+  { items: ['🟡','🟡','🟡','🟡','🟡','🟢'], oddIndex: 1, hint: '找出不同的颜色' },
+  { items: ['🟦','🟦','🟦','🟦','🟦','⬜'], oddIndex: 4, hint: '找出不同的图案' },
+  { items: ['⬡','⬡','⬡','⬡','⬡','⬢'], oddIndex: 5, hint: '找出不同的形状' },
+  { items: ['◈','◈','◈','◈','◈','◆'], oddIndex: 1, hint: '找出不同的形状' },
+  { items: ['○','○','○','○','○','◇'], oddIndex: 3, hint: '找出不同的形状' },
+  { items: ['◆','◆','◆','◆','◆','◈'], oddIndex: 0, hint: '找出不同的形状' },
+  { items: ['⬢','⬢','⬢','⬢','⬢','⬡'], oddIndex: 2, hint: '找出不同的形状' },
+  { items: ['◇','◇','◇','◇','◇','○'], oddIndex: 5, hint: '找出不同的形状' }
+]
+
+export default {
+  data() {
+    return {
+      step: 'instruction',
+      memberId: '',
+      roundQuestions: [],
+      currentIndex: 0,
+      currentQuestion: null,
+      correctCount: 0,
+      timerLeft: 8,
+      timer: null,
+      nextTimer: null,
+      lockInput: false,
+      tappedIndex: -1,
+      score: 0
+    }
+  },
+  computed: {
+    timerPercent: function() {
+      return Math.round(this.timerLeft / 8 * 100)
+    },
+    timerBarStyle: function() {
+      return 'width:' + this.timerPercent + '%'
+    }
+  },
+  onLoad(options) {
+    if (options && options.memberId) {
+      this.memberId = options.memberId
+    }
+  },
+  onUnload() {
+    this.stopTimer()
+    if (this.nextTimer) {
+      clearTimeout(this.nextTimer)
+      this.nextTimer = null
+    }
+  },
+  methods: {
+    getItemKey: function(item, index) {
+      return item + '-' + index
+    },
+    startTest: function() {
+      this.correctCount = 0
+      this.currentIndex = 0
+      this.score = 0
+      this.lockInput = false
+      this.tappedIndex = -1
+      // 洗牌后取前 8 题
+      var bank = QUESTION_BANK.slice()
+      for (var i = bank.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = bank[i]
+        bank[i] = bank[j]
+        bank[j] = tmp
+      }
+      this.roundQuestions = bank.slice(0, 8)
+      this.step = 'playing'
+      this.loadQuestion(0)
+    },
+    loadQuestion: function(index) {
+      this.currentIndex = index
+      this.currentQuestion = this.roundQuestions[index]
+      this.tappedIndex = -1
+      this.lockInput = false
+      this.timerLeft = 8
+      this.startTimer()
+    },
+    startTimer: function() {
+      this.stopTimer()
+      this.timer = setInterval(() => {
+        this.timerLeft--
+        if (this.timerLeft <= 0) {
+          this.timerLeft = 0
+          this.onTimeout()
+        }
+      }, 1000)
+    },
+    stopTimer: function() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    handleTap: function(index) {
+      if (this.lockInput || !this.currentQuestion) return
+      this.lockInput = true
+      this.stopTimer()
+      this.tappedIndex = index
+      if (index === this.currentQuestion.oddIndex) {
+        this.correctCount++
+      }
+      this.scheduleNext()
+    },
+    onTimeout: function() {
+      if (this.lockInput) return
+      this.lockInput = true
+      this.stopTimer()
+      this.tappedIndex = -1
+      // 超时未作答,短暂高亮正确答案后进入下一题
+      this.scheduleNext()
+    },
+    scheduleNext: function() {
+      if (this.nextTimer) clearTimeout(this.nextTimer)
+      this.nextTimer = setTimeout(() => {
+        this.nextTimer = null
+        this.advance()
+      }, 700)
+    },
+    advance: function() {
+      if (this.currentIndex + 1 >= this.roundQuestions.length) {
+        this.finishTest()
+      } else {
+        this.loadQuestion(this.currentIndex + 1)
+      }
+    },
+    finishTest: function() {
+      this.stopTimer()
+      this.score = Math.round(this.correctCount / this.roundQuestions.length * 100)
+      this.step = 'result'
+    },
+    submitResult: function() {
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      submitCognitiveSelfTest(memberId, 'perceptionScore', this.score)
+        .then((res) => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 1500)
+        })
+        .catch((err) => {
+          console.error('提交感知觉测试成绩失败', err)
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    },
+    retryTest: function() {
+      this.startTest()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.test-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+}
+
+.step-panel {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* ===== 说明页 ===== */
+.title-icon {
+  font-size: 88rpx;
+  margin-bottom: 24rpx;
+}
+
+.test-title {
+  font-size: 48rpx;
+  font-weight: bold;
+  color: #fff;
+  margin-bottom: 32rpx;
+}
+
+.test-desc {
+  font-size: 30rpx;
+  color: rgba(255,255,255,0.7);
+  line-height: 1.7;
+  text-align: center;
+}
+
+.test-meta {
+  font-size: 24rpx;
+  color: rgba(255,255,255,0.45);
+  margin-top: 24rpx;
+  margin-bottom: 80rpx;
+  text-align: center;
+}
+
+.btn-primary {
+  background: linear-gradient(135deg, #4ECDC4, #45B7AA);
+  color: #fff;
+  font-size: 34rpx;
+  font-weight: bold;
+  width: 480rpx;
+  height: 92rpx;
+  line-height: 92rpx;
+  border-radius: 46rpx;
+  border: none;
+  margin-top: 60rpx;
+  padding: 0;
+}
+
+.btn-primary::after {
+  border: none;
+}
+
+.btn-secondary {
+  background: rgba(255,255,255,0.1);
+  color: rgba(255,255,255,0.85);
+  font-size: 32rpx;
+  width: 480rpx;
+  height: 92rpx;
+  line-height: 92rpx;
+  border-radius: 46rpx;
+  border: 2rpx solid rgba(255,255,255,0.25);
+  margin-top: 32rpx;
+  padding: 0;
+}
+
+.btn-secondary::after {
+  border: none;
+}
+
+/* ===== 答题页 ===== */
+.progress-row {
+  display: flex;
+  justify-content: space-between;
+  width: 100%;
+  margin-bottom: 24rpx;
+}
+
+.progress-text {
+  font-size: 28rpx;
+  color: rgba(255,255,255,0.7);
+}
+
+.timer-bar {
+  width: 100%;
+  height: 16rpx;
+  background: rgba(255,255,255,0.12);
+  border-radius: 8rpx;
+  overflow: hidden;
+}
+
+.timer-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #4ECDC4, #45B7AA);
+  border-radius: 8rpx;
+  transition: width 0.9s linear;
+}
+
+.timer-text {
+  display: block;
+  font-size: 26rpx;
+  color: #4ECDC4;
+  margin-top: 12rpx;
+  text-align: center;
+}
+
+.question-hint {
+  display: block;
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #fff;
+  margin: 40rpx 0 32rpx;
+  text-align: center;
+}
+
+.grid-wrap {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+  width: 100%;
+}
+
+.grid-cell {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 30%;
+  height: 190rpx;
+  background: #2a2a4a;
+  border-radius: 24rpx;
+  border: 4rpx solid transparent;
+  margin-bottom: 24rpx;
+  box-sizing: border-box;
+}
+
+.grid-cell:active {
+  background: #35406b;
+}
+
+.cell-emoji {
+  font-size: 50rpx;
+}
+
+.cell-correct {
+  background: #2f4a4a;
+  border-color: #4ECDC4;
+}
+
+.cell-wrong {
+  background: #4a2f35;
+  border-color: #ff6b6b;
+}
+
+/* ===== 结果页 ===== */
+.result-title {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #fff;
+  margin-bottom: 48rpx;
+}
+
+.score-circle {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 260rpx;
+  height: 260rpx;
+  border-radius: 50%;
+  border: 10rpx solid #4ECDC4;
+  background: rgba(78,205,196,0.1);
+  margin-bottom: 36rpx;
+}
+
+.score-num {
+  font-size: 88rpx;
+  font-weight: bold;
+  color: #4ECDC4;
+}
+
+.score-row {
+  display: flex;
+  align-items: center;
+  margin-bottom: 24rpx;
+}
+
+.score-label {
+  font-size: 30rpx;
+  color: rgba(255,255,255,0.7);
+  margin-right: 20rpx;
+}
+
+.dimension-tag {
+  font-size: 24rpx;
+  color: #4ECDC4;
+  border: 2rpx solid #4ECDC4;
+  border-radius: 24rpx;
+  padding: 6rpx 24rpx;
+}
+
+.result-tip {
+  margin-bottom: 20rpx;
+}
+
+.result-tip-text {
+  font-size: 28rpx;
+  color: rgba(255,255,255,0.6);
+}
+</style>

+ 517 - 0
cfc-frontend/pages/cognitive-test/spatial-test.vue

@@ -0,0 +1,517 @@
+<template>
+  <view class="page">
+    <!-- 开始引导 -->
+    <view v-if="step === 'instruction'" class="container">
+      <view class="hero-icon">
+        <text class="hero-symbol">↻</text>
+      </view>
+      <text class="hero-title">空间思维测试</text>
+      <text class="hero-desc">观察箭头的初始方向,想象它按提示旋转后的样子,从四个选项中选出正确的方向。</text>
+      <view class="rule-list">
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">共 8 题,每题限时 10 秒</text>
+        </view>
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">超时未作答按答错处理</text>
+        </view>
+        <view class="rule-item">
+          <view class="rule-dot"></view>
+          <text class="rule-text">答题结束后自动计算得分</text>
+        </view>
+      </view>
+      <button class="btn btn-primary" @tap="startTest">开始测试</button>
+    </view>
+
+    <!-- 答题中 -->
+    <view v-else-if="step === 'playing'" class="container">
+      <view class="progress-row">
+        <text class="progress-text">第 {{ currentIndex + 1 }}/{{ questions.length }} 题</text>
+        <text class="timer-text">{{ timeLeft }}s</text>
+      </view>
+      <view class="timer-bar">
+        <view class="timer-bar-fill" :style="timerBarStyle"></view>
+      </view>
+
+      <view class="question-box">
+        <text class="direction-label">初始方向</text>
+        <text class="direction-arrow">{{ currentQuestion.direction }}</text>
+        <text class="rotation-tip">顺时针旋转 {{ currentQuestion.rotation }}°</text>
+      </view>
+
+      <view class="options-wrap">
+        <view
+          v-for="opt in currentQuestion.options"
+          :key="getOptKey(opt)"
+          class="option-btn"
+          :class="selected === opt ? 'option-selected' : ''"
+          @tap="handleOptionTap(opt)"
+        >
+          <text class="option-arrow">{{ opt }}</text>
+        </view>
+      </view>
+
+      <view v-if="feedback" class="feedback-row">
+        <text class="feedback-text" :class="'feedback-' + feedback">{{ feedbackText }}</text>
+      </view>
+    </view>
+
+    <!-- 结果 -->
+    <view v-else class="container">
+      <text class="result-title">测试完成</text>
+      <view class="score-circle">
+        <text class="score-number">{{ score }}</text>
+        <text class="score-unit">分</text>
+      </view>
+      <text class="score-detail">答对 {{ correctCount }}/{{ questions.length }} 题</text>
+      <view class="dimension-tag">
+        <text class="dimension-name">空间思维</text>
+      </view>
+
+      <button class="btn btn-primary" :disabled="submitting" @tap="submitScore">提交成绩</button>
+      <button class="btn btn-secondary" @tap="restartTest">再测一次</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+const TOTAL_TIME = 10
+const TOTAL_QUESTIONS = 8
+const DIRECTIONS = ['↑', '→', '↓', '←']
+// 顺时针旋转 90° 的方向映射
+const ROTATE_CW = { '↑': '→', '→': '↓', '↓': '←', '←': '↑' }
+
+function shuffleArray(arr) {
+  var a = arr.slice()
+  var i = a.length - 1
+  while (i > 0) {
+    var j = Math.floor(Math.random() * (i + 1))
+    var tmp = a[i]
+    a[i] = a[j]
+    a[j] = tmp
+    i -= 1
+  }
+  return a
+}
+
+function rotateDirection(direction, degrees) {
+  var result = direction
+  var times = degrees / 90
+  for (var i = 0; i < times; i++) {
+    result = ROTATE_CW[result]
+  }
+  return result
+}
+
+function buildQuestionBank() {
+  var bank = []
+  var rotations = [90, 180, 270]
+  for (var d = 0; d < DIRECTIONS.length; d++) {
+    var dir = DIRECTIONS[d]
+    for (var r = 0; r < rotations.length; r++) {
+      var rot = rotations[r]
+      var answer = rotateDirection(dir, rot)
+      // 每个 方向×角度 组合生成 2 题,共 24 题
+      bank.push({ direction: dir, rotation: rot, answer: answer, options: shuffleArray(DIRECTIONS) })
+      bank.push({ direction: dir, rotation: rot, answer: answer, options: shuffleArray(DIRECTIONS) })
+    }
+  }
+  return bank
+}
+
+const QUESTION_BANK = buildQuestionBank()
+
+export default {
+  data() {
+    return {
+      memberId: '',
+      step: 'instruction', // instruction | playing | result
+      questions: [],
+      currentIndex: 0,
+      selected: '',
+      answered: false,
+      feedback: '', // '' | correct | wrong | timeout
+      correctCount: 0,
+      timeLeft: TOTAL_TIME,
+      score: 0,
+      submitting: false,
+      timer: null,
+      timeoutJump: null
+    }
+  },
+  computed: {
+    currentQuestion() {
+      return this.questions[this.currentIndex] || {}
+    },
+    timerBarStyle() {
+      return 'width:' + (this.timeLeft / TOTAL_TIME * 100) + '%'
+    },
+    feedbackText() {
+      if (this.feedback === 'correct') return '回答正确'
+      if (this.feedback === 'wrong') return '回答错误'
+      if (this.feedback === 'timeout') return '时间到,未作答'
+      return ''
+    }
+  },
+  onLoad() {
+    this.memberId = uni.getStorageSync('currentChildId') || ''
+  },
+  onUnload() {
+    this.stopTimer()
+    if (this.timeoutJump) {
+      clearTimeout(this.timeoutJump)
+      this.timeoutJump = null
+    }
+  },
+  methods: {
+    getOptKey(opt) {
+      return this.currentIndex + '_' + opt
+    },
+    generateQuestions() {
+      var shuffled = shuffleArray(QUESTION_BANK)
+      return shuffled.slice(0, TOTAL_QUESTIONS)
+    },
+    startTest() {
+      this.questions = this.generateQuestions()
+      this.currentIndex = 0
+      this.selected = ''
+      this.answered = false
+      this.feedback = ''
+      this.correctCount = 0
+      this.score = 0
+      this.submitting = false
+      this.step = 'playing'
+      this.startTimer()
+    },
+    startTimer() {
+      this.stopTimer()
+      this.timeLeft = TOTAL_TIME
+      this.timer = setInterval(() => {
+        this.timeLeft -= 1
+        if (this.timeLeft <= 0) {
+          this.handleTimeout()
+        }
+      }, 1000)
+    },
+    stopTimer() {
+      if (this.timer) {
+        clearInterval(this.timer)
+        this.timer = null
+      }
+    },
+    handleOptionTap(opt) {
+      if (this.answered) {
+        return
+      }
+      this.answered = true
+      this.selected = opt
+      this.stopTimer()
+      if (opt === this.currentQuestion.answer) {
+        this.correctCount += 1
+        this.feedback = 'correct'
+      } else {
+        this.feedback = 'wrong'
+      }
+      this.scheduleNext()
+    },
+    handleTimeout() {
+      this.stopTimer()
+      this.answered = true
+      this.feedback = 'timeout'
+      this.scheduleNext()
+    },
+    scheduleNext() {
+      var self = this
+      this.timeoutJump = setTimeout(function () {
+        self.nextQuestion()
+      }, 800)
+    },
+    nextQuestion() {
+      if (this.timeoutJump) {
+        clearTimeout(this.timeoutJump)
+        this.timeoutJump = null
+      }
+      if (this.currentIndex >= this.questions.length - 1) {
+        this.finishTest()
+        return
+      }
+      this.currentIndex += 1
+      this.selected = ''
+      this.answered = false
+      this.feedback = ''
+      this.startTimer()
+    },
+    finishTest() {
+      this.stopTimer()
+      this.score = Math.round(this.correctCount / this.questions.length * 100)
+      this.step = 'result'
+    },
+    submitScore() {
+      if (this.submitting) {
+        return
+      }
+      this.submitting = true
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      submitCognitiveSelfTest(memberId, 'spatialScore', this.score)
+        .then(() => {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(() => {
+            uni.navigateBack()
+          }, 1500)
+        })
+        .catch(() => {
+          this.submitting = false
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    },
+    restartTest() {
+      this.startTest()
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 60rpx 40rpx;
+  box-sizing: border-box;
+}
+
+.container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+/* --- 引导页 --- */
+.hero-icon {
+  width: 160rpx;
+  height: 160rpx;
+  border-radius: 50%;
+  background: rgba(59, 130, 246, 0.15);
+  border: 2rpx solid rgba(59, 130, 246, 0.5);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 100rpx;
+  margin-bottom: 40rpx;
+}
+.hero-symbol {
+  font-size: 80rpx;
+  color: #3B82F6;
+  line-height: 1;
+}
+.hero-title {
+  font-size: 44rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 24rpx;
+}
+.hero-desc {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.7);
+  line-height: 1.7;
+  text-align: center;
+  margin-bottom: 48rpx;
+}
+.rule-list {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 20rpx;
+  padding: 30rpx;
+  margin-bottom: 80rpx;
+  box-sizing: border-box;
+}
+.rule-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.rule-item:last-child {
+  margin-bottom: 0;
+}
+.rule-dot {
+  width: 12rpx;
+  height: 12rpx;
+  border-radius: 50%;
+  background: #3B82F6;
+  margin-right: 16rpx;
+}
+.rule-text {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.8);
+}
+
+/* --- 答题页 --- */
+.progress-row {
+  width: 100%;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 20rpx;
+}
+.progress-text {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.85);
+}
+.timer-text {
+  font-size: 28rpx;
+  color: #60A5FA;
+  font-weight: bold;
+}
+.timer-bar {
+  width: 100%;
+  height: 12rpx;
+  background: rgba(255, 255, 255, 0.1);
+  border-radius: 6rpx;
+  overflow: hidden;
+  margin-bottom: 80rpx;
+}
+.timer-bar-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #3B82F6, #60A5FA);
+  border-radius: 6rpx;
+  transition: width 1s linear;
+}
+.question-box {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 50rpx 0;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-bottom: 60rpx;
+}
+.direction-label {
+  font-size: 24rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-bottom: 20rpx;
+}
+.direction-arrow {
+  font-size: 100rpx;
+  color: #ffffff;
+  line-height: 1.2;
+  margin-bottom: 20rpx;
+}
+.rotation-tip {
+  font-size: 30rpx;
+  color: #93C5FD;
+}
+.options-wrap {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+.option-btn {
+  width: 300rpx;
+  height: 220rpx;
+  background: rgba(255, 255, 255, 0.08);
+  border: 2rpx solid rgba(255, 255, 255, 0.15);
+  border-radius: 24rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 30rpx;
+  box-sizing: border-box;
+}
+.option-selected {
+  border-color: #3B82F6;
+  background: rgba(59, 130, 246, 0.2);
+}
+.option-arrow {
+  font-size: 72rpx;
+  color: #ffffff;
+}
+.feedback-row {
+  margin-top: 10rpx;
+}
+.feedback-text {
+  font-size: 30rpx;
+}
+.feedback-correct {
+  color: #34D399;
+}
+.feedback-wrong {
+  color: #F87171;
+}
+.feedback-timeout {
+  color: rgba(255, 255, 255, 0.5);
+}
+
+/* --- 结果页 --- */
+.result-title {
+  font-size: 36rpx;
+  color: #ffffff;
+  margin-top: 60rpx;
+  margin-bottom: 20rpx;
+}
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  border: 10rpx solid #3B82F6;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin: 40rpx 0;
+}
+.score-number {
+  font-size: 80rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1.1;
+}
+.score-unit {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.6);
+}
+.score-detail {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.8);
+  margin-bottom: 30rpx;
+}
+.dimension-tag {
+  padding: 10rpx 32rpx;
+  background: rgba(59, 130, 246, 0.15);
+  border: 2rpx solid rgba(59, 130, 246, 0.5);
+  border-radius: 40rpx;
+  margin-bottom: 80rpx;
+}
+.dimension-name {
+  font-size: 26rpx;
+  color: #93C5FD;
+}
+
+/* --- 按钮 --- */
+.btn {
+  width: 100%;
+  height: 96rpx;
+  line-height: 96rpx;
+  border-radius: 48rpx;
+  font-size: 32rpx;
+  text-align: center;
+  margin-bottom: 30rpx;
+  padding: 0;
+}
+.btn::after {
+  border: none;
+}
+.btn-primary {
+  background: #3B82F6;
+  color: #ffffff;
+}
+.btn-secondary {
+  background: transparent;
+  border: 2rpx solid #3B82F6;
+  color: #3B82F6;
+  box-sizing: border-box;
+}
+</style>

+ 577 - 0
cfc-frontend/pages/cognitive-test/speed-test.vue

@@ -0,0 +1,577 @@
+<template>
+  <view class="page-container">
+    <!-- 说明页 -->
+    <view v-if="step === 'instruction'" class="step-wrap">
+      <text class="title">加工速度测试</text>
+      <text class="subtitle">符号匹配 · 反应速度</text>
+
+      <view class="desc-card">
+        <text class="desc-title">测试说明</text>
+        <text class="desc-line">1. 先记住出现的符号</text>
+        <text class="desc-line">2. 从 4 个候选中点出相同的符号</text>
+        <text class="desc-line">3. 越快越准,得分越高</text>
+        <text class="desc-line">共 10 轮,每轮限时 5 秒</text>
+      </view>
+
+      <button class="btn-primary" @click="startTest">开始测试</button>
+    </view>
+
+    <!-- 测试中 -->
+    <view v-if="step === 'playing'" class="step-wrap">
+      <view class="round-bar">
+        <text class="round-text">第 {{ roundIndex }}/10</text>
+        <view class="timer-track">
+          <view class="timer-fill" :style="'width:' + timerPercent + '%'"></view>
+        </view>
+      </view>
+
+      <!-- 记忆阶段:展示目标符号 -->
+      <view v-if="phase === 'show'" class="show-area">
+        <text class="phase-hint">记住这个符号</text>
+        <view class="symbol-box" :style="'width:' + symbolBoxSize + 'px;height:' + symbolBoxSize + 'px'">
+          <text class="symbol-big" :style="'font-size:' + symbolFontSize + 'px'">{{ currentTarget }}</text>
+        </view>
+      </view>
+
+      <!-- 选择阶段:4 选 1 -->
+      <view v-else class="choice-area">
+        <text class="phase-hint">找出刚才的符号</text>
+        <view class="choice-grid">
+          <view
+            v-for="(sym, idx) in currentOptions"
+            :key="getSymKey(sym)"
+            :class="'choice-cell' + (selIndex === idx ? (roundCorrect ? ' cell-hit' : ' cell-miss') : '')"
+            :style="'width:' + cellSize + 'px;height:' + cellSize + 'px'"
+            @click="handleChoice(sym)"
+          >
+            <text class="symbol-opt" :style="'font-size:' + symbolFontSize + 'px'">{{ sym }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- 结果页 -->
+    <view v-if="step === 'result'" class="step-wrap">
+      <view class="score-circle">
+        <text class="score-num">{{ score }}</text>
+        <text class="score-label">分</text>
+      </view>
+
+      <view class="result-stats">
+        <view class="stat-item">
+          <text class="stat-value">{{ correctCount }}/10</text>
+          <text class="stat-label">答对</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">{{ avgTimeText }}</text>
+          <text class="stat-label">平均反应</text>
+        </view>
+        <view class="stat-item">
+          <text class="stat-value">+{{ speedBonus }}</text>
+          <text class="stat-label">速度加分</text>
+        </view>
+      </view>
+
+      <view class="dimension-tag">加工速度</view>
+
+      <button class="btn-primary" :disabled="submitting" @click="submitScore">
+        {{ submitting ? '提交中...' : '提交成绩' }}
+      </button>
+      <button class="btn-secondary" @click="startTest">再测一次</button>
+    </view>
+  </view>
+</template>
+
+<script>
+import { submitCognitiveSelfTest } from '../../utils/api.js'
+
+export default {
+  data() {
+    return {
+      SYMBOLS: ['★', '●', '■', '▲', '◆', '◇', '○', '△'],
+      SYMBOL_PAIRS: [['★', '◆'], ['●', '○'], ['■', '◇'], ['▲', '△']],
+      step: 'instruction',
+      phase: 'show',
+      roundIndex: 1,
+      currentTarget: '',
+      currentOptions: [],
+      roundStartMs: 0,
+      roundElapsed: 0,
+      timerPercent: 0,
+      locked: false,
+      selIndex: -1,
+      roundCorrect: false,
+      results: [],
+      correctCount: 0,
+      avgTimeText: '0.00s',
+      speedBonus: 0,
+      score: 0,
+      submitting: false,
+      memberId: '',
+      showTimer: null,
+      roundTimer: null,
+      windowWidth: 375,
+      symbolFontSize: 75,
+      symbolBoxSize: 150,
+      cellSize: 112
+    }
+  },
+
+  onLoad(options) {
+    var win = uni.getWindowInfo()
+    this.windowWidth = (win && win.windowWidth) || 375
+    this.symbolFontSize = Math.round(this.windowWidth * 0.2)
+    this.symbolBoxSize = Math.round(this.windowWidth * 0.4)
+    this.cellSize = Math.round(this.windowWidth * 0.3)
+    this.memberId = (options && options.memberId) || uni.getStorageSync('currentChildId') || ''
+  },
+
+  onUnload() {
+    this.clearTimers()
+  },
+
+  methods: {
+    getSymKey(sym) {
+      return sym
+    },
+
+    clearTimers() {
+      if (this.showTimer) {
+        clearTimeout(this.showTimer)
+        this.showTimer = null
+      }
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+    },
+
+    startTest() {
+      this.clearTimers()
+      this.results = []
+      this.roundIndex = 1
+      this.submitting = false
+      this.selIndex = -1
+      this.timerPercent = 0
+      this.step = 'playing'
+      this.startRound()
+    },
+
+    // 生成一轮:从符号对中随机取目标,3 个干扰项 + 配对符号
+    generateRound() {
+      var pair = this.SYMBOL_PAIRS[Math.floor(Math.random() * this.SYMBOL_PAIRS.length)]
+      var target = pair[Math.floor(Math.random() * 2)]
+      var pairMate = target === pair[0] ? pair[1] : pair[0]
+      var others = []
+      for (var i = 0; i < this.SYMBOLS.length; i++) {
+        if (this.SYMBOLS[i] !== target && this.SYMBOLS[i] !== pairMate) {
+          others.push(this.SYMBOLS[i])
+        }
+      }
+      this.shuffle(others)
+      var options = [target, pairMate, others[0], others[1]]
+      this.shuffle(options)
+      return { target: target, options: options }
+    },
+
+    shuffle(arr) {
+      for (var i = arr.length - 1; i > 0; i--) {
+        var j = Math.floor(Math.random() * (i + 1))
+        var tmp = arr[i]
+        arr[i] = arr[j]
+        arr[j] = tmp
+      }
+    },
+
+    startRound() {
+      this.clearTimers()
+      this.phase = 'show'
+      this.locked = false
+      this.selIndex = -1
+      this.roundElapsed = 0
+      this.timerPercent = 0
+
+      var round = this.generateRound()
+      this.currentTarget = round.target
+      this.currentOptions = round.options
+
+      // 记忆阶段:展示 1 秒后进入选择
+      var that = this
+      this.showTimer = setTimeout(function () {
+        that.phase = 'choice'
+        that.roundStartMs = Date.now()
+        that.roundTimer = setInterval(function () {
+          that.tickRound()
+        }, 100)
+      }, 1000)
+    },
+
+    tickRound() {
+      this.roundElapsed += 100
+      this.timerPercent = Math.min(this.roundElapsed / 5000 * 100, 100)
+      if (this.roundElapsed >= 5000) {
+        // 超时未作答,记错
+        this.finishRound()
+      }
+    },
+
+    handleChoice(sym) {
+      if (this.phase !== 'choice' || this.locked) return
+      this.locked = true
+      var isCorrect = sym === this.currentTarget
+      this.selIndex = this.currentOptions.indexOf(sym)
+      this.roundCorrect = isCorrect
+      this.results.push({
+        correct: isCorrect,
+        timeMs: Date.now() - this.roundStartMs
+      })
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+      var that = this
+      setTimeout(function () {
+        that.nextRound()
+      }, 450)
+    },
+
+    // 超时:timeMs 记 0(不计入平均反应时间)
+    finishRound() {
+      if (this.locked) return
+      this.locked = true
+      this.results.push({ correct: false, timeMs: 0 })
+      if (this.roundTimer) {
+        clearInterval(this.roundTimer)
+        this.roundTimer = null
+      }
+      var that = this
+      setTimeout(function () {
+        that.nextRound()
+      }, 300)
+    },
+
+    nextRound() {
+      if (this.roundIndex >= 10) {
+        this.finishTest()
+      } else {
+        this.roundIndex++
+        this.startRound()
+      }
+    },
+
+    finishTest() {
+      this.clearTimers()
+      this.step = 'result'
+      this.computeResult()
+    },
+
+    computeResult() {
+      var correct = 0
+      var totalMs = 0
+      var answered = 0
+      for (var i = 0; i < this.results.length; i++) {
+        var r = this.results[i]
+        if (r.correct) correct++
+        if (r.timeMs > 0) {
+          totalMs += r.timeMs
+          answered++
+        }
+      }
+      this.correctCount = correct
+      var avgMs = answered > 0 ? totalMs / answered : 5000
+      this.avgTimeText = (avgMs / 1000).toFixed(2) + 's'
+
+      var avgSec = avgMs / 1000
+      var bonus = 5
+      if (avgSec < 1) {
+        bonus = 20
+      } else if (avgSec < 2) {
+        bonus = 17
+      } else if (avgSec < 3) {
+        bonus = 14
+      } else if (avgSec < 4) {
+        bonus = 10
+      }
+      this.speedBonus = bonus
+
+      var total = Math.round(correct / 10 * 80 + bonus)
+      this.score = Math.min(Math.max(total, 0), 100)
+    },
+
+    submitScore() {
+      if (this.submitting) return
+      var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+      if (!memberId) {
+        uni.showToast({ title: '请先选择孩子', icon: 'none' })
+        return
+      }
+      this.submitting = true
+      var that = this
+      submitCognitiveSelfTest(memberId, 'processingSpeedScore', this.score)
+        .then(function () {
+          uni.showToast({ title: '提交成功', icon: 'success' })
+          setTimeout(function () {
+            uni.navigateBack()
+          }, 800)
+        })
+        .catch(function () {
+          that.submitting = false
+          uni.showToast({ title: '提交失败,请重试', icon: 'none' })
+        })
+    }
+  }
+}
+</script>
+
+<style scoped>
+.page-container {
+  min-height: 100vh;
+  background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
+  padding: 40rpx 50rpx;
+}
+
+.step-wrap {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.title {
+  font-size: 52rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-top: 40rpx;
+}
+
+.subtitle {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-top: 16rpx;
+}
+
+.desc-card {
+  width: 100%;
+  background: rgba(255, 255, 255, 0.06);
+  border-radius: 24rpx;
+  padding: 40rpx;
+  margin: 60rpx 0;
+}
+
+.desc-title {
+  display: block;
+  font-size: 32rpx;
+  font-weight: bold;
+  color: #F97316;
+  margin-bottom: 24rpx;
+}
+
+.desc-line {
+  display: block;
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.85);
+  line-height: 1.9;
+}
+
+/* 按钮 */
+.btn-primary {
+  width: 70%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  color: #ffffff;
+  font-size: 34rpx;
+  border-radius: 50rpx;
+  border: none;
+  margin-top: 20rpx;
+}
+
+.btn-primary::after {
+  border: none;
+}
+
+.btn-primary[disabled] {
+  opacity: 0.6;
+}
+
+.btn-secondary {
+  width: 70%;
+  background: transparent;
+  color: #F97316;
+  font-size: 32rpx;
+  border-radius: 50rpx;
+  border: 2rpx solid #F97316;
+  margin-top: 30rpx;
+}
+
+.btn-secondary::after {
+  border: none;
+}
+
+/* 测试中 */
+.round-bar {
+  width: 100%;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  margin-top: 30rpx;
+}
+
+.round-text {
+  font-size: 34rpx;
+  font-weight: bold;
+  color: #ffffff;
+  margin-bottom: 20rpx;
+}
+
+.timer-track {
+  width: 100%;
+  height: 10rpx;
+  background: rgba(255, 255, 255, 0.12);
+  border-radius: 10rpx;
+  overflow: hidden;
+}
+
+.timer-fill {
+  height: 100%;
+  border-radius: 10rpx;
+  background: linear-gradient(90deg, #F97316, #FB923C);
+  transition: width 0.1s linear;
+}
+
+.phase-hint {
+  font-size: 30rpx;
+  color: rgba(255, 255, 255, 0.6);
+  margin: 70rpx 0 40rpx;
+}
+
+/* 记忆阶段 */
+.show-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+
+.symbol-box {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(255, 255, 255, 0.08);
+  border: 2rpx solid rgba(249, 115, 22, 0.4);
+  border-radius: 24rpx;
+  box-shadow: 0 0 40rpx rgba(249, 115, 22, 0.15);
+}
+
+.symbol-big {
+  color: #ffffff;
+  font-weight: bold;
+  line-height: 1;
+}
+
+/* 选择阶段 */
+.choice-area {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  width: 100%;
+}
+
+.choice-grid {
+  width: 100%;
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: space-between;
+}
+
+.choice-cell {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #2a2a4a;
+  border: 2rpx solid transparent;
+  border-radius: 20rpx;
+  margin-bottom: 30rpx;
+}
+
+.choice-cell:active {
+  background: #36365c;
+}
+
+.choice-cell.cell-hit {
+  background: #1e3a2f;
+  border-color: #22c55e;
+  box-shadow: 0 0 30rpx rgba(34, 197, 94, 0.3);
+}
+
+.choice-cell.cell-miss {
+  background: #3a1f22;
+  border-color: #ef4444;
+  box-shadow: 0 0 30rpx rgba(239, 68, 68, 0.3);
+}
+
+.symbol-opt {
+  color: #ffffff;
+  font-weight: bold;
+  line-height: 1;
+}
+
+/* 结果页 */
+.score-circle {
+  width: 280rpx;
+  height: 280rpx;
+  border-radius: 50%;
+  background: linear-gradient(135deg, #F97316, #FB923C);
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  margin: 60rpx 0 50rpx;
+  box-shadow: 0 0 60rpx rgba(249, 115, 22, 0.35);
+}
+
+.score-num {
+  font-size: 88rpx;
+  font-weight: bold;
+  color: #ffffff;
+  line-height: 1;
+}
+
+.score-label {
+  font-size: 28rpx;
+  color: rgba(255, 255, 255, 0.9);
+  margin-top: 8rpx;
+}
+
+.result-stats {
+  width: 100%;
+  display: flex;
+  justify-content: space-around;
+  margin-bottom: 40rpx;
+}
+
+.stat-item {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+
+.stat-value {
+  font-size: 40rpx;
+  font-weight: bold;
+  color: #ffffff;
+}
+
+.stat-label {
+  font-size: 26rpx;
+  color: rgba(255, 255, 255, 0.5);
+  margin-top: 10rpx;
+}
+
+.dimension-tag {
+  font-size: 28rpx;
+  color: #F97316;
+  background: rgba(249, 115, 22, 0.12);
+  border: 2rpx solid rgba(249, 115, 22, 0.4);
+  border-radius: 30rpx;
+  padding: 12rpx 40rpx;
+  margin-bottom: 50rpx;
+}
+</style>

+ 2 - 2
cfc-frontend/pages/health-main/index.vue

@@ -250,9 +250,9 @@ export default {
       tabs: [
         { code: 'body', label: '健康', icon: '🟠', color: '#FF8C42' },
         { code: 'cognitive', label: '认知', icon: '🧠', color: '#6366F1' },
-        { code: 'mental', label: '心理', icon: '💖', color: '#FF6B9D' },
+        { code: 'wealth', label: '财富', icon: '💧', color: '#F59E0B' },
         { code: 'relationship', label: '社会性', icon: '🤝', color: '#10B981' },
-        { code: 'wealth', label: '财富', icon: '💧', color: '#F59E0B' }
+        { code: 'mental', label: '心理', icon: '💖', color: '#FF6B9D' }
       ],
       quizzes: [
         { code: 'cognitive', label: '认知能力测评', icon: '🧠', score: null },

+ 62 - 1
cfc-frontend/pages/wisdom-detail/training-hub.vue

@@ -10,6 +10,61 @@
       <text class="desc-text">选择训练游戏,提升你的认知能力</text>
     </view>
 
+    <view class="game-card" @click="goGame('logic-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧩</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">逻辑推理测试</text>
+        <text class="game-desc">数列推理 · 锻炼逻辑思维</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('perception-test')">
+      <view class="game-card-left">
+        <text class="game-icon">👁️</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">感知觉测试</text>
+        <text class="game-desc">找不同 · 提高感官信息处理能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('memory-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧠</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">记忆力测试</text>
+        <text class="game-desc">数字广度 · 增强信息记忆与回忆能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('spatial-test')">
+      <view class="game-card-left">
+        <text class="game-icon">🧭</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">空间思维测试</text>
+        <text class="game-desc">方向旋转 · 培养空间想象与构建能力</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
+    <view class="game-card" @click="goGame('speed-test')">
+      <view class="game-card-left">
+        <text class="game-icon">⚡</text>
+      </view>
+      <view class="game-card-center">
+        <text class="game-name">加工速度测试</text>
+        <text class="game-desc">符号匹配 · 加快信息处理与反应速度</text>
+      </view>
+      <text class="game-arrow">›</text>
+    </view>
+
     <view class="game-card" @click="goGame('schulte')">
       <view class="game-card-left">
         <text class="game-icon">🎯</text>
@@ -33,7 +88,7 @@
     </view>
 
     <view class="note-section">
-      <text class="note-text">更多训练游戏正在开发中,敬请期待</text>
+      <text class="note-text">完成测试后,认知雷达图会更新对应维度得分</text>
     </view>
   </view>
 </template>
@@ -60,8 +115,14 @@ export default {
         url = '/pages/games/schulte'
       } else if (game === '1a2b') {
         url = '/pages/games/1a2b'
+      } else {
+        url = '/pages/cognitive-test/' + game
       }
       if (url) {
+        var memberId = this.memberId || uni.getStorageSync('currentChildId') || ''
+        if (memberId) {
+          url += '?memberId=' + memberId
+        }
         uni.navigateTo({ url: url })
       }
     }

+ 1 - 0
cfc-frontend/utils/api.js

@@ -1808,6 +1808,7 @@ export const getEmiReport = (memberId) => request('/api/emireport/latest', 'POST
 export const getEmiFamilyReports = (childIds) => request('/api/emireport/family', 'POST', { childIds })
 export const getCognitiveChildScores = (memberId) => request('/api/cognitive/child', 'POST', { memberId })
 export const getCognitiveFamilyScores = () => request('/api/cognitive/family', 'POST', {})
+export const submitCognitiveSelfTest = (memberId, dimension, score) => request('/api/cognitive/self-test', 'POST', { memberId, dimension, score })
 
 
 // ===== 认知测评结果 =====

+ 33 - 0
docs/superpowers/api/API_REFERENCE.md

@@ -1043,10 +1043,43 @@ find cfc-backend/src/main/java -name "*XxxService.java" -o -name "*XxxController
 
 ---
 
+### 4.38 认知维度(`/api/cognitive/*`)
+
+六维认知体系:感知(perception) / 专注(focus) / 记忆(memory) / 逻辑(logic) / 空间(spatial) / 加工速度(processingSpeed)。
+
+| 路径 | 说明 |
+|------|------|
+| `POST /api/cognitive/child` | 获取孩子最新六维认知得分 |
+| `POST /api/cognitive/family` | 获取家庭所有孩子最新六维认知得分 |
+| `POST /api/cognitive/update` | 更新认知维度得分(由规划师录入,需已有 completed 行) |
+| `POST /api/cognitive/self-test` | 提交认知自测结果(自动创建档案,写入六维认知档案,综合分自动计算) |
+| `POST /api/cognitive/norms` | 获取认知维度百分位常模 |
+| `POST /api/cognitive/trend` | 获取认知维度历史趋势 |
+| `POST /api/cognitive/recommendations` | 获取认知训练建议(按薄弱维度生成) |
+| `POST /api/cognitive/recommendations/create-task` | 从建议创建每日任务 |
+
+**`/api/cognitive/self-test` 请求体:**
+```json
+{
+  "memberId": 12345,
+  "dimension": "memoryScore",
+  "score": 85
+}
+```
+dimension 取值:`memoryScore` / `logicScore` / `perceptionScore` / `spatialScore` / `processingSpeedScore`
+score 范围:0-100(整数)
+
+**响应:** `Result<String>`,成功返回 "提交成功",失败返回错误原因。
+
+**行为:** 若该孩子尚无任何 `status=completed` 的 DanAssessmentResult 行,自动创建(source='self_test'),写入对应维度分,重新计算综合分(7 维度非空均值)。
+
+**已记录认知自测 API 端点 — 禁止重复注册。**
+
 ## 六、待清理的废弃接口
 
 | Controller | 废弃接口 | 替代方案 | 当前状态 | 清理条件 |
 |------------|----------|----------|----------|----------|
+|------------|----------|----------|----------|----------|
 | `FamilyController` | `/invite-code`, `/invite-code/generate` | `/api/family/invite/generate` | @Deprecated + 410 ✓ | 确认前端无调用 |
 | `FamilyUserController` | `/switch-mode`, `/switch-to-child` | `/api/family/member/switch` | @Deprecated + 410 ✓ | 确认前端无调用 |
 | `FamilyUserController` | `/switch-back-to-parent`, `/switch-back-verify` | `/api/family/member/switch` | 已修复:@Deprecated + 410 | 确认前端无调用 |