Преглед на файлове

feat(profile): 我的分享海报改为五维能量数据卡

- ContentSharePoster 新增 energyData / brandFallback 两个可选 prop,封面区降级链为 energyData -> coverImage -> brandFallback -> 占位图,文章/活动/商品三处行为不变
- 新增五维能量数据卡(canvas 绘制:综合分大字 + 五维彩色圆/分数/进度条)与未解锁全景时的品牌渐变卡
- profile.vue 接入 getEnergySandbox('family'),移除旧 share-qr-modal 实现;浮标样式由 emoji 改为 CSS 绘制二维码图形
liaoxg преди 3 дни
родител
ревизия
b63fd92d86
променени са 2 файла, в които са добавени 292 реда и са изтрити 235 реда
  1. 128 2
      cfc-frontend/components/ContentSharePoster.vue
  2. 164 233
      cfc-frontend/pages/profile-main/profile.vue

+ 128 - 2
cfc-frontend/components/ContentSharePoster.vue

@@ -22,7 +22,12 @@ export default {
     summary: { type: String, default: '' },
     priceText: { type: String, default: '' },
     ctaText: { type: String, default: '长按识别二维码 · 查看详情' },
-    inviteText: { type: String, default: '' }
+    inviteText: { type: String, default: '' },
+    // 五维能量数据卡:{ label?: String, overall?: Number, dims: [{ key, label, score, color }] }
+    // 传 null 时不画数据卡,按 coverImage -> brandFallback -> 占位图 顺序降级(原有行为不变)
+    energyData: { type: Object, default: null },
+    // 无 energyData 且 coverImage 为空时,是否用品牌渐变卡替代"暂无封面图"占位
+    brandFallback: { type: Boolean, default: false }
   },
   watch: {
     show(val) {
@@ -92,7 +97,11 @@ export default {
       var coverH = 220
       var self = this
 
-      if (this.coverImage) {
+      if (this.energyData) {
+        // 方案C:五维能量数据卡,canvas 直接绘制(文字锐利、数据真实、无网络开销)
+        this._drawEnergyCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
+      } else if (this.coverImage) {
         uni.downloadFile({
           url: this.coverImage,
           success: function(res) {
@@ -120,6 +129,9 @@ export default {
             self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
           }
         })
+      } else if (this.brandFallback) {
+        this._drawBrandCard(ctx, cardX + 20, coverY, coverW, coverH, colors)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
       } else {
         this._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
         this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors)
@@ -198,6 +210,120 @@ export default {
       ctx.fillText('暂无封面图', x + w / 2, y + h / 2)
     },
 
+    // 五维能量数据卡(方案C):综合分大字 + 五维彩色圆/分数/进度条
+    _drawEnergyCard(ctx, x, y, w, h, colors) {
+      var data = this.energyData || {}
+      var dims = data.dims || []
+
+      ctx.setFillStyle('#FFFFFF')
+      this._roundRect(ctx, x, y, w, h, 8)
+      ctx.fill()
+
+      // 顶部:左侧标题,右侧综合分
+      ctx.setFillStyle('#78716C')
+      ctx.setFontSize(13)
+      ctx.setTextAlign('left')
+      ctx.setTextBaseline('top')
+      ctx.fillText(data.label || '我们家的能量', x + 16, y + 16)
+
+      var hasOverall = data.overall !== null && data.overall !== undefined && !isNaN(data.overall)
+      if (hasOverall) {
+        ctx.setFillStyle(colors.main)
+        ctx.setFontSize(30)
+        ctx.setTextAlign('right')
+        ctx.fillText(String(data.overall), x + w - 28, y + 8)
+        ctx.setFillStyle('#78716C')
+        ctx.setFontSize(13)
+        ctx.fillText('分', x + w - 16, y + 28)
+      }
+
+      ctx.setStrokeStyle('#F5F5F4')
+      ctx.setLineWidth(1)
+      ctx.beginPath()
+      ctx.moveTo(x + 16, y + 54)
+      ctx.lineTo(x + w - 16, y + 54)
+      ctx.stroke()
+
+      // 五维柱:彩色圆 + 单字 + 分数 + 进度条
+      var count = dims.length
+      for (var i = 0; i < count; i++) {
+        var d = dims[i] || {}
+        var cx = x + 16 + ((w - 32) / count) * i + (w - 32) / count / 2
+        var color = d.color || colors.main
+        var score = Number(d.score)
+        if (isNaN(score) || score < 0) score = 0
+        if (score > 100) score = 100
+
+        ctx.setFillStyle(color)
+        ctx.beginPath()
+        ctx.arc(cx, y + 94, 17, 0, 2 * Math.PI)
+        ctx.fill()
+
+        ctx.setFillStyle('#FFFFFF')
+        ctx.setFontSize(15)
+        ctx.setTextAlign('center')
+        ctx.setTextBaseline('middle')
+        ctx.fillText(d.label || '', cx, y + 95)
+
+        ctx.setFillStyle('#1C1917')
+        ctx.setFontSize(18)
+        ctx.setTextBaseline('top')
+        ctx.fillText(String(score), cx, y + 120)
+
+        var barW = 44
+        var barH = 4
+        var barX = cx - barW / 2
+        var barY = y + 148
+        ctx.setFillStyle('#F5F5F4')
+        this._roundRect(ctx, barX, barY, barW, barH, 2)
+        ctx.fill()
+        if (score > 0) {
+          ctx.setFillStyle(color)
+          this._roundRect(ctx, barX, barY, barW * score / 100, barH, 2)
+          ctx.fill()
+        }
+      }
+
+      ctx.setFillStyle('#A8A29E')
+      ctx.setFontSize(11)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('五维能量 · 浠艾福家庭测评', x + w / 2, y + 172)
+    },
+
+    // 品牌渐变卡:用户未解锁全景(无五维数据)且无封面图时的降级方案
+    _drawBrandCard(ctx, x, y, w, h, colors) {
+      var g = ctx.createLinearGradient(x, y, x + w, y + h)
+      g.addColorStop(0, colors.main)
+      g.addColorStop(1, colors.dark)
+      ctx.setFillStyle(g)
+      this._roundRect(ctx, x, y, w, h, 8)
+      ctx.fill()
+
+      var dotColors = ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
+      var centerX = x + w / 2
+      for (var i = 0; i < 5; i++) {
+        ctx.setFillStyle(dotColors[i])
+        ctx.beginPath()
+        ctx.arc(centerX + (i - 2) * 26, y + 66, 5, 0, 2 * Math.PI)
+        ctx.fill()
+      }
+
+      ctx.setFillStyle('#FFFFFF')
+      ctx.setFontSize(24)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('浠艾福', centerX, y + 86)
+
+      ctx.setFillStyle('rgba(255,255,255,0.85)')
+      ctx.setFontSize(13)
+      ctx.fillText('给全家的一站式幸福提案', centerX, y + 124)
+
+      ctx.setFillStyle('rgba(255,255,255,0.7)')
+      ctx.setFontSize(12)
+      ctx.fillText('测一测你家的五维能量', centerX, y + 152)
+    },
+
     _drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH, colors) {
       var self = this
 

+ 164 - 233
cfc-frontend/pages/profile-main/profile.vue

@@ -57,9 +57,20 @@
         </view>
       </view>
 
-      <!-- 我的推广码浮动按钮 -->
+      <!-- 我的分享海报浮动按钮(纯 CSS 二维码图标,避免 emoji 渲染成计算器) -->
       <view class="share-qr-float" @click="showShareQr">
-        <text class="share-qr-icon">📱</text>
+        <view class="qr-glyph">
+          <view class="qr-eye qr-eye-tl"></view>
+          <view class="qr-eye qr-eye-tr"></view>
+          <view class="qr-eye qr-eye-bl"></view>
+          <view class="qr-mid"></view>
+          <view class="qr-cluster">
+            <view class="qr-pip"></view>
+            <view class="qr-pip"></view>
+            <view class="qr-pip"></view>
+            <view class="qr-pip"></view>
+          </view>
+        </view>
       </view>
 
       <!-- 家族成员速览 -->
@@ -265,43 +276,26 @@
       </view>
 
       </template>
-    <view class="share-qr-modal" v-if="showShareQrModal" @click="closeShareQrModal">
-      <view class="modal-mask" @click.stop />
-      <view class="modal-qr" @click.stop>
-        <view class="modal-qr-header">
-          <text class="modal-qr-title">我的分享海报</text>
-          <text class="modal-qr-close" @click="closeShareQrModal">✕</text>
-        </view>
-        <view class="modal-qr-content">
-          <view v-if="qrLoading" class="qrcode-loading">
-            <text class="loading-spinner">⏳</text>
-            <text class="loading-text">生成中...</text>
-          </view>
-          <view v-else="!qrBase64" class="qrcode-empty">
-            <text>暂无二维码</text>
-          </view>
-          <view v-else class="qrcode-image">
-            <image class="qrcode-img" :src="qrBase64" mode="aspectFit"></image>
-          </view>
-          <view class="qrcode-info">
-            <text class="qrcode-text">邀请码: {{ referralCode }}</text>
-            <text class="qrcode-desc">扫码注册,好友成为您的推荐人</text>
-          </view>
-        </view>
-        <view class="modal-qr-actions">
-          <view class="action-btn share-btn" @click="shareToWechat">
-            <text class="share-icon">📤</text>
-            <text class="share-text">分享给朋友</text>
-          </view>
-        </view>
-      </view>
-    </view>
+    <!-- 我的分享海报:复用文章/活动/商品同一套 ContentSharePoster 组件(canvas 绘制 + 保存到相册) -->
+    <ContentSharePoster
+      :show="showSharePoster"
+      :title="shareHeadline"
+      :energyData="posterEnergyData"
+      :brandFallback="true"
+      :qrCodeBase64="posterQrCode || ''"
+      typeLabel="邀友加入"
+      :summary="posterSummary"
+      ctaText="长按识别二维码 · 加入浠艾福"
+      :inviteText="posterInviteText"
+      @close="closeSharePoster"
+    />
     <FloatingAvatar />
   </view>
 </template>
 
 <script>
 import FloatingAvatar from '../../components/AIFloatingAvatar.vue'
+import ContentSharePoster from '../../components/ContentSharePoster.vue'
 import {
   getChildren, getFamilyMemberList,
   getTodayTasksByCategory, getChallengeList, getMyMembership,
@@ -309,11 +303,11 @@ import {
   cartCount, getOrderCounts, getReferralSummary,
   getProfileStats, getMyReferrer, getTeamConsumption,
   getTeacherInfo, getTeacherPendingTasks, getTeacherUnreadCount,
-  getReferralCode, extractReferralCode, getShareQrCode
+  getReferralCode, extractReferralCode, getShareQrCode, getEnergySandbox
 } from '../../utils/api.js'
 
 export default {
-  components: { FloatingAvatar },
+  components: { FloatingAvatar, ContentSharePoster },
   data() {
     return {
       nickname: '',
@@ -348,10 +342,10 @@ export default {
       profileProgress: 0,
       myReferrer: null,
       // 分享推广码
-      showShareQrModal: false,
+      showSharePoster: false,
       referralCode: '',
-      qrBase64: '',
-      qrLoading: false,
+      posterQrCode: '',
+      posterEnergyData: null,
       referralCodeLoaded: false,
     }
   },
@@ -374,7 +368,23 @@ export default {
       return this.memberLevel === 'FREE' || this.memberLevel === 'FAMILY'
     },
     upgradeBtnText() {
-      return this.memberLevel === 'FAMILY' ? '升级' : '立即开通'
+      return this.hasUpgradeSpace ? '升级' : '立即开通'
+    },
+    // 分享海报标题:XXX邀请你关注家庭健康(昵称过长自动截断)
+    shareHeadline() {
+      var name = (this.nickname || '').trim()
+      if (!name) return '邀请你关注家庭健康'
+      if (name.length > 8) name = name.substring(0, 8) + '…'
+      return name + '邀请你关注家庭健康'
+    },
+    posterSummary() {
+      return '身体 · 心理 · 智慧 · 行动 · 财富,一家专注全家健康的成长平台'
+    },
+    posterInviteText() {
+      var name = (this.nickname || '').trim()
+      if (!name) return '扫码加入浠艾福,一起照顾好这个家'
+      if (name.length > 8) name = name.substring(0, 8) + '…'
+      return '「' + name + '」邀请你加入浠艾福'
     }
   },
   onShow() {
@@ -547,72 +557,96 @@ export default {
         console.error('获取推荐人失败', e)
       }
     },
-    // ===== 分享推广码 =====
-    showShareQr() {
-      this.showShareQrModal = true
-      if (!this.referralCodeLoaded) {
-        this.loadReferralCode()
-      } else if (this.referralCode && !this.qrBase64) {
-        this.generateQrcode()
+    // ===== 分享海报(复用 ContentSharePoster,与文章/活动/商品同一套生成逻辑)=====
+    async showShareQr() {
+      uni.showLoading({ title: '生成海报中...' })
+      try {
+        if (!this.referralCodeLoaded) {
+          await this.loadReferralCode()
+        }
+        var scene = 'ref=' + (this.referralCode || '')
+        if (scene.length > 32) {
+          scene = scene.substring(0, 32)
+        }
+        // 首页路径,扫码后进入首页并绑定推荐人
+        // 注意:微信 getwxacodeunlimit 的 page 不能带前导斜杠,否则报 errcode 41030 invalid page rid
+        var res = await getShareQrCode('pages/index-home/index', scene)
+        if (res && res.code === 200 && res.data && res.data.qrCodeBase64) {
+          this.posterQrCode = res.data.qrCodeBase64
+          // 必须在 showSharePoster=true 之前取到数据:组件 watch 到 show 后 300ms 即开始绘制
+          await this.loadPosterEnergy()
+          this.showSharePoster = true
+        } else {
+          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+        }
+      } catch (e) {
+        console.error('生成分享海报失败', e)
+        uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+      } finally {
+        uni.hideLoading()
       }
     },
-    closeShareQrModal() {
-      this.showShareQrModal = false
+    closeSharePoster() {
+      this.showSharePoster = false
+    },
+    // 五维能量数据卡数据(方案C):家庭聚合视角;未解锁全景/取数失败时置空,海报降级为品牌卡
+    loadPosterEnergy() {
+      var self = this
+      return getEnergySandbox('family').then(function(res) {
+        self.posterEnergyData = self.mapPosterEnergy(res)
+      }).catch(function(e) {
+        console.error('获取五维能量失败,海报降级为品牌卡', e)
+        self.posterEnergyData = null
+      })
+    },
+    mapPosterEnergy(res) {
+      if (!res || res.code !== 200 || !res.data) return null
+      // locked=true 表示用户未解锁全景,此时不展示任何数据,改为品牌卡
+      if (res.data.locked) return null
+      // 只认家庭聚合视角;仅当接口返回的是裸 DTO(无 personalData/familyData 包装)时才直接取 res.data
+      var d = res.data.familyData || null
+      if (!d) {
+        if (res.data.personalData) return null
+        d = res.data
+      }
+      if (!d || typeof d !== 'object') return null
+      var defs = [
+        { key: 'body', label: '身', color: '#FF8C42' },
+        { key: 'mind', label: '心', color: '#FF6B9D' },
+        { key: 'wisdom', label: '智', color: '#6366F1' },
+        { key: 'action', label: '行', color: '#10B981' },
+        { key: 'wealth', label: '富', color: '#F59E0B' }
+      ]
+      var dims = []
+      var sum = 0
+      for (var i = 0; i < defs.length; i++) {
+        var raw = d[defs[i].key + 'Score']
+        if (raw === null || raw === undefined || raw === '' || isNaN(raw)) continue
+        var n = Number(raw)
+        if (n < 0) n = 0
+        if (n > 100) n = 100
+        sum += n
+        dims.push({ key: defs[i].key, label: defs[i].label, score: n, color: defs[i].color })
+      }
+      if (dims.length === 0) return null
+      var overall = d.overallScore
+      if (overall === null || overall === undefined || overall === '' || isNaN(overall)) {
+        overall = Math.round(sum / dims.length)
+      }
+      return { label: '我们家的能量', overall: Number(overall), dims: dims }
     },
     async loadReferralCode() {
       if (this.referralCodeLoaded) return
       var self = this
       try {
         var res = await getReferralCode()
-        var code = extractReferralCode(res)
-        self.referralCode = code || ''
-        self.referralCodeLoaded = true
-        if (code) {
-          self.generateQrcode()
-        }
+        self.referralCode = extractReferralCode(res) || ''
       } catch (e) {
         console.error('获取推广码失败', e)
+      } finally {
         self.referralCodeLoaded = true
       }
     },
-    generateQrcode() {
-      if (!this.referralCode) return
-      this.qrLoading = true
-      var self = this
-      var scene = 'ref=' + this.referralCode
-      // 首页路径,扫码后进入首页并绑定推荐人
-      getShareQrCode('/pages/index-home/index', scene)
-        .then(function(res) {
-          if (res && res.code === 200 && res.data) {
-            self.qrBase64 = res.data.qrCodeBase64
-          }
-        })
-        .catch(function(e) {
-          console.error('生成二维码失败', e)
-          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
-        })
-        .finally(function() {
-          self.qrLoading = false
-        })
-    },
-    shareToWechat() {
-      if (!this.referralCode) return
-      var path = '/pages/index-home/index?refCode=' + this.referralCode
-      uni.share({
-        provider: 'weixin',
-        scene: { type: 0 },
-        type: 5,
-        imageUrl: this.qrBase64,
-        path: path,
-        title: '浠艾福 — 邀请你加入',
-        success: function() {
-          uni.showToast({ title: '分享成功', icon: 'success' })
-        },
-        fail: function() {
-          uni.showToast({ title: '分享失败', icon: 'none' })
-        }
-      })
-    },
     goToServiceCenter() {
       uni.navigateTo({ url: '/pages/service-center/index' })
     },
@@ -688,15 +722,14 @@ export default {
     },
     goToSettings: function() {
       uni.showActionSheet({
-        itemList: ['完善信息', '家庭成员', '邀请加入', '隐私政策', '用户协议', '我的画像'],
+        itemList: ['完善信息', '家庭成员', '隐私政策', '用户协议', '我的画像'],
         success: function(res) {
           var urls = {
             0: '/pages/user-edit/user-edit',
             1: '/pages/profile-extra/family-members',
-            2: '/pages/guide/invite/share',
-            3: '/pages/policy/privacy',
-            4: '/pages/policy/agreement',
-            5: '/pages/profile/portrait-edit'
+            2: '/pages/policy/privacy',
+            3: '/pages/policy/agreement',
+            4: '/pages/profile/portrait-edit'
           }
           if (urls[res.tapIndex]) {
             uni.navigateTo({ url: urls[res.tapIndex] })
@@ -1282,7 +1315,7 @@ export default {
 /* 分享推广码浮动按钮 */
 .share-qr-float {
   position: fixed;
-  top: 40rpx;
+  top: 180rpx;
   right: 30rpx;
   width: 80rpx;
   height: 80rpx;
@@ -1294,147 +1327,45 @@ export default {
   justify-content: center;
   z-index: 99;
 }
-.share-qr-icon {
-  font-size: 40rpx;
-  line-height: 1;
+/* 二维码图标(纯 CSS 绘制,不依赖 emoji 字体) */
+.qr-glyph {
+  position: relative;
+  width: 46rpx;
+  height: 46rpx;
 }
-
-/* 分享海报弹窗 */
-.share-qr-modal {
-  position: fixed;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  z-index: 999;
-  display: flex;
-  align-items: center;
-  justify-content: center;
+.qr-eye {
+  position: absolute;
+  width: 16rpx;
+  height: 16rpx;
+  border: 4rpx solid #1E293B;
+  border-radius: 3rpx;
+  box-sizing: border-box;
 }
-.modal-mask {
+.qr-eye-tl { left: 0; top: 0; }
+.qr-eye-tr { right: 0; top: 0; }
+.qr-eye-bl { left: 0; bottom: 0; }
+.qr-mid {
+  position: absolute;
+  left: 20rpx;
+  top: 20rpx;
+  width: 7rpx;
+  height: 7rpx;
+  background: #1E293B;
+  border-radius: 1rpx;
+}
+.qr-cluster {
   position: absolute;
-  top: 0;
-  left: 0;
   right: 0;
   bottom: 0;
-  background: rgba(0, 0, 0, 0.5);
-}
-.modal-qr {
-  position: relative;
-  width: 70%;
-  max-width: 600rpx;
-  background: #fff;
-  border-radius: 24rpx;
-  padding: 30rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-}
-.modal-qr-header {
-  width: 100%;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 20rpx;
-}
-.modal-qr-title {
-  font-size: 30rpx;
-  font-weight: bold;
-  color: #333;
-}
-.modal-qr-close {
-  font-size: 32rpx;
-  color: #999;
-  padding: 10rpx;
-}
-.modal-qr-content {
+  width: 18rpx;
   display: flex;
-  flex-direction: column;
-  align-items: center;
-  width: 100%;
-}
-.qrcode-loading,
-.qrcode-empty {
-  width: 340rpx;
-  height: 340rpx;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  background: #FAFAFA;
-  border-radius: 16rpx;
-  border: 2rpx dashed #E5E7EB;
-}
-.loading-spinner {
-  font-size: 48rpx;
-  margin-bottom: 16rpx;
-}
-.loading-text {
-  font-size: 24rpx;
-  color: #94A3B8;
-}
-.qrcode-empty text {
-  font-size: 24rpx;
-  color: #94A3B8;
-}
-.qrcode-image {
-  width: 340rpx;
-  height: 340rpx;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: #fff;
-  border-radius: 16rpx;
-  border: 1rpx solid #E5E7EB;
-  overflow: hidden;
-}
-.qrcode-img {
-  width: 330rpx;
-  height: 330rpx;
-}
-.qrcode-info {
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  margin-top: 20rpx;
-  gap: 8rpx;
-}
-.qrcode-text {
-  font-size: 28rpx;
-  font-weight: bold;
-  color: #F97316;
-}
-.qrcode-desc {
-  font-size: 22rpx;
-  color: #94A3B8;
-}
-.modal-qr-actions {
-  width: 100%;
-  display: flex;
-  gap: 20rpx;
-  margin-top: 24rpx;
-}
-.action-btn {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  gap: 8rpx;
-  padding: 24rpx;
-  border-radius: 16rpx;
-  font-size: 24rpx;
-  font-weight: bold;
-}
-.share-btn {
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: #fff;
-  width: 100%;
-}
-.share-icon {
-  font-size: 40rpx;
+  flex-wrap: wrap;
 }
-.share-text {
-  font-size: 24rpx;
-  font-weight: bold;
+.qr-pip {
+  width: 7rpx;
+  height: 7rpx;
+  margin: 1rpx;
+  background: #1E293B;
+  border-radius: 1rpx;
 }
 </style>