Przeglądaj źródła

Merge branch 'cfclub' of http://git.iwintrue.com/liaoxg/cfc into cfclub

asus 3 miesięcy temu
rodzic
commit
f1387fb245
28 zmienionych plików z 1410 dodań i 365 usunięć
  1. 17 0
      cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java
  2. 73 0
      cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGrowthArchiveProductController.java
  3. 1 0
      cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java
  4. 27 0
      cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthArchiveProduct.java
  5. 7 0
      cfc-backend/src/main/java/com/etotem/cfc/mapper/GrowthArchiveProductMapper.java
  6. 64 0
      cfc-backend/src/main/java/com/etotem/cfc/service/GrowthArchiveProductService.java
  7. 6 4
      cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java
  8. 27 2
      cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java
  9. 12 0
      cfc-backend/src/main/resources/schema.sql
  10. 4 0
      cfc-frontend/pages/discover-detail/product-detail/product-detail.vue
  11. 1 1
      cfc-frontend/pages/family/self-check.vue
  12. 139 2
      cfc-frontend/pages/growth/upload.vue
  13. 4 12
      cfc-frontend/pages/user-edit/user-edit.vue
  14. 1 1
      cfc-web/.last_build_commit
  15. 2 2
      cfc-web/package-lock.json
  16. 1 1
      cfc-web/package.json
  17. 14 0
      cfc-web/public/CHANGELOG-v$(node.undefined.md
  18. 55 0
      cfc-web/public/CHANGELOG-v1.0.md
  19. 56 1
      cfc-web/public/CHANGELOG.md
  20. 16 0
      cfc-web/src/api/admin.js
  21. 11 11
      cfc-web/src/components/DimensionWeightPicker.vue
  22. 51 4
      cfc-web/src/views/admin/ProductEdit.vue
  23. 230 0
      tests/e2e/admin-menu-survey.spec.js
  24. 218 324
      tests/e2e/assessment-order-flow.spec.js
  25. 126 0
      tests/e2e/debug-admin-spa.spec.js
  26. 63 0
      tests/e2e/debug-control.spec.js
  27. 145 0
      tests/e2e/debug-full-instrument.spec.js
  28. 39 0
      tests/playwright.remote.config.js

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -7511,5 +7511,22 @@ private void runMigration100() {
 		} catch (Exception e) {
 			log.warn("创建product_gift_rules表失败: {}", e.getMessage());
 		}
+
+		// 迁移153: 创建 growth_archive_products 表(成长档案关联商品配置)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS growth_archive_products (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"product_id BIGINT NOT NULL COMMENT '关联products表', " +
+					"status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用', " +
+					"sort_order INT DEFAULT 0 COMMENT '展示排序(升序)', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', " +
+					"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', " +
+					"UNIQUE KEY uk_product_id (product_id), " +
+					"KEY idx_status (status)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长档案关联商品配置'");
+			log.info("已创建growth_archive_products表");
+		} catch (Exception e) {
+			log.warn("创建growth_archive_products表失败: {}", e.getMessage());
+		}
 	}
 }

+ 73 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminGrowthArchiveProductController.java

@@ -0,0 +1,73 @@
+package com.etotem.cfc.controller.admin;
+
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.GrowthArchiveProduct;
+import com.etotem.cfc.service.GrowthArchiveProductService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Map;
+
+/**
+ * 管理端-成长档案关联商品配置控制器
+ */
+@Tag(name = "管理端-成长档案关联商品")
+@RestController
+@RequestMapping("/api/admin/growth-archive-product")
+public class AdminGrowthArchiveProductController {
+
+    @Resource
+    private GrowthArchiveProductService growthArchiveProductService;
+
+    @Operation(summary = "保存成长档案关联商品规则")
+    @PostMapping("/save")
+    public Result<String> save(@RequestBody Map<String, Object> params) {
+        Long productId = params.get("productId") != null
+                ? Long.valueOf(params.get("productId").toString())
+                : null;
+        Integer status = params.get("status") != null
+                ? Integer.valueOf(params.get("status").toString())
+                : null;
+        Integer sortOrder = params.get("sortOrder") != null
+                ? Integer.valueOf(params.get("sortOrder").toString())
+                : 0;
+        if (productId == null) {
+            return Result.error("productId不能为空");
+        }
+        if (status == null || (status != 0 && status != 1)) {
+            return Result.error("status不能为空且必须为0或1");
+        }
+        growthArchiveProductService.saveRule(productId, status, sortOrder);
+        return Result.success("保存成功");
+    }
+
+    @Operation(summary = "成长档案关联商品规则详情")
+    @PostMapping("/detail")
+    public Result<GrowthArchiveProduct> detail(@RequestBody Map<String, Object> params) {
+        Long productId = params.get("productId") != null
+                ? Long.valueOf(params.get("productId").toString())
+                : null;
+        if (productId == null) {
+            return Result.error("productId不能为空");
+        }
+        return Result.success(growthArchiveProductService.getByProductId(productId));
+    }
+
+    @Operation(summary = "删除成长档案关联商品规则")
+    @PostMapping("/delete")
+    public Result<String> delete(@RequestBody Map<String, Object> params) {
+        Long productId = params.get("productId") != null
+                ? Long.valueOf(params.get("productId").toString())
+                : null;
+        if (productId == null) {
+            return Result.error("productId不能为空");
+        }
+        growthArchiveProductService.deleteByProductId(productId);
+        return Result.success("删除成功");
+    }
+}

+ 1 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/ProductListQueryDTO.java

@@ -12,4 +12,5 @@ public class ProductListQueryDTO {
     private Long vendorId;
     private String keyword;
     private Long categoryId;
+    private Boolean growthRelated;   // true=仅返回成长档案关联商品
 }

+ 27 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/GrowthArchiveProduct.java

@@ -0,0 +1,27 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+@Data
+@TableName("growth_archive_products")
+public class GrowthArchiveProduct implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    private Long productId;
+
+    private Integer status;      // 1=启用 0=停用
+
+    private Integer sortOrder;   // 展示排序(升序)
+
+    private Date createdAt;
+
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/GrowthArchiveProductMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.GrowthArchiveProduct;
+
+public interface GrowthArchiveProductMapper extends BaseMapper<GrowthArchiveProduct> {
+}

+ 64 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/GrowthArchiveProductService.java

@@ -0,0 +1,64 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.GrowthArchiveProduct;
+import com.etotem.cfc.mapper.GrowthArchiveProductMapper;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+
+@Service
+public class GrowthArchiveProductService {
+
+    @Resource
+    private GrowthArchiveProductMapper mapper;
+
+    public GrowthArchiveProduct getByProductId(Long productId) {
+        if (productId == null) {
+            return null;
+        }
+        return mapper.selectOne(new LambdaQueryWrapper<GrowthArchiveProduct>()
+                .eq(GrowthArchiveProduct::getProductId, productId));
+    }
+
+    /**
+     * 幂等保存规则:存在则更新,不存在则插入
+     */
+    @Transactional
+    public void saveRule(Long productId, Integer status, Integer sortOrder) {
+        Date now = new Date();
+        GrowthArchiveProduct existing = getByProductId(productId);
+        if (existing != null) {
+            existing.setStatus(status);
+            existing.setSortOrder(sortOrder);
+            existing.setUpdatedAt(now);
+            mapper.updateById(existing);
+        } else {
+            GrowthArchiveProduct rule = new GrowthArchiveProduct();
+            rule.setProductId(productId);
+            rule.setStatus(status);
+            rule.setSortOrder(sortOrder != null ? sortOrder : 0);
+            rule.setCreatedAt(now);
+            rule.setUpdatedAt(now);
+            mapper.insert(rule);
+        }
+    }
+
+    /**
+     * 启用中的关联规则,按 sort_order 升序、id 升序
+     */
+    public List<GrowthArchiveProduct> getActiveRules() {
+        return mapper.selectList(new LambdaQueryWrapper<GrowthArchiveProduct>()
+                .eq(GrowthArchiveProduct::getStatus, 1)
+                .orderByAsc(GrowthArchiveProduct::getSortOrder)
+                .orderByAsc(GrowthArchiveProduct::getId));
+    }
+
+    public void deleteByProductId(Long productId) {
+        mapper.delete(new LambdaQueryWrapper<GrowthArchiveProduct>()
+                .eq(GrowthArchiveProduct::getProductId, productId));
+    }
+}

+ 6 - 4
cfc-backend/src/main/java/com/etotem/cfc/service/PaymentService.java

@@ -28,7 +28,6 @@ import javax.crypto.spec.SecretKeySpec;
 import java.io.*;
 import java.nio.charset.StandardCharsets;
 import java.security.KeyFactory;
-import java.security.MessageDigest;
 import java.security.PrivateKey;
 import java.security.Signature;
 import java.security.spec.PKCS8EncodedKeySpec;
@@ -508,10 +507,13 @@ public class PaymentService implements PaymentServiceInterface {
             log.warn("API V3密钥未配置,使用模拟解密");
             return "{\"out_trade_no\":\"mock\",\"transaction_id\":\"mock\",\"trade_state\":\"SUCCESS\"}";
         }
-        MessageDigest md = MessageDigest.getInstance("SHA-256");
-        byte[] apiKeyHash = md.digest(apiV3Key.getBytes(StandardCharsets.UTF_8));
+        // Per WeChat Pay v3 spec: use the APIv3 key directly as AES-256 key (exactly 32 bytes)
+        byte[] aesKey = apiV3Key.getBytes(StandardCharsets.UTF_8);
+        if (aesKey.length != 32) {
+            log.warn("API V3密钥长度不为32字节,实际长度={}", aesKey.length);
+        }
 
-        SecretKeySpec keySpec = new SecretKeySpec(apiKeyHash, "AES");
+        SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
         GCMParameterSpec gcmSpec = new GCMParameterSpec(128, Base64.getDecoder().decode(nonce));
 
         Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");

+ 27 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/ProductService.java

@@ -8,6 +8,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ProductDTO;
 import com.etotem.cfc.dto.ProductListQueryDTO;
 import com.etotem.cfc.entity.AssessmentProduct;
+import com.etotem.cfc.entity.GrowthArchiveProduct;
 import com.etotem.cfc.entity.Product;
 import com.etotem.cfc.entity.ProductGiftRule;
 import com.etotem.cfc.entity.User;
@@ -17,6 +18,7 @@ import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
+import java.util.ArrayList;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
@@ -41,6 +43,9 @@ public class ProductService {
     @Resource
     private AssessmentProductService assessmentProductService;
 
+    @Resource
+    private GrowthArchiveProductService growthArchiveProductService;
+
     public Product getById(Long id) {
         return productMapper.selectById(id);
     }
@@ -48,8 +53,28 @@ public class ProductService {
     public Result<Map<String, Object>> list(ProductListQueryDTO query, Long userId) {
         Page<Product> page = new Page<>(query.getPage(), query.getSize());
         LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<Product>()
-            .eq(Product::getStatus, "on_shelf")
-            .orderByDesc(Product::getCreatedAt);
+            .eq(Product::getStatus, "on_shelf");
+        // growthRelated=true: 仅返回成长档案关联商品,按 sort_order 升序展示
+        if (Boolean.TRUE.equals(query.getGrowthRelated())) {
+            List<Long> productIds = growthArchiveProductService.getActiveRules().stream()
+                .map(GrowthArchiveProduct::getProductId)
+                .collect(Collectors.toList());
+            if (productIds.isEmpty()) {
+                Map<String, Object> empty = new HashMap<>();
+                empty.put("records", new ArrayList<>());
+                empty.put("total", 0L);
+                empty.put("page", page.getCurrent());
+                empty.put("size", page.getSize());
+                return Result.success(empty);
+            }
+            wrapper.in(Product::getId, productIds);
+            String ids = productIds.stream()
+                .map(String::valueOf)
+                .collect(Collectors.joining(","));
+            wrapper.last("ORDER BY FIELD(id, " + ids + ")");
+        } else {
+            wrapper.orderByDesc(Product::getCreatedAt);
+        }
         // 游客过滤:排除会员专属商品
         if (userId == null) {
             wrapper.ne(Product::getMemberEligible, "yes");

+ 12 - 0
cfc-backend/src/main/resources/schema.sql

@@ -2567,6 +2567,18 @@ CREATE TABLE IF NOT EXISTS assessment_products (
     INDEX idx_product_id (product_id)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='测评商品扩展信息';
 
+-- 成长档案关联商品配置
+CREATE TABLE IF NOT EXISTS growth_archive_products (
+    id BIGINT AUTO_INCREMENT PRIMARY KEY,
+    product_id BIGINT NOT NULL COMMENT '关联products表',
+    status TINYINT(1) DEFAULT 1 COMMENT '1=启用 0=停用',
+    sort_order INT DEFAULT 0 COMMENT '展示排序(升序)',
+    created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    UNIQUE KEY uk_product_id (product_id),
+    KEY idx_status (status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长档案关联商品配置';
+
 -- 测评额度表
 CREATE TABLE IF NOT EXISTS assessment_quotas (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,

+ 4 - 0
cfc-frontend/pages/discover-detail/product-detail/product-detail.vue

@@ -162,6 +162,10 @@ export default {
         this.loading = false
         if (res.code === 200 && res.data) {
           this.product = res.data
+          this.setShareInfo(
+            this.product.name,
+            '/pages/discover-detail/product-detail/product-detail?id=' + this.product.id
+          )
         }
       }).catch(() => {
         this.loading = false

+ 1 - 1
cfc-frontend/pages/family/self-check.vue

@@ -24,7 +24,7 @@
               :key="q.id"
               class="sc-dim-nav-dot-mini"
               :class="{ done: answers[q.id] }"
-              :style="answers[q.id] ? { background: dim.color } : {}"
+              :style="answers[q.id] ? 'background:' + dim.color : 'background:transparent'"
             ></view>
           </view>
         </view>

+ 139 - 2
cfc-frontend/pages/growth/upload.vue

@@ -15,6 +15,29 @@
         </view>
       </view>
 
+      <!-- 成长报告推荐区 -->
+      <view class="purchase-section" v-if="recommendedProducts.length > 0">
+        <view class="purchase-header">
+          <text class="purchase-title">还没有成长报告?</text>
+          <text class="purchase-subtitle">购买测评,获取专业成长报告</text>
+        </view>
+        <scroll-view class="purchase-scroll" scroll-x enable-flex show-scrollbar="false">
+          <view
+            class="purchase-card"
+            v-for="item in recommendedProducts"
+            :key="getProductKey(item)"
+            @click="goToProduct(item.id)"
+          >
+            <image class="purchase-cover" :src="getImageUrl(item.coverImage) || '/static/default-product.png'" mode="aspectFill" />
+            <text class="purchase-name">{{ item.name }}</text>
+            <text class="purchase-price">{{ formatPrice(item.price) }}</text>
+            <view class="purchase-btn">
+              <text class="purchase-btn-text">查看详情</text>
+            </view>
+          </view>
+        </scroll-view>
+      </view>
+
       <view class="form-item">
         <text class="label">选择报告文件</text>
         <view class="upload-area" @click="chooseFile">
@@ -63,7 +86,8 @@
 </template>
 
 <script>
-import { uploadAndParseReport, createGrowthRecordFromUpload, checkGrowthDuplicate, getChildren } from '../../utils/api.js'
+import { uploadAndParseReport, createGrowthRecordFromUpload, checkGrowthDuplicate, getChildren, productList, getChildRecords } from '../../utils/api.js'
+import config from '@/config.js'
 
 export default {
   data() {
@@ -76,7 +100,9 @@ export default {
       sourceFileUrl: '',
       parsedData: null,
       reportDateStr: '',
-      manualDate: ''
+      manualDate: '',
+      recommendedProducts: [],
+      growthLoading: false
     }
   },
   computed: {
@@ -102,6 +128,7 @@ export default {
           }
           if (target) {
             this.selectedChild = target
+            this.loadRecommend()
           }
         }
       } catch (e) {
@@ -117,6 +144,7 @@ export default {
           var idx = res.tapIndex
           if (idx >= 0 && idx < self.children.length) {
             self.selectedChild = self.children[idx]
+            self.loadRecommend()
           }
         }
       })
@@ -212,6 +240,45 @@ export default {
       this.parsedData = null
       this.reportDateStr = ''
       this.manualDate = ''
+    },
+    loadRecommend: async function() {
+      this.recommendedProducts = []
+      var child = this.selectedChild
+      if (!child) {
+        this.growthLoading = false
+        return
+      }
+      this.growthLoading = true
+      try {
+        var res = await getChildRecords(child.id)
+        if (res && res.code === 200) {
+          var records = res.data || []
+          if (records.length === 0) {
+            var prodRes = await productList({ growthRelated: true, size: 10 })
+            if (prodRes && prodRes.code === 200) {
+              var arr = (prodRes.data && prodRes.data.list) || (prodRes.data && prodRes.data.records) || []
+              this.recommendedProducts = arr
+            }
+          }
+        }
+      } catch (e) {
+        this.recommendedProducts = []
+      } finally {
+        this.growthLoading = false
+      }
+    },
+    goToProduct: function(id) {
+      uni.navigateTo({ url: '/pages/discover-detail/product-detail/product-detail?id=' + id })
+    },
+    formatPrice: function(cents) {
+      if (cents === null || cents === undefined) return '¥0'
+      return '¥' + (cents / 100).toFixed(2)
+    },
+    getImageUrl: function(path) {
+      return config.API_BASE_URL + path
+    },
+    getProductKey: function(item) {
+      return item.id
     }
   }
 }
@@ -372,4 +439,74 @@ export default {
 .btn-group .btn-submit {
   flex: 1;
 }
+.purchase-section {
+  background: #F0F9FF;
+  border-radius: 16rpx;
+  padding: 24rpx;
+  margin-bottom: 30rpx;
+  border: 2rpx solid #BAE6FD;
+}
+.purchase-header {
+  display: flex;
+  flex-direction: column;
+  margin-bottom: 16rpx;
+}
+.purchase-title {
+  font-size: 28rpx;
+  font-weight: bold;
+  color: #0369A1;
+}
+.purchase-subtitle {
+  font-size: 24rpx;
+  color: #0284C7;
+  margin-top: 6rpx;
+}
+.purchase-scroll {
+  display: flex;
+  flex-direction: row;
+  white-space: nowrap;
+}
+.purchase-card {
+  display: inline-flex;
+  flex-direction: column;
+  width: 220rpx;
+  background: #fff;
+  border-radius: 12rpx;
+  padding: 16rpx;
+  margin-right: 16rpx;
+  flex-shrink: 0;
+}
+.purchase-cover {
+  width: 188rpx;
+  height: 120rpx;
+  border-radius: 8rpx;
+  background: #F1F5F9;
+  margin-bottom: 8rpx;
+}
+.purchase-name {
+  font-size: 22rpx;
+  color: #333;
+  line-height: 1.3;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-bottom: 4rpx;
+}
+.purchase-price {
+  font-size: 24rpx;
+  color: #EF4444;
+  font-weight: bold;
+  margin-bottom: 8rpx;
+}
+.purchase-btn {
+  background: #3B82F6;
+  border-radius: 8rpx;
+  padding: 8rpx 0;
+  text-align: center;
+}
+.purchase-btn-text {
+  font-size: 22rpx;
+  color: #fff;
+  font-weight: 500;
+}
 </style>

+ 4 - 12
cfc-frontend/pages/user-edit/user-edit.vue

@@ -534,10 +534,7 @@ export default {
 				})
 				uni.showToast({ title: '提交成功,请等待审核' })
 				setTimeout(() => {
-					// parent-index 是分包页面(非 TabBar 页),switchTab 会触发路由系统错误,改用 redirectTo
-					uni.redirectTo({
-						url: '/pages/home-pages/parent-index'
-					})
+					uni.switchTab({ url: '/pages/index-home/index' })
 				}, 1500)
 			} catch (e) {
 				uni.showToast({ title: '提交失败', icon: 'none' })
@@ -649,14 +646,9 @@ export default {
 					console.error('保存 mascot 失败', e)
 				}
 			}
-			const role = this.form.role || 'parent'
-			if (role === 'parent') {
-				uni.navigateTo({ url: '/pages/home-pages/parent-index' })
-			} else if (role === 'child') {
-				uni.navigateTo({ url: '/pages/home-pages/child-index' })
-			} else if (role === 'teacher') {
-				uni.navigateTo({ url: '/pages/teacher/index' })
-			}
+			// 用 switchTab 跳转带底部 TabBar 的首页(行/身/智/心/富)
+            // parent-index/child-index 是分包页面(非 TabBar 页),用 navigateTo 会丢失底部 TabBar
+			uni.switchTab({ url: '/pages/index-home/index' })
 		}
 	}
 }

+ 1 - 1
cfc-web/.last_build_commit

@@ -1 +1 @@
-d371172917cbd1f5219a303ee8dea06bfa92c615
+627b05fe359f3405234b43417c25ffff5e41bb6a

+ 2 - 2
cfc-web/package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "cfc-web",
-  "version": "1.0.763",
+  "version": "1.0.768",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "cfc-web",
-      "version": "1.0.763",
+      "version": "1.0.768",
       "dependencies": {
         "@wangeditor/editor": "^5.1.23",
         "@wangeditor/editor-for-vue": "^1.0.2",

+ 1 - 1
cfc-web/package.json

@@ -1,6 +1,6 @@
 {
   "name": "cfc-web",
-  "version": "1.0.763",
+  "version": "1.0.769",
   "private": true,
   "scripts": {
     "dev": "vue-cli-service serve",

+ 14 - 0
cfc-web/public/CHANGELOG-v$(node.undefined.md

@@ -3,6 +3,20 @@
 [« 返回最新版本](CHANGELOG.md)
 
 ---
+## v$(node (2026-08-03)
+
+### 新功能
+- 健康报告确认页指标按类别分组折叠, 支持全部展开/折叠与需处理计数
+
+### Bug 修复
+- 迁移151 growth_records 补齐学校/年级7列 - 修复 Unknown column 'school'
+
+### 其他
+- 且其 snake_case 列名与实体不匹配, 导致全列查询报 Unknown column 'school'。
+- schema.sql 同步为 camelCase 与实体一致。
+- 
+
+
 ## v$(node (2026-07-20)
 
 ### 其他

+ 55 - 0
cfc-web/public/CHANGELOG-v1.0.md

@@ -4,6 +4,61 @@
 
 ---
 
+## v1.0.769 (2026-08-03)
+
+### Bug 修复
+- 首次登录创建家庭后跳转到带底部TabBar的行首页
+
+### 其他
+- /pages/home-pages/parent-index(非 TabBar 页),导致底部 TabBar 不显示。
+- 改为 uni.switchTab() 跳到 /pages/index-home/index(行 Tab)。
+- 同样修复 submitGuideApplication 中相同的 redirectTo 错误跳转。
+- 
+
+
+## v1.0.768 (2026-08-03)
+
+### Bug 修复
+- self-check 页面 :style 空对象 {} 改用字符串避免 WXML 编译错误
+- 移除 DatabaseInitializer 残留冲突标记行 - 修复生产构建失败
+
+### 其他
+- (7498行), 导致 mvn compile 报 illegal start of expression。
+- 仅删除该残留标记行, 迁移152/153 内容保持不变。
+- 
+
+
+## v1.0.767 (2026-08-03)
+
+### Bug 修复
+- 修复 Playwright addInitScript 单参数传参 bug + 远程 E2E 测试套件
+
+
+## v1.0.766 (2026-08-03)
+
+### 新功能
+- 商品赠送会员功能 - 成长档案关联商品配置(GrowthArchiveProduct) + 后端 Controller/Service/Mapper/Entity + 前端 ProductEdit 商品编辑页
+
+
+## v1.0.765 (2026-08-03)
+
+### Bug 修复
+- PaymentService 微信支付V3解密用原始API密钥而非SHA-256哈希
+- 商品详情原生分享补上 setShareInfo - 分享路径带商品ID和邀请码
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.764 (2026-08-03)
+
+### Bug 修复
+- resolve merge conflict in tap-water-pollution.html (accept upstream version)
+
+
 ## v1.0.763 (2026-08-03)
 
 ### 新功能

+ 56 - 1
cfc-web/public/CHANGELOG.md

@@ -1,6 +1,6 @@
 # 更新日志
 
-> 当前版本: v1.0.763
+> 当前版本: v1.0.769
 
 ## 历史版本
 
@@ -8,6 +8,61 @@
 
 ---
 
+## v1.0.769 (2026-08-03)
+
+### Bug 修复
+- 首次登录创建家庭后跳转到带底部TabBar的行首页
+
+### 其他
+- /pages/home-pages/parent-index(非 TabBar 页),导致底部 TabBar 不显示。
+- 改为 uni.switchTab() 跳到 /pages/index-home/index(行 Tab)。
+- 同样修复 submitGuideApplication 中相同的 redirectTo 错误跳转。
+- 
+
+
+## v1.0.768 (2026-08-03)
+
+### Bug 修复
+- self-check 页面 :style 空对象 {} 改用字符串避免 WXML 编译错误
+- 移除 DatabaseInitializer 残留冲突标记行 - 修复生产构建失败
+
+### 其他
+- (7498行), 导致 mvn compile 报 illegal start of expression。
+- 仅删除该残留标记行, 迁移152/153 内容保持不变。
+- 
+
+
+## v1.0.767 (2026-08-03)
+
+### Bug 修复
+- 修复 Playwright addInitScript 单参数传参 bug + 远程 E2E 测试套件
+
+
+## v1.0.766 (2026-08-03)
+
+### 新功能
+- 商品赠送会员功能 - 成长档案关联商品配置(GrowthArchiveProduct) + 后端 Controller/Service/Mapper/Entity + 前端 ProductEdit 商品编辑页
+
+
+## v1.0.765 (2026-08-03)
+
+### Bug 修复
+- PaymentService 微信支付V3解密用原始API密钥而非SHA-256哈希
+- 商品详情原生分享补上 setShareInfo - 分享路径带商品ID和邀请码
+
+### 其他
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+- Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
+- 
+
+
+## v1.0.764 (2026-08-03)
+
+### Bug 修复
+- resolve merge conflict in tap-water-pollution.html (accept upstream version)
+
+
 ## v1.0.763 (2026-08-03)
 
 ### 新功能

+ 16 - 0
cfc-web/src/api/admin.js

@@ -542,6 +542,22 @@ export function copyProduct(id) {
   })
 }
 
+export function getGrowthArchiveProduct(productId) {
+  return request({
+    url: '/api/admin/growth-archive-product/detail',
+    method: 'post',
+    data: { productId }
+  })
+}
+
+export function saveGrowthArchiveProduct(data) {
+  return request({
+    url: '/api/admin/growth-archive-product/save',
+    method: 'post',
+    data
+  })
+}
+
 // ========== SKU 管理 API ==========
 
 export function getProductSkuList(productId) {

+ 11 - 11
cfc-web/src/components/DimensionWeightPicker.vue

@@ -75,20 +75,20 @@ export default {
     }
   },
   watch: {
-    value: {
-      immediate: true,
-      handler(val) {
-        if (Array.isArray(val) && val.length > 0) {
-          val.forEach(w => {
-            const dim = this.dimensions.find(d => d.code === w.dimension)
-            if (dim) {
-              dim.currentValue = w.weight || 0
-            }
-          })
-        }
+    value(val) {
+      if (Array.isArray(val) && val.length > 0) {
+        val.forEach(w => {
+          const dim = this.dimensions.find(d => d.code === w.dimension)
+          if (dim) {
+            dim.currentValue = w.weight || 0
+          }
+        })
       }
     }
   },
+  mounted() {
+    this.emitValue()
+  },
   methods: {
     onWeightChange(changedCode) {
       var changed = this.dimensions.find(function(d) { return d.code === changedCode })

+ 51 - 4
cfc-web/src/views/admin/ProductEdit.vue

@@ -53,6 +53,21 @@
           </el-row>
         </template>
 
+        <!-- 关联成长档案 -->
+        <el-divider content-position="left">关联成长档案</el-divider>
+        <el-form-item label="关联成长档案">
+          <el-switch
+            v-model="growthArchive.enabled"
+            active-text="开启"
+            inactive-text="关闭"
+          />
+          <span style="margin-left: 10px; font-size: 12px; color: #999;">在成长档案上传页推荐该商品</span>
+        </el-form-item>
+        <el-form-item label="排序值">
+          <el-input-number v-model="growthArchive.sortOrder" :min="0" :precision="0" style="width: 200px;" controls />
+          <span style="margin-left: 10px; font-size: 12px; color: #999;">越小越靠前</span>
+        </el-form-item>
+
         <el-form-item label="商品简介" prop="intro">
           <el-input v-model="form.intro" type="textarea" :rows="2" placeholder="简短描述" maxlength="200" />
         </el-form-item>
@@ -216,7 +231,7 @@
 </template>
 
 <script>
-import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels } from '@/api/admin'
+import { getProduct, createProduct, updateProduct, listAllSupplySystems, getPredefinedPurchaseFields, getProductPurchaseFields, saveProductPurchaseFields, getMySupplySystem, saveProductGiftRule, listProductGiftRules, listMembershipLevels, getGrowthArchiveProduct, saveGrowthArchiveProduct } from '@/api/admin'
 import { saveDimensionWeights, getDimensionWeights } from '@/api/dimension-weight'
 import DimensionWeightPicker from '@/components/DimensionWeightPicker.vue'
 import ProductSkuEditor from './components/ProductSkuEditor.vue'
@@ -251,6 +266,10 @@ export default {
         validityDays: 365,
         guideScope: 'all'
       },
+      growthArchive: {
+        enabled: false,
+        sortOrder: 0
+      },
       imageList: [],
       supplySystems: [],
       giftRuleEnabled: false,
@@ -402,8 +421,10 @@ export default {
           this.loadDimensionWeights(p.id)
           // Load purchase fields config
           this.loadPurchaseFields(p.id)
-          // 赠送会员规则回显
+// 赠送会员规则回显
           this.loadGiftRule(p.id)
+          // Load growth archive config
+          this.loadGrowthArchive(p.id)
           // v-model 已绑定 form.description,editor 内容自动更新
         }
       } catch (e) {
@@ -457,7 +478,7 @@ export default {
         console.warn('加载购买字段配置失败', e)
       }
     },
-    async loadGiftRule(productId) {
+async loadGiftRule(productId) {
       try {
         const lvRes = await listMembershipLevels()
         if (lvRes.code === 200 && Array.isArray(lvRes.data)) {
@@ -475,6 +496,19 @@ export default {
         console.warn('加载赠送会员规则失败', e)
       }
     },
+    async loadGrowthArchive(productId) {
+      try {
+        const res = await getGrowthArchiveProduct(productId)
+        if (res.code === 200 && res.data) {
+          this.growthArchive = {
+            enabled: res.data.status === 1,
+            sortOrder: res.data.sortOrder || 0
+          }
+        }
+      } catch (e) {
+        console.warn('加载成长档案关联配置失败', e)
+      }
+    },
     handlePurchaseFieldsToggle(val) {
       if (!val) {
         this.predefinedFields.forEach(pf => { pf.selected = false })
@@ -590,7 +624,7 @@ export default {
                 console.warn('保存购买字段配置失败', e)
               }
             }
-            // 保存赠送会员规则
+// 保存赠送会员规则
             if (productId) {
               try {
                 await saveProductGiftRule(productId, this.giftRuleEnabled ? 1 : 0,
@@ -599,6 +633,19 @@ export default {
                 console.warn('保存赠送会员规则失败', e)
               }
             }
+            // Save growth archive config (only for existing products)
+            if (this.form.id) {
+              try {
+                await saveGrowthArchiveProduct({
+                  productId: this.form.id,
+                  status: this.growthArchive.enabled ? 1 : 0,
+                  sortOrder: this.growthArchive.sortOrder
+                })
+              } catch (e) {
+                this.$message.error('保存成长档案关联失败')
+                console.warn('保存成长档案关联失败', e)
+              }
+            }
             this.$message.success(this.isEdit ? '保存成功' : '创建成功')
             this.$router.push('/product-manage')
           } else {

+ 230 - 0
tests/e2e/admin-menu-survey.spec.js

@@ -0,0 +1,230 @@
+/**
+ * ================================================================
+ * 场景:后台管理端逐菜单可达性测试
+ * ================================================================
+ * 目标:管理员通过 cfc-web 管理后台(远程部署 http://cfc.iwintrue.com/admin/)
+ *       逐一访问 router/index.js 中的全部菜单路由,验证:
+ *         - 未因权限不足被重定向到 /login 或 /403
+ *         - 页面主体渲染成功(非白屏)
+ *
+ * 部署信息(已验证):
+ *   - SPA 静态资源: http://cfc.iwintrue.com/admin/* (200)
+ *   - 后端 API:     http://cfc.iwintrue.com/api/* (openresty 代理)
+ *   - 管理员登录:   POST /api/admin-auth/login-by-password {phone, password}
+ *   - 测试账号:     13800138000 / admin123(role=admin, adminId=1001)
+ *   - 滑块验证码:   纯前端模拟(拖动 >=88% 即成功),UI 登录可自动化;
+ *                   本测试采用 API 登录 + localStorage 注入,更稳定
+ *
+ * 运行:npx playwright test tests/e2e/admin-menu-survey.spec.js --project=desktop
+ * ================================================================
+ */
+
+const { test, expect } = require('@playwright/test');
+
+var BASE_URL = process.env.PW_BASE_URL || 'http://cfc.iwintrue.com';
+var APP_PREFIX = '/admin';
+
+// ================================================================
+// 全部菜单路由(来自 cfc-web/src/router/index.js,admin 角色可见)
+// 参数化路由(:orderNo / :id / :systemId)单独处理
+// ================================================================
+
+var ADMIN_MENUS = [
+  // 首页/看板
+  { path: '/dashboard', title: '首页' },
+  { path: '/admin/dashboard', title: '数据大屏' },
+  // 家庭管理
+  { path: '/families', title: '家庭列表' },
+  { path: '/children', title: '孩子管理' },
+  { path: '/points', title: '积分管理' },
+  { path: '/points-log', title: '积分记录' },
+  { path: '/membership-center', title: '会员中心' },
+  // 任务管理
+  { path: '/tasks', title: '任务列表' },
+  { path: '/task-templates', title: '任务模板' },
+  { path: '/growth-task', title: '成长任务管理' },
+  // 奖励管理
+  { path: '/rewards', title: '奖励列表' },
+  { path: '/wishes', title: '心愿管理' },
+  // 系统管理
+  { path: '/users', title: '用户管理' },
+  { path: '/review-center', title: '审核中心' },
+  { path: '/operation-logs', title: '操作日志' },
+  // 服务管理(管理员)
+  { path: '/service-role-applications', title: '服务角色申请审核' },
+  // 测评管理
+  { path: '/assessment-admin', title: '测评管理' },
+  { path: '/assessment-assign', title: '待分配规划师' },
+  { path: '/assessment-orders', title: '测评订单' },
+  { path: '/dan-execution', title: 'DAN服务执行' },
+  { path: '/dan-settlement', title: 'DAN结算管理' },
+  // 商品/订单管理
+  { path: '/product-manage', title: '商品管理' },
+  { path: '/product-edit', title: '商品编辑' },
+  { path: '/assessment-products', title: '测评商品管理' },
+  { path: '/assessment-plan-rules', title: '方案生成规则' },
+  { path: '/plan-management', title: '方案管理' },
+  { path: '/category-manage', title: '商品分类' },
+  { path: '/order-manage', title: '订单管理' },
+  { path: '/pending-refund', title: '待退款管理' },
+  // 系统配置
+  { path: '/sys-config', title: '系统配置' },
+  { path: '/energy-sandbox', title: '五维能量' },
+  { path: '/energy-rule', title: '能量规则管理' },
+  { path: '/energy-behavior-config', title: '能量行为配置' },
+  { path: '/periodic-service-config', title: '周期性服务配置' },
+  { path: '/virtual-goods-config', title: '虚拟支付道具' },
+  { path: '/knowledge-tags', title: '知识标签' },
+  // 文章管理
+  { path: '/article-categories', title: '文章分类' },
+  { path: '/article-manage', title: '文章管理' },
+  { path: '/article-edit', title: '文章编辑' },
+  { path: '/comment-review', title: '评论审核' },
+  // 佣金推荐系统
+  { path: '/product-profit-rate', title: '产品利润率' },
+  { path: '/product-ppoint', title: 'P点配置' },
+  { path: '/supply-system', title: '供应商体系' },
+  { path: '/supplier-products', title: '供应商商品管理' },
+  { path: '/supply-hierarchy/change-requests', title: '更换上级审核' },
+  // 供应商管理
+  { path: '/supply-manage', title: '供应商管理' },
+  { path: '/supply-manage/create', title: '新增供应商' },
+  // 星座/八字/血型配置
+  { path: '/zodiac-configs', title: '星座配置' },
+  { path: '/bazi-configs', title: '八字配置' },
+  { path: '/blood-type-configs', title: '血型配置' },
+  // 饮食管理
+  { path: '/foods', title: '食材管理' },
+  { path: '/recipes', title: '食谱管理' },
+  { path: '/seasonal-foods', title: '应季食材' },
+  // 健康/报告
+  { path: '/health-reports', title: '健康报告' },
+  { path: '/report-management', title: '报告管理' },
+  { path: '/health-report-audit', title: '报告草稿审核' },
+  { path: '/health-indicators', title: '健康指标' },
+  { path: '/health-checkins', title: '健康打卡' },
+  { path: '/emotion-alert', title: '情绪告警管理' },
+  { path: '/nutrition-mappings', title: '营养素映射管理' },
+  // 活动管理
+  { path: '/activities', title: '活动列表' },
+  { path: '/activity-edit', title: '活动编辑' },
+  { path: '/activity-review', title: '活动审核' },
+  { path: '/activity-registration-review', title: '活动报名审核' },
+  // 营销管理
+  { path: '/coupon', title: '优惠券管理' },
+  { path: '/promotion', title: '推广管理' },
+  { path: '/family-earnings', title: '家庭收益管理' },
+  { path: '/family-earnings-withdraw', title: '家庭收益提现审核' },
+  // 维度配置 + 知识库
+  { path: '/dimension-config', title: '维度配置' },
+  { path: '/knowledge-base', title: '知识库管理' },
+  { path: '/health-knowledge', title: '健康知识库管理' },
+  { path: '/health-norm-config', title: '健康常模配置' },
+  { path: '/health-data-source', title: '健康数据源配置' },
+  { path: '/health-energy-config', title: '七维能量配置' },
+  { path: '/energy-config', title: '五行能量配置' },
+  { path: '/indicators', title: '指标管理' },
+  // 虚拟团队
+  { path: '/virtual-teams', title: '虚拟团队管理' },
+  // 电商供应商
+  { path: '/ecom-supplier', title: '电商供应商管理' },
+  { path: '/badge-manage', title: '勋章管理' }
+];
+
+// 参数化路由(需要具体 ID,用占位 ID 验证可达性)
+var PARAM_MENUS = [
+  { path: '/order-detail/1', title: '订单详情' },
+  { path: '/supply-system/1', title: '供应商体系详情' },
+  { path: '/supply-system/1/edit', title: '编辑体系' },
+  { path: '/supply-manage/1', title: '供应商详情' },
+  { path: '/supply-manage/1/edit', title: '编辑供应商' },
+  { path: '/virtual-team-detail/1', title: '团队成员管理' }
+];
+
+// ================================================================
+// 登录 helper:API 登录 → 注入 localStorage(含 roles 数组)
+// ================================================================
+
+var ADMIN_PHONE = '13800138000';
+var ADMIN_PASSWORD = 'admin123';
+
+async function loginAsAdmin(page) {
+  var res = await page.request.post(BASE_URL + '/api/admin-auth/login-by-password', {
+    data: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
+    headers: { 'Content-Type': 'application/json' }
+  });
+  expect(res.ok()).toBeTruthy();
+  var body = await res.json();
+  expect(body.code).toBe(200);
+  expect(body.data && body.data.token).toBeTruthy();
+
+  var token = body.data.token;
+  var role = body.data.role || 'admin';
+  var roles = body.data.roles && body.data.roles.length ? body.data.roles : [role];
+
+  // 注意:addInitScript 只接受一个 arg,必须打包成单个对象传参
+  await page.addInitScript(function (auth) {
+    localStorage.setItem('token', auth.token);
+    localStorage.setItem('access_token', auth.token);
+    localStorage.setItem('role', auth.role);
+    localStorage.setItem('roles', JSON.stringify(auth.roles));
+    localStorage.setItem('adminId', String(auth.adminId));
+  }, { token: token, role: role, roles: roles, adminId: body.data.adminId });
+}
+
+// ================================================================
+// 菜单可达性检查:goto → 非 /login、非 /403、主体渲染
+// ================================================================
+
+async function assertMenuAccessible(page, route) {
+  var url = BASE_URL + APP_PREFIX + route.path;
+  await page.goto(url, { waitUntil: 'networkidle', timeout: 60000 });
+
+  // 1. 不应被踢回登录页(token 失效/权限不足)
+  var currentUrl = page.url();
+  expect(currentUrl).not.toContain('/login');
+  expect(currentUrl).not.toContain('/403');
+
+  // 2. 页面主体应渲染:侧边栏 + 内容区
+  var sidebar = page.locator('.sidebar-container, .el-aside, .el-menu');
+  await expect(sidebar.first()).toBeVisible({ timeout: 15000 });
+
+  // 3. 内容区非空(.app-container / .el-main / 页面根)
+  var content = page.locator('.app-container, .el-main, #app > div');
+  var contentCount = await content.count();
+  expect(contentCount).toBeGreaterThan(0);
+}
+
+test.describe('【后台管理】逐菜单可达性测试', () => {
+
+  test('管理员登录并访问全部菜单 - 期望全部可达', async ({ page }) => {
+    await loginAsAdmin(page);
+
+    var failures = [];
+    for (var i = 0; i < ADMIN_MENUS.length; i++) {
+      var menu = ADMIN_MENUS[i];
+      try {
+        await assertMenuAccessible(page, menu);
+        console.log('[PASS] ' + menu.path + ' (' + menu.title + ')');
+      } catch (e) {
+        failures.push(menu.path + ' (' + menu.title + '): ' + (e.message || e).split('\n')[0]);
+        console.log('[FAIL] ' + menu.path + ' (' + menu.title + ')');
+        await page.screenshot({ path: 'test-artifacts/menu-fail-' + menu.path.replace(/\//g, '_') + '.png' })
+          .catch(function () {});
+      }
+    }
+
+    expect(failures, '以下菜单访问失败:\n' + failures.join('\n')).toEqual([]);
+  });
+
+  test('参数化路由可达性 - 期望不跳登录/403', async ({ page }) => {
+    await loginAsAdmin(page);
+
+    for (var j = 0; j < PARAM_MENUS.length; j++) {
+      var pmenu = PARAM_MENUS[j];
+      await assertMenuAccessible(page, pmenu);
+      console.log('[PASS] ' + pmenu.path + ' (' + pmenu.title + ')');
+    }
+  });
+
+});

+ 218 - 324
tests/e2e/assessment-order-flow.spec.js

@@ -1,366 +1,260 @@
 /**
  * ================================================================
- * 场景:测评订单全流程 E2E 测试
+ * 场景:测评管理全流程 E2E 测试(cfc-web 管理端版)
  * ================================================================
- * 用户故事:作为家长,我希望为孩子购买专业测评服务,
- *           以便了解孩子的认知发展状况并获得成长建议。
+ * 适配远程环境:cfc-web 管理端 (/admin),非 uni-app H5
+ * 前置条件:无(远程 API 和 SPA 已部署,独立测试账号可用)
+ * 运行:npx playwright test tests/e2e/assessment-order-flow.spec.js --config=playwright.remote.config.js
  *
- * 流程步骤:
- *   1. 家长选择规划师和测评套餐,进入测评申请页面
- *   2. 家长填写孩子信息,提交测评申请
- *   3. 家长完成支付,订单状态变更为"已支付"
- *   4. 系统自动确认预约,预约状态变更为"待测评"
- *   5. 规划师确认预约,查看孩子的基本信息(快照)
- *   6. 规划师完成测评,录入认知评估结果
- *   7. 家长/孩子查看测评报告
- *
- * 关键里程碑:
- *   - [Milestone-1] 订单创建成功,获得订单号
- *   - [Milestone-2] 支付成功,预约自动创建
- *   - [Milestone-3] 规划师确认预约
- *   - [Milestone-4] 测评结果录入完成
- *   - [Milestone-5] 家长可查询到孩子的最新报告
+ * 5 个场景对应已部署的 admin 测评页面:
+ *   1. /assessment-admin     - 测评管理(积分配置 + 资料管理)
+ *   2. /assessment-orders     - 测评订单(预约管理 + 筛选搜索)
+ *   3. /assessment-assign     - 待分配规划师
+ *   4. /dan-execution         - DAN服务执行管理
+ *   5. /assessment-products   - 测评商品管理
  * ================================================================
- *
- * 运行:npx playwright test tests/e2e/assessment-order-flow.spec.js
  */
-
 const { test, expect } = require('@playwright/test');
 
-test.describe('【场景流程】测评订单全流程', () => {
+var BASE_URL = 'http://cfc.iwintrue.com';
+var APP_PREFIX = '/admin';
 
-  // ===== 场景1:家长选购测评套餐 =====
-
-  /**
-   * 场景:家长选择规划师并购买测评套餐
-   * 角色:家长
-   * 触发条件:家长选择了具体的规划师和测评套餐
-   */
-  test('[场景1] 家长购买测评套餐并完成支付 - 期望全流程成功', async ({ page }) => {
-    // ========== Given:家长进入测评申请页面 ==========
-    await page.goto('/#/pages/assessment/apply');
-    await page.waitForLoadState('networkidle');
-
-    // 验证页面加载成功
-    await expect(page.locator('.assessment-apply-page')).toBeVisible({ timeout: 10000 });
-
-    // ========== Step 1:选择孩子 ==========
-    const childSelector = page.locator('.child-picker');
-    if (await childSelector.isVisible()) {
-      await childSelector.click();
-      await page.waitForSelector('.child-option', { timeout: 5000 });
-      await page.click('.child-option:first-child');
-    }
-
-    // ========== Step 2:选择规划师(可选)==========
-    const guideSelector = page.locator('.guide-picker');
-    if (await guideSelector.isVisible()) {
-      await guideSelector.click();
-      await page.waitForSelector('.guide-option', { timeout: 5000 });
-      await page.click('.guide-option:first-child');
-    }
-
-    // ========== Step 3:选择测评套餐 ==========
-    await page.click('.package-option:first-child');
-
-    // ========== When:提交申请 ==========
-    await page.click('.submit-btn');
-
-    // ========== Then:Milestone-1 - 订单创建成功 ========
-    // 等待订单确认弹窗或跳转
-    await page.waitForSelector('.order-confirm-modal, .order-success-tip', { timeout: 10000 });
-
-    // 验证订单号生成(订单号应该在页面某处显示)
-    const orderSuccessTip = page.locator('.order-success-tip, .order-no');
-    if (await orderSuccessTip.isVisible()) {
-      const orderText = await orderSuccessTip.textContent();
-      expect(orderText).toMatch(/订单|ASR|成功/);
-    }
-
-    // ========== Step 4:点击去支付 ==========
-    await page.click('.go-pay-btn');
-
-    // ========== Then:Milestone-2 - 支付成功 ========
-    // 选择支付方式
-    const wechatPayOption = page.locator('.pay-method:has-text("微信支付")');
-    if (await wechatPayOption.isVisible()) {
-      await wechatPayOption.click();
-    }
+// ================================================================
+// 登录 helper:API 登录 → 注入 localStorage(与 menu-survey 一致)
+// ================================================================
 
-    // 确认支付
-    await page.click('.confirm-pay-btn');
+var ADMIN_PHONE = '13800138000';
+var ADMIN_PASSWORD = 'admin123';
 
-    // 等待支付成功提示
-    await page.waitForSelector('.pay-success-tip, .payment-success', { timeout: 15000 });
-
-    // 验证支付状态
-    const successTip = page.locator('.pay-success-tip, .payment-success');
-    expect(await successTip.isVisible()).toBeTruthy();
+async function loginAsAdmin(page) {
+  var res = await page.request.post(BASE_URL + '/api/admin-auth/login-by-password', {
+    data: { phone: ADMIN_PHONE, password: ADMIN_PASSWORD },
+    headers: { 'Content-Type': 'application/json' }
   });
-
-  // ===== 场景2:自动分配规划师流程 =====
+  expect(res.ok()).toBeTruthy();
+  var body = await res.json();
+  expect(body.code).toBe(200);
+  expect(body.data && body.data.token).toBeTruthy();
+
+  var token = body.data.token;
+  var role = body.data.role || 'admin';
+  var roles = body.data.roles && body.data.roles.length ? body.data.roles : [role];
+
+  // 注意:addInitScript 只接受一个 arg,必须打包成单个对象传参
+  await page.addInitScript(function (auth) {
+    localStorage.setItem('token', auth.token);
+    localStorage.setItem('access_token', auth.token);
+    localStorage.setItem('role', auth.role);
+    localStorage.setItem('roles', JSON.stringify(auth.roles));
+    localStorage.setItem('adminId', String(auth.adminId));
+  }, { token: token, role: role, roles: roles, adminId: body.data.adminId });
+}
+
+test.describe('【场景流程】测评管理全流程 (cfc-web)', () => {
 
   /**
-   * 场景:家长未指定规划师,系统自动分配家庭绑定的规划师
-   * 角色:家长
-   * 触发条件:家长未指定 guideId,但家庭已绑定规划师
+   * 场景1:测评管理页加载
+   * 页面:/assessment-admin → Assessment.vue
+   * 验证:积分配置表单 + 测评资料表格均渲染
    */
-  test('[场景2] 未指定规划师时系统自动分配 - 期望使用家庭绑定的规划师', async ({ page }) => {
-    // ========== Given:家庭已绑定规划师 ==========
-    await page.goto('/#/pages/assessment/apply');
-    await page.waitForLoadState('networkidle');
-
-    // ========== Step 1:选择孩子 ==========
-    const childSelector = page.locator('.child-picker');
-    if (await childSelector.isVisible()) {
-      await childSelector.click();
-      await page.waitForSelector('.child-option', { timeout: 5000 });
-      await page.click('.child-option:first-child');
-    }
-
-    // ========== Step 2:直接选择套餐(不选规划师)==========
-    await page.click('.package-option:first-child');
-
-    // ========== When:提交申请 ==========
-    await page.click('.submit-btn');
-
-    // ========== Then:系统自动使用家庭绑定的规划师 ========
-    // 应该显示自动分配提示或规划师名称
-    await page.waitForSelector('.auto-assign-tip, .guide-name', { timeout: 10000 });
-
-    const guideName = page.locator('.guide-name, .guide-info');
-    if (await guideName.isVisible()) {
-      const nameText = await guideName.textContent();
-      expect(nameText.length).toBeGreaterThan(0);
-    }
-  });
+  test('[场景1] 测评管理页 - 期望积分配置与资料表格可见', async ({ page }) => {
+    await loginAsAdmin(page);
+    await page.goto(BASE_URL + APP_PREFIX + '/assessment-admin', { waitUntil: 'networkidle', timeout: 60000 });
 
-  // ===== 场景3:规划师录入测评结果 =====
+    // 1. 页面根容器
+    await expect(page.locator('.admin-page')).toBeVisible({ timeout: 15000 });
 
-  /**
-   * 场景:规划师完成测评后,录入孩子的认知评估结果
-   * 角色:成长规划师
-   * 触发条件:预约已完成,规划师完成了一对一测评
-   */
-  test('[场景3] 规划师录入测评结果 - 期望结果保存并可查询', async ({ page }) => {
-    // ========== Given:规划师进入测评结果录入页面 ==========
-    await page.goto('/#/pages/guide/assessment/record');
-    await page.waitForLoadState('networkidle');
-
-    // ========== Step 1:选择要录入的预约 ==========
-    await page.waitForSelector('.appointment-item', { timeout: 10000 });
-    await page.click('.appointment-item:first-child');
-
-    // ========== Step 2:填写测评结果 ==========
-    // 输入各项分数
-    const scoreInputs = page.locator('.score-input');
-    const scoreCount = await scoreInputs.count();
-
-    for (let i = 0; i < scoreCount; i++) {
-      await scoreInputs.nth(i).fill(String(Math.floor(Math.random() * 20) + 80));
-    }
+    // 2. 积分配置卡片:el-form + el-input-number
+    var configCard = page.locator('.config-card');
+    await expect(configCard).toBeVisible();
 
-    // 输入综合评语
-    const reportTextarea = page.locator('.analysis-report textarea, .report-textarea');
-    if (await reportTextarea.isVisible()) {
-      await reportTextarea.fill('孩子整体认知发展良好,在注意力和逻辑思维方面表现突出...');
-    }
+    // 3. 测评资料卡片:表格 el-table 渲染
+    var materialsCard = page.locator('.materials-card');
+    await expect(materialsCard).toBeVisible();
 
-    // ========== When:提交测评结果 ==========
-    await page.click('.submit-result-btn');
+    // 4. 表格存在(可能有数据或空态)
+    var table = page.locator('.el-table');
+    await expect(table.first()).toBeVisible();
 
-    // ========== Then:Milestone-4 - 测评结果保存成功 ========
-    await page.waitForSelector('.submit-success-tip, .result-saved', { timeout: 10000 });
+    // 5. 搜索框存在
+    var searchInput = page.locator('.el-input__inner').first();
+    await expect(searchInput).toBeVisible();
 
-    const successTip = page.locator('.submit-success-tip, .result-saved');
-    expect(await successTip.isVisible()).toBeTruthy();
+    // 6. 添加资料按钮存在
+    var addBtn = page.locator('button:has-text("添加资料")');
+    await expect(addBtn).toBeVisible();
   });
 
-  // ===== 场景4:家长查询孩子测评报告 =====
-
   /**
-   * 场景:家长为孩子购买测评后,查看孩子的最新认知评估报告
-   * 角色:家长
-   * 触发条件:规划师已完成测评并录入结果
+   * 场景2:测评订单管理 - 搜索与状态筛选
+   * 页面:/assessment-orders → AssessmentOrders.vue
+   * 验证:标题 + 预约表格 + 筛选组件 + 搜索功能
    */
-  test('[场景4] 家长查询孩子最新报告 - 期望返回完整报告内容', async ({ page }) => {
-    // ========== Given:进入测评报告页面 ==========
-    await page.goto('/#/pages/assessment/results');
-    await page.waitForLoadState('networkidle');
-
-    // ========== Step 1:选择孩子 ==========
-    const childSelector = page.locator('.child-picker');
-    if (await childSelector.isVisible()) {
-      await childSelector.click();
-      await page.waitForSelector('.child-option', { timeout: 5000 });
-      await page.click('.child-option:first-child');
-    }
-
-    // ========== When:查看报告 ==========
-    await page.click('.view-report-btn');
-
-    // ========== Then:Milestone-5 - 返回完整报告内容 ========
-    await page.waitForSelector('.report-detail, .result-card', { timeout: 10000 });
-
-    // 验证报告包含必要内容
-    const reportDetail = page.locator('.report-detail, .result-content');
-    expect(await reportDetail.isVisible()).toBeTruthy();
-
-    // 验证分数显示
-    const scoreDisplay = page.locator('.overall-score, .score-value');
-    if (await scoreDisplay.isVisible()) {
-      const scoreText = await scoreDisplay.textContent();
-      expect(scoreText).toMatch(/\d+/);
+  test('[场景2] 测评订单管理 - 期望预约表格与筛选功能可用', async ({ page }) => {
+    await loginAsAdmin(page);
+    await page.goto(BASE_URL + APP_PREFIX + '/assessment-orders', { waitUntil: 'networkidle', timeout: 60000 });
+
+    // 1. 页面标题
+    var title = page.locator('.admin-page-title');
+    await expect(title).toContainText('测评预约管理');
+
+    // 2. 表格存在
+    var table = page.locator('.el-table');
+    await expect(table.first()).toBeVisible({ timeout: 15000 });
+
+    // 3. 表格列标签(用户/孩子/测评方案/状态等)
+    var headerCells = page.locator('.el-table__header th');
+    var headerCount = await headerCells.count();
+    expect(headerCount).toBeGreaterThanOrEqual(5);
+
+    // 4. 状态筛选下拉框
+    var statusSelect = page.locator('.el-select').first();
+    await expect(statusSelect).toBeVisible();
+
+    // 5. 搜索输入框(用 placeholder 定位,避免与 el-select 的 input 混淆)
+    var searchInput = page.locator('input[placeholder*="搜索"]');
+    await expect(searchInput).toBeVisible();
+
+    // 6. 搜索按钮(使用 force 避免 el-select 下拉箭头遮罩)
+    var searchBtn = page.locator('button:has-text("搜索")');
+    await expect(searchBtn).toBeVisible();
+
+    // 7. 执行搜索(输入关键词测试筛选功能)
+    await searchInput.fill('13701366188');
+    await searchBtn.click({ force: true });
+    await page.waitForTimeout(2000);
+
+    // 搜索后表格仍在(可能无结果,但组件正常工作)
+    await expect(table.first()).toBeVisible();
+
+    // 8. 分页组件存在(数据较多时展示)
+    var pagination = page.locator('.el-pagination');
+    var hasPagination = await pagination.isVisible().catch(function () { return false; });
+    // 分页可能不显示(数据量小),有则断言,无则跳过
+    if (hasPagination) {
+      await expect(pagination).toBeVisible();
     }
   });
 
-  // ===== 场景5:取消未支付订单 =====
-
   /**
-   * 场景:家长创建订单后决定取消,选择关闭交易
-   * 角色:家长
-   * 触发条件:订单状态为"待支付"
+   * 场景3:待分配规划师
+   * 页面:/assessment-assign → AssessmentAssign.vue
+   * 验证:待分配列表 + 分配规划师按钮存在
    */
-  test('[场景5] 家长取消未支付订单 - 期望订单关闭成功', async ({ page }) => {
-    // ========== Given:进入我的订单页面 ==========
-    await page.goto('/#/pages/assessment/orders');
-    await page.waitForLoadState('networkidle');
-
-    // ========== Step 1:找到待支付订单 ==========
-    await page.waitForSelector('.order-item', { timeout: 10000 });
-    const pendingOrder = page.locator('.order-item:has-text("待支付"), .order-item:has-text("待付款")').first();
-    await pendingOrder.click();
-
-    // ========== When:点击取消订单 ==========
-    await page.click('.cancel-order-btn');
-
-    // 确认取消
-    const confirmBtn = page.locator('.confirm-cancel-btn, .van-dialog__confirm');
-    if (await confirmBtn.isVisible()) {
-      await confirmBtn.click();
+  test('[场景3] 待分配规划师 - 期望列表与分配功能可用', async ({ page }) => {
+    await loginAsAdmin(page);
+    await page.goto(BASE_URL + APP_PREFIX + '/assessment-assign', { waitUntil: 'networkidle', timeout: 60000 });
+
+    // 1. 页面标题
+    var title = page.locator('.admin-page-title');
+    await expect(title).toContainText('待分配规划师');
+
+    // 2. 表格存在(可能有记录或空态)
+    var table = page.locator('.el-table');
+    await expect(table.first()).toBeVisible({ timeout: 15000 });
+
+    // 3. 表格列标签(ID/用户/孩子/预约日期等)
+    var headerCells = page.locator('.el-table__header th');
+    var headerCount = await headerCells.count();
+    expect(headerCount).toBeGreaterThanOrEqual(4);
+
+    // 4. 操作列有"分配规划师"按钮(每行一个)
+    var assignBtn = page.locator('button:has-text("分配规划师")').first();
+    var hasAssignBtn = await assignBtn.isVisible().catch(function () { return false; });
+    if (hasAssignBtn) {
+      // 有数据行时验证按钮可点击
+      await expect(assignBtn).toBeEnabled();
     }
 
-    // ========== Then:订单成功取消 ========
-    await page.waitForSelector('.cancel-success-tip, .order-closed', { timeout: 10000 });
-
-    // 订单状态应变为"已取消"
-    const orderStatus = page.locator('.order-status');
-    if (await orderStatus.isVisible()) {
-      const statusText = await orderStatus.textContent();
-      expect(statusText).toMatch(/已取消|已关闭/);
-    }
+    // 5. 刷新按钮存在
+    var refreshBtn = page.locator('button:has-text("刷新")');
+    await expect(refreshBtn).toBeVisible();
   });
 
-  // ===== 场景6:已支付订单退款 =====
-
   /**
-   * 场景:家长支付后因故需要退款
-   * 角色:家长(发起退款)→ 系统处理
-   * 触发条件:订单状态为"已支付"
+   * 场景4:DAN服务执行管理
+   * 页面:/dan-execution → DanExecution.vue
+   * 验证:执行列表 + 状态筛选 + 搜索功能
    */
-  test('[场景6] 已支付订单申请退款 - 期望退款成功', async ({ page }) => {
-    // ========== Given:进入订单详情 ==========
-    await page.goto('/#/pages/assessment/order-detail?orderNo=ASR20260628001');
-    await page.waitForLoadState('networkidle');
-
-    // ========== When:申请退款 ==========
-    await page.click('.apply-refund-btn');
-
-    // 填写退款原因
-    const reasonInput = page.locator('.refund-reason textarea, .reason-input');
-    if (await reasonInput.isVisible()) {
-      await reasonInput.fill('因计划变更,需要退款');
+  test('[场景4] DAN服务执行管理 - 期望执行列表与筛选可用', async ({ page }) => {
+    await loginAsAdmin(page);
+    await page.goto(BASE_URL + APP_PREFIX + '/dan-execution', { waitUntil: 'networkidle', timeout: 60000 });
+
+    // 1. 页面标题
+    var title = page.locator('.admin-page-title');
+    await expect(title).toContainText('DAN测评服务执行管理');
+
+    // 2. 表格存在
+    var table = page.locator('.el-table');
+    await expect(table.first()).toBeVisible({ timeout: 15000 });
+
+    // 3. 表格列标签(ID/订单ID/评估师状态/规划师状态等)
+    var headerCells = page.locator('.el-table__header th');
+    var headerCount = await headerCells.count();
+    expect(headerCount).toBeGreaterThanOrEqual(5);
+
+    // 4. 执行状态筛选下拉框
+    var statusSelect = page.locator('.el-select').first();
+    await expect(statusSelect).toBeVisible();
+
+    // 5. 搜索按钮
+    var searchBtn = page.locator('button:has-text("搜索")');
+    await expect(searchBtn).toBeVisible();
+
+    // 6. 表格行中的数据若存在则验证状态标签
+    var statusTag = page.locator('.el-tag').first();
+    var hasTag = await statusTag.isVisible().catch(function () { return false; });
+    if (hasTag) {
+      var tagText = await statusTag.textContent();
+      expect(tagText).toBeTruthy();
     }
-
-    // 确认提交
-    await page.click('.submit-refund-btn');
-
-    // ========== Then:退款申请提交成功 ========
-    await page.waitForSelector('.refund-submitted, .refund-success', { timeout: 10000 });
-
-    const refundTip = page.locator('.refund-submitted, .refund-success');
-    expect(await refundTip.isVisible()).toBeTruthy();
   });
 
-  // ===== 场景7:完整测评流程(端到端)=====
-
-  test('[场景7] 完整测评生命周期 - 期望全流程成功', async ({ page }) => {
-    // ========== Step 1:申请测评 ==========
-    await page.goto('/#/pages/assessment/apply');
-    await page.waitForLoadState('networkidle');
-
-    // 选择孩子
-    const childSelector = page.locator('.child-picker');
-    if (await childSelector.isVisible()) {
-      await childSelector.click();
-      await page.waitForSelector('.child-option', { timeout: 5000 });
-      await page.click('.child-option:first-child');
-    }
-
-    // 选择套餐并提交
-    await page.click('.package-option:first-child');
-    await page.click('.submit-btn');
-
-    // 等待订单创建
-    await page.waitForSelector('.order-no, .go-pay-btn', { timeout: 10000 });
-
-    // ========== Step 2:支付订单 ==========
-    const goPayBtn = page.locator('.go-pay-btn');
-    if (await goPayBtn.isVisible()) {
-      await goPayBtn.click();
-
-      // 选择微信支付并确认
-      const wechatPay = page.locator('.pay-method:has-text("微信")');
-      if (await wechatPay.isVisible()) {
-        await wechatPay.click();
-      }
-
-      await page.click('.confirm-pay-btn');
-      await page.waitForSelector('.pay-success-tip', { timeout: 15000 });
-    }
-
-    // ========== Step 3:规划师确认预约 ==========
-    await page.goto('/#/pages/guide/assessment/list');
-    await page.waitForLoadState('networkidle');
-
-    const appointmentItem = page.locator('.appointment-item').first();
-    if (await appointmentItem.isVisible()) {
-      await appointmentItem.click();
-      await page.click('.confirm-appointment-btn');
-    }
-
-    // ========== Step 4:规划师录入结果 ==========
-    await page.goto('/#/pages/guide/assessment/record');
-    await page.waitForLoadState('networkidle');
-
-    const pendingRecord = page.locator('.pending-record-item').first();
-    if (await pendingRecord.isVisible()) {
-      await pendingRecord.click();
-
-      // 填写分数
-      const scoreInputs = page.locator('.score-input');
-      for (let i = 0; i < await scoreInputs.count(); i++) {
-        await scoreInputs.nth(i).fill('85');
-      }
-
-      await page.click('.submit-result-btn');
-      await page.waitForSelector('.result-saved', { timeout: 10000 });
-    }
-
-    // ========== Step 5:家长查看报告 ==========
-    await page.goto('/#/pages/assessment/results');
-    await page.waitForLoadState('networkidle');
-
-    // 选择孩子
-    const childPicker = page.locator('.child-picker');
-    if (await childPicker.isVisible()) {
-      await childPicker.click();
-      await page.waitForSelector('.child-option', { timeout: 5000 });
-      await page.click('.child-option:first-child');
-    }
-
-    // 验证报告存在
-    await page.waitForSelector('.report-card, .result-item', { timeout: 10000 });
+  /**
+   * 场景5:测评商品管理
+   * 页面:/assessment-products → AssessmentProducts.vue
+   * 验证:商品列表 + 搜索 + 状态筛选 + 新增商品按钮
+   */
+  test('[场景5] 测评商品管理 - 期望商品列表与筛选功能可用', async ({ page }) => {
+    await loginAsAdmin(page);
+    await page.goto(BASE_URL + APP_PREFIX + '/assessment-products', { waitUntil: 'networkidle', timeout: 60000 });
+
+    // 1. 页面标题
+    var title = page.locator('.admin-page-title');
+    await expect(title).toContainText('测评商品管理');
+
+    // 2. 表格存在
+    var table = page.locator('.el-table');
+    await expect(table.first()).toBeVisible({ timeout: 15000 });
+
+    // 3. 表格列标签(ID/商品名称/状态/售价等)
+    var headerCells = page.locator('.el-table__header th');
+    var headerCount = await headerCells.count();
+    expect(headerCount).toBeGreaterThanOrEqual(5);
+
+    // 4. 搜索框(用 placeholder 定位)
+    var searchInput = page.locator('input[placeholder*="搜索"]');
+    await expect(searchInput).toBeVisible();
+
+    // 5. 商品状态筛选下拉框
+    var statusSelect = page.locator('.el-select').first();
+    await expect(statusSelect).toBeVisible();
+
+    // 6. 新增测评商品按钮
+    var addBtn = page.locator('button:has-text("新增测评商品")');
+    await expect(addBtn).toBeVisible();
+
+    // 7. 查询按钮
+    var queryBtn = page.locator('button:has-text("查询")');
+    await expect(queryBtn).toBeVisible();
+
+    // 8. 执行搜索测试
+    await searchInput.fill('测评');
+    // 触发搜索(按回车或点击查询按钮)
+    await queryBtn.click({ force: true });
+    await page.waitForTimeout(2000);
+    // 搜索后表格仍在
+    await expect(table.first()).toBeVisible();
   });
 
 });

+ 126 - 0
tests/e2e/debug-admin-spa.spec.js

@@ -0,0 +1,126 @@
+/**
+ * 调试脚本:诊断远程 cfc-web SPA 为何不渲染侧边栏
+ * 运行:npx playwright test debug-admin-spa.spec.js --config=playwright.remote.config.js
+ */
+const { test, expect } = require('@playwright/test');
+
+var BASE_URL = 'http://cfc.iwintrue.com';
+
+test('debug SPA render - capture console + network failures', async ({ page }) => {
+  // 1. 捕获页面 console 与错误
+  var consoleMessages = [];
+  var pageErrors = [];
+  var failedRequests = [];
+  var responses = [];
+
+  page.on('console', function (msg) {
+    consoleMessages.push('[' + msg.type() + '] ' + msg.text());
+  });
+  page.on('pageerror', function (err) {
+    pageErrors.push(String(err) + '\nSTACK: ' + (err.stack || 'no-stack'));
+  });
+  page.on('requestfailed', function (req) {
+    failedRequests.push(req.method() + ' ' + req.url() + ' :: ' + (req.failure() && req.failure().errorText));
+  });
+  page.on('response', function (res) {
+    if (res.status() >= 400) {
+      responses.push(res.status() + ' ' + res.url());
+    }
+  });
+
+  // 2. 登录拿 token(与 spec 相同)
+  var loginRes = await page.request.post(BASE_URL + '/api/admin-auth/login-by-password', {
+    data: { phone: '13800138000', password: 'admin123' },
+    headers: { 'Content-Type': 'application/json' }
+  });
+  console.log('LOGIN_STATUS=' + loginRes.status());
+  var body = await loginRes.json();
+  console.log('LOGIN_BODY=' + JSON.stringify(body).substring(0, 300));
+
+  if (body.code === 200 && body.data && body.data.token) {
+    // 注意:addInitScript 只接受一个 arg,必须打包成单个对象传参
+    await page.addInitScript(function (auth) {
+      var token = auth.token, role = auth.role, roles = auth.roles, adminId = auth.adminId;
+      localStorage.setItem('token', token);
+      localStorage.setItem('access_token', token);
+      localStorage.setItem('role', role);
+      localStorage.setItem('roles', JSON.stringify(roles));
+      localStorage.setItem('adminId', String(adminId));
+
+      // Monkey-patch JSON.parse 捕获崩溃点
+      var origParse = JSON.parse;
+      window.__parseLog = [];
+      JSON.parse = function (input) {
+        if (input === undefined || input === null || (typeof input === 'string' && input.indexOf('undefined') >= 0)) {
+          window.__parseLog.push({
+            input: String(input),
+            stack: new Error('captured').stack,
+            rolesRaw: localStorage.getItem('roles'),
+            roleRaw: localStorage.getItem('role'),
+            tokenRaw: localStorage.getItem('token')
+          });
+        }
+        return origParse.apply(this, arguments);
+      };
+    }, { token: body.data.token, role: body.data.role, roles: body.data.roles || [body.data.role], adminId: body.data.adminId });
+  }
+
+  // 捕获 localStorage 所有写入(在 app 脚本前注入)
+  await page.addInitScript(function () {
+    var origSet = Storage.prototype.setItem;
+    window.__storageLog = [];
+    Storage.prototype.setItem = function (k, v) {
+      window.__storageLog.push(k + '=' + String(v).substring(0, 60));
+      return origSet.call(this, k, v);
+    };
+  });
+
+  // 3. 访问 dashboard
+  await page.goto(BASE_URL + '/admin/dashboard', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await page.waitForTimeout(8000);
+
+  console.log('FINAL_URL=' + page.url());
+  console.log('TITLE=' + (await page.title()));
+
+  var appHtml = await page.evaluate(function () {
+    var app = document.getElementById('app');
+    return app ? app.innerHTML.substring(0, 500) : 'NO #app';
+  });
+  console.log('APP_HTML=' + appHtml);
+
+  var bodyText = await page.evaluate(function () {
+    return document.body.innerText.substring(0, 300);
+  });
+  console.log('BODY_TEXT=' + bodyText);
+
+  var asideCount = await page.locator('.el-aside').count();
+  var menuCount = await page.locator('.el-menu').count();
+  var loginCount = await page.locator('.login-container, #login, .login-page').count();
+  console.log('ASIDE_COUNT=' + asideCount + ' MENU_COUNT=' + menuCount + ' LOGIN_COUNT=' + loginCount);
+
+  console.log('--- PAGE ERRORS (' + pageErrors.length + ') ---');
+  pageErrors.forEach(function (e) { console.log('ERR: ' + e.substring(0, 500)); });
+  console.log('--- FAILED REQUESTS (' + failedRequests.length + ') ---');
+  failedRequests.forEach(function (r) { console.log('FAIL: ' + r); });
+  console.log('--- HTTP>=400 RESPONSES (' + responses.length + ') ---');
+  responses.forEach(function (r) { console.log('RESP: ' + r); });
+  console.log('--- CONSOLE (' + consoleMessages.length + ') ---');
+  consoleMessages.forEach(function (m) { console.log('CON: ' + m.substring(0, 300)); });
+
+  await page.screenshot({ path: 'test-artifacts/debug-spa.png', fullPage: true });
+
+  // 输出 monkey-patch 捕获
+  var parseLog = await page.evaluate(function () { return window.__parseLog || []; });
+  console.log('--- JSON.PARSE CAPTURES (' + parseLog.length + ') ---');
+  parseLog.forEach(function (entry) {
+    console.log('INPUT=' + entry.input);
+    console.log('ROLES_RAW=' + entry.rolesRaw + ' ROLE_RAW=' + entry.roleRaw + ' TOKEN_RAW=' + (entry.tokenRaw || '').substring(0, 20));
+    console.log('STACK=' + (entry.stack || 'no-stack'));
+  });
+
+  var storageLog = await page.evaluate(function () { return window.__storageLog || []; });
+  console.log('--- STORAGE WRITES (' + storageLog.length + ') ---');
+  storageLog.forEach(function (s) { console.log('SET: ' + s); });
+
+  expect(true).toBeTruthy();
+});

+ 63 - 0
tests/e2e/debug-control.spec.js

@@ -0,0 +1,63 @@
+/**
+ * 对照实验:不注入任何 localStorage,直接访问远程 SPA
+ * 目的:判断"启动即崩"是部署本身的 bug,还是测试注入方式触发
+ * 流程:
+ *   1. /admin/login  → 登录页应正常渲染(不依赖 token)
+ *   2. /admin/dashboard → 无 token 应重定向 /login
+ *   3. UI 真实登录(填账号密码 + 滑块)→ 看登录后是否也崩
+ */
+const { test, expect } = require('@playwright/test');
+
+var BASE_URL = 'http://cfc.iwintrue.com';
+
+test('CONTROL: 无注入访问 login + dashboard', async ({ page }) => {
+  var consoleMessages = [];
+  var pageErrors = [];
+  page.on('console', function (msg) {
+    consoleMessages.push('[' + msg.type() + '] ' + msg.text().substring(0, 200));
+  });
+  page.on('pageerror', function (err) {
+    pageErrors.push(String(err) + '\nSTACK: ' + (err.stack || '').substring(0, 400));
+  });
+
+  // --- 1. login 页 ---
+  await page.goto(BASE_URL + '/admin/login', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await page.waitForTimeout(6000);
+  console.log('STEP1_LOGIN URL=' + page.url());
+  var loginHtml = await page.evaluate(function () {
+    var app = document.getElementById('app');
+    return app ? app.innerHTML.substring(0, 400) : 'NO #app';
+  });
+  console.log('STEP1_LOGIN APP_HTML=' + loginHtml);
+  var loginVisible = await page.locator('.login-container, .el-form, input[type=password]').count();
+  console.log('STEP1_LOGIN_FORM_ELTS=' + loginVisible);
+  await page.screenshot({ path: 'test-artifacts/control-1-login.png' });
+
+  // --- 2. dashboard 无 token ---
+  await page.goto(BASE_URL + '/admin/dashboard', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await page.waitForTimeout(6000);
+  console.log('STEP2_DASH URL=' + page.url());
+  await page.screenshot({ path: 'test-artifacts/control-2-dashboard-notoken.png' });
+
+  // --- 3. UI 真实登录 ---
+  var inputCount = await page.locator('input').count();
+  console.log('STEP3_INPUT_COUNT=' + inputCount);
+  // 若在登录页,尝试填表
+  var hasPhone = await page.locator('input[placeholder*="手机"], input[placeholder*="账号"], input[type=text]').first().isVisible().catch(function () { return false; });
+  console.log('STEP3_HAS_INPUT=' + hasPhone);
+  if (await page.locator('input').count() > 0) {
+    var inputs = page.locator('input');
+    var n = await inputs.count();
+    for (var k = 0; k < n; k++) {
+      console.log('INPUT_' + k + ' placeholder=' + await inputs.nth(k).getAttribute('placeholder'));
+    }
+  }
+  await page.screenshot({ path: 'test-artifacts/control-3-login-form.png' });
+
+  console.log('--- PAGE ERRORS (' + pageErrors.length + ') ---');
+  pageErrors.forEach(function (e) { console.log('ERR: ' + e.substring(0, 500)); });
+  console.log('--- CONSOLE (' + consoleMessages.length + ') ---');
+  consoleMessages.forEach(function (m) { console.log('CON: ' + m.substring(0, 300)); });
+
+  expect(true).toBeTruthy();
+});

+ 145 - 0
tests/e2e/debug-full-instrument.spec.js

@@ -0,0 +1,145 @@
+/**
+ * 全量仪表化诊断:记录 localStorage 生命周期 + 实际加载的 bundle + 崩溃点
+ * 修复点 vs 上一版:
+ *  1. 记录实际加载的 JS bundle 文件名(performance entries)
+ *  2. 记录注入后 localStorage 的完整快照(boot 前)
+ *  3. 全量 patch:getItem(带堆栈)/setItem/removeItem/clear
+ *  4. 记录崩溃时完整的 localStorage 快照
+ */
+const { test, expect } = require('@playwright/test');
+
+var BASE_URL = 'http://cfc.iwintrue.com';
+
+test('FULL INSTRUMENT: localStorage lifecycle + bundle identity', async ({ page }) => {
+  var consoleMessages = [];
+  var pageErrors = [];
+  page.on('console', function (msg) { consoleMessages.push('[' + msg.type() + '] ' + msg.text().substring(0, 250)); });
+  page.on('pageerror', function (err) { pageErrors.push(String(err) + '\nSTACK: ' + (err.stack || 'no-stack')); });
+
+  // 1. 登录拿 token
+  var loginRes = await page.request.post(BASE_URL + '/api/admin-auth/login-by-password', {
+    data: { phone: '13800138000', password: 'admin123' },
+    headers: { 'Content-Type': 'application/json' }
+  });
+  console.log('LOGIN_STATUS=' + loginRes.status());
+  var body = await loginRes.json();
+  console.log('LOGIN_BODY=' + JSON.stringify(body).substring(0, 400));
+
+  if (body.code === 200 && body.data && body.data.token) {
+    var tok = body.data.token, role = body.data.role, roles = body.data.roles || [body.data.role], adminId = body.data.adminId;
+    console.log('INJECT_RAW token.len=' + String(tok).length + ' role=' + role + ' roles=' + JSON.stringify(roles) + ' adminId=' + adminId);
+
+    // addInitScript #1:注入 + 全量 storage patch + 快照
+    // 注意:addInitScript 只接受一个 arg,必须打包成单个对象传参
+    await page.addInitScript(function (auth) {
+      var token = auth.token, role = auth.role, roles = auth.roles, adminId = auth.adminId;
+      // 记录 boot 前快照(patch 之前)
+      window.__preSnapshot = {
+        token: localStorage.getItem('token'),
+        role: localStorage.getItem('role'),
+        roles: localStorage.getItem('roles'),
+        adminId: localStorage.getItem('adminId')
+      };
+      localStorage.setItem('token', token);
+      localStorage.setItem('access_token', token);
+      localStorage.setItem('role', role);
+      localStorage.setItem('roles', JSON.stringify(roles));
+      localStorage.setItem('adminId', String(adminId));
+      // 注入后快照
+      window.__postSnapshot = {
+        token: localStorage.getItem('token'),
+        role: localStorage.getItem('role'),
+        roles: localStorage.getItem('roles'),
+        adminId: localStorage.getItem('adminId')
+      };
+
+      // 全量 storage patch(此时注入已完成)
+      window.__storageCalls = [];
+      var OGS = Storage.prototype;
+      var origGet = OGS.getItem, origSet = OGS.setItem, origRem = OGS.removeItem, origClear = OGS.clear;
+      OGS.getItem = function (k) {
+        var r = origGet.call(this, k);
+        if (k === 'roles' || k === 'role' || k === 'token') {
+          window.__storageCalls.push({ op: 'get', key: k, val: r === null ? 'NULL' : r, stack: new Error('get').stack.split('\n').slice(2, 5).join(' | ') });
+        }
+        return r;
+      };
+      OGS.setItem = function (k, v) {
+        window.__storageCalls.push({ op: 'set', key: k, val: String(v).substring(0, 60), stack: new Error('set').stack.split('\n').slice(2, 5).join(' | ') });
+        return origSet.call(this, k, v);
+      };
+      OGS.removeItem = function (k) {
+        window.__storageCalls.push({ op: 'remove', key: k, stack: new Error('remove').stack.split('\n').slice(2, 5).join(' | ') });
+        return origRem.call(this, k);
+      };
+      OGS.clear = function () {
+        window.__storageCalls.push({ op: 'clear-all', stack: new Error('clear').stack.split('\n').slice(2, 5).join(' | ') });
+        return origClear.call(this);
+      };
+
+      // JSON.parse patch
+      var origParse = JSON.parse;
+      window.__parseLog = [];
+      JSON.parse = function (input) {
+        if (input === undefined || input === null || (typeof input === 'string' && input.indexOf('undefined') >= 0)) {
+          window.__parseLog.push({
+            input: typeof input === 'string' ? 'STR:' + input.substring(0, 40) : String(input),
+            stack: new Error('captured').stack
+          });
+        }
+        return origParse.apply(this, arguments);
+      };
+    }, { token: tok, role: role, roles: roles, adminId: adminId });
+  }
+
+  // 2. 访问 dashboard
+  await page.goto(BASE_URL + '/admin/dashboard', { waitUntil: 'domcontentloaded', timeout: 60000 });
+  await page.waitForTimeout(10000);
+
+  // 3. 实际加载的 bundle
+  var resources = await page.evaluate(function () {
+    return performance.getEntriesByType('resource').map(function (r) { return r.name; }).filter(function (n) { return n.indexOf('.js') >= 0; });
+  });
+  console.log('--- LOADED JS (' + resources.length + ') ---');
+  resources.forEach(function (r) { console.log('JS: ' + r); });
+
+  console.log('FINAL_URL=' + page.url());
+  var appHtml = await page.evaluate(function () {
+    var app = document.getElementById('app');
+    return app ? app.innerHTML.substring(0, 300) : 'NO #app';
+  });
+  console.log('APP_HTML=' + appHtml);
+
+  console.log('--- PRE SNAPSHOT ---');
+  console.log(JSON.stringify(await page.evaluate(function () { return window.__preSnapshot; })));
+  console.log('--- POST SNAPSHOT ---');
+  console.log(JSON.stringify(await page.evaluate(function () { return window.__postSnapshot; })));
+
+  console.log('--- STORAGE CALLS (' + (await page.evaluate(function () { return (window.__storageCalls || []).length; })) + ') ---');
+  var calls = await page.evaluate(function () { return window.__storageCalls || []; });
+  calls.forEach(function (c) { console.log(c.op.toUpperCase() + ' ' + c.key + (c.val !== undefined ? '=' + c.val : '') + ' || ' + c.stack); });
+
+  console.log('--- JSON.PARSE CAPTURES (' + (await page.evaluate(function () { return (window.__parseLog || []).length; })) + ') ---');
+  var parseLog = await page.evaluate(function () { return window.__parseLog || []; });
+  parseLog.forEach(function (e) { console.log('PARSE input=' + e.input); console.log('PARSE stack=' + e.stack); });
+
+  // 4. 崩溃时 localStorage 完整快照
+  var snap = await page.evaluate(function () {
+    var out = {};
+    for (var i = 0; i < localStorage.length; i++) {
+      var k = localStorage.key(i);
+      out[k] = localStorage.getItem(k);
+    }
+    return out;
+  });
+  console.log('--- FINAL LS SNAPSHOT ---');
+  console.log(JSON.stringify(snap));
+
+  console.log('--- PAGE ERRORS (' + pageErrors.length + ') ---');
+  pageErrors.forEach(function (e) { console.log('ERR: ' + e.substring(0, 800)); });
+  console.log('--- CONSOLE (' + consoleMessages.length + ') ---');
+  consoleMessages.forEach(function (m) { console.log('CON: ' + m.substring(0, 300)); });
+
+  await page.screenshot({ path: 'test-artifacts/full-instrument.png', fullPage: true });
+  expect(true).toBeTruthy();
+});

+ 39 - 0
tests/playwright.remote.config.js

@@ -0,0 +1,39 @@
+/**
+ * Playwright 远程环境测试配置
+ * ================================================================
+ * 用途:针对远程部署环境(http://cfc.iwintrue.com)的只读测试。
+ *       - 移除 globalSetup(db.js 会执行 bash reset-db.sh 重置远程数据库——绝对禁止)
+ *       - 只运行指定 spec:npx playwright test --config=playwright.remote.config.js
+ * ================================================================
+ */
+
+var path = require('path');
+
+module.exports = {
+  testDir: path.join(__dirname, 'e2e'),
+  timeout: 120000,
+  retries: 0,
+  workers: 1,
+  use: {
+    baseURL: 'http://cfc.iwintrue.com',
+    headless: true,
+    ignoreHTTPSErrors: true,
+    actionTimeout: 15000,
+    navigationTimeout: 60000,
+    screenshot: 'only-on-failure',
+    trace: 'retain-on-failure',
+  },
+  projects: [
+    {
+      name: 'desktop',
+      use: {
+        browserName: 'chromium',
+        viewport: { width: 1440, height: 900 },
+      },
+    },
+  ],
+  reporter: [
+    ['html', { outputFolder: 'playwright-report' }],
+    ['list'],
+  ],
+}