merge: 合并 upstream/main 并保留本地图片计费功能

This commit is contained in:
song
2026-01-06 10:49:26 +08:00
187 changed files with 17081 additions and 19407 deletions

View File

@@ -0,0 +1,50 @@
<template>
<Teleport to="body">
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
<div class="py-1">
<template v-if="account">
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
{{ t('admin.accounts.testConnection') }}
</button>
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="chart" size="sm" class="text-indigo-500" />
{{ t('admin.accounts.viewStats') }}
</button>
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="link" size="sm" />
{{ t('admin.accounts.reAuthorize') }}
</button>
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="refresh" size="sm" />
{{ t('admin.accounts.refreshToken') }}
</button>
</template>
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="sync" size="sm" />
{{ t('admin.accounts.resetStatus') }}
</button>
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
<Icon name="clock" size="sm" />
{{ t('admin.accounts.clearRateLimit') }}
</button>
</template>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { Icon } from '@/components/icons'
import type { Account } from '@/types'
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
const { t } = useI18n()
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div v-if="selectedIds.length > 0" class="mb-4 flex items-center justify-between p-3 bg-primary-50 rounded-lg">
<span class="text-sm font-medium">{{ t('admin.accounts.bulkActions.selected', { count: selectedIds.length }) }}</span>
<div class="flex gap-2">
<button @click="$emit('delete')" class="btn btn-danger btn-sm">{{ t('admin.accounts.bulkActions.delete') }}</button>
<button @click="$emit('edit')" class="btn btn-primary btn-sm">{{ t('admin.accounts.bulkActions.edit') }}</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
defineProps(['selectedIds']); defineEmits(['delete', 'edit']); const { t } = useI18n()
</script>

View File

@@ -0,0 +1,674 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.usageStatistics')"
width="extra-wide"
@close="handleClose"
>
<div class="space-y-6">
<!-- Account Info Header -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="chartBar" size="md" class="text-white" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.last30DaysUsage') }}
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-12">
<LoadingSpinner />
</div>
<template v-else-if="stats">
<!-- Row 1: Main Stats Cards -->
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<!-- 30-Day Total Cost -->
<div
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalCost')
}}</span>
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
<Icon name="dollar" size="sm" class="text-emerald-600 dark:text-emerald-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.total_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.accumulatedCost') }}
<span class="text-gray-400 dark:text-gray-500"
>({{ t('admin.accounts.stats.standardCost') }}: ${{
formatCost(stats.summary.total_standard_cost)
}})</span
>
</p>
</div>
<!-- 30-Day Total Requests -->
<div
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalRequests')
}}</span>
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" />
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(stats.summary.total_requests) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.totalCalls') }}
</p>
</div>
<!-- Daily Average Cost -->
<div
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyCost')
}}</span>
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
<Icon
name="calculator"
size="sm"
class="text-amber-600 dark:text-amber-400"
/>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
${{ formatCost(stats.summary.avg_daily_cost) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{
t('admin.accounts.stats.basedOnActualDays', {
days: stats.summary.actual_days_used
})
}}
</p>
</div>
<!-- Daily Average Requests -->
<div
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
>
<div class="mb-2 flex items-center justify-between">
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgDailyRequests')
}}</span>
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
<svg
class="h-4 w-4 text-purple-600 dark:text-purple-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
/>
</svg>
</div>
</div>
<p class="text-2xl font-bold text-gray-900 dark:text-white">
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.stats.avgDailyUsage') }}
</p>
</div>
</div>
<!-- Row 2: Today, Highest Cost, Highest Requests -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Today Overview -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
<Icon name="clock" size="sm" class="text-cyan-600 dark:text-cyan-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.todayOverview')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.tokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Cost Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
<Icon name="fire" size="sm" class="text-orange-600 dark:text-orange-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestCostDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_cost_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.highest_cost_day?.requests || 0)
}}</span>
</div>
</div>
</div>
<!-- Highest Request Day -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
<Icon
name="trendingUp"
size="sm"
class="text-indigo-600 dark:text-indigo-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.highestRequestDay')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.date')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
stats.summary.highest_request_day?.label || '-'
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.requests')
}}</span>
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
formatNumber(stats.summary.highest_request_day?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.cost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Row 3: Token Stats -->
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
<!-- Accumulated Tokens -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.accumulatedTokens')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.totalTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.total_tokens)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.dailyAvgTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(Math.round(stats.summary.avg_daily_tokens))
}}</span>
</div>
</div>
</div>
<!-- Performance -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" />
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.performance')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.avgResponseTime')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatDuration(stats.summary.avg_duration_ms)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.daysActive')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="card p-4">
<div class="mb-3 flex items-center gap-2">
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
<Icon
name="clipboard"
size="sm"
class="text-lime-600 dark:text-lime-400"
/>
</div>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
t('admin.accounts.stats.recentActivity')
}}</span>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayRequests')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatNumber(stats.summary.today?.requests || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayTokens')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
formatTokens(stats.summary.today?.tokens || 0)
}}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.stats.todayCost')
}}</span>
<span class="text-sm font-semibold text-gray-900 dark:text-white"
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
>
</div>
</div>
</div>
</div>
<!-- Usage Trend Chart -->
<div class="card p-4">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.accounts.stats.usageTrend') }}
</h3>
<div class="h-64">
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
<div
v-else
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
</div>
<!-- Model Distribution -->
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
</template>
<!-- No Data State -->
<div
v-else-if="!loading"
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
>
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" />
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
>
{{ t('common.close') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import { Line } from 'vue-chartjs'
import BaseDialog from '@/components/common/BaseDialog.vue'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
import Icon from '@/components/icons/Icon.vue'
import { adminAPI } from '@/api/admin'
import type { Account, AccountUsageStatsResponse } from '@/types'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { t } = useI18n()
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const loading = ref(false)
const stats = ref<AccountUsageStatsResponse | null>(null)
// Dark mode detection
const isDarkMode = computed(() => {
return document.documentElement.classList.contains('dark')
})
// Chart colors
const chartColors = computed(() => ({
text: isDarkMode.value ? '#e5e7eb' : '#374151',
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
}))
// Line chart data
const trendChartData = computed(() => {
if (!stats.value?.history?.length) return null
return {
labels: stats.value.history.map((h) => h.label),
datasets: [
{
label: t('admin.accounts.stats.cost') + ' (USD)',
data: stats.value.history.map((h) => h.cost),
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.3,
yAxisID: 'y'
},
{
label: t('admin.accounts.stats.requests'),
data: stats.value.history.map((h) => h.requests),
borderColor: '#f97316',
backgroundColor: 'rgba(249, 115, 22, 0.1)',
fill: false,
tension: 0.3,
yAxisID: 'y1'
}
]
}
})
// Line chart options with dual Y-axis
const lineChartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const
},
plugins: {
legend: {
position: 'top' as const,
labels: {
color: chartColors.value.text,
usePointStyle: true,
pointStyle: 'circle',
padding: 15,
font: {
size: 11
}
}
},
tooltip: {
callbacks: {
label: (context: any) => {
const label = context.dataset.label || ''
const value = context.raw
if (label.includes('USD')) {
return `${label}: $${formatCost(value)}`
}
return `${label}: ${formatNumber(value)}`
}
}
}
},
scales: {
x: {
grid: {
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10
},
maxRotation: 45,
minRotation: 0
}
},
y: {
type: 'linear' as const,
display: true,
position: 'left' as const,
grid: {
color: chartColors.value.grid
},
ticks: {
color: '#3b82f6',
font: {
size: 10
},
callback: (value: string | number) => '$' + formatCost(Number(value))
},
title: {
display: true,
text: t('admin.accounts.stats.cost') + ' (USD)',
color: '#3b82f6',
font: {
size: 11
}
}
},
y1: {
type: 'linear' as const,
display: true,
position: 'right' as const,
grid: {
drawOnChartArea: false
},
ticks: {
color: '#f97316',
font: {
size: 10
},
callback: (value: string | number) => formatNumber(Number(value))
},
title: {
display: true,
text: t('admin.accounts.stats.requests'),
color: '#f97316',
font: {
size: 11
}
}
}
}
}))
// Load stats when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
await loadStats()
} else {
stats.value = null
}
}
)
const loadStats = async () => {
if (!props.account) return
loading.value = true
try {
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
} catch (error) {
console.error('Failed to load account stats:', error)
stats.value = null
} finally {
loading.value = false
}
}
const handleClose = () => {
emit('close')
}
// Format helpers
const formatCost = (value: number): string => {
if (value >= 1000) {
return (value / 1000).toFixed(2) + 'K'
} else if (value >= 1) {
return value.toFixed(2)
} else if (value >= 0.01) {
return value.toFixed(3)
}
return value.toFixed(4)
}
const formatNumber = (value: number): string => {
if (value >= 1_000_000) {
return (value / 1_000_000).toFixed(2) + 'M'
} else if (value >= 1_000) {
return (value / 1_000).toFixed(2) + 'K'
}
return value.toLocaleString()
}
const formatTokens = (value: number): string => {
if (value >= 1_000_000_000) {
return `${(value / 1_000_000_000).toFixed(2)}B`
} else if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(2)}M`
} else if (value >= 1_000) {
return `${(value / 1_000).toFixed(2)}K`
}
return value.toLocaleString()
}
const formatDuration = (ms: number): string => {
if (ms >= 1000) {
return `${(ms / 1000).toFixed(2)}s`
}
return `${Math.round(ms)}ms`
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
</button>
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import Icon from '@/components/icons/Icon.vue'
defineProps(['loading'])
defineEmits(['refresh', 'sync', 'create'])
const { t } = useI18n()
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div class="flex flex-wrap items-center gap-3">
<SearchInput
:model-value="searchQuery"
:placeholder="t('admin.accounts.searchAccounts')"
class="w-full sm:w-64"
@update:model-value="$emit('update:searchQuery', $event)"
@search="$emit('change')"
/>
<Select v-model="filters.platform" class="w-40" :options="pOpts" @change="$emit('change')" />
<Select v-model="filters.type" class="w-40" :options="tOpts" @change="$emit('change')" />
<Select v-model="filters.status" class="w-40" :options="sOpts" @change="$emit('change')" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
defineProps(['searchQuery', 'filters']); defineEmits(['update:searchQuery', 'change']); const { t } = useI18n()
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>

View File

@@ -0,0 +1,409 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.testAccountConnection')"
width="normal"
@close="handleClose"
>
<div class="space-y-4">
<!-- Account Info Card -->
<div
v-if="account"
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
>
<div class="flex items-center gap-3">
<div
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
>
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
</div>
<div>
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
>
{{ account.type }}
</span>
<span>{{ t('admin.accounts.account') }}</span>
</div>
</div>
</div>
<span
:class="[
'rounded-full px-2.5 py-1 text-xs font-semibold',
account.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
]"
>
{{ account.status }}
</span>
</div>
<div class="space-y-1.5">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
{{ t('admin.accounts.selectTestModel') }}
</label>
<Select
v-model="selectedModelId"
:options="availableModels"
:disabled="loadingModels || status === 'connecting'"
value-key="id"
label-key="display_name"
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
/>
</div>
<!-- Terminal Output -->
<div class="group relative">
<div
ref="terminalRef"
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
>
<!-- Status Line -->
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
<Icon name="play" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.readyToTest') }}</span>
</div>
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
<span>{{ t('admin.accounts.connectingToApi') }}</span>
</div>
<!-- Output Lines -->
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
{{ line.text }}
</div>
<!-- Streaming Content -->
<div v-if="streamingContent" class="text-green-400">
{{ streamingContent }}<span class="animate-pulse">_</span>
</div>
<!-- Result Status -->
<div
v-if="status === 'success'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
>
<Icon name="check" size="sm" :stroke-width="2" />
<span>{{ t('admin.accounts.testCompleted') }}</span>
</div>
<div
v-else-if="status === 'error'"
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
>
<Icon name="x" size="sm" :stroke-width="2" />
<span>{{ errorMessage }}</span>
</div>
</div>
<!-- Copy Button -->
<button
v-if="outputLines.length > 0"
@click="copyOutput"
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
:title="t('admin.accounts.copyOutput')"
>
<Icon name="link" size="sm" :stroke-width="2" />
</button>
</div>
<!-- Test Info -->
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
<div class="flex items-center gap-3">
<span class="flex items-center gap-1">
<Icon name="grid" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testModel') }}
</span>
</div>
<span class="flex items-center gap-1">
<Icon name="chat" size="sm" :stroke-width="2" />
{{ t('admin.accounts.testPrompt') }}
</span>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button
@click="handleClose"
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
:disabled="status === 'connecting'"
>
{{ t('common.close') }}
</button>
<button
@click="startTest"
:disabled="status === 'connecting' || !selectedModelId"
:class="[
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
status === 'connecting' || !selectedModelId
? 'cursor-not-allowed bg-primary-400 text-white'
: status === 'success'
? 'bg-green-500 text-white hover:bg-green-600'
: status === 'error'
? 'bg-orange-500 text-white hover:bg-orange-600'
: 'bg-primary-500 text-white hover:bg-primary-600'
]"
>
<Icon
v-if="status === 'connecting'"
name="refresh"
size="sm"
class="animate-spin"
:stroke-width="2"
/>
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
<span>
{{
status === 'connecting'
? t('admin.accounts.testing')
: status === 'idle'
? t('admin.accounts.startTest')
: t('admin.accounts.retry')
}}
</span>
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Select from '@/components/common/Select.vue'
import { Icon } from '@/components/icons'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { Account, ClaudeModel } from '@/types'
const { t } = useI18n()
const { copyToClipboard } = useClipboard()
interface OutputLine {
text: string
class: string
}
const props = defineProps<{
show: boolean
account: Account | null
}>()
const emit = defineEmits<{
(e: 'close'): void
}>()
const terminalRef = ref<HTMLElement | null>(null)
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
const outputLines = ref<OutputLine[]>([])
const streamingContent = ref('')
const errorMessage = ref('')
const availableModels = ref<ClaudeModel[]>([])
const selectedModelId = ref('')
const loadingModels = ref(false)
let eventSource: EventSource | null = null
// Load available models when modal opens
watch(
() => props.show,
async (newVal) => {
if (newVal && props.account) {
resetState()
await loadAvailableModels()
} else {
closeEventSource()
}
}
)
const loadAvailableModels = async () => {
if (!props.account) return
loadingModels.value = true
selectedModelId.value = '' // Reset selection before loading
try {
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
// Default selection by platform
if (availableModels.value.length > 0) {
if (props.account.platform === 'gemini') {
const preferred =
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
selectedModelId.value = preferred?.id || availableModels.value[0].id
} else {
// Try to select Sonnet as default, otherwise use first model
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
}
}
} catch (error) {
console.error('Failed to load available models:', error)
// Fallback to empty list
availableModels.value = []
selectedModelId.value = ''
} finally {
loadingModels.value = false
}
}
const resetState = () => {
status.value = 'idle'
outputLines.value = []
streamingContent.value = ''
errorMessage.value = ''
}
const handleClose = () => {
// 防止在连接测试进行中关闭对话框
if (status.value === 'connecting') {
return
}
closeEventSource()
emit('close')
}
const closeEventSource = () => {
if (eventSource) {
eventSource.close()
eventSource = null
}
}
const addLine = (text: string, className: string = 'text-gray-300') => {
outputLines.value.push({ text, class: className })
scrollToBottom()
}
const scrollToBottom = async () => {
await nextTick()
if (terminalRef.value) {
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
}
}
const startTest = async () => {
if (!props.account || !selectedModelId.value) return
resetState()
status.value = 'connecting'
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
addLine('', 'text-gray-300')
closeEventSource()
try {
// Create EventSource for SSE
const url = `/api/v1/admin/accounts/${props.account.id}/test`
// Use fetch with streaming for SSE since EventSource doesn't support POST
const response = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ model_id: selectedModelId.value })
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const reader = response.body?.getReader()
if (!reader) {
throw new Error('No response body')
}
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() || ''
for (const line of lines) {
if (line.startsWith('data: ')) {
const jsonStr = line.slice(6).trim()
if (jsonStr) {
try {
const event = JSON.parse(jsonStr)
handleEvent(event)
} catch (e) {
console.error('Failed to parse SSE event:', e)
}
}
}
}
}
} catch (error: any) {
status.value = 'error'
errorMessage.value = error.message || 'Unknown error'
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
}
}
const handleEvent = (event: {
type: string
text?: string
model?: string
success?: boolean
error?: string
}) => {
switch (event.type) {
case 'test_start':
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
if (event.model) {
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
}
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
addLine('', 'text-gray-300')
addLine(t('admin.accounts.response'), 'text-yellow-400')
break
case 'content':
if (event.text) {
streamingContent.value += event.text
scrollToBottom()
}
break
case 'test_complete':
// Move streaming content to output lines
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
if (event.success) {
status.value = 'success'
} else {
status.value = 'error'
errorMessage.value = event.error || 'Test failed'
}
break
case 'error':
status.value = 'error'
errorMessage.value = event.error || 'Unknown error'
if (streamingContent.value) {
addLine(streamingContent.value, 'text-green-300')
streamingContent.value = ''
}
break
}
}
const copyOutput = () => {
const text = outputLines.value.map((l) => l.text).join('\n')
copyToClipboard(text, t('admin.accounts.outputCopied'))
}
</script>

View File

@@ -0,0 +1,614 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.accounts.reAuthorizeAccount')"
width="normal"
@close="handleClose"
>
<div v-if="account" class="space-y-4">
<!-- Account Info -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
>
<div class="flex items-center gap-3">
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: isAntigravity
? 'from-purple-500 to-purple-600'
: 'from-orange-500 to-orange-600'
]"
>
<Icon name="sparkles" size="md" class="text-white" />
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{
account.name
}}</span>
<span class="text-sm text-gray-500 dark:text-gray-400">
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: isAntigravity
? t('admin.accounts.antigravityAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<fieldset v-if="isAnthropic" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="oauth"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.types.oauth')
}}</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
type="radio"
value="setup-token"
class="mr-2 text-primary-600 focus:ring-primary-500"
/>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</fieldset>
<!-- Gemini OAuth Type Selection -->
<fieldset v-if="isGemini" class="border-0 p-0">
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
<div class="mt-2 grid grid-cols-3 gap-3">
<button
type="button"
@click="handleSelectGeminiOAuthType('google_one')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'google_one'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'google_one'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="user" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
<span class="text-xs text-gray-500 dark:text-gray-400">个人账号</span>
</div>
</button>
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="cloud" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
</span>
</div>
</button>
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<Icon name="sparkles" size="sm" />
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
</span>
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
<span
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
</span>
<div
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
>
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
</div>
</div>
</div>
</button>
</div>
</fieldset>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentLoading"
:error="currentError"
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
</div>
<template #footer>
<div v-if="account" class="flex justify-between gap-3">
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
v-if="isManualInputMethod"
type="button"
:disabled="!canExchangeCode"
class="btn btn-primary"
@click="handleExchangeCode"
>
<svg
v-if="currentLoading"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
import type { Account } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue'
// Type for exposed OAuthAuthorizationFlow component
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
interface OAuthFlowExposed {
authCode: string
oauthState: string
projectId: string
sessionKey: string
inputMethod: AuthInputMethod
reset: () => void
}
interface Props {
show: boolean
account: Account | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
reauthorized: []
}>()
const appStore = useAppStore()
const { t } = useI18n()
// OAuth composables
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
const antigravityOAuth = useAntigravityOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check platform
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
if (isAntigravity.value) return antigravityOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
if (isAntigravity.value) return antigravityOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
if (isAntigravity.value) return antigravityOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
if (isAntigravity.value) return antigravityOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = currentSessionId.value
const loading = currentLoading.value
return authCode.trim() && sessionId && !loading
})
// Watchers
watch(
() => props.show,
(newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (
isAnthropic.value &&
(props.account.type === 'oauth' || props.account.type === 'setup-token')
) {
addMethod.value = props.account.type as AddMethod
}
if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
geminiOAuthType.value =
creds.oauth_type === 'google_one'
? 'google_one'
: creds.oauth_type === 'ai_studio'
? 'ai_studio'
: 'code_assist'
}
if (isGemini.value) {
geminiOAuth.getCapabilities().then((caps) => {
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
geminiOAuthType.value = 'code_assist'
}
})
}
} else {
resetState()
}
}
)
// Methods
const resetState = () => {
addMethod.value = 'oauth'
geminiOAuthType.value = 'code_assist'
geminiAIStudioOAuthEnabled.value = false
claudeOAuth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
antigravityOAuth.resetState()
oauthFlowRef.value?.reset()
}
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
}
geminiOAuthType.value = oauthType
}
const handleClose = () => {
emit('close')
}
const handleGenerateUrl = async () => {
if (!props.account) return
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const creds = (props.account.credentials || {}) as Record<string, unknown>
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
} else if (isAntigravity.value) {
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
}
const handleExchangeCode = async () => {
if (!props.account) return
const authCode = oauthFlowRef.value?.authCode || ''
if (!authCode.trim()) return
if (isOpenAI.value) {
// OpenAI OAuth flow
const sessionId = openaiOAuth.sessionId.value
if (!sessionId) return
const tokenInfo = await openaiOAuth.exchangeAuthCode(
authCode.trim(),
sessionId,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
try {
// Update account with new credentials
await adminAPI.accounts.update(props.account.id, {
type: 'oauth', // OpenAI OAuth is always 'oauth' type
credentials,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(openaiOAuth.error.value)
}
} else if (isGemini.value) {
const sessionId = geminiOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) return
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id,
oauthType: geminiOAuthType.value,
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
}
} else if (isAntigravity.value) {
// Antigravity OAuth flow
const sessionId = antigravityOAuth.sessionId.value
if (!sessionId) return
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || antigravityOAuth.state.value
if (!stateToUse) return
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId,
state: stateToUse,
proxyId: props.account.proxy_id
})
if (!tokenInfo) return
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
try {
await adminAPI.accounts.update(props.account.id, {
type: 'oauth',
credentials
})
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(antigravityOAuth.error.value)
}
} else {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
if (!sessionId) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: sessionId,
code: authCode.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(claudeOAuth.error.value)
} finally {
claudeOAuth.loading.value = false
}
}
}
const handleCookieAuth = async (sessionKey: string) => {
if (!props.account || isOpenAI.value) return
claudeOAuth.loading.value = true
claudeOAuth.error.value = ''
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
session_id: '',
code: sessionKey.trim(),
...proxyConfig
})
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
// Update account with new credentials and type
await adminAPI.accounts.update(props.account.id, {
type: addMethod.value, // Update type based on selected method
credentials: tokenInfo,
extra
})
// Clear error status after successful re-authorization
await adminAPI.accounts.clearError(props.account.id)
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value =
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
claudeOAuth.loading.value = false
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<ExportProgressDialog
:show="show"
:progress="progress"
:current="current"
:total="total"
:estimated-time="estimatedTime"
@cancel="$emit('cancel')"
/>
</template>
<script setup lang="ts">
import ExportProgressDialog from '@/components/common/ExportProgressDialog.vue'
defineProps<{ show: boolean, progress: number, current: number, total: number, estimatedTime: string }>()
defineEmits(['cancel'])
</script>

View File

@@ -0,0 +1,353 @@
<template>
<div class="card p-6">
<!-- Toolbar: left filters (multi-line) + right actions -->
<div class="flex flex-wrap items-end justify-between gap-4">
<!-- Left: filters (allowed to wrap to multiple rows) -->
<div class="flex flex-1 flex-wrap items-end gap-4">
<!-- User Search -->
<div ref="userSearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('admin.usage.userFilter') }}</label>
<input
v-model="userKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchUserPlaceholder')"
@input="debounceUserSearch"
@focus="showUserDropdown = true"
/>
<button
v-if="filters.user_id"
type="button"
@click="clearUser"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear user filter"
>
</button>
<div
v-if="showUserDropdown && (userResults.length > 0 || userKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="u in userResults"
:key="u.id"
type="button"
@click="selectUser(u)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span>{{ u.email }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ u.id }}</span>
</button>
</div>
</div>
<!-- API Key Search -->
<div ref="apiKeySearchRef" class="usage-filter-dropdown relative w-full sm:w-auto sm:min-w-[240px]">
<label class="input-label">{{ t('usage.apiKeyFilter') }}</label>
<input
v-model="apiKeyKeyword"
type="text"
class="input pr-8"
:placeholder="t('admin.usage.searchApiKeyPlaceholder')"
@input="debounceApiKeySearch"
@focus="showApiKeyDropdown = true"
/>
<button
v-if="filters.api_key_id"
type="button"
@click="onClearApiKey"
class="absolute right-2 top-9 text-gray-400"
aria-label="Clear API key filter"
>
</button>
<div
v-if="showApiKeyDropdown && (apiKeyResults.length > 0 || apiKeyKeyword)"
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:bg-gray-800"
>
<button
v-for="k in apiKeyResults"
:key="k.id"
type="button"
@click="selectApiKey(k)"
class="w-full px-4 py-2 text-left hover:bg-gray-100 dark:hover:bg-gray-700"
>
<span class="truncate">{{ k.name || `#${k.id}` }}</span>
<span class="ml-2 text-xs text-gray-400">#{{ k.id }}</span>
</button>
</div>
</div>
<!-- Model Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('usage.model') }}</label>
<Select v-model="filters.model" :options="modelOptions" searchable @change="emitChange" />
</div>
<!-- Account Filter -->
<div class="w-full sm:w-auto sm:min-w-[220px]">
<label class="input-label">{{ t('admin.usage.account') }}</label>
<Select v-model="filters.account_id" :options="accountOptions" searchable @change="emitChange" />
</div>
<!-- Stream Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.type') }}</label>
<Select v-model="filters.stream" :options="streamTypeOptions" @change="emitChange" />
</div>
<!-- Billing Type Filter -->
<div class="w-full sm:w-auto sm:min-w-[180px]">
<label class="input-label">{{ t('usage.billingType') }}</label>
<Select v-model="filters.billing_type" :options="billingTypeOptions" @change="emitChange" />
</div>
<!-- Group Filter -->
<div class="w-full sm:w-auto sm:min-w-[200px]">
<label class="input-label">{{ t('admin.usage.group') }}</label>
<Select v-model="filters.group_id" :options="groupOptions" searchable @change="emitChange" />
</div>
<!-- Date Range Filter -->
<div class="w-full sm:w-auto [&_.date-picker-trigger]:w-full">
<label class="input-label">{{ t('usage.timeRange') }}</label>
<DateRangePicker
:start-date="startDate"
:end-date="endDate"
@update:startDate="updateStartDate"
@update:endDate="updateEndDate"
@change="emitChange"
/>
</div>
</div>
<!-- Right: actions -->
<div class="flex w-full flex-wrap items-center justify-end gap-3 sm:w-auto">
<button type="button" @click="$emit('reset')" class="btn btn-secondary">
{{ t('common.reset') }}
</button>
<button type="button" @click="$emit('export')" :disabled="exporting" class="btn btn-primary">
{{ t('usage.exportExcel') }}
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import Select, { type SelectOption } from '@/components/common/Select.vue'
import DateRangePicker from '@/components/common/DateRangePicker.vue'
import type { SimpleApiKey, SimpleUser } from '@/api/admin/usage'
type ModelValue = Record<string, any>
interface Props {
modelValue: ModelValue
exporting: boolean
startDate: string
endDate: string
}
const props = defineProps<Props>()
const emit = defineEmits([
'update:modelValue',
'update:startDate',
'update:endDate',
'change',
'reset',
'export'
])
const { t } = useI18n()
const filters = toRef(props, 'modelValue')
const userSearchRef = ref<HTMLElement | null>(null)
const apiKeySearchRef = ref<HTMLElement | null>(null)
const userKeyword = ref('')
const userResults = ref<SimpleUser[]>([])
const showUserDropdown = ref(false)
let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
const apiKeyKeyword = ref('')
const apiKeyResults = ref<SimpleApiKey[]>([])
const showApiKeyDropdown = ref(false)
let apiKeySearchTimeout: ReturnType<typeof setTimeout> | null = null
const modelOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allModels') }])
const groupOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allGroups') }])
const accountOptions = ref<SelectOption[]>([{ value: null, label: t('admin.usage.allAccounts') }])
const streamTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allTypes') },
{ value: true, label: t('usage.stream') },
{ value: false, label: t('usage.sync') }
])
const billingTypeOptions = ref<SelectOption[]>([
{ value: null, label: t('admin.usage.allBillingTypes') },
{ value: 1, label: t('usage.subscription') },
{ value: 0, label: t('usage.balance') }
])
const emitChange = () => emit('change')
const updateStartDate = (value: string) => {
emit('update:startDate', value)
filters.value.start_date = value
}
const updateEndDate = (value: string) => {
emit('update:endDate', value)
filters.value.end_date = value
}
const debounceUserSearch = () => {
if (userSearchTimeout) clearTimeout(userSearchTimeout)
userSearchTimeout = setTimeout(async () => {
if (!userKeyword.value) {
userResults.value = []
return
}
try {
userResults.value = await adminAPI.usage.searchUsers(userKeyword.value)
} catch {
userResults.value = []
}
}, 300)
}
const debounceApiKeySearch = () => {
if (apiKeySearchTimeout) clearTimeout(apiKeySearchTimeout)
apiKeySearchTimeout = setTimeout(async () => {
if (!apiKeyKeyword.value) {
apiKeyResults.value = []
return
}
try {
apiKeyResults.value = await adminAPI.usage.searchApiKeys(
filters.value.user_id,
apiKeyKeyword.value
)
} catch {
apiKeyResults.value = []
}
}, 300)
}
const selectUser = (u: SimpleUser) => {
userKeyword.value = u.email
showUserDropdown.value = false
filters.value.user_id = u.id
clearApiKey()
emitChange()
}
const clearUser = () => {
userKeyword.value = ''
userResults.value = []
showUserDropdown.value = false
filters.value.user_id = undefined
clearApiKey()
emitChange()
}
const selectApiKey = (k: SimpleApiKey) => {
apiKeyKeyword.value = k.name || String(k.id)
showApiKeyDropdown.value = false
filters.value.api_key_id = k.id
emitChange()
}
const clearApiKey = () => {
apiKeyKeyword.value = ''
apiKeyResults.value = []
showApiKeyDropdown.value = false
filters.value.api_key_id = undefined
}
const onClearApiKey = () => {
clearApiKey()
emitChange()
}
const onDocumentClick = (e: MouseEvent) => {
const target = e.target as Node | null
if (!target) return
const clickedInsideUser = userSearchRef.value?.contains(target) ?? false
const clickedInsideApiKey = apiKeySearchRef.value?.contains(target) ?? false
if (!clickedInsideUser) showUserDropdown.value = false
if (!clickedInsideApiKey) showApiKeyDropdown.value = false
}
watch(
() => props.startDate,
(value) => {
filters.value.start_date = value
},
{ immediate: true }
)
watch(
() => props.endDate,
(value) => {
filters.value.end_date = value
},
{ immediate: true }
)
watch(
() => filters.value.user_id,
(userId) => {
if (!userId) {
userKeyword.value = ''
userResults.value = []
}
}
)
watch(
() => filters.value.api_key_id,
(apiKeyId) => {
if (!apiKeyId) {
apiKeyKeyword.value = ''
apiKeyResults.value = []
}
}
)
onMounted(async () => {
document.addEventListener('click', onDocumentClick)
try {
const [gs, ms, as] = await Promise.all([
adminAPI.groups.list(1, 1000),
adminAPI.dashboard.getModelStats({ start_date: props.startDate, end_date: props.endDate }),
adminAPI.accounts.list(1, 1000)
])
groupOptions.value.push(...gs.items.map((g: any) => ({ value: g.id, label: g.name })))
accountOptions.value.push(...as.items.map((a: any) => ({ value: a.id, label: a.name })))
const uniqueModels = new Set<string>()
ms.models?.forEach((s: any) => s.model && uniqueModels.add(s.model))
modelOptions.value.push(
...Array.from(uniqueModels)
.sort()
.map((m) => ({ value: m, label: m }))
)
} catch {
// Ignore filter option loading errors (page still usable)
}
})
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
<Icon name="document" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
<Icon name="dollar" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
</div>
<div class="card p-4 flex items-center gap-3">
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
<Icon name="clock" size="md" />
</div>
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
import Icon from '@/components/icons/Icon.vue'
defineProps<{ stats: AdminUsageStatsResponse | null }>()
const { t } = useI18n()
const formatDuration = (ms: number) =>
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
const formatTokens = (value: number) => {
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
return value.toLocaleString()
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div class="card overflow-hidden">
<div class="overflow-auto">
<DataTable :columns="cols" :data="data" :loading="loading">
<template #cell-user="{ row }">
<div class="text-sm">
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
</div>
</template>
<template #cell-api_key="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
</template>
<template #cell-account="{ row }">
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
</template>
<template #cell-model="{ value }">
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
</template>
<template #cell-group="{ row }">
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
{{ row.group.name }}
</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-stream="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
</span>
</template>
<template #cell-tokens="{ row }">
<!-- 图片生成请求 -->
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
</div>
<!-- Token 请求 -->
<div v-else class="space-y-1 text-sm">
<div class="flex items-center gap-2">
<div class="inline-flex items-center gap-1">
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
</div>
<div class="inline-flex items-center gap-1">
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
</div>
</div>
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
</div>
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
</div>
</div>
</div>
</template>
<template #cell-cost="{ row }">
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
</template>
<template #cell-billing_type="{ row }">
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
</span>
</template>
<template #cell-first_token="{ row }">
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
</template>
<template #cell-duration="{ row }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
</template>
<template #cell-created_at="{ value }">
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
</template>
<template #cell-request_id="{ row }">
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span>
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')">
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" />
</button>
</div>
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
</template>
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
</DataTable>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDateTime } from '@/utils/format'
import { useAppStore } from '@/stores/app'
import DataTable from '@/components/common/DataTable.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import Icon from '@/components/icons/Icon.vue'
defineProps(['data', 'loading'])
const { t } = useI18n()
const appStore = useAppStore()
const copiedRequestId = ref<string | null>(null)
const cols = computed(() => [
{ key: 'user', label: t('admin.usage.user'), sortable: false },
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
{ key: 'account', label: t('admin.usage.account'), sortable: false },
{ key: 'model', label: t('usage.model'), sortable: true },
{ key: 'group', label: t('admin.usage.group'), sortable: false },
{ key: 'stream', label: t('usage.type'), sortable: false },
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
{ key: 'cost', label: t('usage.cost'), sortable: false },
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
{ key: 'duration', label: t('usage.duration'), sortable: false },
{ key: 'created_at', label: t('usage.time'), sortable: true },
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
])
const formatCacheTokens = (tokens: number): string => {
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
return tokens.toString()
}
const formatDuration = (ms: number | null | undefined): string => {
if (ms == null) return '-'
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(2)}s`
}
const copyRequestId = async (requestId: string) => {
try {
await navigator.clipboard.writeText(requestId)
copiedRequestId.value = requestId
appStore.showSuccess(t('admin.usage.requestIdCopied'))
setTimeout(() => { copiedRequestId.value = null }, 2000)
} catch {
appStore.showError(t('common.copyFailed'))
}
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.setAllowedGroups')" width="normal" @close="$emit('close')">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100">
<span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
<div v-else>
<p class="mb-3 text-sm text-gray-600">{{ t('admin.users.allowedGroupsHint') }}</p>
<div class="max-h-64 space-y-2 overflow-y-auto">
<label v-for="group in groups" :key="group.id" class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
<input type="checkbox" :value="group.id" v-model="selectedIds" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
<div class="flex-1"><p class="font-medium text-gray-900">{{ group.name }}</p><p v-if="group.description" class="truncate text-sm text-gray-500">{{ group.description }}</p></div>
<div class="flex items-center gap-2"><span class="badge badge-gray text-xs">{{ group.platform }}</span><span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span></div>
</label>
</div>
<div class="mt-4 border-t border-gray-200 pt-4">
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-green-300 bg-green-50': selectedIds.length === 0}">
<input type="radio" :checked="selectedIds.length === 0" @change="selectedIds = []" class="h-4 w-4 border-gray-300 text-green-600" />
<div class="flex-1"><p class="font-medium text-gray-900">{{ t('admin.users.allowAllGroups') }}</p><p class="text-sm text-gray-500">{{ t('admin.users.allowAllGroupsHint') }}</p></div>
</label>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button @click="handleSave" :disabled="submitting" class="btn btn-primary">{{ submitting ? t('common.saving') : t('common.save') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User, Group } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } }
const handleSave = async () => {
if (!props.user) return; submitting.value = true
try {
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
} catch {} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,47 @@
<template>
<BaseDialog :show="show" :title="t('admin.users.userApiKeys')" width="wide" @close="$emit('close')">
<div v-if="user" class="space-y-4">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100 dark:bg-primary-900/30">
<span class="text-lg font-medium text-primary-700 dark:text-primary-300">{{ user.email.charAt(0).toUpperCase() }}</span>
</div>
<div><p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p><p class="text-sm text-gray-500 dark:text-dark-400">{{ user.username }}</p></div>
</div>
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
<div v-else-if="apiKeys.length === 0" class="py-8 text-center"><p class="text-sm text-gray-500">{{ t('admin.users.noApiKeys') }}</p></div>
<div v-else class="max-h-96 space-y-3 overflow-y-auto">
<div v-for="key in apiKeys" :key="key.id" class="rounded-xl border border-gray-200 bg-white p-4 dark:border-dark-600 dark:bg-dark-800">
<div class="flex items-start justify-between">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2"><span class="font-medium text-gray-900 dark:text-white">{{ key.name }}</span><span :class="['badge text-xs', key.status === 'active' ? 'badge-success' : 'badge-danger']">{{ key.status }}</span></div>
<p class="truncate font-mono text-sm text-gray-500">{{ key.key.substring(0, 20) }}...{{ key.key.substring(key.key.length - 8) }}</p>
</div>
</div>
<div class="mt-3 flex flex-wrap gap-4 text-xs text-gray-500">
<div class="flex items-center gap-1"><span>{{ t('admin.users.group') }}: {{ key.group?.name || t('admin.users.none') }}</span></div>
<div class="flex items-center gap-1"><span>{{ t('admin.users.columns.created') }}: {{ formatDateTime(key.created_at) }}</span></div>
</div>
</div>
</div>
</div>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api/admin'
import { formatDateTime } from '@/utils/format'
import type { User, ApiKey } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
defineEmits(['close']); const { t } = useI18n()
const apiKeys = ref<ApiKey[]>([]); const loading = ref(false)
watch(() => props.show, (v) => { if (v && props.user) load() })
const load = async () => {
if (!props.user) return; loading.value = true
try { const res = await adminAPI.users.getUserApiKeys(props.user.id); apiKeys.value = res.items || [] } catch {} finally { loading.value = false }
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<BaseDialog :show="show" :title="operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" width="narrow" @close="$emit('close')">
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ user.balance.toFixed(2) }}</p></div>
</div>
<div>
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
<div class="relative"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="0.01" min="0.01" required class="input pl-8" /></div>
</div>
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ calculateNewBalance().toFixed(2) }}</span></div></div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="balance-form" :disabled="submitting || !form.amount" class="btn" :class="operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'">{{ submitting ? t('common.saving') : t('common.confirm') }}</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
import type { User } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0)
const handleBalanceSubmit = async () => {
if (!props.user) return
if (!form.amount || form.amount <= 0) {
appStore.showError(t('admin.users.amountRequired'))
return
}
if (props.operation === 'subtract' && form.amount > props.user.balance) {
appStore.showError(t('admin.users.insufficientBalance'))
return
}
submitting.value = true
try {
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('common.error'))
} finally { submitting.value = false }
}
</script>

View File

@@ -0,0 +1,78 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.createUser')"
width="normal"
@close="$emit('close')"
>
<form id="create-user-form" @submit.prevent="submit" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="form.email" type="email" required class="input" :placeholder="t('admin.users.enterEmail')" />
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input v-model="form.password" type="text" required class="input pr-10" :placeholder="t('admin.users.enterPassword')" />
</div>
<button type="button" @click="generateRandomPassword" class="btn btn-secondary px-3">
<Icon name="refresh" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
<input v-model.number="form.balance" type="number" step="any" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
</div>
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="create-user-form" :disabled="loading" class="btn btn-primary">
{{ loading ? t('admin.users.creating') : t('common.create') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
import { useForm } from '@/composables/useForm'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
const { loading, submit } = useForm({
form,
submitFn: async (data) => {
await adminAPI.users.create(data)
emit('success'); emit('close')
},
successMsg: t('admin.users.userCreated')
})
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }) })
const generateRandomPassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<BaseDialog
:show="show"
:title="t('admin.users.editUser')"
width="normal"
@close="$emit('close')"
>
<form v-if="user" id="edit-user-form" @submit.prevent="handleUpdateUser" class="space-y-5">
<div>
<label class="input-label">{{ t('admin.users.email') }}</label>
<input v-model="form.email" type="email" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.password') }}</label>
<div class="flex gap-2">
<div class="relative flex-1">
<input v-model="form.password" type="text" class="input pr-10" :placeholder="t('admin.users.enterNewPassword')" />
<button v-if="form.password" type="button" @click="copyPassword" class="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="passwordCopied ? 'text-green-500' : 'text-gray-400'">
<svg v-if="passwordCopied" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" /></svg>
</button>
</div>
<button type="button" @click="generatePassword" class="btn btn-secondary px-3">
<Icon name="refresh" size="md" />
</button>
</div>
</div>
<div>
<label class="input-label">{{ t('admin.users.username') }}</label>
<input v-model="form.username" type="text" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.users.notes') }}</label>
<textarea v-model="form.notes" rows="3" class="input"></textarea>
</div>
<div>
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
<input v-model.number="form.concurrency" type="number" class="input" />
</div>
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
</form>
<template #footer>
<div class="flex justify-end gap-3">
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
<button type="submit" form="edit-user-form" :disabled="submitting" class="btn btn-primary">
{{ submitting ? t('admin.users.updating') : t('common.update') }}
</button>
</div>
</template>
</BaseDialog>
</template>
<script setup lang="ts">
import { ref, reactive, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
import { useClipboard } from '@/composables/useClipboard'
import { adminAPI } from '@/api/admin'
import type { User, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
const props = defineProps<{ show: boolean, user: User | null }>()
const emit = defineEmits(['close', 'success'])
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
const submitting = ref(false); const passwordCopied = ref(false)
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
watch(() => props.user, (u) => {
if (u) {
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
passwordCopied.value = false
}
}, { immediate: true })
const generatePassword = () => {
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
form.password = p
}
const copyPassword = async () => {
if (form.password && await copyToClipboard(form.password, t('admin.users.passwordCopied'))) {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const handleUpdateUser = async () => {
if (!props.user) return
if (!form.email.trim()) {
appStore.showError(t('admin.users.emailRequired'))
return
}
if (form.concurrency < 1) {
appStore.showError(t('admin.users.concurrencyMin'))
return
}
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
appStore.showSuccess(t('admin.users.userUpdated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
} finally { submitting.value = false }
}
</script>