mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 08:59:16 +00:00
feat: 支持账号维度的数据统计
This commit is contained in:
@@ -849,6 +849,15 @@
|
||||
<i :class="['fas', account.schedulable ? 'fa-toggle-on' : 'fa-toggle-off']" />
|
||||
<span class="ml-1">{{ account.schedulable ? '调度' : '停用' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="canViewUsage(account)"
|
||||
class="rounded bg-indigo-100 px-2.5 py-1 text-xs font-medium text-indigo-700 transition-colors hover:bg-indigo-200"
|
||||
:title="'查看使用详情'"
|
||||
@click="openAccountUsageModal(account)"
|
||||
>
|
||||
<i class="fas fa-chart-line" />
|
||||
<span class="ml-1">详情</span>
|
||||
</button>
|
||||
<button
|
||||
class="rounded bg-blue-100 px-2.5 py-1 text-xs font-medium text-blue-700 transition-colors hover:bg-blue-200"
|
||||
:title="'编辑账户'"
|
||||
@@ -1154,6 +1163,15 @@
|
||||
{{ account.schedulable ? '暂停' : '启用' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="canViewUsage(account)"
|
||||
class="flex flex-1 items-center justify-center gap-1 rounded-lg bg-indigo-50 px-3 py-2 text-xs text-indigo-600 transition-colors hover:bg-indigo-100"
|
||||
@click="openAccountUsageModal(account)"
|
||||
>
|
||||
<i class="fas fa-chart-line" />
|
||||
详情
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="flex-1 rounded-lg bg-gray-50 px-3 py-2 text-xs text-gray-600 transition-colors hover:bg-gray-100"
|
||||
@click="editAccount(account)"
|
||||
@@ -1298,6 +1316,18 @@
|
||||
@cancel="handleCancel"
|
||||
@confirm="handleConfirm"
|
||||
/>
|
||||
|
||||
<AccountUsageDetailModal
|
||||
v-if="showAccountUsageModal"
|
||||
:account="selectedAccountForUsage || {}"
|
||||
:generated-at="accountUsageGeneratedAt"
|
||||
:history="accountUsageHistory"
|
||||
:loading="accountUsageLoading"
|
||||
:overview="accountUsageOverview"
|
||||
:show="showAccountUsageModal"
|
||||
:summary="accountUsageSummary"
|
||||
@close="closeAccountUsageModal"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1308,6 +1338,7 @@ import { apiClient } from '@/config/api'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
|
||||
@@ -1340,6 +1371,17 @@ const pageSizeOptions = [10, 20, 50, 100]
|
||||
const pageSize = ref(getInitialPageSize())
|
||||
const currentPage = ref(1)
|
||||
|
||||
// 账号使用详情弹窗状态
|
||||
const showAccountUsageModal = ref(false)
|
||||
const accountUsageLoading = ref(false)
|
||||
const selectedAccountForUsage = ref(null)
|
||||
const accountUsageHistory = ref([])
|
||||
const accountUsageSummary = ref({})
|
||||
const accountUsageOverview = ref({})
|
||||
const accountUsageGeneratedAt = ref('')
|
||||
|
||||
const supportedUsagePlatforms = ['claude', 'claude-console', 'openai', 'openai-responses', 'gemini']
|
||||
|
||||
// 缓存状态标志
|
||||
const apiKeysLoaded = ref(false)
|
||||
const groupsLoaded = ref(false)
|
||||
@@ -1453,6 +1495,50 @@ const accountMatchesKeyword = (account, normalizedKeyword) => {
|
||||
)
|
||||
}
|
||||
|
||||
const canViewUsage = (account) => !!account && supportedUsagePlatforms.includes(account.platform)
|
||||
|
||||
const openAccountUsageModal = async (account) => {
|
||||
if (!canViewUsage(account)) {
|
||||
showToast('该账户类型暂不支持查看详情', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
selectedAccountForUsage.value = account
|
||||
showAccountUsageModal.value = true
|
||||
accountUsageLoading.value = true
|
||||
accountUsageHistory.value = []
|
||||
accountUsageSummary.value = {}
|
||||
accountUsageOverview.value = {}
|
||||
accountUsageGeneratedAt.value = ''
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(
|
||||
`/admin/accounts/${account.id}/usage-history?platform=${account.platform}&days=30`
|
||||
)
|
||||
|
||||
if (response.success) {
|
||||
const data = response.data || {}
|
||||
accountUsageHistory.value = data.history || []
|
||||
accountUsageSummary.value = data.summary || {}
|
||||
accountUsageOverview.value = data.overview || {}
|
||||
accountUsageGeneratedAt.value = data.generatedAt || ''
|
||||
} else {
|
||||
showToast(response.error || '加载账号使用详情失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载账号使用详情失败:', error)
|
||||
showToast('加载账号使用详情失败', 'error')
|
||||
} finally {
|
||||
accountUsageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeAccountUsageModal = () => {
|
||||
showAccountUsageModal.value = false
|
||||
accountUsageLoading.value = false
|
||||
selectedAccountForUsage.value = null
|
||||
}
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
|
||||
@@ -408,7 +408,7 @@
|
||||
<i v-else class="fas fa-sort ml-1 text-gray-400" />
|
||||
</th>
|
||||
<th
|
||||
class="w-[23%] min-w-[170px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
class="operations-column sticky right-0 w-[23%] min-w-[200px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
@@ -703,7 +703,10 @@
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-3" style="font-size: 13px">
|
||||
<td
|
||||
class="operations-column operations-cell whitespace-nowrap px-3 py-3"
|
||||
style="font-size: 13px"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="rounded px-2 py-1 text-xs font-medium text-purple-600 transition-colors hover:bg-purple-50 hover:text-purple-900 dark:hover:bg-purple-900/20"
|
||||
@@ -1501,7 +1504,7 @@
|
||||
最后使用
|
||||
</th>
|
||||
<th
|
||||
class="w-[15%] min-w-[120px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
class="operations-column sticky right-0 w-[15%] min-w-[160px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
@@ -1657,7 +1660,7 @@
|
||||
</span>
|
||||
<span v-else class="text-gray-400" style="font-size: 13px">从未使用</span>
|
||||
</td>
|
||||
<td class="px-3 py-3">
|
||||
<td class="operations-column operations-cell px-3 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="key.canRestore"
|
||||
@@ -3765,19 +3768,21 @@ onMounted(async () => {
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: hidden;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 防止表格内容溢出 */
|
||||
/* 防止表格内容溢出,保证横向滚动 */
|
||||
.table-container table {
|
||||
min-width: 100%;
|
||||
min-width: 1200px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -3811,6 +3816,27 @@ onMounted(async () => {
|
||||
background-color: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
/* 固定操作列在右侧,兼容浅色和深色模式 */
|
||||
.operations-column {
|
||||
position: sticky;
|
||||
right: 0;
|
||||
background: inherit;
|
||||
background-color: inherit;
|
||||
z-index: 12;
|
||||
}
|
||||
|
||||
.table-container thead .operations-column {
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
.table-container tbody .operations-column {
|
||||
box-shadow: -8px 0 12px -8px rgba(15, 23, 42, 0.16);
|
||||
}
|
||||
|
||||
.dark .table-container tbody .operations-column {
|
||||
box-shadow: -8px 0 12px -8px rgba(30, 41, 59, 0.45);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
@@ -621,6 +621,58 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账号使用趋势图 -->
|
||||
<div class="mb-4 sm:mb-6 md:mb-8">
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-gray-100 sm:text-lg">
|
||||
账号使用趋势
|
||||
</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||
当前分组:{{ accountUsageTrendData.groupLabel || '未选择' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<div class="flex gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-700">
|
||||
<button
|
||||
v-for="option in accountGroupOptions"
|
||||
:key="option.value"
|
||||
:class="[
|
||||
'rounded-md px-2 py-1 text-xs font-medium transition-colors sm:px-3 sm:text-sm',
|
||||
accountUsageGroup === option.value
|
||||
? 'bg-white text-blue-600 shadow-sm dark:bg-gray-800'
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-300 dark:hover:text-gray-100'
|
||||
]"
|
||||
@click="handleAccountUsageGroupChange(option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mb-4 flex flex-wrap items-center gap-2 text-xs text-gray-600 dark:text-gray-400 sm:text-sm"
|
||||
>
|
||||
<span>共 {{ accountUsageTrendData.totalAccounts || 0 }} 个账号</span>
|
||||
<span
|
||||
v-if="accountUsageTrendData.topAccounts && accountUsageTrendData.topAccounts.length"
|
||||
>
|
||||
显示成本前 {{ accountUsageTrendData.topAccounts.length }} 个账号
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="!accountUsageTrendData.data || accountUsageTrendData.data.length === 0"
|
||||
class="py-12 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
暂无账号使用数据
|
||||
</div>
|
||||
<div v-else class="sm:h-[350px]" style="height: 300px">
|
||||
<canvas ref="accountUsageTrendChart" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -641,6 +693,8 @@ const {
|
||||
dashboardModelStats,
|
||||
trendData,
|
||||
apiKeysTrendData,
|
||||
accountUsageTrendData,
|
||||
accountUsageGroup,
|
||||
formattedUptime,
|
||||
dateFilter,
|
||||
trendGranularity,
|
||||
@@ -655,6 +709,7 @@ const {
|
||||
onCustomDateRangeChange,
|
||||
setTrendGranularity,
|
||||
refreshChartsData,
|
||||
setAccountUsageGroup,
|
||||
disabledDate
|
||||
} = dashboardStore
|
||||
|
||||
@@ -662,9 +717,19 @@ const {
|
||||
const modelUsageChart = ref(null)
|
||||
const usageTrendChart = ref(null)
|
||||
const apiKeysUsageTrendChart = ref(null)
|
||||
const accountUsageTrendChart = ref(null)
|
||||
let modelUsageChartInstance = null
|
||||
let usageTrendChartInstance = null
|
||||
let apiKeysUsageTrendChartInstance = null
|
||||
let accountUsageTrendChartInstance = null
|
||||
|
||||
const accountGroupOptions = [
|
||||
{ value: 'claude', label: 'Claude' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'gemini', label: 'Gemini' }
|
||||
]
|
||||
|
||||
const accountTrendUpdating = ref(false)
|
||||
|
||||
// 自动刷新相关
|
||||
const autoRefreshEnabled = ref(false)
|
||||
@@ -697,6 +762,19 @@ function formatNumber(num) {
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
function formatCostValue(cost) {
|
||||
if (!Number.isFinite(cost)) {
|
||||
return '$0.000000'
|
||||
}
|
||||
if (cost >= 1) {
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
if (cost >= 0.01) {
|
||||
return `$${cost.toFixed(3)}`
|
||||
}
|
||||
return `$${cost.toFixed(6)}`
|
||||
}
|
||||
|
||||
// 计算百分比
|
||||
function calculatePercentage(value, stats) {
|
||||
if (!stats || stats.length === 0) return 0
|
||||
@@ -1201,6 +1279,186 @@ async function updateApiKeysUsageTrendChart() {
|
||||
createApiKeysUsageTrendChart()
|
||||
}
|
||||
|
||||
function createAccountUsageTrendChart() {
|
||||
if (!accountUsageTrendChart.value) return
|
||||
|
||||
if (accountUsageTrendChartInstance) {
|
||||
accountUsageTrendChartInstance.destroy()
|
||||
}
|
||||
|
||||
const trend = accountUsageTrendData.value?.data || []
|
||||
const topAccounts = accountUsageTrendData.value?.topAccounts || []
|
||||
|
||||
const colors = [
|
||||
'#2563EB',
|
||||
'#059669',
|
||||
'#D97706',
|
||||
'#DC2626',
|
||||
'#7C3AED',
|
||||
'#F472B6',
|
||||
'#0EA5E9',
|
||||
'#F97316',
|
||||
'#6366F1',
|
||||
'#22C55E'
|
||||
]
|
||||
|
||||
const datasets = topAccounts.map((accountId, index) => {
|
||||
const dataPoints = trend.map((item) => {
|
||||
if (!item.accounts || !item.accounts[accountId]) return 0
|
||||
return item.accounts[accountId].cost || 0
|
||||
})
|
||||
|
||||
const accountName =
|
||||
trend.find((item) => item.accounts && item.accounts[accountId])?.accounts[accountId]?.name ||
|
||||
`账号 ${String(accountId).slice(0, 6)}`
|
||||
|
||||
return {
|
||||
label: accountName,
|
||||
data: dataPoints,
|
||||
borderColor: colors[index % colors.length],
|
||||
backgroundColor: colors[index % colors.length] + '20',
|
||||
tension: 0.4,
|
||||
fill: false
|
||||
}
|
||||
})
|
||||
|
||||
const labelField = trend[0]?.date ? 'date' : 'hour'
|
||||
|
||||
const chartData = {
|
||||
labels: trend.map((item) => {
|
||||
if (item.label) {
|
||||
return item.label
|
||||
}
|
||||
|
||||
if (labelField === 'hour') {
|
||||
const date = new Date(item.hour)
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hour = String(date.getHours()).padStart(2, '0')
|
||||
return `${month}/${day} ${hour}:00`
|
||||
}
|
||||
|
||||
if (item.date && item.date.includes('-')) {
|
||||
const parts = item.date.split('-')
|
||||
if (parts.length >= 3) {
|
||||
return `${parts[1]}/${parts[2]}`
|
||||
}
|
||||
}
|
||||
|
||||
return item.date
|
||||
}),
|
||||
datasets
|
||||
}
|
||||
|
||||
const topAccountIds = topAccounts
|
||||
|
||||
accountUsageTrendChartInstance = new Chart(accountUsageTrendChart.value, {
|
||||
type: 'line',
|
||||
data: chartData,
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom',
|
||||
labels: {
|
||||
padding: 20,
|
||||
usePointStyle: true,
|
||||
font: {
|
||||
size: 12
|
||||
},
|
||||
color: chartColors.value.legend
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
itemSort: (a, b) => b.parsed.y - a.parsed.y,
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.parsed.y || 0
|
||||
const dataIndex = context.dataIndex
|
||||
const datasetIndex = context.datasetIndex
|
||||
const accountId = topAccountIds[datasetIndex]
|
||||
const dataPoint = accountUsageTrendData.value.data[dataIndex]
|
||||
const accountDetail = dataPoint?.accounts?.[accountId]
|
||||
|
||||
const allValues = context.chart.data.datasets
|
||||
.map((dataset, idx) => ({
|
||||
value: dataset.data[dataIndex] || 0,
|
||||
index: idx
|
||||
}))
|
||||
.sort((a, b) => b.value - a.value)
|
||||
|
||||
const rank = allValues.findIndex((item) => item.index === datasetIndex) + 1
|
||||
let rankIcon = ''
|
||||
if (rank === 1) rankIcon = '🥇 '
|
||||
else if (rank === 2) rankIcon = '🥈 '
|
||||
else if (rank === 3) rankIcon = '🥉 '
|
||||
|
||||
const formattedCost = accountDetail?.formattedCost || formatCostValue(value)
|
||||
const requests = accountDetail?.requests || 0
|
||||
|
||||
return `${rankIcon}${label}: ${formattedCost} / ${requests.toLocaleString()} 次`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: trendGranularity.value === 'hour' ? '时间' : '日期',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text
|
||||
},
|
||||
grid: {
|
||||
color: chartColors.value.grid
|
||||
}
|
||||
},
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: '消耗金额 (USD)',
|
||||
color: chartColors.value.text
|
||||
},
|
||||
ticks: {
|
||||
callback: (value) => formatCostValue(Number(value)),
|
||||
color: chartColors.value.text
|
||||
},
|
||||
grid: {
|
||||
color: chartColors.value.grid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleAccountUsageGroupChange(group) {
|
||||
if (accountUsageGroup.value === group || accountTrendUpdating.value) {
|
||||
return
|
||||
}
|
||||
accountTrendUpdating.value = true
|
||||
try {
|
||||
await setAccountUsageGroup(group)
|
||||
await nextTick()
|
||||
createAccountUsageTrendChart()
|
||||
} finally {
|
||||
accountTrendUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听数据变化更新图表
|
||||
watch(dashboardModelStats, () => {
|
||||
nextTick(() => createModelUsageChart())
|
||||
@@ -1214,6 +1472,10 @@ watch(apiKeysTrendData, () => {
|
||||
nextTick(() => createApiKeysUsageTrendChart())
|
||||
})
|
||||
|
||||
watch(accountUsageTrendData, () => {
|
||||
nextTick(() => createAccountUsageTrendChart())
|
||||
})
|
||||
|
||||
// 刷新所有数据
|
||||
async function refreshAllData() {
|
||||
if (isRefreshing.value) return
|
||||
@@ -1297,6 +1559,7 @@ watch(isDarkMode, () => {
|
||||
createModelUsageChart()
|
||||
createUsageTrendChart()
|
||||
createApiKeysUsageTrendChart()
|
||||
createAccountUsageTrendChart()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1310,6 +1573,7 @@ onMounted(async () => {
|
||||
createModelUsageChart()
|
||||
createUsageTrendChart()
|
||||
createApiKeysUsageTrendChart()
|
||||
createAccountUsageTrendChart()
|
||||
})
|
||||
|
||||
// 清理
|
||||
@@ -1325,6 +1589,9 @@ onUnmounted(() => {
|
||||
if (apiKeysUsageTrendChartInstance) {
|
||||
apiKeysUsageTrendChartInstance.destroy()
|
||||
}
|
||||
if (accountUsageTrendChartInstance) {
|
||||
accountUsageTrendChartInstance.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user