Commit 429f38d0 authored by shaw's avatar shaw
Browse files

Merge PR #37: Add Gemini OAuth and Messages Compat Support

parents 2d89f366 2714be99
......@@ -2,9 +2,9 @@
<div>
<!-- Loading state -->
<div v-if="loading" class="space-y-0.5">
<div class="h-3 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-12 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-10 animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<!-- Error state -->
......@@ -17,24 +17,28 @@
<!-- Requests -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Req:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatNumber(stats.requests) }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatNumber(stats.requests)
}}</span>
</div>
<!-- Tokens -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Tok:</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{ formatTokens(stats.tokens) }}</span>
<span class="font-medium text-gray-700 dark:text-gray-300">{{
formatTokens(stats.tokens)
}}</span>
</div>
<!-- Cost -->
<div class="flex items-center gap-1">
<span class="text-gray-500 dark:text-gray-400">Cost:</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{ formatCurrency(stats.cost) }}</span>
<span class="font-medium text-emerald-600 dark:text-emerald-400">{{
formatCurrency(stats.cost)
}}</span>
</div>
</div>
<!-- No data -->
<div v-else class="text-xs text-gray-400">
-
</div>
<div v-else class="text-xs text-gray-400">-</div>
</div>
</template>
......
<template>
<div v-if="showUsageWindows">
<!-- Anthropic OAuth and Setup Token accounts: fetch real usage data -->
<template v-if="account.platform === 'anthropic' && (account.type === 'oauth' || account.type === 'setup-token')">
<template
v-if="
account.platform === 'anthropic' &&
(account.type === 'oauth' || account.type === 'setup-token')
"
>
<!-- Loading state -->
<div v-if="loading" class="space-y-1.5">
<!-- OAuth: 3 rows, Setup Token: 1 row -->
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<template v-if="account.type === 'oauth'">
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
<div class="flex items-center gap-1">
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full animate-pulse"></div>
<div class="w-[32px] h-3 bg-gray-200 dark:bg-gray-700 rounded animate-pulse"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="h-1.5 w-8 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div class="h-3 w-[32px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
</template>
</div>
......@@ -61,9 +66,7 @@
</div>
<!-- No data yet -->
<div v-else class="text-xs text-gray-400">
-
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
<!-- OpenAI OAuth accounts: show Codex usage from extra field -->
......@@ -97,9 +100,7 @@
</div>
<!-- Non-OAuth/Setup-Token accounts -->
<div v-else class="text-xs text-gray-400">
-
</div>
<div v-else class="text-xs text-gray-400">-</div>
</template>
<script setup lang="ts">
......@@ -117,20 +118,21 @@ const error = ref<string | null>(null)
const usageInfo = ref<AccountUsageInfo | null>(null)
// Show usage windows for OAuth and Setup Token accounts
const showUsageWindows = computed(() =>
props.account.type === 'oauth' || props.account.type === 'setup-token'
const showUsageWindows = computed(
() => props.account.type === 'oauth' || props.account.type === 'setup-token'
)
// OpenAI Codex usage computed properties
const hasCodexUsage = computed(() => {
const extra = props.account.extra
return extra && (
return (
extra &&
// Check for new canonical fields first
extra.codex_5h_used_percent !== undefined ||
(extra.codex_5h_used_percent !== undefined ||
extra.codex_7d_used_percent !== undefined ||
// Fallback to legacy fields
extra.codex_primary_used_percent !== undefined ||
extra.codex_secondary_used_percent !== undefined
extra.codex_secondary_used_percent !== undefined)
)
})
......@@ -145,10 +147,16 @@ const codex5hUsedPercent = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes <= 360
) {
return extra.codex_primary_used_percent ?? null
}
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes <= 360
) {
return extra.codex_secondary_used_percent ?? null
}
......@@ -167,13 +175,19 @@ const codex5hResetAt = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes <= 360) {
if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes <= 360
) {
if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString()
}
}
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes <= 360) {
if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes <= 360
) {
if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString()
......@@ -200,10 +214,16 @@ const codex7dUsedPercent = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes >= 10000
) {
return extra.codex_primary_used_percent ?? null
}
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes >= 10000
) {
return extra.codex_secondary_used_percent ?? null
}
......@@ -222,13 +242,19 @@ const codex7dResetAt = computed(() => {
}
// Fallback: detect from legacy fields using window_minutes
if (extra.codex_primary_window_minutes !== undefined && extra.codex_primary_window_minutes >= 10000) {
if (
extra.codex_primary_window_minutes !== undefined &&
extra.codex_primary_window_minutes >= 10000
) {
if (extra.codex_primary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_primary_reset_after_seconds * 1000)
return resetTime.toISOString()
}
}
if (extra.codex_secondary_window_minutes !== undefined && extra.codex_secondary_window_minutes >= 10000) {
if (
extra.codex_secondary_window_minutes !== undefined &&
extra.codex_secondary_window_minutes >= 10000
) {
if (extra.codex_secondary_reset_after_seconds !== undefined) {
const resetTime = new Date(Date.now() + extra.codex_secondary_reset_after_seconds * 1000)
return resetTime.toISOString()
......
......@@ -2,9 +2,9 @@
<Modal :show="show" :title="t('admin.accounts.bulkEdit.title')" size="lg" @close="handleClose">
<form class="space-y-5" @submit.prevent="handleSubmit">
<!-- Info -->
<div class="rounded-lg bg-blue-50 dark:bg-blue-900/20 p-4">
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400">
<svg class="w-5 h-5 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="mr-1.5 inline h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
......@@ -17,8 +17,8 @@
</div>
<!-- Base URL (API Key only) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.baseUrl') }}</label>
<input
v-model="enableBaseUrl"
......@@ -31,7 +31,7 @@
type="text"
:disabled="!enableBaseUrl"
class="input"
:class="!enableBaseUrl && 'opacity-50 cursor-not-allowed'"
:class="!enableBaseUrl && 'cursor-not-allowed opacity-50'"
:placeholder="t('admin.accounts.bulkEdit.baseUrlPlaceholder')"
/>
<p class="input-hint">
......@@ -40,8 +40,8 @@
</div>
<!-- Model restriction -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.modelRestriction') }}</label>
<input
v-model="enableModelRestriction"
......@@ -50,21 +50,21 @@
/>
</div>
<div :class="!enableModelRestriction && 'opacity-50 pointer-events-none'">
<div :class="!enableModelRestriction && 'pointer-events-none opacity-50'">
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<div class="mb-4 flex gap-2">
<button
type="button"
:class="[
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'whitelist'
? 'bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
@click="modelRestrictionMode = 'whitelist'"
>
<svg
class="w-4 h-4 inline mr-1.5"
class="mr-1.5 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -84,12 +84,12 @@
'flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-all',
modelRestrictionMode === 'mapping'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
@click="modelRestrictionMode = 'mapping'"
>
<svg
class="w-4 h-4 inline mr-1.5"
class="mr-1.5 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -107,10 +107,10 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<div class="mb-3 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="w-4 h-4 inline mr-1"
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -127,7 +127,7 @@
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in allModels"
:key="model.value"
......@@ -158,10 +158,10 @@
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg
class="w-4 h-4 inline mr-1"
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -178,7 +178,7 @@
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
......@@ -191,7 +191,7 @@
:placeholder="t('admin.accounts.requestModel')"
/>
<svg
class="w-4 h-4 text-gray-400 flex-shrink-0"
class="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -211,10 +211,10 @@
/>
<button
type="button"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
@click="removeModelMapping(index)"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
......@@ -228,11 +228,11 @@
<button
type="button"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
@click="addModelMapping"
>
<svg
class="w-4 h-4 inline mr-1"
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -264,11 +264,11 @@
</div>
<!-- Custom error codes -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }}
</p>
</div>
......@@ -280,10 +280,10 @@
</div>
<div v-if="enableCustomErrorCodes" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-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="w-4 h-4 inline mr-1"
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -308,8 +308,8 @@
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500',
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
@click="toggleErrorCode(code.value)"
>
......@@ -329,7 +329,7 @@
@keyup.enter="addCustomErrorCode"
/>
<button type="button" class="btn btn-secondary px-3" @click="addCustomErrorCode">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
......@@ -345,7 +345,7 @@
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{ code }}
<button
......@@ -353,7 +353,7 @@
class="hover:text-red-900 dark:hover:text-red-300"
@click="removeErrorCode(code)"
>
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
......@@ -371,13 +371,13 @@
</div>
<!-- Intercept warmup requests (Anthropic only) -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="flex items-center justify-between">
<div class="flex-1 pr-4">
<label class="input-label mb-0">{{
t('admin.accounts.interceptWarmupRequests')
}}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p>
</div>
......@@ -392,14 +392,14 @@
type="button"
:class="[
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600',
interceptWarmupRequests ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
]"
@click="interceptWarmupRequests = !interceptWarmupRequests"
>
<span
:class="[
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0',
interceptWarmupRequests ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
......@@ -407,8 +407,8 @@
</div>
<!-- Proxy -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.proxy') }}</label>
<input
v-model="enableProxy"
......@@ -416,15 +416,15 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div :class="!enableProxy && 'opacity-50 pointer-events-none'">
<div :class="!enableProxy && 'pointer-events-none opacity-50'">
<ProxySelector v-model="proxyId" :proxies="proxies" />
</div>
</div>
<!-- Concurrency & Priority -->
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="grid grid-cols-2 gap-4 border-t border-gray-200 pt-4 dark:border-dark-600">
<div>
<div class="flex items-center justify-between mb-3">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model="enableConcurrency"
......@@ -438,11 +438,11 @@
min="1"
:disabled="!enableConcurrency"
class="input"
:class="!enableConcurrency && 'opacity-50 cursor-not-allowed'"
:class="!enableConcurrency && 'cursor-not-allowed opacity-50'"
/>
</div>
<div>
<div class="flex items-center justify-between mb-3">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('admin.accounts.priority') }}</label>
<input
v-model="enablePriority"
......@@ -456,14 +456,14 @@
min="1"
:disabled="!enablePriority"
class="input"
:class="!enablePriority && 'opacity-50 cursor-not-allowed'"
:class="!enablePriority && 'cursor-not-allowed opacity-50'"
/>
</div>
</div>
<!-- Status -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('common.status') }}</label>
<input
v-model="enableStatus"
......@@ -471,14 +471,14 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div :class="!enableStatus && 'opacity-50 pointer-events-none'">
<div :class="!enableStatus && 'pointer-events-none opacity-50'">
<Select v-model="status" :options="statusOptions" />
</div>
</div>
<!-- Groups -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<label class="input-label mb-0">{{ t('nav.groups') }}</label>
<input
v-model="enableGroups"
......@@ -486,7 +486,7 @@
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div :class="!enableGroups && 'opacity-50 pointer-events-none'">
<div :class="!enableGroups && 'pointer-events-none opacity-50'">
<GroupSelector v-model="groupIds" :groups="groups" />
</div>
</div>
......@@ -499,7 +499,7 @@
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
class="-ml-1 mr-2 h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
......@@ -601,7 +601,7 @@ const allModels = [
{ value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
{ value: 'gpt-5.1-2025-11-13', label: 'GPT-5.1' },
{ value: 'gpt-5.1-codex-mini', label: 'GPT-5.1 Codex Mini' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' },
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
]
// Preset mappings (combined Anthropic + OpenAI)
......@@ -610,48 +610,46 @@ const presetMappings = [
label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400',
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400',
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
},
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11',
color:
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Max->Codex',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400',
},
color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400'
}
]
// Common HTTP error codes
......@@ -662,12 +660,12 @@ const commonErrorCodes = [
{ value: 500, label: 'Server Error' },
{ value: 502, label: 'Bad Gateway' },
{ value: 503, label: 'Unavailable' },
{ value: 529, label: 'Overloaded' },
{ value: 529, label: 'Overloaded' }
]
const statusOptions = computed(() => [
{ value: 'active', label: t('common.active') },
{ value: 'inactive', label: t('common.inactive') },
{ value: 'inactive', label: t('common.inactive') }
])
// Model mapping helpers
......
<template>
<Modal
:show="show"
:title="t('admin.accounts.createAccount')"
size="lg"
@close="handleClose"
>
<Modal :show="show" :title="t('admin.accounts.createAccount')" size="lg" @close="handleClose">
<!-- Step Indicator for OAuth accounts -->
<div v-if="isOAuthFlow" class="mb-6 flex items-center justify-center">
<div class="flex items-center space-x-4">
......@@ -17,7 +12,9 @@
>
1
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.authMethod') }}</span>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{
t('admin.accounts.oauth.authMethod')
}}</span>
</div>
<div class="h-0.5 w-8 bg-gray-300 dark:bg-dark-600" />
<div class="flex items-center">
......@@ -29,7 +26,9 @@
>
2
</div>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.accounts.oauth.title') }}</span>
<span class="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">{{
oauthStepTitle
}}</span>
</div>
</div>
</div>
......@@ -50,19 +49,29 @@
<!-- Platform Selection - Segmented Control Style -->
<div>
<label class="input-label">{{ t('admin.accounts.platform') }}</label>
<div class="flex rounded-lg bg-gray-100 dark:bg-dark-700 p-1 mt-2">
<div class="mt-2 flex rounded-lg bg-gray-100 p-1 dark:bg-dark-700">
<button
type="button"
@click="form.platform = 'anthropic'"
:class="[
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'anthropic'
? 'bg-white dark:bg-dark-600 text-orange-600 dark:text-orange-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
? 'bg-white text-orange-600 shadow-sm dark:bg-dark-600 dark:text-orange-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg class="w-4 h-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
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>
Anthropic
</button>
......@@ -70,47 +79,98 @@
type="button"
@click="form.platform = 'openai'"
:class="[
'flex-1 flex items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'openai'
? 'bg-white dark:bg-dark-600 text-green-600 dark:text-green-400 shadow-sm'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
? 'bg-white text-green-600 shadow-sm dark:bg-dark-600 dark:text-green-400'
: 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200'
]"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
<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="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z"
/>
</svg>
OpenAI
</button>
<button
type="button"
@click="form.platform = 'gemini'"
:class="[
'flex flex-1 items-center justify-center gap-2 rounded-md px-4 py-2.5 text-sm font-medium transition-all',
form.platform === 'gemini'
? 'bg-white text-blue-600 shadow-sm dark:bg-dark-600 dark:text-blue-400'
: '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="M12 2l1.5 6.5L20 10l-6.5 1.5L12 18l-1.5-6.5L4 10l6.5-1.5L12 2z"
/>
</svg>
Gemini
</button>
</div>
</div>
<!-- Account Type Selection (Anthropic) -->
<div v-if="form.platform === 'anthropic'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="grid grid-cols-2 gap-3 mt-2">
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-orange-500 bg-orange-50 dark:bg-orange-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-orange-300 dark:hover:border-orange-700'
: 'border-gray-200 hover:border-orange-300 dark:border-dark-600 dark:hover:border-orange-700'
]"
>
<div :class="[
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]">
<svg class="w-4 h-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" />
: '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>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeCode') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauthSetupToken') }}</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.claudeCode')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauthSetupToken')
}}</span>
</div>
</button>
......@@ -118,25 +178,41 @@
type="button"
@click="accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-purple-300 dark:hover:border-purple-700'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div :class="[
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]">
<svg class="w-4 h-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" />
: '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>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{ t('admin.accounts.claudeConsole') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.apiKey') }}</span>
<span class="block text-sm font-medium text-gray-900 dark:text-white">{{
t('admin.accounts.claudeConsole')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.apiKey')
}}</span>
</div>
</button>
</div>
......@@ -145,25 +221,37 @@
<!-- Account Type Selection (OpenAI) -->
<div v-if="form.platform === 'openai'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="grid grid-cols-2 gap-3 mt-2">
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? 'border-green-500 bg-green-50 dark:bg-green-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-green-300 dark:hover:border-green-700'
: 'border-gray-200 hover:border-green-300 dark:border-dark-600 dark:hover:border-green-700'
]"
>
<div :class="[
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'oauth-based'
? 'bg-green-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]">
<svg class="w-4 h-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" />
: '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>
</div>
<div>
......@@ -176,20 +264,32 @@
type="button"
@click="accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 transition-all text-left',
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 dark:border-dark-600 hover:border-purple-300 dark:hover:border-purple-700'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div :class="[
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 dark:bg-dark-600 text-gray-500 dark:text-gray-400'
]">
<svg class="w-4 h-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" />
: '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>
</div>
<div>
......@@ -200,10 +300,187 @@
</div>
</div>
<!-- Account Type Selection (Gemini) -->
<div v-if="form.platform === 'gemini'">
<label class="input-label">{{ t('admin.accounts.accountType') }}</label>
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="accountCategory = 'oauth-based'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'oauth-based'
? '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',
accountCategory === 'oauth-based'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z"
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">OAuth</span>
<span class="text-xs text-gray-500 dark:text-gray-400">Google OAuth</span>
</div>
</button>
<button
type="button"
@click="accountCategory = 'apikey'"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
accountCategory === 'apikey'
? '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',
accountCategory === 'apikey'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M15.75 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 1721.75 8.25z"
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">API Key</span>
<span class="text-xs text-gray-500 dark:text-gray-400">AI Studio API Key</span>
</div>
</button>
</div>
<!-- OAuth Type Selection (only show when oauth-based is selected) -->
<div v-if="accountCategory === 'oauth-based'" class="mt-4">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z" />
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{ t('admin.accounts.oauth.gemini.needsProjectId') }}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{ t('admin.accounts.oauth.gemini.needsProjectIdDesc') }}</span>
</div>
</button>
<div class="group relative">
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex w-full items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div class="min-w-0">
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
}}</span>
</div>
<span
v-if="!geminiAIStudioOAuthEnabled"
class="ml-auto shrink-0 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>
</button>
<div
v-if="!geminiAIStudioOAuthEnabled"
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>
</div>
</div>
<!-- Add Method (only for Anthropic OAuth-based type) -->
<div v-if="form.platform === 'anthropic' && isOAuthFlow">
<label class="input-label">{{ t('admin.accounts.addMethod') }}</label>
<div class="flex gap-4 mt-2">
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
......@@ -220,7 +497,9 @@
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>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</div>
......@@ -233,7 +512,13 @@
v-model="apiKeyBaseUrl"
type="text"
class="input"
:placeholder="form.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
:placeholder="
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
......@@ -244,17 +529,29 @@
type="password"
required
class="input font-mono"
:placeholder="form.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
:placeholder="
form.platform === 'openai'
? 'sk-proj-...'
: form.platform === 'gemini'
? 'AIza...'
: 'sk-ant-...'
"
/>
<p class="input-hint">{{ t('admin.accounts.apiKeyHint') }}</p>
<p class="input-hint">
{{
form.platform === 'gemini'
? t('admin.accounts.gemini.apiKeyHint')
: t('admin.accounts.apiKeyHint')
}}
</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="form.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<div class="mb-4 flex gap-2">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
......@@ -265,8 +562,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="mr-1.5 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
......@@ -280,8 +587,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
<svg
class="mr-1.5 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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
......@@ -289,22 +606,36 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<div class="mb-3 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="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
:class="
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type="checkbox"
......@@ -318,23 +649,35 @@
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
<span v-if="allowedModels.length === 0">{{
t('admin.accounts.supportsAllModels')
}}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
......@@ -346,8 +689,18 @@
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
<svg
class="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
<input
v-model="mapping.to"
......@@ -358,10 +711,15 @@
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
......@@ -370,10 +728,20 @@
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
......@@ -385,10 +753,7 @@
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
......@@ -397,11 +762,16 @@
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div
v-if="form.platform !== 'gemini'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }}
</p>
</div>
<button
type="button"
......@@ -421,10 +791,20 @@
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-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="w-4 h-4 inline mr-1" 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
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.customErrorCodesWarning') }}
</p>
......@@ -440,7 +820,7 @@
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
......@@ -459,13 +839,14 @@
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
<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="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
......@@ -475,7 +856,7 @@
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{ code }}
<button
......@@ -483,8 +864,13 @@
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-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 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>
</button>
</span>
......@@ -494,14 +880,50 @@
</div>
</div>
</div>
<!-- Gemini 模型说明 -->
<div v-if="form.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 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 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.modelPassthrough') }}
</p>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
<div v-if="form.platform === 'anthropic'" class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div
v-if="form.platform === 'anthropic'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
<label class="input-label mb-0">{{
t('admin.accounts.interceptWarmupRequests')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p>
</div>
<button
type="button"
......@@ -523,64 +945,56 @@
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
<input v-model.number="form.priority" type="number" min="1" class="input" />
<p class="input-hint">{{ t('admin.accounts.priorityHint') }}</p>
</div>
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
:platform="form.platform"
/>
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="form.platform" />
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
type="button"
class="btn btn-secondary"
>
<button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
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>
<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>
{{ isOAuthFlow ? t('common.next') : (submitting ? t('admin.accounts.creating') : t('common.create')) }}
{{
isOAuthFlow
? t('common.next')
: submitting
? t('admin.accounts.creating')
: t('common.create')
}}
</button>
</div>
</form>
......@@ -589,26 +1003,23 @@
<div v-else class="space-y-5">
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="form.platform === 'openai' ? 'oauth' : addMethod"
:add-method="form.platform === 'anthropic' ? addMethod : 'oauth'"
:auth-url="currentAuthUrl"
:session-id="currentSessionId"
:loading="currentOAuthLoading"
:error="currentOAuthError"
:show-help="form.platform !== 'openai'"
:show-proxy-warning="!!form.proxy_id"
:allow-multiple="form.platform !== 'openai'"
:show-cookie-option="form.platform !== 'openai'"
:show-help="form.platform === 'anthropic'"
:show-proxy-warning="form.platform !== 'openai' && !!form.proxy_id"
:allow-multiple="form.platform === 'anthropic'"
:show-cookie-option="form.platform === 'anthropic'"
:platform="form.platform"
:show-project-id="geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="goBackToBasicInfo"
>
<button type="button" class="btn btn-secondary" @click="goBackToBasicInfo">
{{ t('common.back') }}
</button>
<button
......@@ -620,14 +1031,29 @@
>
<svg
v-if="currentOAuthLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4"
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>
<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>
{{ currentOAuthLoading ? t('admin.accounts.oauth.verifying') : t('admin.accounts.oauth.completeAuth') }}
{{
currentOAuthLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</div>
......@@ -639,8 +1065,13 @@ import { ref, reactive, 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 {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Proxy, Group, AccountPlatform, AccountType } from '@/types'
import Modal from '@/components/common/Modal.vue'
import ProxySelector from '@/components/common/ProxySelector.vue'
......@@ -651,6 +1082,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// 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
......@@ -658,6 +1091,12 @@ interface OAuthFlowExposed {
const { t } = useI18n()
const oauthStepTitle = computed(() => {
if (form.platform === 'openai') return t('admin.accounts.oauth.openai.title')
if (form.platform === 'gemini') return t('admin.accounts.oauth.gemini.title')
return t('admin.accounts.oauth.title')
})
interface Props {
show: boolean
proxies: Proxy[]
......@@ -675,22 +1114,31 @@ const appStore = useAppStore()
// OAuth composables
const oauth = useAccountOAuth() // For Anthropic OAuth
const openaiOAuth = useOpenAIOAuth() // For OpenAI OAuth
const geminiOAuth = useGeminiOAuth() // For Gemini OAuth
// Computed: current OAuth state for template binding
const currentAuthUrl = computed(() => {
return form.platform === 'openai' ? openaiOAuth.authUrl.value : oauth.authUrl.value
if (form.platform === 'openai') return openaiOAuth.authUrl.value
if (form.platform === 'gemini') return geminiOAuth.authUrl.value
return oauth.authUrl.value
})
const currentSessionId = computed(() => {
return form.platform === 'openai' ? openaiOAuth.sessionId.value : oauth.sessionId.value
if (form.platform === 'openai') return openaiOAuth.sessionId.value
if (form.platform === 'gemini') return geminiOAuth.sessionId.value
return oauth.sessionId.value
})
const currentOAuthLoading = computed(() => {
return form.platform === 'openai' ? openaiOAuth.loading.value : oauth.loading.value
if (form.platform === 'openai') return openaiOAuth.loading.value
if (form.platform === 'gemini') return geminiOAuth.loading.value
return oauth.loading.value
})
const currentOAuthError = computed(() => {
return form.platform === 'openai' ? openaiOAuth.error.value : oauth.error.value
if (form.platform === 'openai') return openaiOAuth.error.value
if (form.platform === 'gemini') return geminiOAuth.error.value
return oauth.error.value
})
// Refs
......@@ -716,6 +1164,8 @@ const customErrorCodesEnabled = ref(false)
const selectedErrorCodes = ref<number[]>([])
const customErrorCodeInput = ref<number | null>(null)
const interceptWarmupRequests = ref(false)
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Common models for whitelist - Anthropic
const anthropicModels = [
......@@ -740,34 +1190,143 @@ const openaiModels = [
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
]
// Common models for whitelist - Gemini
const geminiModels = [
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
]
// Computed: current models based on platform
const commonModels = computed(() => {
return form.platform === 'openai' ? openaiModels : anthropicModels
if (form.platform === 'openai') return openaiModels
if (form.platform === 'gemini') return geminiModels
return anthropicModels
})
// Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
{
label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Haiku 3.5',
from: 'claude-3-5-haiku-20241022',
to: 'claude-3-5-haiku-20241022',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'Haiku 4.5',
from: 'claude-haiku-4-5-20251001',
to: 'claude-haiku-4-5-20251001',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - OpenAI
const openaiPresetMappings = [
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Codex Max', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex-max', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Codex Mini', from: 'gpt-5.1-codex-mini', to: 'gpt-5.1-codex-mini', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'GPT-5.1 Codex',
from: 'gpt-5.1-codex',
to: 'gpt-5.1-codex',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Codex Max',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex-max',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Codex Mini',
from: 'gpt-5.1-codex-mini',
to: 'gpt-5.1-codex-mini',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Max->Codex',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - Gemini
const geminiPresetMappings = [
{
label: 'Flash',
from: 'gemini-2.0-flash',
to: 'gemini-2.0-flash',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Flash Lite',
from: 'gemini-2.0-flash-lite',
to: 'gemini-2.0-flash-lite',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: '1.5 Pro',
from: 'gemini-1.5-pro',
to: 'gemini-1.5-pro',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: '1.5 Flash',
from: 'gemini-1.5-flash',
to: 'gemini-1.5-flash',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
}
]
// Computed: current preset mappings based on platform
const presetMappings = computed(() => {
return form.platform === 'openai' ? openaiPresetMappings : anthropicPresetMappings
if (form.platform === 'openai') return openaiPresetMappings
if (form.platform === 'gemini') return geminiPresetMappings
return anthropicPresetMappings
})
// Common HTTP error codes for quick selection
......@@ -804,38 +1363,84 @@ const canExchangeCode = computed(() => {
if (form.platform === 'openai') {
return authCode.trim() && openaiOAuth.sessionId.value && !openaiOAuth.loading.value
}
if (form.platform === 'gemini') {
return authCode.trim() && geminiOAuth.sessionId.value && !geminiOAuth.loading.value
}
return authCode.trim() && oauth.sessionId.value && !oauth.loading.value
})
// Watchers
watch(() => props.show, (newVal) => {
watch(
() => props.show,
(newVal) => {
if (!newVal) {
resetForm()
}
})
}
)
// Sync form.type based on accountCategory and addMethod
watch([accountCategory, addMethod], ([category, method]) => {
watch(
[accountCategory, addMethod],
([category, method]) => {
if (category === 'oauth-based') {
form.type = method as AccountType // 'oauth' or 'setup-token'
} else {
form.type = 'apikey'
}
}, { immediate: true })
},
{ immediate: true }
)
// Reset platform-specific settings when platform changes
watch(() => form.platform, (newPlatform) => {
watch(
() => form.platform,
(newPlatform) => {
// Reset base URL based on platform
apiKeyBaseUrl.value = newPlatform === 'openai'
apiKeyBaseUrl.value =
newPlatform === 'openai'
? 'https://api.openai.com'
: newPlatform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
// Clear model-related settings
allowedModels.value = []
modelMappings.value = []
// Reset Anthropic-specific settings when switching to other platforms
if (newPlatform !== 'anthropic') {
interceptWarmupRequests.value = false
}
// Reset OAuth states
oauth.resetState()
openaiOAuth.resetState()
})
geminiOAuth.resetState()
}
)
// Gemini AI Studio OAuth availability (requires operator-configured OAuth client)
watch(
[() => props.show, () => form.platform, accountCategory],
async ([show, platform, category]) => {
if (!show || platform !== 'gemini' || category !== 'oauth-based') {
geminiAIStudioOAuthEnabled.value = false
return
}
const caps = await geminiOAuth.getCapabilities()
geminiAIStudioOAuthEnabled.value = !!caps?.ai_studio_oauth_enabled
if (!geminiAIStudioOAuthEnabled.value && geminiOAuthType.value === 'ai_studio') {
geminiOAuthType.value = 'code_assist'
}
},
{ immediate: true }
)
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
}
geminiOAuthType.value = oauthType
}
// Model mapping helpers
const addModelMapping = () => {
......@@ -848,7 +1453,7 @@ const removeModelMapping = (index: number) => {
const addPresetMapping = (from: string, to: string) => {
// Check if mapping already exists
const exists = modelMappings.value.some(m => m.from === from)
const exists = modelMappings.value.some((m) => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
......@@ -933,8 +1538,10 @@ const resetForm = () => {
selectedErrorCodes.value = []
customErrorCodeInput.value = null
interceptWarmupRequests.value = false
geminiOAuthType.value = 'code_assist'
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
oauthFlowRef.value?.reset()
}
......@@ -960,8 +1567,11 @@ const handleSubmit = async () => {
}
// Determine default base URL based on platform
const defaultBaseUrl = form.platform === 'openai'
const defaultBaseUrl =
form.platform === 'openai'
? 'https://api.openai.com'
: form.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
// Build credentials with optional model mapping
......@@ -1009,12 +1619,15 @@ const goBackToBasicInfo = () => {
step.value = 1
oauth.resetState()
openaiOAuth.resetState()
geminiOAuth.resetState()
oauthFlowRef.value?.reset()
}
const handleGenerateUrl = async () => {
if (form.platform === 'openai') {
await openaiOAuth.generateAuthUrl(form.proxy_id)
} else if (form.platform === 'gemini') {
await geminiOAuth.generateAuthUrl(form.proxy_id, oauthFlowRef.value?.projectId, geminiOAuthType.value)
} else {
await oauth.generateAuthUrl(addMethod.value, form.proxy_id)
}
......@@ -1044,10 +1657,7 @@ const handleExchangeCode = async () => {
const credentials = openaiOAuth.buildCredentials(tokenInfo)
const extra = openaiOAuth.buildExtraInfo(tokenInfo)
// Merge interceptWarmupRequests into credentials
if (interceptWarmupRequests.value) {
credentials.intercept_warmup_requests = true
}
// Note: intercept_warmup_requests is Anthropic-only, not applicable to OpenAI
await adminAPI.accounts.create({
name: form.name,
......@@ -1073,6 +1683,58 @@ const handleExchangeCode = async () => {
return
}
// For Gemini
if (form.platform === 'gemini') {
if (!authCode.trim() || !geminiOAuth.sessionId.value) return
geminiOAuth.loading.value = true
geminiOAuth.error.value = ''
try {
const stateFromInput = oauthFlowRef.value?.oauthState || ''
const stateToUse = stateFromInput || geminiOAuth.state.value
if (!stateToUse) {
geminiOAuth.error.value = t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
return
}
const tokenInfo = await geminiOAuth.exchangeAuthCode({
code: authCode.trim(),
sessionId: geminiOAuth.sessionId.value,
state: stateToUse,
proxyId: form.proxy_id,
oauthType: geminiOAuthType.value
})
if (!tokenInfo) return
const credentials = geminiOAuth.buildCredentials(tokenInfo)
// Note: intercept_warmup_requests is Anthropic-only, not applicable to Gemini
await adminAPI.accounts.create({
name: form.name,
platform: 'gemini',
type: 'oauth',
credentials,
proxy_id: form.proxy_id,
concurrency: form.concurrency,
priority: form.priority,
group_ids: form.group_ids
})
appStore.showSuccess(t('admin.accounts.accountCreated'))
emit('created')
handleClose()
} catch (error: any) {
geminiOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
appStore.showError(geminiOAuth.error.value)
} finally {
geminiOAuth.loading.value = false
}
return
}
// For Anthropic
if (!authCode.trim() || !oauth.sessionId.value) return
......@@ -1081,7 +1743,8 @@ const handleExchangeCode = async () => {
try {
const proxyConfig = form.proxy_id ? { proxy_id: form.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
......@@ -1135,7 +1798,8 @@ const handleCookieAuth = async (sessionKey: string) => {
return
}
const endpoint = addMethod.value === 'oauth'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
......@@ -1174,7 +1838,12 @@ const handleCookieAuth = async (sessionKey: string) => {
successCount++
} catch (error: any) {
failedCount++
errors.push(t('admin.accounts.oauth.keyAuthFailed', { index: i + 1, error: error.response?.data?.detail || t('admin.accounts.oauth.authFailed') }))
errors.push(
t('admin.accounts.oauth.keyAuthFailed', {
index: i + 1,
error: error.response?.data?.detail || t('admin.accounts.oauth.authFailed')
})
)
}
}
......
<template>
<Modal
:show="show"
:title="t('admin.accounts.editAccount')"
size="lg"
@close="handleClose"
>
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose">
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
<div>
<label class="input-label">{{ t('common.name') }}</label>
<input
v-model="form.name"
type="text"
required
class="input"
/>
<input v-model="form.name" type="text" required class="input" />
</div>
<!-- API Key fields (only for apikey type) -->
......@@ -24,7 +14,13 @@
v-model="editBaseUrl"
type="text"
class="input"
:placeholder="account.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'"
:placeholder="
account.platform === 'openai'
? 'https://api.openai.com'
: account.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
"
/>
<p class="input-hint">{{ t('admin.accounts.baseUrlHint') }}</p>
</div>
......@@ -34,17 +30,23 @@
v-model="editApiKey"
type="password"
class="input font-mono"
:placeholder="account.platform === 'openai' ? 'sk-proj-...' : 'sk-ant-...'"
:placeholder="
account.platform === 'openai'
? 'sk-proj-...'
: account.platform === 'gemini'
? 'AIza...'
: 'sk-ant-...'
"
/>
<p class="input-hint">{{ t('admin.accounts.leaveEmptyToKeep') }}</p>
</div>
<!-- Model Restriction Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<!-- Model Restriction Section (不适用于 Gemini) -->
<div v-if="account.platform !== 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<label class="input-label">{{ t('admin.accounts.modelRestriction') }}</label>
<!-- Mode Toggle -->
<div class="flex gap-2 mb-4">
<div class="mb-4 flex gap-2">
<button
type="button"
@click="modelRestrictionMode = 'whitelist'"
......@@ -55,8 +57,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
<svg
class="mr-1.5 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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.modelWhitelist') }}
</button>
......@@ -70,8 +82,18 @@
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
<svg class="w-4 h-4 inline mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
<svg
class="mr-1.5 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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"
/>
</svg>
{{ t('admin.accounts.modelMapping') }}
</button>
......@@ -79,22 +101,36 @@
<!-- Whitelist Mode -->
<div v-if="modelRestrictionMode === 'whitelist'">
<div class="mb-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 p-3">
<div class="mb-3 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="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.selectAllowedModels') }}
</p>
</div>
<!-- Model Checkbox List -->
<div class="grid grid-cols-2 gap-2 mb-3">
<div class="mb-3 grid grid-cols-2 gap-2">
<label
v-for="model in commonModels"
:key="model.value"
class="flex cursor-pointer items-center rounded-lg border p-3 transition-all hover:bg-gray-50 dark:border-dark-600 dark:hover:bg-dark-700"
:class="allowedModels.includes(model.value) ? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20' : 'border-gray-200'"
:class="
allowedModels.includes(model.value)
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-gray-200'
"
>
<input
type="checkbox"
......@@ -108,23 +144,35 @@
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.selectedModels', { count: allowedModels.length }) }}
<span v-if="allowedModels.length === 0">{{ t('admin.accounts.supportsAllModels') }}</span>
<span v-if="allowedModels.length === 0">{{
t('admin.accounts.supportsAllModels')
}}</span>
</p>
</div>
<!-- Mapping Mode -->
<div v-else>
<div class="mb-3 rounded-lg bg-purple-50 dark:bg-purple-900/20 p-3">
<div class="mb-3 rounded-lg bg-purple-50 p-3 dark:bg-purple-900/20">
<p class="text-xs text-purple-700 dark:text-purple-400">
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{{ t('admin.accounts.mapRequestModels') }}
</p>
</div>
<!-- Model Mapping List -->
<div v-if="modelMappings.length > 0" class="space-y-2 mb-3">
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
<div
v-for="(mapping, index) in modelMappings"
:key="index"
......@@ -136,8 +184,18 @@
class="input flex-1"
:placeholder="t('admin.accounts.requestModel')"
/>
<svg class="w-4 h-4 text-gray-400 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 5l7 7m0 0l-7 7m7-7H3" />
<svg
class="h-4 w-4 flex-shrink-0 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M14 5l7 7m0 0l-7 7m7-7H3"
/>
</svg>
<input
v-model="mapping.to"
......@@ -148,10 +206,15 @@
<button
type="button"
@click="removeModelMapping(index)"
class="p-2 text-red-500 hover:text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
class="rounded-lg p-2 text-red-500 transition-colors hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
......@@ -160,10 +223,20 @@
<button
type="button"
@click="addModelMapping"
class="w-full rounded-lg border-2 border-dashed border-gray-300 dark:border-dark-500 px-4 py-2 text-gray-600 dark:text-gray-400 transition-colors hover:border-gray-400 hover:text-gray-700 dark:hover:border-dark-400 dark:hover:text-gray-300 mb-3"
class="mb-3 w-full rounded-lg border-2 border-dashed border-gray-300 px-4 py-2 text-gray-600 transition-colors hover:border-gray-400 hover:text-gray-700 dark:border-dark-500 dark:text-gray-400 dark:hover:border-dark-400 dark:hover:text-gray-300"
>
<svg
class="mr-1 inline h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<svg class="w-4 h-4 inline mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4v16m8-8H4"
/>
</svg>
{{ t('admin.accounts.addMapping') }}
</button>
......@@ -175,10 +248,7 @@
:key="preset.label"
type="button"
@click="addPresetMapping(preset.from, preset.to)"
:class="[
'rounded-lg px-3 py-1 text-xs transition-colors',
preset.color
]"
:class="['rounded-lg px-3 py-1 text-xs transition-colors', preset.color]"
>
+ {{ preset.label }}
</button>
......@@ -187,11 +257,13 @@
</div>
<!-- Custom Error Codes Section -->
<div class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div class="flex items-center justify-between mb-3">
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="mb-3 flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.customErrorCodes') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.customErrorCodesHint') }}</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.customErrorCodesHint') }}
</p>
</div>
<button
type="button"
......@@ -211,10 +283,20 @@
</div>
<div v-if="customErrorCodesEnabled" class="space-y-3">
<div class="rounded-lg bg-amber-50 dark:bg-amber-900/20 p-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="w-4 h-4 inline mr-1" 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
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.customErrorCodesWarning') }}
</p>
......@@ -230,7 +312,7 @@
:class="[
'rounded-lg px-3 py-1.5 text-sm font-medium transition-colors',
selectedErrorCodes.includes(code.value)
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400 ring-1 ring-red-500'
? 'bg-red-100 text-red-700 ring-1 ring-red-500 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-dark-600 dark:text-gray-400 dark:hover:bg-dark-500'
]"
>
......@@ -249,13 +331,14 @@
:placeholder="t('admin.accounts.enterErrorCode')"
@keyup.enter="addCustomErrorCode"
/>
<button
type="button"
@click="addCustomErrorCode"
class="btn btn-secondary px-3"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
<button type="button" @click="addCustomErrorCode" class="btn btn-secondary px-3">
<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="M12 4v16m8-8H4"
/>
</svg>
</button>
</div>
......@@ -265,7 +348,7 @@
<span
v-for="code in selectedErrorCodes.sort((a, b) => a - b)"
:key="code"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:text-red-400"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2.5 py-0.5 text-sm font-medium text-red-700 dark:bg-red-900/30 dark:text-red-400"
>
{{ code }}
<button
......@@ -273,8 +356,13 @@
@click="removeErrorCode(code)"
class="hover:text-red-900 dark:hover:text-red-300"
>
<svg class="w-3.5 h-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 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>
</button>
</span>
......@@ -284,14 +372,50 @@
</div>
</div>
</div>
<!-- Gemini 模型说明 -->
<div v-if="account.platform === 'gemini'" class="border-t border-gray-200 pt-4 dark:border-dark-600">
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-start gap-3">
<svg
class="h-5 w-5 flex-shrink-0 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 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p class="text-sm font-medium text-blue-800 dark:text-blue-300">
{{ t('admin.accounts.gemini.modelPassthrough') }}
</p>
<p class="mt-1 text-xs text-blue-700 dark:text-blue-400">
{{ t('admin.accounts.gemini.modelPassthroughDesc') }}
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Intercept Warmup Requests (Anthropic only) -->
<div v-if="account?.platform === 'anthropic'" class="border-t border-gray-200 dark:border-dark-600 pt-4">
<div
v-if="account?.platform === 'anthropic'"
class="border-t border-gray-200 pt-4 dark:border-dark-600"
>
<div class="flex items-center justify-between">
<div>
<label class="input-label mb-0">{{ t('admin.accounts.interceptWarmupRequests') }}</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ t('admin.accounts.interceptWarmupRequestsDesc') }}</p>
<label class="input-label mb-0">{{
t('admin.accounts.interceptWarmupRequests')
}}</label>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.interceptWarmupRequestsDesc') }}
</p>
</div>
<button
type="button"
......@@ -313,69 +437,52 @@
<div>
<label class="input-label">{{ t('admin.accounts.proxy') }}</label>
<ProxySelector
v-model="form.proxy_id"
:proxies="proxies"
/>
<ProxySelector v-model="form.proxy_id" :proxies="proxies" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.concurrency') }}</label>
<input
v-model.number="form.concurrency"
type="number"
min="1"
class="input"
/>
<input v-model.number="form.concurrency" type="number" min="1" class="input" />
</div>
<div>
<label class="input-label">{{ t('admin.accounts.priority') }}</label>
<input
v-model.number="form.priority"
type="number"
min="1"
class="input"
/>
<input v-model.number="form.priority" type="number" min="1" class="input" />
</div>
</div>
<div>
<label class="input-label">{{ t('common.status') }}</label>
<Select
v-model="form.status"
:options="statusOptions"
/>
<Select v-model="form.status" :options="statusOptions" />
</div>
<!-- Group Selection -->
<GroupSelector
v-model="form.group_ids"
:groups="groups"
:platform="account?.platform"
/>
<GroupSelector v-model="form.group_ids" :groups="groups" :platform="account?.platform" />
<div class="flex justify-end gap-3 pt-4">
<button
@click="handleClose"
type="button"
class="btn btn-secondary"
>
<button @click="handleClose" type="button" class="btn btn-secondary">
{{ t('common.cancel') }}
</button>
<button
type="submit"
:disabled="submitting"
class="btn btn-primary"
>
<button type="submit" :disabled="submitting" class="btn btn-primary">
<svg
v-if="submitting"
class="animate-spin -ml-1 mr-2 h-4 w-4"
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>
<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>
{{ submitting ? t('admin.accounts.updating') : t('common.update') }}
</button>
......@@ -452,39 +559,150 @@ const openaiModels = [
{ value: 'gpt-5-2025-08-07', label: 'GPT-5' }
]
// Common models for whitelist - Gemini
const geminiModels = [
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
{ value: 'gemini-1.5-pro', label: 'Gemini 1.5 Pro' },
{ value: 'gemini-1.5-flash', label: 'Gemini 1.5 Flash' }
]
// Computed: current models based on platform
const commonModels = computed(() => {
return props.account?.platform === 'openai' ? openaiModels : anthropicModels
if (props.account?.platform === 'openai') return openaiModels
if (props.account?.platform === 'gemini') return geminiModels
return anthropicModels
})
// Preset mappings for quick add - Anthropic
const anthropicPresetMappings = [
{ label: 'Sonnet 4', from: 'claude-sonnet-4-20250514', to: 'claude-sonnet-4-20250514', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'Sonnet 4.5', from: 'claude-sonnet-4-5-20250929', to: 'claude-sonnet-4-5-20250929', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-20251101', to: 'claude-opus-4-5-20251101', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Opus->Sonnet', from: 'claude-opus-4-5-20251101', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
{
label: 'Sonnet 4',
from: 'claude-sonnet-4-20250514',
to: 'claude-sonnet-4-20250514',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Sonnet 4.5',
from: 'claude-sonnet-4-5-20250929',
to: 'claude-sonnet-4-5-20250929',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Opus 4.5',
from: 'claude-opus-4-5-20251101',
to: 'claude-opus-4-5-20251101',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Haiku 3.5',
from: 'claude-3-5-haiku-20241022',
to: 'claude-3-5-haiku-20241022',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'Haiku 4.5',
from: 'claude-haiku-4-5-20251001',
to: 'claude-haiku-4-5-20251001',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Opus->Sonnet',
from: 'claude-opus-4-5-20251101',
to: 'claude-sonnet-4-5-20250929',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - OpenAI
const openaiPresetMappings = [
{ label: 'GPT-5.2', from: 'gpt-5.2-2025-12-11', to: 'gpt-5.2-2025-12-11', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'GPT-5.2 Codex', from: 'gpt-5.2-codex', to: 'gpt-5.2-codex', color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400' },
{ label: 'GPT-5.1 Codex', from: 'gpt-5.1-codex', to: 'gpt-5.1-codex', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Codex Max', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex-max', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Codex Mini', from: 'gpt-5.1-codex-mini', to: 'gpt-5.1-codex-mini', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Max->Codex', from: 'gpt-5.1-codex-max', to: 'gpt-5.1-codex', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
{
label: 'GPT-5.2',
from: 'gpt-5.2-2025-12-11',
to: 'gpt-5.2-2025-12-11',
color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400'
},
{
label: 'GPT-5.2 Codex',
from: 'gpt-5.2-codex',
to: 'gpt-5.2-codex',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'GPT-5.1 Codex',
from: 'gpt-5.1-codex',
to: 'gpt-5.1-codex',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: 'Codex Max',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex-max',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: 'Codex Mini',
from: 'gpt-5.1-codex-mini',
to: 'gpt-5.1-codex-mini',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
},
{
label: 'Max->Codex',
from: 'gpt-5.1-codex-max',
to: 'gpt-5.1-codex',
color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400'
}
]
// Preset mappings for quick add - Gemini
const geminiPresetMappings = [
{
label: 'Flash',
from: 'gemini-2.0-flash',
to: 'gemini-2.0-flash',
color: 'bg-blue-100 text-blue-700 hover:bg-blue-200 dark:bg-blue-900/30 dark:text-blue-400'
},
{
label: 'Flash Lite',
from: 'gemini-2.0-flash-lite',
to: 'gemini-2.0-flash-lite',
color:
'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400'
},
{
label: '1.5 Pro',
from: 'gemini-1.5-pro',
to: 'gemini-1.5-pro',
color:
'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400'
},
{
label: '1.5 Flash',
from: 'gemini-1.5-flash',
to: 'gemini-1.5-flash',
color:
'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400'
}
]
// Computed: current preset mappings based on platform
const presetMappings = computed(() => {
return props.account?.platform === 'openai' ? openaiPresetMappings : anthropicPresetMappings
if (props.account?.platform === 'openai') return openaiPresetMappings
if (props.account?.platform === 'gemini') return geminiPresetMappings
return anthropicPresetMappings
})
// Computed: default base URL based on platform
const defaultBaseUrl = computed(() => {
return props.account?.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
if (props.account?.platform === 'openai') return 'https://api.openai.com'
if (props.account?.platform === 'gemini') return 'https://generativelanguage.googleapis.com'
return 'https://api.anthropic.com'
})
// Common HTTP error codes for quick selection
......@@ -513,7 +731,9 @@ const statusOptions = computed(() => [
])
// Watchers
watch(() => props.account, (newAccount) => {
watch(
() => props.account,
(newAccount) => {
if (newAccount) {
form.name = newAccount.name
form.proxy_id = newAccount.proxy_id
......@@ -529,8 +749,13 @@ watch(() => props.account, (newAccount) => {
// Initialize API Key fields for apikey type
if (newAccount.type === 'apikey' && newAccount.credentials) {
const credentials = newAccount.credentials as Record<string, unknown>
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
editBaseUrl.value = credentials.base_url as string || platformDefaultUrl
const platformDefaultUrl =
newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
editBaseUrl.value = (credentials.base_url as string) || platformDefaultUrl
// Load model mappings and detect mode
const existingMappings = credentials.model_mapping as Record<string, string> | undefined
......@@ -567,7 +792,12 @@ watch(() => props.account, (newAccount) => {
selectedErrorCodes.value = []
}
} else {
const platformDefaultUrl = newAccount.platform === 'openai' ? 'https://api.openai.com' : 'https://api.anthropic.com'
const platformDefaultUrl =
newAccount.platform === 'openai'
? 'https://api.openai.com'
: newAccount.platform === 'gemini'
? 'https://generativelanguage.googleapis.com'
: 'https://api.anthropic.com'
editBaseUrl.value = platformDefaultUrl
modelRestrictionMode.value = 'whitelist'
modelMappings.value = []
......@@ -577,7 +807,9 @@ watch(() => props.account, (newAccount) => {
}
editApiKey.value = ''
}
}, { immediate: true })
},
{ immediate: true }
)
// Model mapping helpers
const addModelMapping = () => {
......@@ -589,7 +821,7 @@ const removeModelMapping = (index: number) => {
}
const addPresetMapping = (from: string, to: string) => {
const exists = modelMappings.value.some(m => m.from === from)
const exists = modelMappings.value.some((m) => m.from === from)
if (exists) {
appStore.showInfo(t('admin.accounts.mappingExists', { model: from }))
return
......@@ -666,7 +898,7 @@ const handleSubmit = async () => {
// For apikey type, handle credentials update
if (props.account.type === 'apikey') {
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newBaseUrl = editBaseUrl.value.trim() || defaultBaseUrl.value
const modelMapping = buildModelMappingObject()
......@@ -707,7 +939,7 @@ const handleSubmit = async () => {
updatePayload.credentials = newCredentials
} else {
// For oauth/setup-token types, only update intercept_warmup_requests if changed
const currentCredentials = props.account.credentials as Record<string, unknown> || {}
const currentCredentials = (props.account.credentials as Record<string, unknown>) || {}
const newCredentials: Record<string, unknown> = { ...currentCredentials }
if (interceptWarmupRequests.value) {
......
<template>
<div class="rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-700 dark:bg-blue-900/30 p-6">
<div
class="rounded-lg border border-blue-200 bg-blue-50 p-6 dark:border-blue-700 dark:bg-blue-900/30"
>
<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="w-5 h-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
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>
</div>
<div class="flex-1">
......@@ -22,7 +34,9 @@
value="manual"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.manualAuth') }}</span>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.manualAuth')
}}</span>
</label>
<label class="flex cursor-pointer items-center gap-2">
<input
......@@ -31,23 +45,39 @@
value="cookie"
class="text-blue-600 focus:ring-blue-500"
/>
<span class="text-sm text-blue-900 dark:text-blue-200">{{ t('admin.accounts.oauth.cookieAutoAuth') }}</span>
<span class="text-sm text-blue-900 dark:text-blue-200">{{
t('admin.accounts.oauth.cookieAutoAuth')
}}</span>
</label>
</div>
</div>
<!-- Cookie Auto-Auth Form -->
<div v-if="inputMethod === 'cookie'" class="space-y-4">
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300">
{{ t('admin.accounts.oauth.cookieAutoAuthDesc') }}
</p>
<!-- sessionKey Input -->
<div class="mb-4">
<label class="mb-2 flex items-center gap-2 text-sm font-semibold text-gray-700 dark:text-gray-300">
<svg class="w-4 h-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" />
<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>
{{ t('admin.accounts.oauth.sessionKey') }}
<span
......@@ -62,16 +92,30 @@
class="text-blue-500 hover:text-blue-600"
@click="showHelpDialog = !showHelpDialog"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
<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.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg>
</button>
</label>
<textarea
v-model="sessionKeyInput"
rows="3"
class="input w-full font-mono text-sm resize-y"
:placeholder="allowMultiple ? t('admin.accounts.oauth.sessionKeyPlaceholder') : t('admin.accounts.oauth.sessionKeyPlaceholderSingle')"
class="input w-full resize-y font-mono text-sm"
:placeholder="
allowMultiple
? t('admin.accounts.oauth.sessionKeyPlaceholder')
: t('admin.accounts.oauth.sessionKeyPlaceholderSingle')
"
></textarea>
<p
v-if="parsedKeyCount > 1 && allowMultiple"
......@@ -84,12 +128,14 @@
<!-- Help Section -->
<div
v-if="showHelpDialog && showHelp"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-700 dark:bg-amber-900/30 p-3"
class="mb-4 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<h5 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
{{ t('admin.accounts.oauth.howToGetSessionKey') }}
</h5>
<ol class="list-inside list-decimal space-y-1 text-xs text-amber-700 dark:text-amber-300">
<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>
......@@ -97,15 +143,18 @@
<li v-html="t('admin.accounts.oauth.step5')"></li>
<li v-html="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')"></p>
<p
class="mt-2 text-xs text-amber-600 dark:text-amber-400"
v-html="t('admin.accounts.oauth.sessionKeyFormat')"
></p>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mb-4 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
......@@ -117,14 +166,45 @@
:disabled="loading || !sessionKeyInput.trim()"
@click="handleCookieAuth"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" 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
v-if="loading"
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>
<svg v-else class="w-4 h-4 mr-2" 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
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>
{{ loading ? t('admin.accounts.oauth.authorizing') : t('admin.accounts.oauth.startAutoAuth') }}
{{
loading
? t('admin.accounts.oauth.authorizing')
: t('admin.accounts.oauth.startAutoAuth')
}}
</button>
</div>
</div>
......@@ -136,15 +216,44 @@
</p>
<!-- Step 1: Generate Auth URL -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
1
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep1GenerateUrl }}
</p>
<div v-if="showProjectId && platform === 'gemini'" class="mb-3">
<label class="input-label flex items-center gap-2">
{{ t('admin.accounts.oauth.gemini.projectIdLabel') }}
<a
href="https://console.cloud.google.com/"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-xs font-normal text-blue-500 hover:text-blue-600 dark:text-blue-400"
>
<svg class="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="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
</svg>
{{ t('admin.accounts.oauth.gemini.howToGetProjectId') }}
</a>
</label>
<input
v-model="projectId"
type="text"
class="input w-full font-mono text-sm"
:placeholder="t('admin.accounts.oauth.gemini.projectIdPlaceholder')"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{{ t('admin.accounts.oauth.gemini.projectIdHint') }}
</p>
</div>
<button
v-if="!authUrl"
type="button"
......@@ -152,12 +261,39 @@
class="btn btn-primary text-sm"
@click="handleGenerateUrl"
>
<svg v-if="loading" class="animate-spin -ml-1 mr-2 h-4 w-4" 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
v-if="loading"
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>
<svg v-else class="w-4 h-4 mr-2" 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
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>
{{ loading ? t('admin.accounts.oauth.generating') : oauthGenerateAuthUrl }}
</button>
......@@ -167,7 +303,7 @@
:value="authUrl"
readonly
type="text"
class="input flex-1 bg-gray-50 dark:bg-gray-700 font-mono text-xs"
class="input flex-1 bg-gray-50 font-mono text-xs dark:bg-gray-700"
/>
<button
type="button"
......@@ -175,11 +311,33 @@
title="Copy URL"
@click="handleCopyUrl"
>
<svg v-if="!copied" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" 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
v-if="!copied"
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.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 v-else class="w-4 h-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
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>
</button>
</div>
......@@ -188,8 +346,18 @@
class="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-400"
@click="handleRegenerate"
>
<svg class="w-3 h-3 inline mr-1" 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
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>
{{ t('admin.accounts.oauth.regenerate') }}
</button>
......@@ -199,9 +367,13 @@
</div>
<!-- Step 2: Open URL and authorize -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
2
</div>
<div class="flex-1">
......@@ -212,58 +384,120 @@
{{ oauthOpenUrlDesc }}
</p>
<!-- OpenAI Important Notice -->
<div v-if="isOpenAI" class="mt-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/30 p-3">
<p class="text-xs text-amber-800 dark:text-amber-300" v-html="oauthImportantNotice">
</p>
<div
v-if="isOpenAI"
class="mt-2 rounded border border-amber-300 bg-amber-50 p-3 dark:border-amber-700 dark:bg-amber-900/30"
>
<p
class="text-xs text-amber-800 dark:text-amber-300"
v-html="oauthImportantNotice"
></p>
</div>
<!-- Proxy Warning (for non-OpenAI) -->
<div v-else-if="showProxyWarning" class="mt-2 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/30 p-3">
<p class="text-xs text-yellow-800 dark:text-yellow-300" v-html="t('admin.accounts.oauth.proxyWarning')">
</p>
<div
v-else-if="showProxyWarning"
class="mt-2 rounded border border-yellow-300 bg-yellow-50 p-3 dark:border-yellow-700 dark:bg-yellow-900/30"
>
<p
class="text-xs text-yellow-800 dark:text-yellow-300"
v-html="t('admin.accounts.oauth.proxyWarning')"
></p>
</div>
</div>
</div>
</div>
<!-- Step 3: Enter authorization code -->
<div class="rounded-lg border border-blue-300 dark:border-blue-600 bg-white/80 dark:bg-gray-800/80 p-4">
<div
class="rounded-lg border border-blue-300 bg-white/80 p-4 dark:border-blue-600 dark:bg-gray-800/80"
>
<div class="flex items-start gap-3">
<div class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white">
<div
class="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-blue-600 text-xs font-bold text-white"
>
3
</div>
<div class="flex-1">
<p class="mb-2 font-medium text-blue-900 dark:text-blue-200">
{{ oauthStep3EnterCode }}
</p>
<p class="mb-3 text-sm text-blue-700 dark:text-blue-300" v-html="oauthAuthCodeDesc">
</p>
<p
class="mb-3 text-sm text-blue-700 dark:text-blue-300"
v-html="oauthAuthCodeDesc"
></p>
<div>
<label class="input-label">
<svg class="w-4 h-4 inline mr-1 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
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>
{{ oauthAuthCode }}
</label>
<textarea
v-model="authCodeInput"
rows="3"
class="input w-full font-mono text-sm resize-none"
class="input w-full resize-none font-mono text-sm"
:placeholder="oauthAuthCodePlaceholder"
></textarea>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
<svg class="w-3 h-3 inline mr-1" 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
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>
{{ oauthAuthCodeHint }}
</p>
<!-- Gemini-specific state parameter warning -->
<div
v-if="platform === 'gemini'"
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>
<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>
</div>
</div>
</div>
</div>
<!-- Error Message -->
<div
v-if="error"
class="mt-3 rounded-lg border border-red-200 bg-red-50 dark:border-red-700 dark:bg-red-900/30 p-3"
class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-700 dark:bg-red-900/30"
>
<p class="text-sm text-red-600 dark:text-red-400 whitespace-pre-line">
<p class="whitespace-pre-line text-sm text-red-600 dark:text-red-400">
{{ error }}
</p>
</div>
......@@ -293,7 +527,8 @@ interface Props {
allowMultiple?: boolean
methodLabel?: string
showCookieOption?: boolean // Whether to show cookie auto-auth option
platform?: 'anthropic' | 'openai' // Platform type for different UI/text
platform?: 'anthropic' | 'openai' | 'gemini' // Platform type for different UI/text
showProjectId?: boolean // New prop to control project ID visibility
}
const props = withDefaults(defineProps<Props>(), {
......@@ -306,7 +541,8 @@ const props = withDefaults(defineProps<Props>(), {
allowMultiple: false,
methodLabel: 'Authorization Method',
showCookieOption: true,
platform: 'anthropic'
platform: 'anthropic',
showProjectId: true
})
const emit = defineEmits<{
......@@ -318,16 +554,12 @@ const emit = defineEmits<{
const { t } = useI18n()
// Platform-specific translation helpers
const isOpenAI = computed(() => props.platform === 'openai')
// Get translation key based on platform
const getOAuthKey = (key: string) => {
if (isOpenAI.value) {
// Try OpenAI-specific key first
const openaiKey = `admin.accounts.oauth.openai.${key}`
return openaiKey
}
if (props.platform === 'openai') return `admin.accounts.oauth.openai.${key}`
if (props.platform === 'gemini') return `admin.accounts.oauth.gemini.${key}`
return `admin.accounts.oauth.${key}`
}
......@@ -343,20 +575,27 @@ const oauthAuthCodeDesc = computed(() => t(getOAuthKey('authCodeDesc')))
const oauthAuthCode = computed(() => t(getOAuthKey('authCode')))
const oauthAuthCodePlaceholder = computed(() => t(getOAuthKey('authCodePlaceholder')))
const oauthAuthCodeHint = computed(() => t(getOAuthKey('authCodeHint')))
const oauthImportantNotice = computed(() => isOpenAI.value ? t('admin.accounts.oauth.openai.importantNotice') : '')
const oauthImportantNotice = computed(() =>
props.platform === 'openai' ? t('admin.accounts.oauth.openai.importantNotice') : ''
)
// Local state
const inputMethod = ref<AuthInputMethod>(props.showCookieOption ? 'manual' : 'manual')
const authCodeInput = ref('')
const sessionKeyInput = ref('')
const showHelpDialog = ref(false)
const oauthState = ref('')
const projectId = ref('')
// Clipboard
const { copied, copyToClipboard } = useClipboard()
// Computed
const parsedKeyCount = computed(() => {
return sessionKeyInput.value.split('\n').map(k => k.trim()).filter(k => k).length
return sessionKeyInput.value
.split('\n')
.map((k) => k.trim())
.filter((k) => k).length
})
// Watchers
......@@ -367,7 +606,7 @@ watch(inputMethod, (newVal) => {
// Auto-extract code from OpenAI callback URL
// e.g., http://localhost:1455/auth/callback?code=ac_xxx...&scope=...&state=...
watch(authCodeInput, (newVal) => {
if (!isOpenAI.value) return
if (props.platform !== 'openai' && props.platform !== 'gemini') return
const trimmed = newVal.trim()
// Check if it looks like a URL with code parameter
......@@ -376,6 +615,10 @@ watch(authCodeInput, (newVal) => {
// Try to parse as URL
const url = new URL(trimmed)
const code = url.searchParams.get('code')
const stateParam = url.searchParams.get('state')
if (props.platform === 'gemini' && stateParam) {
oauthState.value = stateParam
}
if (code && code !== trimmed) {
// Replace the input with just the code
authCodeInput.value = code
......@@ -383,6 +626,10 @@ watch(authCodeInput, (newVal) => {
} catch {
// If URL parsing fails, try regex extraction
const match = trimmed.match(/[?&]code=([^&]+)/)
const stateMatch = trimmed.match(/[?&]state=([^&]+)/)
if (props.platform === 'gemini' && stateMatch && stateMatch[1]) {
oauthState.value = stateMatch[1]
}
if (match && match[1] && match[1] !== trimmed) {
authCodeInput.value = match[1]
}
......@@ -415,10 +662,14 @@ const handleCookieAuth = () => {
// Expose methods and state
defineExpose({
authCode: authCodeInput,
oauthState,
projectId,
sessionKey: sessionKeyInput,
inputMethod,
reset: () => {
authCodeInput.value = ''
oauthState.value = ''
projectId.value = ''
sessionKeyInput.value = ''
inputMethod.value = 'manual'
showHelpDialog.value = false
......
......@@ -7,29 +7,55 @@
>
<div v-if="account" class="space-y-5">
<!-- Account Info -->
<div class="rounded-lg border border-gray-200 dark:border-dark-600 bg-gray-50 dark:bg-dark-700 p-4">
<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="[
<div
:class="[
'flex h-10 w-10 items-center justify-center rounded-lg bg-gradient-to-br',
isOpenAI ? 'from-green-500 to-green-600' : 'from-orange-500 to-orange-600'
]">
<svg class="w-5 h-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" />
isOpenAI
? 'from-green-500 to-green-600'
: isGemini
? 'from-blue-500 to-blue-600'
: 'from-orange-500 to-orange-600'
]"
>
<svg
class="h-5 w-5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span class="block font-semibold text-gray-900 dark:text-white">{{ account.name }}</span>
<span class="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') : t('admin.accounts.claudeCodeAccount') }}
{{
isOpenAI
? t('admin.accounts.openaiAccount')
: isGemini
? t('admin.accounts.geminiAccount')
: t('admin.accounts.claudeCodeAccount')
}}
</span>
</div>
</div>
</div>
<!-- Add Method Selection (Claude only) -->
<div v-if="!isOpenAI">
<div v-if="isAnthropic">
<label class="input-label">{{ t('admin.accounts.oauth.authMethod') }}</label>
<div class="flex gap-4 mt-2">
<div class="mt-2 flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="addMethod"
......@@ -46,12 +72,119 @@
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>
<span class="text-sm text-gray-700 dark:text-gray-300">{{
t('admin.accounts.setupTokenLongLived')
}}</span>
</label>
</div>
</div>
<!-- OAuth Authorization Section -->
<!-- Gemini OAuth Type Selection -->
<div v-if="isGemini">
<label class="input-label">{{ t('admin.accounts.oauth.gemini.oauthTypeLabel') }}</label>
<div class="mt-2 grid grid-cols-2 gap-3">
<button
type="button"
@click="handleSelectGeminiOAuthType('code_assist')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
geminiOAuthType === 'code_assist'
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 hover:border-blue-300 dark:border-dark-600 dark:hover:border-blue-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'code_assist'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 15a4.5 4.5 0 004.5 4.5H18a3.75 3.75 0 001.332-7.257 3 3 0 00-3.758-3.848 5.25 5.25 0 00-10.233 2.33A4.502 4.502 0 002.25 15z"
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">Code Assist</span>
<span class="block text-xs font-medium text-blue-600 dark:text-blue-400">{{
t('admin.accounts.oauth.gemini.needsProjectId')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.needsProjectIdDesc')
}}</span>
</div>
</button>
<button
type="button"
:disabled="!geminiAIStudioOAuthEnabled"
@click="handleSelectGeminiOAuthType('ai_studio')"
:class="[
'flex items-center gap-3 rounded-lg border-2 p-3 text-left transition-all',
!geminiAIStudioOAuthEnabled ? 'cursor-not-allowed opacity-60' : '',
geminiOAuthType === 'ai_studio'
? 'border-purple-500 bg-purple-50 dark:bg-purple-900/20'
: 'border-gray-200 hover:border-purple-300 dark:border-dark-600 dark:hover:border-purple-700'
]"
>
<div
:class="[
'flex h-8 w-8 items-center justify-center rounded-lg',
geminiOAuthType === 'ai_studio'
? 'bg-purple-500 text-white'
: 'bg-gray-100 text-gray-500 dark:bg-dark-600 dark:text-gray-400'
]"
>
<svg
class="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg>
</div>
<div>
<span class="block text-sm font-medium text-gray-900 dark:text-white">AI Studio</span>
<span class="block text-xs font-medium text-purple-600 dark:text-purple-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeeded')
}}</span>
<span class="text-xs text-gray-500 dark:text-gray-400">{{
t('admin.accounts.oauth.gemini.noProjectIdNeededDesc')
}}</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>
</div>
<OAuthAuthorizationFlow
ref="oauthFlowRef"
:add-method="addMethod"
......@@ -59,22 +192,19 @@
:session-id="currentSessionId"
:loading="currentLoading"
:error="currentError"
:show-help="!isOpenAI"
:show-proxy-warning="!isOpenAI"
:show-cookie-option="!isOpenAI"
:show-help="isAnthropic"
:show-proxy-warning="isAnthropic"
:show-cookie-option="isAnthropic"
:allow-multiple="false"
:method-label="t('admin.accounts.inputMethod')"
:platform="isOpenAI ? 'openai' : 'anthropic'"
:platform="isOpenAI ? 'openai' : isGemini ? 'gemini' : 'anthropic'"
:show-project-id="isGemini && geminiOAuthType === 'code_assist'"
@generate-url="handleGenerateUrl"
@cookie-auth="handleCookieAuth"
/>
<div class="flex justify-between gap-3 pt-4">
<button
type="button"
class="btn btn-secondary"
@click="handleClose"
>
<button type="button" class="btn btn-secondary" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button
......@@ -86,14 +216,29 @@
>
<svg
v-if="currentLoading"
class="animate-spin -ml-1 mr-2 h-4 w-4"
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>
<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') }}
{{
currentLoading
? t('admin.accounts.oauth.verifying')
: t('admin.accounts.oauth.completeAuth')
}}
</button>
</div>
</div>
......@@ -105,8 +250,13 @@ 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 {
useAccountOAuth,
type AddMethod,
type AuthInputMethod
} from '@/composables/useAccountOAuth'
import { useOpenAIOAuth } from '@/composables/useOpenAIOAuth'
import { useGeminiOAuth } from '@/composables/useGeminiOAuth'
import type { Account } from '@/types'
import Modal from '@/components/common/Modal.vue'
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
......@@ -115,6 +265,8 @@ import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
// 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
......@@ -137,55 +289,113 @@ const { t } = useI18n()
// OAuth composables - use both Claude and OpenAI
const claudeOAuth = useAccountOAuth()
const openaiOAuth = useOpenAIOAuth()
const geminiOAuth = useGeminiOAuth()
// Refs
const oauthFlowRef = ref<OAuthFlowExposed | null>(null)
// State
const addMethod = ref<AddMethod>('oauth')
const geminiOAuthType = ref<'code_assist' | 'ai_studio'>('code_assist')
const geminiAIStudioOAuthEnabled = ref(false)
// Computed - check if this is an OpenAI account
const isOpenAI = computed(() => props.account?.platform === 'openai')
const isGemini = computed(() => props.account?.platform === 'gemini')
const isAnthropic = computed(() => props.account?.platform === 'anthropic')
// Computed - current OAuth state based on platform
const currentAuthUrl = computed(() => isOpenAI.value ? openaiOAuth.authUrl.value : claudeOAuth.authUrl.value)
const currentSessionId = computed(() => isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value)
const currentLoading = computed(() => isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value)
const currentError = computed(() => isOpenAI.value ? openaiOAuth.error.value : claudeOAuth.error.value)
const currentAuthUrl = computed(() => {
if (isOpenAI.value) return openaiOAuth.authUrl.value
if (isGemini.value) return geminiOAuth.authUrl.value
return claudeOAuth.authUrl.value
})
const currentSessionId = computed(() => {
if (isOpenAI.value) return openaiOAuth.sessionId.value
if (isGemini.value) return geminiOAuth.sessionId.value
return claudeOAuth.sessionId.value
})
const currentLoading = computed(() => {
if (isOpenAI.value) return openaiOAuth.loading.value
if (isGemini.value) return geminiOAuth.loading.value
return claudeOAuth.loading.value
})
const currentError = computed(() => {
if (isOpenAI.value) return openaiOAuth.error.value
if (isGemini.value) return geminiOAuth.error.value
return claudeOAuth.error.value
})
// Computed
const isManualInputMethod = computed(() => {
// OpenAI always uses manual input (no cookie auth option)
return isOpenAI.value || oauthFlowRef.value?.inputMethod === 'manual'
return isOpenAI.value || isGemini.value || oauthFlowRef.value?.inputMethod === 'manual'
})
const canExchangeCode = computed(() => {
const authCode = oauthFlowRef.value?.authCode || ''
const sessionId = isOpenAI.value ? openaiOAuth.sessionId.value : claudeOAuth.sessionId.value
const loading = isOpenAI.value ? openaiOAuth.loading.value : claudeOAuth.loading.value
const sessionId = isOpenAI.value
? openaiOAuth.sessionId.value
: isGemini.value
? geminiOAuth.sessionId.value
: claudeOAuth.sessionId.value
const loading = isOpenAI.value
? openaiOAuth.loading.value
: isGemini.value
? geminiOAuth.loading.value
: claudeOAuth.loading.value
return authCode.trim() && sessionId && !loading
})
// Watchers
watch(() => props.show, (newVal) => {
watch(
() => props.show,
(newVal) => {
if (newVal && props.account) {
// Initialize addMethod based on current account type (Claude only)
if (!isOpenAI.value && (props.account.type === 'oauth' || props.account.type === 'setup-token')) {
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 === '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()
oauthFlowRef.value?.reset()
}
const handleSelectGeminiOAuthType = (oauthType: 'code_assist' | 'ai_studio') => {
if (oauthType === 'ai_studio' && !geminiAIStudioOAuthEnabled.value) {
appStore.showError(t('admin.accounts.oauth.gemini.aiStudioNotConfigured'))
return
}
geminiOAuthType.value = oauthType
}
const handleClose = () => {
emit('close')
}
......@@ -195,6 +405,9 @@ const handleGenerateUrl = async () => {
if (isOpenAI.value) {
await openaiOAuth.generateAuthUrl(props.account.proxy_id)
} else if (isGemini.value) {
const projectId = geminiOAuthType.value === 'code_assist' ? oauthFlowRef.value?.projectId : undefined
await geminiOAuth.generateAuthUrl(props.account.proxy_id, projectId, geminiOAuthType.value)
} else {
await claudeOAuth.generateAuthUrl(addMethod.value, props.account.proxy_id)
}
......@@ -211,7 +424,11 @@ const handleExchangeCode = async () => {
const sessionId = openaiOAuth.sessionId.value
if (!sessionId) return
const tokenInfo = await openaiOAuth.exchangeAuthCode(authCode.trim(), sessionId, props.account.proxy_id)
const tokenInfo = await openaiOAuth.exchangeAuthCode(
authCode.trim(),
sessionId,
props.account.proxy_id
)
if (!tokenInfo) return
// Build credentials and extra info
......@@ -236,6 +453,38 @@ const handleExchangeCode = async () => {
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
})
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 {
// Claude OAuth flow
const sessionId = claudeOAuth.sessionId.value
......@@ -246,7 +495,8 @@ const handleExchangeCode = async () => {
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/exchange-code'
: '/admin/accounts/exchange-setup-token-code'
......@@ -288,7 +538,8 @@ const handleCookieAuth = async (sessionKey: string) => {
try {
const proxyConfig = props.account.proxy_id ? { proxy_id: props.account.proxy_id } : {}
const endpoint = addMethod.value === 'oauth'
const endpoint =
addMethod.value === 'oauth'
? '/admin/accounts/cookie-auth'
: '/admin/accounts/setup-token-cookie-auth'
......@@ -314,7 +565,8 @@ const handleCookieAuth = async (sessionKey: string) => {
emit('reauthorized')
handleClose()
} catch (error: any) {
claudeOAuth.error.value = error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
claudeOAuth.error.value =
error.response?.data?.detail || t('admin.accounts.oauth.cookieAuthFailed')
} finally {
claudeOAuth.loading.value = false
}
......
......@@ -10,10 +10,15 @@
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div class="text-xs text-gray-500 dark:text-dark-400 bg-gray-50 dark:bg-dark-700/60 rounded-lg p-3">
已有账号仅同步 CRS 返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
<div
class="rounded-lg bg-gray-50 p-3 text-xs text-gray-500 dark:bg-dark-700/60 dark:text-dark-400"
>
已有账号仅同步 CRS
返回的字段,缺失字段保持原值;凭据按键合并,不会清空未下发的键;未勾选"同步代理"时保留原有代理。
</div>
<div class="text-xs text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<div
class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-600 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-400"
>
{{ t('admin.accounts.crsVersionRequirement') }}
</div>
......@@ -31,12 +36,7 @@
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input
v-model="form.username"
type="text"
class="input"
autocomplete="username"
/>
<input v-model="form.username" type="text" class="input" autocomplete="username" />
</div>
<div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
......@@ -50,12 +50,19 @@
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" />
<input
v-model="form.sync_proxies"
type="checkbox"
class="rounded border-gray-300 dark:border-dark-600"
/>
{{ t('admin.accounts.syncProxies') }}
</label>
</div>
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
<div
v-if="result"
class="space-y-2 rounded-xl border border-gray-200 p-4 dark:border-dark-700"
>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.syncResult') }}
</div>
......@@ -67,9 +74,12 @@
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.syncErrors') }}
</div>
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono">
<div
class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 p-3 font-mono text-xs dark:bg-dark-800"
>
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.crs_account_id }}{{ item.action }}{{ item.error ? `: ${item.error}` : '' }}
{{ item.kind }} {{ item.crs_account_id }}{{ item.action
}}{{ item.error ? `: ${item.error}` : '' }}
</div>
</div>
</div>
......
<template>
<div>
<!-- Window stats row (above progress bar, left-right aligned with progress bar) -->
<div v-if="windowStats" class="flex items-center justify-between mb-0.5" :title="`5h 窗口用量统计`">
<div class="flex items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400 cursor-help">
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
<div
v-if="windowStats"
class="mb-0.5 flex items-center justify-between"
:title="`5h 窗口用量统计`"
>
<div
class="flex cursor-help items-center gap-1.5 text-[9px] text-gray-500 dark:text-gray-400"
>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatRequests }} req
</span>
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800">
{{ formatTokens }}
</span>
<span class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-800">
${{ formatCost }}
</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5 dark:bg-gray-800"> ${{ formatCost }} </span>
</div>
</div>
......@@ -19,16 +23,13 @@
<div class="flex items-center gap-1">
<!-- Label badge (fixed width for alignment) -->
<span
:class="[
'text-[10px] font-medium px-1 rounded w-[32px] text-center shrink-0',
labelClass
]"
:class="['w-[32px] shrink-0 rounded px-1 text-center text-[10px] font-medium', labelClass]"
>
{{ label }}
</span>
<!-- Progress bar container -->
<div class="w-8 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden shrink-0">
<div class="h-1.5 w-8 shrink-0 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
:class="['h-full transition-all duration-300', barClass]"
:style="{ width: barWidth }"
......@@ -36,12 +37,12 @@
</div>
<!-- Percentage -->
<span :class="['text-[10px] font-medium w-[32px] text-right shrink-0', textClass]">
<span :class="['w-[32px] shrink-0 text-right text-[10px] font-medium', textClass]">
{{ displayPercent }}
</span>
<!-- Reset time -->
<span v-if="resetsAt" class="text-[10px] text-gray-400 shrink-0">
<span v-if="resetsAt" class="shrink-0 text-[10px] text-gray-400">
{{ formatResetTime }}
</span>
</div>
......
<template>
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.modelDistribution') }}</h3>
<div v-if="loading" class="flex items-center justify-center h-48">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.modelDistribution') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="modelStats.length > 0 && chartData" class="flex items-center gap-6">
<div class="w-48 h-48">
<div class="h-48 w-48">
<Doughnut :data="chartData" :options="doughnutOptions" />
</div>
<div class="flex-1 max-h-48 overflow-y-auto">
<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="text-left pb-2">{{ t('admin.dashboard.model') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.requests') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.tokens') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.actual') }}</th>
<th class="text-right pb-2">{{ t('admin.dashboard.standard') }}</th>
<th class="pb-2 text-left">{{ t('admin.dashboard.model') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.requests') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.tokens') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.actual') }}</th>
<th class="pb-2 text-right">{{ t('admin.dashboard.standard') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="model in modelStats" :key="model.model" class="border-t border-gray-100 dark:border-gray-700">
<td class="py-1.5 text-gray-900 dark:text-white font-medium truncate max-w-[100px]" :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
v-for="model in modelStats"
: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 v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
<div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
......@@ -40,12 +62,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import {
Chart as ChartJS,
ArcElement,
Tooltip,
Legend
} from 'chart.js'
import { Chart as ChartJS, ArcElement, Tooltip, Legend } from 'chart.js'
import { Doughnut } from 'vue-chartjs'
import LoadingSpinner from '@/components/common/LoadingSpinner.vue'
import type { ModelStat } from '@/types'
......@@ -60,20 +77,30 @@ const props = defineProps<{
}>()
const chartColors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#14b8a6', '#f97316', '#6366f1', '#84cc16'
'#3b82f6',
'#10b981',
'#f59e0b',
'#ef4444',
'#8b5cf6',
'#ec4899',
'#14b8a6',
'#f97316',
'#6366f1',
'#84cc16'
]
const chartData = computed(() => {
if (!props.modelStats?.length) return null
return {
labels: props.modelStats.map(m => m.model),
datasets: [{
data: props.modelStats.map(m => m.total_tokens),
labels: props.modelStats.map((m) => m.model),
datasets: [
{
data: props.modelStats.map((m) => m.total_tokens),
backgroundColor: chartColors.slice(0, props.modelStats.length),
borderWidth: 0,
}],
borderWidth: 0
}
]
}
})
......@@ -82,7 +109,7 @@ const doughnutOptions = computed(() => ({
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
display: false
},
tooltip: {
callbacks: {
......@@ -91,10 +118,10 @@ const doughnutOptions = computed(() => ({
const total = context.dataset.data.reduce((a: number, b: number) => a + b, 0)
const percentage = ((value / total) * 100).toFixed(1)
return `${context.label}: ${formatTokens(value)} (${percentage}%)`
},
},
},
},
}
}
}
}
}))
const formatTokens = (value: number): string => {
......
<template>
<div class="card p-4">
<h3 class="text-sm font-semibold text-gray-900 dark:text-white mb-4">{{ t('admin.dashboard.tokenUsageTrend') }}</h3>
<div v-if="loading" class="flex items-center justify-center h-48">
<h3 class="mb-4 text-sm font-semibold text-gray-900 dark:text-white">
{{ t('admin.dashboard.tokenUsageTrend') }}
</h3>
<div v-if="loading" class="flex h-48 items-center justify-center">
<LoadingSpinner />
</div>
<div v-else-if="trendData.length > 0 && chartData" class="h-48">
<Line :data="chartData" :options="lineOptions" />
</div>
<div v-else class="flex items-center justify-center h-48 text-gray-500 dark:text-gray-400 text-sm">
<div
v-else
class="flex h-48 items-center justify-center text-sm text-gray-500 dark:text-gray-400"
>
{{ t('admin.dashboard.noDataAvailable') }}
</div>
</div>
......@@ -58,40 +63,40 @@ const chartColors = computed(() => ({
grid: isDarkMode.value ? '#374151' : '#e5e7eb',
input: '#3b82f6',
output: '#10b981',
cache: '#f59e0b',
cache: '#f59e0b'
}))
const chartData = computed(() => {
if (!props.trendData?.length) return null
return {
labels: props.trendData.map(d => d.date),
labels: props.trendData.map((d) => d.date),
datasets: [
{
label: 'Input',
data: props.trendData.map(d => d.input_tokens),
data: props.trendData.map((d) => d.input_tokens),
borderColor: chartColors.value.input,
backgroundColor: `${chartColors.value.input}20`,
fill: true,
tension: 0.3,
tension: 0.3
},
{
label: 'Output',
data: props.trendData.map(d => d.output_tokens),
data: props.trendData.map((d) => d.output_tokens),
borderColor: chartColors.value.output,
backgroundColor: `${chartColors.value.output}20`,
fill: true,
tension: 0.3,
tension: 0.3
},
{
label: 'Cache',
data: props.trendData.map(d => d.cache_tokens),
data: props.trendData.map((d) => d.cache_tokens),
borderColor: chartColors.value.cache,
backgroundColor: `${chartColors.value.cache}20`,
fill: true,
tension: 0.3,
},
],
tension: 0.3
}
]
}
})
......@@ -100,7 +105,7 @@ const lineOptions = computed(() => ({
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index' as const,
mode: 'index' as const
},
plugins: {
legend: {
......@@ -111,9 +116,9 @@ const lineOptions = computed(() => ({
pointStyle: 'circle',
padding: 15,
font: {
size: 11,
},
},
size: 11
}
}
},
tooltip: {
callbacks: {
......@@ -127,35 +132,35 @@ const lineOptions = computed(() => ({
return `Actual: $${formatCost(data.actual_cost)} | Standard: $${formatCost(data.cost)}`
}
return ''
},
},
},
}
}
}
},
scales: {
x: {
grid: {
color: chartColors.value.grid,
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
},
size: 10
}
}
},
y: {
grid: {
color: chartColors.value.grid,
color: chartColors.value.grid
},
ticks: {
color: chartColors.value.text,
font: {
size: 10,
},
callback: (value: string | number) => formatTokens(Number(value)),
},
},
size: 10
},
callback: (value: string | number) => formatTokens(Number(value))
}
}
}
}))
const formatTokens = (value: number): string => {
......
......@@ -9,7 +9,7 @@
<button
@click="handleCancel"
type="button"
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800 focus:ring-primary-500"
class="rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600 dark:focus:ring-offset-dark-800"
>
{{ cancelText }}
</button>
......@@ -17,7 +17,7 @@
@click="handleConfirm"
type="button"
:class="[
'px-4 py-2 text-sm font-medium text-white rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
'rounded-md px-4 py-2 text-sm font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-dark-800',
danger
? 'bg-red-600 hover:bg-red-700 focus:ring-red-500'
: 'bg-primary-600 hover:bg-primary-700 focus:ring-primary-500'
......
......@@ -7,7 +7,7 @@
v-for="column in columns"
:key="column.key"
scope="col"
class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-dark-400 uppercase tracking-wider"
class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400"
:class="{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable }"
@click="column.sortable && handleSort(column.key)"
>
......@@ -16,8 +16,8 @@
<span v-if="column.sortable" class="text-gray-400 dark:text-dark-500">
<svg
v-if="sortKey === column.key"
class="w-4 h-4"
:class="{ 'transform rotate-180': sortOrder === 'desc' }"
class="h-4 w-4"
:class="{ 'rotate-180 transform': sortOrder === 'desc' }"
fill="currentColor"
viewBox="0 0 20 20"
>
......@@ -27,7 +27,7 @@
clip-rule="evenodd"
/>
</svg>
<svg v-else class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<svg v-else class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path
d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
/>
......@@ -37,22 +37,30 @@
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-dark-900 divide-y divide-gray-200 dark:divide-dark-700">
<tbody class="divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
<!-- Loading skeleton -->
<tr v-if="loading" v-for="i in 5" :key="i">
<td v-for="column in columns" :key="column.key" class="px-6 py-4 whitespace-nowrap">
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
<div class="animate-pulse">
<div class="h-4 bg-gray-200 dark:bg-dark-700 rounded w-3/4"></div>
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
</div>
</td>
</tr>
<!-- Empty state -->
<tr v-else-if="!data || data.length === 0">
<td :colspan="columns.length" class="px-6 py-12 text-center text-gray-500 dark:text-dark-400">
<td
:colspan="columns.length"
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
>
<slot name="empty">
<div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-400 dark:text-dark-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg
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"
......@@ -60,18 +68,25 @@
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>
<p class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ t('empty.noData') }}
</p>
</div>
</slot>
</td>
</tr>
<!-- Data rows -->
<tr v-else v-for="(row, index) in sortedData" :key="index" class="hover:bg-gray-50 dark:hover:bg-dark-800">
<tr
v-else
v-for="(row, index) in sortedData"
:key="index"
class="hover:bg-gray-50 dark:hover:bg-dark-800"
>
<td
v-for="column in columns"
:key="column.key"
class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100"
class="whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100"
>
<slot :name="`cell-${column.key}`" :row="row" :value="row[column.key]">
{{ column.formatter ? column.formatter(row[column.key], row) : row[column.key] }}
......
......@@ -3,14 +3,21 @@
<button
type="button"
@click="toggle"
:class="[
'date-picker-trigger',
isOpen && 'date-picker-trigger-open'
]"
:class="['date-picker-trigger', isOpen && 'date-picker-trigger-open']"
>
<span class="date-picker-icon">
<svg class="w-4 h-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
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>
</span>
<span class="date-picker-value">
......@@ -18,7 +25,7 @@
</span>
<span class="date-picker-chevron">
<svg
:class="['w-4 h-4 transition-transform duration-200', isOpen && 'rotate-180']"
:class="['h-4 w-4 transition-transform duration-200', isOpen && 'rotate-180']"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
......@@ -30,20 +37,14 @@
</button>
<Transition name="date-picker-dropdown">
<div
v-if="isOpen"
class="date-picker-dropdown"
>
<div v-if="isOpen" class="date-picker-dropdown">
<!-- Quick presets -->
<div class="date-picker-presets">
<button
v-for="preset in presets"
:key="preset.value"
@click="selectPreset(preset)"
:class="[
'date-picker-preset',
isPresetActive(preset) && 'date-picker-preset-active'
]"
:class="['date-picker-preset', isPresetActive(preset) && 'date-picker-preset-active']"
>
{{ t(preset.labelKey) }}
</button>
......@@ -64,8 +65,18 @@
/>
</div>
<div class="date-picker-separator">
<svg class="w-4 h-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
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>
</div>
<div class="date-picker-field">
......@@ -83,10 +94,7 @@
<!-- Apply button -->
<div class="date-picker-actions">
<button
@click="apply"
class="date-picker-apply"
>
<button @click="apply" class="date-picker-apply">
{{ t('dates.apply') }}
</button>
</div>
......@@ -204,7 +212,7 @@ const presets: DatePreset[] = [
const displayValue = computed(() => {
if (activePreset.value) {
const preset = presets.find(p => p.value === activePreset.value)
const preset = presets.find((p) => p.value === activePreset.value)
if (preset) return t(preset.labelKey)
}
......@@ -275,15 +283,21 @@ const handleEscape = (event: KeyboardEvent) => {
}
// Sync local state with props
watch(() => props.startDate, (val) => {
watch(
() => props.startDate,
(val) => {
localStartDate.value = val
onDateChange()
})
}
)
watch(() => props.endDate, (val) => {
watch(
() => props.endDate,
(val) => {
localEndDate.value = val
onDateChange()
})
}
)
onMounted(() => {
document.addEventListener('click', handleClickOutside)
......@@ -301,18 +315,18 @@ onUnmounted(() => {
<style scoped>
.date-picker-trigger {
@apply flex items-center gap-2;
@apply px-3 py-2 rounded-lg text-sm;
@apply rounded-lg px-3 py-2 text-sm;
@apply bg-white dark:bg-dark-800;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-700 dark:text-gray-300;
@apply transition-all duration-200;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
@apply hover:border-gray-300 dark:hover:border-dark-500;
@apply cursor-pointer;
}
.date-picker-trigger-open {
@apply ring-2 ring-primary-500/30 border-primary-500;
@apply border-primary-500 ring-2 ring-primary-500/30;
}
.date-picker-icon {
......@@ -328,7 +342,7 @@ onUnmounted(() => {
}
.date-picker-dropdown {
@apply absolute z-[100] mt-2 left-0;
@apply absolute left-0 z-[100] mt-2;
@apply bg-white dark:bg-dark-800;
@apply rounded-xl;
@apply border border-gray-200 dark:border-dark-700;
......@@ -342,7 +356,7 @@ onUnmounted(() => {
}
.date-picker-preset {
@apply px-3 py-1.5 text-xs font-medium rounded-md;
@apply rounded-md px-3 py-1.5 text-xs font-medium;
@apply text-gray-600 dark:text-gray-400;
@apply hover:bg-gray-100 dark:hover:bg-dark-700;
@apply transition-colors duration-150;
......@@ -366,15 +380,15 @@ onUnmounted(() => {
}
.date-picker-label {
@apply block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1;
@apply mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400;
}
.date-picker-input {
@apply w-full px-2 py-1.5 text-sm rounded-md;
@apply w-full rounded-md px-2 py-1.5 text-sm;
@apply bg-gray-50 dark:bg-dark-700;
@apply border border-gray-200 dark:border-dark-600;
@apply text-gray-900 dark:text-gray-100;
@apply focus:outline-none focus:ring-2 focus:ring-primary-500/30 focus:border-primary-500;
@apply focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/30;
}
.date-picker-input::-webkit-calendar-picker-indicator {
......@@ -395,7 +409,7 @@ onUnmounted(() => {
}
.date-picker-apply {
@apply px-4 py-1.5 text-sm font-medium rounded-lg;
@apply rounded-lg px-4 py-1.5 text-sm font-medium;
@apply bg-primary-600 text-white;
@apply hover:bg-primary-700;
@apply transition-colors duration-150;
......
<template>
<div class="empty-state">
<!-- Icon -->
<div class="w-20 h-20 mb-5 rounded-2xl bg-gray-100 dark:bg-dark-800 flex items-center justify-center">
<div
class="mb-5 flex h-20 w-20 items-center justify-center rounded-2xl bg-gray-100 dark:bg-dark-800"
>
<slot name="icon">
<component
v-if="icon"
:is="icon"
class="empty-state-icon w-10 h-10"
aria-hidden="true"
/>
<component v-if="icon" :is="icon" class="empty-state-icon h-10 w-10" aria-hidden="true" />
<svg
v-else
class="empty-state-icon w-10 h-10"
class="empty-state-icon h-10 w-10"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
......@@ -48,17 +45,13 @@
>
<svg
v-if="actionIcon"
class="w-5 h-5 mr-2"
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"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{{ actionText }}
</component>
......
<template>
<span
:class="[
'inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium transition-colors',
'inline-flex items-center gap-1.5 rounded-md px-2 py-0.5 text-xs font-medium transition-colors',
badgeClass
]"
>
......@@ -10,10 +10,7 @@
<!-- Group name -->
<span class="truncate">{{ name }}</span>
<!-- Right side label -->
<span
v-if="showLabel"
:class="labelClass"
>
<span v-if="showLabel" :class="labelClass">
{{ labelText }}
</span>
</span>
......@@ -97,6 +94,9 @@ const labelClass = computed(() => {
if (props.platform === 'openai') {
return `${base} bg-emerald-200/60 text-emerald-800 dark:bg-emerald-800/40 dark:text-emerald-300`
}
if (props.platform === 'gemini') {
return `${base} bg-blue-200/60 text-blue-800 dark:bg-blue-800/40 dark:text-blue-300`
}
return `${base} bg-violet-200/60 text-violet-800 dark:bg-violet-800/40 dark:text-violet-300`
})
......@@ -113,6 +113,11 @@ const badgeClass = computed(() => {
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-400'
}
if (props.platform === 'gemini') {
return isSubscription.value
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-sky-50 text-sky-700 dark:bg-sky-900/20 dark:text-sky-400'
}
// Fallback: original colors
return isSubscription.value
? 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
......
......@@ -2,15 +2,15 @@
<div>
<label class="input-label">
Groups
<span class="text-gray-400 font-normal">({{ modelValue.length }} selected)</span>
<span class="font-normal text-gray-400">({{ modelValue.length }} selected)</span>
</label>
<div
class="grid grid-cols-2 gap-1 max-h-32 overflow-y-auto p-2 border border-gray-200 dark:border-dark-600 rounded-lg bg-gray-50 dark:bg-dark-800"
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"
>
<label
v-for="group in filteredGroups"
:key="group.id"
class="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-white dark:hover:bg-dark-700 cursor-pointer transition-colors"
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 transition-colors hover:bg-white dark:hover:bg-dark-700"
:title="`${group.rate_multiplier}x rate · ${group.account_count || 0} accounts`"
>
<input
......@@ -18,19 +18,19 @@
:value="group.id"
:checked="modelValue.includes(group.id)"
@change="handleChange(group.id, ($event.target as HTMLInputElement).checked)"
class="w-3.5 h-3.5 text-primary-500 border-gray-300 dark:border-dark-500 rounded focus:ring-primary-500 shrink-0"
class="h-3.5 w-3.5 shrink-0 rounded border-gray-300 text-primary-500 focus:ring-primary-500 dark:border-dark-500"
/>
<GroupBadge
:name="group.name"
:subscription-type="group.subscription_type"
:rate-multiplier="group.rate_multiplier"
class="flex-1 min-w-0"
class="min-w-0 flex-1"
/>
<span class="text-xs text-gray-400 shrink-0">{{ group.account_count || 0 }}</span>
<span class="shrink-0 text-xs text-gray-400">{{ group.account_count || 0 }}</span>
</label>
<div
v-if="filteredGroups.length === 0"
class="col-span-2 text-center text-sm text-gray-500 dark:text-gray-400 py-2"
class="col-span-2 py-2 text-center text-sm text-gray-500 dark:text-gray-400"
>
No groups available
</div>
......@@ -59,13 +59,13 @@ const filteredGroups = computed(() => {
if (!props.platform) {
return props.groups
}
return props.groups.filter(g => g.platform === props.platform)
return props.groups.filter((g) => g.platform === props.platform)
})
const handleChange = (groupId: number, checked: boolean) => {
const newValue = checked
? [...props.modelValue, groupId]
: props.modelValue.filter(id => id !== groupId)
: props.modelValue.filter((id) => id !== groupId)
emit('update:modelValue', newValue)
}
</script>
......@@ -2,13 +2,13 @@
<div class="relative" ref="dropdownRef">
<button
@click="toggleDropdown"
class="flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-sm font-medium text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
:title="currentLocale?.name"
>
<span class="text-base">{{ currentLocale?.flag }}</span>
<span class="hidden sm:inline">{{ currentLocale?.code.toUpperCase() }}</span>
<svg
class="w-3.5 h-3.5 text-gray-400 transition-transform duration-200"
class="h-3.5 w-3.5 text-gray-400 transition-transform duration-200"
:class="{ 'rotate-180': isOpen }"
fill="none"
viewBox="0 0 24 24"
......@@ -22,20 +22,23 @@
<transition name="dropdown">
<div
v-if="isOpen"
class="absolute right-0 mt-1 w-32 rounded-lg bg-white dark:bg-dark-800 shadow-lg border border-gray-200 dark:border-dark-700 overflow-hidden z-50"
class="absolute right-0 z-50 mt-1 w-32 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-dark-700 dark:bg-dark-800"
>
<button
v-for="locale in availableLocales"
:key="locale.code"
@click="selectLocale(locale.code)"
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
:class="{ 'bg-primary-50 dark:bg-primary-900/20 text-primary-600 dark:text-primary-400': locale.code === currentLocaleCode }"
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
:class="{
'bg-primary-50 text-primary-600 dark:bg-primary-900/20 dark:text-primary-400':
locale.code === currentLocaleCode
}"
>
<span class="text-base">{{ locale.flag }}</span>
<span>{{ locale.name }}</span>
<svg
v-if="locale.code === currentLocaleCode"
class="w-4 h-4 ml-auto text-primary-500"
class="ml-auto h-4 w-4 text-primary-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
......@@ -60,7 +63,7 @@ const isOpen = ref(false)
const dropdownRef = ref<HTMLElement | null>(null)
const currentLocaleCode = computed(() => locale.value)
const currentLocale = computed(() => availableLocales.find(l => l.code === locale.value))
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
function toggleDropdown() {
isOpen.value = !isOpen.value
......
......@@ -9,24 +9,24 @@
@click.self="handleClose"
>
<!-- Modal panel -->
<div
:class="['modal-content', sizeClasses]"
@click.stop
>
<div :class="['modal-content', sizeClasses]" @click.stop>
<!-- Header -->
<div class="modal-header">
<h3
id="modal-title"
class="modal-title"
>
<h3 id="modal-title" class="modal-title">
{{ title }}
</h3>
<button
@click="emit('close')"
class="p-2 -mr-2 rounded-xl text-gray-400 dark:text-dark-500 hover:text-gray-600 dark:hover:text-dark-300 hover:bg-gray-100 dark:hover:bg-dark-700 transition-colors"
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="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5">
<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>
</button>
......@@ -38,10 +38,7 @@
</div>
<!-- Footer -->
<div
v-if="$slots.footer"
class="modal-footer"
>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer"></slot>
</div>
</div>
......
<template>
<div class="flex items-center justify-between px-4 py-3 bg-white dark:bg-dark-800 border-t border-gray-200 dark:border-dark-700 sm:px-6">
<div class="flex items-center justify-between flex-1 sm:hidden">
<div
class="flex items-center justify-between border-t border-gray-200 bg-white px-4 py-3 dark:border-dark-700 dark:bg-dark-800 sm:px-6"
>
<div class="flex flex-1 items-center justify-between sm:hidden">
<!-- Mobile pagination -->
<button
@click="goToPage(page - 1)"
:disabled="page === 1"
class="relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
class="relative inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
>
{{ t('pagination.previous') }}
</button>
......@@ -15,7 +17,7 @@
<button
@click="goToPage(page + 1)"
:disabled="page === totalPages"
class="relative inline-flex items-center px-4 py-2 ml-3 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
class="relative ml-3 inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-200 dark:hover:bg-dark-600"
>
{{ t('pagination.next') }}
</button>
......@@ -36,8 +38,10 @@
<!-- Page size selector -->
<div class="flex items-center space-x-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ t('pagination.perPage') }}:</span>
<div class="w-20 page-size-select">
<span class="text-sm text-gray-700 dark:text-gray-300"
>{{ t('pagination.perPage') }}:</span
>
<div class="page-size-select w-20">
<Select
:model-value="pageSize"
:options="pageSizeSelectOptions"
......@@ -56,10 +60,10 @@
<button
@click="goToPage(page - 1)"
:disabled="page === 1"
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-l-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
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="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<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"
......@@ -75,13 +79,15 @@
@click="typeof pageNum === 'number' && goToPage(pageNum)"
:disabled="typeof pageNum !== 'number'"
:class="[
'relative inline-flex items-center px-4 py-2 text-sm font-medium border',
'relative inline-flex items-center border px-4 py-2 text-sm font-medium',
pageNum === page
? 'z-10 bg-primary-50 dark:bg-primary-900/30 border-primary-500 text-primary-600 dark:text-primary-400'
: 'bg-white dark:bg-dark-700 border-gray-300 dark:border-dark-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-dark-600',
? 'z-10 border-primary-500 bg-primary-50 text-primary-600 dark:bg-primary-900/30 dark:text-primary-400'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-300 dark:hover:bg-dark-600',
typeof pageNum !== 'number' && 'cursor-default'
]"
:aria-label="typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined"
:aria-label="
typeof pageNum === 'number' ? t('pagination.goToPage', { page: pageNum }) : undefined
"
:aria-current="pageNum === page ? 'page' : undefined"
>
{{ pageNum }}
......@@ -91,10 +97,10 @@
<button
@click="goToPage(page + 1)"
:disabled="page === totalPages"
class="relative inline-flex items-center px-2 py-2 text-sm font-medium text-gray-500 dark:text-gray-400 bg-white dark:bg-dark-700 border border-gray-300 dark:border-dark-600 rounded-r-md hover:bg-gray-50 dark:hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
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="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<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"
......@@ -145,7 +151,7 @@ const toItem = computed(() => {
})
const pageSizeSelectOptions = computed(() => {
return props.pageSizeOptions.map(size => ({
return props.pageSizeOptions.map((size) => ({
value: size,
label: String(size)
}))
......@@ -209,6 +215,6 @@ const handlePageSizeChange = (value: string | number | null) => {
<style scoped>
.page-size-select :deep(.select-trigger) {
@apply py-1.5 px-3 text-sm;
@apply px-3 py-1.5 text-sm;
}
</style>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment