merge: 合并 upstream/main 并保留本地图片计费功能
This commit is contained in:
@@ -15,14 +15,7 @@
|
||||
<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>
|
||||
<Icon name="chartBar" size="md" class="text-white" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
@@ -97,19 +90,7 @@
|
||||
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>
|
||||
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
@@ -129,19 +110,12 @@
|
||||
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>
|
||||
<Icon
|
||||
name="calculator"
|
||||
size="sm"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
@@ -245,19 +219,12 @@
|
||||
<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>
|
||||
<Icon
|
||||
name="fire"
|
||||
size="sm"
|
||||
class="text-orange-600 dark:text-orange-400"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestCostDay')
|
||||
@@ -295,19 +262,12 @@
|
||||
<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>
|
||||
<Icon
|
||||
name="trendingUp"
|
||||
size="sm"
|
||||
class="text-indigo-600 dark:text-indigo-400"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestRequestDay')
|
||||
@@ -348,19 +308,7 @@
|
||||
<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>
|
||||
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" :stroke-width="2" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.accumulatedTokens')
|
||||
@@ -390,19 +338,7 @@
|
||||
<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>
|
||||
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.performance')
|
||||
@@ -432,19 +368,12 @@
|
||||
<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>
|
||||
<Icon
|
||||
name="clipboard"
|
||||
size="sm"
|
||||
class="text-lime-600 dark:text-lime-400"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.recentActivity')
|
||||
@@ -504,14 +433,7 @@
|
||||
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>
|
||||
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" :stroke-width="1.5" />
|
||||
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -547,6 +469,7 @@ import { Line } from 'vue-chartjs'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageStatsResponse } from '@/types'
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
@@ -48,20 +48,14 @@
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
429
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<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>
|
||||
@@ -73,20 +67,14 @@
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded bg-red-100 px-1.5 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
>
|
||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="xs" :stroke-width="2" />
|
||||
529
|
||||
</span>
|
||||
<!-- Tooltip -->
|
||||
<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>
|
||||
@@ -100,6 +88,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Account } from '@/types'
|
||||
import { formatTime } from '@/utils/format'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -160,7 +149,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 +160,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 = () => {
|
||||
|
||||
@@ -15,14 +15,7 @@
|
||||
<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>
|
||||
<Icon name="userCircle" size="md" class="text-white" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
@@ -48,21 +41,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 -->
|
||||
@@ -73,14 +63,7 @@
|
||||
>
|
||||
<!-- 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>
|
||||
<Icon name="bolt" size="sm" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
@@ -131,14 +114,7 @@
|
||||
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>
|
||||
<Icon name="xCircle" size="sm" :stroke-width="2" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -150,14 +126,7 @@
|
||||
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>
|
||||
<Icon name="copy" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -165,26 +134,12 @@
|
||||
<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>
|
||||
<Icon name="cpu" size="sm" :stroke-width="2" />
|
||||
{{ 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>
|
||||
<Icon name="chatBubble" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -280,6 +235,8 @@
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, ClaudeModel } from '@/types'
|
||||
|
||||
@@ -318,19 +318,7 @@
|
||||
<div v-if="enableCustomErrorCodes" id="bulk-edit-custom-error-codes-body" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -391,14 +379,7 @@
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
@click="removeErrorCode(code)"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||||
@@ -642,6 +623,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -849,7 +831,8 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
let credentialsChanged = false
|
||||
|
||||
if (enableProxy.value) {
|
||||
updates.proxy_id = proxyId.value
|
||||
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
||||
updates.proxy_id = proxyId.value === null ? 0 : proxyId.value
|
||||
}
|
||||
|
||||
if (enableConcurrency.value) {
|
||||
|
||||
@@ -56,6 +56,16 @@
|
||||
data-tour="account-form-name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.notes') }}</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.notesPlaceholder')"
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.accounts.notesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Platform Selection - Segmented Control Style -->
|
||||
<div>
|
||||
@@ -71,19 +81,7 @@
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="sparkles" size="sm" />
|
||||
Anthropic
|
||||
</button>
|
||||
<button
|
||||
@@ -146,19 +144,7 @@
|
||||
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
|
||||
]"
|
||||
>
|
||||
<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>
|
||||
<Icon name="cloud" size="sm" />
|
||||
Antigravity
|
||||
</button>
|
||||
</div>
|
||||
@@ -186,19 +172,7 @@
|
||||
: '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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="sparkles" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
|
||||
@@ -228,19 +202,7 @@
|
||||
: '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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
|
||||
@@ -276,19 +238,7 @@
|
||||
: '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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
@@ -314,19 +264,7 @@
|
||||
: '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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
|
||||
@@ -370,19 +308,7 @@
|
||||
: '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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -477,9 +403,7 @@
|
||||
: '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>
|
||||
<Icon name="user" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -522,9 +446,7 @@
|
||||
: '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>
|
||||
<Icon name="cloud" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -700,19 +622,7 @@
|
||||
class="flex items-center gap-3 rounded-lg border-2 border-purple-500 bg-purple-50 p-3 dark:bg-purple-900/20"
|
||||
>
|
||||
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-purple-500 text-white">
|
||||
<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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
|
||||
@@ -1002,19 +912,7 @@
|
||||
<div v-if="customErrorCodesEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -1073,14 +971,7 @@
|
||||
@click="removeErrorCode(code)"
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||||
@@ -1148,23 +1039,11 @@
|
||||
|
||||
<div v-if="tempUnschedEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -1195,9 +1074,7 @@
|
||||
@click="moveTempUnschedRule(index, -1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<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="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<Icon name="chevronUp" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -1214,14 +1091,7 @@
|
||||
@click="removeTempUnschedRule(index)"
|
||||
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1522,7 +1392,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 +1401,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"
|
||||
@@ -1715,6 +1594,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
@@ -1869,8 +1749,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
|
||||
@@ -1907,6 +1788,7 @@ const tempUnschedPresets = computed(() => [
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
notes: '',
|
||||
platform: 'anthropic' as AccountPlatform,
|
||||
type: 'oauth' as AccountType, // Will be 'oauth', 'setup-token', or 'apikey'
|
||||
credentials: {} as Record<string, unknown>,
|
||||
@@ -2165,6 +2047,7 @@ const splitTempUnschedKeywords = (value: string) => {
|
||||
const resetForm = () => {
|
||||
step.value = 1
|
||||
form.name = ''
|
||||
form.notes = ''
|
||||
form.platform = 'anthropic'
|
||||
form.type = 'oauth'
|
||||
form.credentials = {}
|
||||
@@ -2311,6 +2194,7 @@ const createAccountAndFinish = async (
|
||||
}
|
||||
await adminAPI.accounts.create({
|
||||
name: form.name,
|
||||
notes: form.notes,
|
||||
platform,
|
||||
type,
|
||||
credentials,
|
||||
|
||||
@@ -15,6 +15,16 @@
|
||||
<label class="input-label">{{ t('common.name') }}</label>
|
||||
<input v-model="form.name" type="text" required class="input" data-tour="edit-account-form-name" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.accounts.notes') }}</label>
|
||||
<textarea
|
||||
v-model="form.notes"
|
||||
rows="3"
|
||||
class="input"
|
||||
:placeholder="t('admin.accounts.notesPlaceholder')"
|
||||
></textarea>
|
||||
<p class="input-hint">{{ t('admin.accounts.notesHint') }}</p>
|
||||
</div>
|
||||
|
||||
<!-- API Key fields (only for apikey type) -->
|
||||
<div v-if="account.type === 'apikey'" class="space-y-4">
|
||||
@@ -255,19 +265,7 @@
|
||||
<div v-if="customErrorCodesEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-amber-50 p-3 dark:bg-amber-900/20">
|
||||
<p class="text-xs text-amber-700 dark:text-amber-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.customErrorCodesWarning') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -326,14 +324,7 @@
|
||||
@click="removeErrorCode(code)"
|
||||
class="hover:text-red-900 dark:hover:text-red-300"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</span>
|
||||
<span v-if="selectedErrorCodes.length === 0" class="text-xs text-gray-400">
|
||||
@@ -402,19 +393,7 @@
|
||||
<div v-if="tempUnschedEnabled" class="space-y-3">
|
||||
<div class="rounded-lg bg-blue-50 p-3 dark:bg-blue-900/20">
|
||||
<p class="text-xs text-blue-700 dark:text-blue-400">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="exclamationTriangle" size="sm" class="mr-1 inline" :stroke-width="2" />
|
||||
{{ t('admin.accounts.tempUnschedulable.notice') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -448,9 +427,7 @@
|
||||
@click="moveTempUnschedRule(index, -1)"
|
||||
class="rounded p-1 text-gray-400 transition-colors hover:text-gray-600 disabled:cursor-not-allowed disabled:opacity-40 dark:hover:text-gray-200"
|
||||
>
|
||||
<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="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<Icon name="chevronUp" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -467,14 +444,7 @@
|
||||
@click="removeTempUnschedRule(index)"
|
||||
class="rounded p-1 text-red-500 transition-colors hover:text-red-600"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -692,6 +662,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import type { Account, Proxy, Group } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
@@ -795,6 +766,7 @@ const defaultBaseUrl = computed(() => {
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
notes: '',
|
||||
proxy_id: null as number | null,
|
||||
concurrency: 1,
|
||||
priority: 1,
|
||||
@@ -813,6 +785,7 @@ watch(
|
||||
(newAccount) => {
|
||||
if (newAccount) {
|
||||
form.name = newAccount.name
|
||||
form.notes = newAccount.notes || ''
|
||||
form.proxy_id = newAccount.proxy_id
|
||||
form.concurrency = newAccount.concurrency
|
||||
form.priority = newAccount.priority
|
||||
@@ -1080,6 +1053,10 @@ const handleSubmit = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const updatePayload: Record<string, unknown> = { ...form }
|
||||
// 后端期望 proxy_id: 0 表示清除代理,而不是 null
|
||||
if (updatePayload.proxy_id === null) {
|
||||
updatePayload.proxy_id = 0
|
||||
}
|
||||
|
||||
// For apikey type, handle credentials update
|
||||
if (props.account.type === 'apikey') {
|
||||
|
||||
@@ -21,9 +21,7 @@
|
||||
@click.stop="removeModel(model)"
|
||||
class="shrink-0 rounded-full hover:bg-gray-200 dark:hover:bg-dark-500"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="xs" class="h-3.5 w-3.5" :stroke-width="2" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -126,6 +124,7 @@ import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import ModelIcon from '@/components/common/ModelIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { allModels, getModelsByPlatform } from '@/composables/useModelWhitelist'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -2,21 +2,9 @@
|
||||
<div
|
||||
class="rounded-lg border border-blue-200 bg-blue-50 p-4 dark:border-blue-700 dark:bg-blue-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-blue-500">
|
||||
<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="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="link" size="md" class="text-white" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h4 class="mb-3 font-semibold text-blue-900 dark:text-blue-200">{{ oauthTitle }}</h4>
|
||||
@@ -66,19 +54,7 @@
|
||||
<label
|
||||
class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-blue-500"
|
||||
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>
|
||||
<Icon name="key" size="sm" class="text-blue-500" />
|
||||
{{ t('admin.accounts.oauth.sessionKey') }}
|
||||
<span
|
||||
v-if="parsedKeyCount > 1 && allowMultiple"
|
||||
@@ -136,16 +112,16 @@
|
||||
<ol
|
||||
class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300"
|
||||
>
|
||||
<li v-html="t('admin.accounts.oauth.step1')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step2')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step3')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step4')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step5')"></li>
|
||||
<li v-html="t('admin.accounts.oauth.step6')"></li>
|
||||
<li>{{ t('admin.accounts.oauth.step1') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step2') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step3') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step4') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step5') }}</li>
|
||||
<li>{{ t('admin.accounts.oauth.step6') }}</li>
|
||||
</ol>
|
||||
<p
|
||||
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
|
||||
v-html="t('admin.accounts.oauth.sessionKeyFormat')"
|
||||
v-text="t('admin.accounts.oauth.sessionKeyFormat')"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
@@ -186,20 +162,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>
|
||||
<svg
|
||||
v-else
|
||||
class="mr-2 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>
|
||||
<Icon v-else name="sparkles" size="sm" class="mr-2" />
|
||||
{{
|
||||
loading
|
||||
? t('admin.accounts.oauth.authorizing')
|
||||
@@ -281,20 +244,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>
|
||||
<svg
|
||||
v-else
|
||||
class="mr-2 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.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
|
||||
/>
|
||||
</svg>
|
||||
<Icon v-else name="link" size="sm" class="mr-2" />
|
||||
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
|
||||
</button>
|
||||
<div v-else class="space-y-3">
|
||||
@@ -325,20 +275,13 @@
|
||||
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>
|
||||
<svg
|
||||
<Icon
|
||||
v-else
|
||||
class="h-4 w-4 text-green-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-green-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
@@ -346,19 +289,7 @@
|
||||
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
|
||||
@click="handleRegenerate"
|
||||
>
|
||||
<svg
|
||||
class="mr-1 inline h-3 w-3"
|
||||
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>
|
||||
<Icon name="refresh" size="xs" class="mr-1 inline" />
|
||||
{{ t('admin.accounts.oauth.regenerate') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -390,7 +321,7 @@
|
||||
>
|
||||
<p
|
||||
class="text-xs text-amber-800 dark:text-amber-300"
|
||||
v-html="oauthImportantNotice"
|
||||
v-text="oauthImportantNotice"
|
||||
></p>
|
||||
</div>
|
||||
<!-- Proxy Warning (for non-OpenAI) -->
|
||||
@@ -400,7 +331,7 @@
|
||||
>
|
||||
<p
|
||||
class="text-xs text-yellow-800 dark:text-yellow-300"
|
||||
v-html="t('admin.accounts.oauth.proxyWarning')"
|
||||
v-text="t('admin.accounts.oauth.proxyWarning')"
|
||||
></p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -423,23 +354,11 @@
|
||||
</p>
|
||||
<p
|
||||
class="mb-3 text-sm text-blue-700 dark:text-blue-300"
|
||||
v-html="oauthAuthCodeDesc"
|
||||
v-text="oauthAuthCodeDesc"
|
||||
></p>
|
||||
<div>
|
||||
<label class="input-label">
|
||||
<svg
|
||||
class="mr-1 inline h-4 w-4 text-blue-500"
|
||||
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>
|
||||
<Icon name="key" size="sm" class="mr-1 inline text-blue-500" />
|
||||
{{ oauthAuthCode }}
|
||||
</label>
|
||||
<textarea
|
||||
@@ -449,19 +368,7 @@
|
||||
:placeholder="oauthAuthCodePlaceholder"
|
||||
></textarea>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg
|
||||
class="mr-1 inline h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="infoCircle" size="xs" class="mr-1 inline" />
|
||||
{{ oauthAuthCodeHint }}
|
||||
</p>
|
||||
|
||||
@@ -471,19 +378,12 @@
|
||||
class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 p-3 dark:border-amber-600 dark:bg-amber-900/30"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<svg
|
||||
class="h-5 w-5 flex-shrink-0 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon
|
||||
name="exclamationTriangle"
|
||||
size="md"
|
||||
class="flex-shrink-0 text-amber-600 dark:text-amber-400"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<div class="text-sm text-amber-800 dark:text-amber-300">
|
||||
<p class="font-semibold">{{ $t('admin.accounts.oauth.gemini.stateWarningTitle') }}</p>
|
||||
<p class="mt-1">{{ $t('admin.accounts.oauth.gemini.stateWarningDesc') }}</p>
|
||||
@@ -514,6 +414,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { AddMethod, AuthInputMethod } from '@/composables/useAccountOAuth'
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -23,19 +23,7 @@
|
||||
: '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>
|
||||
<Icon name="sparkles" size="md" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{
|
||||
@@ -135,19 +123,7 @@
|
||||
: '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>
|
||||
<Icon name="cloud" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -179,19 +155,7 @@
|
||||
: '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>
|
||||
<Icon name="sparkles" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
@@ -295,6 +259,7 @@ import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Account } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
|
||||
50
frontend/src/components/admin/account/AccountActionMenu.vue
Normal file
50
frontend/src/components/admin/account/AccountActionMenu.vue
Normal file
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="show && position" class="action-menu-content fixed z-[9999] w-52 overflow-hidden rounded-xl bg-white shadow-lg ring-1 ring-black/5 dark:bg-dark-800" :style="{ top: position.top + 'px', left: position.left + 'px' }">
|
||||
<div class="py-1">
|
||||
<template v-if="account">
|
||||
<button @click="$emit('test', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="play" size="sm" class="text-green-500" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testConnection') }}
|
||||
</button>
|
||||
<button @click="$emit('stats', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="chart" size="sm" class="text-indigo-500" />
|
||||
{{ t('admin.accounts.viewStats') }}
|
||||
</button>
|
||||
<template v-if="account.type === 'oauth' || account.type === 'setup-token'">
|
||||
<button @click="$emit('reauth', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-blue-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="link" size="sm" />
|
||||
{{ t('admin.accounts.reAuthorize') }}
|
||||
</button>
|
||||
<button @click="$emit('refresh-token', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-purple-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="refresh" size="sm" />
|
||||
{{ t('admin.accounts.refreshToken') }}
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="account.status === 'error' || isRateLimited || isOverloaded" class="my-1 border-t border-gray-100 dark:border-dark-700"></div>
|
||||
<button v-if="account.status === 'error'" @click="$emit('reset-status', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-yellow-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="sync" size="sm" />
|
||||
{{ t('admin.accounts.resetStatus') }}
|
||||
</button>
|
||||
<button v-if="isRateLimited || isOverloaded" @click="$emit('clear-rate-limit', account); $emit('close')" class="flex w-full items-center gap-2 px-4 py-2 text-sm text-amber-600 hover:bg-gray-100 dark:hover:bg-dark-700">
|
||||
<Icon name="clock" size="sm" />
|
||||
{{ t('admin.accounts.clearRateLimit') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Icon } from '@/components/icons'
|
||||
import type { Account } from '@/types'
|
||||
|
||||
const props = defineProps<{ show: boolean; account: Account | null; position: { top: number; left: number } | null }>()
|
||||
defineEmits(['close', 'test', 'stats', 'reauth', 'refresh-token', 'reset-status', 'clear-rate-limit'])
|
||||
const { t } = useI18n()
|
||||
const isRateLimited = computed(() => props.account?.rate_limit_reset_at && new Date(props.account.rate_limit_reset_at) > new Date())
|
||||
const isOverloaded = computed(() => props.account?.overload_until && new Date(props.account.overload_until) > new Date())
|
||||
</script>
|
||||
@@ -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>
|
||||
674
frontend/src/components/admin/account/AccountStatsModal.vue
Normal file
674
frontend/src/components/admin/account/AccountStatsModal.vue
Normal file
@@ -0,0 +1,674 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.usageStatistics')"
|
||||
width="extra-wide"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-6">
|
||||
<!-- Account Info Header -->
|
||||
<div
|
||||
v-if="account"
|
||||
class="flex items-center justify-between rounded-xl border border-primary-200 bg-gradient-to-r from-primary-50 to-primary-100 p-3 dark:border-primary-700/50 dark:from-primary-900/20 dark:to-primary-800/20"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<Icon name="chartBar" size="md" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.last30DaysUsage') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ account.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
<template v-else-if="stats">
|
||||
<!-- Row 1: Main Stats Cards -->
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<!-- 30-Day Total Cost -->
|
||||
<div
|
||||
class="card border-emerald-200 bg-gradient-to-br from-emerald-50 to-white p-4 dark:border-emerald-800/30 dark:from-emerald-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-emerald-100 p-1.5 dark:bg-emerald-900/30">
|
||||
<Icon name="dollar" size="sm" class="text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.total_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.accumulatedCost') }}
|
||||
<span class="text-gray-400 dark:text-gray-500"
|
||||
>({{ t('admin.accounts.stats.standardCost') }}: ${{
|
||||
formatCost(stats.summary.total_standard_cost)
|
||||
}})</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 30-Day Total Requests -->
|
||||
<div
|
||||
class="card border-blue-200 bg-gradient-to-br from-blue-50 to-white p-4 dark:border-blue-800/30 dark:from-blue-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-blue-100 p-1.5 dark:bg-blue-900/30">
|
||||
<Icon name="bolt" size="sm" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(stats.summary.total_requests) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.totalCalls') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Cost -->
|
||||
<div
|
||||
class="card border-amber-200 bg-gradient-to-br from-amber-50 to-white p-4 dark:border-amber-800/30 dark:from-amber-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyCost')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-amber-100 p-1.5 dark:bg-amber-900/30">
|
||||
<Icon
|
||||
name="calculator"
|
||||
size="sm"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
${{ formatCost(stats.summary.avg_daily_cost) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
t('admin.accounts.stats.basedOnActualDays', {
|
||||
days: stats.summary.actual_days_used
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Daily Average Requests -->
|
||||
<div
|
||||
class="card border-purple-200 bg-gradient-to-br from-purple-50 to-white p-4 dark:border-purple-800/30 dark:from-purple-900/10 dark:to-dark-700"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgDailyRequests')
|
||||
}}</span>
|
||||
<div class="rounded-lg bg-purple-100 p-1.5 dark:bg-purple-900/30">
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ formatNumber(Math.round(stats.summary.avg_daily_requests)) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.stats.avgDailyUsage') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 2: Today, Highest Cost, Highest Requests -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Today Overview -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-cyan-100 p-1.5 dark:bg-cyan-900/30">
|
||||
<Icon name="clock" size="sm" class="text-cyan-600 dark:text-cyan-400" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.todayOverview')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.tokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Cost Day -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-orange-100 p-1.5 dark:bg-orange-900/30">
|
||||
<Icon name="fire" size="sm" class="text-orange-600 dark:text-orange-400" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestCostDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_cost_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-orange-600 dark:text-orange-400"
|
||||
>${{ formatCost(stats.summary.highest_cost_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.highest_cost_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highest Request Day -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-indigo-100 p-1.5 dark:bg-indigo-900/30">
|
||||
<Icon
|
||||
name="trendingUp"
|
||||
size="sm"
|
||||
class="text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.highestRequestDay')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.date')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
stats.summary.highest_request_day?.label || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.requests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-indigo-600 dark:text-indigo-400">{{
|
||||
formatNumber(stats.summary.highest_request_day?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.cost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.highest_request_day?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Row 3: Token Stats -->
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||
<!-- Accumulated Tokens -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-teal-100 p-1.5 dark:bg-teal-900/30">
|
||||
<Icon name="cube" size="sm" class="text-teal-600 dark:text-teal-400" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.accumulatedTokens')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.totalTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.total_tokens)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.dailyAvgTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(Math.round(stats.summary.avg_daily_tokens))
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Performance -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-rose-100 p-1.5 dark:bg-rose-900/30">
|
||||
<Icon name="bolt" size="sm" class="text-rose-600 dark:text-rose-400" />
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.performance')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.avgResponseTime')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatDuration(stats.summary.avg_duration_ms)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.daysActive')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>{{ stats.summary.actual_days_used }} / {{ stats.summary.days }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="card p-4">
|
||||
<div class="mb-3 flex items-center gap-2">
|
||||
<div class="rounded-lg bg-lime-100 p-1.5 dark:bg-lime-900/30">
|
||||
<Icon
|
||||
name="clipboard"
|
||||
size="sm"
|
||||
class="text-lime-600 dark:text-lime-400"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
t('admin.accounts.stats.recentActivity')
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayRequests')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatNumber(stats.summary.today?.requests || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayTokens')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white">{{
|
||||
formatTokens(stats.summary.today?.tokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
t('admin.accounts.stats.todayCost')
|
||||
}}</span>
|
||||
<span class="text-sm font-semibold text-gray-900 dark:text-white"
|
||||
>${{ formatCost(stats.summary.today?.cost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Trend Chart -->
|
||||
<div class="card p-4">
|
||||
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.stats.usageTrend') }}
|
||||
</h3>
|
||||
<div class="h-64">
|
||||
<Line v-if="trendChartData" :data="trendChartData" :options="lineChartOptions" />
|
||||
<div
|
||||
v-else
|
||||
class="flex h-full items-center justify-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ t('admin.dashboard.noDataAvailable') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Distribution -->
|
||||
<ModelDistributionChart :model-stats="stats.models" :loading="false" />
|
||||
</template>
|
||||
|
||||
<!-- No Data State -->
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<Icon name="chartBar" size="xl" class="mb-4 h-12 w-12" />
|
||||
<p class="text-sm">{{ t('admin.accounts.stats.noData') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
import { Line } from 'vue-chartjs'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
|
||||
import ModelDistributionChart from '@/components/charts/ModelDistributionChart.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, AccountUsageStatsResponse } from '@/types'
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref<AccountUsageStatsResponse | null>(null)
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = computed(() => {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
})
|
||||
|
||||
// Chart colors
|
||||
const chartColors = computed(() => ({
|
||||
text: isDarkMode.value ? '#e5e7eb' : '#374151',
|
||||
grid: isDarkMode.value ? '#374151' : '#e5e7eb'
|
||||
}))
|
||||
|
||||
// Line chart data
|
||||
const trendChartData = computed(() => {
|
||||
if (!stats.value?.history?.length) return null
|
||||
|
||||
return {
|
||||
labels: stats.value.history.map((h) => h.label),
|
||||
datasets: [
|
||||
{
|
||||
label: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
data: stats.value.history.map((h) => h.cost),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y'
|
||||
},
|
||||
{
|
||||
label: t('admin.accounts.stats.requests'),
|
||||
data: stats.value.history.map((h) => h.requests),
|
||||
borderColor: '#f97316',
|
||||
backgroundColor: 'rgba(249, 115, 22, 0.1)',
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
yAxisID: 'y1'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Line chart options with dual Y-axis
|
||||
const lineChartOptions = computed(() => ({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top' as const,
|
||||
labels: {
|
||||
color: chartColors.value.text,
|
||||
usePointStyle: true,
|
||||
pointStyle: 'circle',
|
||||
padding: 15,
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context: any) => {
|
||||
const label = context.dataset.label || ''
|
||||
const value = context.raw
|
||||
if (label.includes('USD')) {
|
||||
return `${label}: $${formatCost(value)}`
|
||||
}
|
||||
return `${label}: ${formatNumber(value)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
grid: {
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: chartColors.value.text,
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
maxRotation: 45,
|
||||
minRotation: 0
|
||||
}
|
||||
},
|
||||
y: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'left' as const,
|
||||
grid: {
|
||||
color: chartColors.value.grid
|
||||
},
|
||||
ticks: {
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => '$' + formatCost(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.cost') + ' (USD)',
|
||||
color: '#3b82f6',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
},
|
||||
y1: {
|
||||
type: 'linear' as const,
|
||||
display: true,
|
||||
position: 'right' as const,
|
||||
grid: {
|
||||
drawOnChartArea: false
|
||||
},
|
||||
ticks: {
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 10
|
||||
},
|
||||
callback: (value: string | number) => formatNumber(Number(value))
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: t('admin.accounts.stats.requests'),
|
||||
color: '#f97316',
|
||||
font: {
|
||||
size: 11
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
// Load stats when modal opens
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
await loadStats()
|
||||
} else {
|
||||
stats.value = null
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
stats.value = await adminAPI.accounts.getStats(props.account.id, 30)
|
||||
} catch (error) {
|
||||
console.error('Failed to load account stats:', error)
|
||||
stats.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Format helpers
|
||||
const formatCost = (value: number): string => {
|
||||
if (value >= 1000) {
|
||||
return (value / 1000).toFixed(2) + 'K'
|
||||
} else if (value >= 1) {
|
||||
return value.toFixed(2)
|
||||
} else if (value >= 0.01) {
|
||||
return value.toFixed(3)
|
||||
}
|
||||
return value.toFixed(4)
|
||||
}
|
||||
|
||||
const formatNumber = (value: number): string => {
|
||||
if (value >= 1_000_000) {
|
||||
return (value / 1_000_000).toFixed(2) + 'M'
|
||||
} else if (value >= 1_000) {
|
||||
return (value / 1_000).toFixed(2) + 'K'
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000_000) {
|
||||
return `${(value / 1_000_000_000).toFixed(2)}B`
|
||||
} else if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(2)}M`
|
||||
} else if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(2)}K`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms >= 1000) {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
return `${Math.round(ms)}ms`
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<button @click="$emit('refresh')" :disabled="loading" class="btn btn-secondary">
|
||||
<Icon name="refresh" size="md" :class="[loading ? 'animate-spin' : '']" />
|
||||
</button>
|
||||
<button @click="$emit('sync')" class="btn btn-secondary">{{ t('admin.accounts.syncFromCrs') }}</button>
|
||||
<button @click="$emit('create')" class="btn btn-primary">{{ t('admin.accounts.createAccount') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps(['loading'])
|
||||
defineEmits(['refresh', 'sync', 'create'])
|
||||
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<SearchInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="t('admin.accounts.searchAccounts')"
|
||||
class="w-full sm:w-64"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
@search="$emit('change')"
|
||||
/>
|
||||
<Select v-model="filters.platform" class="w-40" :options="pOpts" @change="$emit('change')" />
|
||||
<Select v-model="filters.type" class="w-40" :options="tOpts" @change="$emit('change')" />
|
||||
<Select v-model="filters.status" class="w-40" :options="sOpts" @change="$emit('change')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'; import { useI18n } from 'vue-i18n'; import Select from '@/components/common/Select.vue'; import SearchInput from '@/components/common/SearchInput.vue'
|
||||
defineProps(['searchQuery', 'filters']); defineEmits(['update:searchQuery', 'change']); const { t } = useI18n()
|
||||
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
|
||||
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
|
||||
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
|
||||
</script>
|
||||
409
frontend/src/components/admin/account/AccountTestModal.vue
Normal file
409
frontend/src/components/admin/account/AccountTestModal.vue
Normal file
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.testAccountConnection')"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<!-- Account Info Card -->
|
||||
<div
|
||||
v-if="account"
|
||||
class="flex items-center justify-between rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-100 p-3 dark:border-dark-500 dark:from-dark-700 dark:to-dark-600"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br from-primary-500 to-primary-600"
|
||||
>
|
||||
<Icon name="play" size="md" class="text-white" :stroke-width="2" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{{ account.name }}</div>
|
||||
<div class="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
class="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium uppercase dark:bg-dark-500"
|
||||
>
|
||||
{{ account.type }}
|
||||
</span>
|
||||
<span>{{ t('admin.accounts.account') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
:class="[
|
||||
'rounded-full px-2.5 py-1 text-xs font-semibold',
|
||||
account.status === 'active'
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
{{ account.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.accounts.selectTestModel') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="selectedModelId"
|
||||
:options="availableModels"
|
||||
:disabled="loadingModels || status === 'connecting'"
|
||||
value-key="id"
|
||||
label-key="display_name"
|
||||
:placeholder="loadingModels ? t('common.loading') + '...' : t('admin.accounts.selectTestModel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Terminal Output -->
|
||||
<div class="group relative">
|
||||
<div
|
||||
ref="terminalRef"
|
||||
class="max-h-[240px] min-h-[120px] overflow-y-auto rounded-xl border border-gray-700 bg-gray-900 p-4 font-mono text-sm dark:border-gray-800 dark:bg-black"
|
||||
>
|
||||
<!-- Status Line -->
|
||||
<div v-if="status === 'idle'" class="flex items-center gap-2 text-gray-500">
|
||||
<Icon name="play" size="sm" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.readyToTest') }}</span>
|
||||
</div>
|
||||
<div v-else-if="status === 'connecting'" class="flex items-center gap-2 text-yellow-400">
|
||||
<Icon name="refresh" size="sm" class="animate-spin" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.connectingToApi') }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Output Lines -->
|
||||
<div v-for="(line, index) in outputLines" :key="index" :class="line.class">
|
||||
{{ line.text }}
|
||||
</div>
|
||||
|
||||
<!-- Streaming Content -->
|
||||
<div v-if="streamingContent" class="text-green-400">
|
||||
{{ streamingContent }}<span class="animate-pulse">_</span>
|
||||
</div>
|
||||
|
||||
<!-- Result Status -->
|
||||
<div
|
||||
v-if="status === 'success'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-green-400"
|
||||
>
|
||||
<Icon name="check" size="sm" :stroke-width="2" />
|
||||
<span>{{ t('admin.accounts.testCompleted') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'error'"
|
||||
class="mt-3 flex items-center gap-2 border-t border-gray-700 pt-3 text-red-400"
|
||||
>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
<span>{{ errorMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Copy Button -->
|
||||
<button
|
||||
v-if="outputLines.length > 0"
|
||||
@click="copyOutput"
|
||||
class="absolute right-2 top-2 rounded-lg bg-gray-800/80 p-1.5 text-gray-400 opacity-0 transition-all hover:bg-gray-700 hover:text-white group-hover:opacity-100"
|
||||
:title="t('admin.accounts.copyOutput')"
|
||||
>
|
||||
<Icon name="link" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Test Info -->
|
||||
<div class="flex items-center justify-between px-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="grid" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testModel') }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="chat" size="sm" :stroke-width="2" />
|
||||
{{ t('admin.accounts.testPrompt') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="handleClose"
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-300 dark:hover:bg-dark-500"
|
||||
:disabled="status === 'connecting'"
|
||||
>
|
||||
{{ t('common.close') }}
|
||||
</button>
|
||||
<button
|
||||
@click="startTest"
|
||||
:disabled="status === 'connecting' || !selectedModelId"
|
||||
:class="[
|
||||
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
status === 'connecting' || !selectedModelId
|
||||
? 'cursor-not-allowed bg-primary-400 text-white'
|
||||
: status === 'success'
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: status === 'error'
|
||||
? 'bg-orange-500 text-white hover:bg-orange-600'
|
||||
: 'bg-primary-500 text-white hover:bg-primary-600'
|
||||
]"
|
||||
>
|
||||
<Icon
|
||||
v-if="status === 'connecting'"
|
||||
name="refresh"
|
||||
size="sm"
|
||||
class="animate-spin"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
<Icon v-else-if="status === 'idle'" name="play" size="sm" :stroke-width="2" />
|
||||
<Icon v-else name="refresh" size="sm" :stroke-width="2" />
|
||||
<span>
|
||||
{{
|
||||
status === 'connecting'
|
||||
? t('admin.accounts.testing')
|
||||
: status === 'idle'
|
||||
? t('admin.accounts.startTest')
|
||||
: t('admin.accounts.retry')
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { Icon } from '@/components/icons'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { Account, ClaudeModel } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
|
||||
interface OutputLine {
|
||||
text: string
|
||||
class: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const terminalRef = ref<HTMLElement | null>(null)
|
||||
const status = ref<'idle' | 'connecting' | 'success' | 'error'>('idle')
|
||||
const outputLines = ref<OutputLine[]>([])
|
||||
const streamingContent = ref('')
|
||||
const errorMessage = ref('')
|
||||
const availableModels = ref<ClaudeModel[]>([])
|
||||
const selectedModelId = ref('')
|
||||
const loadingModels = ref(false)
|
||||
let eventSource: EventSource | null = null
|
||||
|
||||
// Load available models when modal opens
|
||||
watch(
|
||||
() => props.show,
|
||||
async (newVal) => {
|
||||
if (newVal && props.account) {
|
||||
resetState()
|
||||
await loadAvailableModels()
|
||||
} else {
|
||||
closeEventSource()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const loadAvailableModels = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
loadingModels.value = true
|
||||
selectedModelId.value = '' // Reset selection before loading
|
||||
try {
|
||||
availableModels.value = await adminAPI.accounts.getAvailableModels(props.account.id)
|
||||
// Default selection by platform
|
||||
if (availableModels.value.length > 0) {
|
||||
if (props.account.platform === 'gemini') {
|
||||
const preferred =
|
||||
availableModels.value.find((m) => m.id === 'gemini-2.5-pro') ||
|
||||
availableModels.value.find((m) => m.id === 'gemini-3-pro')
|
||||
selectedModelId.value = preferred?.id || availableModels.value[0].id
|
||||
} else {
|
||||
// Try to select Sonnet as default, otherwise use first model
|
||||
const sonnetModel = availableModels.value.find((m) => m.id.includes('sonnet'))
|
||||
selectedModelId.value = sonnetModel?.id || availableModels.value[0].id
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load available models:', error)
|
||||
// Fallback to empty list
|
||||
availableModels.value = []
|
||||
selectedModelId.value = ''
|
||||
} finally {
|
||||
loadingModels.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetState = () => {
|
||||
status.value = 'idle'
|
||||
outputLines.value = []
|
||||
streamingContent.value = ''
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
// 防止在连接测试进行中关闭对话框
|
||||
if (status.value === 'connecting') {
|
||||
return
|
||||
}
|
||||
closeEventSource()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const closeEventSource = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
}
|
||||
}
|
||||
|
||||
const addLine = (text: string, className: string = 'text-gray-300') => {
|
||||
outputLines.value.push({ text, class: className })
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
await nextTick()
|
||||
if (terminalRef.value) {
|
||||
terminalRef.value.scrollTop = terminalRef.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
const startTest = async () => {
|
||||
if (!props.account || !selectedModelId.value) return
|
||||
|
||||
resetState()
|
||||
status.value = 'connecting'
|
||||
addLine(t('admin.accounts.startingTestForAccount', { name: props.account.name }), 'text-blue-400')
|
||||
addLine(t('admin.accounts.testAccountTypeLabel', { type: props.account.type }), 'text-gray-400')
|
||||
addLine('', 'text-gray-300')
|
||||
|
||||
closeEventSource()
|
||||
|
||||
try {
|
||||
// Create EventSource for SSE
|
||||
const url = `/api/v1/admin/accounts/${props.account.id}/test`
|
||||
|
||||
// Use fetch with streaming for SSE since EventSource doesn't support POST
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${localStorage.getItem('auth_token')}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ model_id: selectedModelId.value })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) {
|
||||
throw new Error('No response body')
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ''
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const jsonStr = line.slice(6).trim()
|
||||
if (jsonStr) {
|
||||
try {
|
||||
const event = JSON.parse(jsonStr)
|
||||
handleEvent(event)
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE event:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
status.value = 'error'
|
||||
errorMessage.value = error.message || 'Unknown error'
|
||||
addLine(`Error: ${errorMessage.value}`, 'text-red-400')
|
||||
}
|
||||
}
|
||||
|
||||
const handleEvent = (event: {
|
||||
type: string
|
||||
text?: string
|
||||
model?: string
|
||||
success?: boolean
|
||||
error?: string
|
||||
}) => {
|
||||
switch (event.type) {
|
||||
case 'test_start':
|
||||
addLine(t('admin.accounts.connectedToApi'), 'text-green-400')
|
||||
if (event.model) {
|
||||
addLine(t('admin.accounts.usingModel', { model: event.model }), 'text-cyan-400')
|
||||
}
|
||||
addLine(t('admin.accounts.sendingTestMessage'), 'text-gray-400')
|
||||
addLine('', 'text-gray-300')
|
||||
addLine(t('admin.accounts.response'), 'text-yellow-400')
|
||||
break
|
||||
|
||||
case 'content':
|
||||
if (event.text) {
|
||||
streamingContent.value += event.text
|
||||
scrollToBottom()
|
||||
}
|
||||
break
|
||||
|
||||
case 'test_complete':
|
||||
// Move streaming content to output lines
|
||||
if (streamingContent.value) {
|
||||
addLine(streamingContent.value, 'text-green-300')
|
||||
streamingContent.value = ''
|
||||
}
|
||||
if (event.success) {
|
||||
status.value = 'success'
|
||||
} else {
|
||||
status.value = 'error'
|
||||
errorMessage.value = event.error || 'Test failed'
|
||||
}
|
||||
break
|
||||
|
||||
case 'error':
|
||||
status.value = 'error'
|
||||
errorMessage.value = event.error || 'Unknown error'
|
||||
if (streamingContent.value) {
|
||||
addLine(streamingContent.value, 'text-green-300')
|
||||
streamingContent.value = ''
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const copyOutput = () => {
|
||||
const text = outputLines.value.map((l) => l.text).join('\n')
|
||||
copyToClipboard(text, t('admin.accounts.outputCopied'))
|
||||
}
|
||||
</script>
|
||||
614
frontend/src/components/admin/account/ReAuthAccountModal.vue
Normal file
614
frontend/src/components/admin/account/ReAuthAccountModal.vue
Normal file
@@ -0,0 +1,614 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.accounts.reAuthorizeAccount')"
|
||||
width="normal"
|
||||
@close="handleClose"
|
||||
>
|
||||
<div v-if="account" class="space-y-4">
|
||||
<!-- Account Info -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-dark-600 dark:bg-dark-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
|
||||
isOpenAI
|
||||
? 'from-green-500 to-green-600'
|
||||
: isGemini
|
||||
? 'from-blue-500 to-blue-600'
|
||||
: isAntigravity
|
||||
? 'from-purple-500 to-purple-600'
|
||||
: 'from-orange-500 to-orange-600'
|
||||
]"
|
||||
>
|
||||
<Icon name="sparkles" size="md" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<span class="block font-semibold text-gray-900 dark:text-white">{{
|
||||
account.name
|
||||
}}</span>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{
|
||||
isOpenAI
|
||||
? t('admin.accounts.openaiAccount')
|
||||
: isGemini
|
||||
? t('admin.accounts.geminiAccount')
|
||||
: isAntigravity
|
||||
? t('admin.accounts.antigravityAccount')
|
||||
: t('admin.accounts.claudeCodeAccount')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Method Selection (Claude only) -->
|
||||
<fieldset v-if="isAnthropic" class="border-0 p-0">
|
||||
<legend class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</legend>
|
||||
<div class="mt-2 flex gap-4">
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="addMethod"
|
||||
type="radio"
|
||||
value="oauth"
|
||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('admin.accounts.types.oauth')
|
||||
}}</span>
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="addMethod"
|
||||
type="radio"
|
||||
value="setup-token"
|
||||
class="mr-2 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">{{
|
||||
t('admin.accounts.setupTokenLongLived')
|
||||
}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Gemini OAuth Type Selection -->
|
||||
<fieldset v-if="isGemini" class="border-0 p-0">
|
||||
<legend class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</legend>
|
||||
<div class="mt-2 grid grid-cols-3 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSelectGeminiOAuthType('google_one')"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
geminiOAuthType === 'google_one'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
geminiOAuthType === 'google_one'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="user" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">Google One</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">个人账号</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="handleSelectGeminiOAuthType('code_assist')"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
geminiOAuthType === 'code_assist'
|
||||
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
|
||||
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
geminiOAuthType === 'code_assist'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="cloud" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.builtInTitle') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.gemini.oauthType.builtInDesc') }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!geminiAIStudioOAuthEnabled"
|
||||
@click="handleSelectGeminiOAuthType('ai_studio')"
|
||||
:class="[
|
||||
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
|
||||
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
|
||||
geminiOAuthType === 'ai_studio'
|
||||
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
|
||||
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg',
|
||||
geminiOAuthType === 'ai_studio'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
|
||||
]"
|
||||
>
|
||||
<Icon name="sparkles" size="sm" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white">
|
||||
{{ t('admin.accounts.gemini.oauthType.customTitle') }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.gemini.oauthType.customDesc') }}
|
||||
</span>
|
||||
<div v-if="!geminiAIStudioOAuthEnabled" class="group relative mt-1 inline-block">
|
||||
<span
|
||||
class="rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredShort') }}
|
||||
</span>
|
||||
<div
|
||||
class="pointer-events-none absolute left-0 top-full z-10 mt-2 w-[28rem] rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 opacity-0 shadow-sm transition-opacity group-hover:opacity-100 dark:border-amber-700 dark:bg-amber-900/40 dark:text-amber-200"
|
||||
>
|
||||
{{ t('admin.accounts.oauth.gemini.aiStudioNotConfiguredTip') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<OAuthAuthorizationFlow
|
||||
ref="oauthFlowRef"
|
||||
:add-method="addMethod"
|
||||
:auth-url="currentAuthUrl"
|
||||
:session-id="currentSessionId"
|
||||
:loading="currentLoading"
|
||||
:error="currentError"
|
||||
:show-help="isAnthropic"
|
||||
:show-proxy-warning="isAnthropic"
|
||||
:show-cookie-option="isAnthropic"
|
||||
:allow-multiple="false"
|
||||
:method-label="t('admin.accounts.inputMethod')"
|
||||
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : isAntigravity ? 'antigravity' : 'anthropic'"
|
||||
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
|
||||
@generate-url="handleGenerateUrl"
|
||||
@cookie-auth="handleCookieAuth"
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div v-if="account" class="flex justify-between gap-3">
|
||||
<button type="button" class="btn btn-secondary" @click="handleClose">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="isManualInputMethod"
|
||||
type="button"
|
||||
:disabled="!canExchangeCode"
|
||||
class="btn btn-primary"
|
||||
@click="handleExchangeCode"
|
||||
>
|
||||
<svg
|
||||
v-if="currentLoading"
|
||||
class="-ml-1 mr-2 h-4 w-4 animate-spin"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{{
|
||||
currentLoading
|
||||
? t('admin.accounts.oauth.verifying')
|
||||
: t('admin.accounts.oauth.completeAuth')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import {
|
||||
useAccountOAuth,
|
||||
type AddMethod,
|
||||
type AuthInputMethod
|
||||
} from '@/composables/useAccountOAuth'
|
||||
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
|
||||
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
|
||||
import { useAntigravityOAuth } from '@/composables/useAntigravityOAuth'
|
||||
import type { Account } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import OAuthAuthorizationFlow from '@/components/account/OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
// Note: defineExpose automatically unwraps refs, so we use the unwrapped types
|
||||
interface OAuthFlowExposed {
|
||||
authCode: string
|
||||
oauthState: string
|
||||
projectId: string
|
||||
sessionKey: string
|
||||
inputMethod: AuthInputMethod
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
account: Account | null
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
reauthorized: []
|
||||
}>()
|
||||
|
||||
const appStore = useAppStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
// OAuth composables
|
||||
const claudeOAuth = useAccountOAuth()
|
||||
const openaiOAuth = useOpenAIOAuth()
|
||||
const geminiOAuth = useGeminiOAuth()
|
||||
const antigravityOAuth = useAntigravityOAuth()
|
||||
|
||||
// Refs
|
||||
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
|
||||
|
||||
// State
|
||||
const addMethod = ref<AddMethod>('oauth')
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('code_assist')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
|
||||
// Computed - check platform
|
||||
const isOpenAI = computed(() => props.account?.platform === 'openai')
|
||||
const isGemini = computed(() => props.account?.platform === 'gemini')
|
||||
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
|
||||
const isAntigravity = computed(() => props.account?.platform === 'antigravity')
|
||||
|
||||
// Computed - current OAuth state based on platform
|
||||
const currentAuthUrl = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.authUrl.value
|
||||
if (isGemini.value) return geminiOAuth.authUrl.value
|
||||
if (isAntigravity.value) return antigravityOAuth.authUrl.value
|
||||
return claudeOAuth.authUrl.value
|
||||
})
|
||||
const currentSessionId = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.sessionId.value
|
||||
if (isGemini.value) return geminiOAuth.sessionId.value
|
||||
if (isAntigravity.value) return antigravityOAuth.sessionId.value
|
||||
return claudeOAuth.sessionId.value
|
||||
})
|
||||
const currentLoading = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.loading.value
|
||||
if (isGemini.value) return geminiOAuth.loading.value
|
||||
if (isAntigravity.value) return antigravityOAuth.loading.value
|
||||
return claudeOAuth.loading.value
|
||||
})
|
||||
const currentError = computed(() => {
|
||||
if (isOpenAI.value) return openaiOAuth.error.value
|
||||
if (isGemini.value) return geminiOAuth.error.value
|
||||
if (isAntigravity.value) return antigravityOAuth.error.value
|
||||
return claudeOAuth.error.value
|
||||
})
|
||||
|
||||
// Computed
|
||||
const isManualInputMethod = computed(() => {
|
||||
// OpenAI/Gemini/Antigravity always use manual input (no cookie auth option)
|
||||
return isOpenAI.value || isGemini.value || isAntigravity.value || oauthFlowRef.value?.inputMethod === 'manual'
|
||||
})
|
||||
|
||||
const canExchangeCode = computed(() => {
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
const sessionId = currentSessionId.value
|
||||
const loading = currentLoading.value
|
||||
return authCode.trim() && sessionId && !loading
|
||||
})
|
||||
|
||||
// Watchers
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal && props.account) {
|
||||
// Initialize addMethod based on current account type (Claude only)
|
||||
if (
|
||||
isAnthropic.value &&
|
||||
(props.account.type === 'oauth' || props.account.type === 'setup-token')
|
||||
) {
|
||||
addMethod.value = props.account.type as AddMethod
|
||||
}
|
||||
if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
geminiOAuthType.value =
|
||||
creds.oauth_type === 'google_one'
|
||||
? 'google_one'
|
||||
: creds.oauth_type === 'ai_studio'
|
||||
? 'ai_studio'
|
||||
: 'code_assist'
|
||||
}
|
||||
if (isGemini.value) {
|
||||
geminiOAuth.getCapabilities().then((caps) => {
|
||||
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
|
||||
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
}
|
||||
})
|
||||
}
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Methods
|
||||
const resetState = () => {
|
||||
addMethod.value = 'oauth'
|
||||
geminiOAuthType.value = 'code_assist'
|
||||
geminiAIStudioOAuthEnabled.value = false
|
||||
claudeOAuth.resetState()
|
||||
openaiOAuth.resetState()
|
||||
geminiOAuth.resetState()
|
||||
antigravityOAuth.resetState()
|
||||
oauthFlowRef.value?.reset()
|
||||
}
|
||||
|
||||
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'google_one' | 'ai_studio') => {
|
||||
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
|
||||
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
|
||||
return
|
||||
}
|
||||
geminiOAuthType.value = oauthType
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleGenerateUrl = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else if (isGemini.value) {
|
||||
const creds = (props.account.credentials || {}) as Record<string, unknown>
|
||||
const tierId = typeof creds.tier_id === 'string' ? creds.tier_id : undefined
|
||||
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
|
||||
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value, tierId)
|
||||
} else if (isAntigravity.value) {
|
||||
await antigravityOAuth.generateAuthUrl(props.account.proxy_id)
|
||||
} else {
|
||||
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExchangeCode = async () => {
|
||||
if (!props.account) return
|
||||
|
||||
const authCode = oauthFlowRef.value?.authCode || ''
|
||||
if (!authCode.trim()) return
|
||||
|
||||
if (isOpenAI.value) {
|
||||
// OpenAI OAuth flow
|
||||
const sessionId = openaiOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const tokenInfo = await openaiOAuth.exchangeAuthCode(
|
||||
authCode.trim(),
|
||||
sessionId,
|
||||
props.account.proxy_id
|
||||
)
|
||||
if (!tokenInfo) return
|
||||
|
||||
// Build credentials and extra info
|
||||
const credentials = openaiOAuth.buildCredentials(tokenInfo)
|
||||
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
try {
|
||||
// Update account with new credentials
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: 'oauth', // OpenAI OAuth is always 'oauth' type
|
||||
credentials,
|
||||
extra
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
openaiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(openaiOAuth.error.value)
|
||||
}
|
||||
} else if (isGemini.value) {
|
||||
const sessionId = geminiOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || geminiOAuth.state.value
|
||||
if (!stateToUse) return
|
||||
|
||||
const tokenInfo = await geminiOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId,
|
||||
state: stateToUse,
|
||||
proxyId: props.account.proxy_id,
|
||||
oauthType: geminiOAuthType.value,
|
||||
tierId: typeof (props.account.credentials as any)?.tier_id === 'string' ? ((props.account.credentials as any).tier_id as string) : undefined
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = geminiOAuth.buildCredentials(tokenInfo)
|
||||
|
||||
try {
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(geminiOAuth.error.value)
|
||||
}
|
||||
} else if (isAntigravity.value) {
|
||||
// Antigravity OAuth flow
|
||||
const sessionId = antigravityOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
const stateFromInput = oauthFlowRef.value?.oauthState || ''
|
||||
const stateToUse = stateFromInput || antigravityOAuth.state.value
|
||||
if (!stateToUse) return
|
||||
|
||||
const tokenInfo = await antigravityOAuth.exchangeAuthCode({
|
||||
code: authCode.trim(),
|
||||
sessionId,
|
||||
state: stateToUse,
|
||||
proxyId: props.account.proxy_id
|
||||
})
|
||||
if (!tokenInfo) return
|
||||
|
||||
const credentials = antigravityOAuth.buildCredentials(tokenInfo)
|
||||
|
||||
try {
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: 'oauth',
|
||||
credentials
|
||||
})
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
antigravityOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(antigravityOAuth.error.value)
|
||||
}
|
||||
} else {
|
||||
// Claude OAuth flow
|
||||
const sessionId = claudeOAuth.sessionId.value
|
||||
if (!sessionId) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint =
|
||||
addMethod.value === 'oauth'
|
||||
? '/admin/accounts/exchange-code'
|
||||
: '/admin/accounts/exchange-setup-token-code'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: sessionId,
|
||||
code: authCode.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
|
||||
appStore.showError(claudeOAuth.error.value)
|
||||
} finally {
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCookieAuth = async (sessionKey: string) => {
|
||||
if (!props.account || isOpenAI.value) return
|
||||
|
||||
claudeOAuth.loading.value = true
|
||||
claudeOAuth.error.value = ''
|
||||
|
||||
try {
|
||||
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
|
||||
const endpoint =
|
||||
addMethod.value === 'oauth'
|
||||
? '/admin/accounts/cookie-auth'
|
||||
: '/admin/accounts/setup-token-cookie-auth'
|
||||
|
||||
const tokenInfo = await adminAPI.accounts.exchangeCode(endpoint, {
|
||||
session_id: '',
|
||||
code: sessionKey.trim(),
|
||||
...proxyConfig
|
||||
})
|
||||
|
||||
const extra = claudeOAuth.buildExtraInfo(tokenInfo)
|
||||
|
||||
// Update account with new credentials and type
|
||||
await adminAPI.accounts.update(props.account.id, {
|
||||
type: addMethod.value, // Update type based on selected method
|
||||
credentials: tokenInfo,
|
||||
extra
|
||||
})
|
||||
|
||||
// Clear error status after successful re-authorization
|
||||
await adminAPI.accounts.clearError(props.account.id)
|
||||
|
||||
appStore.showSuccess(t('admin.accounts.reAuthorizedSuccess'))
|
||||
emit('reauthorized')
|
||||
handleClose()
|
||||
} catch (error: any) {
|
||||
claudeOAuth.error.value =
|
||||
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
|
||||
} finally {
|
||||
claudeOAuth.loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
46
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
46
frontend/src/components/admin/usage/UsageStatsCards.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30 text-blue-600">
|
||||
<Icon name="document" size="md" />
|
||||
</div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalRequests') }}</p><p class="text-xl font-bold">{{ stats?.total_requests?.toLocaleString() || '0' }}</p></div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-amber-100 p-2 dark:bg-amber-900/30 text-amber-600"><svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 7.5-9-5.25L3 7.5m18 0-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9" /></svg></div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalTokens') }}</p><p class="text-xl font-bold">{{ formatTokens(stats?.total_tokens || 0) }}</p></div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30 text-green-600">
|
||||
<Icon name="dollar" size="md" />
|
||||
</div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.totalCost') }}</p><p class="text-xl font-bold text-green-600">${{ (stats?.total_actual_cost || 0).toFixed(4) }}</p></div>
|
||||
</div>
|
||||
<div class="card p-4 flex items-center gap-3">
|
||||
<div class="rounded-lg bg-purple-100 p-2 dark:bg-purple-900/30 text-purple-600">
|
||||
<Icon name="clock" size="md" />
|
||||
</div>
|
||||
<div><p class="text-xs font-medium text-gray-500">{{ t('usage.avgDuration') }}</p><p class="text-xl font-bold">{{ formatDuration(stats?.average_duration_ms || 0) }}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { AdminUsageStatsResponse } from '@/api/admin/usage'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps<{ stats: AdminUsageStatsResponse | null }>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const formatDuration = (ms: number) =>
|
||||
ms < 1000 ? `${ms.toFixed(0)}ms` : `${(ms / 1000).toFixed(2)}s`
|
||||
|
||||
const formatTokens = (value: number) => {
|
||||
if (value >= 1e9) return (value / 1e9).toFixed(2) + 'B'
|
||||
if (value >= 1e6) return (value / 1e6).toFixed(2) + 'M'
|
||||
if (value >= 1e3) return (value / 1e3).toFixed(2) + 'K'
|
||||
return value.toLocaleString()
|
||||
}
|
||||
</script>
|
||||
163
frontend/src/components/admin/usage/UsageTable.vue
Normal file
163
frontend/src/components/admin/usage/UsageTable.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<div class="card overflow-hidden">
|
||||
<div class="overflow-auto">
|
||||
<DataTable :columns="cols" :data="data" :loading="loading">
|
||||
<template #cell-user="{ row }">
|
||||
<div class="text-sm">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.user?.email || '-' }}</span>
|
||||
<span class="ml-1 text-gray-500 dark:text-gray-400">#{{ row.user_id }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-api_key="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.api_key?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-account="{ row }">
|
||||
<span class="text-sm text-gray-900 dark:text-white">{{ row.account?.name || '-' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-model="{ value }">
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ value }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-group="{ row }">
|
||||
<span v-if="row.group" class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200">
|
||||
{{ row.group.name }}
|
||||
</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-stream="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.stream ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' : 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'">
|
||||
{{ row.stream ? t('usage.stream') : t('usage.sync') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-tokens="{ row }">
|
||||
<!-- 图片生成请求 -->
|
||||
<div v-if="row.image_count > 0" class="flex items-center gap-1.5">
|
||||
<svg class="h-4 w-4 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.image_count }}{{ t('usage.imageUnit') }}</span>
|
||||
<span class="text-gray-400">({{ row.image_size || '2K' }})</span>
|
||||
</div>
|
||||
<!-- Token 请求 -->
|
||||
<div v-else class="space-y-1 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowDown" size="sm" class="h-3.5 w-3.5 text-emerald-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.input_tokens?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<Icon name="arrowUp" size="sm" class="h-3.5 w-3.5 text-violet-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-white">{{ row.output_tokens?.toLocaleString() || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="row.cache_read_tokens > 0 || row.cache_creation_tokens > 0" class="flex items-center gap-2">
|
||||
<div v-if="row.cache_read_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-sky-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /></svg>
|
||||
<span class="font-medium text-sky-600 dark:text-sky-400">{{ formatCacheTokens(row.cache_read_tokens) }}</span>
|
||||
</div>
|
||||
<div v-if="row.cache_creation_tokens > 0" class="inline-flex items-center gap-1">
|
||||
<svg class="h-3.5 w-3.5 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg>
|
||||
<span class="font-medium text-amber-600 dark:text-amber-400">{{ formatCacheTokens(row.cache_creation_tokens) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-cost="{ row }">
|
||||
<span class="font-medium text-green-600 dark:text-green-400">${{ row.actual_cost?.toFixed(6) || '0.000000' }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-billing_type="{ row }">
|
||||
<span class="inline-flex items-center rounded px-2 py-0.5 text-xs font-medium" :class="row.billing_type === 1 ? 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200' : 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200'">
|
||||
{{ row.billing_type === 1 ? t('usage.subscription') : t('usage.balance') }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-first_token="{ row }">
|
||||
<span v-if="row.first_token_ms != null" class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.first_token_ms) }}</span>
|
||||
<span v-else class="text-sm text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #cell-duration="{ row }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDuration(row.duration_ms) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-created_at="{ value }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">{{ formatDateTime(value) }}</span>
|
||||
</template>
|
||||
|
||||
<template #cell-request_id="{ row }">
|
||||
<div v-if="row.request_id" class="flex items-center gap-1.5 max-w-[120px]">
|
||||
<span class="font-mono text-xs text-gray-500 dark:text-gray-400 truncate" :title="row.request_id">{{ row.request_id }}</span>
|
||||
<button @click="copyRequestId(row.request_id)" class="flex-shrink-0 rounded p-0.5 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="copiedRequestId === row.request_id ? 'text-green-500' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'" :title="copiedRequestId === row.request_id ? t('keys.copied') : t('keys.copyToClipboard')">
|
||||
<svg v-if="copiedRequestId === row.request_id" class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
<Icon v-else name="copy" size="sm" class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<span v-else class="text-gray-400 dark:text-gray-500">-</span>
|
||||
</template>
|
||||
|
||||
<template #empty><EmptyState :message="t('usage.noRecords')" /></template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
defineProps(['data', 'loading'])
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
const copiedRequestId = ref<string | null>(null)
|
||||
|
||||
const cols = computed(() => [
|
||||
{ key: 'user', label: t('admin.usage.user'), sortable: false },
|
||||
{ key: 'api_key', label: t('usage.apiKeyFilter'), sortable: false },
|
||||
{ key: 'account', label: t('admin.usage.account'), sortable: false },
|
||||
{ key: 'model', label: t('usage.model'), sortable: true },
|
||||
{ key: 'group', label: t('admin.usage.group'), sortable: false },
|
||||
{ key: 'stream', label: t('usage.type'), sortable: false },
|
||||
{ key: 'tokens', label: t('usage.tokens'), sortable: false },
|
||||
{ key: 'cost', label: t('usage.cost'), sortable: false },
|
||||
{ key: 'billing_type', label: t('usage.billingType'), sortable: false },
|
||||
{ key: 'first_token', label: t('usage.firstToken'), sortable: false },
|
||||
{ key: 'duration', label: t('usage.duration'), sortable: false },
|
||||
{ key: 'created_at', label: t('usage.time'), sortable: true },
|
||||
{ key: 'request_id', label: t('admin.usage.requestId'), sortable: false }
|
||||
])
|
||||
|
||||
const formatCacheTokens = (tokens: number): string => {
|
||||
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`
|
||||
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`
|
||||
return tokens.toString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number | null | undefined): string => {
|
||||
if (ms == null) return '-'
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const copyRequestId = async (requestId: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(requestId)
|
||||
copiedRequestId.value = requestId
|
||||
appStore.showSuccess(t('admin.usage.requestIdCopied'))
|
||||
setTimeout(() => { copiedRequestId.value = null }, 2000)
|
||||
} catch {
|
||||
appStore.showError(t('common.copyFailed'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="t('admin.users.setAllowedGroups')" width="normal" @close="$emit('close')">
|
||||
<div v-if="user" class="space-y-4">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100">
|
||||
<span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span>
|
||||
</div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||
</div>
|
||||
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
|
||||
<div v-else>
|
||||
<p class="mb-3 text-sm text-gray-600">{{ t('admin.users.allowedGroupsHint') }}</p>
|
||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<label v-for="group in groups" :key="group.id" class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
|
||||
<input type="checkbox" :value="group.id" v-model="selectedIds" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ group.name }}</p><p v-if="group.description" class="truncate text-sm text-gray-500">{{ group.description }}</p></div>
|
||||
<div class="flex items-center gap-2"><span class="badge badge-gray text-xs">{{ group.platform }}</span><span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span></div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-green-300 bg-green-50': selectedIds.length === 0}">
|
||||
<input type="radio" :checked="selectedIds.length === 0" @change="selectedIds = []" class="h-4 w-4 border-gray-300 text-green-600" />
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ t('admin.users.allowAllGroups') }}</p><p class="text-sm text-gray-500">{{ t('admin.users.allowAllGroupsHint') }}</p></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button @click="handleSave" :disabled="submitting" class="btn btn-primary">{{ submitting ? t('common.saving') : t('common.save') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, Group } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
|
||||
|
||||
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
|
||||
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch {} finally { loading.value = false } }
|
||||
const handleSave = async () => {
|
||||
if (!props.user) return; submitting.value = true
|
||||
try {
|
||||
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
|
||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
||||
} catch {} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
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>
|
||||
57
frontend/src/components/admin/user/UserBalanceModal.vue
Normal file
57
frontend/src/components/admin/user/UserBalanceModal.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<BaseDialog :show="show" :title="operation === 'add' ? t('admin.users.deposit') : t('admin.users.withdraw')" width="narrow" @close="$emit('close')">
|
||||
<form v-if="user" id="balance-form" @submit.prevent="handleBalanceSubmit" class="space-y-5">
|
||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100"><span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span></div>
|
||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ user.email }}</p><p class="text-sm text-gray-500">{{ t('admin.users.currentBalance') }}: ${{ user.balance.toFixed(2) }}</p></div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ operation === 'add' ? t('admin.users.depositAmount') : t('admin.users.withdrawAmount') }}</label>
|
||||
<div class="relative"><div class="absolute left-3 top-1/2 -translate-y-1/2 font-medium text-gray-500">$</div><input v-model.number="form.amount" type="number" step="0.01" min="0.01" required class="input pl-8" /></div>
|
||||
</div>
|
||||
<div><label class="input-label">{{ t('admin.users.notes') }}</label><textarea v-model="form.notes" rows="3" class="input"></textarea></div>
|
||||
<div v-if="form.amount > 0" class="rounded-xl border border-blue-200 bg-blue-50 p-4"><div class="flex items-center justify-between text-sm"><span>{{ t('admin.users.newBalance') }}:</span><span class="font-bold">${{ calculateNewBalance().toFixed(2) }}</span></div></div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="balance-form" :disabled="submitting || !form.amount" class="btn" :class="operation === 'add' ? 'bg-emerald-600 text-white' : 'btn-danger'">{{ submitting ? t('common.saving') : t('common.confirm') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null, operation: 'add' | 'subtract' }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
||||
|
||||
const submitting = ref(false); const form = reactive({ amount: 0, notes: '' })
|
||||
watch(() => props.show, (v) => { if(v) { form.amount = 0; form.notes = '' } })
|
||||
|
||||
const calculateNewBalance = () => (props.user ? (props.operation === 'add' ? props.user.balance + form.amount : props.user.balance - form.amount) : 0)
|
||||
const handleBalanceSubmit = async () => {
|
||||
if (!props.user) return
|
||||
if (!form.amount || form.amount <= 0) {
|
||||
appStore.showError(t('admin.users.amountRequired'))
|
||||
return
|
||||
}
|
||||
if (props.operation === 'subtract' && form.amount > props.user.balance) {
|
||||
appStore.showError(t('admin.users.insufficientBalance'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAPI.users.updateBalance(props.user.id, form.amount, props.operation, form.notes)
|
||||
appStore.showSuccess(t('common.success')); emit('success'); emit('close')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e.response?.data?.detail || t('common.error'))
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
78
frontend/src/components/admin/user/UserCreateModal.vue
Normal file
78
frontend/src/components/admin/user/UserCreateModal.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.users.createUser')"
|
||||
width="normal"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<form id="create-user-form" @submit.prevent="submit" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.email') }}</label>
|
||||
<input v-model="form.email" type="email" required class="input" :placeholder="t('admin.users.enterEmail')" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.password') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input v-model="form.password" type="text" required class="input pr-10" :placeholder="t('admin.users.enterPassword')" />
|
||||
</div>
|
||||
<button type="button" @click="generateRandomPassword" class="btn btn-secondary px-3">
|
||||
<Icon name="refresh" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" :placeholder="t('admin.users.enterUsername')" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.balance') }}</label>
|
||||
<input v-model.number="form.balance" type="number" step="any" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="create-user-form" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? t('admin.users.creating') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
|
||||
import { useForm } from '@/composables/useForm'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
|
||||
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 })
|
||||
|
||||
const { loading, submit } = useForm({
|
||||
form,
|
||||
submitFn: async (data) => {
|
||||
await adminAPI.users.create(data)
|
||||
emit('success'); emit('close')
|
||||
},
|
||||
successMsg: t('admin.users.userCreated')
|
||||
})
|
||||
|
||||
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', balance: 0, concurrency: 1 }) })
|
||||
|
||||
const generateRandomPassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
form.password = p
|
||||
}
|
||||
</script>
|
||||
110
frontend/src/components/admin/user/UserEditModal.vue
Normal file
110
frontend/src/components/admin/user/UserEditModal.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<BaseDialog
|
||||
:show="show"
|
||||
:title="t('admin.users.editUser')"
|
||||
width="normal"
|
||||
@close="$emit('close')"
|
||||
>
|
||||
<form v-if="user" id="edit-user-form" @submit.prevent="handleUpdateUser" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.email') }}</label>
|
||||
<input v-model="form.email" type="email" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.password') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative flex-1">
|
||||
<input v-model="form.password" type="text" class="input pr-10" :placeholder="t('admin.users.enterNewPassword')" />
|
||||
<button v-if="form.password" type="button" @click="copyPassword" class="absolute right-2 top-1/2 -translate-y-1/2 rounded-lg p-1 transition-colors hover:bg-gray-100 dark:hover:bg-dark-700" :class="passwordCopied ? 'text-green-500' : 'text-gray-400'">
|
||||
<svg v-if="passwordCopied" class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" /></svg>
|
||||
<svg v-else class="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" @click="generatePassword" class="btn btn-secondary px-3">
|
||||
<Icon name="refresh" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.username') }}</label>
|
||||
<input v-model="form.username" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.notes') }}</label>
|
||||
<textarea v-model="form.notes" rows="3" class="input"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.users.columns.concurrency') }}</label>
|
||||
<input v-model.number="form.concurrency" type="number" class="input" />
|
||||
</div>
|
||||
<UserAttributeForm v-model="form.customAttributes" :user-id="user?.id" />
|
||||
</form>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button @click="$emit('close')" type="button" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
||||
<button type="submit" form="edit-user-form" :disabled="submitting" class="btn btn-primary">
|
||||
{{ submitting ? t('admin.users.updating') : t('common.update') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import type { User, UserAttributeValuesMap } from '@/types'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const props = defineProps<{ show: boolean, user: User | null }>()
|
||||
const emit = defineEmits(['close', 'success'])
|
||||
const { t } = useI18n(); const appStore = useAppStore(); const { copyToClipboard } = useClipboard()
|
||||
|
||||
const submitting = ref(false); const passwordCopied = ref(false)
|
||||
const form = reactive({ email: '', password: '', username: '', notes: '', concurrency: 1, customAttributes: {} as UserAttributeValuesMap })
|
||||
|
||||
watch(() => props.user, (u) => {
|
||||
if (u) {
|
||||
Object.assign(form, { email: u.email, password: '', username: u.username || '', notes: u.notes || '', concurrency: u.concurrency, customAttributes: {} })
|
||||
passwordCopied.value = false
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
const generatePassword = () => {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%^&*'
|
||||
let p = ''; for (let i = 0; i < 16; i++) p += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
form.password = p
|
||||
}
|
||||
const copyPassword = async () => {
|
||||
if (form.password && await copyToClipboard(form.password, t('admin.users.passwordCopied'))) {
|
||||
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
|
||||
}
|
||||
}
|
||||
const handleUpdateUser = async () => {
|
||||
if (!props.user) return
|
||||
if (!form.email.trim()) {
|
||||
appStore.showError(t('admin.users.emailRequired'))
|
||||
return
|
||||
}
|
||||
if (form.concurrency < 1) {
|
||||
appStore.showError(t('admin.users.concurrencyMin'))
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const data: any = { email: form.email, username: form.username, notes: form.notes, concurrency: form.concurrency }
|
||||
if (form.password.trim()) data.password = form.password.trim()
|
||||
await adminAPI.users.update(props.user.id, data)
|
||||
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
|
||||
appStore.showSuccess(t('admin.users.userUpdated'))
|
||||
emit('success'); emit('close')
|
||||
} catch (e: any) {
|
||||
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
|
||||
} finally { submitting.value = false }
|
||||
}
|
||||
</script>
|
||||
@@ -21,15 +21,7 @@
|
||||
class="-mr-2 rounded-xl p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-dark-500 dark:hover:bg-dark-700 dark:hover:text-dark-300"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted, ref, nextTick } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
// 生成唯一ID以避免多个对话框时ID冲突
|
||||
let dialogIdCounter = 0
|
||||
|
||||
@@ -66,19 +66,11 @@
|
||||
>
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
<svg
|
||||
<Icon
|
||||
name="inbox"
|
||||
size="xl"
|
||||
class="mb-4 h-12 w-12 text-gray-400 dark:text-dark-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||
/>
|
||||
</svg>
|
||||
/>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('empty.noData') }}
|
||||
</p>
|
||||
@@ -117,6 +109,7 @@
|
||||
import { computed, ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Column } from './types'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
@@ -6,33 +6,17 @@
|
||||
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
|
||||
>
|
||||
<span class="date-picker-icon">
|
||||
<svg
|
||||
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="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"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="calendar" size="sm" />
|
||||
</span>
|
||||
<span class="date-picker-value">
|
||||
{{ displayValue }}
|
||||
</span>
|
||||
<span class="date-picker-chevron">
|
||||
<svg
|
||||
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size="sm"
|
||||
:class="['transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -65,19 +49,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="date-picker-separator">
|
||||
<svg
|
||||
class="h-4 w-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M17.25 8.25L21 12m0 0l-3.75 3.75M21 12H3"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="arrowRight" size="sm" class="text-gray-400" />
|
||||
</div>
|
||||
<div class="date-picker-field">
|
||||
<label class="date-picker-label">{{ t('dates.endDate') }}</label>
|
||||
@@ -106,6 +78,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
interface DatePreset {
|
||||
labelKey: string
|
||||
|
||||
@@ -43,16 +43,7 @@
|
||||
@click="!actionTo && $emit('action')"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
<svg
|
||||
v-if="actionIcon"
|
||||
class="mr-2 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="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<Icon v-if="actionIcon" name="plus" size="md" class="mr-2" />
|
||||
{{ actionText }}
|
||||
</component>
|
||||
</slot>
|
||||
@@ -64,6 +55,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { Component } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
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>
|
||||
@@ -7,16 +7,12 @@
|
||||
>
|
||||
<span class="text-base">{{ currentLocale?.flag }}</span>
|
||||
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
|
||||
<svg
|
||||
class="h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size="xs"
|
||||
class="text-gray-400 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isOpen }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
/>
|
||||
</button>
|
||||
|
||||
<transition name="dropdown">
|
||||
@@ -36,16 +32,7 @@
|
||||
>
|
||||
<span class="text-base">{{ locale.flag }}</span>
|
||||
<span>{{ locale.name }}</span>
|
||||
<svg
|
||||
v-if="locale.code === currentLocaleCode"
|
||||
class="ml-auto h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<Icon v-if="locale.code === currentLocaleCode" name="check" size="sm" class="ml-auto text-primary-500" />
|
||||
</button>
|
||||
</div>
|
||||
</transition>
|
||||
@@ -55,6 +42,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { setLocale, availableLocales } from '@/i18n'
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
@@ -63,13 +63,7 @@
|
||||
class="relative inline-flex items-center rounded-l-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.previous')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="chevronLeft" size="md" />
|
||||
</button>
|
||||
|
||||
<!-- Page numbers -->
|
||||
@@ -100,13 +94,7 @@
|
||||
class="relative inline-flex items-center rounded-r-md border border-gray-300 bg-white px-2 py-2 text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400 dark:hover:bg-dark-600"
|
||||
:aria-label="t('pagination.next')"
|
||||
>
|
||||
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="chevronRight" size="md" />
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -116,6 +104,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from './Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -23,35 +23,9 @@
|
||||
/>
|
||||
</svg>
|
||||
<!-- Setup Token icon -->
|
||||
<svg
|
||||
v-else-if="type === 'setup-token'"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon v-else-if="type === 'setup-token'" name="shield" size="xs" />
|
||||
<!-- API Key icon -->
|
||||
<svg
|
||||
v-else
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
<Icon v-else name="key" size="xs" />
|
||||
<span>{{ typeLabel }}</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -61,6 +35,7 @@
|
||||
import { computed } from 'vue'
|
||||
import type { AccountPlatform, AccountType } from '@/types'
|
||||
import PlatformIcon from './PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
interface Props {
|
||||
platform: AccountPlatform
|
||||
|
||||
@@ -14,15 +14,11 @@
|
||||
{{ selectedLabel }}
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size="md"
|
||||
:class="['transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -31,19 +27,7 @@
|
||||
<!-- Search and Batch Test Header -->
|
||||
<div class="select-header">
|
||||
<div class="select-search">
|
||||
<svg
|
||||
class="h-4 w-4 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>
|
||||
<Icon name="search" size="sm" class="text-gray-400" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
@@ -76,20 +60,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>
|
||||
<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="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon v-else name="play" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -101,16 +72,7 @@
|
||||
:class="['select-option', modelValue === null && 'select-option-selected']"
|
||||
>
|
||||
<span class="select-option-label">{{ t('admin.accounts.noProxy') }}</span>
|
||||
<svg
|
||||
v-if="modelValue === null"
|
||||
class="h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<Icon v-if="modelValue === null" name="check" size="sm" class="text-primary-500" />
|
||||
</div>
|
||||
|
||||
<!-- Proxy options -->
|
||||
@@ -184,32 +146,15 @@
|
||||
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
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 010 1.972l-11.54 6.347a1.125 1.125 0 01-1.667-.986V5.653z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon v-else name="play" size="xs" />
|
||||
</button>
|
||||
|
||||
<svg
|
||||
<Icon
|
||||
v-if="modelValue === proxy.id"
|
||||
class="h-4 w-4 flex-shrink-0 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
name="check"
|
||||
size="sm"
|
||||
class="flex-shrink-0 text-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
@@ -226,6 +171,7 @@
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { adminAPI } from '@/api/admin'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import type { Proxy } from '@/types'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
43
frontend/src/components/common/SearchInput.vue
Normal file
43
frontend/src/components/common/SearchInput.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="relative w-full">
|
||||
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
|
||||
<Icon name="search" size="md" class="text-gray-400" />
|
||||
</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'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
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">
|
||||
@@ -17,44 +23,31 @@
|
||||
</slot>
|
||||
</span>
|
||||
<span class="select-icon">
|
||||
<svg
|
||||
:class="['h-5 w-5 transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
<Icon
|
||||
name="chevronDown"
|
||||
size="md"
|
||||
:class="['transition-transform duration-200', isOpen && 'rotate-180']"
|
||||
/>
|
||||
</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">
|
||||
<svg
|
||||
class="h-4 w-4 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>
|
||||
<Icon name="search" size="sm" class="text-gray-400" />
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchQuery"
|
||||
@@ -66,25 +59,31 @@
|
||||
</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>
|
||||
<svg
|
||||
<Icon
|
||||
v-if="isSelected(option)"
|
||||
class="h-4 w-4 text-primary-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
name="check"
|
||||
size="sm"
|
||||
class="text-primary-500"
|
||||
:stroke-width="2"
|
||||
/>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
@@ -102,9 +101,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
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 +141,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 +168,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 +213,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 +253,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 +392,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 +416,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 +425,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 +433,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 +450,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;
|
||||
|
||||
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>
|
||||
@@ -8,18 +8,12 @@
|
||||
<div class="mt-1 flex items-baseline gap-2">
|
||||
<p class="stat-value">{{ formattedValue }}</p>
|
||||
<span v-if="change !== undefined" :class="['stat-trend', trendClass]">
|
||||
<svg
|
||||
<Icon
|
||||
v-if="changeType !== 'neutral'"
|
||||
:class="['h-3 w-3', changeType === 'down' && 'rotate-180']"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
name="arrowUp"
|
||||
size="xs"
|
||||
:class="changeType === 'down' && 'rotate-180'"
|
||||
/>
|
||||
{{ formattedChange }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -30,6 +24,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Component } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
type ChangeType = 'up' | 'down' | 'neutral'
|
||||
type IconVariant = 'primary' | 'success' | 'warning' | 'danger'
|
||||
|
||||
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>
|
||||
@@ -6,19 +6,7 @@
|
||||
class="flex cursor-pointer items-center gap-2 rounded-xl bg-purple-50 px-3 py-1.5 transition-colors hover:bg-purple-100 dark:bg-purple-900/20 dark:hover:bg-purple-900/30"
|
||||
:title="t('subscriptionProgress.viewDetails')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-purple-600 dark:text-purple-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="creditCard" size="sm" class="text-purple-600 dark:text-purple-400" />
|
||||
<div class="flex items-center gap-1.5">
|
||||
<!-- Combined progress indicator -->
|
||||
<div class="flex items-center gap-0.5">
|
||||
@@ -192,6 +180,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useSubscriptionStore } from '@/stores'
|
||||
import type { UserSubscription } from '@/types'
|
||||
|
||||
|
||||
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>
|
||||
@@ -27,9 +27,10 @@
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- Icon -->
|
||||
<div class="mt-0.5 flex-shrink-0">
|
||||
<component
|
||||
:is="getIcon(toast.type)"
|
||||
:class="['h-5 w-5', getIconColor(toast.type)]"
|
||||
<Icon
|
||||
:name="getToastIconName(toast.type)"
|
||||
size="md"
|
||||
:class="getIconColor(toast.type)"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
@@ -57,13 +58,7 @@
|
||||
class="-m-1 flex-shrink-0 rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-dark-700 dark:hover:text-gray-300"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="x" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -82,77 +77,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, h } from 'vue'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
const toasts = computed(() => appStore.toasts)
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
const icons = {
|
||||
success: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
error: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
warning: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
),
|
||||
info: () =>
|
||||
h(
|
||||
'svg',
|
||||
{
|
||||
fill: 'currentColor',
|
||||
viewBox: '0 0 20 20'
|
||||
},
|
||||
[
|
||||
h('path', {
|
||||
'fill-rule': 'evenodd',
|
||||
d: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z',
|
||||
'clip-rule': 'evenodd'
|
||||
})
|
||||
]
|
||||
)
|
||||
const getToastIconName = (type: string): 'checkCircle' | 'xCircle' | 'exclamationTriangle' | 'infoCircle' => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'checkCircle'
|
||||
case 'error':
|
||||
return 'xCircle'
|
||||
case 'warning':
|
||||
return 'exclamationTriangle'
|
||||
case 'info':
|
||||
default:
|
||||
return 'infoCircle'
|
||||
}
|
||||
return icons[type as keyof typeof icons] || icons.info
|
||||
}
|
||||
|
||||
const getIconColor = (type: string): string => {
|
||||
|
||||
@@ -46,20 +46,12 @@
|
||||
:disabled="loading"
|
||||
:title="t('version.refresh')"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
<Icon
|
||||
name="refresh"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
:class="{ 'animate-spin': loading }"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<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>
|
||||
|
||||
@@ -129,19 +121,12 @@
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
<Icon
|
||||
name="x"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
@@ -253,19 +238,12 @@
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<Icon
|
||||
name="download"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
@@ -314,23 +292,16 @@
|
||||
<div
|
||||
class="flex items-center gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800/50 dark:bg-amber-900/20"
|
||||
>
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4 text-amber-600 dark:text-amber-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50"
|
||||
>
|
||||
<Icon
|
||||
name="download"
|
||||
size="sm"
|
||||
:stroke-width="2"
|
||||
class="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium text-amber-700 dark:text-amber-300">
|
||||
{{ t('version.updateAvailable') }}
|
||||
@@ -362,20 +333,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>
|
||||
<svg
|
||||
v-else
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
<Icon v-else name="download" size="sm" :stroke-width="2" />
|
||||
{{ updating ? t('version.updating') : t('version.updateNow') }}
|
||||
</button>
|
||||
|
||||
@@ -388,19 +346,7 @@
|
||||
class="flex items-center justify-center gap-1 text-xs text-gray-500 transition-colors hover:text-gray-700 dark:text-dark-400 dark:hover:text-dark-200"
|
||||
>
|
||||
{{ t('version.viewChangelog') }}
|
||||
<svg
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="externalLink" size="xs" :stroke-width="2" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -439,6 +385,7 @@ import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAuthStore, useAppStore } from '@/stores'
|
||||
import { performUpdate, restartService } from '@/api/admin/system'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
||||
139
frontend/src/components/icons/Icon.vue
Normal file
139
frontend/src/components/icons/Icon.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<svg
|
||||
:class="sizeClass"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
:stroke-width="strokeWidth"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" :d="iconPath" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: keyof typeof icons
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
strokeWidth?: number
|
||||
}>(), {
|
||||
size: 'md',
|
||||
strokeWidth: 1.5
|
||||
})
|
||||
|
||||
const icons = {
|
||||
// Actions
|
||||
play: 'M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z',
|
||||
refresh: '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',
|
||||
edit: 'M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10',
|
||||
trash: 'M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0',
|
||||
plus: 'M12 4.5v15m7.5-7.5h-15',
|
||||
search: 'M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z',
|
||||
more: 'M6.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM12.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0zM18.75 12a.75.75 0 11-1.5 0 .75.75 0 011.5 0z',
|
||||
|
||||
// Status & Info
|
||||
chart: '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',
|
||||
clock: 'M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
link: 'M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244',
|
||||
sync: 'M19.5 12c0-1.232-.046-2.453-.138-3.662a4.006 4.006 0 00-3.7-3.7 48.678 48.678 0 00-7.324 0 4.006 4.006 0 00-3.7 3.7c-.017.22-.032.441-.046.662M19.5 12l3-3m-3 3l-3-3m-12 3c0 1.232.046 2.453.138 3.662a4.006 4.006 0 003.7 3.7 48.656 48.656 0 007.324 0 4.006 4.006 0 003.7-3.7c.017-.22.032-.441.046-.662M4.5 12l3 3m-3-3l-3 3',
|
||||
|
||||
// Navigation
|
||||
chevronDown: 'M19.5 8.25l-7.5 7.5-7.5-7.5',
|
||||
chevronRight: 'M8.25 4.5l7.5 7.5-7.5 7.5',
|
||||
chevronLeft: 'M15.75 19.5L8.25 12l7.5-7.5',
|
||||
|
||||
// UI Elements
|
||||
check: 'M4.5 12.75l6 6 9-13.5',
|
||||
x: 'M6 18L18 6M6 6l12 12',
|
||||
eye: 'M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178zM15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
eyeOff: 'M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88',
|
||||
cog: 'M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z M15 12a3 3 0 11-6 0 3 3 0 016 0z',
|
||||
grid: 'M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z',
|
||||
chat: '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',
|
||||
lightbulb: 'M12 18v-5.25m0 0a6.01 6.01 0 001.5-.189m-1.5.189a6.01 6.01 0 01-1.5-.189m3.75 7.478a12.06 12.06 0 01-4.5 0m3.75 2.383a14.406 14.406 0 01-3 0M14.25 18v-.192c0-.983.658-1.823 1.508-2.316a7.5 7.5 0 10-7.517 0c.85.493 1.509 1.333 1.509 2.316V18',
|
||||
|
||||
// Navigation & Arrows
|
||||
arrowRight: 'M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3',
|
||||
arrowLeft: 'M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18',
|
||||
arrowUp: 'M5 10l7-7m0 0l7 7m-7-7v18',
|
||||
arrowDown: 'M19 14l-7 7m0 0l-7-7m7 7V3',
|
||||
chevronUp: 'M5 15l7-7 7 7',
|
||||
externalLink: 'M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14',
|
||||
|
||||
// Status & Indicators
|
||||
checkCircle: 'M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
xCircle: 'M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
exclamationCircle: 'M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z',
|
||||
exclamationTriangle: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z',
|
||||
infoCircle: 'M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z',
|
||||
questionCircle: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||
|
||||
// User & Account
|
||||
user: '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',
|
||||
userCircle: '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',
|
||||
userPlus: 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z',
|
||||
users: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||
|
||||
// Files & Documents
|
||||
document: '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',
|
||||
clipboard: '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',
|
||||
copy: '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',
|
||||
inbox: 'M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4',
|
||||
|
||||
// Actions
|
||||
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
|
||||
upload: 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5',
|
||||
filter: 'M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z',
|
||||
sort: 'M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9',
|
||||
|
||||
// Security
|
||||
key: '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',
|
||||
lock: 'M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z',
|
||||
shield: 'M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z',
|
||||
|
||||
// UI Elements
|
||||
menu: 'M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5',
|
||||
calendar: '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',
|
||||
home: 'M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25',
|
||||
terminal: 'M6.75 7.5l3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0021 18V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v12a2.25 2.25 0 002.25 2.25z',
|
||||
gift: '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',
|
||||
creditCard: 'M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z',
|
||||
mail: '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',
|
||||
|
||||
// Data & Analytics
|
||||
chartBar: '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',
|
||||
trendingUp: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6',
|
||||
database: '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',
|
||||
cube: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||||
|
||||
// Misc
|
||||
bolt: 'M13 10V3L4 14h7v7l9-11h-7z',
|
||||
sparkles: '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.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456z',
|
||||
cloud: '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',
|
||||
server: 'M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z',
|
||||
sun: 'M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z',
|
||||
moon: 'M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z',
|
||||
book: 'M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25',
|
||||
dollar: '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',
|
||||
ban: 'M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636',
|
||||
login: 'M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9',
|
||||
swap: 'M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5',
|
||||
beaker: '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',
|
||||
cpu: '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',
|
||||
chatBubble: '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',
|
||||
calculator: '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',
|
||||
fire: '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',
|
||||
badge: 'M9 12.75L11.25 15 15 9.75M21 12c0 1.268-.63 2.39-1.593 3.068a3.745 3.745 0 01-1.043 3.296 3.745 3.745 0 01-3.296 1.043A3.745 3.745 0 0112 21c-1.268 0-2.39-.63-3.068-1.593a3.746 3.746 0 01-3.296-1.043 3.745 3.745 0 01-1.043-3.296A3.745 3.745 0 013 12c0-1.268.63-2.39 1.593-3.068a3.745 3.745 0 011.043-3.296 3.746 3.746 0 013.296-1.043A3.746 3.746 0 0112 3c1.268 0 2.39.63 3.068 1.593a3.746 3.746 0 013.296 1.043 3.746 3.746 0 011.043 3.296A3.745 3.745 0 0121 12z'
|
||||
} as const
|
||||
|
||||
const iconPath = computed(() => icons[props.name])
|
||||
|
||||
const sizeClass = computed(() => ({
|
||||
xs: 'h-3 w-3',
|
||||
sm: 'h-4 w-4',
|
||||
md: 'h-5 w-5',
|
||||
lg: 'h-6 w-6',
|
||||
xl: 'h-8 w-8'
|
||||
}[props.size]))
|
||||
</script>
|
||||
1
frontend/src/components/icons/index.ts
Normal file
1
frontend/src/components/icons/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Icon } from './Icon.vue'
|
||||
@@ -81,9 +81,7 @@
|
||||
>
|
||||
<!-- File Hint (if exists) -->
|
||||
<p v-if="file.hint" class="text-xs text-amber-600 dark:text-amber-400 mb-1.5 flex items-center gap-1">
|
||||
<svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<Icon name="exclamationCircle" size="sm" class="flex-shrink-0" />
|
||||
{{ file.hint }}
|
||||
</p>
|
||||
<div class="bg-gray-900 dark:bg-dark-900 rounded-xl overflow-hidden">
|
||||
@@ -107,16 +105,17 @@
|
||||
</button>
|
||||
</div>
|
||||
<!-- Code Content -->
|
||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto"><code v-html="file.highlighted"></code></pre>
|
||||
<pre class="p-4 text-sm font-mono text-gray-100 overflow-x-auto">
|
||||
<code v-if="file.highlighted" v-html="file.highlighted"></code>
|
||||
<code v-else v-text="file.content"></code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage Note -->
|
||||
<div class="flex items-start gap-3 p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800">
|
||||
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
</svg>
|
||||
<Icon name="infoCircle" size="md" class="text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
{{ platformNote }}
|
||||
</p>
|
||||
@@ -141,6 +140,7 @@
|
||||
import { ref, computed, h, watch, type Component } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import type { GroupPlatform } from '@/types'
|
||||
|
||||
@@ -164,8 +164,8 @@ interface TabConfig {
|
||||
interface FileConfig {
|
||||
path: string
|
||||
content: string
|
||||
highlighted: string
|
||||
hint?: string // Optional hint message for this file
|
||||
highlighted?: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -311,14 +311,23 @@ const platformNote = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Syntax highlighting helpers
|
||||
const keyword = (text: string) => `<span class="text-purple-400">${text}</span>`
|
||||
const variable = (text: string) => `<span class="text-cyan-400">${text}</span>`
|
||||
const string = (text: string) => `<span class="text-green-400">${text}</span>`
|
||||
const operator = (text: string) => `<span class="text-yellow-400">${text}</span>`
|
||||
const comment = (text: string) => `<span class="text-gray-500">${text}</span>`
|
||||
const key = (text: string) => `<span class="text-blue-400">${text}</span>`
|
||||
const escapeHtml = (value: string) => value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const wrapToken = (className: string, value: string) =>
|
||||
`<span class="${className}">${escapeHtml(value)}</span>`
|
||||
|
||||
const keyword = (value: string) => wrapToken('text-emerald-300', value)
|
||||
const variable = (value: string) => wrapToken('text-sky-200', value)
|
||||
const operator = (value: string) => wrapToken('text-slate-400', value)
|
||||
const string = (value: string) => wrapToken('text-amber-200', value)
|
||||
const comment = (value: string) => wrapToken('text-slate-500', value)
|
||||
|
||||
// Syntax highlighting helpers
|
||||
// Generate file configs based on platform and active tab
|
||||
const currentFiles = computed((): FileConfig[] => {
|
||||
const baseUrl = props.baseUrl || window.location.origin
|
||||
@@ -343,37 +352,29 @@ const currentFiles = computed((): FileConfig[] => {
|
||||
function generateAnthropicFiles(baseUrl: string, apiKey: string): FileConfig[] {
|
||||
let path: string
|
||||
let content: string
|
||||
let highlighted: string
|
||||
|
||||
switch (activeTab.value) {
|
||||
case 'unix':
|
||||
path = 'Terminal'
|
||||
content = `export ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
export ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
highlighted = `${keyword('export')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('export')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
break
|
||||
case 'cmd':
|
||||
path = 'Command Prompt'
|
||||
content = `set ANTHROPIC_BASE_URL=${baseUrl}
|
||||
set ANTHROPIC_AUTH_TOKEN=${apiKey}`
|
||||
highlighted = `${keyword('set')} ${variable('ANTHROPIC_BASE_URL')}${operator('=')}${baseUrl}
|
||||
${keyword('set')} ${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${apiKey}`
|
||||
break
|
||||
case 'powershell':
|
||||
path = 'PowerShell'
|
||||
content = `$env:ANTHROPIC_BASE_URL="${baseUrl}"
|
||||
$env:ANTHROPIC_AUTH_TOKEN="${apiKey}"`
|
||||
highlighted = `${keyword('$env:')}${variable('ANTHROPIC_BASE_URL')}${operator('=')}${string(`"${baseUrl}"`)}
|
||||
${keyword('$env:')}${variable('ANTHROPIC_AUTH_TOKEN')}${operator('=')}${string(`"${apiKey}"`)}`
|
||||
break
|
||||
default:
|
||||
path = 'Terminal'
|
||||
content = ''
|
||||
highlighted = ''
|
||||
}
|
||||
|
||||
return [{ path, content, highlighted }]
|
||||
return [{ path, content }]
|
||||
}
|
||||
|
||||
function generateGeminiCliContent(baseUrl: string, apiKey: string): FileConfig {
|
||||
@@ -398,9 +399,9 @@ ${keyword('export')} ${variable('GEMINI_MODEL')}${operator('=')}${string(`"${mod
|
||||
content = `set GOOGLE_GEMINI_BASE_URL=${baseUrl}
|
||||
set GEMINI_API_KEY=${apiKey}
|
||||
set GEMINI_MODEL=${model}`
|
||||
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${baseUrl}
|
||||
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${apiKey}
|
||||
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${model}
|
||||
highlighted = `${keyword('set')} ${variable('GOOGLE_GEMINI_BASE_URL')}${operator('=')}${string(baseUrl)}
|
||||
${keyword('set')} ${variable('GEMINI_API_KEY')}${operator('=')}${string(apiKey)}
|
||||
${keyword('set')} ${variable('GEMINI_MODEL')}${operator('=')}${string(model)}
|
||||
${comment(`REM ${modelComment}`)}`
|
||||
break
|
||||
case 'powershell':
|
||||
@@ -440,40 +441,20 @@ base_url = "${baseUrl}"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true`
|
||||
|
||||
const configHighlighted = `${key('model_provider')} ${operator('=')} ${string('"sub2api"')}
|
||||
${key('model')} ${operator('=')} ${string('"gpt-5.2-codex"')}
|
||||
${key('model_reasoning_effort')} ${operator('=')} ${string('"high"')}
|
||||
${key('network_access')} ${operator('=')} ${string('"enabled"')}
|
||||
${key('disable_response_storage')} ${operator('=')} ${keyword('true')}
|
||||
${key('windows_wsl_setup_acknowledged')} ${operator('=')} ${keyword('true')}
|
||||
${key('model_verbosity')} ${operator('=')} ${string('"high"')}
|
||||
|
||||
${comment('[model_providers.sub2api]')}
|
||||
${key('name')} ${operator('=')} ${string('"sub2api"')}
|
||||
${key('base_url')} ${operator('=')} ${string(`"${baseUrl}"`)}
|
||||
${key('wire_api')} ${operator('=')} ${string('"responses"')}
|
||||
${key('requires_openai_auth')} ${operator('=')} ${keyword('true')}`
|
||||
|
||||
// auth.json content
|
||||
const authContent = `{
|
||||
"OPENAI_API_KEY": "${apiKey}"
|
||||
}`
|
||||
|
||||
const authHighlighted = `{
|
||||
${key('"OPENAI_API_KEY"')}: ${string(`"${apiKey}"`)}
|
||||
}`
|
||||
|
||||
return [
|
||||
{
|
||||
path: `${configDir}/config.toml`,
|
||||
content: configContent,
|
||||
highlighted: configHighlighted,
|
||||
hint: t('keys.useKeyModal.openai.configTomlHint')
|
||||
},
|
||||
{
|
||||
path: `${configDir}/auth.json`,
|
||||
content: authContent,
|
||||
highlighted: authHighlighted
|
||||
content: authContent
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,19 +8,7 @@
|
||||
class="btn-ghost btn-icon lg:hidden"
|
||||
aria-label="Toggle Menu"
|
||||
>
|
||||
<svg
|
||||
class="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="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="menu" size="md" />
|
||||
</button>
|
||||
|
||||
<div class="hidden lg:block">
|
||||
@@ -84,19 +72,7 @@
|
||||
{{ user.role }}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
class="hidden h-4 w-4 text-gray-400 md:block"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
|
||||
/>
|
||||
</svg>
|
||||
<Icon name="chevronDown" size="sm" class="hidden text-gray-400 md:block" />
|
||||
</button>
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
@@ -122,36 +98,12 @@
|
||||
|
||||
<div class="py-1">
|
||||
<router-link to="/profile" @click="closeDropdown" class="dropdown-item">
|
||||
<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>
|
||||
<Icon name="user" size="sm" />
|
||||
{{ t('nav.profile') }}
|
||||
</router-link>
|
||||
|
||||
<router-link to="/keys" @click="closeDropdown" class="dropdown-item">
|
||||
<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 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>
|
||||
<Icon name="key" size="sm" />
|
||||
{{ t('nav.apiKeys') }}
|
||||
</router-link>
|
||||
|
||||
@@ -246,6 +198,7 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore, useAuthStore, useOnboardingStore } from '@/stores'
|
||||
import LocaleSwitcher from '@/components/common/LocaleSwitcher.vue'
|
||||
import SubscriptionProgressMini from '@/components/common/SubscriptionProgressMini.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { getPublicSettings } from '@/api/auth'
|
||||
import { sanitizeUrl } from '@/utils/url'
|
||||
|
||||
const siteName = ref('Sub2API')
|
||||
const siteLogo = ref('')
|
||||
@@ -74,7 +75,7 @@ onMounted(async () => {
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
siteName.value = settings.site_name || 'Sub2API'
|
||||
siteLogo.value = settings.site_logo || ''
|
||||
siteLogo.value = sanitizeUrl(settings.site_logo || '', { allowRelative: true })
|
||||
siteSubtitle.value = settings.site_subtitle || 'Subscription to API Conversion Platform'
|
||||
} catch (error) {
|
||||
console.error('Failed to load public settings:', error)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
{{ t('admin.users.attributes.description') }}
|
||||
</p>
|
||||
<button @click="openCreateModal" class="btn btn-primary btn-sm">
|
||||
<svg class="mr-1.5 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<Icon name="plus" size="sm" class="mr-1.5" :stroke-width="2" />
|
||||
{{ t('admin.users.attributes.addAttribute') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -45,9 +43,7 @@
|
||||
>
|
||||
<!-- Drag Handle -->
|
||||
<div class="cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" :title="t('admin.users.attributes.dragToReorder')">
|
||||
<svg class="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="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
<Icon name="menu" size="md" />
|
||||
</div>
|
||||
|
||||
<!-- Attribute Info -->
|
||||
@@ -77,18 +73,14 @@
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-primary-600 dark:hover:bg-dark-700 dark:hover:text-primary-400"
|
||||
:title="t('common.edit')"
|
||||
>
|
||||
<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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
</svg>
|
||||
<Icon name="edit" size="sm" />
|
||||
</button>
|
||||
<button
|
||||
@click="confirmDelete(attr)"
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
:title="t('common.delete')"
|
||||
>
|
||||
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
</svg>
|
||||
<Icon name="trash" size="sm" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -142,11 +134,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) -->
|
||||
@@ -172,15 +163,11 @@
|
||||
@click="removeOption(index)"
|
||||
class="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<Icon name="x" size="sm" :stroke-width="2" />
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" @click="addOption" class="btn btn-secondary btn-sm">
|
||||
<svg class="mr-1 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<Icon name="plus" size="sm" class="mr-1" :stroke-width="2" />
|
||||
{{ t('admin.users.attributes.addOption') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -257,6 +244,8 @@ 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 Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -344,6 +333,18 @@ const removeOption = (index: number) => {
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.key.trim()) {
|
||||
appStore.showError(t('admin.users.attributes.keyRequired'))
|
||||
return
|
||||
}
|
||||
if (!form.name.trim()) {
|
||||
appStore.showError(t('admin.users.attributes.nameRequired'))
|
||||
return
|
||||
}
|
||||
if ((form.type === 'select' || form.type === 'multi_select') && form.options.length === 0) {
|
||||
appStore.showError(t('admin.users.attributes.optionsRequired'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
|
||||
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,61 @@
|
||||
<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">
|
||||
<Icon name="key" size="lg" class="text-primary-600 dark:text-primary-400" />
|
||||
</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>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size="md"
|
||||
class="text-gray-400 transition-colors group-hover:text-primary-500 dark:text-dark-500"
|
||||
/>
|
||||
</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">
|
||||
<Icon name="chart" size="lg" class="text-emerald-600 dark:text-emerald-400" />
|
||||
</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>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size="md"
|
||||
class="text-gray-400 transition-colors group-hover:text-emerald-500 dark:text-dark-500"
|
||||
/>
|
||||
</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">
|
||||
<Icon name="gift" size="lg" class="text-amber-600 dark:text-amber-400" />
|
||||
</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>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size="md"
|
||||
class="text-gray-400 transition-colors group-hover:text-amber-500 dark:text-dark-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
@@ -0,0 +1,57 @@
|
||||
<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">
|
||||
<Icon name="beaker" size="md" class="text-primary-600 dark:text-primary-400" />
|
||||
</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') }}
|
||||
<Icon name="arrowRight" size="sm" />
|
||||
</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 Icon from '@/components/icons/Icon.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>
|
||||
162
frontend/src/components/user/dashboard/UserDashboardStats.vue
Normal file
162
frontend/src/components/user/dashboard/UserDashboardStats.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<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">
|
||||
<Icon name="key" size="md" class="text-blue-600 dark:text-blue-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="chart" size="md" class="text-green-600 dark:text-green-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="dollar" size="md" class="text-purple-600 dark:text-purple-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="cube" size="md" class="text-amber-600 dark:text-amber-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="database" size="md" class="text-indigo-600 dark:text-indigo-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="bolt" size="md" class="text-violet-600 dark:text-violet-400" :stroke-width="2" />
|
||||
</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">
|
||||
<Icon name="clock" size="md" class="text-rose-600 dark:text-rose-400" :stroke-width="2" />
|
||||
</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 Icon from '@/components/icons/Icon.vue'
|
||||
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) => {
|
||||
if (t >= 1_000_000) return `${(t / 1_000_000).toFixed(1)}M`
|
||||
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`
|
||||
return 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>
|
||||
58
frontend/src/components/user/profile/ProfileInfoCard.vue
Normal file
58
frontend/src/components/user/profile/ProfileInfoCard.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<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">
|
||||
<Icon name="mail" size="sm" class="text-gray-400 dark:text-gray-500" />
|
||||
<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"
|
||||
>
|
||||
<Icon name="user" size="sm" class="text-gray-400 dark:text-gray-500" />
|
||||
<span class="truncate">{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
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>
|
||||
Reference in New Issue
Block a user