| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176 |
- <template>
- <view class="page">
- <view class="header">
- <text class="title">消费账单</text>
- </view>
- <!-- 汇总卡片 -->
- <view class="summary-card">
- <view class="summary-item">
- <text class="summary-value">¥{{ summary.totalCost }}</text>
- <text class="summary-label">总费用</text>
- </view>
- <view class="summary-divider" />
- <view class="summary-item">
- <text class="summary-value">{{ summary.totalCalls }}</text>
- <text class="summary-label">总调用次数</text>
- </view>
- <view class="summary-divider" />
- <view class="summary-item">
- <text class="summary-value">{{ summary.totalTokens }}</text>
- <text class="summary-label">总Token消耗</text>
- </view>
- </view>
- <!-- 分类统计 -->
- <view class="type-stats" v-if="summary.byType.length">
- <view class="type-item" v-for="t in summary.byType" :key="t.type">
- <text class="type-name">{{ typeLabel(t.type) }}</text>
- <view class="type-data">
- <text class="type-count">{{ t.count }}次</text>
- <text class="type-cost">¥{{ t.cost.toFixed(4) }}</text>
- </view>
- </view>
- </view>
- <!-- 明细列表 -->
- <view class="section-title">调用明细</view>
- <view class="record-list">
- <view class="record-card" v-for="r in records" :key="r.id">
- <view class="record-top">
- <text :class="['call-badge', r.callType.startsWith('llm') ? 'badge-llm' : 'badge-tts']">
- {{ typeLabel(r.callType) }}
- </text>
- <text class="record-time">{{ formatTime(r.time) }}</text>
- </view>
- <view class="record-info">
- <text class="record-model">{{ r.model }}</text>
- </view>
- <view class="record-tokens" v-if="r.inputTokens || r.outputTokens">
- <text v-if="r.callType.startsWith('llm')">
- in: {{ r.inputTokens }}tk / out: {{ r.outputTokens }}tk
- </text>
- <text v-else>
- 字符: {{ r.textLen || r.outputTokens }}
- </text>
- </view>
- <view class="record-bottom">
- <text class="record-cost">¥{{ r.cost }}</text>
- <text class="record-dur" v-if="r.duration">{{ (r.duration / 1000).toFixed(1) }}s</text>
- <text :class="['record-status', r.success ? 'status-ok' : 'status-fail']">
- {{ r.success ? '✓' : '✗' }}
- </text>
- </view>
- </view>
- </view>
- <view class="empty" v-if="loading">
- <text>加载中...</text>
- </view>
- <view class="empty" v-else-if="!records.length">
- <text>暂无消费记录</text>
- </view>
- </view>
- </template>
- <script setup lang="ts">
- import { ref, onMounted } from 'vue';
- import { request } from '@/utils/request';
- interface LogRecord {
- id: number; callType: string; provider: string; model: string;
- textLen: number; inputTokens: number; outputTokens: number;
- cost: number; duration: number; success: boolean; bookId: number; time: string;
- }
- const records = ref<LogRecord[]>([]);
- const loading = ref(true);
- const summary = ref({
- totalCost: '0',
- totalCalls: 0,
- totalTokens: 0,
- byType: [] as Array<{ type: string; count: number; cost: number; inputTokens: number; outputTokens: number }>,
- });
- function typeLabel(type: string): string {
- const map: Record<string, string> = {
- llm_chat: '文本生成', llm_tools: '工具调用',
- tts_synthesize: '语音合成', tts_create: 'TTS创建',
- tts_poll: 'TTS查询', tts_task_completed: 'TTS完成',
- tts_task_failed: 'TTS失败',
- };
- return map[type] || type;
- }
- function formatTime(t: string): string {
- if (!t) return '';
- return t.replace('T', ' ').substring(0, 19);
- }
- async function loadBilling() {
- try {
- const data = await request('/subscription/billing/summary') as any;
- // request() 已解包 result.data,data 直接是 {totalCalls, records, ...}
- if (data?.records) {
- records.value = (data.records || []).map((r: any) => ({ ...r, cost: r.cost || 0 }));
- summary.value = {
- totalCost: (data.totalCost || 0).toFixed(4),
- totalCalls: data.totalCalls || 0,
- totalTokens: (data.totalInputTokens || 0) + (data.totalOutputTokens || 0),
- byType: data.byType || [],
- };
- }
- } catch (e) {
- console.error('加载账单失败', e);
- } finally {
- loading.value = false;
- }
- }
- onMounted(loadBilling);
- </script>
- <style scoped>
- .page { min-height: 100vh; background: #f5f5f5; padding-bottom: 30rpx; }
- .header { padding: 30rpx; display: flex; justify-content: space-between; align-items: center; }
- .title { font-size: 36rpx; font-weight: bold; color: #333; }
- .summary-card {
- margin: 0 24rpx 20rpx; padding: 30rpx; border-radius: 16rpx;
- background: linear-gradient(135deg, #6366f1, #8b5cf6);
- display: flex; justify-content: space-around;
- }
- .summary-item { display: flex; flex-direction: column; align-items: center; }
- .summary-value { font-size: 40rpx; font-weight: bold; color: #fff; }
- .summary-label { font-size: 22rpx; color: rgba(255,255,255,0.7); margin-top: 6rpx; }
- .summary-divider { width: 1px; background: rgba(255,255,255,0.2); }
- .type-stats { margin: 0 24rpx 20rpx; padding: 20rpx; background: #fff; border-radius: 12rpx; }
- .type-item { display: flex; justify-content: space-between; align-items: center; padding: 12rpx 0; border-bottom: 1px solid #f0f0f0; }
- .type-item:last-child { border-bottom: none; }
- .type-name { font-size: 28rpx; color: #333; }
- .type-data { display: flex; gap: 20rpx; align-items: center; }
- .type-count { font-size: 24rpx; color: #999; }
- .type-cost { font-size: 28rpx; font-weight: bold; color: #6366f1; }
- .section-title { margin: 24rpx 24rpx 16rpx; font-size: 28rpx; font-weight: bold; color: #333; }
- .record-list { margin: 0 24rpx; }
- .record-card { background: #fff; border-radius: 12rpx; padding: 20rpx; margin-bottom: 12rpx; }
- .record-top { display: flex; justify-content: space-between; align-items: center; }
- .call-badge { font-size: 22rpx; padding: 4rpx 12rpx; border-radius: 6rpx; color: #fff; }
- .badge-llm { background: #3b82f6; }
- .badge-tts { background: #10b981; }
- .record-time { font-size: 22rpx; color: #999; }
- .record-info { margin-top: 8rpx; }
- .record-model { font-size: 26rpx; color: #333; }
- .record-tokens { margin-top: 6rpx; }
- .record-tokens text { font-size: 22rpx; color: #888; }
- .record-bottom { margin-top: 10rpx; display: flex; align-items: center; gap: 16rpx; }
- .record-cost { font-size: 30rpx; font-weight: bold; color: #f59e0b; }
- .record-dur { font-size: 22rpx; color: #aaa; }
- .record-status { font-size: 24rpx; font-weight: bold; }
- .status-ok { color: #10b981; }
- .status-fail { color: #ef4444; }
- .empty { text-align: center; padding: 60rpx; color: #999; font-size: 28rpx; }
- </style>
|