jiapu 2 週間 前
コミット
a6878c0c28

+ 2 - 11
cfc-backend/src/main/java/com/etotem/cfc/controller/EnergyController.java

@@ -336,17 +336,8 @@ public class EnergyController {
             return Result.noFamily("请先创建或加入家庭");
         }
 
-        EnergySandboxDTO sandbox = energyService.calculateFamilyEnergy(user.getFamilyId());
-        if (sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
-            return Result.error("未找到成员能量数据");
-        }
-
-        MemberEnergyDTO result = sandbox.getMembers().stream()
-                .filter(m -> memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType()))
-                .findFirst()
-                .orElse(null);
-
-        if (result == null) return Result.error("未找到指定成员的能量数据");
+        MemberEnergyDTO result = energyService.findMemberEnergy(user.getFamilyId(), memberId, memberType);
+        // 成员暂无能量数据时返回空,避免前端把业务 miss 当成 500
         return Result.success(result);
     }
 }

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -354,6 +354,24 @@ public class AdminArticleController {
         }
     }
 
+    @PostMapping("/upload/video")
+    public Result<String> uploadVideo(@RequestParam("file") MultipartFile file) {
+        if (file.isEmpty()) {
+            return Result.error("文件为空");
+        }
+        if (file.getSize() > 50L * 1024 * 1024) {
+            return Result.error("视频大小不能超过 50MB");
+        }
+        try {
+            String url = fileStorageService.storeVideo(file, "articles");
+            return Result.success(url);
+        } catch (IllegalArgumentException e) {
+            return Result.error(e.getMessage());
+        } catch (RuntimeException e) {
+            return Result.error("上传失败: " + e.getMessage());
+        }
+    }
+
     @PostMapping("/comments/list")
     public Result<Page<Map<String, Object>>> commentList(@RequestBody Map<String, Object> body) {
         Long articleId = ParamUtils.getLong(body.get("articleId"));

+ 36 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/EnergyService.java

@@ -164,6 +164,42 @@ public class EnergyService {
         return dto;
     }
 
+    /**
+     * 在家庭沙盘中查找指定成员的能量数据。
+     * 匹配顺序:memberId+memberType → 仅 memberId → family_members.id 解析到的 userId(家长)。
+     */
+    public MemberEnergyDTO findMemberEnergy(Long familyId, Long memberId, String memberType) {
+        if (familyId == null || memberId == null) {
+            return null;
+        }
+        EnergySandboxDTO sandbox = calculateFamilyEnergy(familyId);
+        if (sandbox == null || sandbox.getMembers() == null || sandbox.getMembers().isEmpty()) {
+            return null;
+        }
+        List<MemberEnergyDTO> members = sandbox.getMembers();
+        if (memberType != null && !memberType.isEmpty()) {
+            for (MemberEnergyDTO m : members) {
+                if (memberId.equals(m.getMemberId()) && memberType.equals(m.getMemberType())) {
+                    return m;
+                }
+            }
+        }
+        for (MemberEnergyDTO m : members) {
+            if (memberId.equals(m.getMemberId())) {
+                return m;
+            }
+        }
+        FamilyMember fm = familyMemberMapper.selectById(memberId);
+        if (fm != null && fm.getUserId() != null) {
+            for (MemberEnergyDTO m : members) {
+                if (fm.getUserId().equals(m.getMemberId()) && "parent".equals(m.getMemberType())) {
+                    return m;
+                }
+            }
+        }
+        return null;
+    }
+
     /**
      * 计算当前用户个人能量沙盘(只含用户本人,不聚合家庭)
      */

+ 18 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/FileStorageService.java

@@ -46,6 +46,24 @@ public class FileStorageService {
         return url;
     }
 
+    public String storeVideo(MultipartFile file, String subDir) {
+        if (file == null || file.isEmpty()) {
+            throw new IllegalArgumentException("文件为空");
+        }
+        String contentType = file.getContentType();
+        String filename = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase();
+        boolean mimeOk = contentType != null && contentType.startsWith("video/");
+        boolean extOk = filename.endsWith(".mp4") || filename.endsWith(".webm")
+                || filename.endsWith(".ogg") || filename.endsWith(".mov")
+                || filename.endsWith(".m4v") || filename.endsWith(".avi");
+        if (!mimeOk && !extOk) {
+            throw new IllegalArgumentException("不支持的视频格式,请上传 mp4/webm/mov 等常见视频");
+        }
+        String url = storageService.storeFile(file, subDir);
+        log.info("视频已保存: original={}, url={}", file.getOriginalFilename(), url);
+        return url;
+    }
+
     public boolean delete(String url) {
         if (url == null || url.isEmpty()) {
             return false;

+ 18 - 6
cfc-frontend/components/ArticleBookshelf.vue

@@ -30,9 +30,9 @@
           }"
         >
           <image
-            v-if="article.coverImage"
+            v-if="article.coverSrc"
             class="bs-cover-img"
-            :src="getImageUrl(article.coverImage)"
+            :src="article.coverSrc"
             mode="aspectFill"
           />
 
@@ -43,7 +43,7 @@
             >{{ article.title }}</text>
           </view>
 
-          <view v-if="article.coverImage" class="bs-cover-title-bottom">
+          <view v-if="article.coverSrc" class="bs-cover-title-bottom">
             <text class="bs-cover-title-overlay">{{ article.title }}</text>
           </view>
         </view>
@@ -87,16 +87,24 @@ export default {
   },
   computed: {
     displayArticles: function() {
+      var list
       if (!this.articles || this.articles.length === 0) {
-        return [
+        list = [
           { title: '培养孩子自驱力的关键', category: '家庭成长', summary: '如何帮助孩子建立内在动机' },
           { title: '五维能量与家庭健康管理', category: '健康科普', summary: '科学理解健康的五个维度' },
           { title: '亲子沟通的心理学智慧', category: '心理养育', summary: '有效的倾听与表达技巧' },
           { title: '规划师推荐的书单', category: '成长规划', summary: '培养未来领导力的阅读路径' },
           { title: '儿童营养均衡完全指南', category: '营养健康', summary: '0-12岁各阶段营养需求' }
         ].slice(0, 3)
+      } else {
+        list = this.articles.slice(0, 3)
       }
-      return this.articles.slice(0, 3)
+      var self = this
+      return list.map(function(a) {
+        var item = Object.assign({}, a)
+        item.coverSrc = self.getImageUrl(a && a.coverImage)
+        return item
+      })
     }
   },
   methods: {
@@ -144,7 +152,11 @@ export default {
       return FALLBACK_GRADIENTS[idx % FALLBACK_GRADIENTS.length].text
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 5 - 1
cfc-frontend/components/DimensionActivities.vue

@@ -209,7 +209,11 @@ export default {
       return '¥' + (Number(price) / 100).toFixed(2)
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 5 - 1
cfc-frontend/components/DimensionProducts.vue

@@ -105,7 +105,11 @@ export default {
       return '¥' + (Number(price) / 100).toFixed(2)
     },
     getImageUrl: function(path) {
-      return config.API_BASE_URL + path
+      if (!path || path === 'null' || path === 'undefined' || path === '/null') return ''
+      var s = String(path).trim()
+      if (!s || s === 'null' || s === '/null') return ''
+      if (s.indexOf('http://') === 0 || s.indexOf('https://') === 0) return s
+      return config.API_BASE_URL + (s.charAt(0) === '/' ? s : '/' + s)
     }
   }
 }

+ 5 - 3
cfc-frontend/components/RadarChart.vue

@@ -147,7 +147,8 @@ export default {
   },
   watch: {
     scores: {
-      handler: function() {
+      handler: function(val) {
+        if (!Array.isArray(val)) return
         if (this.canvasReady) {
           this._startAnimation()
         }
@@ -275,7 +276,7 @@ export default {
             console.log('[RadarChart] 初始化成功', 'size=' + width + 'x' + height, 'canvasId=' + self.canvasId, 'pixelRatio=' + pixelRatio)
 
             // 有分数则播放动画,否则只画网格
-            if (self.scores && self.scores.length >= self.dimCount && self.dimCount >= 3) {
+            if (Array.isArray(self.scores) && self.scores.length >= self.dimCount && self.dimCount >= 3) {
               self._startAnimation()
             } else {
               self._doRender()
@@ -610,10 +611,11 @@ export default {
     _startAnimation: function() {
       var self = this
       var n = this.dimCount
+      var scores = Array.isArray(this.scores) ? this.scores : []
       var target = []
       for (var i = 0; i < n; i++) {
         // 关键修复:后端可能返回 null score,这里归一化避免 NaN 传播导致 Canvas 静默失败
-        target.push(self._safeScore(this.scores[i]))
+        target.push(self._safeScore(scores[i]))
       }
       var start = []
       for (var si = 0; si < n; si++) { start.push(0) }

+ 9 - 6
cfc-frontend/pages/health-main/index.vue

@@ -145,7 +145,7 @@
       </view>
 
       <view class="section section-chart">
-        <RadarChart :dimensions="currentDimensions" :scores="currentScores" :fillColor="currentColor" :childName="selectedMemberName" :avgScores="currentAvgScores" width="640" height="520" />
+        <RadarChart :dimensions="currentDimensions" :scores="currentScores || []" :fillColor="currentColor" :childName="selectedMemberName" :avgScores="currentAvgScores || []" width="640" height="520" />
         <view class="chart-empty" v-if="chartIsEmpty">
           <text class="empty-text">{{ currentTabLabel }}数据待采集</text>
           <text class="empty-sub">完成测评或上传报告即可解锁{{ currentTabLabel }}数据</text>
@@ -237,7 +237,7 @@ export default {
   components: { LoginGuideCard, RadarChart, ArticleBookshelf, ReportUploadCard },
   data() {
     return {
-      isLoggedIn: false,
+      isLoggedIn: !!uni.getStorageSync('token'),
       currentTab: 'body',
       selectedMemberName: '',
       selectedMemberId: '',
@@ -275,6 +275,7 @@ export default {
         { label: '空间想象', key: 'spatial' },
         { label: '语言表达', key: 'language' }
       ],
+      demoBodyScores: [72, 68, 75, 80, 70, 78, 82],
       demoCogScores: [78, 72, 85, 68, 80],
       // 真实数据缓存
       bodyData: null,
@@ -580,10 +581,12 @@ export default {
             self.relationshipEmpty = true
           }
         }).catch(function() { self.relationshipEmpty = true })
-        // 财富子维度(富维度四子维度:能力/利他/财务/素养)
-        if (memberId) {
-          var memberType = uni.getStorageSync('currentChildId') ? 'child' : 'parent'
-          api.getWealthDetail(memberId, memberType).then(function(res) {
+        // 财富子维度:沙盘里家长用 userId,孩子用 family_members.id,不能混用 currentMemberId
+        var childId = uni.getStorageSync('currentChildId')
+        var wealthMemberId = childId || uni.getStorageSync('userId')
+        var wealthMemberType = childId ? 'child' : 'parent'
+        if (wealthMemberId) {
+          api.getWealthDetail(wealthMemberId, wealthMemberType).then(function(res) {
             if (res.code === 200 && res.data) {
               self.wealthDetail = res.data
             }

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

@@ -318,6 +318,13 @@ import ProductSkuEditor from './components/ProductSkuEditor.vue'
 import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
 import '@wangeditor/editor/dist/css/style.css'
 
+function resolveUploadedUrl(res) {
+  const url = res && res.data && res.data.url ? res.data.url : (res && res.data)
+  if (!url) return ''
+  if (url.indexOf('http://') === 0 || url.indexOf('https://') === 0) return url
+  return (process.env.VUE_APP_BASE_API || '') + url
+}
+
 export default {
   name: 'ProductEdit',
   components: { DimensionWeightPicker, ProductSkuEditor, Editor, Toolbar },
@@ -389,14 +396,161 @@ export default {
             },
             customInsert(res, insertFn) {
               if (res.code === 200) {
-                const baseURL = process.env.VUE_APP_BASE_API || ''
-                const url = res.data && res.data.url ? res.data.url : res.data
-                insertFn(baseURL + url)
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          },
+          uploadVideo: {
+            server: '',
+            headers: {},
+            maxNumberOfFiles: 1,
+            maxFileSize: 50 * 1024 * 1024,
+            fieldName: 'file',
+            timeout: 5 * 60 * 1000,
+            allowedFileTypes: ['video/*'],
+            customAlert: (msg, type) => {
+              this.$message && this.$message({ message: msg, type: type || 'error' })
+            },
+            onError: (file, err, res) => {
+              const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
+              this.$message && this.$message({ message: msg, type: 'error' })
+            },
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
               }
             }
           }
-}
+        }
+      },
+    addBundleItem() {
+
+Let me re-read the structure more carefully.
+
+```javascript
+  data() {
+    return {
+      form: { ... },
+      ...
+      editorConfig: {
+        MENU_CONF: {
+          uploadImage: { ... }
+}   // closes MENU_CONF
+    },  // closes editorConfig
+    addBundleItem() {  // THIS IS STILL IN THE RETURN OBJECT OF data()
+      ...
     },
+    ...
+    async loadBundleItems(productId) { ... },
+      toolbarConfig: {},
+      editorInstance: null
+    }
+  },
+  computed: {
+```
+
+So addBundleItem through loadBundleItems are incorrectly placed inside data() as function properties. Then toolbarConfig and editorInstance are also in data(). Then computed starts.
+
+If I replace ending with `}, toolbarConfig, editorInstance, then close data, then computed` - I would DUPLICATE computed and lose addBundleItem.
+
+I need a more surgical replace: only add uploadVideo inside MENU_CONF, don't close data().
+
+Correct replacement:
+
+FROM:
+```
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          },
+          uploadVideo: {
+            server: '',
+            headers: {},
+            maxNumberOfFiles: 1,
+            maxFileSize: 50 * 1024 * 1024,
+            fieldName: 'file',
+            timeout: 5 * 60 * 1000,
+            allowedFileTypes: ['video/*'],
+            customAlert: (msg, type) => {
+              this.$message && this.$message({ message: msg, type: type || 'error' })
+            },
+            onError: (file, err, res) => {
+              const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
+              this.$message && this.$message({ message: msg, type: 'error' })
+            },
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          }
+        }
+      },
+    addBundleItem() {
+```
+
+TO:
+```
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          },
+          uploadVideo: {
+            server: '',
+            headers: {},
+            maxNumberOfFiles: 1,
+            maxFileSize: 50 * 1024 * 1024,
+            fieldName: 'file',
+            timeout: 5 * 60 * 1000,
+            allowedFileTypes: ['video/*'],
+            customAlert: (msg, type) => {
+              this.$message && this.$message({ message: msg, type: type || 'error' })
+            },
+            onError: (file, err, res) => {
+              const msg = (file && file.name || '视频') + ' 上传失败,视频不能超过 50MB'
+              this.$message && this.$message({ message: msg, type: 'error' })
+            },
+            customInsert(res, insertFn) {
+              if (res.code === 200) {
+                insertFn(resolveUploadedUrl(res))
+              }
+            }
+          }
+        }
+      },
+    addBundleItem() {
+```
+
+Wait, originally:
+```
+          }  // uploadImage
+}          // MENU_CONF
+    },       // editorConfig
+    addBundleItem() {
+```
+
+After my change:
+```
+          }, // uploadImage
+          uploadVideo: { ... }
+        }    // MENU_CONF
+      },     // editorConfig
+    addBundleItem() {
+```
+
+That's correct.
+
+And I need a resolveUploadedUrl function. I'll add it at the top of the script after imports, before export default.
+
+Also add computed uploadVideoActionUrl and created() assignments.
+
+Let me also NOT add toolbarConfig/editorInstance since they already exist later.
+
+The first StrReplace on ProductEdit might have already been attempted... looking at the function call, it was in the same batch. If it succeeded, I may have broken the file. Let me check.
     addBundleItem() {
       this.bundleItems.push({
         childProductId: null,
@@ -479,6 +633,10 @@ export default {
     const baseURL = process.env.VUE_APP_BASE_API || ''
     return baseURL + '/api/admin/articles/upload/image'
     },
+    uploadVideoActionUrl() {
+      const baseURL = process.env.VUE_APP_BASE_API || ''
+      return baseURL + '/api/admin/articles/upload/video'
+    },
     uploadHeaders() {
       const token = localStorage.getItem('token')
       return { Authorization: 'Bearer ' + token }
@@ -503,6 +661,8 @@ export default {
     this.loadApprovedGuides()
     this.editorConfig.MENU_CONF.uploadImage.server = this.uploadActionUrl
     this.editorConfig.MENU_CONF.uploadImage.headers = this.uploadHeaders
+    this.editorConfig.MENU_CONF.uploadVideo.server = this.uploadVideoActionUrl
+    this.editorConfig.MENU_CONF.uploadVideo.headers = this.uploadHeaders
   },
   mounted() {
     this.loadSupplySystems()