浏览代码

feat: 添加订单历史和Token使用记录页面

MyFramework User 4 月之前
父节点
当前提交
69754930b5
共有 2 个文件被更改,包括 294 次插入0 次删除
  1. 6 0
      my-uniapp-vue3/src/pages.json
  2. 288 0
      my-uniapp-vue3/src/pages/orders/index.vue

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

@@ -103,6 +103,12 @@
       "style": {
       "style": {
         "navigationStyle": "custom"
         "navigationStyle": "custom"
       }
       }
+    },
+    {
+      "path": "pages/orders/index",
+      "style": {
+        "navigationStyle": "custom"
+      }
     }
     }
   ],
   ],
   "globalStyle": {
   "globalStyle": {

+ 288 - 0
my-uniapp-vue3/src/pages/orders/index.vue

@@ -0,0 +1,288 @@
+<template>
+  <view class="page">
+    <view class="nav-bar">
+      <view class="nav-btn" @click="goBack">
+        <text class="nav-icon">←</text>
+      </view>
+      <text class="nav-title">订单历史</text>
+      <view class="nav-btn" />
+    </view>
+
+    <!-- Token余额 -->
+    <view class="balance-card" v-if="tokenBalance">
+      <view class="balance-info">
+        <text class="balance-label">剩余Token</text>
+        <text class="balance-value">{{ tokenBalance.remainingTokens.toLocaleString() }}</text>
+        <text class="balance-unit" v-if="!tokenBalance.isUnlimited"> / {{ tokenBalance.totalTokens.toLocaleString() }}</text>
+      </view>
+      <view class="balance-bar">
+        <view class="balance-progress" :style="{ width: progressWidth + '%' }"></view>
+      </view>
+    </view>
+
+    <!-- 订单列表 -->
+    <view class="orders-section">
+      <view v-if="loading" class="loading-container">
+        <text class="loading-text">加载中...</text>
+      </view>
+
+      <view v-else-if="orders.length === 0" class="empty-container">
+        <text class="empty-icon">📋</text>
+        <text class="empty-text">暂无订单记录</text>
+        <button class="subscribe-btn" @click="goToSubscribe">
+          <text>去订阅</text>
+        </button>
+      </view>
+
+      <view v-else class="orders-list">
+        <view v-for="order in orders" :key="order.id" class="order-card">
+          <view class="order-header">
+            <text class="order-plan">{{ order.planName || '套餐订阅' }}</text>
+            <text class="order-status" :class="order.status">
+              {{ getStatusText(order.status) }}
+            </text>
+          </view>
+
+          <view class="order-info">
+            <view class="info-row">
+              <text class="info-label">订单号</text>
+              <text class="info-value">{{ order.orderNo }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">支付方式</text>
+              <text class="info-value">{{ getPaymentMethodText(order.paymentMethod) }}</text>
+            </view>
+            <view class="info-row">
+              <text class="info-label">下单时间</text>
+              <text class="info-value">{{ formatDate(order.createdAt) }}</text>
+            </view>
+            <view class="info-row" v-if="order.paidAt">
+              <text class="info-label">支付时间</text>
+              <text class="info-value">{{ formatDate(order.paidAt) }}</text>
+            </view>
+          </view>
+
+          <view class="order-footer">
+            <text class="order-amount">¥{{ order.amount }}</text>
+            <text class="order-type">{{ order.productType === 'yearly' ? '年付' : '月付' }}</text>
+          </view>
+        </view>
+      </view>
+
+      <!-- 分页 -->
+      <view v-if="totalPages > 1" class="pagination">
+        <button class="page-btn" :disabled="page <= 1" @click="loadOrders(page - 1)">
+          上一页
+        </button>
+        <text class="page-info">{{ page }} / {{ totalPages }}</text>
+        <button class="page-btn" :disabled="page >= totalPages" @click="loadOrders(page + 1)">
+          下一页
+        </button>
+      </view>
+    </view>
+
+    <!-- Token使用记录 -->
+    <view class="usage-section">
+      <text class="section-title">Token使用记录</text>
+
+      <view v-if="usageLoading" class="loading-container">
+        <text class="loading-text">加载中...</text>
+      </view>
+
+      <view v-else-if="usageList.length === 0" class="empty-container small">
+        <text class="empty-text">暂无使用记录</text>
+      </view>
+
+      <view v-else class="usage-list">
+        <view v-for="usage in usageList" :key="usage.id" class="usage-item">
+          <view class="usage-info">
+            <text class="usage-type">{{ getUsageTypeText(usage.type) }}</text>
+            <text class="usage-desc">{{ usage.description || '文本转语音' }}</text>
+          </view>
+          <view class="usage-amount">
+            <text class="amount-value">-{{ usage.amount.toLocaleString() }}</text>
+            <text class="amount-unit">Token</text>
+          </view>
+        </view>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue';
+import { useUserStore } from '../../store/user';
+import { get } from '../../utils/request';
+
+const userStore = useUserStore();
+
+const orders = ref<any[]>([]);
+const tokenBalance = ref<any>(null);
+const usageList = ref<any[]>([]);
+const loading = ref(false);
+const usageLoading = ref(false);
+const page = ref(1);
+const pageSize = ref(10);
+const total = ref(0);
+
+const totalPages = computed(() => Math.ceil(total.value / pageSize.value));
+const progressWidth = computed(() => {
+  if (!tokenBalance.value) return 0;
+  if (tokenBalance.value.isUnlimited) return 100;
+  if (tokenBalance.value.totalTokens === 0) return 0;
+  return Math.max(0, Math.min(100, 
+    ((tokenBalance.value.totalTokens - tokenBalance.value.usedTokens) / tokenBalance.value.totalTokens) * 100
+  ));
+});
+
+onMounted(async () => {
+  if (userStore.isLoggedIn) {
+    await Promise.all([
+      loadOrders(1),
+      loadTokenBalance(),
+      loadUsageList(1)
+    ]);
+  }
+});
+
+async function loadOrders(pageNum: number) {
+  loading.value = true;
+  try {
+    const result = await get<any>(`/payment/orders?page=${pageNum}&pageSize=${pageSize.value}`);
+    orders.value = result.list || [];
+    total.value = result.total || 0;
+    page.value = pageNum;
+  } catch (error) {
+    console.error('获取订单列表失败:', error);
+    uni.showToast({ title: '加载失败', icon: 'none' });
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function loadTokenBalance() {
+  try {
+    tokenBalance.value = await get('/subscription/balance');
+  } catch (error) {
+    console.error('获取Token余额失败:', error);
+  }
+}
+
+async function loadUsageList(pageNum: number) {
+  usageLoading.value = true;
+  try {
+    const result = await get<any>(`/subscription/usage?page=${pageNum}&pageSize=20`);
+    usageList.value = result.list || [];
+  } catch (error) {
+    console.error('获取使用记录失败:', error);
+  } finally {
+    usageLoading.value = false;
+  }
+}
+
+function goBack() {
+  uni.navigateBack();
+}
+
+function goToSubscribe() {
+  uni.navigateTo({ url: '/pages/member/index' });
+}
+
+function formatDate(dateStr: string): string {
+  if (!dateStr) return '-';
+  const date = new Date(dateStr);
+  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
+}
+
+function getStatusText(status: string): string {
+  const statusMap: Record<string, string> = {
+    pending: '待支付',
+    paid: '已支付',
+    failed: '支付失败',
+    refunded: '已退款'
+  };
+  return statusMap[status] || status;
+}
+
+function getPaymentMethodText(method: string): string {
+  const methodMap: Record<string, string> = {
+    alipay: '支付宝',
+    wechat: '微信支付',
+    mock: '模拟支付'
+  };
+  return methodMap[method] || method || '-';
+}
+
+function getUsageTypeText(type: string): string {
+  const typeMap: Record<string, string> = {
+    text_to_speech: '语音合成',
+    api_call: 'API调用',
+    batch_process: '批量处理'
+  };
+  return typeMap[type] || type;
+}
+</script>
+
+<style scoped>
+.page { min-height: 100vh; background: #f9fafb; padding-bottom: 40rpx; }
+
+.nav-bar { display: flex; align-items: center; justify-content: space-between; padding: 44rpx 32rpx 24rpx; background: #fff; }
+.nav-btn { width: 64rpx; height: 64rpx; display: flex; align-items: center; justify-content: center; }
+.nav-icon { font-size: 36rpx; color: #1f2937; }
+.nav-title { font-size: 34rpx; font-weight: 600; color: #1f2937; }
+
+.balance-card { margin: 24rpx 32rpx; padding: 24rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 20rpx; color: #fff; }
+.balance-info { display: flex; align-items: baseline; margin-bottom: 12rpx; }
+.balance-label { font-size: 28rpx; opacity: 0.9; margin-right: 8rpx; }
+.balance-value { font-size: 48rpx; font-weight: 700; }
+.balance-unit { font-size: 24rpx; opacity: 0.8; margin-left: 8rpx; }
+.balance-bar { height: 8rpx; background: rgba(255,255,255,0.3); border-radius: 4rpx; overflow: hidden; }
+.balance-progress { height: 100%; background: #fff; border-radius: 4rpx; transition: width 0.3s; }
+
+.orders-section { padding: 0 32rpx; }
+.loading-container { display: flex; align-items: center; justify-content: center; padding: 100rpx 0; }
+.loading-text { font-size: 28rpx; color: #9ca3af; }
+
+.empty-container { display: flex; flex-direction: column; align-items: center; padding: 100rpx 0; }
+.empty-container.small { padding: 40rpx 0; }
+.empty-icon { font-size: 80rpx; margin-bottom: 24rpx; }
+.empty-text { font-size: 28rpx; color: #9ca3af; margin-bottom: 32rpx; }
+
+.subscribe-btn { padding: 16rpx 48rpx; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 24rpx; border: none; }
+.subscribe-btn text { font-size: 28rpx; color: #fff; }
+
+.orders-list { display: flex; flex-direction: column; gap: 20rpx; }
+.order-card { background: #fff; border-radius: 16rpx; padding: 24rpx; }
+.order-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16rpx; padding-bottom: 16rpx; border-bottom: 1rpx solid #f3f4f6; }
+.order-plan { font-size: 30rpx; font-weight: 600; color: #1f2937; }
+.order-status { font-size: 24rpx; padding: 4rpx 16rpx; border-radius: 12rpx; }
+.order-status.pending { background: #fef3c7; color: #92400e; }
+.order-status.paid { background: #d1fae5; color: #065f46; }
+.order-status.failed { background: #fee2e2; color: #991b1b; }
+.order-status.refunded { background: #e5e7eb; color: #374151; }
+
+.order-info { display: flex; flex-direction: column; gap: 12rpx; margin-bottom: 16rpx; }
+.info-row { display: flex; justify-content: space-between; }
+.info-label { font-size: 24rpx; color: #9ca3af; }
+.info-value { font-size: 24rpx; color: #4b5563; }
+
+.order-footer { display: flex; justify-content: flex-end; align-items: baseline; }
+.order-amount { font-size: 36rpx; font-weight: 700; color: #f97316; margin-right: 8rpx; }
+.order-type { font-size: 24rpx; color: #6b7280; }
+
+.pagination { display: flex; justify-content: center; align-items: center; gap: 24rpx; margin-top: 32rpx; }
+.page-btn { padding: 12rpx 24rpx; background: #fff; border-radius: 12rpx; border: 1rpx solid #e5e7eb; font-size: 26rpx; color: #4b5563; }
+.page-btn[disabled] { opacity: 0.5; }
+.page-info { font-size: 26rpx; color: #6b7280; }
+
+.usage-section { margin-top: 40rpx; padding: 0 32rpx; }
+.section-title { font-size: 32rpx; font-weight: 600; color: #1f2937; margin-bottom: 20rpx; display: block; }
+.usage-list { display: flex; flex-direction: column; gap: 16rpx; }
+.usage-item { display: flex; justify-content: space-between; align-items: center; background: #fff; padding: 20rpx 24rpx; border-radius: 12rpx; }
+.usage-info { display: flex; flex-direction: column; gap: 4rpx; }
+.usage-type { font-size: 28rpx; color: #1f2937; font-weight: 500; }
+.usage-desc { font-size: 24rpx; color: #9ca3af; }
+.usage-amount { display: flex; align-items: baseline; }
+.amount-value { font-size: 32rpx; font-weight: 600; color: #ef4444; }
+.amount-unit { font-size: 22rpx; color: #9ca3af; margin-left: 4rpx; }
+</style>