|
@@ -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>
|