index.vue 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
  1. <template>
  2. <view class="page">
  3. <view class="header">
  4. <text class="title">消费账单</text>
  5. </view>
  6. <!-- 汇总卡片 -->
  7. <view class="summary-card">
  8. <view class="summary-item">
  9. <text class="summary-value">¥{{ summary.totalCost }}</text>
  10. <text class="summary-label">总费用</text>
  11. </view>
  12. <view class="summary-divider" />
  13. <view class="summary-item">
  14. <text class="summary-value">{{ summary.totalCalls }}</text>
  15. <text class="summary-label">总调用次数</text>
  16. </view>
  17. <view class="summary-divider" />
  18. <view class="summary-item">
  19. <text class="summary-value">{{ summary.totalTokens }}</text>
  20. <text class="summary-label">总Token消耗</text>
  21. </view>
  22. </view>
  23. <!-- 分类统计 -->
  24. <view class="type-stats" v-if="summary.byType.length">
  25. <view class="type-item" v-for="t in summary.byType" :key="t.type">
  26. <text class="type-name">{{ typeLabel(t.type) }}</text>
  27. <view class="type-data">
  28. <text class="type-count">{{ t.count }}次</text>
  29. <text class="type-cost">¥{{ t.cost.toFixed(4) }}</text>
  30. </view>
  31. </view>
  32. </view>
  33. <!-- 明细列表 -->
  34. <view class="section-title">调用明细</view>
  35. <view class="record-list">
  36. <view class="record-card" v-for="r in records" :key="r.id">
  37. <view class="record-top">
  38. <text :class="['call-badge', r.callType.startsWith('llm') ? 'badge-llm' : 'badge-tts']">
  39. {{ typeLabel(r.callType) }}
  40. </text>
  41. <text class="record-time">{{ formatTime(r.time) }}</text>
  42. </view>
  43. <view class="record-info">
  44. <text class="record-model">{{ r.model }}</text>
  45. </view>
  46. <view class="record-tokens" v-if="r.inputTokens || r.outputTokens">
  47. <text v-if="r.callType.startsWith('llm')">
  48. in: {{ r.inputTokens }}tk / out: {{ r.outputTokens }}tk
  49. </text>
  50. <text v-else>
  51. 字符: {{ r.textLen || r.outputTokens }}
  52. </text>
  53. </view>
  54. <view class="record-bottom">
  55. <text class="record-cost">¥{{ r.cost }}</text>
  56. <text class="record-dur" v-if="r.duration">{{ (r.duration / 1000).toFixed(1) }}s</text>
  57. <text :class="['record-status', r.success ? 'status-ok' : 'status-fail']">
  58. {{ r.success ? '✓' : '✗' }}
  59. </text>
  60. </view>
  61. </view>
  62. </view>
  63. <view class="empty" v-if="loading">
  64. <text>加载中...</text>
  65. </view>
  66. <view class="empty" v-else-if="!records.length">
  67. <text>暂无消费记录</text>
  68. </view>
  69. </view>
  70. </template>
  71. <script setup lang="ts">
  72. import { ref, onMounted } from 'vue';
  73. import { request } from '@/utils/request';
  74. interface LogRecord {
  75. id: number; callType: string; provider: string; model: string;
  76. textLen: number; inputTokens: number; outputTokens: number;
  77. cost: number; duration: number; success: boolean; bookId: number; time: string;
  78. }
  79. const records = ref<LogRecord[]>([]);
  80. const loading = ref(true);
  81. const summary = ref({
  82. totalCost: '0',
  83. totalCalls: 0,
  84. totalTokens: 0,
  85. byType: [] as Array<{ type: string; count: number; cost: number; inputTokens: number; outputTokens: number }>,
  86. });
  87. function typeLabel(type: string): string {
  88. const map: Record<string, string> = {
  89. llm_chat: '文本生成', llm_tools: '工具调用',
  90. tts_synthesize: '语音合成', tts_create: 'TTS创建',
  91. tts_poll: 'TTS查询', tts_task_completed: 'TTS完成',
  92. tts_task_failed: 'TTS失败',
  93. };
  94. return map[type] || type;
  95. }
  96. function formatTime(t: string): string {
  97. if (!t) return '';
  98. return t.replace('T', ' ').substring(0, 19);
  99. }
  100. async function loadBilling() {
  101. try {
  102. const data = await request('/subscription/billing/summary') as any;
  103. // request() 已解包 result.data,data 直接是 {totalCalls, records, ...}
  104. if (data?.records) {
  105. records.value = (data.records || []).map((r: any) => ({ ...r, cost: r.cost || 0 }));
  106. summary.value = {
  107. totalCost: (data.totalCost || 0).toFixed(4),
  108. totalCalls: data.totalCalls || 0,
  109. totalTokens: (data.totalInputTokens || 0) + (data.totalOutputTokens || 0),
  110. byType: data.byType || [],
  111. };
  112. }
  113. } catch (e) {
  114. console.error('加载账单失败', e);
  115. } finally {
  116. loading.value = false;
  117. }
  118. }
  119. onMounted(loadBilling);
  120. </script>
  121. <style scoped>
  122. .page { min-height: 100vh; background: #f5f5f5; padding-bottom: 30rpx; }
  123. .header { padding: 30rpx; display: flex; justify-content: space-between; align-items: center; }
  124. .title { font-size: 36rpx; font-weight: bold; color: #333; }
  125. .summary-card {
  126. margin: 0 24rpx 20rpx; padding: 30rpx; border-radius: 16rpx;
  127. background: linear-gradient(135deg, #6366f1, #8b5cf6);
  128. display: flex; justify-content: space-around;
  129. }
  130. .summary-item { display: flex; flex-direction: column; align-items: center; }
  131. .summary-value { font-size: 40rpx; font-weight: bold; color: #fff; }
  132. .summary-label { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 6rpx; }
  133. .summary-divider { width: 1px; background: rgba(255,255,255,0.2); }
  134. .type-stats { margin: 0 24rpx 20rpx; padding: 20rpx; background: #fff; border-radius: 12rpx; }
  135. .type-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1px solid #f0f0f0; }
  136. .type-item:last-child { border-bottom: none; }
  137. .type-name { font-size: 28rpx; color: #333; }
  138. .type-data { display: flex; gap: 20rpx; align-items: center; }
  139. .type-count { font-size: 24rpx; color: #999; }
  140. .type-cost { font-size: 28rpx; font-weight: bold; color: #6366f1; }
  141. .section-title { margin: 24rpx 24rpx 16rpx; font-size: 28rpx; font-weight: bold; color: #333; }
  142. .record-list { margin: 0 24rpx; }
  143. .record-card { background: #fff; border-radius: 12rpx; padding: 20rpx; margin-bottom: 12rpx; }
  144. .record-top { display: flex; justify-content: space-between; align-items: center; }
  145. .call-badge { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 6rpx; color: #fff; }
  146. .badge-llm { background: #3b82f6; }
  147. .badge-tts { background: #10b981; }
  148. .record-time { font-size: 22rpx; color: #999; }
  149. .record-info { margin-top: 8rpx; }
  150. .record-model { font-size: 26rpx; color: #333; }
  151. .record-tokens { margin-top: 6rpx; }
  152. .record-tokens text { font-size: 22rpx; color: #888; }
  153. .record-bottom { margin-top: 10rpx; display: flex; align-items: center; gap: 16rpx; }
  154. .record-cost { font-size: 30rpx; font-weight: bold; color: #f59e0b; }
  155. .record-dur { font-size: 22rpx; color: #aaa; }
  156. .record-status { font-size: 24rpx; font-weight: bold; }
  157. .status-ok { color: #10b981; }
  158. .status-fail { color: #ef4444; }
  159. .empty { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
  160. </style>