فهرست منبع

feat: 推广海报Canvas生成

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
liaoxg 3 ماه پیش
والد
کامیت
c67978b97b
2فایلهای تغییر یافته به همراه1152 افزوده شده و 998 حذف شده
  1. 956 803
      client/pages/profile/index.vue
  2. 196 195
      client/utils/api.js

+ 956 - 803
client/pages/profile/index.vue

@@ -1,804 +1,957 @@
-<template>
-  <view class="page-profile">
-    <!-- ========== Region 1: User Card ========== -->
-    <view class="user-card">
-      <!-- Login prompt for unauthenticated users -->
-      <view v-if="!userStore.isLoggedIn" class="user-card__login" @click="goLogin">
-        <view class="avatar avatar--empty">
-          <text class="avatar__icon">+</text>
-        </view>
-        <text class="user-card__nickname">未登录</text>
-        <text class="user-card__login-hint">点击登录 / 注册</text>
-      </view>
-
-      <!-- Authenticated user card — tap to edit -->
-      <view v-else class="user-card__logged" @click="goEditProfile">
-        <view class="user-card__inner">
-          <view class="avatar">
-            <image :src="userStore.avatarUrl || '/static/default-avatar.png'" mode="aspectFill" />
-          </view>
-          <view class="user-card__info">
-            <text class="user-card__nickname">{{ userStore.nickname }}</text>
-
-            <!-- Role tag -->
-            <view
-              class="user-card__tag"
-              :class="{
-                'user-card__tag--practitioner': userStore.vipType === 'practitioner',
-                'user-card__tag--annual': userStore.vipType === 'annual',
-                'user-card__tag--normal': !userStore.vipType
-              }"
-            >
-              <text v-if="!userStore.vipType">普通用户</text>
-              <text v-else-if="userStore.vipType === 'annual'">C端会员</text>
-              <text v-else>能量师 🌟</text>
-            </view>
-
-            <!-- Social summary (optional) -->
-            <text v-if="userStore.bio || userStore.city" class="user-card__summary">
-              {{ userStore.city || '' }}{{ userStore.city && userStore.bio ? ' · ' : '' }}{{ userStore.bio || '' }}
-            </text>
-          </view>
-          <text class="user-card__arrow">›</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- ========== Region 2: Membership Status Cards ========== -->
-
-    <!-- US-5.1 §13: Profile incomplete banner -->
-    <view v-if="userStore.isLoggedIn && userStore.profileIncomplete" class="banner" @click="goEditProfile">
-      <text class="banner__text">📝 请完善个人资料,开启能量匹配</text>
-      <text class="banner__arrow">›</text>
-    </view>
-
-    <!-- US-4.3: Renewal reminder (7 days before expiry) -->
-    <view v-if="isExpiringSoon" class="banner banner--warning" @click="goRenew">
-      <text class="banner__text">⏰ 您的会员即将到期,请及时续费</text>
-      <text class="banner__arrow">›</text>
-    </view>
-
-    <!-- Seed-price badge -->
-    <view v-if="userStore.isSeedPriceUser" class="seed-badge">
-      <text class="seed-badge__text">🌱 种子价专属会员</text>
-    </view>
-
-    <!-- VIP status card — role-based -->
-    <!-- Case 1: Energy master (practitioner, in valid period) -->
-    <view
-      v-if="userStore.vipType === 'practitioner' && userStore.isVip && !isExpired"
-      class="vip-card vip-card--practitioner"
-    >
-      <view class="vip-card__body">
-        <text class="vip-card__title">能量师会员 🌟</text>
-        <text class="vip-card__desc">
-          有效期至 {{ formatDate(userStore.vipEndTime) }}
-        </text>
-      </view>
-      <text class="vip-card__action" @click="goRenew">续费</text>
-    </view>
-
-    <!-- Case 2: C-end annual subscriber (in valid period) -->
-    <view
-      v-else-if="userStore.vipType === 'annual' && userStore.isVip && !isExpired"
-      class="vip-card vip-card--annual"
-    >
-      <view class="vip-card__body">
-        <text class="vip-card__title">C端会员</text>
-        <text class="vip-card__desc">
-          有效期至 {{ formatDate(userStore.vipEndTime) }}
-        </text>
-      </view>
-      <text class="vip-card__action" @click="goUpgradePractitioner">升级能量师</text>
-    </view>
-
-    <!-- Case 3: Expired member (vipEndTime < now) -->
-    <view v-else-if="userStore.vipType && isExpired" class="vip-card vip-card--expired">
-      <view class="vip-card__body">
-        <text class="vip-card__title">会员已过期</text>
-        <text class="vip-card__desc">
-          过期时间 {{ formatDate(userStore.vipEndTime) }}
-        </text>
-      </view>
-      <text class="vip-card__action" @click="goPayment">重新开通</text>
-    </view>
-
-    <!-- Case 4: Free user — two payment options -->
-    <view v-else class="vip-card vip-card--upsell">
-      <view class="vip-card__body">
-        <text class="vip-card__title">选择方案</text>
-        <view class="vip-card__dual">
-          <view class="vip-card__option" @click="goPayment">
-            <text class="option-title">C端会员</text>
-            <text class="option-price">¥131/年</text>
-            <text class="option-desc">无限AI解读</text>
-            <button class="option-btn light">开通</button>
-          </view>
-          <view class="vip-card__option vip-card__option--pro" @click="goUpgradePractitioner">
-            <text class="option-title">能量师</text>
-            <text class="option-price">¥1,314/年</text>
-            <text class="option-desc">完整功能+分销</text>
-            <button class="option-btn primary">申请</button>
-          </view>
-        </view>
-      </view>
-    </view>
-
-    <!-- Daily quota (non-VIP only) -->
-    <view v-if="!userStore.isVip" class="quota-bar">
-      <view class="quota-bar__item">
-        <text class="quota-bar__label">今日分析</text>
-        <text class="quota-bar__value">{{ userStore.usedCharts }}/{{ userStore.maxCharts }}</text>
-      </view>
-      <view class="quota-bar__divider"></view>
-      <view class="quota-bar__item">
-        <text class="quota-bar__label">今日解读</text>
-        <text class="quota-bar__value">{{ userStore.usedChats }}/{{ userStore.maxChats }}</text>
-      </view>
-    </view>
-
-    <!-- ========== Region 3: Feature Menu ========== -->
-
-    <!-- Group 1: My Services -->
-    <view class="menu-section">
-      <view class="menu-section__header">我的服务</view>
-
-      <!-- Analysis history (all logged-in) -->
-      <view v-if="userStore.isLoggedIn" class="menu-item" @click="goRecords">
-        <text class="menu-item__label">📋 我的分析</text>
-        <text class="menu-item__arrow">›</text>
-      </view>
-
-      <!-- Distribution panel (paid users only) -->
-      <view v-if="userStore.isLoggedIn && userStore.isVip" class="menu-item" @click="goCommission">
-        <text class="menu-item__label">💰 我的推广</text>
-        <text class="menu-item__arrow">›</text>
-      </view>
-
-      <!-- Practitioner workbench (practitioners only) — EPIC 9 -->
-      <view v-if="userStore.vipType === 'practitioner' && userStore.isVip" class="menu-item" @click="goWorkbench">
-        <text class="menu-item__label">🔧 工作台</text>
-        <view class="menu-item__badge menu-item__badge--new">NEW</view>
-        <text class="menu-item__arrow">›</text>
-      </view>
-    </view>
-
-    <!-- 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="promo-card">
-        <view class="promo-card__code-area">
-          <text class="promo-card__prefix">推广码</text>
-          <text class="promo-card__code">{{ userStore.referralCode }}</text>
-        </view>
-        <view class="promo-card__actions">
-          <text class="promo-card__btn" @click="copyReferral">复制推广码</text>
-          <text class="promo-card__divider">|</text>
-          <text class="promo-card__btn" @click="generatePoster">生成推广海报</text>
-        </view>
-      </view>
-    </view>
-
-    <!-- Group 3: More -->
-    <view class="menu-section">
-      <view class="menu-section__header">更多</view>
-
-      <!-- My tags (practitioner only, P2) -->
-      <view v-if="userStore.vipType === 'practitioner'" class="menu-item" @click="goTags">
-        <text class="menu-item__label">🏷 我的标签</text>
-        <text class="menu-item__arrow">›</text>
-      </view>
-
-      <!-- About us (all) -->
-      <view class="menu-item" @click="goAbout">
-        <text class="menu-item__label">ℹ️ 关于我们</text>
-        <text class="menu-item__arrow">›</text>
-      </view>
-    </view>
-
-    <!-- ========== Region 4: Logout ========== -->
-    <view v-if="userStore.isLoggedIn" class="menu-section">
-      <view class="menu-item menu-item--danger" @click="handleLogout">
-        <text class="menu-item__label menu-item__label--danger">退出登录</text>
-      </view>
-    </view>
-  </view>
-</template>
-
-<script setup>
-import { computed } from 'vue'
-import { onShow } from '@dcloudio/uni-app'
-import { useUserStore } from '@/stores/user'
-
-const userStore = useUserStore()
-
-// US-4.3: Renewal reminder if expiry within 7 days
-const isExpiringSoon = computed(() => {
-  if (!userStore.vipEndTime) return false
-  const expiry = new Date(userStore.vipEndTime)
-  const now = new Date()
-  const diffHours = (expiry - now) / (1000 * 60 * 60)
-  return diffHours > 0 && diffHours <= 7 * 24
-})
-
-// US-4.3: Expired check
-const isExpired = computed(() => {
-  if (!userStore.vipEndTime) return false
-  return new Date(userStore.vipEndTime) < new Date()
-})
-
-function formatDate(dateStr) {
-  if (!dateStr) return ''
-  return dateStr.slice(0, 10)
-}
-
-onShow(() => {
-  if (userStore.isLoggedIn) {
-    userStore.fetchProfile()
-    userStore.fetchQuota()
-    userStore.fetchCommissionSummary()
-  }
-})
-
-function goLogin() {
-  uni.navigateTo({ url: '/pages/login/index' })
-}
-
-function goEditProfile() {
-  if (!userStore.isLoggedIn) {
-    uni.navigateTo({ url: '/pages/login/index' })
-    return
-  }
-  uni.navigateTo({ url: '/pages/profile/edit' })
-}
-
-function goPayment() {
-  uni.navigateTo({ url: '/pages/payment/index?product=annual' })
-}
-
-function goRenew() {
-  const product = userStore.vipType === 'practitioner' ? 'practitioner' : 'annual'
-  uni.navigateTo({ url: `/pages/payment/index?product=${product}` })
-}
-
-function goUpgradePractitioner() {
-  uni.navigateTo({ url: '/pages/payment/index?product=practitioner' })
-}
-
-function goRecords() {
-  uni.switchTab({ url: '/pages/records/index' })
-}
-
-function goCommission() {
-  if (!userStore.isLoggedIn) {
-    uni.navigateTo({ url: '/pages/login/index' })
-    return
-  }
-  uni.navigateTo({ url: '/pages/commission/index' })
-}
-
-// EPIC 9: Navigate to practitioner workbench
-function goWorkbench() {
-  uni.navigateTo({ url: '/pages/plan/workbench' })
-}
-
-function copyReferral() {
-  if (userStore.referralCode) {
-    uni.setClipboardData({
-      data: userStore.referralCode,
-      success: () => uni.showToast({ title: '已复制推广码', icon: 'success' })
-    })
-  }
-}
-
-function generatePoster() {
-  uni.showToast({ title: '海报生成中…', icon: 'none' })
-  // US-6.5: Backend generates poster with mini-program code
-}
-
-function goTags() {
-  uni.navigateTo({ url: '/pages/tags/index' })
-}
-
-function goAbout() {
-  uni.navigateTo({ url: '/pages/about/index' })
-}
-
-function handleLogout() {
-  uni.showModal({
-    title: '确认退出',
-    content: '确定要退出登录吗?',
-    success: (res) => {
-      if (res.confirm) {
-        userStore.logout()
-        uni.showToast({ title: '已退出', icon: 'success' })
-      }
-    }
-  })
-}
-</script>
-
-<style scoped lang="scss">
-// Brand colors (aligned with style-guide.md)
-$blue-dark: #1E3A5F;
-$gold: #C9A84C;
-$gold-light: #F8F0D8;
-$warm-white: #F8F6F1;
-$white: #FFFFFF;
-$text-primary: #1F2937;
-$text-secondary: #6B7280;
-$text-muted: #9CA3AF;
-$danger: #EF4444;
-
-.page-profile {
-  padding: 16px;
-  min-height: 100vh;
-  background: $warm-white;
-}
-
-// ========== Region 1: User Card ==========
-.user-card {
-  background: linear-gradient(135deg, $blue-dark, darken($blue-dark, 6%));
-  border-radius: 16px;
-  padding: 20px 18px;
-  margin-bottom: 16px;
-
-  &__login {
-    cursor: pointer;
-    text-align: center;
-    padding: 8px 0;
-  }
-
-  &__logged {
-    cursor: pointer;
-  }
-
-  &__inner {
-    display: flex;
-    align-items: center;
-    gap: 14px;
-  }
-
-  &__info {
-    flex: 1;
-    min-width: 0;
-  }
-
-  &__nickname {
-    font-size: 17px;
-    font-weight: 600;
-    color: $white;
-    display: block;
-  }
-
-  &__login-hint {
-    font-size: 12px;
-    color: rgba(255, 255, 255, 0.7);
-    display: block;
-    margin-top: 4px;
-  }
-
-  &__tag {
-    display: inline-flex;
-    padding: 2px 12px;
-    border-radius: 20px;
-    font-size: 11px;
-    margin-top: 6px;
-
-    &--practitioner {
-      background: $gold;
-      color: $blue-dark;
-      font-weight: 600;
-    }
-
-    &--annual {
-      background: rgba($gold, 0.25);
-      color: $gold;
-    }
-
-    &--normal {
-      background: rgba(255, 255, 255, 0.15);
-      color: rgba(255, 255, 255, 0.8);
-    }
-  }
-
-  &__summary {
-    font-size: 11px;
-    color: rgba(255, 255, 255, 0.55);
-    margin-top: 4px;
-    display: block;
-    overflow: hidden;
-    text-overflow: ellipsis;
-    white-space: nowrap;
-  }
-
-  &__arrow {
-    font-size: 22px;
-    color: rgba(255, 255, 255, 0.3);
-    flex-shrink: 0;
-    padding-left: 4px;
-  }
-}
-
-// Avatar
-.avatar {
-  width: 56px;
-  height: 56px;
-  border-radius: 50%;
-  overflow: hidden;
-  flex-shrink: 0;
-
-  image {
-    width: 100%;
-    height: 100%;
-    border-radius: 50%;
-    border: 2px solid rgba(255, 255, 255, 0.25);
-  }
-
-  &--empty {
-    width: 56px;
-    height: 56px;
-    background: rgba(255, 255, 255, 0.12);
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    border: 2px dashed rgba(255, 255, 255, 0.25);
-  }
-
-  &__icon {
-    font-size: 24px;
-    color: rgba(255, 255, 255, 0.5);
-  }
-}
-
-// ========== Region 2: Membership Status ==========
-
-// Banners
-.banner {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 10px 14px;
-  border-radius: 10px;
-  margin-bottom: 12px;
-  background: #FEF3C7;
-  border: 1px solid #FDE68A;
-
-  &--warning {
-    background: #FEF3C7;
-    border-color: #FDE68A;
-  }
-
-  &__text {
-    font-size: 13px;
-    color: #92400E;
-    flex: 1;
-  }
-
-  &__arrow {
-    font-size: 18px;
-    color: #92400E;
-    margin-left: 8px;
-  }
-}
-
-// Seed-price badge
-.seed-badge {
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  padding: 6px 14px;
-  border-radius: 8px;
-  margin-bottom: 12px;
-  background: $gold-light;
-  border: 1px solid $gold;
-
-  &__text {
-    font-size: 12px;
-    color: $blue-dark;
-    font-weight: 500;
-  }
-}
-
-// VIP status card
-.vip-card {
-  border-radius: 12px;
-  padding: 16px;
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  margin-bottom: 16px;
-
-  &__body {
-    flex: 1;
-  }
-
-  &__title {
-    font-weight: 600;
-    font-size: 16px;
-    display: block;
-  }
-
-  &__desc {
-    font-size: 12px;
-    opacity: 0.8;
-    display: block;
-    margin-top: 4px;
-  }
-
-  &__action {
-    font-size: 14px;
-    font-weight: 500;
-    padding: 6px 14px;
-    border-radius: 8px;
-    white-space: nowrap;
-    cursor: pointer;
-  }
-
-  // Practitioner
-  &--practitioner {
-    background: linear-gradient(135deg, #7c3aed, #6d28d9);
-    .vip-card__title, .vip-card__desc { color: #fff; }
-    .vip-card__action {
-      color: #7c3aed;
-      background: rgba(255, 255, 255, 0.95);
-    }
-  }
-
-  // Annual
-  &--annual {
-    background: linear-gradient(135deg, #f59e0b, #d97706);
-    .vip-card__title, .vip-card__desc { color: #fff; }
-    .vip-card__action {
-      color: #d97706;
-      background: rgba(255, 255, 255, 0.95);
-    }
-  }
-
-  // Expired
-  &--expired {
-    background: #E5E7EB;
-    .vip-card__title { color: $text-secondary; }
-    .vip-card__desc { color: $text-muted; }
-    .vip-card__action {
-      color: $text-secondary;
-      background: $white;
-    }
-  }
-
-  // Upsell (free user — two options)
-  &--upsell {
-    background: linear-gradient(135deg, $blue-dark, darken($blue-dark, 4%));
-    flex-direction: column;
-    align-items: flex-start;
-    gap: 14px;
-
-    .vip-card__body {
-      width: 100%;
-    }
-
-    .vip-card__title {
-      color: $gold;
-      font-size: 16px;
-      margin-bottom: 8px;
-    }
-
-    .vip-card__dual {
-      display: flex;
-      gap: 12px;
-      width: 100%;
-    }
-
-    .vip-card__option {
-      flex: 1;
-      background: rgba(255, 255, 255, 0.06);
-      border: 1px solid rgba(255, 255, 255, 0.08);
-      border-radius: 12px;
-      padding: 14px 12px;
-      display: flex;
-      flex-direction: column;
-      align-items: center;
-      gap: 6px;
-      cursor: pointer;
-    }
-
-    .vip-card__option:active {
-      background: rgba(255, 255, 255, 0.1);
-    }
-
-    .vip-card__option .option-title {
-      font-size: 15px;
-      font-weight: 600;
-      color: rgba(255, 255, 255, 0.9);
-    }
-
-    .vip-card__option .option-price {
-      font-size: 18px;
-      font-weight: 700;
-      color: $gold;
-    }
-
-    .vip-card__option .option-desc {
-      font-size: 11px;
-      color: rgba(255, 255, 255, 0.45);
-    }
-
-    .vip-card__option .option-btn {
-      width: 100%;
-      border: none;
-      border-radius: 8px;
-      padding: 8px 0;
-      font-size: 13px;
-      font-weight: 600;
-      cursor: pointer;
-      margin-top: 4px;
-    }
-
-    .option-btn.light {
-      background: rgba(255, 255, 255, 0.12);
-      color: #fff;
-    }
-
-    .option-btn.primary {
-      background: $gold;
-      color: $blue-dark;
-    }
-
-    .vip-card__option--pro {
-      border-color: rgba($gold, 0.2);
-      background: rgba($gold, 0.06);
-    }
-  }
-}
-
-// Daily quota
-.quota-bar {
-  background: $white;
-  border-radius: 12px;
-  padding: 16px;
-  margin-bottom: 16px;
-  display: flex;
-  align-items: center;
-  justify-content: space-around;
-
-  &__item {
-    text-align: center;
-  }
-
-  &__label {
-    font-size: 12px;
-    color: $text-secondary;
-    display: block;
-    margin-bottom: 4px;
-  }
-
-  &__value {
-    font-size: 20px;
-    font-weight: 700;
-    color: $text-primary;
-  }
-
-  &__divider {
-    width: 1px;
-    height: 36px;
-    background: #E5E7EB;
-  }
-}
-
-// ========== Region 3: Feature Menu ==========
-.menu-section {
-  background: $white;
-  border-radius: 12px;
-  margin-bottom: 16px;
-  overflow: hidden;
-
-  &__header {
-    font-size: 12px;
-    font-weight: 600;
-    color: $text-muted;
-    text-transform: uppercase;
-    letter-spacing: 0.5px;
-    padding: 14px 16px 6px;
-  }
-}
-
-.menu-item {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-  padding: 14px 16px;
-  border-bottom: 1px solid #F3F4F6;
-  cursor: pointer;
-  transition: background 0.15s;
-
-  &:active {
-    background: #F9FAFB;
-  }
-
-  &:last-child {
-    border-bottom: none;
-  }
-
-  &__label {
-    flex: 1;
-    font-size: 15px;
-    color: $text-primary;
-
-    &--danger {
-      color: $danger;
-    }
-  }
-
-  &__badge {
-    font-size: 10px;
-    font-weight: 700;
-    padding: 2px 6px;
-    border-radius: 4px;
-
-    &--new {
-      background: #FEF3C7;
-      color: #D97706;
-    }
-  }
-
-  &__arrow {
-    color: $text-muted;
-    font-size: 18px;
-  }
-
-  &--danger {
-    justify-content: center;
-    border-bottom: none;
-  }
-}
-
-// Promotion tools card
-.promo-card {
-  margin: 0 16px 14px;
-  background: #FFFBEB;
-  border: 1px solid #FDE68A;
-  border-radius: 10px;
-  overflow: hidden;
-
-  &__code-area {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 10px;
-    padding: 14px 16px 8px;
-  }
-
-  &__prefix {
-    font-size: 13px;
-    color: $text-secondary;
-  }
-
-  &__code {
-    font-size: 22px;
-    font-weight: 700;
-    color: $blue-dark;
-    letter-spacing: 3px;
-    font-family: monospace;
-  }
-
-  &__actions {
-    display: flex;
-    align-items: center;
-    justify-content: center;
-    gap: 10px;
-    padding: 6px 16px 14px;
-  }
-
-  &__btn {
-    font-size: 12px;
-    color: $blue-dark;
-    font-weight: 500;
-    cursor: pointer;
-  }
-
-  &__divider {
-    color: #D1D5DB;
-    font-size: 12px;
-  }
-}
+<template>
+  <view class="page-profile">
+    <!-- ========== Region 1: User Card ========== -->
+    <view class="user-card">
+      <!-- Login prompt for unauthenticated users -->
+      <view v-if="!userStore.isLoggedIn" class="user-card__login" @click="goLogin">
+        <view class="avatar avatar--empty">
+          <text class="avatar__icon">+</text>
+        </view>
+        <text class="user-card__nickname">未登录</text>
+        <text class="user-card__login-hint">点击登录 / 注册</text>
+      </view>
+
+      <!-- Authenticated user card — tap to edit -->
+      <view v-else class="user-card__logged" @click="goEditProfile">
+        <view class="user-card__inner">
+          <view class="avatar">
+            <image :src="userStore.avatarUrl || '/static/default-avatar.png'" mode="aspectFill" />
+          </view>
+          <view class="user-card__info">
+            <text class="user-card__nickname">{{ userStore.nickname }}</text>
+
+            <!-- Role tag -->
+            <view
+              class="user-card__tag"
+              :class="{
+                'user-card__tag--practitioner': userStore.vipType === 'practitioner',
+                'user-card__tag--annual': userStore.vipType === 'annual',
+                'user-card__tag--normal': !userStore.vipType
+              }"
+            >
+              <text v-if="!userStore.vipType">普通用户</text>
+              <text v-else-if="userStore.vipType === 'annual'">C端会员</text>
+              <text v-else>能量师 🌟</text>
+            </view>
+
+            <!-- Social summary (optional) -->
+            <text v-if="userStore.bio || userStore.city" class="user-card__summary">
+              {{ userStore.city || '' }}{{ userStore.city && userStore.bio ? ' · ' : '' }}{{ userStore.bio || '' }}
+            </text>
+          </view>
+          <text class="user-card__arrow">›</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- ========== Region 2: Membership Status Cards ========== -->
+
+    <!-- US-5.1 §13: Profile incomplete banner -->
+    <view v-if="userStore.isLoggedIn && userStore.profileIncomplete" class="banner" @click="goEditProfile">
+      <text class="banner__text">📝 请完善个人资料,开启能量匹配</text>
+      <text class="banner__arrow">›</text>
+    </view>
+
+    <!-- US-4.3: Renewal reminder (7 days before expiry) -->
+    <view v-if="isExpiringSoon" class="banner banner--warning" @click="goRenew">
+      <text class="banner__text">⏰ 您的会员即将到期,请及时续费</text>
+      <text class="banner__arrow">›</text>
+    </view>
+
+    <!-- Seed-price badge -->
+    <view v-if="userStore.isSeedPriceUser" class="seed-badge">
+      <text class="seed-badge__text">🌱 种子价专属会员</text>
+    </view>
+
+    <!-- VIP status card — role-based -->
+    <!-- Case 1: Energy master (practitioner, in valid period) -->
+    <view
+      v-if="userStore.vipType === 'practitioner' && userStore.isVip && !isExpired"
+      class="vip-card vip-card--practitioner"
+    >
+      <view class="vip-card__body">
+        <text class="vip-card__title">能量师会员 🌟</text>
+        <text class="vip-card__desc">
+          有效期至 {{ formatDate(userStore.vipEndTime) }}
+        </text>
+      </view>
+      <text class="vip-card__action" @click="goRenew">续费</text>
+    </view>
+
+    <!-- Case 2: C-end annual subscriber (in valid period) -->
+    <view
+      v-else-if="userStore.vipType === 'annual' && userStore.isVip && !isExpired"
+      class="vip-card vip-card--annual"
+    >
+      <view class="vip-card__body">
+        <text class="vip-card__title">C端会员</text>
+        <text class="vip-card__desc">
+          有效期至 {{ formatDate(userStore.vipEndTime) }}
+        </text>
+      </view>
+      <text class="vip-card__action" @click="goUpgradePractitioner">升级能量师</text>
+    </view>
+
+    <!-- Case 3: Expired member (vipEndTime < now) -->
+    <view v-else-if="userStore.vipType && isExpired" class="vip-card vip-card--expired">
+      <view class="vip-card__body">
+        <text class="vip-card__title">会员已过期</text>
+        <text class="vip-card__desc">
+          过期时间 {{ formatDate(userStore.vipEndTime) }}
+        </text>
+      </view>
+      <text class="vip-card__action" @click="goPayment">重新开通</text>
+    </view>
+
+    <!-- Case 4: Free user — two payment options -->
+    <view v-else class="vip-card vip-card--upsell">
+      <view class="vip-card__body">
+        <text class="vip-card__title">选择方案</text>
+        <view class="vip-card__dual">
+          <view class="vip-card__option" @click="goPayment">
+            <text class="option-title">C端会员</text>
+            <text class="option-price">¥131/年</text>
+            <text class="option-desc">无限AI解读</text>
+            <button class="option-btn light">开通</button>
+          </view>
+          <view class="vip-card__option vip-card__option--pro" @click="goUpgradePractitioner">
+            <text class="option-title">能量师</text>
+            <text class="option-price">¥1,314/年</text>
+            <text class="option-desc">完整功能+分销</text>
+            <button class="option-btn primary">申请</button>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <!-- Daily quota (non-VIP only) -->
+    <view v-if="!userStore.isVip" class="quota-bar">
+      <view class="quota-bar__item">
+        <text class="quota-bar__label">今日分析</text>
+        <text class="quota-bar__value">{{ userStore.usedCharts }}/{{ userStore.maxCharts }}</text>
+      </view>
+      <view class="quota-bar__divider"></view>
+      <view class="quota-bar__item">
+        <text class="quota-bar__label">今日解读</text>
+        <text class="quota-bar__value">{{ userStore.usedChats }}/{{ userStore.maxChats }}</text>
+      </view>
+    </view>
+
+    <!-- ========== Region 3: Feature Menu ========== -->
+
+    <!-- Group 1: My Services -->
+    <view class="menu-section">
+      <view class="menu-section__header">我的服务</view>
+
+      <!-- Analysis history (all logged-in) -->
+      <view v-if="userStore.isLoggedIn" class="menu-item" @click="goRecords">
+        <text class="menu-item__label">📋 我的分析</text>
+        <text class="menu-item__arrow">›</text>
+      </view>
+
+      <!-- Distribution panel (paid users only) -->
+      <view v-if="userStore.isLoggedIn && userStore.isVip" class="menu-item" @click="goCommission">
+        <text class="menu-item__label">💰 我的推广</text>
+        <text class="menu-item__arrow">›</text>
+      </view>
+
+      <!-- Practitioner workbench (practitioners only) — EPIC 9 -->
+      <view v-if="userStore.vipType === 'practitioner' && userStore.isVip" class="menu-item" @click="goWorkbench">
+        <text class="menu-item__label">🔧 工作台</text>
+        <view class="menu-item__badge menu-item__badge--new">NEW</view>
+        <text class="menu-item__arrow">›</text>
+      </view>
+    </view>
+
+    <!-- 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="promo-card">
+        <view class="promo-card__code-area">
+          <text class="promo-card__prefix">推广码</text>
+          <text class="promo-card__code">{{ userStore.referralCode }}</text>
+        </view>
+        <view class="promo-card__actions">
+          <text class="promo-card__btn" @click="copyReferral">复制推广码</text>
+          <text class="promo-card__divider">|</text>
+          <text class="promo-card__btn" @click="generatePoster">生成推广海报</text>
+        </view>
+      </view>
+    </view>
+
+    <!-- Group 3: More -->
+    <view class="menu-section">
+      <view class="menu-section__header">更多</view>
+
+      <!-- My tags (practitioner only, P2) -->
+      <view v-if="userStore.vipType === 'practitioner'" class="menu-item" @click="goTags">
+        <text class="menu-item__label">🏷 我的标签</text>
+        <text class="menu-item__arrow">›</text>
+      </view>
+
+      <!-- About us (all) -->
+      <view class="menu-item" @click="goAbout">
+        <text class="menu-item__label">ℹ️ 关于我们</text>
+        <text class="menu-item__arrow">›</text>
+      </view>
+    </view>
+
+    <!-- ========== Region 4: Logout ========== -->
+    <view v-if="userStore.isLoggedIn" class="menu-section">
+      <view class="menu-item menu-item--danger" @click="handleLogout">
+        <text class="menu-item__label menu-item__label--danger">退出登录</text>
+      </view>
+    </view>
+
+    <!-- Hidden canvas for poster generation -->
+    <canvas canvas-id="posterCanvas" id="posterCanvas" class="poster-canvas" />
+  </view>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { useUserStore } from '@/stores/user'
+import { profileApi } from '@/utils/api'
+
+const userStore = useUserStore()
+
+// US-4.3: Renewal reminder if expiry within 7 days
+const isExpiringSoon = computed(() => {
+  if (!userStore.vipEndTime) return false
+  const expiry = new Date(userStore.vipEndTime)
+  const now = new Date()
+  const diffHours = (expiry - now) / (1000 * 60 * 60)
+  return diffHours > 0 && diffHours <= 7 * 24
+})
+
+// US-4.3: Expired check
+const isExpired = computed(() => {
+  if (!userStore.vipEndTime) return false
+  return new Date(userStore.vipEndTime) < new Date()
+})
+
+function formatDate(dateStr) {
+  if (!dateStr) return ''
+  return dateStr.slice(0, 10)
+}
+
+onShow(() => {
+  if (userStore.isLoggedIn) {
+    userStore.fetchProfile()
+    userStore.fetchQuota()
+    userStore.fetchCommissionSummary()
+  }
+})
+
+function goLogin() {
+  uni.navigateTo({ url: '/pages/login/index' })
+}
+
+function goEditProfile() {
+  if (!userStore.isLoggedIn) {
+    uni.navigateTo({ url: '/pages/login/index' })
+    return
+  }
+  uni.navigateTo({ url: '/pages/profile/edit' })
+}
+
+function goPayment() {
+  uni.navigateTo({ url: '/pages/payment/index?product=annual' })
+}
+
+function goRenew() {
+  const product = userStore.vipType === 'practitioner' ? 'practitioner' : 'annual'
+  uni.navigateTo({ url: `/pages/payment/index?product=${product}` })
+}
+
+function goUpgradePractitioner() {
+  uni.navigateTo({ url: '/pages/payment/index?product=practitioner' })
+}
+
+function goRecords() {
+  uni.switchTab({ url: '/pages/records/index' })
+}
+
+function goCommission() {
+  if (!userStore.isLoggedIn) {
+    uni.navigateTo({ url: '/pages/login/index' })
+    return
+  }
+  uni.navigateTo({ url: '/pages/commission/index' })
+}
+
+// EPIC 9: Navigate to practitioner workbench
+function goWorkbench() {
+  uni.navigateTo({ url: '/pages/plan/workbench' })
+}
+
+function copyReferral() {
+  if (userStore.referralCode) {
+    uni.setClipboardData({
+      data: userStore.referralCode,
+      success: () => uni.showToast({ title: '已复制推广码', icon: 'success' })
+    })
+  }
+}
+
+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', getCurrentInstance())
+    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)
+    }
+
+    // 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' })
+        }
+      }, getCurrentInstance())
+    }, 150)
+
+  }).catch(e => {
+    console.error('generatePoster error:', e)
+    uni.hideLoading()
+    uni.showToast({ title: '生成失败,请重试', icon: 'none' })
+  })
+}
+
+function downloadImage(url) {
+  if (!url) return Promise.resolve('')
+  return new Promise((resolve) => {
+    uni.downloadFile({
+      url,
+      success: (res) => resolve(res.tempFilePath),
+      fail: () => resolve('')
+    })
+  })
+}
+
+function goTags() {
+  uni.navigateTo({ url: '/pages/tags/index' })
+}
+
+function goAbout() {
+  uni.navigateTo({ url: '/pages/about/index' })
+}
+
+function handleLogout() {
+  uni.showModal({
+    title: '确认退出',
+    content: '确定要退出登录吗?',
+    success: (res) => {
+      if (res.confirm) {
+        userStore.logout()
+        uni.showToast({ title: '已退出', icon: 'success' })
+      }
+    }
+  })
+}
+</script>
+
+<style scoped lang="scss">
+// Brand colors (aligned with style-guide.md)
+$blue-dark: #1E3A5F;
+$gold: #C9A84C;
+$gold-light: #F8F0D8;
+$warm-white: #F8F6F1;
+$white: #FFFFFF;
+$text-primary: #1F2937;
+$text-secondary: #6B7280;
+$text-muted: #9CA3AF;
+$danger: #EF4444;
+
+.page-profile {
+  padding: 16px;
+  min-height: 100vh;
+  background: $warm-white;
+}
+
+// ========== Region 1: User Card ==========
+.user-card {
+  background: linear-gradient(135deg, $blue-dark, darken($blue-dark, 6%));
+  border-radius: 16px;
+  padding: 20px 18px;
+  margin-bottom: 16px;
+
+  &__login {
+    cursor: pointer;
+    text-align: center;
+    padding: 8px 0;
+  }
+
+  &__logged {
+    cursor: pointer;
+  }
+
+  &__inner {
+    display: flex;
+    align-items: center;
+    gap: 14px;
+  }
+
+  &__info {
+    flex: 1;
+    min-width: 0;
+  }
+
+  &__nickname {
+    font-size: 17px;
+    font-weight: 600;
+    color: $white;
+    display: block;
+  }
+
+  &__login-hint {
+    font-size: 12px;
+    color: rgba(255, 255, 255, 0.7);
+    display: block;
+    margin-top: 4px;
+  }
+
+  &__tag {
+    display: inline-flex;
+    padding: 2px 12px;
+    border-radius: 20px;
+    font-size: 11px;
+    margin-top: 6px;
+
+    &--practitioner {
+      background: $gold;
+      color: $blue-dark;
+      font-weight: 600;
+    }
+
+    &--annual {
+      background: rgba($gold, 0.25);
+      color: $gold;
+    }
+
+    &--normal {
+      background: rgba(255, 255, 255, 0.15);
+      color: rgba(255, 255, 255, 0.8);
+    }
+  }
+
+  &__summary {
+    font-size: 11px;
+    color: rgba(255, 255, 255, 0.55);
+    margin-top: 4px;
+    display: block;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+  }
+
+  &__arrow {
+    font-size: 22px;
+    color: rgba(255, 255, 255, 0.3);
+    flex-shrink: 0;
+    padding-left: 4px;
+  }
+}
+
+// Avatar
+.avatar {
+  width: 56px;
+  height: 56px;
+  border-radius: 50%;
+  overflow: hidden;
+  flex-shrink: 0;
+
+  image {
+    width: 100%;
+    height: 100%;
+    border-radius: 50%;
+    border: 2px solid rgba(255, 255, 255, 0.25);
+  }
+
+  &--empty {
+    width: 56px;
+    height: 56px;
+    background: rgba(255, 255, 255, 0.12);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 2px dashed rgba(255, 255, 255, 0.25);
+  }
+
+  &__icon {
+    font-size: 24px;
+    color: rgba(255, 255, 255, 0.5);
+  }
+}
+
+// ========== Region 2: Membership Status ==========
+
+// Banners
+.banner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 10px 14px;
+  border-radius: 10px;
+  margin-bottom: 12px;
+  background: #FEF3C7;
+  border: 1px solid #FDE68A;
+
+  &--warning {
+    background: #FEF3C7;
+    border-color: #FDE68A;
+  }
+
+  &__text {
+    font-size: 13px;
+    color: #92400E;
+    flex: 1;
+  }
+
+  &__arrow {
+    font-size: 18px;
+    color: #92400E;
+    margin-left: 8px;
+  }
+}
+
+// Seed-price badge
+.seed-badge {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 6px 14px;
+  border-radius: 8px;
+  margin-bottom: 12px;
+  background: $gold-light;
+  border: 1px solid $gold;
+
+  &__text {
+    font-size: 12px;
+    color: $blue-dark;
+    font-weight: 500;
+  }
+}
+
+// VIP status card
+.vip-card {
+  border-radius: 12px;
+  padding: 16px;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 16px;
+
+  &__body {
+    flex: 1;
+  }
+
+  &__title {
+    font-weight: 600;
+    font-size: 16px;
+    display: block;
+  }
+
+  &__desc {
+    font-size: 12px;
+    opacity: 0.8;
+    display: block;
+    margin-top: 4px;
+  }
+
+  &__action {
+    font-size: 14px;
+    font-weight: 500;
+    padding: 6px 14px;
+    border-radius: 8px;
+    white-space: nowrap;
+    cursor: pointer;
+  }
+
+  // Practitioner
+  &--practitioner {
+    background: linear-gradient(135deg, #7c3aed, #6d28d9);
+    .vip-card__title, .vip-card__desc { color: #fff; }
+    .vip-card__action {
+      color: #7c3aed;
+      background: rgba(255, 255, 255, 0.95);
+    }
+  }
+
+  // Annual
+  &--annual {
+    background: linear-gradient(135deg, #f59e0b, #d97706);
+    .vip-card__title, .vip-card__desc { color: #fff; }
+    .vip-card__action {
+      color: #d97706;
+      background: rgba(255, 255, 255, 0.95);
+    }
+  }
+
+  // Expired
+  &--expired {
+    background: #E5E7EB;
+    .vip-card__title { color: $text-secondary; }
+    .vip-card__desc { color: $text-muted; }
+    .vip-card__action {
+      color: $text-secondary;
+      background: $white;
+    }
+  }
+
+  // Upsell (free user — two options)
+  &--upsell {
+    background: linear-gradient(135deg, $blue-dark, darken($blue-dark, 4%));
+    flex-direction: column;
+    align-items: flex-start;
+    gap: 14px;
+
+    .vip-card__body {
+      width: 100%;
+    }
+
+    .vip-card__title {
+      color: $gold;
+      font-size: 16px;
+      margin-bottom: 8px;
+    }
+
+    .vip-card__dual {
+      display: flex;
+      gap: 12px;
+      width: 100%;
+    }
+
+    .vip-card__option {
+      flex: 1;
+      background: rgba(255, 255, 255, 0.06);
+      border: 1px solid rgba(255, 255, 255, 0.08);
+      border-radius: 12px;
+      padding: 14px 12px;
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      gap: 6px;
+      cursor: pointer;
+    }
+
+    .vip-card__option:active {
+      background: rgba(255, 255, 255, 0.1);
+    }
+
+    .vip-card__option .option-title {
+      font-size: 15px;
+      font-weight: 600;
+      color: rgba(255, 255, 255, 0.9);
+    }
+
+    .vip-card__option .option-price {
+      font-size: 18px;
+      font-weight: 700;
+      color: $gold;
+    }
+
+    .vip-card__option .option-desc {
+      font-size: 11px;
+      color: rgba(255, 255, 255, 0.45);
+    }
+
+    .vip-card__option .option-btn {
+      width: 100%;
+      border: none;
+      border-radius: 8px;
+      padding: 8px 0;
+      font-size: 13px;
+      font-weight: 600;
+      cursor: pointer;
+      margin-top: 4px;
+    }
+
+    .option-btn.light {
+      background: rgba(255, 255, 255, 0.12);
+      color: #fff;
+    }
+
+    .option-btn.primary {
+      background: $gold;
+      color: $blue-dark;
+    }
+
+    .vip-card__option--pro {
+      border-color: rgba($gold, 0.2);
+      background: rgba($gold, 0.06);
+    }
+  }
+}
+
+// Daily quota
+.quota-bar {
+  background: $white;
+  border-radius: 12px;
+  padding: 16px;
+  margin-bottom: 16px;
+  display: flex;
+  align-items: center;
+  justify-content: space-around;
+
+  &__item {
+    text-align: center;
+  }
+
+  &__label {
+    font-size: 12px;
+    color: $text-secondary;
+    display: block;
+    margin-bottom: 4px;
+  }
+
+  &__value {
+    font-size: 20px;
+    font-weight: 700;
+    color: $text-primary;
+  }
+
+  &__divider {
+    width: 1px;
+    height: 36px;
+    background: #E5E7EB;
+  }
+}
+
+// ========== Region 3: Feature Menu ==========
+.menu-section {
+  background: $white;
+  border-radius: 12px;
+  margin-bottom: 16px;
+  overflow: hidden;
+
+  &__header {
+    font-size: 12px;
+    font-weight: 600;
+    color: $text-muted;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    padding: 14px 16px 6px;
+  }
+}
+
+.menu-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 14px 16px;
+  border-bottom: 1px solid #F3F4F6;
+  cursor: pointer;
+  transition: background 0.15s;
+
+  &:active {
+    background: #F9FAFB;
+  }
+
+  &:last-child {
+    border-bottom: none;
+  }
+
+  &__label {
+    flex: 1;
+    font-size: 15px;
+    color: $text-primary;
+
+    &--danger {
+      color: $danger;
+    }
+  }
+
+  &__badge {
+    font-size: 10px;
+    font-weight: 700;
+    padding: 2px 6px;
+    border-radius: 4px;
+
+    &--new {
+      background: #FEF3C7;
+      color: #D97706;
+    }
+  }
+
+  &__arrow {
+    color: $text-muted;
+    font-size: 18px;
+  }
+
+  &--danger {
+    justify-content: center;
+    border-bottom: none;
+  }
+}
+
+// Promotion tools card
+.promo-card {
+  margin: 0 16px 14px;
+  background: #FFFBEB;
+  border: 1px solid #FDE68A;
+  border-radius: 10px;
+  overflow: hidden;
+
+  &__code-area {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    padding: 14px 16px 8px;
+  }
+
+  &__prefix {
+    font-size: 13px;
+    color: $text-secondary;
+  }
+
+  &__code {
+    font-size: 22px;
+    font-weight: 700;
+    color: $blue-dark;
+    letter-spacing: 3px;
+    font-family: monospace;
+  }
+
+  &__actions {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    gap: 10px;
+    padding: 6px 16px 14px;
+  }
+
+  &__btn {
+    font-size: 12px;
+    color: $blue-dark;
+    font-weight: 500;
+    cursor: pointer;
+  }
+
+  &__divider {
+    color: #D1D5DB;
+    font-size: 12px;
+  }
+}
+
+.poster-canvas {
+  position: fixed;
+  left: -9999px;
+  top: -9999px;
+  width: 375px;
+  height: 670px;
+}
 </style>

+ 196 - 195
client/utils/api.js

@@ -1,195 +1,196 @@
-// API 服务器地址 - 开发环境使用本地地址,生产环境请替换为实际域名
-// 微信开发者工具需勾选: 设置 → 安全 → 不校验合法域名
-const API_BASE_URL = 'http://127.0.0.1:8186/api'
-const TIMEOUT = 30000
-
-async function request(method, url, data) {
-  const token = uni.getStorageSync('token')
-  const header = { 'Content-Type': 'application/json' }
-  if (token) {
-    header.Authorization = `Bearer ${token}`
-  }
-
-  return new Promise((resolve, reject) => {
-    uni.request({
-      url: API_BASE_URL + url,
-      method,
-      data,
-      header,
-      timeout: TIMEOUT,
-      success(res) {
-        const response = res.data
-        if (response.code !== 0) {
-          uni.showToast({ title: response.message || '请求失败', icon: 'none' })
-          if (response.code === 1001) {
-            uni.removeStorageSync('token')
-            // 跳转到登录页,避免 switchTab 超时问题
-            setTimeout(() => {
-              uni.reLaunch({ url: '/pages/login/index' })
-            }, 500)
-          }
-          const err = new Error(response.message || '请求失败')
-          err.code = response.code
-          reject(err)
-        } else {
-          resolve(response.data)
-        }
-      },
-      fail(err) {
-        uni.showToast({ title: '网络错误', icon: 'none' })
-        reject(err)
-      }
-    })
-  })
-}
-
-const requestInstance = {
-  get: (url, data) => request('GET', url, data),
-  post: (url, data) => request('POST', url, data),
-  put: (url, data) => request('PUT', url, data),
-  delete: (url, data) => request('DELETE', url, data)
-}
-
-export default requestInstance
-
-export const authApi = {
-  login: (data) => requestInstance.post('/auth/login', data)
-}
-
-export const consultationApi = {
-  start: (data) => requestInstance.post('/consultation/start', data),
-  check: (data) => requestInstance.post('/consultation/check', data),
-  regenerate: (chartRecordId) => requestInstance.post('/consultation/regenerate', { chartRecordId })
-}
-
-export const chartApi = {
-  create: (data) => requestInstance.post('/chart/create', data),
-  list: () => requestInstance.post('/chart/list', {}),
-  detail: (id) => requestInstance.post('/chart/detail', { id }),
-  delete: (id) => requestInstance.post('/chart/delete', { id }),
-  interpret: (chartRecordId) => requestInstance.post('/chart/interpret', { chartRecordId }),
-  academicOrientation: (chartRecordId) => requestInstance.post('/chart/academic-orientation', { chartRecordId })
-}
-
-export const chatApi = {
-  send: (data) => requestInstance.post('/chat/send', data),
-  history: (chartRecordId) => requestInstance.post('/chat/history', { chartRecordId }),
-  voiceUpload: (filePath, chartRecordId) => {
-    const token = uni.getStorageSync('token')
-    return new Promise((resolve, reject) => {
-      uni.uploadFile({
-        url: API_BASE_URL + '/chat/voice',
-        filePath,
-        name: 'file',
-        formData: { chartRecordId: String(chartRecordId || '') },
-        header: token ? { Authorization: `Bearer ${token}` } : {},
-        success: (res) => {
-          try {
-            const data = JSON.parse(res.data)
-            if (data.code === 0) resolve(data.data)
-            else reject(new Error(data.message || '上传失败'))
-          } catch (e) { reject(new Error('解析响应失败')) }
-        },
-        fail: (err) => reject(err)
-      })
-    })
-  }
-}
-
-export const payApi = {
-  create: (data) => requestInstance.post('/pay/create', data)
-}
-
-export const pricingApi = {
-  current: () => requestInstance.post('/pricing/current', {}),
-  upgrade: () => requestInstance.post('/pricing/upgrade', {})
-}
-
-export const planApi = {
-  apply: (data) => requestInstance.post('/plan/apply', data),
-  myRequests: () => requestInstance.post('/plan/requests/my', {})
-}
-
-export const workbenchApi = {
-  list: () => requestInstance.post('/plan/requests/incoming', {}),
-  accept: (requestId) => requestInstance.post('/plan/requests/accept', { requestId }),
-  reject: (requestId) => requestInstance.post('/plan/requests/reject', { requestId })
-}
-
-export const proposalApi = {
-  create: (data) => requestInstance.post('/plan/proposals/create', data),
-  counter: (data) => requestInstance.post('/plan/proposals/counter', data),
-  accept: (data) => requestInstance.post('/plan/proposals/accept', data),
-  reject: (data) => requestInstance.post('/plan/proposals/reject', data),
-  logs: (requestId) => requestInstance.post('/plan/proposals/logs', { requestId })
-}
-
-export const profileApi = {
-  info: () => requestInstance.post('/profile/info', {}),
-  vipStatus: () => requestInstance.post('/profile/vip-status', {}),
-  quota: () => requestInstance.post('/profile/quota', {}),
-  commissionSummary: () => requestInstance.post('/profile/commission-summary', {}),
-  chartCount: () => requestInstance.post('/profile/chart-count', {}),
-  trackShare: () => requestInstance.post('/profile/share/track', {}),
-  complete: (data) => requestInstance.post('/profile/complete', data),
-  update: (data) => requestInstance.post('/profile/update', data)
-}
-
-export const annotationApi = {
-  add: (data) => requestInstance.post('/annotation/add', data),
-  list: (recordId) => requestInstance.post('/annotation/list', { recordId }),
-  delete: (id) => requestInstance.post('/annotation/delete', { id }),
-  addTag: (data) => requestInstance.post('/annotation/tag/add', data),
-  listTags: (recordId) => requestInstance.post('/annotation/tag/list', { recordId }),
-  deleteTag: (id) => requestInstance.post('/annotation/tag/delete', { id })
-}
-
-export const commissionApi = {
-  list: () => requestInstance.post('/commission/list', {}),
-  total: () => requestInstance.post('/commission/total', {}),
-  team: () => requestInstance.post('/commission/team', {}),
-  stats: () => requestInstance.post('/commission/stats', {})
-}
-
-export const withdrawApi = {
-  apply: (amount) => requestInstance.post('/withdraw/apply', { amount }),
-  list: () => requestInstance.post('/withdraw/list', {}),
-  balance: () => requestInstance.post('/withdraw/balance', {})
-}
-export const interventionApi = {
-  chatList: () => requestInstance.post('/intervention/chat-list', {}),
-  history: (chartRecordId) => requestInstance.post('/intervention/chat-history', { chartRecordId }),
-  start: (chartRecordId) => requestInstance.post('/intervention/start', { chartRecordId }),
-  end: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'practitioner' }),
-  endByUser: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'user' }),
-  send: (data) => requestInstance.post('/intervention/send', data)
-}
-
-export const deliveryApi = {
-  create: (data) => requestInstance.post('/plan/delivery/create', data),
-  list: (planRequestId) => requestInstance.post('/plan/delivery/list', { planRequestId })
-}
-
-export const exportApi = {
-  pdf: (recordId) => {
-    const token = uni.getStorageSync('token')
-    return new Promise((resolve, reject) => {
-      uni.downloadFile({
-        url: API_BASE_URL + '/export/pdf',
-        method: 'POST',
-        data: { recordId },
-        header: token ? { Authorization: `Bearer ${token}` } : {},
-        success: (res) => {
-          if (res.statusCode === 200) {
-            resolve(res.tempFilePath)
-          } else {
-            reject(new Error('导出失败: ' + res.statusCode))
-          }
-        },
-        fail: (err) => {
-          reject(err)
-        }
-      })
-    })
-  }
-}
+// API 服务器地址 - 开发环境使用本地地址,生产环境请替换为实际域名
+// 微信开发者工具需勾选: 设置 → 安全 → 不校验合法域名
+const API_BASE_URL = 'http://127.0.0.1:8186/api'
+const TIMEOUT = 30000
+
+async function request(method, url, data) {
+  const token = uni.getStorageSync('token')
+  const header = { 'Content-Type': 'application/json' }
+  if (token) {
+    header.Authorization = `Bearer ${token}`
+  }
+
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: API_BASE_URL + url,
+      method,
+      data,
+      header,
+      timeout: TIMEOUT,
+      success(res) {
+        const response = res.data
+        if (response.code !== 0) {
+          uni.showToast({ title: response.message || '请求失败', icon: 'none' })
+          if (response.code === 1001) {
+            uni.removeStorageSync('token')
+            // 跳转到登录页,避免 switchTab 超时问题
+            setTimeout(() => {
+              uni.reLaunch({ url: '/pages/login/index' })
+            }, 500)
+          }
+          const err = new Error(response.message || '请求失败')
+          err.code = response.code
+          reject(err)
+        } else {
+          resolve(response.data)
+        }
+      },
+      fail(err) {
+        uni.showToast({ title: '网络错误', icon: 'none' })
+        reject(err)
+      }
+    })
+  })
+}
+
+const requestInstance = {
+  get: (url, data) => request('GET', url, data),
+  post: (url, data) => request('POST', url, data),
+  put: (url, data) => request('PUT', url, data),
+  delete: (url, data) => request('DELETE', url, data)
+}
+
+export default requestInstance
+
+export const authApi = {
+  login: (data) => requestInstance.post('/auth/login', data)
+}
+
+export const consultationApi = {
+  start: (data) => requestInstance.post('/consultation/start', data),
+  check: (data) => requestInstance.post('/consultation/check', data),
+  regenerate: (chartRecordId) => requestInstance.post('/consultation/regenerate', { chartRecordId })
+}
+
+export const chartApi = {
+  create: (data) => requestInstance.post('/chart/create', data),
+  list: () => requestInstance.post('/chart/list', {}),
+  detail: (id) => requestInstance.post('/chart/detail', { id }),
+  delete: (id) => requestInstance.post('/chart/delete', { id }),
+  interpret: (chartRecordId) => requestInstance.post('/chart/interpret', { chartRecordId }),
+  academicOrientation: (chartRecordId) => requestInstance.post('/chart/academic-orientation', { chartRecordId })
+}
+
+export const chatApi = {
+  send: (data) => requestInstance.post('/chat/send', data),
+  history: (chartRecordId) => requestInstance.post('/chat/history', { chartRecordId }),
+  voiceUpload: (filePath, chartRecordId) => {
+    const token = uni.getStorageSync('token')
+    return new Promise((resolve, reject) => {
+      uni.uploadFile({
+        url: API_BASE_URL + '/chat/voice',
+        filePath,
+        name: 'file',
+        formData: { chartRecordId: String(chartRecordId || '') },
+        header: token ? { Authorization: `Bearer ${token}` } : {},
+        success: (res) => {
+          try {
+            const data = JSON.parse(res.data)
+            if (data.code === 0) resolve(data.data)
+            else reject(new Error(data.message || '上传失败'))
+          } catch (e) { reject(new Error('解析响应失败')) }
+        },
+        fail: (err) => reject(err)
+      })
+    })
+  }
+}
+
+export const payApi = {
+  create: (data) => requestInstance.post('/pay/create', data)
+}
+
+export const pricingApi = {
+  current: () => requestInstance.post('/pricing/current', {}),
+  upgrade: () => requestInstance.post('/pricing/upgrade', {})
+}
+
+export const planApi = {
+  apply: (data) => requestInstance.post('/plan/apply', data),
+  myRequests: () => requestInstance.post('/plan/requests/my', {})
+}
+
+export const workbenchApi = {
+  list: () => requestInstance.post('/plan/requests/incoming', {}),
+  accept: (requestId) => requestInstance.post('/plan/requests/accept', { requestId }),
+  reject: (requestId) => requestInstance.post('/plan/requests/reject', { requestId })
+}
+
+export const proposalApi = {
+  create: (data) => requestInstance.post('/plan/proposals/create', data),
+  counter: (data) => requestInstance.post('/plan/proposals/counter', data),
+  accept: (data) => requestInstance.post('/plan/proposals/accept', data),
+  reject: (data) => requestInstance.post('/plan/proposals/reject', data),
+  logs: (requestId) => requestInstance.post('/plan/proposals/logs', { requestId })
+}
+
+export const profileApi = {
+  info: () => requestInstance.post('/profile/info', {}),
+  vipStatus: () => requestInstance.post('/profile/vip-status', {}),
+  quota: () => requestInstance.post('/profile/quota', {}),
+  commissionSummary: () => requestInstance.post('/profile/commission-summary', {}),
+  chartCount: () => requestInstance.post('/profile/chart-count', {}),
+  trackShare: () => requestInstance.post('/profile/share/track', {}),
+  complete: (data) => requestInstance.post('/profile/complete', data),
+  update: (data) => requestInstance.post('/profile/update', data),
+  qrcode: () => requestInstance.get('/profile/qrcode', {})
+}
+
+export const annotationApi = {
+  add: (data) => requestInstance.post('/annotation/add', data),
+  list: (recordId) => requestInstance.post('/annotation/list', { recordId }),
+  delete: (id) => requestInstance.post('/annotation/delete', { id }),
+  addTag: (data) => requestInstance.post('/annotation/tag/add', data),
+  listTags: (recordId) => requestInstance.post('/annotation/tag/list', { recordId }),
+  deleteTag: (id) => requestInstance.post('/annotation/tag/delete', { id })
+}
+
+export const commissionApi = {
+  list: () => requestInstance.post('/commission/list', {}),
+  total: () => requestInstance.post('/commission/total', {}),
+  team: () => requestInstance.post('/commission/team', {}),
+  stats: () => requestInstance.post('/commission/stats', {})
+}
+
+export const withdrawApi = {
+  apply: (amount) => requestInstance.post('/withdraw/apply', { amount }),
+  list: () => requestInstance.post('/withdraw/list', {}),
+  balance: () => requestInstance.post('/withdraw/balance', {})
+}
+export const interventionApi = {
+  chatList: () => requestInstance.post('/intervention/chat-list', {}),
+  history: (chartRecordId) => requestInstance.post('/intervention/chat-history', { chartRecordId }),
+  start: (chartRecordId) => requestInstance.post('/intervention/start', { chartRecordId }),
+  end: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'practitioner' }),
+  endByUser: (interventionId) => requestInstance.post('/intervention/end', { interventionId, endedBy: 'user' }),
+  send: (data) => requestInstance.post('/intervention/send', data)
+}
+
+export const deliveryApi = {
+  create: (data) => requestInstance.post('/plan/delivery/create', data),
+  list: (planRequestId) => requestInstance.post('/plan/delivery/list', { planRequestId })
+}
+
+export const exportApi = {
+  pdf: (recordId) => {
+    const token = uni.getStorageSync('token')
+    return new Promise((resolve, reject) => {
+      uni.downloadFile({
+        url: API_BASE_URL + '/export/pdf',
+        method: 'POST',
+        data: { recordId },
+        header: token ? { Authorization: `Bearer ${token}` } : {},
+        success: (res) => {
+          if (res.statusCode === 200) {
+            resolve(res.tempFilePath)
+          } else {
+            reject(new Error('导出失败: ' + res.statusCode))
+          }
+        },
+        fail: (err) => {
+          reject(err)
+        }
+      })
+    })
+  }
+}