Browse Source

feat: 新增积分不足弹窗组件及微信登录入口

- 新增 CreditInsufficientModal 积分不足弹窗组件
- 新增 useCreditInsufficient composable
- 新增微信登录入口页面 wechat-entry.vue
- 优化 request.ts 请求配置
- 更新 pages.json 路由配置

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
MyFramework User 3 months ago
parent
commit
204c50f374

BIN
book-create-page.png


+ 91 - 7
my-uniapp-vue3/src/App.vue

@@ -5,10 +5,20 @@ import { useUserStore } from './store/user';
 import { initDebug } from './utils/debug';
 import { initAnalytics } from './utils/analytics';
 import MiniPlayer from './components/MiniPlayer.vue';
+import CreditInsufficientModal from './components/CreditInsufficientModal.vue';
 
 // 初始化全局调试工具
 initDebug();
 
+// ==================== 微信环境检测 ====================
+function isInWechatBrowser(): boolean {
+  // #ifdef H5
+  const ua = navigator.userAgent.toLowerCase();
+  return ua.includes('micromessenger');
+  // #endif
+  return false;
+}
+
 // ==================== 微信 OAuth 回调处理 ====================
 async function handleWechatOAuthCallback() {
   // 只在 H5 环境处理
@@ -17,7 +27,11 @@ async function handleWechatOAuthCallback() {
   const code = searchParams.get('code');
   const state = searchParams.get('state');
 
-  if (!code) return; // 不是 OAuth 回调,跳过
+  console.log('[App] handleWechatOAuthCallback 触发', { code: !!code, fullUrl: window.location.href });
+  if (!code) {
+    console.log('[App] 无 code 参数,跳过 OAuth 处理');
+    return; // 不是 OAuth 回调,跳过
+  }
 
   console.log('[App] 检测到微信 OAuth 回调,处理 code...');
   uni.showLoading({ title: '授权中...' });
@@ -35,18 +49,15 @@ async function handleWechatOAuthCallback() {
         await userStore.loginByOpenid(result.openid);
         console.log('[App] OpenID 已绑定,自动登录成功');
 
-        // 清除 URL 参数并跳转首
+        // 清除 URL 参数,停留在当前
         searchParams.delete('code');
         searchParams.delete('state');
         const newSearch = searchParams.toString();
         const newUrl = window.location.origin + window.location.pathname
-          + (newSearch ? '?' + newSearch : '');
+          + (newSearch ? '?' + newSearch : '') + window.location.hash;
         window.history.replaceState({}, '', newUrl);
         uni.hideLoading();
         uni.showToast({ title: '自动登录成功', icon: 'success' });
-        setTimeout(() => {
-          uni.switchTab({ url: '/pages/index/index' });
-        }, 500);
         return;
       } catch (e: any) {
         if (e?.message?.includes('未绑定') || e?.code === -1) {
@@ -80,7 +91,7 @@ async function handleWechatOAuthCallback() {
         searchParams.delete('state');
         const newSearch = searchParams.toString();
         const newUrl = window.location.origin + window.location.pathname
-          + (newSearch ? '?' + newSearch : '');
+          + (newSearch ? '?' + newSearch : '') + window.location.hash;
         window.history.replaceState({}, '', newUrl);
       }
     }
@@ -112,8 +123,80 @@ onLaunch(() => {
 
   // 处理微信 OAuth 回调(必须在 initUser 之后)
   handleWechatOAuthCallback();
+
+  // 全局自动登录检查(微信环境下,有 openid 但未登录时尝试自动登录)
+  // #ifdef H5
+  checkAutoLogin();
+  // #endif
 });
 
+// ==================== 全局自动登录检查 ====================
+async function checkAutoLogin() {
+  // 只在微信环境下处理
+  if (!isInWechatBrowser()) {
+    console.log('[App] 非微信环境,跳过自动登录检查');
+    return;
+  }
+
+  const userStore = useUserStore();
+
+  // 如果已经登录,跳过
+  if (userStore.isLoggedIn) {
+    console.log('[App] 已登录,跳过自动登录检查');
+    return;
+  }
+
+  // 检查 localStorage 是否有 openid
+  const wxOpenid = localStorage.getItem('wx_openid');
+  if (!wxOpenid) {
+    console.log('[App] 微信环境无 openid,触发 OAuth 授权');
+    // 触发 OAuth 授权
+    await triggerWechatOAuth();
+    return;
+  }
+
+  console.log('[App] 检测到 openid,尝试自动登录:', wxOpenid.substring(0, 8) + '...');
+
+  try {
+    await userStore.loginByOpenid(wxOpenid);
+    console.log('[App] 自动登录成功!');
+    uni.showToast({ title: '自动登录成功', icon: 'success' });
+  } catch (e: any) {
+    console.log('[App] 自动登录失败(openid 未绑定账号):', e?.message);
+    // 不做处理,让用户手动登录
+  }
+}
+
+// ==================== 触发微信 OAuth 授权 ====================
+async function triggerWechatOAuth() {
+  // #ifdef H5
+  uni.showLoading({ title: '正在跳转微信授权...' });
+
+  try {
+    const { get } = await import('./utils/request');
+
+    // 获取当前页面路径,用于授权后返回
+    const pages = getCurrentPages();
+    const currentPage = pages[pages.length - 1];
+    const route = currentPage?.route || 'pages/index_index';
+    const currentPath = `/${route}${window.location.hash}`;
+    const redirectUrl = `${window.location.origin}${currentPath}`;
+    console.log('[App] 授权回调地址:', redirectUrl);
+
+    // 调用后端获取微信授权链接
+    const result = await get<{ oauthUrl: string }>(`/payment/wechat/oauth-url?redirect=${encodeURIComponent(redirectUrl)}`);
+    console.log('[App] 微信授权链接:', result.oauthUrl);
+
+    // 跳转到微信授权页
+    window.location.href = result.oauthUrl;
+  } catch (e) {
+    console.error('[App] 获取授权链接失败:', e);
+    uni.hideLoading();
+    uni.showToast({ title: '授权失败,请重试', icon: 'none' });
+  }
+  // #endif
+}
+
 onShow(() => {
   console.log('App Show');
 });
@@ -139,6 +222,7 @@ function applyDarkMode() {
   <view id="app">
     <router-view />
     <MiniPlayer />
+    <CreditInsufficientModal />
   </view>
 </template>
 

+ 224 - 0
my-uniapp-vue3/src/components/CreditInsufficientModal.vue

@@ -0,0 +1,224 @@
+<template>
+  <Teleport to="body">
+    <view v-if="visible" class="credit-modal-mask" @click="handleClose">
+      <view class="credit-modal-sheet" :class="{ show: visible }" @click.stop>
+        <!-- 关闭按钮 -->
+        <view class="sheet-handle" @click="handleClose"></view>
+
+        <!-- 图标和标题 -->
+        <view class="modal-header">
+          <view class="credit-icon">⚡</view>
+          <text class="modal-title">积分不足</text>
+          <text class="modal-desc" v-if="currentCredits !== null && requiredCredits !== null">
+            当前 {{ currentCredits }} 积分,还差 {{ requiredCredits - currentCredits }}
+          </text>
+          <text class="modal-desc" v-else>
+            请充值积分继续使用
+          </text>
+        </view>
+
+        <!-- 最低消费引导 -->
+        <view class="entry-point" v-if="lowestPrice">
+          <text class="entry-tip">最低 </text>
+          <text class="entry-price">¥{{ lowestPrice }}</text>
+          <text class="entry-tip"> 即可开始</text>
+        </view>
+
+        <!-- 操作按钮 -->
+        <view class="action-buttons">
+          <!-- VIP订阅 -->
+          <view class="action-card vip-action" @click="handleVipSubscribe">
+            <view class="action-icon">👑</view>
+            <view class="action-content">
+              <text class="action-title">开通VIP会员</text>
+              <text class="action-desc">每月享更多积分权益</text>
+            </view>
+            <text class="action-arrow">→</text>
+          </view>
+
+          <!-- 购买积分包 -->
+          <view class="action-card pack-action" @click="handleBuyPack">
+            <view class="action-icon">🎁</view>
+            <view class="action-content">
+              <text class="action-title">购买积分包</text>
+              <text class="action-desc">100分钟 ¥4.8 起</text>
+            </view>
+            <text class="action-arrow">→</text>
+          </view>
+        </view>
+
+        <!-- 底部留白 -->
+        <view class="sheet-footer"></view>
+      </view>
+    </view>
+  </Teleport>
+</template>
+
+<script setup lang="ts">
+import { getCreditModalState } from '../composables/useCreditInsufficient';
+
+const { visible, currentCredits, requiredCredits, lowestPrice } = getCreditModalState();
+
+function handleClose() {
+  visible.value = false;
+}
+
+function handleVipSubscribe() {
+  uni.navigateTo({ url: '/pages/member/index' });
+  handleClose();
+}
+
+function handleBuyPack() {
+  uni.navigateTo({ url: '/pages/token-packs/index' });
+  handleClose();
+}
+</script>
+
+<style scoped>
+.credit-modal-mask {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.5);
+  z-index: 9999;
+  display: flex;
+  align-items: flex-end;
+  justify-content: center;
+  opacity: 0;
+  transition: opacity 0.25s ease;
+}
+
+.credit-modal-mask.show {
+  opacity: 1;
+}
+
+.credit-modal-sheet {
+  width: 100%;
+  background: #ffffff;
+  border-radius: 32rpx 32rpx 0 0;
+  padding: 0 32rpx;
+  padding-bottom: constant(safe-area-inset-bottom);
+  padding-bottom: env(safe-area-inset-bottom);
+  transform: translateY(100%);
+  transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1);
+}
+
+.credit-modal-sheet.show {
+  transform: translateY(0);
+}
+
+.sheet-handle {
+  width: 80rpx;
+  height: 8rpx;
+  background: #d1d5db;
+  border-radius: 4rpx;
+  margin: 20rpx auto;
+}
+
+.modal-header {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 24rpx 0 32rpx;
+}
+
+.credit-icon {
+  font-size: 64rpx;
+  margin-bottom: 16rpx;
+}
+
+.modal-title {
+  font-size: 36rpx;
+  font-weight: 700;
+  color: #1f2937;
+  margin-bottom: 8rpx;
+}
+
+.modal-desc {
+  font-size: 28rpx;
+  color: #6b7280;
+}
+
+.entry-point {
+  display: flex;
+  align-items: baseline;
+  justify-content: center;
+  padding: 16rpx 32rpx;
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+  border-radius: 16rpx;
+  margin-bottom: 24rpx;
+}
+
+.entry-tip {
+  font-size: 26rpx;
+  color: #92400e;
+}
+
+.entry-price {
+  font-size: 40rpx;
+  font-weight: 700;
+  color: #f59e0b;
+}
+
+.action-buttons {
+  display: flex;
+  flex-direction: column;
+  gap: 20rpx;
+}
+
+.action-card {
+  display: flex;
+  align-items: center;
+  padding: 28rpx;
+  background: #f9fafb;
+  border-radius: 20rpx;
+  transition: all 0.2s ease;
+}
+
+.action-card:active {
+  transform: scale(0.98);
+  opacity: 0.9;
+}
+
+.vip-action {
+  background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
+}
+
+.pack-action {
+  background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
+}
+
+.action-icon {
+  font-size: 48rpx;
+  margin-right: 20rpx;
+}
+
+.action-content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+
+.action-title {
+  font-size: 32rpx;
+  font-weight: 600;
+  color: #1f2937;
+  margin-bottom: 4rpx;
+}
+
+.action-desc {
+  font-size: 24rpx;
+  color: #6b7280;
+}
+
+.action-arrow {
+  font-size: 36rpx;
+  color: #9ca3af;
+}
+
+.sheet-footer {
+  height: 40rpx;
+}
+</style>

+ 147 - 0
my-uniapp-vue3/src/composables/useCreditInsufficient.ts

@@ -0,0 +1,147 @@
+import { ref, computed } from 'vue';
+import { get } from '../utils/request';
+
+// ==================== 全局状态(单例)====================
+const globalState = {
+  visible: ref(false),
+  currentCredits: ref<number | null>(null),
+  requiredCredits: ref<number | null>(null),
+  lowestPrice: ref<number | null>(4.8),
+};
+
+let balanceCache: { remainingTokens: number; timestamp: number } | null = null;
+const BALANCE_CACHE_TTL = 30000; // 30秒缓存
+
+// ==================== Composable ====================
+/**
+ * 全局积分不足引导 composable
+ *
+ * 功能:
+ * - 检测用户积分余额
+ * - 发现不足时自动弹出引导Modal
+ * - 提供VIP订阅和购买积分包两个入口
+ *
+ * 使用方式:
+ * ```ts
+ * // 在页面中引入
+ * const { checkCreditsAndShow, handleCreditError } = useCreditInsufficient();
+ *
+ * // 方式1:先检查积分,不足时弹窗
+ * const canProceed = await checkCreditsAndShow(requiredAmount);
+ * if (!canProceed) return;
+ *
+ * // 方式2:处理API返回的积分不足错误
+ * } catch (e) {
+ *   if (handleCreditError(e)) return;
+ * }
+ * ```
+ */
+export function useCreditInsufficient() {
+  /**
+   * 检查用户积分是否足够
+   * @param required 需要积分数(可选)
+   * @returns true=积分足够或用户选择继续, false=积分不足且用户取消
+   */
+  async function checkCreditsAndShow(required?: number): Promise<boolean> {
+    try {
+      // 获取余额(带缓存)
+      let remaining = 0;
+      if (balanceCache && Date.now() - balanceCache.timestamp < BALANCE_CACHE_TTL) {
+        remaining = balanceCache.remainingTokens;
+      } else {
+        const result = await get<{ remainingTokens: number }>('/subscription/balance');
+        remaining = result?.remainingTokens ?? 0;
+        balanceCache = { remainingTokens: remaining, timestamp: Date.now() };
+      }
+
+      globalState.currentCredits.value = remaining;
+      globalState.requiredCredits.value = required ?? null;
+
+      // 积分足够,直接通过
+      if (required === undefined || remaining >= required) {
+        return true;
+      }
+
+      // 积分不足,弹出引导Modal
+      globalState.visible.value = true;
+      return false;
+    } catch (e: any) {
+      console.error('[useCreditInsufficient] 检查积分失败:', e);
+      // 检查失败时默认通过,避免阻塞用户操作
+      return true;
+    }
+  }
+
+  /**
+   * 处理API返回的积分不足错误
+   * @param err 错误对象
+   * @returns true=已处理(积分不足弹窗), false=未处理(不是积分不足错误)
+   */
+  function handleCreditError(err: any): boolean {
+    if (!isCreditError(err)) return false;
+
+    // 设置积分信息并显示弹窗
+    globalState.currentCredits.value = err.remaining ?? null;
+    globalState.requiredCredits.value = err.required ?? null;
+    globalState.lowestPrice.value = err.lowestPrice ?? 4.8;
+    globalState.visible.value = true;
+    return true;
+  }
+
+  /**
+   * 直接显示积分不足弹窗(不检查余额)
+   */
+  function showInsufficientModal(opts?: {
+    current?: number;
+    required?: number;
+    price?: number;
+  }) {
+    if (opts?.current !== undefined) globalState.currentCredits.value = opts.current;
+    if (opts?.required !== undefined) globalState.requiredCredits.value = opts.required;
+    if (opts?.price !== undefined) globalState.lowestPrice.value = opts.price;
+    globalState.visible.value = true;
+  }
+
+  function hideModal() {
+    globalState.visible.value = false;
+  }
+
+  return {
+    showModal: globalState.visible,
+    currentCredits: globalState.currentCredits,
+    requiredCredits: globalState.requiredCredits,
+    lowestPrice: globalState.lowestPrice,
+    checkCreditsAndShow,
+    handleCreditError,
+    showInsufficientModal,
+    hideModal,
+  };
+}
+
+// ==================== 全局弹窗组件 ====================
+// 导出全局状态供 CreditInsufficientModal 使用
+export function getCreditModalState() {
+  return globalState;
+}
+
+// ==================== 错误类型判断 ====================
+export interface CreditError {
+  code: 'CREDIT_INSUFFICIENT' | 'QUOTA_EXHAUSTED';
+  message: string;
+  required?: number;
+  remaining?: number;
+  lowestPrice?: number;
+}
+
+export function isCreditError(err: any): err is CreditError {
+  if (!err) return false;
+  if (err.code === 'CREDIT_INSUFFICIENT' || err.code === 'QUOTA_EXHAUSTED') return true;
+  if (typeof err.message === 'string') {
+    const msg = err.message.toLowerCase();
+    return msg.includes('积分不足') ||
+           msg.includes('credit') ||
+           msg.includes('quota') ||
+           msg.includes('额度');
+  }
+  return false;
+}

+ 6 - 0
my-uniapp-vue3/src/pages.json

@@ -55,6 +55,12 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/login/wechat-entry",
+      "style": {
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/favorites/index",
       "style": {

+ 83 - 0
my-uniapp-vue3/src/pages/login/wechat-entry.vue

@@ -0,0 +1,83 @@
+<template>
+  <view class="page">
+    <view class="loading">
+      <text class="loading-text">{{ statusText }}</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { onLoad } from '@dcloudio/uni-app';
+import { useUserStore } from '../../store/user';
+import { get as httpsGet } from '../../utils/request';
+
+const statusText = '正在跳转微信授权...';
+
+onLoad(async () => {
+  const userStore = useUserStore();
+  console.log('[wechat-entry] 页面加载');
+
+  // 如果已有 openid,先尝试自动登录
+  const wxOpenid = localStorage.getItem('wx_openid');
+  if (wxOpenid && !userStore.isLoggedIn) {
+    try {
+      console.log('[wechat-entry] 有 openid,尝试自动登录');
+      await userStore.loginByOpenid(wxOpenid);
+      uni.showToast({ title: '自动登录成功', icon: 'success' });
+      setTimeout(() => {
+        uni.switchTab({ url: '/pages/index/index' });
+      }, 500);
+      return;
+    } catch (e) {
+      console.log('[wechat-entry] 自动登录失败,将进行微信授权');
+    }
+  }
+
+  // 没有 openid 或自动登录失败,发起微信 OAuth 授权
+  console.log('[wechat-entry] 发起微信 OAuth 授权');
+  uni.showLoading({ title: '正在跳转微信授权...' });
+
+  try {
+    // 获取当前页面的完整 URL(包含 hash 路由),用于微信回调后返回
+    const currentPage = `/${getCurrentPages()[0]?.route || 'pages/login/wechat-entry'}`;
+    const redirectUrl = `${window.location.origin}${currentPage}`;
+    console.log('[wechat-entry] redirectUrl:', redirectUrl);
+
+    // 调用后端获取微信授权链接
+    const oauthUrlResult = await httpsGet<{ oauthUrl: string }>(`/payment/wechat/oauth-url?redirect=${encodeURIComponent(redirectUrl)}`);
+    console.log('[wechat-entry] 获取到授权链接:', oauthUrlResult.oauthUrl);
+
+    // 跳转到微信授权页
+    window.location.href = oauthUrlResult.oauthUrl;
+  } catch (e: any) {
+    console.error('[wechat-entry] 获取授权链接失败:', e);
+    uni.hideLoading();
+    uni.showToast({ title: '授权失败,请重试', icon: 'none' });
+    setTimeout(() => {
+      uni.switchTab({ url: '/pages/index/index' });
+    }, 1500);
+  }
+});
+</script>
+
+<style scoped>
+.page {
+  min-height: 100vh;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: linear-gradient(180deg, #4f46e5 0%, #818cf8 50%, #f9fafb 50%);
+}
+
+.loading {
+  background: #ffffff;
+  padding: 48rpx 64rpx;
+  border-radius: 24rpx;
+  text-align: center;
+}
+
+.loading-text {
+  font-size: 28rpx;
+  color: #6b7280;
+}
+</style>

+ 8 - 0
my-uniapp-vue3/src/utils/request.ts

@@ -153,6 +153,14 @@ function makeRequest<T>(
           // 请求过于频繁
           const retryAfter = (result as any).retryAfter || 1;
           reject(new Error(`请求过于频繁,请${retryAfter}秒后再试`));
+        } else if (result.code === 402 || (result as any).codeName === 'CREDIT_INSUFFICIENT') {
+          // 积分不足
+          const err = new Error(result.message || '积分不足') as any;
+          err.code = 'CREDIT_INSUFFICIENT';
+          err.required = (result as any).required;
+          err.remaining = (result as any).remaining;
+          err.lowestPrice = (result as any).lowestPrice;
+          reject(err);
         } else if (result.code >= 500) {
           // 服务器错误
           uni.showToast({ title: result.message || '服务器错误', icon: 'none' });