Merge branch 'main' into test-dev
This commit is contained in:
@@ -34,6 +34,9 @@ export interface SystemSettings {
|
||||
turnstile_enabled: boolean
|
||||
turnstile_site_key: string
|
||||
turnstile_secret_key_configured: boolean
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
enable_identity_patch: boolean
|
||||
identity_patch_prompt: string
|
||||
}
|
||||
|
||||
export interface UpdateSettingsRequest {
|
||||
@@ -57,6 +60,8 @@ export interface UpdateSettingsRequest {
|
||||
turnstile_enabled?: boolean
|
||||
turnstile_site_key?: string
|
||||
turnstile_secret_key?: string
|
||||
enable_identity_patch?: boolean
|
||||
identity_patch_prompt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import type { ApiResponse } from '@/types'
|
||||
import { getLocale } from '@/i18n'
|
||||
|
||||
// ==================== Axios Instance Configuration ====================
|
||||
|
||||
@@ -27,6 +28,12 @@ apiClient.interceptors.request.use(
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
// Attach locale for backend translations
|
||||
if (config.headers) {
|
||||
config.headers['Accept-Language'] = getLocale()
|
||||
}
|
||||
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
v-if="isTempUnschedulable"
|
||||
type="button"
|
||||
:class="['badge text-xs', statusClass, 'cursor-pointer']"
|
||||
:title="t('admin.accounts.tempUnschedulable.viewDetails')"
|
||||
:title="t('admin.accounts.status.viewTempUnschedDetails')"
|
||||
@click="handleTempUnschedClick"
|
||||
>
|
||||
{{ statusText }}
|
||||
@@ -61,7 +61,7 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
Rate limited until {{ formatTime(account.rate_limit_reset_at) }}
|
||||
{{ t('admin.accounts.status.rateLimitedUntil', { time: formatTime(account.rate_limit_reset_at) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
@@ -86,7 +86,7 @@
|
||||
<div
|
||||
class="pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 whitespace-nowrap rounded bg-gray-900 px-2 py-1 text-xs text-white opacity-0 transition-opacity group-hover:opacity-100 dark:bg-gray-700"
|
||||
>
|
||||
Overloaded until {{ formatTime(account.overload_until) }}
|
||||
{{ t('admin.accounts.status.overloadedUntil', { time: formatTime(account.overload_until) }) }}
|
||||
<div
|
||||
class="absolute left-1/2 top-full -translate-x-1/2 border-4 border-transparent border-t-gray-900 dark:border-t-gray-700"
|
||||
></div>
|
||||
@@ -160,7 +160,7 @@ const statusClass = computed(() => {
|
||||
// Computed: status text
|
||||
const statusText = computed(() => {
|
||||
if (hasError.value) {
|
||||
return t('common.error')
|
||||
return t('admin.accounts.status.error')
|
||||
}
|
||||
if (isTempUnschedulable.value) {
|
||||
return t('admin.accounts.status.tempUnschedulable')
|
||||
@@ -171,7 +171,7 @@ const statusText = computed(() => {
|
||||
if (isRateLimited.value || isOverloaded.value) {
|
||||
return t('admin.accounts.status.limited')
|
||||
}
|
||||
return t(`common.${props.account.status}`)
|
||||
return t(`admin.accounts.status.${props.account.status}`)
|
||||
})
|
||||
|
||||
const handleTempUnschedClick = () => {
|
||||
@@ -179,4 +179,4 @@ const handleTempUnschedClick = () => {
|
||||
emit('show-temp-unsched', props.account)
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
@@ -48,21 +48,18 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Model Selection -->
|
||||
<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
|
||||
<Select
|
||||
v-model="selectedModelId"
|
||||
:options="availableModels"
|
||||
:disabled="loadingModels || status === 'connecting'"
|
||||
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-500 dark:bg-dark-700 dark:text-gray-100"
|
||||
>
|
||||
<option v-if="loadingModels" value="">{{ t('common.loading') }}...</option>
|
||||
<option v-for="model in availableModels" :key="model.id" :value="model.id">
|
||||
{{ model.display_name }} ({{ model.id }})
|
||||
</option>
|
||||
</select>
|
||||
value-key="id"
|
||||
label-key="display_name"
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
@@ -280,6 +277,7 @@
|
||||
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 { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, ClaudeModel } from '@/types'
|
||||
|
||||
@@ -1522,7 +1522,7 @@
|
||||
</ul>
|
||||
<div class="mt-2 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="https://gemini.google.com/faq#location"
|
||||
href="https://policies.google.com/terms"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -1531,7 +1531,16 @@
|
||||
</a>
|
||||
<span class="text-gray-400">·</span>
|
||||
<a
|
||||
href="https://gemini.google.com"
|
||||
href="https://policies.google.com/country-association-form"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
>
|
||||
修改归属地
|
||||
</a>
|
||||
<span class="text-gray-400">·</span>
|
||||
<a
|
||||
href="https://gemini.google.com/gems/create?hl=en-US&pli=1"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm text-blue-600 hover:underline dark:text-blue-400"
|
||||
@@ -1869,8 +1878,9 @@ const geminiHelpLinks = {
|
||||
apiKey: 'https://aistudio.google.com/app/apikey',
|
||||
aiStudioPricing: 'https://ai.google.dev/pricing',
|
||||
gcpProject: 'https://console.cloud.google.com/welcome/new',
|
||||
geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US',
|
||||
countryCheck: 'https://policies.google.com/country-association-form'
|
||||
geminiWebActivation: 'https://gemini.google.com/gems/create?hl=en-US&pli=1',
|
||||
countryCheck: 'https://policies.google.com/terms',
|
||||
countryChange: 'https://policies.google.com/country-association-form'
|
||||
}
|
||||
|
||||
// Computed: current preset mappings based on platform
|
||||
|
||||
21
frontend/src/components/admin/account/AccountActionMenu.vue
Normal file
21
frontend/src/components/admin/account/AccountActionMenu.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<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"><span class="text-green-500">▶</span> {{ 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"><span class="text-indigo-500">📊</span> {{ 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 hover:bg-gray-100 text-blue-600">🔗 {{ 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 hover:bg-gray-100 text-purple-600">🔄 {{ t('admin.accounts.refreshToken') }}</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
defineProps(['show', 'account', 'position']); defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token']); const { t } = useI18n()
|
||||
</script>
|
||||
@@ -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>
|
||||
783
frontend/src/components/admin/account/AccountStatsModal.vue
Normal file
783
frontend/src/components/admin/account/AccountStatsModal.vue
Normal file
@@ -0,0 +1,783 @@
|
||||
<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"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-emerald-600 dark:text-emerald-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-cyan-600 dark:text-cyan-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-orange-600 dark:text-orange-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-indigo-600 dark:text-indigo-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-teal-600 dark:text-teal-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-rose-600 dark:text-rose-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 text-lime-600 dark:text-lime-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
|
||||
/>
|
||||
</svg>
|
||||
</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"
|
||||
>
|
||||
<svg class="mb-4 h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<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 { 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>
|
||||
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex max-w-full flex-wrap justify-end gap-3">
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary flex-shrink-0"><svg :class="['h-5 w-5', loading ? 'animate-spin' : '']" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg></button>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary flex-shrink-0">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<button @click="$emit('create')" class="btn btn-primary flex-shrink-0">{{ t('admin.accounts.createAccount') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'; defineProps(['loading']); defineEmits(['refresh', 'sync', 'create']); const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-start gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
@search="$emit('change')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Select v-model="filters.platform" class="w-40 flex-shrink-0" :options="pOpts" @change="$emit('change')" />
|
||||
<Select v-model="filters.status" class="w-40 flex-shrink-0" :options="sOpts" @change="$emit('change')" />
|
||||
</div>
|
||||
</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: 'openai', label: 'OpenAI' }, { value: 'anthropic', label: 'Anthropic' }, { value: 'gemini', label: 'Gemini' }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'error', label: t('admin.accounts.status.error') }])
|
||||
</script>
|
||||
510
frontend/src/components/admin/account/AccountTestModal.vue
Normal file
510
frontend/src/components/admin/account/AccountTestModal.vue
Normal file
@@ -0,0 +1,510 @@
|
||||
<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"
|
||||
>
|
||||
<svg class="h-5 w-5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5.121 17.804A13.937 13.937 0 0112 16c2.5 0 4.847.655 6.879 1.804M15 10a3 3 0 11-6 0 3 3 0 016 0zm6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
<svg class="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>
|
||||
<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"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<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"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<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')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</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">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
</svg>
|
||||
{{ 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'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
v-if="status === 'connecting'"
|
||||
class="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>
|
||||
<svg
|
||||
v-else-if="status === 'idle'"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
<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 { 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>
|
||||
651
frontend/src/components/admin/account/ReAuthAccountModal.vue
Normal file
651
frontend/src/components/admin/account/ReAuthAccountModal.vue
Normal file
@@ -0,0 +1,651 @@
|
||||
<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'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-5 w-5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
</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'
|
||||
]"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
</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'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
|
||||
/>
|
||||
</svg>
|
||||
</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'
|
||||
]"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
</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 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>
|
||||
16
frontend/src/components/admin/usage/UsageExportProgress.vue
Normal file
16
frontend/src/components/admin/usage/UsageExportProgress.vue
Normal 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>
|
||||
353
frontend/src/components/admin/usage/UsageFilters.vue
Normal file
353
frontend/src/components/admin/usage/UsageFilters.vue
Normal 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>
|
||||
27
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
27
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<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"><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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg></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"><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="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></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"><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="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /></svg></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'
|
||||
defineProps<{ stats: AdminUsageStatsResponse | null }>(); const { t } = useI18n()
|
||||
const formatDuration = (ms: number) => ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms/1000).toFixed(2)}s`
|
||||
const formatTokens = (v: number) => { if (v >= 1e9) return (v/1e9).toFixed(2) + 'B'; if (v >= 1e6) return (v/1e6).toFixed(2) + 'M'; if (v >= 1e3) return (v/1e3).toFixed(2) + 'K'; return v.toLocaleString() }
|
||||
</script>
|
||||
22
frontend/src/components/admin/usage/UsageTable.vue
Normal file
22
frontend/src/components/admin/usage/UsageTable.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<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-xs text-gray-400">#{{ row.user_id }}</span></div></template>
|
||||
<template #cell-model="{ value }"><span class="font-medium">{{ value }}</span></template>
|
||||
<template #cell-tokens="{ row }"><div class="text-sm">In: {{ row.input_tokens.toLocaleString() }} / Out: {{ row.output_tokens.toLocaleString() }}</div></template>
|
||||
<template #cell-cost="{ row }"><span class="font-medium text-green-600">${{ row.actual_cost.toFixed(6) }}</span></template>
|
||||
<template #cell-created_at="{ value }"><span class="text-sm text-gray-500">{{ formatDateTime(value) }}</span></template>
|
||||
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
|
||||
</DataTable>
|
||||
</div></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import { formatDateTime } from '@/utils/format'; import DataTable from '@/components/common/DataTable.vue'; import EmptyState from '@/components/common/EmptyState.vue'
|
||||
defineProps(['data', 'loading']); const { t } = useI18n()
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user') }, { key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'tokens', label: t('usage.tokens') }, { key: 'cost', label: t('usage.cost') },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true }
|
||||
])
|
||||
</script>
|
||||
@@ -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.length > 0 ? selectedIds.value : null })
|
||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
||||
} catch {} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
47
frontend/src/components/admin/user/UserApiKeysModal.vue
Normal file
47
frontend/src/components/admin/user/UserApiKeysModal.vue
Normal 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>
|
||||
46
frontend/src/components/admin/user/UserBalanceModal.vue
Normal file
46
frontend/src/components/admin/user/UserBalanceModal.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<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; 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 {} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
77
frontend/src/components/admin/user/UserCreateModal.vue
Normal file
77
frontend/src/components/admin/user/UserCreateModal.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<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">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</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'
|
||||
|
||||
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>
|
||||
101
frontend/src/components/admin/user/UserEditModal.vue
Normal file
101
frontend/src/components/admin/user/UserEditModal.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<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">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /></svg>
|
||||
</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'
|
||||
|
||||
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
|
||||
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>
|
||||
52
frontend/src/components/common/GroupOptionItem.vue
Normal file
52
frontend/src/components/common/GroupOptionItem.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div class="flex min-w-0 flex-1 items-center justify-between gap-2">
|
||||
<div
|
||||
class="flex min-w-0 flex-1 flex-col items-start gap-1"
|
||||
:title="description || undefined"
|
||||
>
|
||||
<GroupBadge
|
||||
:name="name"
|
||||
:platform="platform"
|
||||
:subscription-type="subscriptionType"
|
||||
:rate-multiplier="rateMultiplier"
|
||||
/>
|
||||
<span
|
||||
v-if="description"
|
||||
class="w-full truncate text-left text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ description }}
|
||||
</span>
|
||||
</div>
|
||||
<svg
|
||||
v-if="showCheckmark && selected"
|
||||
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
|
||||
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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import GroupBadge from './GroupBadge.vue'
|
||||
import type { SubscriptionType, GroupPlatform } from '@/types'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
platform: GroupPlatform
|
||||
subscriptionType?: SubscriptionType
|
||||
rateMultiplier?: number
|
||||
description?: string | null
|
||||
selected?: boolean
|
||||
showCheckmark?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subscriptionType: 'standard',
|
||||
selected: false,
|
||||
showCheckmark: true
|
||||
})
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
Groups
|
||||
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
|
||||
{{ t('admin.users.groups') }}
|
||||
<span class="font-normal text-gray-400">{{ t('common.selectedCount', { count: modelValue.length }) }}</span>
|
||||
</label>
|
||||
<div
|
||||
class="grid max-h-32 grid-cols-2 gap-1 overflow-y-auto rounded-lg border border-gray-200 bg-gray-50 p-2 dark:border-dark-600 dark:bg-dark-800"
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="filteredGroups.length === 0"
|
||||
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
No groups available
|
||||
{{ t('common.noGroupsAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
103
frontend/src/components/common/Input.vue
Normal file
103
frontend/src/components/common/Input.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<label v-if="label" :for="id" class="input-label mb-1.5 block">
|
||||
{{ label }}
|
||||
<span v-if="required" class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<!-- Prefix Icon Slot -->
|
||||
<div
|
||||
v-if="$slots.prefix"
|
||||
class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5 text-gray-400 dark:text-dark-400"
|
||||
>
|
||||
<slot name="prefix"></slot>
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
ref="inputRef"
|
||||
:type="type"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:placeholder="placeholderText"
|
||||
:autocomplete="autocomplete"
|
||||
:readonly="readonly"
|
||||
:class="[
|
||||
'input w-full transition-all duration-200',
|
||||
$slots.prefix ? 'pl-11' : '',
|
||||
$slots.suffix ? 'pr-11' : '',
|
||||
error ? 'input-error ring-2 ring-red-500/20' : '',
|
||||
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
|
||||
]"
|
||||
@input="onInput"
|
||||
@change="$emit('change', ($event.target as HTMLInputElement).value)"
|
||||
@blur="$emit('blur', $event)"
|
||||
@focus="$emit('focus', $event)"
|
||||
@keyup.enter="$emit('enter', $event)"
|
||||
/>
|
||||
|
||||
<!-- Suffix Slot (e.g. Password Toggle or Clear Button) -->
|
||||
<div
|
||||
v-if="$slots.suffix"
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 dark:text-dark-400"
|
||||
>
|
||||
<slot name="suffix"></slot>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hint / Error Text -->
|
||||
<p v-if="error" class="input-error-text mt-1.5">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="hint" class="input-hint mt-1.5">
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | number | null | undefined
|
||||
type?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
hint?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
type: 'text',
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
(e: 'focus', event: FocusEvent): void
|
||||
(e: 'enter', event: KeyboardEvent): void
|
||||
}>()
|
||||
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
const placeholderText = computed(() => props.placeholder || '')
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Expose focus method
|
||||
defineExpose({
|
||||
focus: () => inputRef.value?.focus(),
|
||||
select: () => inputRef.value?.select()
|
||||
})
|
||||
</script>
|
||||
54
frontend/src/components/common/SearchInput.vue
Normal file
54
frontend/src/components/common/SearchInput.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<svg
|
||||
class="h-5 w-5 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
:value="modelValue"
|
||||
type="text"
|
||||
class="input pl-10"
|
||||
:placeholder="placeholder"
|
||||
@input="handleInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
debounceMs?: number
|
||||
}>(), {
|
||||
placeholder: 'Search...',
|
||||
debounceMs: 300
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'search', value: string): void
|
||||
}>()
|
||||
|
||||
const debouncedEmitSearch = useDebounceFn((value: string) => {
|
||||
emit('search', value)
|
||||
}, props.debounceMs)
|
||||
|
||||
const handleInput = (event: Event) => {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
emit('update:modelValue', value)
|
||||
debouncedEmitSearch(value)
|
||||
}
|
||||
</script>
|
||||
@@ -1,15 +1,21 @@
|
||||
<template>
|
||||
<div class="relative" ref="containerRef">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
:disabled="disabled"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
aria-label="Select option"
|
||||
:class="[
|
||||
'select-trigger',
|
||||
isOpen && 'select-trigger-open',
|
||||
error && 'select-trigger-error',
|
||||
disabled && 'select-trigger-disabled'
|
||||
]"
|
||||
@keydown.down.prevent="onTriggerKeyDown"
|
||||
@keydown.up.prevent="onTriggerKeyDown"
|
||||
>
|
||||
<span class="select-value">
|
||||
<slot name="selected" :option="selectedOption">
|
||||
@@ -29,16 +35,19 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Teleport dropdown to body to escape stacking context (for driver.js overlay compatibility) -->
|
||||
<!-- Teleport dropdown to body to escape stacking context -->
|
||||
<Teleport to="body">
|
||||
<Transition name="select-dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="dropdownRef"
|
||||
class="select-dropdown-portal"
|
||||
:class="[instanceId]"
|
||||
:style="dropdownStyle"
|
||||
role="listbox"
|
||||
@click.stop
|
||||
@mousedown.stop
|
||||
@keydown="onDropdownKeyDown"
|
||||
>
|
||||
<!-- Search input -->
|
||||
<div v-if="searchable" class="select-search">
|
||||
@@ -66,12 +75,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Options list -->
|
||||
<div class="select-options">
|
||||
<div class="select-options" ref="optionsListRef">
|
||||
<div
|
||||
v-for="option in filteredOptions"
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="`${typeof getOptionValue(option)}:${String(getOptionValue(option) ?? '')}`"
|
||||
@click.stop="selectOption(option)"
|
||||
:class="['select-option', isSelected(option) && 'select-option-selected']"
|
||||
role="option"
|
||||
:aria-selected="isSelected(option)"
|
||||
:aria-disabled="isOptionDisabled(option)"
|
||||
@click.stop="!isOptionDisabled(option) && selectOption(option)"
|
||||
@mouseenter="focusedIndex = index"
|
||||
:class="[
|
||||
'select-option',
|
||||
isSelected(option) && 'select-option-selected',
|
||||
isOptionDisabled(option) && 'select-option-disabled',
|
||||
focusedIndex === index && 'select-option-focused'
|
||||
]"
|
||||
>
|
||||
<slot name="option" :option="option" :selected="isSelected(option)">
|
||||
<span class="select-option-label">{{ getOptionLabel(option) }}</span>
|
||||
@@ -105,6 +123,9 @@ import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// Instance ID for unique click-outside detection
|
||||
const instanceId = `select-${Math.random().toString(36).substring(2, 9)}`
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number | boolean | null
|
||||
label: string
|
||||
@@ -138,23 +159,24 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
labelKey: 'label'
|
||||
})
|
||||
|
||||
// Use computed for i18n default values
|
||||
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
|
||||
const searchPlaceholderText = computed(
|
||||
() => props.searchPlaceholder ?? t('common.searchPlaceholder')
|
||||
)
|
||||
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const focusedIndex = ref(-1)
|
||||
const containerRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const optionsListRef = ref<HTMLElement | null>(null)
|
||||
const dropdownPosition = ref<'bottom' | 'top'>('bottom')
|
||||
const triggerRect = ref<DOMRect | null>(null)
|
||||
|
||||
// i18n placeholders
|
||||
const placeholderText = computed(() => props.placeholder ?? t('common.selectOption'))
|
||||
const searchPlaceholderText = computed(() => props.searchPlaceholder ?? t('common.searchPlaceholder'))
|
||||
const emptyTextDisplay = computed(() => props.emptyText ?? t('common.noOptionsFound'))
|
||||
|
||||
// Computed style for teleported dropdown
|
||||
const dropdownStyle = computed(() => {
|
||||
if (!triggerRect.value) return {}
|
||||
@@ -164,34 +186,39 @@ const dropdownStyle = computed(() => {
|
||||
position: 'fixed',
|
||||
left: `${rect.left}px`,
|
||||
minWidth: `${rect.width}px`,
|
||||
zIndex: '100000020' // Higher than driver.js overlay (99999998)
|
||||
zIndex: '100000020'
|
||||
}
|
||||
|
||||
if (dropdownPosition.value === 'top') {
|
||||
style.bottom = `${window.innerHeight - rect.top + 8}px`
|
||||
style.bottom = `${window.innerHeight - rect.top + 4}px`
|
||||
} else {
|
||||
style.top = `${rect.bottom + 8}px`
|
||||
style.top = `${rect.bottom + 4}px`
|
||||
}
|
||||
|
||||
return style
|
||||
})
|
||||
|
||||
const getOptionValue = (
|
||||
option: SelectOption | Record<string, unknown>
|
||||
): string | number | boolean | null | undefined => {
|
||||
const getOptionValue = (option: any): any => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return option[props.valueKey] as string | number | boolean | null | undefined
|
||||
return option[props.valueKey]
|
||||
}
|
||||
return option as string | number | boolean | null
|
||||
return option
|
||||
}
|
||||
|
||||
const getOptionLabel = (option: SelectOption | Record<string, unknown>): string => {
|
||||
const getOptionLabel = (option: any): string => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return String(option[props.labelKey] ?? '')
|
||||
}
|
||||
return String(option ?? '')
|
||||
}
|
||||
|
||||
const isOptionDisabled = (option: any): boolean => {
|
||||
if (typeof option === 'object' && option !== null) {
|
||||
return !!option.disabled
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
return props.options.find((opt) => getOptionValue(opt) === props.modelValue) || null
|
||||
})
|
||||
@@ -204,36 +231,35 @@ const selectedLabel = computed(() => {
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => {
|
||||
if (!props.searchable || !searchQuery.value) {
|
||||
return props.options
|
||||
let opts = props.options as any[]
|
||||
if (props.searchable && searchQuery.value) {
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
opts = opts.filter((opt) => getOptionLabel(opt).toLowerCase().includes(query))
|
||||
}
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return props.options.filter((opt) => {
|
||||
const label = getOptionLabel(opt).toLowerCase()
|
||||
return label.includes(query)
|
||||
})
|
||||
return opts
|
||||
})
|
||||
|
||||
const isSelected = (option: SelectOption | Record<string, unknown>): boolean => {
|
||||
const isSelected = (option: any): boolean => {
|
||||
return getOptionValue(option) === props.modelValue
|
||||
}
|
||||
|
||||
// Update trigger rect periodically while open to follow scroll/resize
|
||||
const updateTriggerRect = () => {
|
||||
if (containerRef.value) {
|
||||
triggerRect.value = containerRef.value.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
const calculateDropdownPosition = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
// Update trigger rect for positioning
|
||||
triggerRect.value = containerRef.value.getBoundingClientRect()
|
||||
updateTriggerRect()
|
||||
|
||||
nextTick(() => {
|
||||
if (!containerRef.value || !dropdownRef.value) return
|
||||
if (!dropdownRef.value || !triggerRect.value) return
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240
|
||||
const spaceBelow = window.innerHeight - triggerRect.value.bottom
|
||||
const spaceAbove = triggerRect.value.top
|
||||
|
||||
const rect = triggerRect.value!
|
||||
const dropdownHeight = dropdownRef.value.offsetHeight || 240 // Max height fallback
|
||||
const viewportHeight = window.innerHeight
|
||||
const spaceBelow = viewportHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
|
||||
// If not enough space below but enough space above, show dropdown on top
|
||||
if (spaceBelow < dropdownHeight && spaceAbove > dropdownHeight) {
|
||||
dropdownPosition.value = 'top'
|
||||
} else {
|
||||
@@ -245,63 +271,108 @@ const calculateDropdownPosition = () => {
|
||||
const toggle = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = !isOpen.value
|
||||
if (isOpen.value) {
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (open) {
|
||||
calculateDropdownPosition()
|
||||
// Reset focused index to current selection or first item
|
||||
const selectedIdx = filteredOptions.value.findIndex(isSelected)
|
||||
focusedIndex.value = selectedIdx >= 0 ? selectedIdx : 0
|
||||
|
||||
if (props.searchable) {
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus()
|
||||
})
|
||||
nextTick(() => searchInputRef.value?.focus())
|
||||
}
|
||||
// Add scroll listener to update position
|
||||
window.addEventListener('scroll', updateTriggerRect, { capture: true, passive: true })
|
||||
window.addEventListener('resize', calculateDropdownPosition)
|
||||
} else {
|
||||
searchQuery.value = ''
|
||||
focusedIndex.value = -1
|
||||
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
|
||||
window.removeEventListener('resize', calculateDropdownPosition)
|
||||
}
|
||||
})
|
||||
|
||||
const selectOption = (option: any) => {
|
||||
const value = getOptionValue(option) ?? null
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, option)
|
||||
isOpen.value = false
|
||||
triggerRef.value?.focus()
|
||||
}
|
||||
|
||||
// Keyboards
|
||||
const onTriggerKeyDown = () => {
|
||||
if (!isOpen.value) {
|
||||
isOpen.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const selectOption = (option: SelectOption | Record<string, unknown>) => {
|
||||
const value = getOptionValue(option) ?? null
|
||||
emit('update:modelValue', value)
|
||||
emit('change', value, option as SelectOption)
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
const onDropdownKeyDown = (e: KeyboardEvent) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value + 1) % filteredOptions.value.length
|
||||
scrollToFocused()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
e.preventDefault()
|
||||
focusedIndex.value = (focusedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
scrollToFocused()
|
||||
break
|
||||
case 'Enter':
|
||||
e.preventDefault()
|
||||
if (focusedIndex.value >= 0 && focusedIndex.value < filteredOptions.value.length) {
|
||||
const opt = filteredOptions.value[focusedIndex.value]
|
||||
if (!isOptionDisabled(opt)) selectOption(opt)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
isOpen.value = false
|
||||
triggerRef.value?.focus()
|
||||
break
|
||||
case 'Tab':
|
||||
isOpen.value = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToFocused = () => {
|
||||
nextTick(() => {
|
||||
const list = optionsListRef.value
|
||||
if (!list) return
|
||||
const focusedEl = list.children[focusedIndex.value] as HTMLElement
|
||||
if (!focusedEl) return
|
||||
|
||||
if (focusedEl.offsetTop < list.scrollTop) {
|
||||
list.scrollTop = focusedEl.offsetTop
|
||||
} else if (focusedEl.offsetTop + focusedEl.offsetHeight > list.scrollTop + list.offsetHeight) {
|
||||
list.scrollTop = focusedEl.offsetTop + focusedEl.offsetHeight - list.offsetHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
// Check if click is inside THIS specific instance's dropdown or trigger
|
||||
const isInDropdown = !!target.closest(`.${instanceId}`)
|
||||
const isInTrigger = containerRef.value?.contains(target)
|
||||
|
||||
// 使用 closest 检查点击是否在下拉菜单内部(更可靠,不依赖 ref)
|
||||
if (target.closest('.select-dropdown-portal')) {
|
||||
return // 点击在下拉菜单内,不关闭
|
||||
}
|
||||
|
||||
// 检查是否点击在触发器内
|
||||
if (containerRef.value && containerRef.value.contains(target)) {
|
||||
return // 点击在触发器内,让 toggle 处理
|
||||
}
|
||||
|
||||
// 点击在外部,关闭下拉菜单
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen.value) {
|
||||
if (!isInDropdown && !isInTrigger && isOpen.value) {
|
||||
isOpen.value = false
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(isOpen, (open) => {
|
||||
if (!open) {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
window.removeEventListener('scroll', updateTriggerRect, { capture: true })
|
||||
window.removeEventListener('resize', calculateDropdownPosition)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -339,16 +410,14 @@ onUnmounted(() => {
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- Global styles for teleported dropdown -->
|
||||
<style>
|
||||
.select-dropdown-portal {
|
||||
@apply w-max max-w-[300px];
|
||||
@apply w-max min-w-[160px] max-w-[320px];
|
||||
@apply bg-white dark:bg-dark-800;
|
||||
@apply rounded-xl;
|
||||
@apply border border-gray-200 dark:border-dark-700;
|
||||
@apply shadow-lg shadow-black/10 dark:shadow-black/30;
|
||||
@apply overflow-hidden;
|
||||
/* 确保下拉菜单在引导期间可点击(覆盖 driver.js 的 pointer-events 影响) */
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
@@ -365,7 +434,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-options {
|
||||
@apply max-h-60 overflow-y-auto py-1;
|
||||
@apply max-h-60 overflow-y-auto py-1 outline-none;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option {
|
||||
@@ -374,7 +443,6 @@ onUnmounted(() => {
|
||||
@apply text-gray-700 dark:text-gray-300;
|
||||
@apply cursor-pointer transition-colors duration-150;
|
||||
@apply hover:bg-gray-50 dark:hover:bg-dark-700;
|
||||
/* 确保选项在引导期间可点击 */
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
@@ -383,6 +451,14 @@ onUnmounted(() => {
|
||||
@apply text-primary-700 dark:text-primary-300;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-focused {
|
||||
@apply bg-gray-100 dark:bg-dark-700;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-disabled {
|
||||
@apply cursor-not-allowed opacity-40;
|
||||
}
|
||||
|
||||
.select-dropdown-portal .select-option-label {
|
||||
@apply flex-1 min-w-0 truncate text-left;
|
||||
}
|
||||
@@ -392,7 +468,6 @@ onUnmounted(() => {
|
||||
@apply text-gray-500 dark:text-dark-400;
|
||||
}
|
||||
|
||||
/* Dropdown animation */
|
||||
.select-dropdown-enter-active,
|
||||
.select-dropdown-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
@@ -403,4 +478,4 @@ onUnmounted(() => {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
46
frontend/src/components/common/Skeleton.vue
Normal file
46
frontend/src/components/common/Skeleton.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'animate-pulse bg-gray-200 dark:bg-dark-700',
|
||||
variant === 'circle' ? 'rounded-full' : 'rounded-lg',
|
||||
customClass
|
||||
]"
|
||||
:style="style"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
variant?: 'rect' | 'circle' | 'text'
|
||||
width?: string | number
|
||||
height?: string | number
|
||||
class?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
variant: 'rect',
|
||||
width: '100%'
|
||||
})
|
||||
|
||||
const customClass = computed(() => props.class || '')
|
||||
|
||||
const style = computed(() => {
|
||||
const s: Record<string, string> = {}
|
||||
|
||||
if (props.width) {
|
||||
s.width = typeof props.width === 'number' ? `${props.width}px` : props.width
|
||||
}
|
||||
|
||||
if (props.height) {
|
||||
s.height = typeof props.height === 'number' ? `${props.height}px` : props.height
|
||||
} else if (props.variant === 'text') {
|
||||
s.height = '1em'
|
||||
s.marginTop = '0.25em'
|
||||
s.marginBottom = '0.25em'
|
||||
}
|
||||
|
||||
return s
|
||||
})
|
||||
</script>
|
||||
39
frontend/src/components/common/StatusBadge.vue
Normal file
39
frontend/src/components/common/StatusBadge.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-2 w-2 rounded-full',
|
||||
variantClass
|
||||
]"
|
||||
></span>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||
{{ label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
status: string
|
||||
label: string
|
||||
}>()
|
||||
|
||||
const variantClass = computed(() => {
|
||||
switch (props.status) {
|
||||
case 'active':
|
||||
case 'success':
|
||||
return 'bg-green-500'
|
||||
case 'disabled':
|
||||
case 'inactive':
|
||||
case 'warning':
|
||||
return 'bg-yellow-500'
|
||||
case 'error':
|
||||
case 'danger':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-400'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
81
frontend/src/components/common/TextArea.vue
Normal file
81
frontend/src/components/common/TextArea.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<label v-if="label" :for="id" class="input-label mb-1.5 block">
|
||||
{{ label }}
|
||||
<span v-if="required" class="text-red-500">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
:id="id"
|
||||
ref="textAreaRef"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
:required="required"
|
||||
:placeholder="placeholderText"
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
:class="[
|
||||
'input w-full min-h-[80px] transition-all duration-200 resize-y',
|
||||
error ? 'input-error ring-2 ring-red-500/20' : '',
|
||||
disabled ? 'cursor-not-allowed bg-gray-100 opacity-60 dark:bg-dark-900' : ''
|
||||
]"
|
||||
@input="onInput"
|
||||
@change="$emit('change', ($event.target as HTMLTextAreaElement).value)"
|
||||
@blur="$emit('blur', $event)"
|
||||
@focus="$emit('focus', $event)"
|
||||
></textarea>
|
||||
</div>
|
||||
<!-- Hint / Error Text -->
|
||||
<p v-if="error" class="input-error-text mt-1.5">
|
||||
{{ error }}
|
||||
</p>
|
||||
<p v-else-if="hint" class="input-hint mt-1.5">
|
||||
{{ hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
modelValue: string | null | undefined
|
||||
label?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
required?: boolean
|
||||
readonly?: boolean
|
||||
error?: string
|
||||
hint?: string
|
||||
id?: string
|
||||
rows?: number | string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
disabled: false,
|
||||
required: false,
|
||||
readonly: false,
|
||||
rows: 3
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string): void
|
||||
(e: 'change', value: string): void
|
||||
(e: 'blur', event: FocusEvent): void
|
||||
(e: 'focus', event: FocusEvent): void
|
||||
}>()
|
||||
|
||||
const textAreaRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const placeholderText = computed(() => props.placeholder || '')
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const value = (event.target as HTMLTextAreaElement).value
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
|
||||
// Expose focus method
|
||||
defineExpose({
|
||||
focus: () => textAreaRef.value?.focus(),
|
||||
select: () => textAreaRef.value?.select()
|
||||
})
|
||||
</script>
|
||||
@@ -52,18 +52,12 @@
|
||||
/>
|
||||
|
||||
<!-- Select -->
|
||||
<select
|
||||
<Select
|
||||
v-else-if="attr.type === 'select'"
|
||||
v-model="localValues[attr.id]"
|
||||
:required="attr.required"
|
||||
class="input"
|
||||
:options="attr.options || []"
|
||||
@change="emitChange"
|
||||
>
|
||||
<option value="">{{ t('common.selectOption') }}</option>
|
||||
<option v-for="opt in attr.options" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
/>
|
||||
|
||||
<!-- Multi-Select (Checkboxes) -->
|
||||
<div v-else-if="attr.type === 'multi_select'" class="space-y-2">
|
||||
@@ -99,11 +93,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { UserAttributeDefinition, UserAttributeValuesMap } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
interface Props {
|
||||
userId?: number
|
||||
|
||||
@@ -142,11 +142,10 @@
|
||||
<!-- Type -->
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.attributes.type') }}</label>
|
||||
<select v-model="form.type" class="input" required>
|
||||
<option v-for="type in attributeTypes" :key="type" :value="type">
|
||||
{{ t(`admin.users.attributes.types.${type}`) }}
|
||||
</option>
|
||||
</select>
|
||||
<Select
|
||||
v-model="form.type"
|
||||
:options="attributeTypes.map(type => ({ value: type, label: t(`admin.users.attributes.types.${type}`) }))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Options (for select/multi_select) -->
|
||||
@@ -257,6 +256,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { UserAttributeDefinition, UserAttributeType, UserAttributeOption } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
151
frontend/src/components/user/dashboard/UserDashboardCharts.vue
Normal file
151
frontend/src/components/user/dashboard/UserDashboardCharts.vue
Normal file
@@ -0,0 +1,151 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Date Range Filter -->
|
||||
<div class="card p-4">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.timeRange') }}:</span>
|
||||
<DateRangePicker :start-date="startDate" :end-date="endDate" @update:startDate="$emit('update:startDate', $event)" @update:endDate="$emit('update:endDate', $event)" @change="$emit('dateRangeChange', $event)" />
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('dashboard.granularity') }}:</span>
|
||||
<div class="w-28">
|
||||
<Select :model-value="granularity" :options="[{value:'day', label:t('dashboard.day')}, {value:'hour', label:t('dashboard.hour')}]" @update:model-value="$emit('update:granularity', $event)" @change="$emit('granularityChange')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Charts Grid -->
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<!-- Model Distribution Chart -->
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.modelDistribution') }}</h3>
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="h-48 w-48">
|
||||
<Doughnut v-if="modelData" :data="modelData" :options="doughnutOptions" />
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
||||
</div>
|
||||
<div class="max-h-48 flex-1 overflow-y-auto">
|
||||
<table class="w-full text-xs">
|
||||
<thead>
|
||||
<tr class="text-gray-500 dark:text-gray-400">
|
||||
<th class="pb-2 text-left">{{ t('dashboard.model') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('dashboard.requests') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('dashboard.tokens') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('dashboard.actual') }}</th>
|
||||
<th class="pb-2 text-right">{{ t('dashboard.standard') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="model in models" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
|
||||
<td class="max-w-[100px] truncate py-1.5 font-medium text-gray-900 dark:text-white" :title="model.model">{{ model.model }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatNumber(model.requests) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-600 dark:text-gray-400">{{ formatTokens(model.total_tokens) }}</td>
|
||||
<td class="py-1.5 text-right text-green-600 dark:text-green-400">${{ formatCost(model.actual_cost) }}</td>
|
||||
<td class="py-1.5 text-right text-gray-400 dark:text-gray-500">${{ formatCost(model.cost) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Usage Trend Chart -->
|
||||
<div class="card relative overflow-hidden p-4">
|
||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-white/50 backdrop-blur-sm dark:bg-dark-800/50">
|
||||
<LoadingSpinner size="md" />
|
||||
</div>
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">{{ t('dashboard.tokenUsageTrend') }}</h3>
|
||||
<div class="h-48">
|
||||
<Line v-if="trendData" :data="trendData" :options="lineOptions" />
|
||||
<div v-else class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.noDataAvailable') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import DateRangePicker from '@/components/common/DateRangePicker.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { Line, Doughnut } from 'vue-chartjs'
|
||||
import type { TrendDataPoint, ModelStat } from '@/types'
|
||||
import { formatCostFixed as formatCost, formatNumberLocaleString as formatNumber, formatTokensK as formatTokens } from '@/utils/format'
|
||||
import { Chart as ChartJS, CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler } from 'chart.js'
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, ArcElement, Title, Tooltip, Legend, Filler)
|
||||
|
||||
const props = defineProps<{ loading: boolean, startDate: string, endDate: string, granularity: string, trend: TrendDataPoint[], models: ModelStat[] }>()
|
||||
defineEmits(['update:startDate', 'update:endDate', 'update:granularity', 'dateRangeChange', 'granularityChange'])
|
||||
const { t } = useI18n()
|
||||
|
||||
const modelData = computed(() => !props.models?.length ? null : {
|
||||
labels: props.models.map((m: ModelStat) => m.model),
|
||||
datasets: [{
|
||||
data: props.models.map((m: ModelStat) => m.total_tokens),
|
||||
backgroundColor: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899', '#06b6d4', '#84cc16']
|
||||
}]
|
||||
})
|
||||
|
||||
const trendData = computed(() => !props.trend?.length ? null : {
|
||||
labels: props.trend.map((d: TrendDataPoint) => d.date),
|
||||
datasets: [
|
||||
{
|
||||
label: t('dashboard.input'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.input_tokens),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: t('dashboard.output'),
|
||||
data: props.trend.map((d: TrendDataPoint) => d.output_tokens),
|
||||
borderColor: '#10b981',
|
||||
backgroundColor: 'rgba(16, 185, 129, 0.1)',
|
||||
tension: 0.3,
|
||||
fill: true
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const doughnutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => `${context.label}: ${formatTokens(context.parsed)} tokens`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lineOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { display: true, position: 'top' as const },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => `${context.dataset.label}: ${formatTokens(context.parsed.y)} tokens`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
callback: (value: any) => formatTokens(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.quickActions') }}</h2>
|
||||
</div>
|
||||
<div class="space-y-3 p-4">
|
||||
<button @click="router.push('/keys')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
|
||||
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 transition-transform group-hover:scale-105 dark:bg-primary-900/30">
|
||||
<svg class="h-6 w-6 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.createApiKey') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.generateNewKey') }}</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button @click="router.push('/usage')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
|
||||
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-emerald-100 transition-transform group-hover:scale-105 dark:bg-emerald-900/30">
|
||||
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.viewUsage') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.checkDetailedLogs') }}</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button @click="router.push('/redeem')" class="group flex w-full items-center gap-4 rounded-xl bg-gray-50 p-4 text-left transition-all duration-200 hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
|
||||
<div class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-amber-100 transition-transform group-hover:scale-105 dark:bg-amber-900/30">
|
||||
<svg class="h-6 w-6 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ t('dashboard.redeemCode') }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ t('dashboard.addBalance') }}</p>
|
||||
</div>
|
||||
<svg class="h-5 w-5 text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t('dashboard.recentUsage') }}</h2>
|
||||
<span class="badge badge-gray">{{ t('dashboard.last7Days') }}</span>
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
<div v-else-if="data.length === 0" class="py-8">
|
||||
<EmptyState :title="t('dashboard.noUsageRecords')" :description="t('dashboard.startUsingApi')" />
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="log in data" :key="log.id" class="flex items-center justify-between rounded-xl bg-gray-50 p-4 transition-colors hover:bg-gray-100 dark:bg-dark-800/50 dark:hover:bg-dark-800">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30">
|
||||
<svg class="h-5 w-5 text-primary-600 dark:text-primary-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 3.104v5.714a2.25 2.25 0 01-.659 1.591L5 14.5M9.75 3.104c-.251.023-.501.05-.75.082m.75-.082a24.301 24.301 0 014.5 0m0 0v5.714c0 .597.237 1.17.659 1.591L19.8 15.3M14.25 3.104c.251.023.501.05.75.082M19.8 15.3l-1.57.393A9.065 9.065 0 0112 15a9.065 9.065 0 00-6.23.693L5 14.5m14.8.8l1.402 1.402c1.232 1.232.65 3.318-1.067 3.611A48.309 48.309 0 0112 21c-2.773 0-5.491-.235-8.135-.687-1.718-.293-2.3-2.379-1.067-3.61L5 14.5" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{{ log.model }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ formatDateTime(log.created_at) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-sm font-semibold">
|
||||
<span class="text-green-600 dark:text-green-400" :title="t('dashboard.actual')">${{ formatCost(log.actual_cost) }}</span>
|
||||
<span class="font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(log.total_cost) }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-dark-400">{{ (log.input_tokens + log.output_tokens).toLocaleString() }} tokens</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link to="/usage" class="flex items-center justify-center gap-2 py-3 text-sm font-medium text-primary-600 transition-colors hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
{{ t('dashboard.viewAllUsage') }}
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||
</svg>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { UsageLog } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
data: UsageLog[]
|
||||
loading: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
const formatCost = (c: number) => c.toFixed(4)
|
||||
</script>
|
||||
171
frontend/src/components/user/dashboard/UserDashboardStats.vue
Normal file
171
frontend/src/components/user/dashboard/UserDashboardStats.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<!-- Row 1: Core Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Balance -->
|
||||
<div v-if="!isSimple" class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-emerald-100 p-2 dark:bg-emerald-900/30">
|
||||
<svg class="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.balance') }}</p>
|
||||
<p class="text-xl font-bold text-emerald-600 dark:text-emerald-400">${{ formatBalance(balance) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.available') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Keys -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.apiKeys') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats?.total_api_keys || 0 }}</p>
|
||||
<p class="text-xs text-green-600 dark:text-green-400">{{ stats?.active_api_keys || 0 }} {{ t('common.active') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Requests -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<svg class="h-5 w-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayRequests') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ stats?.today_requests || 0 }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('common.total') }}: {{ formatNumber(stats?.total_requests || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today Cost -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30">
|
||||
<svg class="h-5 w-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.todayCost') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats?.today_actual_cost || 0) }}</span>
|
||||
<span class="text-sm font-normal text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats?.today_cost || 0) }}</span>
|
||||
</p>
|
||||
<p class="text-xs">
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ t('common.total') }}: </span>
|
||||
<span class="text-purple-600 dark:text-purple-400" :title="t('dashboard.actual')">${{ formatCost(stats?.total_actual_cost || 0) }}</span>
|
||||
<span class="text-gray-400 dark:text-gray-500" :title="t('dashboard.standard')"> / ${{ formatCost(stats?.total_cost || 0) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Token Stats -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- Today Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30">
|
||||
<svg class="h-5 w-5 text-amber-600 dark:text-amber-400" 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 dark:text-gray-400">{{ t('dashboard.todayTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.today_tokens || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.input') }}: {{ formatTokens(stats?.today_input_tokens || 0) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats?.today_output_tokens || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-indigo-100 p-2 dark:bg-indigo-900/30">
|
||||
<svg class="h-5 w-5 text-indigo-600 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.totalTokens') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.total_tokens || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.input') }}: {{ formatTokens(stats?.total_input_tokens || 0) }} / {{ t('dashboard.output') }}: {{ formatTokens(stats?.total_output_tokens || 0) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance (RPM/TPM) -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-violet-100 p-2 dark:bg-violet-900/30">
|
||||
<svg class="h-5 w-5 text-violet-600 dark:text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.performance') }}</p>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatTokens(stats?.rpm || 0) }}</p>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">RPM</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<p class="text-sm font-semibold text-violet-600 dark:text-violet-400">{{ formatTokens(stats?.tpm || 0) }}</p>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">TPM</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avg Response Time -->
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-rose-100 p-2 dark:bg-rose-900/30">
|
||||
<svg class="h-5 w-5 text-rose-600 dark:text-rose-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">{{ t('dashboard.avgResponse') }}</p>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">{{ formatDuration(stats?.average_duration_ms || 0) }}</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">{{ t('dashboard.averageTime') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { UserDashboardStats as UserStatsType } from '@/api/usage'
|
||||
|
||||
defineProps<{
|
||||
stats: UserStatsType
|
||||
balance: number
|
||||
isSimple: boolean
|
||||
}>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const formatBalance = (b: number) =>
|
||||
new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
}).format(b)
|
||||
|
||||
const formatNumber = (n: number) => n.toLocaleString()
|
||||
const formatCost = (c: number) => c.toFixed(4)
|
||||
const formatTokens = (t: number) => (t >= 1000 ? `${(t / 1000).toFixed(1)}K` : t.toString())
|
||||
const formatDuration = (ms: number) => ms >= 1000 ? `${(ms / 1000).toFixed(2)}s` : `${ms.toFixed(0)}ms`
|
||||
</script>
|
||||
74
frontend/src/components/user/profile/ProfileEditForm.vue
Normal file
74
frontend/src/components/user/profile/ProfileEditForm.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.editProfile') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="input-label">
|
||||
{{ t('profile.username') }}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('profile.enterUsername')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? t('profile.updating') : t('profile.updateProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { userAPI } from '@/api'
|
||||
|
||||
const props = defineProps<{
|
||||
initialUsername: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const username = ref(props.initialUsername)
|
||||
const loading = ref(false)
|
||||
|
||||
watch(() => props.initialUsername, (val) => {
|
||||
username.value = val
|
||||
})
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
if (!username.value.trim()) {
|
||||
appStore.showError(t('profile.usernameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const updatedUser = await userAPI.updateProfile({
|
||||
username: username.value
|
||||
})
|
||||
authStore.user = updatedUser
|
||||
appStore.showSuccess(t('profile.updateSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
frontend/src/components/user/profile/ProfileInfoCard.vue
Normal file
81
frontend/src/components/user/profile/ProfileInfoCard.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div
|
||||
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
||||
>
|
||||
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ user?.email }}
|
||||
</h2>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span :class="['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']">
|
||||
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
|
||||
</span>
|
||||
<span
|
||||
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
|
||||
>
|
||||
{{ user?.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ user?.email }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="user?.username"
|
||||
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { User } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
user: User | null
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
109
frontend/src/components/user/profile/ProfilePasswordForm.vue
Normal file
109
frontend/src/components/user/profile/ProfilePasswordForm.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.changePassword') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old_password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="old_password"
|
||||
v-model="form.old_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="input-label">
|
||||
{{ t('profile.newPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="new_password"
|
||||
v-model="form.new_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t('profile.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="input-label">
|
||||
{{ t('profile.confirmNewPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="confirm_password"
|
||||
v-model="form.confirm_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p
|
||||
v-if="form.new_password && form.confirm_password && form.new_password !== form.confirm_password"
|
||||
class="input-error-text"
|
||||
>
|
||||
{{ t('profile.passwordsNotMatch') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? t('profile.changingPassword') : t('profile.changePasswordButton') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { userAPI } from '@/api'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const form = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (form.value.new_password !== form.value.confirm_password) {
|
||||
appStore.showError(t('profile.passwordsNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
if (form.value.new_password.length < 8) {
|
||||
appStore.showError(t('profile.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await userAPI.changePassword(form.value.old_password, form.value.new_password)
|
||||
form.value = { old_password: '', new_password: '', confirm_password: '' }
|
||||
appStore.showSuccess(t('profile.passwordChangeSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { i18n } from '@/i18n'
|
||||
|
||||
const { t } = i18n.global
|
||||
|
||||
/**
|
||||
* 检测是否支持 Clipboard API(需要安全上下文:HTTPS/localhost)
|
||||
@@ -31,7 +34,7 @@ export function useClipboard() {
|
||||
|
||||
const copyToClipboard = async (
|
||||
text: string,
|
||||
successMessage = 'Copied to clipboard'
|
||||
successMessage?: string
|
||||
): Promise<boolean> => {
|
||||
if (!text) return false
|
||||
|
||||
@@ -50,12 +53,12 @@ export function useClipboard() {
|
||||
|
||||
if (success) {
|
||||
copied.value = true
|
||||
appStore.showSuccess(successMessage)
|
||||
appStore.showSuccess(successMessage || t('common.copiedToClipboard'))
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} else {
|
||||
appStore.showError('Copy failed')
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
|
||||
return success
|
||||
|
||||
43
frontend/src/composables/useForm.ts
Normal file
43
frontend/src/composables/useForm.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ref } from 'vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
interface UseFormOptions<T> {
|
||||
form: T
|
||||
submitFn: (data: T) => Promise<void>
|
||||
successMsg?: string
|
||||
errorMsg?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一表单提交逻辑
|
||||
* 管理加载状态、错误捕获及通知
|
||||
*/
|
||||
export function useForm<T>(options: UseFormOptions<T>) {
|
||||
const { form, submitFn, successMsg, errorMsg } = options
|
||||
const loading = ref(false)
|
||||
const appStore = useAppStore()
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await submitFn(form)
|
||||
if (successMsg) {
|
||||
appStore.showSuccess(successMsg)
|
||||
}
|
||||
} catch (error: any) {
|
||||
const detail = error.response?.data?.detail || error.response?.data?.message || error.message
|
||||
appStore.showError(errorMsg || detail)
|
||||
// 继续抛出错误,让组件有机会进行局部处理(如验证错误显示)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
submit
|
||||
}
|
||||
}
|
||||
105
frontend/src/composables/useTableLoader.ts
Normal file
105
frontend/src/composables/useTableLoader.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { ref, reactive, onUnmounted, toRaw } from 'vue'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import type { BasePaginationResponse, FetchOptions } from '@/types'
|
||||
|
||||
interface PaginationState {
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
interface TableLoaderOptions<T, P> {
|
||||
fetchFn: (page: number, pageSize: number, params: P, options?: FetchOptions) => Promise<BasePaginationResponse<T>>
|
||||
initialParams?: P
|
||||
pageSize?: number
|
||||
debounceMs?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用表格数据加载 Composable
|
||||
* 统一处理分页、筛选、搜索防抖和请求取消
|
||||
*/
|
||||
export function useTableLoader<T, P extends Record<string, any>>(options: TableLoaderOptions<T, P>) {
|
||||
const { fetchFn, initialParams, pageSize = 20, debounceMs = 300 } = options
|
||||
|
||||
const items = ref<T[]>([])
|
||||
const loading = ref(false)
|
||||
const params = reactive<P>({ ...(initialParams || {}) } as P)
|
||||
const pagination = reactive<PaginationState>({
|
||||
page: 1,
|
||||
page_size: pageSize,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const isAbortError = (error: any) => {
|
||||
return error?.name === 'AbortError' || error?.code === 'ERR_CANCELED' || error?.name === 'CanceledError'
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
}
|
||||
abortController = new AbortController()
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await fetchFn(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
toRaw(params) as P,
|
||||
{ signal: abortController.signal }
|
||||
)
|
||||
|
||||
items.value = response.items || []
|
||||
pagination.total = response.total || 0
|
||||
pagination.pages = response.pages || 0
|
||||
} catch (error) {
|
||||
if (!isAbortError(error)) {
|
||||
console.error('Table load error:', error)
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
if (abortController && !abortController.signal.aborted) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reload = () => {
|
||||
pagination.page = 1
|
||||
return load()
|
||||
}
|
||||
|
||||
const debouncedReload = useDebounceFn(reload, debounceMs)
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
load()
|
||||
}
|
||||
|
||||
const handlePageSizeChange = (size: number) => {
|
||||
pagination.page_size = size
|
||||
pagination.page = 1
|
||||
load()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
abortController?.abort()
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
loading,
|
||||
params,
|
||||
pagination,
|
||||
load,
|
||||
reload,
|
||||
debouncedReload,
|
||||
handlePageChange,
|
||||
handlePageSizeChange
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default {
|
||||
description: 'Configure your Sub2API instance',
|
||||
database: {
|
||||
title: 'Database Configuration',
|
||||
description: 'Connect to your PostgreSQL database',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
username: 'Username',
|
||||
@@ -63,6 +64,7 @@ export default {
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis Configuration',
|
||||
description: 'Connect to your Redis server',
|
||||
host: 'Host',
|
||||
port: 'Port',
|
||||
password: 'Password (optional)',
|
||||
@@ -71,6 +73,7 @@ export default {
|
||||
},
|
||||
admin: {
|
||||
title: 'Admin Account',
|
||||
description: 'Create your administrator account',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
confirmPassword: 'Confirm Password',
|
||||
@@ -80,9 +83,21 @@ export default {
|
||||
},
|
||||
ready: {
|
||||
title: 'Ready to Install',
|
||||
description: 'Review your configuration and complete setup',
|
||||
database: 'Database',
|
||||
redis: 'Redis',
|
||||
adminEmail: 'Admin Email'
|
||||
},
|
||||
status: {
|
||||
testing: 'Testing...',
|
||||
success: 'Connection Successful',
|
||||
testConnection: 'Test Connection',
|
||||
installing: 'Installing...',
|
||||
completeInstallation: 'Complete Installation',
|
||||
completed: 'Installation completed!',
|
||||
redirecting: 'Redirecting to login page...',
|
||||
restarting: 'Service is restarting, please wait...',
|
||||
timeout: 'Service restart is taking longer than expected. Please refresh the page manually.'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -130,11 +145,13 @@ export default {
|
||||
copiedToClipboard: 'Copied to clipboard',
|
||||
copyFailed: 'Failed to copy',
|
||||
contactSupport: 'Contact Support',
|
||||
selectOption: 'Select an option',
|
||||
searchPlaceholder: 'Search...',
|
||||
noOptionsFound: 'No options found',
|
||||
saving: 'Saving...',
|
||||
refresh: 'Refresh',
|
||||
selectOption: 'Select an option',
|
||||
searchPlaceholder: 'Search...',
|
||||
noOptionsFound: 'No options found',
|
||||
noGroupsAvailable: 'No groups available',
|
||||
unknownError: 'Unknown error occurred',
|
||||
saving: 'Saving...',
|
||||
selectedCount: '({count} selected)', refresh: 'Refresh',
|
||||
notAvailable: 'N/A',
|
||||
now: 'Now',
|
||||
unknown: 'Unknown',
|
||||
@@ -673,6 +690,10 @@ export default {
|
||||
failedToWithdraw: 'Failed to withdraw',
|
||||
useDepositWithdrawButtons: 'Please use deposit/withdraw buttons to adjust balance',
|
||||
insufficientBalance: 'Insufficient balance, balance cannot be negative after withdrawal',
|
||||
roles: {
|
||||
admin: 'Admin',
|
||||
user: 'User'
|
||||
},
|
||||
// Settings Dropdowns
|
||||
filterSettings: 'Filter Settings',
|
||||
columnSettings: 'Column Settings',
|
||||
@@ -739,6 +760,7 @@ export default {
|
||||
groups: {
|
||||
title: 'Group Management',
|
||||
description: 'Manage API key groups and rate multipliers',
|
||||
searchGroups: 'Search groups...',
|
||||
createGroup: 'Create Group',
|
||||
editGroup: 'Edit Group',
|
||||
deleteGroup: 'Delete Group',
|
||||
@@ -794,6 +816,13 @@ export default {
|
||||
failedToCreate: 'Failed to create group',
|
||||
failedToUpdate: 'Failed to update group',
|
||||
failedToDelete: 'Failed to delete group',
|
||||
platforms: {
|
||||
all: 'All Platforms',
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
},
|
||||
deleteConfirm:
|
||||
"Are you sure you want to delete '{name}'? All associated API keys will no longer belong to any group.",
|
||||
deleteConfirmSubscription:
|
||||
@@ -935,9 +964,16 @@ export default {
|
||||
antigravityOauth: 'Antigravity OAuth'
|
||||
},
|
||||
status: {
|
||||
active: 'Active',
|
||||
inactive: 'Inactive',
|
||||
error: 'Error',
|
||||
cooldown: 'Cooldown',
|
||||
paused: 'Paused',
|
||||
limited: 'Limited',
|
||||
tempUnschedulable: 'Temp Unschedulable'
|
||||
tempUnschedulable: 'Temp Unschedulable',
|
||||
rateLimitedUntil: 'Rate limited until {time}',
|
||||
overloadedUntil: 'Overloaded until {time}',
|
||||
viewTempUnschedDetails: 'View temp unschedulable details'
|
||||
},
|
||||
tempUnschedulable: {
|
||||
title: 'Temp Unschedulable',
|
||||
@@ -1484,6 +1520,12 @@ export default {
|
||||
searchProxies: 'Search proxies...',
|
||||
allProtocols: 'All Protocols',
|
||||
allStatus: 'All Status',
|
||||
protocols: {
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
socks5: 'SOCKS5',
|
||||
socks5h: 'SOCKS5H (Remote DNS)'
|
||||
},
|
||||
columns: {
|
||||
name: 'Name',
|
||||
protocol: 'Protocol',
|
||||
@@ -1601,7 +1643,13 @@ export default {
|
||||
selectGroupPlaceholder: 'Choose a subscription group',
|
||||
validityDays: 'Validity Days',
|
||||
groupRequired: 'Please select a subscription group',
|
||||
days: ' days'
|
||||
days: ' days',
|
||||
status: {
|
||||
unused: 'Unused',
|
||||
used: 'Used',
|
||||
expired: 'Expired',
|
||||
disabled: 'Disabled'
|
||||
}
|
||||
},
|
||||
|
||||
// Usage Records
|
||||
@@ -1610,6 +1658,7 @@ export default {
|
||||
description: 'View and manage all user usage records',
|
||||
userFilter: 'User',
|
||||
searchUserPlaceholder: 'Search user by email...',
|
||||
searchApiKeyPlaceholder: 'Search API key by name...',
|
||||
selectedUser: 'Selected',
|
||||
user: 'User',
|
||||
account: 'Account',
|
||||
|
||||
@@ -44,6 +44,7 @@ export default {
|
||||
description: '配置您的 Sub2API 实例',
|
||||
database: {
|
||||
title: '数据库配置',
|
||||
description: '连接到您的 PostgreSQL 数据库',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
username: '用户名',
|
||||
@@ -60,6 +61,7 @@ export default {
|
||||
},
|
||||
redis: {
|
||||
title: 'Redis 配置',
|
||||
description: '连接到您的 Redis 服务器',
|
||||
host: '主机',
|
||||
port: '端口',
|
||||
password: '密码(可选)',
|
||||
@@ -68,6 +70,7 @@ export default {
|
||||
},
|
||||
admin: {
|
||||
title: '管理员账户',
|
||||
description: '创建您的管理员账户',
|
||||
email: '邮箱',
|
||||
password: '密码',
|
||||
confirmPassword: '确认密码',
|
||||
@@ -77,9 +80,21 @@ export default {
|
||||
},
|
||||
ready: {
|
||||
title: '准备安装',
|
||||
description: '检查您的配置并完成安装',
|
||||
database: '数据库',
|
||||
redis: 'Redis',
|
||||
adminEmail: '管理员邮箱'
|
||||
},
|
||||
status: {
|
||||
testing: '测试中...',
|
||||
success: '连接成功',
|
||||
testConnection: '测试连接',
|
||||
installing: '安装中...',
|
||||
completeInstallation: '完成安装',
|
||||
completed: '安装完成!',
|
||||
redirecting: '正在跳转到登录页面...',
|
||||
restarting: '服务正在重启,请稍候...',
|
||||
timeout: '服务重启时间超出预期,请手动刷新页面。'
|
||||
}
|
||||
},
|
||||
|
||||
@@ -130,7 +145,10 @@ export default {
|
||||
selectOption: '请选择',
|
||||
searchPlaceholder: '搜索...',
|
||||
noOptionsFound: '无匹配选项',
|
||||
noGroupsAvailable: '无可用分组',
|
||||
unknownError: '发生未知错误',
|
||||
saving: '保存中...',
|
||||
selectedCount: '(已选 {count} 个)',
|
||||
refresh: '刷新',
|
||||
notAvailable: '不可用',
|
||||
now: '现在',
|
||||
@@ -665,10 +683,6 @@ export default {
|
||||
admin: '管理员',
|
||||
user: '用户'
|
||||
},
|
||||
statuses: {
|
||||
active: '正常',
|
||||
banned: '禁用'
|
||||
},
|
||||
form: {
|
||||
emailLabel: '邮箱',
|
||||
emailPlaceholder: '请输入邮箱',
|
||||
@@ -795,6 +809,7 @@ export default {
|
||||
groups: {
|
||||
title: '分组管理',
|
||||
description: '管理 API 密钥分组和费率配置',
|
||||
searchGroups: '搜索分组...',
|
||||
createGroup: '创建分组',
|
||||
editGroup: '编辑分组',
|
||||
deleteGroup: '删除分组',
|
||||
@@ -852,8 +867,10 @@ export default {
|
||||
rateMultiplierHint: '1.0 = 标准费率,0.5 = 半价,2.0 = 双倍',
|
||||
platforms: {
|
||||
all: '全部平台',
|
||||
claude: 'Claude',
|
||||
openai: 'OpenAI'
|
||||
anthropic: 'Anthropic',
|
||||
openai: 'OpenAI',
|
||||
gemini: 'Gemini',
|
||||
antigravity: 'Antigravity'
|
||||
},
|
||||
saving: '保存中...',
|
||||
noGroups: '暂无分组',
|
||||
@@ -1054,16 +1071,17 @@ export default {
|
||||
api_key: 'API Key',
|
||||
cookie: 'Cookie'
|
||||
},
|
||||
statuses: {
|
||||
status: {
|
||||
active: '正常',
|
||||
inactive: '停用',
|
||||
error: '错误',
|
||||
cooldown: '冷却中'
|
||||
},
|
||||
status: {
|
||||
paused: '已暂停',
|
||||
limited: '受限',
|
||||
tempUnschedulable: '临时不可调度'
|
||||
cooldown: '冷却中',
|
||||
paused: '暂停',
|
||||
limited: '限流',
|
||||
tempUnschedulable: '临时不可调度',
|
||||
rateLimitedUntil: '限流中,重置时间:{time}',
|
||||
overloadedUntil: '负载过重,重置时间:{time}',
|
||||
viewTempUnschedDetails: '查看临时不可调度详情'
|
||||
},
|
||||
tempUnschedulable: {
|
||||
title: '临时不可调度',
|
||||
@@ -1596,25 +1614,6 @@ export default {
|
||||
deleteConfirmMessage: "确定要删除代理 '{name}' 吗?",
|
||||
testProxy: '测试代理',
|
||||
columns: {
|
||||
name: '名称',
|
||||
protocol: '协议',
|
||||
address: '地址',
|
||||
priority: '优先级',
|
||||
status: '状态',
|
||||
lastCheck: '最近检测',
|
||||
actions: '操作'
|
||||
},
|
||||
protocols: {
|
||||
http: 'HTTP',
|
||||
https: 'HTTPS',
|
||||
socks5: 'SOCKS5'
|
||||
},
|
||||
statuses: {
|
||||
active: '正常',
|
||||
inactive: '停用',
|
||||
error: '错误'
|
||||
},
|
||||
form: {
|
||||
nameLabel: '名称',
|
||||
namePlaceholder: '请输入代理名称',
|
||||
protocolLabel: '协议',
|
||||
@@ -1753,7 +1752,7 @@ export default {
|
||||
validityDays: '有效天数',
|
||||
groupRequired: '请选择订阅分组',
|
||||
days: '天',
|
||||
statuses: {
|
||||
status: {
|
||||
unused: '未使用',
|
||||
used: '已使用',
|
||||
expired: '已过期',
|
||||
@@ -1805,6 +1804,7 @@ export default {
|
||||
description: '查看和管理所有用户的使用记录',
|
||||
userFilter: '用户',
|
||||
searchUserPlaceholder: '按邮箱搜索用户...',
|
||||
searchApiKeyPlaceholder: '按名称搜索 API 密钥...',
|
||||
selectedUser: '已选择',
|
||||
user: '用户',
|
||||
account: '账户',
|
||||
|
||||
@@ -2,6 +2,26 @@
|
||||
* Core Type Definitions for Sub2API Frontend
|
||||
*/
|
||||
|
||||
// ==================== Common Types ====================
|
||||
|
||||
export interface SelectOption {
|
||||
value: string | number | boolean | null
|
||||
label: string
|
||||
[key: string]: any // Support extra properties for custom templates
|
||||
}
|
||||
|
||||
export interface BasePaginationResponse<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface FetchOptions {
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
// ==================== User & Auth Types ====================
|
||||
|
||||
export interface User {
|
||||
@@ -476,6 +496,7 @@ export interface UpdateAccountRequest {
|
||||
proxy_id?: number | null
|
||||
concurrency?: number
|
||||
priority?: number
|
||||
schedulable?: boolean
|
||||
status?: 'active' | 'inactive'
|
||||
group_ids?: number[]
|
||||
confirm_mixed_channel_risk?: boolean
|
||||
@@ -826,6 +847,7 @@ export type UserAttributeType = 'text' | 'textarea' | 'number' | 'email' | 'url'
|
||||
export interface UserAttributeOption {
|
||||
value: string
|
||||
label: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserAttributeValidation {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 参考 CRS 项目的 format.js 实现
|
||||
*/
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import { i18n, getLocale } from '@/i18n'
|
||||
|
||||
/**
|
||||
* 格式化相对时间
|
||||
@@ -39,33 +39,39 @@ export function formatRelativeTime(date: string | Date | null | undefined): stri
|
||||
export function formatNumber(num: number | null | undefined): string {
|
||||
if (num === null || num === undefined) return '0'
|
||||
|
||||
const locale = getLocale()
|
||||
const absNum = Math.abs(num)
|
||||
|
||||
if (absNum >= 1e9) {
|
||||
return (num / 1e9).toFixed(2) + 'B'
|
||||
} else if (absNum >= 1e6) {
|
||||
return (num / 1e6).toFixed(2) + 'M'
|
||||
} else if (absNum >= 1e3) {
|
||||
return (num / 1e3).toFixed(1) + 'K'
|
||||
}
|
||||
// Use Intl.NumberFormat for compact notation if supported and needed
|
||||
// Note: Compact notation in 'zh' uses '万/亿', which is appropriate for Chinese
|
||||
const formatter = new Intl.NumberFormat(locale, {
|
||||
notation: absNum >= 10000 ? 'compact' : 'standard',
|
||||
maximumFractionDigits: 1
|
||||
})
|
||||
|
||||
return num.toLocaleString()
|
||||
return formatter.format(num)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化货币金额
|
||||
* @param amount 金额
|
||||
* @returns 格式化后的字符串,如 "$1.25" 或 "$0.000123"
|
||||
* @param currency 货币代码,默认 USD
|
||||
* @returns 格式化后的字符串,如 "$1.25"
|
||||
*/
|
||||
export function formatCurrency(amount: number | null | undefined): string {
|
||||
export function formatCurrency(amount: number | null | undefined, currency: string = 'USD'): string {
|
||||
if (amount === null || amount === undefined) return '$0.00'
|
||||
|
||||
// 小于 0.01 时显示更多小数位
|
||||
if (amount > 0 && amount < 0.01) {
|
||||
return '$' + amount.toFixed(6)
|
||||
}
|
||||
const locale = getLocale()
|
||||
|
||||
return '$' + amount.toFixed(2)
|
||||
// For very small amounts, show more decimals
|
||||
const fractionDigits = amount > 0 && amount < 0.01 ? 6 : 2
|
||||
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency: currency,
|
||||
minimumFractionDigits: fractionDigits,
|
||||
maximumFractionDigits: fractionDigits
|
||||
}).format(amount)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,57 +95,89 @@ export function formatBytes(bytes: number, decimals: number = 2): string {
|
||||
/**
|
||||
* 格式化日期
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @param format 格式字符串,支持 YYYY, MM, DD, HH, mm, ss
|
||||
* @param options Intl.DateTimeFormatOptions
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDate(
|
||||
date: string | Date | null | undefined,
|
||||
format: string = 'YYYY-MM-DD HH:mm:ss'
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}
|
||||
): string {
|
||||
if (!date) return ''
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return ''
|
||||
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return format
|
||||
.replace('YYYY', String(year))
|
||||
.replace('MM', month)
|
||||
.replace('DD', day)
|
||||
.replace('HH', hours)
|
||||
.replace('mm', minutes)
|
||||
.replace('ss', seconds)
|
||||
const locale = getLocale()
|
||||
return new Intl.DateTimeFormat(locale, options).format(d)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(只显示日期部分)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的日期字符串,格式为 YYYY-MM-DD
|
||||
* @returns 格式化后的日期字符串
|
||||
*/
|
||||
export function formatDateOnly(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, 'YYYY-MM-DD')
|
||||
return formatDate(date, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(完整格式)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的日期时间字符串,格式为 YYYY-MM-DD HH:mm:ss
|
||||
* @returns 格式化后的日期时间字符串
|
||||
*/
|
||||
export function formatDateTime(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, 'YYYY-MM-DD HH:mm:ss')
|
||||
return formatDate(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化时间(只显示时分)
|
||||
* @param date 日期字符串或 Date 对象
|
||||
* @returns 格式化后的时间字符串,格式为 HH:mm
|
||||
* @returns 格式化后的时间字符串
|
||||
*/
|
||||
export function formatTime(date: string | Date | null | undefined): string {
|
||||
return formatDate(date, 'HH:mm')
|
||||
return formatDate(date, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(千分位分隔,不使用紧凑单位)
|
||||
* @param num 数字
|
||||
* @returns 格式化后的字符串,如 "12,345"
|
||||
*/
|
||||
export function formatNumberLocaleString(num: number): string {
|
||||
return num.toLocaleString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化金额(固定小数位,不带货币符号)
|
||||
* @param amount 金额
|
||||
* @param fractionDigits 小数位数,默认 4
|
||||
* @returns 格式化后的字符串,如 "1.2345"
|
||||
*/
|
||||
export function formatCostFixed(amount: number, fractionDigits: number = 4): string {
|
||||
return amount.toFixed(fractionDigits)
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化 token 数量(>=1000 显示为 K,保留 1 位小数)
|
||||
* @param tokens token 数量
|
||||
* @returns 格式化后的字符串,如 "950", "1.2K"
|
||||
*/
|
||||
export function formatTokensK(tokens: number): string {
|
||||
return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}K` : tokens.toString()
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -504,7 +504,7 @@ const userTrendChartData = computed(() => {
|
||||
if (email && email.includes('@')) {
|
||||
return email.split('@')[0]
|
||||
}
|
||||
return `User #${userId}`
|
||||
return t('admin.redeem.userPrefix', { id: userId })
|
||||
}
|
||||
|
||||
// Group by user
|
||||
@@ -652,16 +652,4 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Compact Select styling for dashboard */
|
||||
:deep(.select-trigger) {
|
||||
@apply rounded-lg px-3 py-1.5 text-sm;
|
||||
}
|
||||
|
||||
:deep(.select-dropdown) {
|
||||
@apply rounded-lg;
|
||||
}
|
||||
|
||||
:deep(.select-option) {
|
||||
@apply px-3 py-2 text-sm;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,31 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadGroups"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
data-tour="groups-create-btn"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<div class="flex flex-col justify-between gap-4 lg:flex-row lg:items-start">
|
||||
<!-- Left: fuzzy search + filters (can wrap to multiple lines) -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<div class="relative w-full sm:w-72 lg:w-80">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.groups.searchGroups')"
|
||||
class="input pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
v-model="filters.platform"
|
||||
:options="platformFilterOptions"
|
||||
@@ -65,11 +47,56 @@
|
||||
class="w-44"
|
||||
@change="loadGroups"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right: actions -->
|
||||
<div class="flex w-full flex-shrink-0 flex-wrap items-center justify-end gap-3 lg:w-auto">
|
||||
<button
|
||||
@click="loadGroups"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
@click="showCreateModal = true"
|
||||
class="btn btn-primary"
|
||||
data-tour="groups-create-btn"
|
||||
>
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.groups.createGroup') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="groups" :loading="loading">
|
||||
<DataTable :columns="columns" :data="displayedGroups" :loading="loading">
|
||||
<template #cell-name="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
@@ -88,15 +115,7 @@
|
||||
]"
|
||||
>
|
||||
<PlatformIcon :platform="value" size="xs" />
|
||||
{{
|
||||
value === 'anthropic'
|
||||
? 'Anthropic'
|
||||
: value === 'openai'
|
||||
? 'OpenAI'
|
||||
: value === 'antigravity'
|
||||
? 'Antigravity'
|
||||
: 'Gemini'
|
||||
}}
|
||||
{{ t('admin.groups.platforms.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -172,7 +191,7 @@
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
{{ value }}
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -691,8 +710,8 @@ const columns = computed<Column[]>(() => [
|
||||
// Filter options
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.groups.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
{ value: 'active', label: t('admin.accounts.status.active') },
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
||||
])
|
||||
|
||||
const exclusiveOptions = computed(() => [
|
||||
@@ -717,8 +736,8 @@ const platformFilterOptions = computed(() => [
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
{ value: 'active', label: t('admin.accounts.status.active') },
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
||||
])
|
||||
|
||||
const subscriptionTypeOptions = computed(() => [
|
||||
@@ -728,6 +747,7 @@ const subscriptionTypeOptions = computed(() => [
|
||||
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const filters = reactive({
|
||||
platform: '',
|
||||
status: '',
|
||||
@@ -742,6 +762,16 @@ const pagination = reactive({
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
const displayedGroups = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return groups.value
|
||||
return groups.value.filter((group) => {
|
||||
const name = group.name?.toLowerCase?.() ?? ''
|
||||
const description = group.description?.toLowerCase?.() ?? ''
|
||||
return name.includes(q) || description.includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const showCreateModal = ref(false)
|
||||
const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
|
||||
@@ -1,82 +1,92 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #filters>
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="relative max-w-md flex-1">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<!-- Left: Fuzzy search + filters (wrap to multiple lines) -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- Search -->
|
||||
<div class="relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md">
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="t('admin.proxies.searchProxies')"
|
||||
class="input pl-10"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-36">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.protocol"
|
||||
:options="protocolOptions"
|
||||
:placeholder="t('admin.proxies.allProtocols')"
|
||||
class="w-40"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.proxies.allStatus')"
|
||||
class="w-36"
|
||||
@change="loadProxies"
|
||||
/>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
@click="loadProxies"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showCreateModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.proxies.createProxy') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -103,7 +113,7 @@
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-danger']">
|
||||
{{ value }}
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -634,21 +644,21 @@ const protocolOptions = computed(() => [
|
||||
|
||||
const statusOptions = computed(() => [
|
||||
{ value: '', label: t('admin.proxies.allStatus') },
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
{ value: 'active', label: t('admin.accounts.status.active') },
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
||||
])
|
||||
|
||||
// Form options
|
||||
const protocolSelectOptions = [
|
||||
{ value: 'http', label: 'HTTP' },
|
||||
{ value: 'https', label: 'HTTPS' },
|
||||
{ value: 'socks5', label: 'SOCKS5' },
|
||||
{ value: 'socks5h', label: 'SOCKS5H (服务端解析DNS)' }
|
||||
]
|
||||
const protocolSelectOptions = computed(() => [
|
||||
{ value: 'http', label: t('admin.proxies.protocols.http') },
|
||||
{ value: 'https', label: t('admin.proxies.protocols.https') },
|
||||
{ value: 'socks5', label: t('admin.proxies.protocols.socks5') },
|
||||
{ value: 'socks5h', label: t('admin.proxies.protocols.socks5h') }
|
||||
])
|
||||
|
||||
const editStatusOptions = computed(() => [
|
||||
{ value: 'active', label: t('common.active') },
|
||||
{ value: 'inactive', label: t('common.inactive') }
|
||||
{ value: 'active', label: t('admin.accounts.status.active') },
|
||||
{ value: 'inactive', label: t('admin.accounts.status.inactive') }
|
||||
])
|
||||
|
||||
const proxies = ref<Proxy[]>([])
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
: 'badge-primary'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
{{ t('admin.redeem.types.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -120,7 +120,7 @@
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
<template v-if="row.type === 'balance'">${{ value.toFixed(2) }}</template>
|
||||
<template v-else-if="row.type === 'subscription'">
|
||||
{{ row.validity_days || 30 }}{{ t('admin.redeem.days') }}
|
||||
{{ row.validity_days || 30 }} {{ t('admin.redeem.days') }}
|
||||
<span v-if="row.group" class="ml-1 text-xs text-gray-500 dark:text-gray-400"
|
||||
>({{ row.group.name }})</span
|
||||
>
|
||||
@@ -140,7 +140,7 @@
|
||||
: 'badge-danger'
|
||||
]"
|
||||
>
|
||||
{{ value }}
|
||||
{{ t('admin.redeem.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -775,7 +775,10 @@ const form = reactive<SettingsForm>({
|
||||
turnstile_enabled: false,
|
||||
turnstile_site_key: '',
|
||||
turnstile_secret_key: '',
|
||||
turnstile_secret_key_configured: false
|
||||
turnstile_secret_key_configured: false,
|
||||
// Identity patch (Claude -> Gemini)
|
||||
enable_identity_patch: true,
|
||||
identity_patch_prompt: ''
|
||||
})
|
||||
|
||||
function handleLogoUpload(event: Event) {
|
||||
|
||||
@@ -1,62 +1,143 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<TablePageLayout>
|
||||
<!-- Page Header Actions -->
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showAssignModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Filters -->
|
||||
<template #filters>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.subscriptions.allStatus')"
|
||||
class="w-40"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
<Select
|
||||
v-model="filters.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('admin.subscriptions.allGroups')"
|
||||
class="w-48"
|
||||
@change="loadSubscriptions"
|
||||
/>
|
||||
</div>
|
||||
<!-- Top Toolbar: Left (search + filters) / Right (actions) -->
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<!-- Left: Fuzzy user search + filters (wrap to multiple lines) -->
|
||||
<div class="flex flex-1 flex-wrap items-center gap-3">
|
||||
<!-- User Search -->
|
||||
<div
|
||||
class="relative w-full sm:flex-1 sm:min-w-[14rem] sm:max-w-md"
|
||||
data-filter-user-search
|
||||
>
|
||||
<svg
|
||||
class="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
<input
|
||||
v-model="filterUserKeyword"
|
||||
type="text"
|
||||
:placeholder="t('admin.users.searchUsers')"
|
||||
class="input pl-10 pr-8"
|
||||
@input="debounceSearchFilterUsers"
|
||||
@focus="showFilterUserDropdown = true"
|
||||
/>
|
||||
<button
|
||||
v-if="selectedFilterUser"
|
||||
@click="clearFilterUser"
|
||||
type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:title="t('common.clear')"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- User Dropdown -->
|
||||
<div
|
||||
v-if="showFilterUserDropdown && (filterUserResults.length > 0 || filterUserKeyword)"
|
||||
class="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div
|
||||
v-if="filterUserLoading"
|
||||
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filterUserResults.length === 0 && filterUserKeyword"
|
||||
class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('common.noOptionsFound') }}
|
||||
</div>
|
||||
<button
|
||||
v-for="user in filterUserResults"
|
||||
:key="user.id"
|
||||
type="button"
|
||||
@click="selectFilterUser(user)"
|
||||
class="w-full px-4 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ user.email }}</span>
|
||||
<span class="ml-2 text-gray-500 dark:text-gray-400">#{{ user.id }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="w-full sm:w-40">
|
||||
<Select
|
||||
v-model="filters.status"
|
||||
:options="statusOptions"
|
||||
:placeholder="t('admin.subscriptions.allStatus')"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full sm:w-48">
|
||||
<Select
|
||||
v-model="filters.group_id"
|
||||
:options="groupOptions"
|
||||
:placeholder="t('admin.subscriptions.allGroups')"
|
||||
@change="applyFilters"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Actions -->
|
||||
<div class="ml-auto flex flex-wrap items-center justify-end gap-3">
|
||||
<button
|
||||
@click="loadSubscriptions"
|
||||
:disabled="loading"
|
||||
class="btn btn-secondary"
|
||||
:title="t('common.refresh')"
|
||||
>
|
||||
<svg
|
||||
:class="['h-5 w-5', loading ? 'animate-spin' : '']"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="showAssignModal = true" class="btn btn-primary">
|
||||
<svg
|
||||
class="mr-2 h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 4.5v15m7.5-7.5h-15"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.subscriptions.assignSubscription') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Subscriptions Table -->
|
||||
@@ -72,7 +153,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{
|
||||
row.user?.email || `User #${row.user_id}`
|
||||
row.user?.email || t('admin.redeem.userPrefix', { id: row.user_id })
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -338,7 +419,7 @@
|
||||
>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.subscriptions.form.user') }}</label>
|
||||
<div class="relative">
|
||||
<div class="relative" data-assign-user-search>
|
||||
<input
|
||||
v-model="userSearchKeyword"
|
||||
type="text"
|
||||
@@ -555,6 +636,14 @@ const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
// Toolbar user filter (fuzzy search -> select user_id)
|
||||
const filterUserKeyword = ref('')
|
||||
const filterUserResults = ref<SimpleUser[]>([])
|
||||
const filterUserLoading = ref(false)
|
||||
const showFilterUserDropdown = ref(false)
|
||||
const selectedFilterUser = ref<SimpleUser | null>(null)
|
||||
let filterUserSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// User search state
|
||||
const userSearchKeyword = ref('')
|
||||
const userSearchResults = ref<SimpleUser[]>([])
|
||||
@@ -565,7 +654,8 @@ let userSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const filters = reactive({
|
||||
status: '',
|
||||
group_id: ''
|
||||
group_id: '',
|
||||
user_id: null as number | null
|
||||
})
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
@@ -604,6 +694,11 @@ const subscriptionGroupOptions = computed(() =>
|
||||
.map((g) => ({ value: g.id, label: g.name }))
|
||||
)
|
||||
|
||||
const applyFilters = () => {
|
||||
pagination.page = 1
|
||||
loadSubscriptions()
|
||||
}
|
||||
|
||||
const loadSubscriptions = async () => {
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
@@ -614,12 +709,18 @@ const loadSubscriptions = async () => {
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await adminAPI.subscriptions.list(pagination.page, pagination.page_size, {
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined
|
||||
}, {
|
||||
signal
|
||||
})
|
||||
const response = await adminAPI.subscriptions.list(
|
||||
pagination.page,
|
||||
pagination.page_size,
|
||||
{
|
||||
status: (filters.status as any) || undefined,
|
||||
group_id: filters.group_id ? parseInt(filters.group_id) : undefined,
|
||||
user_id: filters.user_id || undefined
|
||||
},
|
||||
{
|
||||
signal
|
||||
}
|
||||
)
|
||||
if (signal.aborted || abortController !== requestController) return
|
||||
subscriptions.value = response.items
|
||||
pagination.total = response.total
|
||||
@@ -646,6 +747,57 @@ const loadGroups = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Toolbar user filter search with debounce
|
||||
const debounceSearchFilterUsers = () => {
|
||||
if (filterUserSearchTimeout) {
|
||||
clearTimeout(filterUserSearchTimeout)
|
||||
}
|
||||
filterUserSearchTimeout = setTimeout(searchFilterUsers, 300)
|
||||
}
|
||||
|
||||
const searchFilterUsers = async () => {
|
||||
const keyword = filterUserKeyword.value.trim()
|
||||
|
||||
// Clear active user filter if user modified the search keyword
|
||||
if (selectedFilterUser.value && keyword !== selectedFilterUser.value.email) {
|
||||
selectedFilterUser.value = null
|
||||
filters.user_id = null
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
if (!keyword) {
|
||||
filterUserResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
filterUserLoading.value = true
|
||||
try {
|
||||
filterUserResults.value = await adminAPI.usage.searchUsers(keyword)
|
||||
} catch (error) {
|
||||
console.error('Failed to search users:', error)
|
||||
filterUserResults.value = []
|
||||
} finally {
|
||||
filterUserLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectFilterUser = (user: SimpleUser) => {
|
||||
selectedFilterUser.value = user
|
||||
filterUserKeyword.value = user.email
|
||||
showFilterUserDropdown.value = false
|
||||
filters.user_id = user.id
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
const clearFilterUser = () => {
|
||||
selectedFilterUser.value = null
|
||||
filterUserKeyword.value = ''
|
||||
filterUserResults.value = []
|
||||
showFilterUserDropdown.value = false
|
||||
filters.user_id = null
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// User search with debounce
|
||||
const debounceSearchUsers = () => {
|
||||
if (userSearchTimeout) {
|
||||
@@ -856,9 +1008,8 @@ const formatResetTime = (windowStart: string, period: 'daily' | 'weekly' | 'mont
|
||||
// Handle click outside to close user dropdown
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (!target.closest('.relative')) {
|
||||
showUserDropdown.value = false
|
||||
}
|
||||
if (!target.closest('[data-assign-user-search]')) showUserDropdown.value = false
|
||||
if (!target.closest('[data-filter-user-search]')) showFilterUserDropdown.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -869,6 +1020,9 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
if (filterUserSearchTimeout) {
|
||||
clearTimeout(filterUserSearchTimeout)
|
||||
}
|
||||
if (userSearchTimeout) {
|
||||
clearTimeout(userSearchTimeout)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -87,7 +87,7 @@
|
||||
{{ t('setup.database.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
Connect to your PostgreSQL database
|
||||
{{ t('setup.database.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -145,12 +145,15 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('setup.database.sslMode') }}</label>
|
||||
<select v-model="formData.database.sslmode" class="input">
|
||||
<option value="disable">{{ t('setup.database.ssl.disable') }}</option>
|
||||
<option value="require">{{ t('setup.database.ssl.require') }}</option>
|
||||
<option value="verify-ca">{{ t('setup.database.ssl.verifyCa') }}</option>
|
||||
<option value="verify-full">{{ t('setup.database.ssl.verifyFull') }}</option>
|
||||
</select>
|
||||
<Select
|
||||
v-model="formData.database.sslmode"
|
||||
:options="[
|
||||
{ value: 'disable', label: t('setup.database.ssl.disable') },
|
||||
{ value: 'require', label: t('setup.database.ssl.require') },
|
||||
{ value: 'verify-ca', label: t('setup.database.ssl.verifyCa') },
|
||||
{ value: 'verify-full', label: t('setup.database.ssl.verifyFull') }
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,7 +193,11 @@
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
{{
|
||||
testingDb ? 'Testing...' : dbConnected ? 'Connection Successful' : 'Test Connection'
|
||||
testingDb
|
||||
? t('setup.status.testing')
|
||||
: dbConnected
|
||||
? t('setup.status.success')
|
||||
: t('setup.status.testConnection')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -202,7 +209,7 @@
|
||||
{{ t('setup.redis.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
Connect to your Redis server
|
||||
{{ t('setup.redis.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -285,10 +292,10 @@
|
||||
</svg>
|
||||
{{
|
||||
testingRedis
|
||||
? 'Testing...'
|
||||
? t('setup.status.testing')
|
||||
: redisConnected
|
||||
? 'Connection Successful'
|
||||
: 'Test Connection'
|
||||
? t('setup.status.success')
|
||||
: t('setup.status.testConnection')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -300,7 +307,7 @@
|
||||
{{ t('setup.admin.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
Create your administrator account
|
||||
{{ t('setup.admin.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -348,7 +355,7 @@
|
||||
{{ t('setup.ready.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-dark-400">
|
||||
Review your configuration and complete setup
|
||||
{{ t('setup.ready.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -447,13 +454,13 @@
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
Installation completed!
|
||||
{{ t('setup.status.completed') }}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-green-600 dark:text-green-500">
|
||||
{{
|
||||
serviceReady
|
||||
? 'Redirecting to login page...'
|
||||
: 'Service is restarting, please wait...'
|
||||
? t('setup.status.redirecting')
|
||||
: t('setup.status.restarting')
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
@@ -480,7 +487,7 @@
|
||||
d="M15.75 19.5L8.25 12l7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
Previous
|
||||
{{ t('common.back') }}
|
||||
</button>
|
||||
<div v-else></div>
|
||||
|
||||
@@ -490,7 +497,7 @@
|
||||
:disabled="!canProceed"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
Next
|
||||
{{ t('common.next') }}
|
||||
<svg
|
||||
class="ml-2 h-4 w-4"
|
||||
fill="none"
|
||||
@@ -528,7 +535,7 @@
|
||||
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>
|
||||
{{ installing ? 'Installing...' : 'Complete Installation' }}
|
||||
{{ installing ? t('setup.status.installing') : t('setup.status.completeInstallation') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -540,15 +547,16 @@
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { testDatabase, testRedis, install, type InstallRequest } from '@/api/setup'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const steps = [
|
||||
{ id: 'database', title: 'Database' },
|
||||
{ id: 'redis', title: 'Redis' },
|
||||
{ id: 'admin', title: 'Admin' },
|
||||
{ id: 'complete', title: 'Complete' }
|
||||
]
|
||||
const steps = computed(() => [
|
||||
{ id: 'database', title: t('setup.database.title') },
|
||||
{ id: 'redis', title: t('setup.redis.title') },
|
||||
{ id: 'admin', title: t('setup.admin.title') },
|
||||
{ id: 'complete', title: t('setup.ready.title') }
|
||||
])
|
||||
|
||||
const currentStep = ref(0)
|
||||
const errorMessage = ref('')
|
||||
@@ -710,7 +718,6 @@ async function waitForServiceRestart() {
|
||||
|
||||
// If we reach here, service didn't restart in time
|
||||
// Show a message to refresh manually
|
||||
errorMessage.value =
|
||||
'Service restart is taking longer than expected. Please refresh the page manually.'
|
||||
errorMessage.value = t('setup.status.timeout')
|
||||
}
|
||||
</script>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,7 +141,7 @@
|
||||
|
||||
<template #cell-status="{ value }">
|
||||
<span :class="['badge', value === 'active' ? 'badge-success' : 'badge-gray']">
|
||||
{{ value }}
|
||||
{{ t('admin.accounts.status.' + value) }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
@@ -335,12 +335,14 @@
|
||||
/>
|
||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||
</template>
|
||||
<template #option="{ option }">
|
||||
<GroupBadge
|
||||
<template #option="{ option, selected }">
|
||||
<GroupOptionItem
|
||||
:name="(option as unknown as GroupOption).label"
|
||||
:platform="(option as unknown as GroupOption).platform"
|
||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||
:description="(option as unknown as GroupOption).description"
|
||||
:selected="selected"
|
||||
/>
|
||||
</template>
|
||||
</Select>
|
||||
@@ -501,7 +503,8 @@
|
||||
<div
|
||||
v-if="groupSelectorKeyId !== null && dropdownPosition"
|
||||
ref="dropdownRef"
|
||||
class="animate-in fade-in slide-in-from-top-2 fixed z-[9999] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
|
||||
class="animate-in fade-in slide-in-from-top-2 fixed z-[100000020] w-64 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 duration-200 dark:bg-dark-800 dark:ring-white/10"
|
||||
style="pointer-events: auto !important;"
|
||||
:style="{ top: dropdownPosition.top + 'px', left: dropdownPosition.left + 'px' }"
|
||||
>
|
||||
<div class="max-h-64 overflow-y-auto p-1.5">
|
||||
@@ -516,26 +519,19 @@
|
||||
? 'bg-primary-50 dark:bg-primary-900/20'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-dark-700'
|
||||
]"
|
||||
:title="option.description || undefined"
|
||||
>
|
||||
<GroupBadge
|
||||
<GroupOptionItem
|
||||
:name="option.label"
|
||||
:platform="option.platform"
|
||||
:subscription-type="option.subscriptionType"
|
||||
:rate-multiplier="option.rate"
|
||||
/>
|
||||
<svg
|
||||
v-if="
|
||||
:description="option.description"
|
||||
:selected="
|
||||
selectedKeyForGroup?.group_id === option.value ||
|
||||
(!selectedKeyForGroup?.group_id && option.value === null)
|
||||
"
|
||||
class="h-4 w-4 shrink-0 text-primary-600 dark:text-primary-400"
|
||||
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>
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -562,6 +558,7 @@ import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import UseKeyModal from '@/components/keys/UseKeyModal.vue'
|
||||
import GroupBadge from '@/components/common/GroupBadge.vue'
|
||||
import GroupOptionItem from '@/components/common/GroupOptionItem.vue'
|
||||
import type { ApiKey, Group, PublicSettings, SubscriptionType, GroupPlatform } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import type { BatchApiKeyUsageStats } from '@/api/usage'
|
||||
@@ -570,6 +567,7 @@ import { formatDateTime } from '@/utils/format'
|
||||
interface GroupOption {
|
||||
value: number
|
||||
label: string
|
||||
description: string | null
|
||||
rate: number
|
||||
subscriptionType: SubscriptionType
|
||||
platform: GroupPlatform
|
||||
@@ -665,6 +663,7 @@ const groupOptions = computed(() =>
|
||||
groups.value.map((group) => ({
|
||||
value: group.id,
|
||||
label: group.name,
|
||||
description: group.description,
|
||||
rate: group.rate_multiplier,
|
||||
subscriptionType: group.subscription_type,
|
||||
platform: group.platform
|
||||
|
||||
@@ -1,389 +1,40 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="mx-auto max-w-4xl space-y-6">
|
||||
<!-- Account Stats Summary -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-3">
|
||||
<StatCard
|
||||
:title="t('profile.accountBalance')"
|
||||
:value="formatCurrency(user?.balance || 0)"
|
||||
:icon="WalletIcon"
|
||||
icon-variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.concurrencyLimit')"
|
||||
:value="user?.concurrency || 0"
|
||||
:icon="BoltIcon"
|
||||
icon-variant="warning"
|
||||
/>
|
||||
<StatCard
|
||||
:title="t('profile.memberSince')"
|
||||
:value="formatDate(user?.created_at || '', 'YYYY-MM')"
|
||||
:icon="CalendarIcon"
|
||||
icon-variant="primary"
|
||||
/>
|
||||
<StatCard :title="t('profile.accountBalance')" :value="formatCurrency(user?.balance || 0)" :icon="WalletIcon" icon-variant="success" />
|
||||
<StatCard :title="t('profile.concurrencyLimit')" :value="user?.concurrency || 0" :icon="BoltIcon" icon-variant="warning" />
|
||||
<StatCard :title="t('profile.memberSince')" :value="formatDate(user?.created_at || '', { year: 'numeric', month: 'long' })" :icon="CalendarIcon" icon-variant="primary" />
|
||||
</div>
|
||||
|
||||
<!-- User Information -->
|
||||
<div class="card overflow-hidden">
|
||||
<div
|
||||
class="border-b border-gray-100 bg-gradient-to-r from-primary-500/10 to-primary-600/5 px-6 py-5 dark:border-dark-700 dark:from-primary-500/20 dark:to-primary-600/10"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-500 to-primary-600 text-2xl font-bold text-white shadow-lg shadow-primary-500/20"
|
||||
>
|
||||
{{ user?.email?.charAt(0).toUpperCase() || 'U' }}
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ user?.email }}
|
||||
</h2>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<span :class="['badge', user?.role === 'admin' ? 'badge-primary' : 'badge-gray']">
|
||||
{{ user?.role === 'admin' ? t('profile.administrator') : t('profile.user') }}
|
||||
</span>
|
||||
<span
|
||||
:class="['badge', user?.status === 'active' ? 'badge-success' : 'badge-danger']"
|
||||
>
|
||||
{{ user?.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4">
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ user?.email }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="user?.username"
|
||||
class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400 dark:text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Support Section -->
|
||||
<div
|
||||
v-if="contactInfo"
|
||||
class="card border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100/50 dark:border-primary-800/40 dark:from-primary-900/20 dark:to-primary-800/10"
|
||||
>
|
||||
<div class="px-6 py-5">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-primary-100 dark:bg-primary-900/30"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6 text-primary-600 dark:text-primary-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-sm font-semibold text-primary-800 dark:text-primary-200">
|
||||
{{ t('common.contactSupport') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm font-medium text-primary-600 dark:text-primary-300">
|
||||
{{ contactInfo }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Profile Section -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.editProfile') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleUpdateProfile" class="space-y-4">
|
||||
<div>
|
||||
<label for="username" class="input-label">
|
||||
{{ t('profile.username') }}
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
v-model="profileForm.username"
|
||||
type="text"
|
||||
class="input"
|
||||
:placeholder="t('profile.enterUsername')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" :disabled="updatingProfile" class="btn btn-primary">
|
||||
{{ updatingProfile ? t('profile.updating') : t('profile.updateProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password Section -->
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ t('profile.changePassword') }}
|
||||
</h2>
|
||||
</div>
|
||||
<div class="px-6 py-6">
|
||||
<form @submit.prevent="handleChangePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="old_password" class="input-label">
|
||||
{{ t('profile.currentPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="old_password"
|
||||
v-model="passwordForm.old_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="current-password"
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="new_password" class="input-label">
|
||||
{{ t('profile.newPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="new_password"
|
||||
v-model="passwordForm.new_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p class="input-hint">
|
||||
{{ t('profile.passwordHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="confirm_password" class="input-label">
|
||||
{{ t('profile.confirmNewPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="confirm_password"
|
||||
v-model="passwordForm.confirm_password"
|
||||
type="password"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
class="input"
|
||||
/>
|
||||
<p
|
||||
v-if="passwordForm.new_password && passwordForm.confirm_password && passwordForm.new_password !== passwordForm.confirm_password"
|
||||
class="input-error-text"
|
||||
>
|
||||
{{ t('profile.passwordsNotMatch') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end pt-4">
|
||||
<button type="submit" :disabled="changingPassword" class="btn btn-primary">
|
||||
{{
|
||||
changingPassword
|
||||
? t('profile.changingPassword')
|
||||
: t('profile.changePasswordButton')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<ProfileInfoCard :user="user" />
|
||||
<div v-if="contactInfo" class="card border-primary-200 bg-primary-50 dark:bg-primary-900/20 p-6">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="p-3 bg-primary-100 rounded-xl text-primary-600">💬</div>
|
||||
<div><h3 class="font-semibold text-primary-800 dark:text-primary-200">{{ t('common.contactSupport') }}</h3><p class="text-sm font-medium">{{ contactInfo }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileEditForm :initial-username="user?.username || ''" />
|
||||
<ProfilePasswordForm />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, h, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDate } from '@/utils/format'
|
||||
|
||||
const { t } = useI18n()
|
||||
import { userAPI, authAPI } from '@/api'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import { ref, computed, h, onMounted } from 'vue'; import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore } from '@/stores/auth'; import { formatDate } from '@/utils/format'
|
||||
import { authAPI } from '@/api'; import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import StatCard from '@/components/common/StatCard.vue'
|
||||
import ProfileInfoCard from '@/components/user/profile/ProfileInfoCard.vue'
|
||||
import ProfileEditForm from '@/components/user/profile/ProfileEditForm.vue'
|
||||
import ProfilePasswordForm from '@/components/user/profile/ProfilePasswordForm.vue'
|
||||
|
||||
// SVG Icon Components
|
||||
const WalletIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const BoltIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const CalendarIcon = {
|
||||
render: () =>
|
||||
h(
|
||||
'svg',
|
||||
{ fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' },
|
||||
[
|
||||
h('path', {
|
||||
'stroke-linecap': 'round',
|
||||
'stroke-linejoin': 'round',
|
||||
d: 'M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5'
|
||||
})
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const user = computed(() => authStore.user)
|
||||
|
||||
const passwordForm = ref({
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
})
|
||||
|
||||
const profileForm = ref({
|
||||
username: ''
|
||||
})
|
||||
|
||||
const changingPassword = ref(false)
|
||||
const updatingProfile = ref(false)
|
||||
const { t } = useI18n(); const authStore = useAuthStore(); const user = computed(() => authStore.user)
|
||||
const contactInfo = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const settings = await authAPI.getPublicSettings()
|
||||
contactInfo.value = settings.contact_info || ''
|
||||
const WalletIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12' })]) }
|
||||
const BoltIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'm3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z' })]) }
|
||||
const CalendarIcon = { render: () => h('svg', { fill: 'none', viewBox: '0 0 24 24', stroke: 'currentColor', 'stroke-width': '1.5' }, [h('path', { d: 'M6.75 3v2.25M17.25 3v2.25' })]) }
|
||||
|
||||
// Initialize profile form with current user data
|
||||
if (user.value) {
|
||||
profileForm.value.username = user.value.username || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load contact info:', error)
|
||||
}
|
||||
})
|
||||
|
||||
const formatCurrency = (value: number): string => {
|
||||
return `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
// Validate password match
|
||||
if (passwordForm.value.new_password !== passwordForm.value.confirm_password) {
|
||||
appStore.showError(t('profile.passwordsNotMatch'))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (passwordForm.value.new_password.length < 8) {
|
||||
appStore.showError(t('profile.passwordTooShort'))
|
||||
return
|
||||
}
|
||||
|
||||
changingPassword.value = true
|
||||
try {
|
||||
await userAPI.changePassword(passwordForm.value.old_password, passwordForm.value.new_password)
|
||||
|
||||
// Clear form
|
||||
passwordForm.value = {
|
||||
old_password: '',
|
||||
new_password: '',
|
||||
confirm_password: ''
|
||||
}
|
||||
|
||||
appStore.showSuccess(t('profile.passwordChangeSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.passwordChangeFailed'))
|
||||
} finally {
|
||||
changingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
// Basic validation
|
||||
if (!profileForm.value.username.trim()) {
|
||||
appStore.showError(t('profile.usernameRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
updatingProfile.value = true
|
||||
try {
|
||||
const updatedUser = await userAPI.updateProfile({
|
||||
username: profileForm.value.username
|
||||
})
|
||||
|
||||
// Update auth store with new user data
|
||||
authStore.user = updatedUser
|
||||
|
||||
appStore.showSuccess(t('profile.updateSuccess'))
|
||||
} catch (error: any) {
|
||||
appStore.showError(error.response?.data?.detail || t('profile.updateFailed'))
|
||||
} finally {
|
||||
updatingProfile.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
onMounted(async () => { try { const s = await authAPI.getPublicSettings(); contactInfo.value = s.contact_info || '' } catch {} })
|
||||
const formatCurrency = (v: number) => `$${v.toFixed(2)}`
|
||||
</script>
|
||||
Reference in New Issue
Block a user