Explorar o código

feat: content share poster with wxacode for product/activity/article pages

- ShareController: POST /api/share/qrcode generates wxacode for any page+scene
- ContentSharePoster.vue: reusable canvas poster with cover/title/QR code/CTA
- product-detail, activity-detail, article-detail: add poster button + component
- api.js: add getShareQrCode(page, scene) helper
- DimensionWeightPicker: fix proportional redistribution to keep sum=100%
Xiaogang Liao hai 3 meses
pai
achega
cb7b66568e

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

@@ -3,6 +3,7 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.ShareEvent;
 import com.etotem.cfc.entity.ShareEvent;
 import com.etotem.cfc.service.ShareService;
 import com.etotem.cfc.service.ShareService;
+import com.etotem.cfc.service.WechatService;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
@@ -21,6 +22,9 @@ public class ShareController {
     @Resource
     @Resource
     private ShareService shareService;
     private ShareService shareService;
 
 
+    @Resource
+    private WechatService wechatService;
+
     private Long getUserId(HttpServletRequest request) {
     private Long getUserId(HttpServletRequest request) {
         Object userIdObj = request.getAttribute("userId");
         Object userIdObj = request.getAttribute("userId");
         if (userIdObj != null) {
         if (userIdObj != null) {
@@ -108,4 +112,33 @@ public class ShareController {
                 ? Long.valueOf(params.get("familyId").toString()) : null;
                 ? Long.valueOf(params.get("familyId").toString()) : null;
         return Result.success(shareService.getFamilyFootprint(familyId));
         return Result.success(shareService.getFamilyFootprint(familyId));
     }
     }
+
+    /**
+     * 生成分享小程序码 — 用于商品/活动/文章详情页海报
+     * @param params { "page": "pages/discover/product-detail/product-detail", "scene": "id=123" }
+     * @return { "qrCodeBase64": "data:image/png;base64,..." }
+     */
+    @Operation(summary = "生成分享小程序码")
+    @PostMapping("/qrcode")
+    public Result<Map<String, Object>> getQrCode(@RequestBody Map<String, String> params) {
+        String page = params.get("page");
+        String scene = params.get("scene");
+        if (page == null || page.isEmpty()) {
+            return Result.error("page不能为空");
+        }
+        if (scene == null) {
+            scene = "";
+        }
+        if (scene.length() > 32) {
+            scene = scene.substring(0, 32);
+        }
+        try {
+            String qrBase64 = wechatService.generateWxacode(scene, page);
+            Map<String, Object> result = new HashMap<>();
+            result.put("qrCodeBase64", "data:image/png;base64," + qrBase64);
+            return Result.success(result);
+        } catch (Exception e) {
+            return Result.error("生成二维码失败: " + e.getMessage());
+        }
+    }
 }
 }

+ 404 - 0
cfc-frontend/components/ContentSharePoster.vue

@@ -0,0 +1,404 @@
+<template>
+  <view class="poster-overlay" v-if="show" @click.self="$emit('close')">
+    <view class="poster-container">
+      <canvas canvas-id="contentPosterCanvas" class="poster-canvas" id="contentPosterCanvas"></canvas>
+      <view class="poster-actions">
+        <button class="poster-btn save" @click="savePoster">保存到相册</button>
+        <button class="poster-btn cancel" @click="$emit('close')">取消</button>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script>
+export default {
+  name: 'ContentSharePoster',
+  props: {
+    show: { type: Boolean, default: false },
+    title: { type: String, default: '' },
+    coverImage: { type: String, default: '' },
+    qrCodeBase64: { type: String, default: '' },
+    typeLabel: { type: String, default: '好物推荐' }
+  },
+  watch: {
+    show(val) {
+      if (val) {
+        this.$nextTick(function() {
+          setTimeout(this.drawPoster.bind(this), 300)
+        }.bind(this))
+      }
+    }
+  },
+  methods: {
+    drawPoster() {
+      var ctx = uni.createCanvasContext('contentPosterCanvas', this)
+      var w = 500
+      var h = 800
+
+      var gradient = ctx.createLinearGradient(0, 0, w * 0.3, h)
+      gradient.addColorStop(0, '#F97316')
+      gradient.addColorStop(0.55, '#EA580C')
+      gradient.addColorStop(1, '#D94F0A')
+      ctx.setFillStyle(gradient)
+      ctx.fillRect(0, 0, w, h)
+
+      this._drawTopDecoration(ctx, w)
+
+      var cardX = 28
+      var cardY = 112
+      var cardW = 444
+      var cardH = 600
+      this._drawContentCard(ctx, cardX, cardY, cardW, cardH)
+
+      var coverY = cardY + 20
+      var coverW = cardW - 40
+      var coverH = 220
+      var self = this
+
+      if (this.coverImage) {
+        uni.downloadFile({
+          url: this.coverImage,
+          success: function(res) {
+            if (res.statusCode === 200) {
+              ctx.drawImage(res.tempFilePath, cardX + 20, coverY, coverW, coverH)
+            } else {
+              self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
+            }
+            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH)
+          },
+          fail: function() {
+            self._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
+            self._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH)
+          }
+        })
+      } else {
+        this._drawCoverPlaceholder(ctx, cardX + 20, coverY, coverW, coverH)
+        this._drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH)
+      }
+    },
+
+    _drawTopDecoration(ctx, w) {
+      var centerX = w / 2
+      var dotColors = ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
+      var dotY = 28
+      var dotR = 5
+      var dotSpacing = 26
+      var i, offsetX, offsetY
+
+      for (i = 0; i < 5; i++) {
+        offsetX = (i - 2) * dotSpacing
+        offsetY = Math.abs(i - 2) * 3
+        ctx.setFillStyle(dotColors[i])
+        ctx.beginPath()
+        ctx.arc(centerX + offsetX, dotY + offsetY, dotR, 0, 2 * Math.PI)
+        ctx.fill()
+      }
+
+      ctx.setFillStyle('#ffffff')
+      ctx.setFontSize(20)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('浠艾福', centerX, 52)
+
+      ctx.setFontSize(14)
+      ctx.setFillStyle('rgba(255,255,255,0.8)')
+      ctx.fillText('给全家的一站式幸福提案', centerX, 76)
+
+      ctx.setStrokeStyle('rgba(255,255,255,0.25)')
+      ctx.setLineWidth(1)
+      ctx.beginPath()
+      ctx.moveTo(80, 100)
+      ctx.lineTo(w - 80, 100)
+      ctx.stroke()
+    },
+
+    _drawContentCard(ctx, x, y, w, h) {
+      ctx.setFillStyle('rgba(0,0,0,0.04)')
+      this._roundRect(ctx, x + 2, y + 3, w, h, 16)
+      ctx.fill()
+
+      ctx.setFillStyle('#FFFCF5')
+      this._roundRect(ctx, x, y, w, h, 16)
+      ctx.fill()
+    },
+
+    _drawCoverPlaceholder(ctx, x, y, w, h) {
+      ctx.setFillStyle('#FEF3C7')
+      this._roundRect(ctx, x, y, w, h, 8)
+      ctx.fill()
+
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(16)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('暂无封面图', x + w / 2, y + h / 2)
+    },
+
+    _drawContentAfterCover(ctx, w, h, cardX, cardW, coverY, coverH) {
+      var self = this
+
+      var labelY = coverY + coverH + 16
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(13)
+      ctx.setTextAlign('left')
+      ctx.setTextBaseline('top')
+      ctx.fillText(this.typeLabel, cardX + 20, labelY)
+
+      var titleY = labelY + 22
+      ctx.setFillStyle('#1C1917')
+      ctx.setFontSize(18)
+      ctx.setTextAlign('left')
+      ctx.setTextBaseline('top')
+      this._drawWrappedText(ctx, this.title || '精彩内容', cardX + 20, titleY, cardW - 40, 24, 2)
+
+      var hintY = titleY + 56
+      ctx.setFontSize(13)
+      ctx.setFillStyle('#A8A29E')
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('长按识别二维码 · 查看详情', w / 2, hintY)
+
+      var qrSize = 160
+      var qrX = (w - qrSize) / 2
+      var qrY = hintY + 20
+
+      if (this.qrCodeBase64) {
+        var base64Data = this.qrCodeBase64
+        if (base64Data.indexOf('data:image') === 0) {
+          var commaIdx = base64Data.indexOf(',')
+          if (commaIdx > -1) {
+            base64Data = base64Data.substring(commaIdx + 1)
+          }
+        }
+
+        var fs = uni.getFileSystemManager()
+        var filePath = wx.env.USER_DATA_PATH + '/content_share_qr_tmp.png'
+
+        fs.writeFile({
+          filePath: filePath,
+          data: base64Data,
+          encoding: 'base64',
+          success: function() {
+            ctx.setFillStyle('#ffffff')
+            self._roundRect(ctx, qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 8)
+            ctx.fill()
+
+            ctx.setStrokeStyle('#E7E5E4')
+            ctx.setLineWidth(0.5)
+            self._roundRect(ctx, qrX - 10, qrY - 10, qrSize + 20, qrSize + 20, 8)
+            ctx.stroke()
+
+            ctx.drawImage(filePath, qrX, qrY, qrSize, qrSize)
+            self._drawBottomDecoration(ctx, w, h, qrY, qrSize)
+          },
+          fail: function() {
+            self._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+            self._drawBottomDecoration(ctx, w, h, qrY, qrSize)
+          }
+        })
+      } else {
+        this._drawQrPlaceholder(ctx, qrX, qrY, qrSize)
+        this._drawBottomDecoration(ctx, w, h, qrY, qrSize)
+      }
+    },
+
+    _drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
+      var chars = (text || '').split('')
+      var line = ''
+      var lineCount = 0
+      for (var i = 0; i < chars.length; i++) {
+        var testLine = line + chars[i]
+        var testWidth = 0
+        if (ctx.measureText) {
+          try { testWidth = ctx.measureText(testLine).width } catch (e) { testWidth = testLine.length * 18 }
+        } else {
+          testWidth = testLine.length * 18
+        }
+        if (testWidth > maxWidth && line.length > 0) {
+          ctx.fillText(line, x, y + lineCount * lineHeight)
+          line = chars[i]
+          lineCount++
+          if (lineCount >= maxLines - 1) {
+            var remaining = chars.slice(i).join('')
+            var remainingLine = line + remaining
+            if (ctx.measureText) {
+              while (ctx.measureText(remainingLine + '..').width > maxWidth && remainingLine.length > 2) {
+                remainingLine = remainingLine.substring(0, remainingLine.length - 1)
+              }
+            }
+            ctx.fillText(remainingLine + '..', x, y + lineCount * lineHeight)
+            return
+          }
+        } else {
+          line = testLine
+        }
+      }
+      ctx.fillText(line, x, y + lineCount * lineHeight)
+    },
+
+    _drawQrPlaceholder(ctx, x, y, size) {
+      ctx.setFillStyle('#ffffff')
+      this._roundRect(ctx, x - 10, y - 10, size + 20, size + 20, 8)
+      ctx.fill()
+
+      ctx.setStrokeStyle('#E7E5E4')
+      ctx.setLineWidth(0.5)
+      this._roundRect(ctx, x - 10, y - 10, size + 20, size + 20, 8)
+      ctx.stroke()
+
+      ctx.setFillStyle('#A8A29E')
+      ctx.setFontSize(16)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('middle')
+      ctx.fillText('二维码加载中', x + size / 2, y + size / 2)
+    },
+
+    _drawBottomDecoration(ctx, w, h, qrY, qrSize) {
+      var centerX = w / 2
+      var bottomY = qrY + qrSize + 16
+
+      ctx.setFillStyle('rgba(249,115,22,0.15)')
+      var diamondY = bottomY + 6
+      ctx.beginPath()
+      ctx.moveTo(centerX - 76, diamondY)
+      ctx.lineTo(centerX - 72, diamondY - 4)
+      ctx.lineTo(centerX - 68, diamondY)
+      ctx.lineTo(centerX - 72, diamondY + 4)
+      ctx.closePath()
+      ctx.fill()
+      ctx.beginPath()
+      ctx.moveTo(centerX + 68, diamondY)
+      ctx.lineTo(centerX + 72, diamondY - 4)
+      ctx.lineTo(centerX + 76, diamondY)
+      ctx.lineTo(centerX + 72, diamondY + 4)
+      ctx.closePath()
+      ctx.fill()
+
+      ctx.setFillStyle('#F97316')
+      ctx.setFontSize(18)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('扫码查看详情', centerX, bottomY)
+
+      var dotColors = ['#FF8C42', '#6366F1', '#F59E0B', '#10B981', '#FF6B9D']
+      var dotY = h - 22
+      var dotR = 4
+      var dotSpacing = 20
+      var i_b, offsetX_b
+
+      for (i_b = 0; i_b < 5; i_b++) {
+        offsetX_b = (i_b - 2) * dotSpacing
+        ctx.setFillStyle(dotColors[i_b])
+        ctx.beginPath()
+        ctx.arc(centerX + offsetX_b, dotY, dotR, 0, 2 * Math.PI)
+        ctx.fill()
+      }
+
+      ctx.setFillStyle('rgba(255,255,255,0.45)')
+      ctx.setFontSize(12)
+      ctx.setTextAlign('center')
+      ctx.setTextBaseline('top')
+      ctx.fillText('浠艾福 · 家庭健康俱乐部', centerX, dotY + 7)
+
+      ctx.draw()
+    },
+
+    _roundRect(ctx, x, y, w, h, r) {
+      ctx.beginPath()
+      ctx.moveTo(x + r, y)
+      ctx.arcTo(x + w, y, x + w, y + r, r)
+      ctx.arcTo(x + w, y + h, x + w - r, y + h, r)
+      ctx.arcTo(x, y + h, y + h - r, r)
+      ctx.arcTo(x, y, x + r, y, r)
+      ctx.closePath()
+    },
+
+    savePoster() {
+      var self = this
+      uni.canvasToTempFilePath({
+        canvasId: 'contentPosterCanvas',
+        success: function(res) {
+          uni.saveImageToPhotosAlbum({
+            filePath: res.tempFilePath,
+            success: function() {
+              uni.showToast({ title: '已保存到相册', icon: 'success' })
+              self.$emit('close')
+            },
+            fail: function(err) {
+              if (err && err.errMsg && (err.errMsg.indexOf('deny') > -1 || err.errMsg.indexOf('auth denied') > -1 || err.errMsg.indexOf('authorize') > -1)) {
+                uni.showModal({
+                  title: '提示',
+                  content: '需要相册权限才能保存图片',
+                  confirmText: '去设置',
+                  success: function(modalRes) {
+                    if (modalRes.confirm) {
+                      uni.openSetting()
+                    }
+                  }
+                })
+              } else {
+                uni.showToast({ title: '保存失败,请重试', icon: 'none' })
+              }
+            }
+          })
+        },
+        fail: function() {
+          uni.showToast({ title: '生成图片失败', icon: 'none' })
+        }
+      }, this)
+    }
+  }
+}
+</script>
+
+<style scoped>
+.poster-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.6);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 999;
+}
+.poster-container {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+}
+.poster-canvas {
+  width: 500px;
+  height: 800px;
+  border-radius: 16rpx;
+}
+.poster-actions {
+  display: flex;
+  flex-direction: column;
+  width: 500px;
+  margin-top: 30rpx;
+}
+.poster-btn {
+  height: 80rpx;
+  line-height: 80rpx;
+  font-size: 30rpx;
+  border-radius: 40rpx;
+  text-align: center;
+  border: none;
+}
+.poster-btn::after { border: none; }
+.poster-btn.save {
+  background: #ffffff;
+  color: #F97316;
+  font-weight: 600;
+  margin-bottom: 16rpx;
+}
+.poster-btn.save:active { opacity: 0.85; }
+.poster-btn.cancel {
+  background: rgba(255, 255, 255, 0.15);
+  color: #ffffff;
+}
+.poster-btn.cancel:active { opacity: 0.85; }
+</style>

+ 37 - 3
cfc-frontend/pages/article-center/article-detail.vue

@@ -56,17 +56,32 @@
           <text class="share-btn-icon">📤</text>
           <text class="share-btn-icon">📤</text>
           <text class="share-btn-text">分享</text>
           <text class="share-btn-text">分享</text>
         </button>
         </button>
+        <view class="share-btn" @click="generatePoster">
+          <text class="share-btn-icon">🖼</text>
+          <text class="share-btn-text">海报</text>
+        </view>
       </view>
       </view>
     </template>
     </template>
+
+    <ContentSharePoster
+      :show="showPoster"
+      :title="article && article.title"
+      :coverImage="article && article.coverImage"
+      :qrCodeBase64="posterQrCode"
+      typeLabel="好文推荐"
+      @close="showPoster = false"
+    />
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getArticleDetail, reportReadingTime } from '@/utils/api.js'
+import { getArticleDetail, reportReadingTime, getShareQrCode } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
+import ContentSharePoster from '@/components/ContentSharePoster.vue'
 
 
 export default {
 export default {
   mixins: [shareMixin],
   mixins: [shareMixin],
+  components: { ContentSharePoster },
   data() {
   data() {
     return {
     return {
       articleId: '',
       articleId: '',
@@ -78,7 +93,9 @@ export default {
       lastReportedSeconds: 0,
       lastReportedSeconds: 0,
       isTimerRunning: false,
       isTimerRunning: false,
       timerHandle: null,
       timerHandle: null,
-      syncHandle: null
+      syncHandle: null,
+      showPoster: false,
+      posterQrCode: ''
     }
     }
   },
   },
   onLoad(options) {
   onLoad(options) {
@@ -103,6 +120,23 @@ export default {
     this.pauseReadingTimer()
     this.pauseReadingTimer()
   },
   },
   methods: {
   methods: {
+    async generatePoster() {
+      if (!this.articleId) return
+      uni.showLoading({ title: '生成海报中...' })
+      try {
+        var page = 'pages/article-center/article-detail'
+        var scene = 'id=' + this.articleId
+        var res = await getShareQrCode(page, scene)
+        if (res && res.data) {
+          this.posterQrCode = res.data.qrCodeBase64 || ''
+          this.showPoster = true
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
+    },
     async loadDetail(id) {
     async loadDetail(id) {
       this.loading = true
       this.loading = true
       this.error = false
       this.error = false
@@ -246,7 +280,7 @@ export default {
 .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
 .detail-body { padding: 0 30rpx; font-size: 28rpx; color: #444; line-height: 1.8; }
 .detail-body rich-text { word-break: break-word; }
 .detail-body rich-text { word-break: break-word; }
 /* 底部按钮 */
 /* 底部按钮 */
-.detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
+.detail-footer { position: fixed; bottom: 0; left: 0; right: 0; background: #fff; padding: 20rpx 30rpx; display: flex; align-items: center; gap: 16rpx; box-shadow: 0 -2rpx 10rpx rgba(0,0,0,0.06); z-index: 10; }
 .share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
 .share-btn { height: 72rpx; line-height: 72rpx; background: #f5f5f5; color: #666; font-size: 24rpx; border-radius: 36rpx; padding: 0 24rpx; display: flex; align-items: center; border: none; }
 .share-btn::after { border: none; }
 .share-btn::after { border: none; }
 .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }
 .share-btn-icon { font-size: 28rpx; margin-right: 6rpx; }

+ 45 - 1
cfc-frontend/pages/discover/activity-detail/activity-detail.vue

@@ -79,6 +79,10 @@
           <text class="bottom-vendor" v-if="activity.vendorName">{{ activity.vendorName }}</text>
           <text class="bottom-vendor" v-if="activity.vendorName">{{ activity.vendorName }}</text>
         </view>
         </view>
         <view class="action-btns">
         <view class="action-btns">
+          <view class="poster-btn" @click="generatePoster">
+            <text class="poster-btn-icon">🖼</text>
+            <text class="poster-btn-text">海报</text>
+          </view>
           <!-- 未报名 → 显示报名按钮 -->
           <!-- 未报名 → 显示报名按钮 -->
           <button
           <button
             v-if="!registrationStatus && activity.requireRegistration !== 0"
             v-if="!registrationStatus && activity.requireRegistration !== 0"
@@ -141,15 +145,26 @@
     <view v-else class="empty-wrap">
     <view v-else class="empty-wrap">
       <text class="empty-text">活动不存在或已下架</text>
       <text class="empty-text">活动不存在或已下架</text>
     </view>
     </view>
+
+    <ContentSharePoster
+      :show="showPoster"
+      :title="activity.title"
+      :coverImage="activity.coverImage"
+      :qrCodeBase64="posterQrCode"
+      typeLabel="活动推荐"
+      @close="showPoster = false"
+    />
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
-import { getActivityDetail, activityCheckin, registerActivity, getMyActivityRegistrations, cancelActivityRegistration } from '@/utils/api.js'
+import { getActivityDetail, activityCheckin, registerActivity, getMyActivityRegistrations, cancelActivityRegistration, getShareQrCode } from '@/utils/api.js'
+import ContentSharePoster from '@/components/ContentSharePoster.vue'
 
 
 var WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
 var WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
 
 
 export default {
 export default {
+  components: { ContentSharePoster },
   data() {
   data() {
     return {
     return {
       activityId: null,
       activityId: null,
@@ -160,6 +175,8 @@ export default {
       checkinLoading: false,
       checkinLoading: false,
       checkinDone: false,
       checkinDone: false,
       checkinPoints: 0,
       checkinPoints: 0,
+      showPoster: false,
+      posterQrCode: '',
       // 报名相关
       // 报名相关
       registrationStatus: '',     // '' | 'pending' | 'approved'
       registrationStatus: '',     // '' | 'pending' | 'approved'
       registrationLoading: false,
       registrationLoading: false,
@@ -187,6 +204,23 @@ export default {
     }
     }
   },
   },
   methods: {
   methods: {
+    async generatePoster() {
+      if (!this.activity.id) return
+      uni.showLoading({ title: '生成海报中...' })
+      try {
+        var page = 'pages/discover/activity-detail/activity-detail'
+        var scene = 'id=' + this.activity.id
+        var res = await getShareQrCode(page, scene)
+        if (res && res.data) {
+          this.posterQrCode = res.data.qrCodeBase64 || ''
+          this.showPoster = true
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
+    },
     // ===== 工具方法 =====
     // ===== 工具方法 =====
     formatDate: function(dateStr) {
     formatDate: function(dateStr) {
       if (!dateStr) return ''
       if (!dateStr) return ''
@@ -597,7 +631,17 @@ export default {
   display: flex;
   display: flex;
   flex-direction: row;
   flex-direction: row;
   gap: 16rpx;
   gap: 16rpx;
+  align-items: center;
+}
+.poster-btn {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  padding: 8rpx 16rpx;
 }
 }
+.poster-btn-icon { font-size: 36rpx; }
+.poster-btn-text { font-size: 20rpx; color: #64748B; }
 
 
 /* 报名按钮 */
 /* 报名按钮 */
 .btn-register {
 .btn-register {

+ 36 - 2
cfc-frontend/pages/discover/product-detail/product-detail.vue

@@ -79,6 +79,10 @@
             <text class="share-btn-icon">📤</text>
             <text class="share-btn-icon">📤</text>
             <text class="share-btn-text">分享</text>
             <text class="share-btn-text">分享</text>
           </button>
           </button>
+          <view class="share-btn" @click="generatePoster">
+            <text class="share-btn-icon">🖼</text>
+            <text class="share-btn-text">海报</text>
+          </view>
           <view class="cart-icon-wrap" @click="onAddToCart">
           <view class="cart-icon-wrap" @click="onAddToCart">
             <text class="cart-icon">🛒</text>
             <text class="cart-icon">🛒</text>
             <text v-if="cartCount > 0" class="cart-badge">{{ cartCount > 99 ? '99+' : cartCount }}</text>
             <text v-if="cartCount > 0" class="cart-badge">{{ cartCount > 99 ? '99+' : cartCount }}</text>
@@ -91,16 +95,27 @@
     <view v-else class="empty-wrap">
     <view v-else class="empty-wrap">
       <text class="empty-text">商品不存在或已下架</text>
       <text class="empty-text">商品不存在或已下架</text>
     </view>
     </view>
+
+    <ContentSharePoster
+      :show="showPoster"
+      :title="product.name"
+      :coverImage="product.coverImage"
+      :qrCodeBase64="posterQrCode"
+      typeLabel="好物推荐"
+      @close="showPoster = false"
+    />
   </view>
   </view>
 </template>
 </template>
 
 
 <script>
 <script>
 import config from '@/config.js'
 import config from '@/config.js'
-import { productDetail, getMyMembership } from '@/utils/api.js'
+import { productDetail, getMyMembership, getShareQrCode } from '@/utils/api.js'
 import shareMixin from '../../components/share-mixin.js'
 import shareMixin from '../../components/share-mixin.js'
+import ContentSharePoster from '@/components/ContentSharePoster.vue'
 
 
 export default {
 export default {
   mixins: [shareMixin],
   mixins: [shareMixin],
+  components: { ContentSharePoster },
   data() {
   data() {
     return {
     return {
       product: {},
       product: {},
@@ -110,7 +125,9 @@ export default {
       specMap: {},
       specMap: {},
       selectedSpecs: {},
       selectedSpecs: {},
       suppliers: [],
       suppliers: [],
-      isMember: false
+      isMember: false,
+      showPoster: false,
+      posterQrCode: ''
     }
     }
   },
   },
   computed: {
   computed: {
@@ -169,6 +186,23 @@ export default {
     this.loadCartCount()
     this.loadCartCount()
   },
   },
   methods: {
   methods: {
+    async generatePoster() {
+      if (!this.product.id) return
+      uni.showLoading({ title: '生成海报中...' })
+      try {
+        var page = 'pages/discover/product-detail/product-detail'
+        var scene = 'id=' + this.product.id
+        var res = await getShareQrCode(page, scene)
+        if (res && res.data) {
+          this.posterQrCode = res.data.qrCodeBase64 || ''
+          this.showPoster = true
+        }
+      } catch (e) {
+        uni.showToast({ title: '生成失败', icon: 'none' })
+      } finally {
+        uni.hideLoading()
+      }
+    },
     checkMemberStatus() {
     checkMemberStatus() {
       var that = this
       var that = this
       getMyMembership().then(function(res) {
       getMyMembership().then(function(res) {

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

@@ -1451,6 +1451,10 @@ export const getInviteQrCode = () => {
   return request('/api/invite/qrcode', 'POST')
   return request('/api/invite/qrcode', 'POST')
 }
 }
 
 
+export const getShareQrCode = (page, scene) => {
+  return request('/api/share/qrcode', 'POST', { page, scene })
+}
+
 // 缁戝畾鎺ㄨ崘浜?
 // 缁戝畾鎺ㄨ崘浜?
 export const bindReferral = (referralCode) => {
 export const bindReferral = (referralCode) => {
   return request('/api/invite/bind', 'POST', { referralCode })
   return request('/api/invite/bind', 'POST', { referralCode })

+ 21 - 30
cfc-web/src/components/DimensionWeightPicker.vue

@@ -91,43 +91,34 @@ export default {
   },
   },
   methods: {
   methods: {
     onWeightChange(changedCode) {
     onWeightChange(changedCode) {
-      const changed = this.dimensions.find(function(d) { return d.code === changedCode })
+      var changed = this.dimensions.find(function(d) { return d.code === changedCode })
       if (changed) {
       if (changed) {
-        changed.currentValue = Math.max(0, Math.min(100, changed.currentValue))
-      }
-      var remaining = 100 - this.sum
-      if (Math.abs(remaining) < 0.01) {
-        this.emitValue()
-        return
+        changed.currentValue = Math.max(0, Math.min(100, changed.currentValue || 0))
       }
       }
+      var changedValue = changed ? (changed.currentValue || 0) : 0
+      var remaining = 100 - changedValue
       var others = this.dimensions.filter(function(d) { return d.code !== changedCode })
       var others = this.dimensions.filter(function(d) { return d.code !== changedCode })
-      if (remaining > 0) {
-        var othersTotal = others.reduce(function(s, d) { return s + (d.currentValue || 0) }, 0)
-        var exactVals = []
-        for (var i = 0; i < others.length; i++) {
-          exactVals.push(othersTotal > 0 ? ((others[i].currentValue || 0) / othersTotal) * remaining : remaining / others.length)
-        }
+      var othersTotal = others.reduce(function(s, d) { return s + (d.currentValue || 0) }, 0)
+
+      if (othersTotal === 0) {
+        // 其他维度全为0时,均分剩余
+        var share = Math.floor(remaining / others.length)
         var distributed = 0
         var distributed = 0
-        for (var j = 0; j < others.length - 1; j++) {
-          var rounded = Math.round(exactVals[j])
-          others[j].currentValue = Math.max(0, rounded)
-          distributed += rounded
+        for (var i = 0; i < others.length - 1; i++) {
+          others[i].currentValue = share
+          distributed += share
         }
         }
-        others[others.length - 1].currentValue = Math.max(0, Math.round(remaining - distributed))
+        others[others.length - 1].currentValue = remaining - distributed
       } else {
       } else {
-        var excess = -remaining
-        var othersTotal2 = others.reduce(function(s, d) { return s + (d.currentValue || 0) }, 0)
-        var exactCuts = []
-        for (var k = 0; k < others.length; k++) {
-          exactCuts.push(othersTotal2 > 0 ? ((others[k].currentValue || 0) / othersTotal2) * excess : excess / others.length)
-        }
-        var cutSum = 0
-        for (var m = 0; m < others.length - 1; m++) {
-          var cutRounded = Math.round(exactCuts[m])
-          others[m].currentValue = Math.max(0, (others[m].currentValue || 0) - cutRounded)
-          cutSum += cutRounded
+        // 按其他维度的当前比例分配剩余值
+        var distributed2 = 0
+        for (var j = 0; j < others.length - 1; j++) {
+          var exactVal = ((others[j].currentValue || 0) / othersTotal) * remaining
+          var rounded = Math.round(exactVal)
+          others[j].currentValue = rounded
+          distributed2 += rounded
         }
         }
-        others[others.length - 1].currentValue = Math.max(0, (others[others.length - 1].currentValue || 0) - Math.round(excess - cutSum))
+        others[others.length - 1].currentValue = remaining - distributed2
       }
       }
       this.emitValue()
       this.emitValue()
     },
     },