Bladeren bron

feat(frontend): canvas 2D迁移+Profile分销入口+admin抗压布局+stores修复

liaoxg 3 maanden geleden
bovenliggende
commit
ae096a6860
3 gewijzigde bestanden met toevoegingen van 345 en 159 verwijderingen
  1. 137 24
      client/pages/admin/index.vue
  2. 194 133
      client/pages/profile/index.vue
  3. 14 2
      client/stores/user.js

+ 137 - 24
client/pages/admin/index.vue

@@ -500,18 +500,25 @@ onPullDownRefresh(async () => {
 
 // ─── User Actions ───
 async function handleUpgrade(userId) {
-  actionLoading.userId = userId
-  actionLoading.type = 'upgrade'
-  try {
-    await adminApi.upgradeUser(userId)
-    uni.showToast({ title: '已升级为VIP', icon: 'success' })
-    await fetchAllData()
-  } catch (e) {
-    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
-  } finally {
-    actionLoading.userId = null
-    actionLoading.type = null
-  }
+  uni.showModal({
+    title: '升级VIP',
+    content: '确定要将该用户升级为VIP吗?',
+    success: async (res) => {
+      if (!res.confirm) return
+      actionLoading.userId = userId
+      actionLoading.type = 'upgrade'
+      try {
+        await adminApi.upgradeUser(userId)
+        uni.showToast({ title: '已升级为VIP', icon: 'success' })
+        await fetchAllData()
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        actionLoading.userId = null
+        actionLoading.type = null
+      }
+    }
+  })
 }
 
 async function handleDowngrade(userId) {
@@ -538,18 +545,25 @@ async function handleDowngrade(userId) {
 
 async function handleToggleAdmin(user) {
   const newIsAdmin = !user.isAdmin
-  actionLoading.userId = user.id
-  actionLoading.type = 'set-admin'
-  try {
-    await adminApi.setAdmin(user.id, newIsAdmin)
-    uni.showToast({ title: newIsAdmin ? '已设为管理员' : '已取消管理员', icon: 'success' })
-    await fetchAllData()
-  } catch (e) {
-    uni.showToast({ title: e.message || '操作失败', icon: 'none' })
-  } finally {
-    actionLoading.userId = null
-    actionLoading.type = null
-  }
+  uni.showModal({
+    title: newIsAdmin ? '设为管理员' : '取消管理员',
+    content: newIsAdmin ? '确定要将该用户设为管理员吗?' : '确定要取消该用户的管理员权限吗?',
+    success: async (res) => {
+      if (!res.confirm) return
+      actionLoading.userId = user.id
+      actionLoading.type = 'set-admin'
+      try {
+        await adminApi.setAdmin(user.id, newIsAdmin)
+        uni.showToast({ title: newIsAdmin ? '已设为管理员' : '已取消管理员', icon: 'success' })
+        await fetchAllData()
+      } catch (e) {
+        uni.showToast({ title: e.message || '操作失败', icon: 'none' })
+      } finally {
+        actionLoading.userId = null
+        actionLoading.type = null
+      }
+    }
+  })
 }
 
 // ─── Withdraw Actions ───
@@ -1146,3 +1160,102 @@ async function handleResetConfig(configKey) {
   text-decoration: underline;
 }
 </style>
+
+<style lang="scss">
+// ─── Light Theme Overrides ───
+.theme-light .page-admin {
+  background: linear-gradient(180deg, #f0f2f5 0%, #e8ecf0 100%);
+}
+
+.theme-light {
+  // Placeholder
+  .placeholder-style {
+    color: rgba(0, 0, 0, 0.25);
+  }
+
+  // Login
+  .login-card {
+    background: #ffffff;
+    border-color: rgba(0, 0, 0, 0.06);
+    box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
+  }
+
+  .login-title {
+    color: rgba(0, 0, 0, 0.9);
+  }
+
+  .field-input {
+    background: rgba(0, 0, 0, 0.02);
+    border-color: rgba(0, 0, 0, 0.08);
+    color: rgba(0, 0, 0, 0.85);
+  }
+
+  // Stats
+  .stat-card {
+    background: #ffffff;
+    border-color: rgba(0, 0, 0, 0.06);
+  }
+
+  .stat-label {
+    color: rgba(0, 0, 0, 0.55);
+  }
+
+  // Section card (recent orders)
+  .section-card {
+    background: #ffffff;
+    border-color: rgba(0, 0, 0, 0.06);
+  }
+
+  .section-title {
+    color: rgba(0, 0, 0, 0.85);
+  }
+
+  .mini-row {
+    border-bottom-color: rgba(0, 0, 0, 0.06);
+  }
+
+  .mini-label {
+    color: rgba(0, 0, 0, 0.8);
+  }
+
+  // Tabs
+  .tab-item {
+    background: rgba(0, 0, 0, 0.02);
+    border-color: rgba(0, 0, 0, 0.06);
+  }
+
+  // Data cards
+  .data-card {
+    background: #ffffff;
+    border-color: rgba(0, 0, 0, 0.06);
+  }
+
+  .data-primary {
+    color: rgba(0, 0, 0, 0.85);
+  }
+
+  .data-price {
+    color: rgba(0, 0, 0, 0.85);
+  }
+
+  .data-extras {
+    border-top-color: rgba(0, 0, 0, 0.06);
+  }
+
+  .action-row {
+    border-top-color: rgba(0, 0, 0, 0.06);
+  }
+
+  // Config
+  .config-input {
+    background: rgba(0, 0, 0, 0.02);
+    border-color: rgba(0, 0, 0, 0.08);
+    color: rgba(0, 0, 0, 0.85);
+  }
+
+  // Empty state
+  .empty-text {
+    color: rgba(0, 0, 0, 0.35);
+  }
+}
+</style>

+ 194 - 133
client/pages/profile/index.vue

@@ -287,7 +287,10 @@
 
     <!-- Group 2: Promotion Tools (paid users only) -->
     <view v-if="userStore.isLoggedIn && userStore.isVip && userStore.referralCode" class="menu-section">
-      <view class="menu-section__header">推广工具</view>
+      <view class="menu-section__header">
+        <text>推广工具</text>
+        <text class="menu-section__more" @click="goCommission">查看详细 ›</text>
+      </view>
       <view class="promo-card">
         <view class="promo-card__code-area">
           <text class="promo-card__prefix">推广码</text>
@@ -326,7 +329,7 @@
     </view>
 
     <!-- Hidden canvas for poster generation -->
-    <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" />
+    <canvas type="2d" id="posterCanvas" class="poster-canvas" />
   </view>
 </template>
 
@@ -380,6 +383,12 @@ const isExpired = computed(() => {
 
 function formatDate(dateStr) {
   if (!dateStr) return ''
+  // Handle Jackson array format: [2026, 6, 8, 23, 59, 59]
+  if (Array.isArray(dateStr)) {
+    const [y, m, d] = dateStr
+    return `${y}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}`
+  }
+  // Handle ISO string: "2026-06-08T23:59:59"
   return dateStr.slice(0, 10)
 }
 
@@ -472,134 +481,170 @@ function generatePoster() {
   uni.showLoading({ title: '生成海报…', mask: true })
   const baseUrl = 'http://127.0.0.1:8186'
 
-  Promise.all([
-    downloadImage(userStore.avatarUrl),
-    profileApi.qrcode().then(res => {
-      const url = res.qrCodeUrl.startsWith('http') ? res.qrCodeUrl : baseUrl + res.qrCodeUrl
-      return downloadImage(url)
-    })
-  ]).then(([avatarPath, qrPath]) => {
-    const ctx = uni.createCanvasContext('posterCanvas')
-    const W = 375, H = 670
-
-    // Background
-    const grd = ctx.createLinearGradient(0, 0, 0, H)
-    grd.addColorStop(0, '#0c0a1a')
-    grd.addColorStop(1, '#1a1040')
-    ctx.fillStyle = grd
-    ctx.fillRect(0, 0, W, H)
-
-    // Top glow
-    const topGlow = ctx.createLinearGradient(0, 0, 0, 220)
-    topGlow.addColorStop(0, 'rgba(90, 50, 180, 0.35)')
-    topGlow.addColorStop(1, 'rgba(90, 50, 180, 0)')
-    ctx.fillStyle = topGlow
-    ctx.fillRect(0, 0, W, 220)
-
-    // Gold accent line
-    ctx.fillStyle = '#C9A84C'
-    ctx.fillRect(W * 0.32, 28, W * 0.36, 1.5)
-
-    // Brand title
-    ctx.font = '13px sans-serif'
-    ctx.fillStyle = 'rgba(201, 168, 76, 0.7)'
-    ctx.textAlign = 'center'
-    ctx.fillText('数字能量学', W / 2, 52)
-
-    // Avatar (circular)
-    const avatarSize = 76
-    ctx.save()
-    ctx.beginPath()
-    ctx.arc(W / 2, 128, avatarSize / 2, 0, 2 * Math.PI)
-    ctx.closePath()
-    ctx.fillStyle = '#ffffff'
-    ctx.fill()
-    if (avatarPath) {
-      ctx.clip()
-      ctx.drawImage(avatarPath, (W - avatarSize) / 2, 128 - avatarSize / 2, avatarSize, avatarSize)
-    }
-    ctx.restore()
-
-    // Nickname
-    ctx.font = 'bold 18px sans-serif'
-    ctx.fillStyle = '#FFFFFF'
-    ctx.textAlign = 'center'
-    ctx.fillText(userStore.nickname || '数字能量探索者', W / 2, 192)
-
-    // Role badge
-    const roleText = userStore.vipType === 'practitioner' ? '能量师' : userStore.vipType === 'annual' ? 'C端会员' : '探索者'
-    ctx.font = '11px sans-serif'
-    ctx.fillStyle = userStore.vipType === 'practitioner' ? '#C9A84C' : 'rgba(255,255,255,0.5)'
-    ctx.fillText(roleText, W / 2, 214)
-
-    // Divider
-    ctx.fillStyle = 'rgba(255,255,255,0.07)'
-    ctx.fillRect(30, 240, W - 60, 1)
-
-    // Section title
-    ctx.font = '12px sans-serif'
-    ctx.fillStyle = 'rgba(255,255,255,0.38)'
-    ctx.fillText('推荐体验', W / 2, 270)
-
-    // QR code background
-    const qrSize = 160
-    const qrX = (W - qrSize) / 2
-    const qrY = 288
-    ctx.fillStyle = '#FFFFFF'
-    ctx.fillRect(qrX, qrY, qrSize, qrSize)
-    if (qrPath) {
-      ctx.drawImage(qrPath, qrX + 8, qrY + 8, qrSize - 16, qrSize - 16)
+  // Get canvas node via selector query (Canvas 2D API)
+  const query = uni.createSelectorQuery()
+  query.select('#posterCanvas').fields({ node: true, size: true }).exec((res) => {
+    if (!res || !res[0]) {
+      uni.hideLoading()
+      uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+      return
     }
+    const canvas = res[0].node
+    const ctx = canvas.getContext('2d')
+    const W = 375, H = 670
+    const dpr = uni.getSystemInfoSync().pixelRatio || 2
+    canvas.width = W * dpr
+    canvas.height = H * dpr
+    ctx.scale(dpr, dpr)
+
+    // Download images first
+    Promise.all([
+      downloadImage(userStore.avatarUrl),
+      profileApi.qrcode().then(res => {
+        const url = res.qrCodeUrl.startsWith('http') ? res.qrCodeUrl : baseUrl + res.qrCodeUrl
+        return downloadImage(url)
+      })
+    ]).then(([avatarPath, qrPath]) => {
+      // Load images into Canvas 2D Image objects
+      const imgPromises = []
+      let avatarImg = null
+      let qrImg = null
+
+      if (avatarPath) {
+        imgPromises.push(new Promise((resolve) => {
+          const img = canvas.createImage()
+          img.onload = () => { avatarImg = img; resolve() }
+          img.onerror = () => resolve()
+          img.src = avatarPath
+        }))
+      }
+      if (qrPath) {
+        imgPromises.push(new Promise((resolve) => {
+          const img = canvas.createImage()
+          img.onload = () => { qrImg = img; resolve() }
+          img.onerror = () => resolve()
+          img.src = qrPath
+        }))
+      }
 
-    // Hint
-    ctx.font = '11px sans-serif'
-    ctx.fillStyle = 'rgba(255,255,255,0.32)'
-    ctx.fillText('扫码开启你的数字能量之旅', W / 2, qrY + qrSize + 24)
-
-    // Bottom
-    ctx.font = '10px sans-serif'
-    ctx.fillStyle = 'rgba(255,255,255,0.18)'
-    ctx.fillText('数字能量学 · 数字能量智能体', W / 2, H - 22)
-
-    ctx.draw(false)
-
-    setTimeout(() => {
-      uni.canvasToTempFilePath({
-        canvasId: 'posterCanvas',
-        quality: 1,
-        success: (res) => {
-          uni.saveImageToPhotosAlbum({
-            filePath: res.tempFilePath,
-            success: () => {
-              uni.hideLoading()
-              uni.showToast({ title: '海报已保存到相册', icon: 'success' })
-            },
-            fail: (e) => {
-              uni.hideLoading()
-              if (e.errMsg && e.errMsg.includes('auth deny')) {
-                uni.showModal({
-                  title: '保存海报',
-                  content: '需要相册权限才能保存海报到本地',
-                  confirmText: '去设置',
-                  success: (r) => { if (r.confirm) uni.openSetting() }
-                })
-              } else {
-                uni.showToast({ title: '保存失败,请重试', icon: 'none' })
-              }
-            }
-          })
-        },
-        fail: () => {
-          uni.hideLoading()
-          uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+      Promise.all(imgPromises).then(() => {
+        // ─── Background ───
+        const grd = ctx.createLinearGradient(0, 0, 0, H)
+        grd.addColorStop(0, '#0c0a1a')
+        grd.addColorStop(1, '#1a1040')
+        ctx.fillStyle = grd
+        ctx.fillRect(0, 0, W, H)
+
+        // Top glow
+        const topGlow = ctx.createLinearGradient(0, 0, 0, 220)
+        topGlow.addColorStop(0, 'rgba(90, 50, 180, 0.35)')
+        topGlow.addColorStop(1, 'rgba(90, 50, 180, 0)')
+        ctx.fillStyle = topGlow
+        ctx.fillRect(0, 0, W, 220)
+
+        // Gold accent line
+        ctx.fillStyle = '#C9A84C'
+        ctx.fillRect(W * 0.32, 28, W * 0.36, 1.5)
+
+        // Brand title
+        ctx.font = '13px sans-serif'
+        ctx.fillStyle = 'rgba(201, 168, 76, 0.7)'
+        ctx.textAlign = 'center'
+        ctx.fillText('数字能量学', W / 2, 52)
+
+        // Avatar (circular)
+        const avatarSize = 76
+        ctx.save()
+        ctx.beginPath()
+        ctx.arc(W / 2, 128, avatarSize / 2, 0, 2 * Math.PI)
+        ctx.closePath()
+        ctx.fillStyle = '#ffffff'
+        ctx.fill()
+        if (avatarImg) {
+          ctx.clip()
+          ctx.drawImage(avatarImg, (W - avatarSize) / 2, 128 - avatarSize / 2, avatarSize, avatarSize)
+        }
+        ctx.restore()
+
+        // Nickname
+        ctx.font = 'bold 18px sans-serif'
+        ctx.fillStyle = '#FFFFFF'
+        ctx.textAlign = 'center'
+        ctx.fillText(userStore.nickname || '数字能量探索者', W / 2, 192)
+
+        // Role badge
+        const roleText = userStore.vipType === 'practitioner' ? '能量师' : userStore.vipType === 'annual' ? 'C端会员' : '探索者'
+        ctx.font = '11px sans-serif'
+        ctx.fillStyle = userStore.vipType === 'practitioner' ? '#C9A84C' : 'rgba(255,255,255,0.5)'
+        ctx.fillText(roleText, W / 2, 214)
+
+        // Divider
+        ctx.fillStyle = 'rgba(255,255,255,0.07)'
+        ctx.fillRect(30, 240, W - 60, 1)
+
+        // Section title
+        ctx.font = '12px sans-serif'
+        ctx.fillStyle = 'rgba(255,255,255,0.38)'
+        ctx.fillText('推荐体验', W / 2, 270)
+
+        // QR code background
+        const qrSize = 160
+        const qrX = (W - qrSize) / 2
+        const qrY = 288
+        ctx.fillStyle = '#FFFFFF'
+        ctx.fillRect(qrX, qrY, qrSize, qrSize)
+        if (qrImg) {
+          ctx.drawImage(qrImg, qrX + 8, qrY + 8, qrSize - 16, qrSize - 16)
         }
-      }, getCurrentInstance())
-    }, 150)
 
-  }).catch(e => {
-    console.error('generatePoster error:', e)
-    uni.hideLoading()
-    uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+        // Hint
+        ctx.font = '11px sans-serif'
+        ctx.fillStyle = 'rgba(255,255,255,0.32)'
+        ctx.fillText('扫码开启你的数字能量之旅', W / 2, qrY + qrSize + 24)
+
+        // Bottom
+        ctx.font = '10px sans-serif'
+        ctx.fillStyle = 'rgba(255,255,255,0.18)'
+        ctx.fillText('数字能量学 · 数字能量智能体', W / 2, H - 22)
+
+        // Canvas 2D draws immediately — no ctx.draw() needed
+        // Export to temp file (pass canvas object, not canvasId)
+        uni.canvasToTempFilePath({
+          canvas,
+          quality: 1,
+          success: (res) => {
+            uni.saveImageToPhotosAlbum({
+              filePath: res.tempFilePath,
+              success: () => {
+                uni.hideLoading()
+                uni.showToast({ title: '海报已保存到相册', icon: 'success' })
+              },
+              fail: (e) => {
+                uni.hideLoading()
+                if (e.errMsg && e.errMsg.includes('auth deny')) {
+                  uni.showModal({
+                    title: '保存海报',
+                    content: '需要相册权限才能保存海报到本地',
+                    confirmText: '去设置',
+                    success: (r) => { if (r.confirm) uni.openSetting() }
+                  })
+                } else {
+                  uni.showToast({ title: '保存失败,请重试', icon: 'none' })
+                }
+              }
+            })
+          },
+          fail: () => {
+            uni.hideLoading()
+            uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+          }
+        })
+      })
+    }).catch(e => {
+      console.error('generatePoster error:', e)
+      uni.hideLoading()
+      uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+    })
   })
 }
 
@@ -619,7 +664,11 @@ function goTags() {
 }
 
 function goAbout() {
-  uni.navigateTo({ url: '/pages/about/index' })
+  uni.showToast({ title: '功能开发中', icon: 'none' })
+}
+
+function goCommission() {
+  uni.navigateTo({ url: '/pages/commission/index' })
 }
 
 function handleLogout() {
@@ -1363,6 +1412,18 @@ $danger: var(--color-danger);
     text-transform: uppercase;
     letter-spacing: 0.5px;
     padding: 14px 16px 6px;
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+  }
+
+  &__more {
+    font-size: 12px;
+    font-weight: 500;
+    color: #3B82F6;
+    text-transform: none;
+    letter-spacing: 0;
+    cursor: pointer;
   }
 }
 
@@ -1467,11 +1528,11 @@ $danger: var(--color-danger);
 }
 
 .poster-canvas {
-position: fixed;
- left: -9999px;
- top: -9999px;
- width: 375px;
- height: 670px;
- }
+  position: fixed;
+  left: -9999px;
+  top: -9999px;
+  width: 375px;
+  height: 670px;
+}
 
  </style>

+ 14 - 2
client/stores/user.js

@@ -1,6 +1,17 @@
 import { defineStore } from 'pinia'
 import { authApi, profileApi } from '../utils/api'
 
+/** Normalize vipEndTime from Jackson array [y,m,d,h,m,s] to ISO string */
+function normalizeDate(v) {
+  if (!v) return null
+  if (Array.isArray(v)) {
+    const [y, m, d, h = 0, min = 0, s = 0] = v
+    const pad = (n) => String(n).padStart(2, '0')
+    return `${y}-${pad(m)}-${pad(d)}T${pad(h)}:${pad(min)}:${pad(s)}`
+  }
+  return v
+}
+
 export const useUserStore = defineStore('user', {
   state: () => ({
     token: uni.getStorageSync('token') || '',
@@ -74,9 +85,10 @@ export const useUserStore = defineStore('user', {
       this.nickname = user.nickname
       this.avatarUrl = user.avatarUrl
       this.referralCode = user.referralCode
-      this.isVip = !!user.vipEndTime && new Date(user.vipEndTime) > new Date()
       this.vipType = user.vipType || null
-      this.vipEndTime = user.vipEndTime
+      // Normalize vipEndTime: Jackson may send [2026,6,8,23,59,59]; always store as ISO string
+      this.vipEndTime = normalizeDate(user.vipEndTime)
+      this.isVip = !!this.vipEndTime && new Date(this.vipEndTime) > new Date()
       // US-5.1 profile fields
       this.gender = user.gender || 0
       this.birthDate = user.birthDate || null